From 9d4661708fd7cd9001524d7aa039aa3339193a43 Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 19 Aug 2026 16:41:25 +1000 Subject: [PATCH 1/2] Introduce `+` and change semantics of `*` for the `--name-pattern` index - Introduce a "+" to indicate always append a number (starting from 1). - Change the semantic of "*" to only add the number if there is more than one names (starting from 2). - When absent, implicitly append "*" at the end of the name pattern. --- command_before_func.go | 4 ++ internal/meta/name_pattern.go | 82 +++++++++++++++++++++--------- internal/meta/name_pattern_test.go | 77 ++++++++++++++++++---------- main.go | 14 ++--- pkg/config/config.go | 14 +++-- 5 files changed, 130 insertions(+), 61 deletions(-) diff --git a/command_before_func.go b/command_before_func.go index 069f2a2..e39e6f5 100644 --- a/command_before_func.go +++ b/command_before_func.go @@ -59,6 +59,10 @@ func commandBeforeFunc(fset *FlagSet, mode Mode) func(ctx *cli.Context) error { } } + if err := meta.ValidateNamePattern(fset.flagPattern); err != nil { + return fmt.Errorf("invalid value of `--name-pattern`: %v", err) + } + if err := conflictArgs([]argDesc{ { name: "--client-id", diff --git a/internal/meta/name_pattern.go b/internal/meta/name_pattern.go index 4a228f2..b236893 100644 --- a/internal/meta/name_pattern.go +++ b/internal/meta/name_pattern.go @@ -2,6 +2,7 @@ package meta import ( "fmt" + "strconv" "strings" "unicode" @@ -16,36 +17,71 @@ const ( 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. +const ( + // idxOptional expands to an incremental index only when the same name is + // shared by more than one resource, in which case the index starts from 2 + // (i.e. the first occurrence has no index at all). + idxOptional = '*' + // idxAlways always expands to an incremental index, starting from 1. + idxAlways = '+' +) + +// idxChars is the set of the index characters supported in a name pattern. +var idxChars = string([]rune{idxOptional, idxAlways}) + +// ValidateNamePattern validates the resource name pattern, which can contain at +// most one index character, either `*` or `+`. +func ValidateNamePattern(pattern string) error { + if n := strings.Count(pattern, string(idxOptional)) + strings.Count(pattern, string(idxAlways)); n > 1 { + return fmt.Errorf("the name pattern %q contains %d %q/%q, while at most one (exclusively) is allowed", pattern, n, string(idxOptional), string(idxAlways)) + } + return nil +} + type nameExpander struct { - pattern string - counts map[string]int + // prefix and suffix are the pattern segments before/after the index character. + prefix string + suffix string + // always indicates the index character is `+`, rather than `*`. + always bool + // counts counts the name per resource type. + counts map[string]map[string]int } func newNameExpander(pattern string) *nameExpander { - return &nameExpander{pattern: pattern, counts: map[string]int{}} + // An `*` is implicitly appended at the end when no index character is specified. + if !strings.ContainsAny(pattern, idxChars) { + pattern += string(idxOptional) + } + + pos := strings.IndexAny(pattern, idxChars) + return &nameExpander{ + prefix: pattern[:pos], + suffix: pattern[pos+1:], + always: rune(pattern[pos]) == idxAlways, + counts: map[string]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) + prefix, suffix := expandPlaceholders(e.prefix, res), expandPlaceholders(e.suffix, res) + + key := prefix + "\x00" + suffix + + if e.counts[res.TFType] == nil { + e.counts[res.TFType] = map[string]int{} + } + ic := e.counts[res.TFType] + ic[key]++ + + var idx string + if n := ic[key]; e.always || n > 1 { + idx = strconv.Itoa(n) + } + + return toTFName(prefix + idx + suffix) } func expandPlaceholders(pattern string, res resourceset.TFResource) string { @@ -134,11 +170,11 @@ func snakeCase(s string) string { return strings.Trim(out, "_") } -// ensureValidTFName makes sure the final name is a valid Terraform identifier. +// toTFName 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 { +func toTFName(s string) string { if s == "" { return "res" } diff --git a/internal/meta/name_pattern_test.go b/internal/meta/name_pattern_test.go index 72fe4fd..abfca50 100644 --- a/internal/meta/name_pattern_test.go +++ b/internal/meta/name_pattern_test.go @@ -36,7 +36,7 @@ func TestSnakeCase(t *testing.T) { } } -func TestEnsureValidTFName(t *testing.T) { +func TestToTFName(t *testing.T) { cases := []struct { in, want string }{ @@ -47,7 +47,7 @@ func TestEnsureValidTFName(t *testing.T) { {"foo-bar_baz0", "foo-bar_baz0"}, } for _, c := range cases { - if got := ensureValidTFName(c.in); got != c.want { + if got := toTFName(c.in); got != c.want { t.Errorf("ensureValidTFName(%q) = %q, want %q", c.in, got, c.want) } } @@ -55,22 +55,23 @@ func TestEnsureValidTFName(t *testing.T) { func TestNameExpander(t *testing.T) { vm1 := resourceset.TFResource{ - AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vm1"), + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vmone"), TFType: "azurerm_linux_virtual_machine", } vm2 := resourceset.TFResource{ - AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vm2"), + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Compute/virtualMachines/vmtwo"), TFType: "azurerm_linux_virtual_machine", } vnet := resourceset.TFResource{ - AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Network/virtualNetworks/vnet1"), + AzureId: mustParseID(t, "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRg/providers/Microsoft.Network/virtualNetworks/myvnet"), TFType: "azurerm_virtual_network", } t.Run("default-pattern", func(t *testing.T) { - e := newNameExpander("res-") + // The `*` is implicitly appended. + e := newNameExpander("res") got := []string{e.Expand(vm1), e.Expand(vm2), e.Expand(vnet)} - want := []string{"res-0", "res-1", "res-2"} + want := []string{"res", "res2", "res"} for i := range got { if got[i] != want[i] { t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) @@ -78,10 +79,41 @@ func TestNameExpander(t *testing.T) { } }) - t.Run("star-suffix", func(t *testing.T) { - e := newNameExpander("res-*") + t.Run("star-single-resource", func(t *testing.T) { + e := newNameExpander("res*") + got := e.Expand(vm1) + want := "res" + if got != want { + t.Errorf("= %q, want %q", got, want) + } + }) + + t.Run("star-infix", func(t *testing.T) { + e := newNameExpander("pre*post") + got := []string{e.Expand(vm1), e.Expand(vm2)} + want := []string{"prepost", "pre2post"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("plus-always-index", func(t *testing.T) { + e := newNameExpander("res-+") + got := []string{e.Expand(vm1), e.Expand(vm2), e.Expand(vnet)} + want := []string{"res-1", "res-2", "res-1"} + for i := range got { + if got[i] != want[i] { + t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("plus-infix", func(t *testing.T) { + e := newNameExpander("pre_+_post") got := []string{e.Expand(vm1), e.Expand(vm2)} - want := []string{"res-0", "res-1"} + want := []string{"pre_1_post", "pre_2_post"} for i := range got { if got[i] != want[i] { t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) @@ -90,10 +122,10 @@ func TestNameExpander(t *testing.T) { }) t.Run("type-placeholder", func(t *testing.T) { - e := newNameExpander("{type}_*") + 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"} + want := []string{"virtual_machines", "virtual_machines2", "virtual_networks"} for i := range got { if got[i] != want[i] { t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) @@ -102,38 +134,27 @@ func TestNameExpander(t *testing.T) { }) t.Run("name-and-root_scope-placeholders", func(t *testing.T) { - e := newNameExpander("{root_scope}_{name}_*") + e := newNameExpander("{root_scope}_{name}") got := e.Expand(vm1) - want := "my_rg_vm1_0" + want := "my_rg_vmone" if got != want { t.Errorf("= %q, want %q", got, want) } }) t.Run("rp-placeholder", func(t *testing.T) { - e := newNameExpander("{rp}_{type}_*") + e := newNameExpander("{rp}_{type}") got := e.Expand(vm1) - want := "microsoft_compute_virtual_machines_0" + want := "microsoft_compute_virtual_machines" 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`). + // Spaces and `!` become underscores. // 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 == '-' diff --git a/main.go b/main.go index 7bf1f79..ae8671b 100644 --- a/main.go +++ b/main.go @@ -92,6 +92,8 @@ func prepareConfigFile(ctx *cli.Context) error { return nil } +const namePatternUsage = `The pattern of the resource name. The pattern supports at most one index character, either '*' or '+' (exclusively): '*' expands to an incremental index (starting from 2) only when the same name is shared by more than one resource, whilst '+' always expands to an incremental index (starting from 1). If none is specified, a '*' is implicitly appended at the end of the pattern. The pattern also supports a set of placeholders that are 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', 'virtual_machines2', ...` + func main() { commonFlags := []cli.Flag{ &cli.StringFlag{ @@ -421,8 +423,8 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - 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-", + Usage: namePatternUsage + " (only works for multi-resource mode).", + Value: "res-+", Destination: &flagset.flagPattern, }, &cli.BoolFlag{ @@ -445,8 +447,8 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - 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-", + Usage: namePatternUsage, + Value: "res", Destination: &flagset.flagPattern, }, }, commonFlags...) @@ -456,8 +458,8 @@ func main() { Name: "name-pattern", EnvVars: []string{"AZTFEXPORT_NAME_PATTERN"}, Aliases: []string{"p"}, - 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-", + Usage: namePatternUsage, + Value: "res-+", Destination: &flagset.flagPattern, }, &cli.BoolFlag{ diff --git a/pkg/config/config.go b/pkg/config/config.go index 3e2e155..6012d82 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -142,10 +142,16 @@ type Config struct { // 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: + // The pattern supports at most one index character, either '*' or '+' + // (exclusively): + // '*' - expands to an incremental index (starting from 2) only when the + // same name is shared by more than one resource. Otherwise, it + // expands to an empty string + // '+' - always expands to an incremental index, starting from 1 + // If none is specified, a '*' is implicitly appended at the end of the pattern. + // + // The pattern also supports 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 From 70948b4e6a4efae583e575431c200ee00d3b7ff4 Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 19 Aug 2026 17:15:39 +1000 Subject: [PATCH 2/2] Update main.go Co-authored-by: Gerry Tan <2316198+gerrytan@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index ae8671b..1a532e7 100644 --- a/main.go +++ b/main.go @@ -92,7 +92,7 @@ func prepareConfigFile(ctx *cli.Context) error { return nil } -const namePatternUsage = `The pattern of the resource name. The pattern supports at most one index character, either '*' or '+' (exclusively): '*' expands to an incremental index (starting from 2) only when the same name is shared by more than one resource, whilst '+' always expands to an incremental index (starting from 1). If none is specified, a '*' is implicitly appended at the end of the pattern. The pattern also supports a set of placeholders that are 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', 'virtual_machines2', ...` +const namePatternUsage = `The pattern of the resource name. The pattern supports at most one index character, either '*' or '+' (exclusively): both expands to an incremental type-scoped index, '*' outputs no suffix for the first element, then 2, 3 and so on, where '+' output 1, 2, and so on. If none is specified, a '*' is implicitly appended at the end of the pattern. The pattern also supports a set of placeholders that are 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', 'virtual_machines2', ...` func main() { commonFlags := []cli.Flag{