diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf8e009..e594618 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,9 +32,9 @@ jobs: languages: ${{ matrix.language }} - name: Set up Go - uses: actions/setup-go@v1 + uses: actions/setup-go@v6 with: - go-version: 1.22 + go-version: 1.25 - name: Build run: | diff --git a/internal/meta/base_meta.go b/internal/meta/base_meta.go index a41d9fd..ef16071 100644 --- a/internal/meta/base_meta.go +++ b/internal/meta/base_meta.go @@ -1223,9 +1223,3 @@ func appendToFile(path, content string) error { return err } -func resourceNamePattern(p string) (prefix, suffix string) { - if pos := strings.LastIndex(p, "*"); pos != -1 { - return p[:pos], p[pos+1:] - } - return p, "" -} diff --git a/internal/meta/meta_query.go b/internal/meta/meta_query.go index 8f7ff7c..283be69 100644 --- a/internal/meta/meta_query.go +++ b/internal/meta/meta_query.go @@ -15,8 +15,7 @@ type MetaQuery struct { baseMeta argPredicate string recursiveQuery bool - resourceNamePrefix string - resourceNameSuffix string + resourceNameExpander *nameExpander includeRoleAssignment bool includeManagedResource bool includeResourceGroup bool @@ -41,7 +40,7 @@ func NewMetaQuery(cfg config.Config) (*MetaQuery, error) { argTable: cfg.ARGTable, argAuthenticationScopeFilter: armresourcegraph.AuthorizationScopeFilter(cfg.ARGAuthorizationScopeFilter), } - meta.resourceNamePrefix, meta.resourceNameSuffix = resourceNamePattern(cfg.ResourceNamePattern) + meta.resourceNameExpander = newNameExpander(cfg.ResourceNamePattern) return meta, nil } @@ -75,17 +74,18 @@ func (meta *MetaQuery) ListResource(ctx context.Context) (ImportList, error) { } var l ImportList - for i, res := range rl { + for _, res := range rl { + name := meta.resourceNameExpander.Expand(res) item := ImportItem{ AzureResourceID: res.AzureId, TFResourceId: res.TFId, TFAddr: tfaddr.TFAddr{ Type: "", - Name: fmt.Sprintf("%s%d%s", meta.resourceNamePrefix, i, meta.resourceNameSuffix), + Name: name, }, TFAddrCache: tfaddr.TFAddr{ Type: "", - Name: fmt.Sprintf("%s%d%s", meta.resourceNamePrefix, i, meta.resourceNameSuffix), + Name: name, }, } if res.TFType != "" { diff --git a/internal/meta/meta_res.go b/internal/meta/meta_res.go index f51fc97..6374cff 100644 --- a/internal/meta/meta_res.go +++ b/internal/meta/meta_res.go @@ -14,11 +14,10 @@ import ( type MetaResource struct { baseMeta - AzureIds []armid.ResourceId - ResourceName string - ResourceType string - resourceNamePrefix string - resourceNameSuffix string + AzureIds []armid.ResourceId + ResourceName string + ResourceType string + resourceNameExpander *nameExpander includeRoleAssignment bool includeManagedResource bool @@ -54,7 +53,7 @@ func NewMetaResource(cfg config.Config) (*MetaResource, error) { includeResourceGroup: cfg.IncludeResourceGroup, } - meta.resourceNamePrefix, meta.resourceNameSuffix = resourceNamePattern(cfg.ResourceNamePattern) + meta.resourceNameExpander = newNameExpander(cfg.ResourceNamePattern) return meta, nil } @@ -87,8 +86,8 @@ func (meta *MetaResource) ListResource(ctx context.Context) (ImportList, error) tfl = rset.ToTFAzureRMResources(meta.Logger(), meta.parallelism, meta.azureSDKCred, meta.azureSDKClientOpt) } - // Split the specified resources and the extension resources - var tfrl, tfel []resourceset.TFResource + // Split the specified resources and the property-liked/associated resources + var tfrl, tfpl []resourceset.TFResource for _, tfres := range tfl { rmap := map[string]bool{} for _, r := range rl { @@ -97,7 +96,7 @@ func (meta *MetaResource) ListResource(ctx context.Context) (ImportList, error) if rmap[tfres.AzureId.String()] { tfrl = append(tfrl, tfres) } else { - tfel = append(tfel, tfres) + tfpl = append(tfpl, tfres) } } @@ -110,7 +109,7 @@ func (meta *MetaResource) ListResource(ctx context.Context) (ImportList, error) // Honor the ResourceName name := meta.ResourceName if name == "" { - name = fmt.Sprintf("%s%d%s", meta.resourceNamePrefix, 0, meta.resourceNameSuffix) + name = meta.resourceNameExpander.Expand(res) } // Honor the ResourceType @@ -146,20 +145,20 @@ func (meta *MetaResource) ListResource(ctx context.Context) (ImportList, error) } l = append(l, item) } else { - l = append(l, meta.toImportList(tfrl, 0)...) + l = append(l, meta.toImportList(tfrl)...) } - l = append(l, meta.toImportList(tfel, len(tfrl))...) + l = append(l, meta.toImportList(tfpl)...) l = meta.excludeImportList(l) return l, nil } -func (meta MetaResource) toImportList(rl []resourceset.TFResource, fromIdx int) ImportList { +func (meta MetaResource) toImportList(rl []resourceset.TFResource) ImportList { var l ImportList - for idx, res := range rl { + for _, res := range rl { tfAddr := tfaddr.TFAddr{ Type: "", - Name: fmt.Sprintf("%s%d%s", meta.resourceNamePrefix, idx+fromIdx, meta.resourceNameSuffix), + Name: meta.resourceNameExpander.Expand(res), } item := ImportItem{ AzureResourceID: res.AzureId, diff --git a/internal/meta/meta_rg.go b/internal/meta/meta_rg.go index 6303968..ae42046 100644 --- a/internal/meta/meta_rg.go +++ b/internal/meta/meta_rg.go @@ -13,8 +13,7 @@ import ( type MetaResourceGroup struct { baseMeta resourceGroup string - resourceNamePrefix string - resourceNameSuffix string + resourceNameExpander *nameExpander includeRoleAssignment bool includeManagedResource bool } @@ -32,7 +31,7 @@ func NewMetaResourceGroup(cfg config.Config) (*MetaResourceGroup, error) { includeRoleAssignment: cfg.IncludeRoleAssignment, includeManagedResource: cfg.IncludeManagedResource, } - meta.resourceNamePrefix, meta.resourceNameSuffix = resourceNamePattern(cfg.ResourceNamePattern) + meta.resourceNameExpander = newNameExpander(cfg.ResourceNamePattern) return meta, nil } @@ -62,10 +61,10 @@ func (meta *MetaResourceGroup) ListResource(ctx context.Context) (ImportList, er } var l ImportList - for i, res := range rl { + for _, res := range rl { tfAddr := tfaddr.TFAddr{ Type: "", - Name: fmt.Sprintf("%s%d%s", meta.resourceNamePrefix, i, meta.resourceNameSuffix), + Name: meta.resourceNameExpander.Expand(res), } item := ImportItem{ AzureResourceID: res.AzureId, diff --git a/internal/meta/name_pattern.go b/internal/meta/name_pattern.go new file mode 100644 index 0000000..4a228f2 --- /dev/null +++ b/internal/meta/name_pattern.go @@ -0,0 +1,165 @@ +package meta + +import ( + "fmt" + "strings" + "unicode" + + "github.com/Azure/aztfexport/internal/resourceset" + "github.com/magodo/armid" +) + +const ( + phType = "{type}" // last Azure resource type segment, e.g. "virtual_machines" + phRP = "{rp}" // Azure resource provider namespace, e.g. "microsoft_compute" + phName = "{name}" // last name segment of the Azure resource id + phRootScope = "{root_scope}" // last name of the root scope (e.g. resource group name) +) + +// nameExpander turns a resource name pattern (with placeholders and `*`) into +// concrete resource names. It is stateful: it tracks per-prefix counts so the +// indices produced via `*` are unique per expanded prefix/suffix pair. +type nameExpander struct { + pattern string + counts map[string]int +} + +func newNameExpander(pattern string) *nameExpander { + return &nameExpander{pattern: pattern, counts: map[string]int{}} +} + +// Expand returns the resource name produced by applying the pattern to the +// given TF resource. +func (e *nameExpander) Expand(res resourceset.TFResource) string { + expanded := expandPlaceholders(e.pattern, res) + + var name string + if pos := strings.LastIndex(expanded, "*"); pos != -1 { + prefix, suffix := expanded[:pos], expanded[pos+1:] + key := prefix + "\x00" + suffix + idx := e.counts[key] + e.counts[key] = idx + 1 + name = fmt.Sprintf("%s%d%s", prefix, idx, suffix) + } else { + idx := e.counts[expanded] + e.counts[expanded] = idx + 1 + name = fmt.Sprintf("%s%d", expanded, idx) + } + return ensureValidTFName(name) +} + +func expandPlaceholders(pattern string, res resourceset.TFResource) string { + id := res.AzureId + + out := pattern + if strings.Contains(out, phType) { + out = strings.ReplaceAll(out, phType, snakeCase(lastSegment(id.Types()))) + } + if strings.Contains(out, phRP) { + out = strings.ReplaceAll(out, phRP, snakeCase(id.Provider())) + } + if strings.Contains(out, phName) { + out = strings.ReplaceAll(out, phName, snakeCase(lastSegment(id.Names()))) + } + if strings.Contains(out, phRootScope) { + out = strings.ReplaceAll(out, phRootScope, snakeCase(rootScopeName(id))) + } + return out +} + +func lastSegment(segs []string) string { + if len(segs) == 0 { + return "" + } + return segs[len(segs)-1] +} + +// rootScopeName returns a short, identifier-friendly representation of the +// root scope of the resource id (e.g. the resource group name, the +// subscription id, or the management group name). +func rootScopeName(id armid.ResourceId) string { + if id == nil { + return "" + } + root := id.RootScope() + if root == nil { + return "" + } + names := root.Names() + if len(names) == 0 { + return "" + } + return names[len(names)-1] +} + +// snakeCase converts a string (potentially CamelCase / dotted / mixed) to a +// lowercase, underscore-separated identifier. Non-alphanumeric characters +// become underscores; runs of underscores are collapsed; leading/trailing +// underscores are trimmed. +func snakeCase(s string) string { + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s) + 4) + runes := []rune(s) + for i, r := range runes { + switch { + case unicode.IsUpper(r): + // Insert `_` before an uppercase letter when: + // - it follows a lowercase / digit, or + // - it is followed by a lowercase letter and preceded by another uppercase + // (so that "HTTPServer" -> "http_server") + if i > 0 { + prev := runes[i-1] + switch { + case unicode.IsLower(prev) || unicode.IsDigit(prev): + b.WriteByte('_') + case unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]): + b.WriteByte('_') + } + } + b.WriteRune(unicode.ToLower(r)) + case unicode.IsLower(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + // Collapse runs of underscores and trim. + out := b.String() + for strings.Contains(out, "__") { + out = strings.ReplaceAll(out, "__", "_") + } + return strings.Trim(out, "_") +} + +// ensureValidTFName makes sure the final name is a valid Terraform identifier. +// Terraform identifiers must start with a letter or underscore and may then +// contain letters, digits, underscores and dashes. We restrict ourselves to +// the conservative subset [A-Za-z0-9_]. +func ensureValidTFName(s string) string { + if s == "" { + return "res" + } + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := b.String() + if out == "" { + return "res" + } + if c := out[0]; c >= '0' && c <= '9' { + out = "_" + out + } + return out +} diff --git a/internal/meta/name_pattern_test.go b/internal/meta/name_pattern_test.go new file mode 100644 index 0000000..72fe4fd --- /dev/null +++ b/internal/meta/name_pattern_test.go @@ -0,0 +1,145 @@ +package meta + +import ( + "testing" + + "github.com/Azure/aztfexport/internal/resourceset" + "github.com/magodo/armid" +) + +func mustParseID(t *testing.T, s string) armid.ResourceId { + t.Helper() + id, err := armid.ParseResourceId(s) + if err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return id +} + +func TestSnakeCase(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {"virtualMachines", "virtual_machines"}, + {"HTTPServer", "http_server"}, + {"Microsoft.Compute", "microsoft_compute"}, + {"my-vm.01", "my_vm_01"}, + {"azurerm_virtual_machine", "azurerm_virtual_machine"}, + {"FooBARBaz", "foo_bar_baz"}, + {"--__weird__--", "weird"}, + } + for _, c := range cases { + if got := snakeCase(c.in); got != c.want { + t.Errorf("snakeCase(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestEnsureValidTFName(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", "res"}, + {"foo", "foo"}, + {"1foo", "_1foo"}, + {"foo.bar", "foo_bar"}, + {"foo-bar_baz0", "foo-bar_baz0"}, + } + for _, c := range cases { + if got := ensureValidTFName(c.in); got != c.want { + t.Errorf("ensureValidTFName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestNameExpander(t *testing.T) { + vm1 := resourceset.TFResource{ + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vm1"), + TFType: "azurerm_linux_virtual_machine", + } + vm2 := resourceset.TFResource{ + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vm2"), + TFType: "azurerm_linux_virtual_machine", + } + vnet := resourceset.TFResource{ + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Network/virtualNetworks/vnet1"), + TFType: "azurerm_virtual_network", + } + + t.Run("default-pattern", func(t *testing.T) { + e := newNameExpander("res-") + got := []string{e.Expand(vm1), e.Expand(vm2), e.Expand(vnet)} + want := []string{"res-0", "res-1", "res-2"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("star-suffix", func(t *testing.T) { + e := newNameExpander("res-*") + got := []string{e.Expand(vm1), e.Expand(vm2)} + want := []string{"res-0", "res-1"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("type-placeholder", func(t *testing.T) { + e := newNameExpander("{type}_*") + got := []string{e.Expand(vm1), e.Expand(vm2), e.Expand(vnet)} + // Per-prefix counter restarts per distinct expanded prefix. + want := []string{"virtual_machines_0", "virtual_machines_1", "virtual_networks_0"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("name-and-root_scope-placeholders", func(t *testing.T) { + e := newNameExpander("{root_scope}_{name}_*") + got := e.Expand(vm1) + want := "my_rg_vm1_0" + if got != want { + t.Errorf("= %q, want %q", got, want) + } + }) + + t.Run("rp-placeholder", func(t *testing.T) { + e := newNameExpander("{rp}_{type}_*") + got := e.Expand(vm1) + want := "microsoft_compute_virtual_machines_0" + if got != want { + t.Errorf("= %q, want %q", got, want) + } + }) + + t.Run("no-star-appends-index", func(t *testing.T) { + e := newNameExpander("{type}") + got := []string{e.Expand(vm1), e.Expand(vm2)} + want := []string{"virtual_machines0", "virtual_machines1"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("sanitizes-invalid-chars", func(t *testing.T) { + e := newNameExpander("bad name!*") + got := e.Expand(vm1) + // Spaces and `!` become underscores; trailing underscore from `!` is kept (collapsed once with `*->0`). + // We don't assert the exact collapsing rules but ensure the result is a valid identifier. + for _, r := range got { + ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' + if !ok { + t.Errorf("invalid char %q in %q", r, got) + } + } + }) +} diff --git a/main.go b/main.go index 82ead37..515e9df 100644 --- a/main.go +++ b/main.go @@ -420,7 +420,7 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - Usage: `The pattern of the resource name. The semantic of a pattern is the same as Go's os.CreateTemp() (only works for multi-resource mode).`, + Usage: `The pattern of the resource name. The pattern supports an incremental index via '*' (same semantic as Go's os.CreateTemp()) and a set of placeholders expanded per resource: {type} (the last Azure resource type segment, snake_cased, e.g. 'virtual_machines'), {rp} (the Azure resource provider namespace, snake_cased, e.g. 'microsoft_compute'), {name} (the last name segment of the Azure resource id, snake_cased), {root_scope} (the root scope of the resource, snake_cased, e.g. the resource group name). E.g. '{type}' may expand to 'virtual_machines'. (only works for multi-resource mode).`, Value: "res-", Destination: &flagset.flagPattern, }, @@ -444,7 +444,7 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - Usage: `The pattern of the resource name. The semantic of a pattern is the same as Go's os.CreateTemp()`, + Usage: `The pattern of the resource name. The pattern supports an incremental index via '*' (same semantic as Go's os.CreateTemp()) and a set of placeholders expanded per resource: {type} (the last Azure resource type segment, snake_cased, e.g. 'virtual_machines'), {rp} (the Azure resource provider namespace, snake_cased, e.g. 'microsoft_compute'), {name} (the last name segment of the Azure resource id, snake_cased), {root_scope} (the root scope of the resource, snake_cased, e.g. the resource group name). E.g. '{type}*' may expand to 'virtual_machines0'.`, Value: "res-", Destination: &flagset.flagPattern, }, @@ -455,7 +455,7 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - Usage: `The pattern of the resource name. The semantic of a pattern is the same as Go's os.CreateTemp()`, + Usage: `The pattern of the resource name. The pattern supports an incremental index via '*' (same semantic as Go's os.CreateTemp()) and a set of placeholders expanded per resource: {type} (the last Azure resource type segment, snake_cased, e.g. 'virtual_machines'), {rp} (the Azure resource provider namespace, snake_cased, e.g. 'microsoft_compute'), {name} (the last name segment of the Azure resource id, snake_cased), {root_scope} (the root scope of the resource, snake_cased, e.g. the resource group name). E.g. '{type}*' may expand to 'virtual_machines0'.`, Value: "res-", Destination: &flagset.flagPattern, }, diff --git a/pkg/config/config.go b/pkg/config/config.go index b51d4e6..274e7b8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -119,7 +119,18 @@ type Config struct { ///////////////////////// // Scope: rg, res (multi), query - // ResourceNamePattern specifies the resource name pattern + // ResourceNamePattern specifies the resource name pattern. + // + // The pattern supports an incremental index via the '*' character (same + // semantic as Go's os.CreateTemp()), as well as the following per-resource + // placeholders, expanded based on the parsed Azure resource id and the + // recommended TF resource type: + // {type} - last Azure resource type segment, snake_cased (e.g. "virtual_machines") + // {rp} - Azure resource provider namespace, snake_cased (e.g. "microsoft_compute") + // {name} - last name segment of the Azure resource id + // {root_scope} - the root scope of the resource (e.g. resource group name) + // + // Each expanded value is sanitized to be a valid Terraform identifier. ResourceNamePattern string // IncludeRoleAssignment specifies whether to include the role assignments assigned to the exported resources