diff --git a/cmd/alzlibtool/command/cache/create.go b/cmd/alzlibtool/command/cache/create.go index 07700f4..eeb9f98 100644 --- a/cmd/alzlibtool/command/cache/create.go +++ b/cmd/alzlibtool/command/cache/create.go @@ -36,6 +36,14 @@ When --library and --architecture are specified, only the definitions referenced architecture are included, producing a smaller, use-case-specific cache. This is useful for embedding the minimal set of definitions needed for a given deployment workflow. +A --library value may be either a local path (e.g. ./mylib) or an ALZ Library reference in +the form @, e.g. platform/alz@2026.01.3. The flag may be specified multiple +times to combine several libraries; their dependencies are fetched recursively. + +Use --library-overwrite-enabled to allow later libraries to overwrite definitions, policy +assignments, role definitions, archetypes and architectures already provided by earlier +libraries. This is useful when layering a custom library on top of a base ALZ library. + Use --from-cache to seed from an existing cache file (requires --library and --architecture). Definitions already present in the seed cache are used directly and not re-fetched from Azure, reducing the number of API calls. The same file may be used for both --from-cache and --output @@ -44,12 +52,13 @@ to update a cache in-place.`, Run: func(cmd *cobra.Command, _ []string) { outFile, _ := cmd.Flags().GetString("output") verbose, _ := cmd.Flags().GetBool("verbose") - libraryPath, _ := cmd.Flags().GetString("library") + libraryRefs, _ := cmd.Flags().GetStringArray("library") architectureName, _ := cmd.Flags().GetString("architecture") fromCacheFile, _ := cmd.Flags().GetString("from-cache") + libraryOverwriteEnabled, _ := cmd.Flags().GetBool("library-overwrite-enabled") // --library and --architecture must be specified together. - if (libraryPath == "") != (architectureName == "") { + if (len(libraryRefs) == 0) != (architectureName == "") { cmd.PrintErrf( "%s --library and --architecture must be specified together\n", cmd.ErrPrefix(), @@ -61,7 +70,7 @@ to update a cache in-place.`, // lazy cache-first lookup can skip individual Azure API calls. In full-scan // mode the bulk listing APIs fetch everything regardless, so a seed cache // cannot reduce work. - if fromCacheFile != "" && libraryPath == "" { + if fromCacheFile != "" && len(libraryRefs) == 0 { cmd.PrintErrf( "%s --from-cache requires --library and --architecture\n", cmd.ErrPrefix(), @@ -69,6 +78,16 @@ to update a cache in-place.`, os.Exit(1) } + // --library-overwrite-enabled is only meaningful in architecture-scoped mode + // where multiple libraries are loaded into an AlzLib instance. + if libraryOverwriteEnabled && len(libraryRefs) == 0 { + cmd.PrintErrf( + "%s --library-overwrite-enabled requires --library and --architecture\n", + cmd.ErrPrefix(), + ) + os.Exit(1) + } + // Read the seed cache BEFORE opening the output file, because --from-cache // and --output may point to the same path. var seedCache *cache.Cache @@ -106,12 +125,15 @@ to update a cache in-place.`, var resultCache *cache.Cache - if libraryPath != "" { + if len(libraryRefs) > 0 { // Architecture-scoped mode: process the library + architecture and export // only the built-in definitions that were actually referenced. - thisLib := alzlib.NewCustomLibraryReference(libraryPath) + refs := make(alzlib.LibraryReferences, 0, len(libraryRefs)) + for _, r := range libraryRefs { + refs = append(refs, alzlib.NewLibraryReference(r)) + } - allLibs, err := thisLib.FetchWithDependencies(cmd.Context()) + allLibs, err := refs.FetchWithDependencies(cmd.Context()) if err != nil { cmd.PrintErrf( "%s could not fetch libraries with dependencies: %v\n", @@ -121,6 +143,7 @@ to update a cache in-place.`, } az := alzlib.NewAlzLib(nil) + az.Options.AllowOverwrite = libraryOverwriteEnabled if seedCache != nil { az.AddCache(seedCache) @@ -221,10 +244,13 @@ func init() { createCmd.Flags(). BoolP("verbose", "v", false, "Display detailed progress during cache creation.") createCmd.Flags(). - StringP( - "library", "L", "", - "Path to a library. When set together with --architecture, creates a minimal cache "+ - "containing only the definitions referenced by the specified architecture.") + StringArrayP( + "library", "L", nil, + "Path or reference to a library. May be specified multiple times. Each value is "+ + "either a local filesystem path (e.g. ./mylib) or an ALZ Library reference of "+ + "the form @ (e.g. platform/alz@2026.01.3). When set together with "+ + "--architecture, creates a minimal cache containing only the definitions "+ + "referenced by the specified architecture.") createCmd.Flags(). StringP( "architecture", "a", "", @@ -235,4 +261,11 @@ func init() { "Path to an existing cache file to use as a seed (requires --library and --architecture). "+ "Definitions found in the seed cache are not re-fetched from Azure, reducing API calls. "+ "The same path may be used for both --from-cache and --output to update a cache in-place.") + createCmd.Flags(). + Bool( + "library-overwrite-enabled", false, + "Allow later libraries to overwrite definitions, policy assignments, role definitions, "+ + "archetypes and architectures already provided by earlier libraries. Useful when "+ + "layering a custom library on top of a base ALZ library. Requires --library and "+ + "--architecture.") } diff --git a/metadata.go b/metadata.go index df6694a..38deb01 100644 --- a/metadata.go +++ b/metadata.go @@ -97,6 +97,30 @@ func NewAlzLibraryReference(path, ref string) *AlzLibraryReference { } } +// NewAlzLibraryReferenceFromString creates a new AlzLibraryReference by parsing +// a string in the form "@" (the inverse of AlzLibraryReference.String()). +// Returns an error if s does not contain an "@" separator, has an empty path or +// has an empty ref. +func NewAlzLibraryReferenceFromString(s string) (*AlzLibraryReference, error) { + idx := strings.LastIndex(s, "@") + if idx <= 0 { + return nil, fmt.Errorf( + "NewAlzLibraryReferenceFromString: %q is not in the form @", s, + ) + } + + path := s[:idx] + ref := s[idx+1:] + + if ref == "" { + return nil, fmt.Errorf( + "NewAlzLibraryReferenceFromString: %q has an empty ref", s, + ) + } + + return NewAlzLibraryReference(path, ref), nil +} + // NewAlzLibraryReferenceFromFS creates a new AlzLibraryReference with the given path, ref and filesystem. func NewAlzLibraryReferenceFromFS(path, ref string, filesystem fs.FS) *AlzLibraryReference { return &AlzLibraryReference{ @@ -173,6 +197,14 @@ func NewCustomLibraryReference(url string) *CustomLibraryReference { } } +// NewCustomLibraryReferenceFromString creates a new CustomLibraryReference from +// a go-getter URL or local path string (the inverse of CustomLibraryReference.String()). +// It is equivalent to NewCustomLibraryReference and is provided for symmetry with +// NewAlzLibraryReferenceFromString. +func NewCustomLibraryReferenceFromString(s string) *CustomLibraryReference { + return NewCustomLibraryReference(s) +} + // NewCustomLibraryReferenceFromFS creates a new CustomLibraryReference with the given URL and filesystem. func NewCustomLibraryReferenceFromFS(url string, filesystem fs.FS) *CustomLibraryReference { return &CustomLibraryReference{ @@ -288,3 +320,36 @@ func (m *Metadata) IsAlzLibraryRef() bool { func (m *Metadata) Ref() LibraryReference { return m.ref } + +// NewLibraryReference is a universal constructor that creates a LibraryReference +// from a string by detecting its form. Values that contain an "@" separator and +// do not look like a local filesystem path (i.e. do not start with ".", "/", "\" +// or a Windows drive letter such as "C:") are treated as ALZ Library references +// of the form "@" and an *AlzLibraryReference is returned. Everything +// else is returned as a *CustomLibraryReference. +func NewLibraryReference(s string) LibraryReference { + if !looksLikeLocalPath(s) { + if ref, err := NewAlzLibraryReferenceFromString(s); err == nil { + return ref + } + } + + return NewCustomLibraryReference(s) +} + +// looksLikeLocalPath reports whether p has a prefix that indicates a local +// filesystem path rather than an ALZ Library member path. +func looksLikeLocalPath(p string) bool { + if strings.HasPrefix(p, ".") || strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) { + return true + } + // Windows drive letter, e.g. "C:" or "c:". + if len(p) >= 2 && p[1] == ':' { + c := p[0] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + return true + } + } + + return false +} diff --git a/metadata_test.go b/metadata_test.go index d5a029a..16c56a7 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -97,3 +97,143 @@ func TestNewCustomLibraryReferenceFromFS(t *testing.T) { require.NoError(t, err) assert.Equal(t, fs.FS(memfs), got) } + +// TestNewAlzLibraryReferenceFromString verifies parsing of "@" strings. +func TestNewAlzLibraryReferenceFromString(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantErr bool + wantPath string + wantRef string + }{ + {name: "valid", input: "platform/alz@2026.01.3", wantPath: "platform/alz", wantRef: "2026.01.3"}, + {name: "single segment path", input: "alz@2024.07.01", wantPath: "alz", wantRef: "2024.07.01"}, + {name: "no separator", input: "platform/alz", wantErr: true}, + {name: "empty ref", input: "platform/alz@", wantErr: true}, + {name: "empty path", input: "@2026.01.3", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ref, err := NewAlzLibraryReferenceFromString(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.Nil(t, ref) + + return + } + + require.NoError(t, err) + require.NotNil(t, ref) + assert.Equal(t, tc.wantPath, ref.Path()) + assert.Equal(t, tc.wantRef, ref.Ref()) + // Round-trip via String(). + assert.Equal(t, tc.input, ref.String()) + }) + } +} + +// TestNewLibraryReference verifies the universal constructor's detection of +// ALZ Library references vs. custom (URL or local path) references. +func TestNewLibraryReference(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantAlz bool + wantPath string + wantRef string // only for ALZ refs + wantCustS string // only for custom refs (String()) + }{ + { + name: "alz reference", + input: "platform/alz@2026.01.3", + wantAlz: true, + wantPath: "platform/alz", + wantRef: "2026.01.3", + }, + { + name: "alz reference single segment", + input: "alz@2024.07.01", + wantAlz: true, + wantPath: "alz", + wantRef: "2024.07.01", + }, + { + name: "local relative path", + input: "./mylib", + wantAlz: false, + wantCustS: "./mylib", + }, + { + name: "local relative path with at sign", + input: "./mylib@dev", + wantAlz: false, + wantCustS: "./mylib@dev", + }, + { + name: "absolute path", + input: "/tmp/lib", + wantAlz: false, + wantCustS: "/tmp/lib", + }, + { + name: "windows-style path", + input: `\\share\lib`, + wantAlz: false, + wantCustS: `\\share\lib`, + }, + { + name: "windows drive path with at sign", + input: `C:\libs\mylib@dev`, + wantAlz: false, + wantCustS: `C:\libs\mylib@dev`, + }, + { + name: "windows drive path lowercase with at sign", + input: `d:/libs/mylib@dev`, + wantAlz: false, + wantCustS: `d:/libs/mylib@dev`, + }, + { + name: "no separator", + input: "platform/alz", + wantAlz: false, + wantCustS: "platform/alz", + }, + { + name: "trailing at sign with empty ref", + input: "platform/alz@", + wantAlz: false, + wantCustS: "platform/alz@", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := NewLibraryReference(tc.input) + if tc.wantAlz { + ref, ok := got.(*AlzLibraryReference) + if assert.True(t, ok, "expected *AlzLibraryReference, got %T", got) { + assert.Equal(t, tc.wantPath, ref.Path()) + assert.Equal(t, tc.wantRef, ref.Ref()) + } + + return + } + + _, ok := got.(*CustomLibraryReference) + assert.True(t, ok, "expected *CustomLibraryReference, got %T", got) + assert.Equal(t, tc.wantCustS, got.String()) + }) + } +}