Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions command_before_func.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 59 additions & 23 deletions internal/meta/name_pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package meta

import (
"fmt"
"strconv"
"strings"
"unicode"

Expand All @@ -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 {
Expand Down Expand Up @@ -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"
}
Expand Down
77 changes: 49 additions & 28 deletions internal/meta/name_pattern_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestSnakeCase(t *testing.T) {
}
}

func TestEnsureValidTFName(t *testing.T) {
func TestToTFName(t *testing.T) {
cases := []struct {
in, want string
}{
Expand All @@ -47,41 +47,73 @@ 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)
}
}
}

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])
}
}
})

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])
Expand All @@ -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])
Expand All @@ -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 == '-'
Expand Down
14 changes: 8 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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): 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{
&cli.StringFlag{
Expand Down Expand Up @@ -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{
Expand All @@ -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...)
Expand All @@ -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{
Expand Down
14 changes: 10 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading