diff --git a/function/.scripts/gen-comp.sh b/function/.scripts/gen-comp.sh index 5d073dd..3b6a6f2 100755 --- a/function/.scripts/gen-comp.sh +++ b/function/.scripts/gen-comp.sh @@ -20,7 +20,7 @@ do fi run=1 echo $dir - (cd $dir && fn-hcl-tools package src/*hcl >/tmp/script.txtar) + (cd $dir && fn-hcl-tools package src/ >/tmp/script.txtar) (cd $dir && cat src/comp-template.yaml | script="$(cat /tmp/script.txtar | jq -sR)" envsubst | yq -P>composition.yaml) done diff --git a/function/api/api.go b/function/api/api.go index 5425505..6314ff1 100644 --- a/function/api/api.go +++ b/function/api/api.go @@ -1,11 +1,15 @@ package api import ( + "github.com/crossplane-contrib/function-hcl/function/internal/composition" "github.com/crossplane-contrib/function-hcl/function/internal/evaluator" "github.com/crossplane-contrib/function-hcl/function/internal/format" "github.com/hashicorp/hcl/v2" ) +// ConfigFile is the well-named file that contains XRD metadata and library file paths. +const ConfigFile = composition.ConfigFile + // FormatHCL formats the supplied code. func FormatHCL(code string) string { return format.Source(code, format.Options{StandardizeObjectLiterals: true}) @@ -19,3 +23,26 @@ func Analyze(files ...File) hcl.Diagnostics { e, _ := evaluator.New(evaluator.Options{}) return e.AnalyzeHCLFiles(files...) } + +// FS is a minimal filesystem implementation that the caller can implement. +type FS = composition.FS + +// XRD provides the XRD information if available as metadata. +type XRD = composition.XRD + +// LoadModule loads metadata and HCL files from the supplied directory and returns the +// results. File paths are relative to the directory that was processed. +func LoadModule(fs FS, dir string, ignoreMetadataErrors bool) (*XRD, []string, error) { + cfg, files, err := composition.Load(fs, dir, ignoreMetadataErrors) + if err != nil { + return nil, nil, err + } + var xrd *composition.XRD + if cfg != nil { + xrd = &cfg.XRD + } + if xrd != nil && (xrd.APIVersion == "" || xrd.Kind == "") { + xrd = nil + } + return xrd, files, nil +} diff --git a/function/cmd/fn-hcl-tools/tools.go b/function/cmd/fn-hcl-tools/tools.go index 8a9a493..f132115 100644 --- a/function/cmd/fn-hcl-tools/tools.go +++ b/function/cmd/fn-hcl-tools/tools.go @@ -2,56 +2,35 @@ package main import ( "fmt" - "log" "os" - "github.com/crossplane-contrib/function-hcl/function/internal/evaluator" + "github.com/crossplane-contrib/function-hcl/function/internal/composition" "github.com/crossplane-contrib/function-hcl/function/internal/format" - "github.com/hashicorp/hcl/v2" "github.com/spf13/cobra" - "golang.org/x/tools/txtar" ) -func doAnalyze(files []evaluator.File) error { - e, err := evaluator.New(evaluator.Options{}) - if err != nil { - return err +func getDir(args []string) (string, error) { + if len(args) > 1 { + return "", fmt.Errorf("zero or exactly one argument expected, found %d", len(args)) } - diags := e.Analyze(files...) - for _, diag := range diags { - sev := "ERROR:" - if diag.Severity == hcl.DiagWarning { - sev = "WARN :" - } - log.Println("\t", sev, diag.Error()) + dir := "." + if len(args) == 1 { + dir = args[0] } - if diags.HasErrors() { - return fmt.Errorf("analysis failed") - } - return nil + return dir, nil } func analyzeCommand() *cobra.Command { c := &cobra.Command{ - Use: "analyze file1.hcl file2.hcl ...", - Short: "perform a static analysis of the supplied files", + Use: "analyze [dir]", + Short: "perform a static analysis of the supplied directory (default is current directory)", RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("no files to analyze") + dir, err := getDir(args) + if err != nil { + return err } cmd.SilenceUsage = true - var files []evaluator.File - for _, file := range args { - contents, err := os.ReadFile(file) - if err != nil { - return err - } - files = append(files, evaluator.File{ - Name: file, - Content: string(contents), - }) - } - return doAnalyze(files) + return composition.Analyze(dir) }, } return c @@ -60,35 +39,18 @@ func analyzeCommand() *cobra.Command { func packageScriptCommand() *cobra.Command { var skipAnalysis bool c := &cobra.Command{ - Use: "package file1.hcl file2.hcl ...", - Short: "generate a txtar script for the supplied files", + Use: "package [dir]", + Short: "generate a txtar script for the supplied directory (default is current directory)", RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("no files to package") + dir, err := getDir(args) + if err != nil { + return err } cmd.SilenceUsage = true - var archive txtar.Archive - var files []evaluator.File - for _, file := range args { - contents, err := os.ReadFile(file) - if err != nil { - return err - } - archive.Files = append(archive.Files, txtar.File{ - Name: file, - Data: contents, - }) - files = append(files, evaluator.File{ - Name: file, - Content: string(contents), - }) - } - if !skipAnalysis { - if err := doAnalyze(files); err != nil { - return err - } + b, err := composition.Package(dir, skipAnalysis) + if err != nil { + return err } - b := txtar.Format(&archive) _, _ = os.Stdout.Write(b) return nil }, diff --git a/function/example/basic-locals/composition.yaml b/function/example/basic-locals/composition.yaml index ce57ee0..1ff91a1 100644 --- a/function/example/basic-locals/composition.yaml +++ b/function/example/basic-locals/composition.yaml @@ -18,7 +18,7 @@ spec: debug: true source: Inline hcl: |+ - -- src/main.hcl -- + -- main.hcl -- // top-level locals behave like Terraform locals and are available everywhere // and accessed just using their name (no need to put "local." in front of it like Terraform) locals { diff --git a/function/example/basic-resource-list/composition.yaml b/function/example/basic-resource-list/composition.yaml index 9043ee5..1861871 100644 --- a/function/example/basic-resource-list/composition.yaml +++ b/function/example/basic-resource-list/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- // the resources block defines multiple resources to be created, the associated name is // used as a basename. resources my-bucket { diff --git a/function/example/basic-resource/composition.yaml b/function/example/basic-resource/composition.yaml index bf8af2c..7f53469 100644 --- a/function/example/basic-resource/composition.yaml +++ b/function/example/basic-resource/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- // the resource block defines a single resource to be created, the name is the crossplane name resource my-bucket { diff --git a/function/example/extra-resources-absent/composition.yaml b/function/example/extra-resources-absent/composition.yaml index c735c34..f44901c 100644 --- a/function/example/extra-resources-absent/composition.yaml +++ b/function/example/extra-resources-absent/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- locals { comp = req.composite // req.composite contains the composite resource compName = comp.metadata.name diff --git a/function/example/extra-resources-absent/src/expected.yaml b/function/example/extra-resources-absent/src/expected.yaml index 08398d0..a4424cc 100644 --- a/function/example/extra-resources-absent/src/expected.yaml +++ b/function/example/extra-resources-absent/src/expected.yaml @@ -15,7 +15,7 @@ status: status: "False" type: FullyResolved - lastTransitionTime: "2024-01-01T00:00:00Z" - message: 'hcl.Diagnostics contains 1 warnings; src/main.hcl:27,49-52: Attempt to index null value' + message: 'hcl.Diagnostics contains 1 warnings; main.hcl:27,49-52: Attempt to index null value' reason: Eval status: "False" type: HclDiagnostics @@ -23,8 +23,8 @@ status: apiVersion: render.crossplane.io/v1beta1 kind: Result message: |- - src/main.hcl:21,10-34,4:discarded resource my-bucket - req.extra_resources.labels-config[0].data.labels, src/main.hcl:27,49-52: Attempt to index null value; This value is null, so it does not have any indices. + main.hcl:21,10-34,4:discarded resource my-bucket + req.extra_resources.labels-config[0].data.labels, main.hcl:27,49-52: Attempt to index null value; This value is null, so it does not have any indices. unknown values: req.extra_resources.labels-config[0].data.labels severity: SEVERITY_WARNING step: run hcl composition @@ -33,7 +33,7 @@ metadata: --- apiVersion: render.crossplane.io/v1beta1 kind: Result -message: 'warnings: [src/main.hcl:27,49-52: Attempt to index null value]' +message: 'warnings: [main.hcl:27,49-52: Attempt to index null value]' severity: SEVERITY_WARNING step: run hcl composition metadata: diff --git a/function/example/extra-resources-present/composition.yaml b/function/example/extra-resources-present/composition.yaml index 796129f..e60439c 100644 --- a/function/example/extra-resources-present/composition.yaml +++ b/function/example/extra-resources-present/composition.yaml @@ -18,7 +18,7 @@ spec: source: Inline debug: true hcl: |+ - -- src/main.hcl -- + -- main.hcl -- locals { comp = req.composite // req.composite contains the composite resource compName = comp.metadata.name diff --git a/function/example/set-context/composition.yaml b/function/example/set-context/composition.yaml index 8aec9d4..0acac98 100644 --- a/function/example/set-context/composition.yaml +++ b/function/example/set-context/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- context { key = "processed" value = req.composite.metadata.name diff --git a/function/example/set-status-incomplete/composition.yaml b/function/example/set-status-incomplete/composition.yaml index 3a45e52..cfc7342 100644 --- a/function/example/set-status-incomplete/composition.yaml +++ b/function/example/set-status-incomplete/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- resource my-bucket { body = { diff --git a/function/example/set-status-incomplete/src/expected.yaml b/function/example/set-status-incomplete/src/expected.yaml index c7372ab..fb024b7 100644 --- a/function/example/set-status-incomplete/src/expected.yaml +++ b/function/example/set-status-incomplete/src/expected.yaml @@ -17,7 +17,7 @@ status: type: FullyResolved - type: HclDiagnostics lastTransitionTime: "2024-01-01T00:00:00Z" - message: "hcl.Diagnostics contains 1 warnings; src/main.hcl:20,32-39: Attempt to get attribute from null value" + message: "hcl.Diagnostics contains 1 warnings; main.hcl:20,32-39: Attempt to get attribute from null value" reason: Eval status: "False" --- @@ -51,7 +51,7 @@ apiVersion: render.crossplane.io/v1beta1 kind: Result metadata: name: r-0 -message: "src/main.hcl:19,12-21,6:discarded composite-status \nself.resource.status.atProvider.arn, src/main.hcl:20,32-39: Attempt to get attribute from null value; This value is null, so it does not have any attributes." +message: "main.hcl:19,12-21,6:discarded composite-status \nself.resource.status.atProvider.arn, main.hcl:20,32-39: Attempt to get attribute from null value; This value is null, so it does not have any attributes." severity: SEVERITY_WARNING step: "run hcl composition" --- @@ -59,7 +59,7 @@ apiVersion: render.crossplane.io/v1beta1 kind: Result metadata: name: r-1 -message: "warnings: [src/main.hcl:20,32-39: Attempt to get attribute from null value]" +message: "warnings: [main.hcl:20,32-39: Attempt to get attribute from null value]" severity: SEVERITY_WARNING step: "run hcl composition" --- diff --git a/function/example/set-status/composition.yaml b/function/example/set-status/composition.yaml index 3a45e52..cfc7342 100644 --- a/function/example/set-status/composition.yaml +++ b/function/example/set-status/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- resource my-bucket { body = { diff --git a/function/example/spec-example/composition.yaml b/function/example/spec-example/composition.yaml index 6e0e2ea..4586dea 100644 --- a/function/example/spec-example/composition.yaml +++ b/function/example/spec-example/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- resource my-s3-bucket { // self.name will be set to "my-s3-bucket" diff --git a/function/example/user-function/composition.yaml b/function/example/user-function/composition.yaml index 179ed27..e205a78 100644 --- a/function/example/user-function/composition.yaml +++ b/function/example/user-function/composition.yaml @@ -17,7 +17,7 @@ spec: kind: HclInput source: Inline hcl: | - -- src/main.hcl -- + -- main.hcl -- function toProviderK8sObject { arg name { description = "metadata name of the return object" diff --git a/function/internal/composition/api.go b/function/internal/composition/api.go new file mode 100644 index 0000000..fbca26d --- /dev/null +++ b/function/internal/composition/api.go @@ -0,0 +1,68 @@ +// Package composition provides loading, analysis and archive creation of a function-hcl +// composition module. It also processes a composition.yaml metadata file that is optionally +// present in the composition directory. +package composition + +import ( + "io/fs" + + "golang.org/x/tools/txtar" +) + +const ConfigFile = "composition.yaml" + +type XRD struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` +} + +// FS is the minimal filesystem interface needed to load files for a module. +type FS interface { + Stat(name string) (fs.FileInfo, error) + ReadDir(name string) ([]fs.DirEntry, error) + ReadFile(name string) ([]byte, error) +} + +// Config represents the configuration for the composition in terms of library file requirements +// and XRD type information. +type Config struct { + XRD XRD `json:"xrd"` + LibraryFiles []string `json:"libraryFiles"` +} + +// Load returns composition information and a list of files to process from a specific directory. +// File paths in the list are relative to the directory that was loaded. +func Load(fs FS, dir string, ignoreMetadataErrors bool) (*Config, []string, error) { + l := newLoader(fs) + l.ignoreMetadataErrors = ignoreMetadataErrors + return l.load(dir) +} + +// Package combines all HCL files and any additional library files and returns a byte array +// that contains the entire package in txtar format. +func Package(dir string, skipAnalysis bool) ([]byte, error) { + l := newLoader(osFs{}) + archive, files, err := l.loadArchive(dir) + if err != nil { + return nil, err + } + if !skipAnalysis { + if err = doAnalyze(files); err != nil { + return nil, err + } + } + return txtar.Format(archive), nil +} + +// Analyze analyzes all HCL files and any additional library files and returns an error on a failed analysis. +func Analyze(dir string) error { + l := newLoader(osFs{}) + _, files, err := l.loadArchive(dir) + if err != nil { + return err + } + if err = doAnalyze(files); err != nil { + return err + } + return nil +} diff --git a/function/internal/composition/api_test.go b/function/internal/composition/api_test.go new file mode 100644 index 0000000..3758681 --- /dev/null +++ b/function/internal/composition/api_test.go @@ -0,0 +1,59 @@ +package composition + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/tools/txtar" +) + +func TestPackageLib(t *testing.T) { + dir := filepath.Join("testdata", "with-libs") + cfg, files, err := Load(osFs{}, dir, false) + require.NoError(t, err) + assert.Equal(t, "example.com/v1", cfg.XRD.APIVersion) + assert.Equal(t, "FooBar", cfg.XRD.Kind) + require.Equal(t, 1, len(cfg.LibraryFiles)) + require.Equal(t, 2, len(files)) + assert.Contains(t, files, "main.hcl") + assert.Contains(t, files, "lib/bar.hcl") + + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 2) + err = Analyze(dir) + require.NoError(t, err) +} + +func TestPackageNoLib(t *testing.T) { + dir := filepath.Join("testdata", "dir-only") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1) + err = Analyze(dir) + require.NoError(t, err) +} + +func TestLoadMetadataErrorsBadYAML(t *testing.T) { + dir := filepath.Join("testdata", "metadata-errors") + cfg, files, err := Load(osFs{}, dir, true) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, "", cfg.XRD.APIVersion) + require.Equal(t, "", cfg.XRD.Kind) + require.Equal(t, 1, len(files)) +} + +func TestLoadMetadataErrorsBadLibPaths(t *testing.T) { + dir := filepath.Join("testdata", "bad-lib-paths") + cfg, files, err := Load(osFs{}, dir, true) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, "example.com/v1", cfg.XRD.APIVersion) + require.Equal(t, "FooBar", cfg.XRD.Kind) + require.Equal(t, 2, len(files)) +} diff --git a/function/internal/composition/composition.go b/function/internal/composition/composition.go new file mode 100644 index 0000000..802431d --- /dev/null +++ b/function/internal/composition/composition.go @@ -0,0 +1,194 @@ +package composition + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/crossplane-contrib/function-hcl/function/internal/evaluator" + "github.com/ghodss/yaml" + "github.com/hashicorp/hcl/v2" + "github.com/pkg/errors" + "golang.org/x/tools/txtar" +) + +func doAnalyze(files []evaluator.File) error { + logger := log.New(os.Stderr, "", 0) + e, err := evaluator.New(evaluator.Options{}) + if err != nil { + return err + } + diags := e.Analyze(files...) + for _, diag := range diags { + sev := "ERROR:" + if diag.Severity == hcl.DiagWarning { + sev = "WARN :" + } + logger.Println("\t", sev, diag.Error()) + } + if diags.HasErrors() { + return fmt.Errorf("analysis failed") + } + return nil +} + +type loader struct { + fs FS + ignoreMetadataErrors bool +} + +func newLoader(fs FS) *loader { + return &loader{fs: fs} +} + +func (l *loader) load(dir string) (*Config, []string, error) { + dir, err := l.checkDir(dir) + if err != nil { + return nil, nil, err + } + cfg, err := l.loadConfig(dir) + if err != nil { + if !l.ignoreMetadataErrors { + return nil, nil, err + } + log.Printf("WARN: ignore metadata load error: %v", err) + cfg = &Config{} + } + fsFiles, err := l.fileList(dir, cfg) + if err != nil { + return nil, nil, err + } + return cfg, fsFiles, nil +} + +func (l *loader) loadArchive(dir string) (*txtar.Archive, []evaluator.File, error) { + _, fsFiles, err := l.load(dir) + if err != nil { + return nil, nil, err + } + var archive txtar.Archive + var files []evaluator.File + for _, file := range fsFiles { + // since the file list has file relative to the directory loaded + // we need to make it relative to the working directory instead. + contents, err := l.fs.ReadFile(filepath.Join(dir, file)) + if err != nil { + return nil, nil, err + } + archive.Files = append(archive.Files, txtar.File{ + Name: file, + Data: contents, + }) + files = append(files, evaluator.File{ + Name: file, + Content: string(contents), + }) + } + return &archive, files, nil +} + +func (l *loader) checkDir(dir string) (string, error) { + st, err := l.fs.Stat(dir) + if err != nil { + return "", errors.Wrapf(err, "stat %s", dir) + } + if !st.IsDir() { + return "", errors.Errorf("%s is not a directory", dir) + } + return dir, nil +} + +func (l *loader) loadConfig(dir string) (*Config, error) { + var cfg Config + file := filepath.Join(dir, ConfigFile) + st, err := l.fs.Stat(file) + if err != nil { + if os.IsNotExist(err) { + return &cfg, nil + } + return nil, err + } + if st.IsDir() { + return nil, errors.Errorf("%s is a directory", file) + } + b, err := l.fs.ReadFile(file) + if err != nil { + return nil, err + } + err = yaml.Unmarshal(b, &cfg) + if err != nil { + return nil, errors.Wrapf(err, "unmarshal contents of %s", file) + } + return &cfg, nil +} + +func (l *loader) fileList(dir string, cfg *Config) ([]string, error) { + var err error + var files []string + allFiles, err := l.fs.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, entry := range allFiles { + if filepath.Ext(entry.Name()) != ".hcl" { + continue + } + file := filepath.Join(dir, entry.Name()) + s, err := l.fs.Stat(file) + if err != nil { + return nil, errors.Wrapf(err, "stat %s", file) + } + if s.IsDir() { + continue + } + files = append(files, file) + } + + for _, file := range cfg.LibraryFiles { + if filepath.IsAbs(file) { + errMsg := fmt.Sprintf("library file %q is an absolute path, not allowed", file) + if l.ignoreMetadataErrors { + log.Println(errMsg) + continue + } + return nil, errors.New(errMsg) + } + file = filepath.Clean(filepath.Join(dir, file)) + s, err := l.fs.Stat(file) + if err != nil { + errMsg := fmt.Sprintf("stat %s: %v", file, err) + if l.ignoreMetadataErrors { + log.Println(errMsg) + continue + } + return nil, errors.New(errMsg) + } + if s.IsDir() { + errMsg := fmt.Sprintf("library file %s cannot be a directory", file) + if l.ignoreMetadataErrors { + log.Println(errMsg) + continue + } + return nil, errors.New(errMsg) + } + files = append(files, file) + } + + var outFiles []string + seen := map[string]bool{} + + for _, file := range files { + rel, err := filepath.Rel(dir, file) + if err != nil { + return nil, err + } + if seen[rel] { + continue + } + outFiles = append(outFiles, rel) + seen[rel] = true + } + return outFiles, nil +} diff --git a/function/internal/composition/composition_test.go b/function/internal/composition/composition_test.go new file mode 100644 index 0000000..835da2b --- /dev/null +++ b/function/internal/composition/composition_test.go @@ -0,0 +1,330 @@ +package composition + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/tools/txtar" +) + +// validResourceHCL is a minimal valid HCL resource block for use in dynamic test fixtures. +const validResourceHCL = `resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} +` + +// --- Package tests --- + +func TestPackage_NonExistentDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "does-not-exist") + _, err := Package(dir, false) + require.Error(t, err) + require.Contains(t, err.Error(), "does-not-exist") +} + +func TestPackage_FileNotDirectory(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "*.hcl") + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, err = Package(f.Name(), false) + require.Error(t, err) + require.Contains(t, err.Error(), "not a directory") +} + +func TestPackage_EmptyDirectory(t *testing.T) { + dir := t.TempDir() + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Empty(t, archive.Files) +} + +func TestPackage_NonHCLFilesAreExcluded(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.hcl"), []byte(validResourceHCL), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("some text"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("key: value"), 0o644)) + + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1, "only .hcl files should be packaged") +} + +func TestPackage_HCLSubdirectoryIsExcluded(t *testing.T) { + // A subdirectory that happens to match the *.hcl glob (unusual but possible) must be skipped. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.hcl"), []byte(validResourceHCL), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub.hcl"), 0o755)) + + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1, "directory matching *.hcl glob must not be included") +} + +func TestPackage_MultipleHCLFiles(t *testing.T) { + dir := filepath.Join("testdata", "multi-hcl") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 2) +} + +func TestPackage_ArchiveFileNamesAreRelativeToProcessedDir(t *testing.T) { + dir := filepath.Join("testdata", "dir-only") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1) + + assert.Equal(t, "main.hcl", archive.Files[0].Name) +} + +func TestPackage_ArchiveFileContentsMatchDisk(t *testing.T) { + dir := filepath.Join("testdata", "dir-only") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1) + + expected, err := os.ReadFile(filepath.Join(dir, "main.hcl")) + require.NoError(t, err) + assert.Equal(t, string(expected), string(archive.Files[0].Data)) +} + +func TestPackage_WithLibs_ArchiveContainsBothHCLAndLibFiles(t *testing.T) { + dir := filepath.Join("testdata", "with-libs") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 2) + + names := make([]string, len(archive.Files)) + for i, f := range archive.Files { + names[i] = f.Name + } + sort.Strings(names) + + assert.Equal(t, filepath.Join("lib", "bar.hcl"), names[0]) + assert.Equal(t, "main.hcl", names[1]) +} + +func TestPackage_WithLibs_LibFilesAppendedAfterHCLFiles(t *testing.T) { + // Library files are appended after the glob'd HCL files. + dir := filepath.Join("testdata", "with-libs") + b, err := Package(dir, false) + require.NoError(t, err) + archive := txtar.Parse(b) + require.Len(t, archive.Files, 2) + + assert.True(t, strings.HasSuffix(archive.Files[0].Name, "main.hcl"), "first file should be the glob'd main.hcl") + assert.True(t, strings.HasSuffix(archive.Files[1].Name, "bar.hcl"), "second file should be the library bar.hcl") +} + +func TestPackage_MissingLibraryFile(t *testing.T) { + dir := filepath.Join("testdata", "missing-lib") + _, err := Package(dir, false) + require.Error(t, err) +} + +func TestPackage_LibraryFileIsDirectory(t *testing.T) { + dir := filepath.Join("testdata", "dir-as-lib") + _, err := Package(dir, false) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be a directory") +} + +func TestPackage_InvalidCompositionYAML(t *testing.T) { + dir := filepath.Join("testdata", "invalid-yaml-config") + _, err := Package(dir, false) + require.Error(t, err) +} + +func TestPackage_CompositionYAMLIsADirectory(t *testing.T) { + dir := t.TempDir() + // Create a directory named composition.yaml instead of a file. + require.NoError(t, os.Mkdir(filepath.Join(dir, ConfigFile), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.hcl"), []byte(validResourceHCL), 0o644)) + + _, err := Package(dir, false) + require.Error(t, err) + require.Contains(t, err.Error(), "is a directory") +} + +func TestPackage_SkipAnalysis_WithInvalidHCL(t *testing.T) { + // With skipAnalysis=true, packaging succeeds even if HCL is invalid. + dir := filepath.Join("testdata", "invalid-hcl") + b, err := Package(dir, true) + require.NoError(t, err) + + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1) +} + +func TestPackage_WithAnalysis_InvalidHCL(t *testing.T) { + dir := filepath.Join("testdata", "invalid-hcl") + _, err := Package(dir, false) + require.Error(t, err) + require.Equal(t, "analysis failed", err.Error()) +} + +func TestPackage_AbsoluteLibraryPath(t *testing.T) { + // Library files specified with absolute paths should be rejected. + libDir := t.TempDir() + libFile := filepath.Join(libDir, "mylib.hcl") + libContent := `function mylib { + description = "absolute path library" + arg x {} + body = x +} +` + require.NoError(t, os.WriteFile(libFile, []byte(libContent), 0o644)) + + compDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(compDir, "main.hcl"), []byte(validResourceHCL), 0o644)) + + configContent := fmt.Sprintf("libraryFiles:\n - %s\n", libFile) + require.NoError(t, os.WriteFile(filepath.Join(compDir, ConfigFile), []byte(configContent), 0o644)) + + _, err := Package(compDir, true) // skip analysis; lib function isn't used + require.Error(t, err) + assert.Contains(t, err.Error(), "is an absolute path, not allowed") +} + +func TestPackage_RelativeLibraryPath(t *testing.T) { + // Library files specified with relative paths are resolved relative to the composition dir. + compDir := t.TempDir() + libDir := filepath.Join(compDir, "libs") + require.NoError(t, os.Mkdir(libDir, 0o755)) + + libContent := `function helper { + description = "relative path library" + arg v {} + body = v +} +` + require.NoError(t, os.WriteFile(filepath.Join(libDir, "helper.hcl"), []byte(libContent), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(compDir, "main.hcl"), []byte(validResourceHCL), 0o644)) + + configContent := "version: \"1.0\"\nlibraryFiles:\n - libs/helper.hcl\n" + require.NoError(t, os.WriteFile(filepath.Join(compDir, ConfigFile), []byte(configContent), 0o644)) + + b, err := Package(compDir, true) + require.NoError(t, err) + + archive := txtar.Parse(b) + require.Len(t, archive.Files, 2) +} + +// --- Analyze tests --- + +func TestAnalyze_NonExistentDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "does-not-exist") + err := Analyze(dir) + require.Error(t, err) +} + +func TestAnalyze_FileNotDirectory(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "*.hcl") + require.NoError(t, err) + require.NoError(t, f.Close()) + + err = Analyze(f.Name()) + require.Error(t, err) + require.Contains(t, err.Error(), "not a directory") +} + +func TestAnalyze_EmptyDirectory(t *testing.T) { + dir := t.TempDir() + err := Analyze(dir) + require.NoError(t, err) +} + +func TestAnalyze_InvalidHCL(t *testing.T) { + dir := filepath.Join("testdata", "invalid-hcl") + err := Analyze(dir) + require.Error(t, err) + require.Equal(t, "analysis failed", err.Error()) +} + +func TestAnalyze_MissingLibraryFile(t *testing.T) { + dir := filepath.Join("testdata", "missing-lib") + err := Analyze(dir) + require.Error(t, err) +} + +func TestAnalyze_LibraryFileIsDirectory(t *testing.T) { + dir := filepath.Join("testdata", "dir-as-lib") + err := Analyze(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be a directory") +} + +func TestAnalyze_InvalidCompositionYAML(t *testing.T) { + dir := filepath.Join("testdata", "invalid-yaml-config") + err := Analyze(dir) + require.Error(t, err) +} + +func TestAnalyze_ValidSingleFile(t *testing.T) { + dir := filepath.Join("testdata", "dir-only") + err := Analyze(dir) + require.NoError(t, err) +} + +func TestAnalyze_ValidWithLibs(t *testing.T) { + dir := filepath.Join("testdata", "with-libs") + err := Analyze(dir) + require.NoError(t, err) +} + +func TestAnalyze_ValidMultipleFiles(t *testing.T) { + dir := filepath.Join("testdata", "multi-hcl") + err := Analyze(dir) + require.NoError(t, err) +} + +// --- loadConfig tests (exercised via Package/Analyze) --- + +func TestPackage_NoCompositionYAML_UsesEmptyConfig(t *testing.T) { + // When composition.yaml is absent, an empty Config is used (no library files). + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.hcl"), []byte(validResourceHCL), 0o644)) + + b, err := Package(dir, false) + require.NoError(t, err) + + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1, "only the single HCL file should be packaged") +} + +func TestPackage_ConfigXRDFields(t *testing.T) { + // XRD fields in composition.yaml are parsed but don't affect packaging output. + compDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(compDir, "main.hcl"), []byte(validResourceHCL), 0o644)) + + configContent := `version: "1.0" +xrd: + apiVersion: example.io/v1 + kind: XMyResource +` + require.NoError(t, os.WriteFile(filepath.Join(compDir, ConfigFile), []byte(configContent), 0o644)) + + b, err := Package(compDir, false) + require.NoError(t, err) + + archive := txtar.Parse(b) + require.Len(t, archive.Files, 1) +} diff --git a/function/internal/composition/os-fs.go b/function/internal/composition/os-fs.go new file mode 100644 index 0000000..947435d --- /dev/null +++ b/function/internal/composition/os-fs.go @@ -0,0 +1,12 @@ +package composition + +import ( + "io/fs" + "os" +) + +type osFs struct{} + +func (o osFs) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } +func (o osFs) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) } +func (o osFs) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } diff --git a/function/internal/composition/testdata/bad-lib-paths/composition.yaml b/function/internal/composition/testdata/bad-lib-paths/composition.yaml new file mode 100644 index 0000000..ee294f8 --- /dev/null +++ b/function/internal/composition/testdata/bad-lib-paths/composition.yaml @@ -0,0 +1,7 @@ +xrd: + apiVersion: example.com/v1 + kind: FooBar +libraryFiles: + - /tmp/foo.hcl + - lib/non-existent.hcl + - lib/bar.hcl diff --git a/function/internal/composition/testdata/bad-lib-paths/lib/bar.hcl b/function/internal/composition/testdata/bad-lib-paths/lib/bar.hcl new file mode 100644 index 0000000..f33274f --- /dev/null +++ b/function/internal/composition/testdata/bad-lib-paths/lib/bar.hcl @@ -0,0 +1,6 @@ +function bar { + description = "test function" + arg input {} + body = "bar-${input}" +} + diff --git a/function/internal/composition/testdata/bad-lib-paths/main.hcl b/function/internal/composition/testdata/bad-lib-paths/main.hcl new file mode 100644 index 0000000..b18d75d --- /dev/null +++ b/function/internal/composition/testdata/bad-lib-paths/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/dir-as-lib/composition.yaml b/function/internal/composition/testdata/dir-as-lib/composition.yaml new file mode 100644 index 0000000..0373baa --- /dev/null +++ b/function/internal/composition/testdata/dir-as-lib/composition.yaml @@ -0,0 +1,2 @@ +libraryFiles: + - lib diff --git a/function/internal/composition/testdata/dir-as-lib/lib/.gitkeep b/function/internal/composition/testdata/dir-as-lib/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/function/internal/composition/testdata/dir-as-lib/main.hcl b/function/internal/composition/testdata/dir-as-lib/main.hcl new file mode 100644 index 0000000..b18d75d --- /dev/null +++ b/function/internal/composition/testdata/dir-as-lib/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/dir-only/main.hcl b/function/internal/composition/testdata/dir-only/main.hcl new file mode 100644 index 0000000..8203476 --- /dev/null +++ b/function/internal/composition/testdata/dir-only/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/invalid-hcl/main.hcl b/function/internal/composition/testdata/invalid-hcl/main.hcl new file mode 100644 index 0000000..e8e1bd0 --- /dev/null +++ b/function/internal/composition/testdata/invalid-hcl/main.hcl @@ -0,0 +1 @@ +% this is intentionally invalid HCL to trigger a parse error diff --git a/function/internal/composition/testdata/invalid-yaml-config/composition.yaml b/function/internal/composition/testdata/invalid-yaml-config/composition.yaml new file mode 100644 index 0000000..d9f4ae3 --- /dev/null +++ b/function/internal/composition/testdata/invalid-yaml-config/composition.yaml @@ -0,0 +1,2 @@ +: this is not valid yaml: [unclosed bracket + - missing: close diff --git a/function/internal/composition/testdata/invalid-yaml-config/main.hcl b/function/internal/composition/testdata/invalid-yaml-config/main.hcl new file mode 100644 index 0000000..b18d75d --- /dev/null +++ b/function/internal/composition/testdata/invalid-yaml-config/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/metadata-errors/composition.yaml b/function/internal/composition/testdata/metadata-errors/composition.yaml new file mode 100644 index 0000000..413a39e --- /dev/null +++ b/function/internal/composition/testdata/metadata-errors/composition.yaml @@ -0,0 +1,4 @@ +# invalid YAML +xrd: + apiVersion: example.com/v1 + - kind: FooBar diff --git a/function/internal/composition/testdata/metadata-errors/main.hcl b/function/internal/composition/testdata/metadata-errors/main.hcl new file mode 100644 index 0000000..b18d75d --- /dev/null +++ b/function/internal/composition/testdata/metadata-errors/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/missing-lib/composition.yaml b/function/internal/composition/testdata/missing-lib/composition.yaml new file mode 100644 index 0000000..6abfc6b --- /dev/null +++ b/function/internal/composition/testdata/missing-lib/composition.yaml @@ -0,0 +1,2 @@ +libraryFiles: + - lib/does-not-exist.hcl diff --git a/function/internal/composition/testdata/missing-lib/main.hcl b/function/internal/composition/testdata/missing-lib/main.hcl new file mode 100644 index 0000000..b18d75d --- /dev/null +++ b/function/internal/composition/testdata/missing-lib/main.hcl @@ -0,0 +1,7 @@ +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = "bar" } + } +} diff --git a/function/internal/composition/testdata/multi-hcl/a.hcl b/function/internal/composition/testdata/multi-hcl/a.hcl new file mode 100644 index 0000000..0630704 --- /dev/null +++ b/function/internal/composition/testdata/multi-hcl/a.hcl @@ -0,0 +1,7 @@ +resource cm_a { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { key = "value-a" } + } +} diff --git a/function/internal/composition/testdata/multi-hcl/b.hcl b/function/internal/composition/testdata/multi-hcl/b.hcl new file mode 100644 index 0000000..6c07457 --- /dev/null +++ b/function/internal/composition/testdata/multi-hcl/b.hcl @@ -0,0 +1,7 @@ +resource cm_b { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { key = "value-b" } + } +} diff --git a/function/internal/composition/testdata/with-libs/composition.yaml b/function/internal/composition/testdata/with-libs/composition.yaml new file mode 100644 index 0000000..274264c --- /dev/null +++ b/function/internal/composition/testdata/with-libs/composition.yaml @@ -0,0 +1,5 @@ +xrd: + apiVersion: example.com/v1 + kind: FooBar +libraryFiles: + - lib/bar.hcl diff --git a/function/internal/composition/testdata/with-libs/lib/bar.hcl b/function/internal/composition/testdata/with-libs/lib/bar.hcl new file mode 100644 index 0000000..f33274f --- /dev/null +++ b/function/internal/composition/testdata/with-libs/lib/bar.hcl @@ -0,0 +1,6 @@ +function bar { + description = "test function" + arg input {} + body = "bar-${input}" +} + diff --git a/function/internal/composition/testdata/with-libs/main.hcl b/function/internal/composition/testdata/with-libs/main.hcl new file mode 100644 index 0000000..531a40f --- /dev/null +++ b/function/internal/composition/testdata/with-libs/main.hcl @@ -0,0 +1,11 @@ +locals { + foo = invoke("bar", { input = 10 }) +} + +resource cmap { + body = { + apiVersion = "v1" + kind = "ConfigMap" + data = { foo = foo } + } +}