diff --git a/api/v1beta1/artifactgenerator_types.go b/api/v1beta1/artifactgenerator_types.go index 02e28eb..c15df70 100644 --- a/api/v1beta1/artifactgenerator_types.go +++ b/api/v1beta1/artifactgenerator_types.go @@ -191,6 +191,13 @@ type CopyOperation struct { // +optional // +kubebuilder:validation:Enum=Overwrite;Merge;Extract Strategy string `json:"strategy,omitempty"` + + // Optional, when set to true, allows the copy operation to silently + // skip when the source path or glob pattern matches no files. + // When false (default), the reconciliation fails with an error if + // no files are matched. + // +optional + Optional bool `json:"optional,omitempty"` } // ArtifactGeneratorStatus defines the observed state of ArtifactGenerator. diff --git a/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml b/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml index 9118a79..84a358e 100644 --- a/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml +++ b/config/crd/bases/source.extensions.fluxcd.io_artifactgenerators.yaml @@ -90,6 +90,13 @@ spec: minLength: 1 pattern: ^@([a-z0-9]([a-z0-9_-]*[a-z0-9])?)/(.*)$ type: string + optional: + description: |- + Optional, when set to true, allows the copy operation to silently + skip when the source path or glob pattern matches no files. + When false (default), the reconciliation fails with an error if + no files are matched. + type: boolean strategy: description: |- Strategy specifies the copy strategy to use. diff --git a/docs/spec/v1beta1/artifactgenerators.md b/docs/spec/v1beta1/artifactgenerators.md index dc5992e..44ccdc4 100644 --- a/docs/spec/v1beta1/artifactgenerators.md +++ b/docs/spec/v1beta1/artifactgenerators.md @@ -322,6 +322,9 @@ Each copy operation specifies how to copy files from sources into the generated file name at any depth. - `strategy` (optional): Defines how to handle files during copy operations: `Overwrite` (default), `Merge` (for YAML files), or `Extract` (for tarball archives). +- `optional` (optional): When set to `true`, the copy operation silently + skips if the source path or glob pattern matches no files. Defaults to `false`, + which causes the reconciliation to fail with an error on missing sources. Copy operations use `cp`-like semantics: diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 8e636fe..35deda6 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -197,6 +197,9 @@ func applyCopyOperation(ctx context.Context, } if len(matches) == 0 { + if op.Optional { + return nil + } return fmt.Errorf("no files match pattern '%s' in source '%s'", srcPattern, srcAlias) } @@ -218,6 +221,9 @@ func applyCopyOperation(ctx context.Context, } if len(filteredMatches) == 0 { + if op.Optional { + return nil + } return fmt.Errorf("all files matching pattern '%s' in source '%s' were excluded", srcPattern, srcAlias) } @@ -266,6 +272,9 @@ func applySingleSourceCopy(ctx context.Context, srcInfo, err := srcRoot.Stat(srcPath) if err != nil { if os.IsNotExist(err) { + if op.Optional { + return nil + } return fmt.Errorf("source '%s' does not exist", srcPath) } return fmt.Errorf("failed to stat source '%s': %w", srcPath, err) diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index 5caf5ea..2b63032 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -656,6 +656,94 @@ func TestBuildErrors(t *testing.T) { "source": tmpDir, } + return spec, sources, workspaceDir + }, + }, + { + name: "optional skips when source file does not exist", + expectedError: "", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + spec := &swapi.OutputArtifact{ + Name: "optional-source-file", + Copy: []swapi.CopyOperation{ + { + From: "@source/test.yaml", + To: "@artifact/", + Optional: true, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + }, + { + name: "optional skips when glob pattern matches no files", + expectedError: "", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + spec := &swapi.OutputArtifact{ + Name: "optional-glob-match", + Copy: []swapi.CopyOperation{ + { + From: "@source/*.yaml", + To: "@artifact/", + Optional: true, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + + return spec, sources, workspaceDir + }, + }, + { + name: "optional skips when all files are excluded", + expectedError: "", + setupFunc: func(t *testing.T) (*swapi.OutputArtifact, map[string]string, string) { + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "source") + workspaceDir := filepath.Join(tmpDir, "workspace") + + setupDirs(t, srcDir, workspaceDir) + + createFile(t, srcDir, "test.md", "test file") + createFile(t, srcDir, "other.md", "other file") + + spec := &swapi.OutputArtifact{ + Name: "optional-all-excluded", + Copy: []swapi.CopyOperation{ + { + From: "@source/*.md", + To: "@artifact/", + Exclude: []string{"*.md"}, + Optional: true, + }, + }, + } + + sources := map[string]string{ + "source": srcDir, + } + return spec, sources, workspaceDir }, }, @@ -667,6 +755,10 @@ func TestBuildErrors(t *testing.T) { spec, sources, workspace := tt.setupFunc(t) _, err := testBuilder.Build(context.Background(), spec, sources, "test-namespace", workspace) + if tt.expectedError == "" { + g.Expect(err).ToNot(HaveOccurred()) + return + } if err == nil { t.Logf("Staging directory contents:") walkErr := filepath.Walk(filepath.Join(workspace, spec.Name), func(path string, info os.FileInfo, err error) error {