From 40b69fe333ac22c9fb899e9c07f4d117d4ddf83f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 26 Aug 2024 21:55:25 -0400 Subject: [PATCH 1/4] feat: adds blueprint package --- blueprint/README.md | 93 +++++ blueprint/go.mod | 21 ++ blueprint/go.sum | 53 +++ blueprint/internal/testutils/helpers.go | 24 ++ blueprint/main.go | 24 ++ blueprint/pkg/blueprint/blueprint.go | 107 ++++++ blueprint/pkg/blueprint/blueprint_test.go | 169 +++++++++ blueprint/pkg/injector/injector.go | 156 ++++++++ blueprint/pkg/injector/injector_mock.go | 74 ++++ blueprint/pkg/injector/injector_test.go | 261 ++++++++++++++ blueprint/pkg/loader/loader.go | 221 ++++++++++++ blueprint/pkg/loader/loader_test.go | 415 ++++++++++++++++++++++ blueprint/pkg/loader/walker.go | 142 ++++++++ blueprint/pkg/loader/walker_mock.go | 86 +++++ blueprint/pkg/loader/walker_test.go | 224 ++++++++++++ blueprint/pkg/version/version.go | 44 +++ blueprint/pkg/version/version_test.go | 123 +++++++ blueprint/schema/README.md | 18 + blueprint/schema/_embed/schema.cue | 36 ++ blueprint/schema/cue.mod/module.cue | 4 + blueprint/schema/schema.go | 31 ++ blueprint/schema/schema_go_gen.cue | 25 ++ blueprint/schema/schema_overrides.cue | 16 + blueprint/schema/util.go | 39 ++ blueprint/schema/version.cue | 3 + 25 files changed, 2409 insertions(+) create mode 100644 blueprint/README.md create mode 100644 blueprint/go.mod create mode 100644 blueprint/go.sum create mode 100644 blueprint/internal/testutils/helpers.go create mode 100644 blueprint/main.go create mode 100644 blueprint/pkg/blueprint/blueprint.go create mode 100644 blueprint/pkg/blueprint/blueprint_test.go create mode 100644 blueprint/pkg/injector/injector.go create mode 100644 blueprint/pkg/injector/injector_mock.go create mode 100644 blueprint/pkg/injector/injector_test.go create mode 100644 blueprint/pkg/loader/loader.go create mode 100644 blueprint/pkg/loader/loader_test.go create mode 100644 blueprint/pkg/loader/walker.go create mode 100644 blueprint/pkg/loader/walker_mock.go create mode 100644 blueprint/pkg/loader/walker_test.go create mode 100644 blueprint/pkg/version/version.go create mode 100644 blueprint/pkg/version/version_test.go create mode 100644 blueprint/schema/README.md create mode 100644 blueprint/schema/_embed/schema.cue create mode 100644 blueprint/schema/cue.mod/module.cue create mode 100644 blueprint/schema/schema.go create mode 100644 blueprint/schema/schema_go_gen.cue create mode 100644 blueprint/schema/schema_overrides.cue create mode 100644 blueprint/schema/util.go create mode 100644 blueprint/schema/version.cue diff --git a/blueprint/README.md b/blueprint/README.md new file mode 100644 index 00000000..85ffb8fa --- /dev/null +++ b/blueprint/README.md @@ -0,0 +1,93 @@ +# Blueprint + +The `blueprint` package provides the Go API code for loading blueprint files from a given directory. +It provides all necessary functionality to scan, load, and unify one or more blueprint files. +Additionally, the `blueprint` package embeds the full schema for blueprint files. +Every blueprint loaded is automatically validated against the embedded schema. + +## Usage + +### Loading Blueprint Files + +The `BlueprintLoader` can be used to load blueprint files from a given path. +By default, the loader performs the following: + +1. Walks the filesystem searching for `blueprint.cue` files + a. If the path is in a git repository, it walks up to the root of the repository + b. If the path is not in a git repository, it only searches the given path +2. Loads and processes all found blueprint files (including things like injecting environment variables) +3. Unifies all blueprint files into a single blueprint (including handling versions) +4. Validates the final blueprint against the embedded schema + +The loader's `Decode` function can be used to get a `Blueprint` structure that represents the final unified blueprint. +The following is an example that uses the loader to load blueprints: + +```go +package main + +import ( + "log" + + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/loader" +) + +func main() { + loader := loader.NewDefaultBlueprintLoader("/path/to/load", nil) + if err := loader.Load(); err != nil { + log.Fatalf("failed to load blueprint: %v", err) + } + + bp, err := loader.Decode() + if err != nil { + log.Fatalf("failed to decode blueprint: %v", err) + } + + log.Printf("blueprint: %v", bp) +} +``` + +### Blueprint Schema + +The blueprint schema is embedded in the `schema` package and can be loaded using the included function: + +```go +package main + +import ( + "fmt" + "log" + + "cuelang.org/go/cue/cuecontext" + "github.com/input-output-hk/catalyst-forge/blueprint/schema" +) + +func main() { + ctx := cuecontext.New() + schema, err := schema.LoadSchema(ctx) + if err != nil { + log.Fatalf("failed to load schema: %v", err) + } + + fmt.Printf("Schema version: %s\n", schema.Version) + + v := schema.Unify(ctx.CompileString(`{version: "1.0"}`)) + if v.Err() != nil { + log.Fatalf("failed to unify schema: %v", v.Err()) + } +} +``` + +All blueprints must specify the schema version they are using in the top-level `schema` field. +The schema itself carries its version at `schema.Version`. +This value is managed by Catalyst Forge developers and will periodically change as the schema evolves. +The loader will automatically perform version checks to ensure any parsed blueprints are compatible with the embedded schema. + +For more information on the schema, see the [schema README](./schema/README.md). + +## Testing + +Tests can be run with: + +``` +go test ./... +``` diff --git a/blueprint/go.mod b/blueprint/go.mod new file mode 100644 index 00000000..4d8b9c92 --- /dev/null +++ b/blueprint/go.mod @@ -0,0 +1,21 @@ +module github.com/input-output-hk/catalyst-forge/blueprint + +require ( + cuelang.org/go v0.10.0 + github.com/Masterminds/semver/v3 v3.2.1 + github.com/input-output-hk/catalyst-forge/cuetools v0.0.0 + github.com/spf13/afero v1.11.0 +) + +require ( + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/text v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/input-output-hk/catalyst-forge/cuetools => ../cuetools + +go 1.22.3 diff --git a/blueprint/go.sum b/blueprint/go.sum new file mode 100644 index 00000000..7baae16e --- /dev/null +++ b/blueprint/go.sum @@ -0,0 +1,53 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79 h1:EceZITBGET3qHneD5xowSTY/YHbNybvMWGh62K2fG/M= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg= +cuelang.org/go v0.10.0 h1:Y1Pu4wwga5HkXfLFK1sWAYaSWIBdcsr5Cb5AWj2pOuE= +cuelang.org/go v0.10.0/go.mod h1:HzlaqqqInHNiqE6slTP6+UtxT9hN6DAzgJgdbNxXvX8= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4= +github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21/go.mod h1:RMRJLmBOqWacUkmJHRMiPKh1S1m3PA7Zh4W80/kWPpg= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/blueprint/internal/testutils/helpers.go b/blueprint/internal/testutils/helpers.go new file mode 100644 index 00000000..55a58b38 --- /dev/null +++ b/blueprint/internal/testutils/helpers.go @@ -0,0 +1,24 @@ +package testutils + +import ( + "fmt" + "testing" +) + +// CheckError checks if the error is expected or not. If the error is +// unexpected, it returns an error. +// If the function returns true, the test should return immediately. +func CheckError(t *testing.T, err error, expected bool, expectedErr error) (bool, error) { + if expected && err != nil { + if expectedErr != nil && err.Error() != expectedErr.Error() { + return true, fmt.Errorf("got error %v, want error %v", err, expectedErr) + } + return true, nil + } else if !expected && err != nil { + return true, fmt.Errorf("unexpected error: %v", err) + } else if expected && err == nil { + return true, fmt.Errorf("expected error %v, got nil", expectedErr) + } + + return false, nil +} diff --git a/blueprint/main.go b/blueprint/main.go new file mode 100644 index 00000000..e47c936b --- /dev/null +++ b/blueprint/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "log" + + "cuelang.org/go/cue/cuecontext" + "github.com/input-output-hk/catalyst-forge/blueprint/schema" +) + +func main() { + ctx := cuecontext.New() + schema, err := schema.LoadSchema(ctx) + if err != nil { + log.Fatalf("failed to load schema: %v", err) + } + + fmt.Printf("Schema version: %s\n", schema.Version) + + v := schema.Unify(ctx.CompileString(`{version: "1.0"}`)) + if v.Err() != nil { + log.Fatalf("failed to unify schema: %v", v.Err()) + } +} diff --git a/blueprint/pkg/blueprint/blueprint.go b/blueprint/pkg/blueprint/blueprint.go new file mode 100644 index 00000000..539b2188 --- /dev/null +++ b/blueprint/pkg/blueprint/blueprint.go @@ -0,0 +1,107 @@ +package blueprint + +import ( + "fmt" + "sort" + + "cuelang.org/go/cue" + "github.com/Masterminds/semver/v3" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/injector" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/version" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +// BlueprintFile represents a single blueprint file. +type BlueprintFile struct { + Path string + Value cue.Value + Version *semver.Version +} + +// BlueprintFiles represents a collection of blueprint files. +type BlueprintFiles []BlueprintFile + +// Unify unifies the blueprints into a single CUE value. If the unification +// fails, an error is returned. +func (b BlueprintFiles) Unify(ctx *cue.Context) (cue.Value, error) { + v := ctx.CompileString("{}") + for _, bp := range b { + v = v.Unify(bp.Value) + } + + if err := cuetools.Validate(v, cue.Concrete(true)); err != nil { + return cue.Value{}, err + } + + return v, nil +} + +// validateMajors validates the major versions of the blueprints. If the +// blueprints have different major versions, an error is returned. +func (b BlueprintFiles) ValidateMajorVersions() error { + var last *semver.Version + for _, bp := range b { + if last == nil { + last = bp.Version + continue + } + + if bp.Version.Major() != last.Major() { + return fmt.Errorf("blueprints have different major versions") + } + } + + return nil +} + +// Version returns the highest version number from the blueprints. +// If there are no blueprints, nil is returned. +func (b BlueprintFiles) Version() *semver.Version { + if len(b) == 0 { + return nil + } + + var versions []*semver.Version + for _, bp := range b { + versions = append(versions, bp.Version) + } + + sort.Sort(semver.Collection(versions)) + return versions[len(versions)-1] +} + +// NewBlueprintFile creates a new BlueprintFile from the given CUE context, +// path, and contents. The contents are compiled and validated, including +// injecting any necessary environment variables. Additionally, the version is +// extracted from the CUE value. If the version is not found or invalid, or the +// final CUE value is invalid, an error is returned. +func NewBlueprintFile(ctx *cue.Context, path string, contents []byte, inj injector.Injector) (BlueprintFile, error) { + v, err := cuetools.Compile(ctx, contents) + if err != nil { + return BlueprintFile{}, fmt.Errorf("failed to compile CUE file: %w", err) + } + + version, err := version.GetVersion(v) + if err != nil { + return BlueprintFile{}, fmt.Errorf("failed to get version: %w", err) + } + + // Delete the version to avoid conflicts when merging blueprints. + // This is safe as we have already extracted the version. + v, err = cuetools.Delete(ctx, v, "version") + if err != nil { + return BlueprintFile{}, fmt.Errorf("failed to delete version from blueprint file: %w", err) + } + + v = inj.InjectEnv(v) + + if err := cuetools.Validate(v, cue.Concrete(true)); err != nil { + return BlueprintFile{}, err + } + + return BlueprintFile{ + Path: path, + Value: v, + Version: version, + }, nil +} diff --git a/blueprint/pkg/blueprint/blueprint_test.go b/blueprint/pkg/blueprint/blueprint_test.go new file mode 100644 index 00000000..f544da92 --- /dev/null +++ b/blueprint/pkg/blueprint/blueprint_test.go @@ -0,0 +1,169 @@ +package blueprint + +import ( + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/format" + "github.com/Masterminds/semver/v3" + "github.com/input-output-hk/catalyst-forge/blueprint/internal/testutils" +) + +func TestBlueprintFilesUnify(t *testing.T) { + ctx := cuecontext.New() + compile := func(src string) cue.Value { + return ctx.CompileString(src) + } + tests := []struct { + name string + files BlueprintFiles + expect cue.Value + }{ + { + name: "no file", + files: BlueprintFiles{}, + expect: compile("{}"), + }, + { + name: "single file", + files: BlueprintFiles{ + { + Value: compile("{a: 1}"), + }, + }, + expect: compile("{a: 1}"), + }, + { + name: "multiple files", + files: BlueprintFiles{ + { + Value: compile("{a: 1}"), + }, + { + Value: compile("{b: 2}"), + }, + }, + expect: compile("{a: 1, b: 2}"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := cuecontext.New() + v, err := tt.files.Unify(ctx) + if err != nil { + t.Fatalf("failed to unify: %v", err) + } + + expectSrc, err := format.Node(tt.expect.Syntax()) + if err != nil { + t.Fatalf("failed to format expect: %v", err) + } + + gotSrc, err := format.Node(v.Syntax()) + if err != nil { + t.Fatalf("failed to format got: %v", err) + } + + if string(gotSrc) != string(expectSrc) { + t.Errorf("got %s, want %s", gotSrc, expectSrc) + } + }) + } +} + +func TestBlueprintFilesValidateMajorVersions(t *testing.T) { + tests := []struct { + name string + files BlueprintFiles + expectErr bool + }{ + { + name: "same major versions", + files: BlueprintFiles{ + { + Version: semver.MustParse("1.0.0"), + }, + { + Version: semver.MustParse("1.1.0"), + }, + }, + expectErr: false, + }, + { + name: "different major versions", + files: BlueprintFiles{ + { + Version: semver.MustParse("1.0.0"), + }, + { + Version: semver.MustParse("2.0.0"), + }, + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.files.ValidateMajorVersions() + if r, err := testutils.CheckError(t, err, tt.expectErr, nil); r || err != nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +func TestBlueprintFilesVersion(t *testing.T) { + tests := []struct { + name string + files BlueprintFiles + expect *semver.Version + }{ + { + name: "no files", + files: BlueprintFiles{}, + expect: nil, + }, + { + name: "single file", + files: BlueprintFiles{ + { + Version: semver.MustParse("1.0.0"), + }, + }, + expect: semver.MustParse("1.0.0"), + }, + { + name: "multiple files", + files: BlueprintFiles{ + { + Version: semver.MustParse("1.0.0"), + }, + { + Version: semver.MustParse("1.1.0"), + }, + { + Version: semver.MustParse("2.0.0"), + }, + }, + expect: semver.MustParse("2.0.0"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.files.Version() + if got == nil && tt.expect == nil { + return + } else if got == nil || tt.expect == nil { + t.Fatalf("got %v, want %v", got, tt.expect) + } else if !got.Equal(tt.expect) { + t.Errorf("got %v, want %v", got, tt.expect) + } + }) + } +} diff --git a/blueprint/pkg/injector/injector.go b/blueprint/pkg/injector/injector.go new file mode 100644 index 00000000..d217bbd1 --- /dev/null +++ b/blueprint/pkg/injector/injector.go @@ -0,0 +1,156 @@ +package injector + +//go:generate go run github.com/matryer/moq@latest --out ./injector_mock.go . EnvGetter + +import ( + "fmt" + "io" + "log/slog" + "os" + "strconv" + + "cuelang.org/go/cue" +) + +const ( + EnvAttrName = "env" + EnvNameKey = "name" + EnvTypeKey = "type" +) + +// EnvType represents the type of an environment variable +type envType string + +const ( + EnvTypeString envType = "string" + EnvTypeInt envType = "int" + EnvTypeBool envType = "bool" +) + +// envAttr represents a parsed @env() attribute +type envAttr struct { + name string + envType envType +} + +// EnvGetter is an interface to get environment variables +type EnvGetter interface { + Get(key string) (string, bool) +} + +// OSEnvGetter is an implementation of EnvGetter that gets environment variables +// from the OS +type OSEnvGetter struct{} + +func (OSEnvGetter) Get(key string) (string, bool) { + return os.LookupEnv(key) +} + +// Injector is a struct that injects environment variables into a CUE value +type Injector struct { + getter EnvGetter + logger *slog.Logger +} + +// InjectEnv injects environment variables into the given CUE value +func (i *Injector) InjectEnv(v cue.Value) cue.Value { + rv := v + + v.Walk(func(v cue.Value) bool { + attr := findEnvAttr(v) + if attr == nil { + return true + } + + i.logger.Debug("found @env() attribute", "path", v.Path()) + + env, err := parseEnvAttr(attr) + if err != nil { + rv = rv.FillPath(v.Path(), err) + return true + } + + i.logger.Debug("parsed @env() attribute", "name", env.name, "type", env.envType) + + envValue, ok := i.getter.Get(env.name) + if !ok { + i.logger.Warn("environment variable not found", "name", env.name) + return true + } + + switch env.envType { + case EnvTypeString: + rv = rv.FillPath(v.Path(), envValue) + case EnvTypeInt: + n, err := strconv.Atoi(envValue) + if err != nil { + rv = rv.FillPath(v.Path(), fmt.Errorf("invalid int value '%s'", envValue)) + } + rv = rv.FillPath(v.Path(), n) + case EnvTypeBool: + rv = rv.FillPath(v.Path(), true) + default: + rv = rv.FillPath(v.Path(), fmt.Errorf("invalid type '%s', must be one of: string, int, bool", env.envType)) + } + + return true + }, func(v cue.Value) {}) + + return rv +} + +// NewDefaultInjector creates a new Injector with default settings and an +// optional logger. +func NewDefaultInjector(logger *slog.Logger) Injector { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return Injector{ + getter: OSEnvGetter{}, + logger: logger, + } +} + +// NewInjector creates a new Injector +func NewInjector(logger *slog.Logger, getter EnvGetter) Injector { + return Injector{ + getter: getter, + logger: logger, + } +} + +// findEnvAttr finds an @env() attribute in the given CUE value +func findEnvAttr(v cue.Value) *cue.Attribute { + for _, attr := range v.Attributes(cue.FieldAttr) { + if attr.Name() == EnvAttrName { + return &attr + } + } + return nil +} + +// parseEnvAttr parses an @env() attribute +func parseEnvAttr(a *cue.Attribute) (envAttr, error) { + var env envAttr + + nameArg, ok, err := a.Lookup(0, EnvNameKey) + if err != nil { + return env, err + } + if !ok { + return env, fmt.Errorf("missing name key in attribute body '%s'", a.Contents()) + } + env.name = nameArg + + typeArg, ok, err := a.Lookup(0, EnvTypeKey) + if err != nil { + return env, err + } + if !ok { + return env, fmt.Errorf("missing type key in attribute body '%s'", a.Contents()) + } + env.envType = envType(typeArg) + + return env, nil +} diff --git a/blueprint/pkg/injector/injector_mock.go b/blueprint/pkg/injector/injector_mock.go new file mode 100644 index 00000000..4e05b4cc --- /dev/null +++ b/blueprint/pkg/injector/injector_mock.go @@ -0,0 +1,74 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package injector + +import ( + "sync" +) + +// Ensure, that EnvGetterMock does implement EnvGetter. +// If this is not the case, regenerate this file with moq. +var _ EnvGetter = &EnvGetterMock{} + +// EnvGetterMock is a mock implementation of EnvGetter. +// +// func TestSomethingThatUsesEnvGetter(t *testing.T) { +// +// // make and configure a mocked EnvGetter +// mockedEnvGetter := &EnvGetterMock{ +// GetFunc: func(key string) (string, bool) { +// panic("mock out the Get method") +// }, +// } +// +// // use mockedEnvGetter in code that requires EnvGetter +// // and then make assertions. +// +// } +type EnvGetterMock struct { + // GetFunc mocks the Get method. + GetFunc func(key string) (string, bool) + + // calls tracks calls to the methods. + calls struct { + // Get holds details about calls to the Get method. + Get []struct { + // Key is the key argument value. + Key string + } + } + lockGet sync.RWMutex +} + +// Get calls GetFunc. +func (mock *EnvGetterMock) Get(key string) (string, bool) { + if mock.GetFunc == nil { + panic("EnvGetterMock.GetFunc: method is nil but EnvGetter.Get was just called") + } + callInfo := struct { + Key string + }{ + Key: key, + } + mock.lockGet.Lock() + mock.calls.Get = append(mock.calls.Get, callInfo) + mock.lockGet.Unlock() + return mock.GetFunc(key) +} + +// GetCalls gets all the calls that were made to Get. +// Check the length with: +// +// len(mockedEnvGetter.GetCalls()) +func (mock *EnvGetterMock) GetCalls() []struct { + Key string +} { + var calls []struct { + Key string + } + mock.lockGet.RLock() + calls = mock.calls.Get + mock.lockGet.RUnlock() + return calls +} diff --git a/blueprint/pkg/injector/injector_test.go b/blueprint/pkg/injector/injector_test.go new file mode 100644 index 00000000..9aae14cf --- /dev/null +++ b/blueprint/pkg/injector/injector_test.go @@ -0,0 +1,261 @@ +package injector + +import ( + "io" + "log/slog" + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +func TestInjectEnv(t *testing.T) { + tests := []struct { + name string + raw string + env map[string]string + path string + expectedType envType + expectedValue any + expectValid bool + }{ + { + name: "string env", + raw: ` + foo: string | *"test" @env(name=FOO,type=string) + `, + env: map[string]string{ + "FOO": "bar", + }, + path: "foo", + expectedType: EnvTypeString, + expectedValue: "bar", + expectValid: true, + }, + { + name: "int env", + raw: ` + foo: int & >2 @env(name=FOO,type=int) + `, + env: map[string]string{ + "FOO": "3", + }, + path: "foo", + expectedType: EnvTypeInt, + expectedValue: int64(3), + expectValid: true, + }, + { + name: "bool env", + raw: ` + foo: bool @env(name=FOO,type=bool) + `, + env: map[string]string{ + "FOO": "true", + }, + path: "foo", + expectedType: EnvTypeBool, + expectedValue: true, + expectValid: true, + }, + { + name: "bad int", + raw: ` + foo: int @env(name=FOO,type=int) + `, + env: map[string]string{ + "FOO": "foo", + }, + path: "foo", + expectedType: EnvTypeInt, + expectedValue: true, + expectValid: false, + }, + { + name: "mismatched types", + raw: ` + foo: int @env(name=FOO,type=string) + `, + env: map[string]string{ + "FOO": "foo", + }, + path: "foo", + expectedType: EnvTypeString, + expectedValue: true, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := cuetools.Compile(cuecontext.New(), []byte(tt.raw)) + if err != nil { + t.Fatalf("failed to compile CUE: %v", err) + } + + i := Injector{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + getter: &EnvGetterMock{ + GetFunc: func(key string) (string, bool) { + return tt.env[key], true + }, + }, + } + v = i.InjectEnv(v) + + err = v.Validate(cue.Concrete(true)) + if err != nil && tt.expectValid { + t.Fatalf("expected value to be invalid, got %v", err) + } else if err == nil && !tt.expectValid { + t.Fatalf("expected value to be valid, got none") + } else if err != nil && !tt.expectValid { + return + } + + switch tt.expectedType { + case EnvTypeString: + got, err := v.LookupPath(cue.ParsePath(tt.path)).String() + if err != nil { + t.Fatal("expected value to be string") + } + + if got != tt.expectedValue { + t.Errorf("expected value to be %v, got %v", tt.expectedValue, got) + } + case EnvTypeInt: + got, err := v.LookupPath(cue.ParsePath(tt.path)).Int64() + if err != nil { + t.Fatal("expected value to be int") + } + + if got != tt.expectedValue { + t.Fatalf("expected value to be %v, got %v", tt.expectedValue, got) + } + case EnvTypeBool: + got, err := v.LookupPath(cue.ParsePath(tt.path)).Bool() + if err != nil { + t.Fatal("expected value to be bool") + } + + if got != tt.expectedValue { + t.Fatalf("expected value to be %v, got %v", tt.expectedValue, got) + } + } + }) + } +} + +func Test_findEnvAttr(t *testing.T) { + tests := []struct { + name string + raw string + path string + expectedBody string + }{ + { + name: "env attribute found", + raw: ` + foo: string | *"test" @env(name=FOO,type=string) + `, + path: "foo", + expectedBody: `name=FOO,type=string`, + }, + { + name: "env attribute not found", + raw: ` + foo: string | *"test" + `, + path: "", + expectedBody: "", + }, + { + name: "different attribute found", + raw: ` + foo: string | *"test" @bar(name=FOO,type=string) + `, + path: "", + expectedBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := cuetools.Compile(cuecontext.New(), []byte(tt.raw)) + if err != nil { + t.Fatalf("failed to compile CUE: %v", err) + } + + attr := findEnvAttr(v.LookupPath(cue.ParsePath(tt.path))) + if attr == nil && tt.expectedBody != "" { + t.Fatalf("expected to find env attribute") + } else if attr == nil && tt.expectedBody == "" { + return + } + + if got := attr.Contents(); got != tt.expectedBody { + t.Errorf("expected body to be %s, got %s", tt.expectedBody, got) + } + }) + } +} + +func Test_parseEnvAttr(t *testing.T) { + tests := []struct { + name string + raw string + expected envAttr + expectErr bool + }{ + { + name: "env attribute parsed", + raw: ` + foo: string | *"test" @env(name=FOO,type=string) + `, + expected: envAttr{ + name: "FOO", + envType: "string", + }, + expectErr: false, + }, + { + name: "malformed keys", + raw: ` + foo: string | *"test" @env(names=FOO,types=string) + `, + expected: envAttr{}, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := cuetools.Compile(cuecontext.New(), []byte(tt.raw)) + if err != nil { + t.Fatalf("failed to compile CUE: %v", err) + } + + attr := findEnvAttr(v.LookupPath(cue.ParsePath("foo"))) + if attr == nil { + t.Fatalf("expected to find env attribute") + } + + env, err := parseEnvAttr(attr) + if err != nil && tt.expectErr { + return + } else if err != nil && !tt.expectErr { + t.Fatalf("expected no error, got %v", err) + } else if err == nil && tt.expectErr { + t.Fatalf("expected error, got none") + } + + if env.name != tt.expected.name { + t.Errorf("expected name to be %s, got %s", tt.expected.name, env.name) + } + + if env.envType != tt.expected.envType { + t.Errorf("expected envType to be %s, got %s", tt.expected.envType, env.envType) + } + }) + } +} diff --git a/blueprint/pkg/loader/loader.go b/blueprint/pkg/loader/loader.go new file mode 100644 index 00000000..c37ab2ef --- /dev/null +++ b/blueprint/pkg/loader/loader.go @@ -0,0 +1,221 @@ +package loader + +import ( + "fmt" + "io" + "log/slog" + "path/filepath" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + "github.com/Masterminds/semver/v3" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/blueprint" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/injector" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/version" + "github.com/input-output-hk/catalyst-forge/blueprint/schema" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +const BlueprintFileName = "blueprint.cue" + +var ( + ErrGitRootNotFound = errors.New("git root not found") + ErrVersionNotFound = errors.New("version not found") +) + +type BlueprintLoader struct { + blueprint cue.Value + injector injector.Injector + logger *slog.Logger + rootPath string + walker ReverseWalker +} + +func (b *BlueprintLoader) Load() error { + b.logger.Info("Searching for git root", "rootPath", b.rootPath) + gitRoot, err := b.findGitRoot(b.rootPath) + if err != nil && !errors.Is(err, ErrGitRootNotFound) { + b.logger.Error("Failed to find git root", "error", err) + return fmt.Errorf("failed to find git root: %w", err) + } + + var files map[string][]byte + if errors.Is(err, ErrGitRootNotFound) { + b.logger.Warn("Git root not found, searching for blueprint files in root path", "rootPath", b.rootPath) + files, err = b.findBlueprints(b.rootPath, b.rootPath) + if err != nil { + b.logger.Error("Failed to find blueprint files", "error", err) + return fmt.Errorf("failed to find blueprint files: %w", err) + } + } else { + b.logger.Info("Git root found, searching for blueprint files up to git root", "gitRoot", gitRoot) + files, err = b.findBlueprints(b.rootPath, gitRoot) + if err != nil { + b.logger.Error("Failed to find blueprint files", "error", err) + return fmt.Errorf("failed to find blueprint files: %w", err) + } + } + + ctx := cuecontext.New() + schema, err := schema.LoadSchema(ctx) + if err != nil { + b.logger.Error("Failed to load schema", "error", err) + return fmt.Errorf("failed to load schema: %w", err) + } + + var finalBlueprint cue.Value + var finalVersion *semver.Version + var bps blueprint.BlueprintFiles + if len(files) > 0 { + for path, data := range files { + b.logger.Info("Loading blueprint file", "path", path) + bp, err := blueprint.NewBlueprintFile(ctx, path, data, b.injector) + if err != nil { + b.logger.Error("Failed to load blueprint file", "path", path, "error", err) + return fmt.Errorf("failed to load blueprint file: %w", err) + } + + bps = append(bps, bp) + } + + if err := bps.ValidateMajorVersions(); err != nil { + b.logger.Error("Major version mismatch") + return err + } + + userBlueprint, err := bps.Unify(ctx) + if err != nil { + b.logger.Error("Failed to unify blueprint files", "error", err) + return fmt.Errorf("failed to unify blueprint files: %w", err) + } + + finalVersion = bps.Version() + userBlueprint = userBlueprint.FillPath(cue.ParsePath("version"), finalVersion.String()) + finalBlueprint = schema.Unify(userBlueprint) + } else { + b.logger.Warn("No blueprint files found, using default values") + finalVersion = schema.Version + finalBlueprint = schema.Value.FillPath(cue.ParsePath("version"), finalVersion) + } + + if err := cuetools.Validate(finalBlueprint, cue.Concrete(true)); err != nil { + b.logger.Error("Failed to validate full blueprint", "error", err) + return err + } + + if err := version.ValidateVersions(finalVersion, schema.Version); err != nil { + if errors.Is(err, version.ErrMinorMismatch) { + b.logger.Warn("The minor version of the blueprint is greater than the supported version", "version", finalVersion) + } else { + b.logger.Error("The major version of the blueprint is greater than the supported version", "version", finalVersion) + return fmt.Errorf("the major version of the blueprint (%s) is different than the supported version: cannot continue", finalVersion.String()) + } + } + + b.blueprint = finalBlueprint + return nil +} + +func (b *BlueprintLoader) Decode() (schema.Blueprint, error) { + var cfg schema.Blueprint + if err := b.blueprint.Decode(&cfg); err != nil { + return schema.Blueprint{}, err + } + + return cfg, nil +} + +// findBlueprints searches for blueprint files starting from the startPath and +// ending at the endPath. It returns a map of blueprint file paths to their +// contents or an error if the search fails. +func (b *BlueprintLoader) findBlueprints(startPath, endPath string) (map[string][]byte, error) { + bps := make(map[string][]byte) + + err := b.walker.Walk(startPath, endPath, func(path string, fileType FileType, openFile func() (FileSeeker, error)) error { + if fileType == FileTypeFile { + if filepath.Base(path) == BlueprintFileName { + reader, err := openFile() + if err != nil { + return err + } + + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return err + } + + bps[path] = data + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return bps, nil +} + +// findGitRoot finds the root of a Git repository starting from the given +// path. It returns the path to the root of the Git repository or an error if +// the root is not found. +func (b *BlueprintLoader) findGitRoot(startPath string) (string, error) { + var gitRoot string + err := b.walker.Walk(startPath, "/", func(path string, fileType FileType, openFile func() (FileSeeker, error)) error { + if fileType == FileTypeDir { + if filepath.Base(path) == ".git" { + gitRoot = filepath.Dir(path) + return io.EOF + } + } + + return nil + }) + + if err != nil { + return "", err + } + + if gitRoot == "" { + return "", ErrGitRootNotFound + } + + return gitRoot, nil +} + +// NewDefaultBlueprintLoader creates a new blueprint loader with default +// settings and an optional logger. +func NewDefaultBlueprintLoader(rootPath string, + logger *slog.Logger, +) BlueprintLoader { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + walker := NewDefaultFSReverseWalker(logger) + return BlueprintLoader{ + injector: injector.NewDefaultInjector(logger), + logger: logger, + rootPath: rootPath, + walker: &walker, + } +} + +// NewBlueprintLoader creates a new blueprint loader +func NewBlueprintLoader(rootPath string, + logger *slog.Logger, + walker ReverseWalker, + injector injector.Injector, +) BlueprintLoader { + return BlueprintLoader{ + injector: injector, + logger: logger, + rootPath: rootPath, + walker: walker, + } +} diff --git a/blueprint/pkg/loader/loader_test.go b/blueprint/pkg/loader/loader_test.go new file mode 100644 index 00000000..da1bf3a9 --- /dev/null +++ b/blueprint/pkg/loader/loader_test.go @@ -0,0 +1,415 @@ +package loader + +import ( + "errors" + "io" + "io/fs" + "log/slog" + "path/filepath" + "slices" + "strings" + "testing" + + "cuelang.org/go/cue" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/injector" +) + +type fieldTest struct { + fieldPath string + fieldType string + fieldValue any +} + +// MockFileSeeker is a mock implementation of the FileSeeker interface. +type MockFileSeeker struct { + *strings.Reader +} + +func (MockFileSeeker) Stat() (fs.FileInfo, error) { + return MockFileInfo{}, nil +} + +func (MockFileSeeker) Close() error { + return nil +} + +// MockFileInfo is a mock implementation of the fs.FileInfo interface. +type MockFileInfo struct { + fs.FileInfo +} + +func (MockFileInfo) Name() string { + return "Earthfile" +} + +// NewMockFileSeeker creates a new MockFileSeeker with the given content. +func NewMockFileSeeker(s string) MockFileSeeker { + return MockFileSeeker{strings.NewReader(s)} +} + +func TestBlueprintLoaderLoad(t *testing.T) { + tests := []struct { + name string + root string + files map[string]string + want []fieldTest + wantErr bool + }{ + { + name: "no files", + root: "/tmp/dir1/dir2", + files: map[string]string{}, + want: []fieldTest{ + { + fieldPath: "version", + fieldType: "string", + fieldValue: "1.0.0", // TODO: This may change + }, + }, + }, + { + name: "single file", + root: "/tmp/dir1/dir2", + files: map[string]string{ + "/tmp/dir1/dir2/blueprint.cue": ` + version: "1.0" + targets: { + test: { + privileged: true + } + } + `, + "/tmp/dir1/.git": "", + }, + want: []fieldTest{ + { + fieldPath: "targets.test.privileged", + fieldType: "bool", + fieldValue: true, + }, + }, + }, + { + name: "multiple files", + root: "/tmp/dir1/dir2", + files: map[string]string{ + "/tmp/dir1/dir2/blueprint.cue": ` + version: "1.0" + targets: { + test: { + privileged: true + } + } + `, + "/tmp/dir1/blueprint.cue": ` + version: "1.1" + targets: { + test: { + retries: 3 + } + } + `, + "/tmp/dir1/.git": "", + }, + want: []fieldTest{ + { + fieldPath: "version", + fieldType: "string", + fieldValue: "1.1.0", + }, + { + fieldPath: "targets.test.privileged", + fieldType: "bool", + fieldValue: true, + }, + { + fieldPath: "targets.test.retries", + fieldType: "int", + fieldValue: int64(3), + }, + }, + }, + { + name: "multiple files, no git root", + root: "/tmp/dir1/dir2", + files: map[string]string{ + "/tmp/dir1/dir2/blueprint.cue": ` + version: "1.0" + targets: { + test: { + privileged: true + } + } + `, + "/tmp/dir1/blueprint.cue": ` + targets: { + test: { + retries: 3 + } + } + `, + }, + want: []fieldTest{ + { + fieldPath: "targets.test.privileged", + fieldType: "bool", + fieldValue: true, + }, + { + fieldPath: "targets.test.retries", + fieldType: "int", + fieldValue: int64(0), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + walker := &ReverseWalkerMock{ + WalkFunc: func(startPath string, endPath string, callback WalkerCallback) error { + // True when there is no git root, so we simulate only searching for blueprint files in the root path. + if startPath == endPath && len(tt.files) > 0 { + err := callback(filepath.Join(tt.root, "blueprint.cue"), FileTypeFile, func() (FileSeeker, error) { + return NewMockFileSeeker(tt.files[filepath.Join(tt.root, "blueprint.cue")]), nil + }) + + if err != nil { + return err + } + + return nil + } else if startPath == endPath && len(tt.files) == 0 { + return nil + } + + for path, content := range tt.files { + var err error + if content == "" { + err = callback(path, FileTypeDir, func() (FileSeeker, error) { + return nil, nil + }) + } else { + err = callback(path, FileTypeFile, func() (FileSeeker, error) { + return NewMockFileSeeker(content), nil + }) + } + + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + } + + return nil + }, + } + + loader := BlueprintLoader{ + injector: injector.NewInjector( + slog.New(slog.NewTextHandler(io.Discard, nil)), + &injector.EnvGetterMock{ + GetFunc: func(name string) (string, bool) { + return "", false + }, + }, + ), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + rootPath: tt.root, + walker: walker, + } + + err := loader.Load() + if (err != nil) != tt.wantErr { + t.Errorf("got error %v, want error %v", err, tt.wantErr) + return + } + + for _, test := range tt.want { + value := loader.blueprint.LookupPath(cue.ParsePath(test.fieldPath)) + if value.Err() != nil { + t.Fatalf("failed to lookup field %s: %v", test.fieldPath, value.Err()) + } + + switch test.fieldType { + case "bool": + b, err := value.Bool() + if err != nil { + t.Fatalf("failed to get bool value: %v", err) + } + if b != test.fieldValue.(bool) { + t.Errorf("for %v - got %v, want %v", test.fieldPath, b, test.fieldValue) + } + case "int": + i, err := value.Int64() + if err != nil { + t.Fatalf("failed to get int value: %v", err) + } + if i != test.fieldValue.(int64) { + t.Errorf("for %v - got %v, want %v", test.fieldPath, i, test.fieldValue) + } + case "string": + s, err := value.String() + if err != nil { + t.Fatalf("failed to get string value: %v", err) + } + if s != test.fieldValue.(string) { + t.Errorf("for %v - got %v, want %v", test.fieldPath, s, test.fieldValue) + } + } + } + }) + } +} + +func TestBlueprintLoader_findBlueprints(t *testing.T) { + tests := []struct { + name string + files map[string]string + walkErr error + want map[string][]byte + wantErr bool + }{ + { + name: "simple", + files: map[string]string{ + "/tmp/test1/test2/blueprint.cue": "test1", + "/tmp/test1/foo.bar": "foobar", + "/tmp/test1/blueprint.cue": "test2", + "/tmp/blueprint.cue": "test3", + }, + want: map[string][]byte{ + "/tmp/test1/test2/blueprint.cue": []byte("test1"), + "/tmp/test1/blueprint.cue": []byte("test2"), + "/tmp/blueprint.cue": []byte("test3"), + }, + }, + { + name: "no files", + files: map[string]string{ + "/tmp/test1/foo.bar": "foobar", + }, + want: map[string][]byte{}, + wantErr: false, + }, + { + name: "error", + files: map[string]string{ + "/tmp/test1/foo.bar": "foobar", + }, + walkErr: errors.New("error"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + walker := &ReverseWalkerMock{ + WalkFunc: func(startPath string, endPath string, callback WalkerCallback) error { + for path, content := range tt.files { + err := callback(path, FileTypeFile, func() (FileSeeker, error) { + return NewMockFileSeeker(content), nil + }) + + if err != nil { + return err + } + } + return tt.walkErr + }, + } + + loader := BlueprintLoader{ + walker: walker, + } + got, err := loader.findBlueprints("/tmp", "/tmp") + if (err != nil) != tt.wantErr { + t.Errorf("got error %v, want error %v", err, tt.wantErr) + return + } + + for k, v := range got { + if _, ok := tt.want[k]; !ok { + t.Errorf("got unexpected key %v", k) + } + + if !slices.Equal(v, tt.want[k]) { + t.Errorf("got %s, want %s", string(v), string(tt.want[k])) + } + } + }) + } +} + +func TestBlueprintLoader_findGitRoot(t *testing.T) { + tests := []struct { + name string + start string + dirs []string + want string + wantErr bool + }{ + { + name: "simple", + start: "/tmp/test1/test2/test3", + dirs: []string{ + "/tmp/test1/test2", + "/tmp/test1", + "/tmp/.git", + "/", + }, + want: "/tmp", + wantErr: false, + }, + { + name: "no git root", + start: "/tmp/test1/test2/test3", + dirs: []string{ + "/tmp/test1/test2", + "/tmp/test1", + "/", + }, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lastPath string + walker := &ReverseWalkerMock{ + WalkFunc: func(startPath string, endPath string, callback WalkerCallback) error { + for _, dir := range tt.dirs { + err := callback(dir, FileTypeDir, func() (FileSeeker, error) { + return nil, nil + }) + + if errors.Is(err, io.EOF) { + lastPath = dir + return nil + } else if err != nil { + return err + } + } + return nil + }, + } + + loader := BlueprintLoader{ + walker: walker, + } + got, err := loader.findGitRoot(tt.start) + if (err != nil) != tt.wantErr { + t.Errorf("got error %v, want error %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + if err == nil && lastPath != filepath.Join(tt.want, ".git") { + t.Errorf("got last path %v, want %v", lastPath, tt.want) + } + }) + } +} diff --git a/blueprint/pkg/loader/walker.go b/blueprint/pkg/loader/walker.go new file mode 100644 index 00000000..557bdaab --- /dev/null +++ b/blueprint/pkg/loader/walker.go @@ -0,0 +1,142 @@ +package loader + +//go:generate go run github.com/matryer/moq@latest -out ./walker_mock.go . ReverseWalker + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "path/filepath" + "strings" + + "github.com/spf13/afero" +) + +// FileType is an enum that represents the type of a file. +type FileType int + +const ( + FileTypeFile FileType = iota + FileTypeDir +) + +// FileSeeker is an interface that combines the fs.File and io.Seeker interfaces. +type FileSeeker interface { + fs.File + io.Seeker +} + +// WalkerCallback is a callback function that is called for each file in the +// Walk function. +type WalkerCallback func(string, FileType, func() (FileSeeker, error)) error + +// ReverseWalker is an interface that allows reverse walking over a set of +// files. +// The start path is the path where the walk starts and the end path is the path +// where the walk ends. +type ReverseWalker interface { + // Walk performs a reverse walk from the end path to the start path and + // calls the given function for each file. + Walk(startPath, endPath string, callback WalkerCallback) error +} + +// FSReverseWalker is a ReverseWalker that walks over the local filesystem. +type FSReverseWalker struct { + fs afero.Fs + logger *slog.Logger +} + +// Walk performs a reverse walk over the files and directories from the start +// path to the end path and calls the given function for each entry. +func (w *FSReverseWalker) Walk(startPath, endPath string, callback WalkerCallback) error { + currentDir, err := filepath.Abs(startPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + endDir, err := filepath.Abs(endPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + if !strings.HasPrefix(currentDir, endDir) { + return fmt.Errorf("start path is not a subdirectory of end path") + } + + for { + w.logger.Debug("reverse walking path", "path", currentDir) + files, err := afero.ReadDir(w.fs, currentDir) + + if err != nil { + w.logger.Error("error reading directory", "path", currentDir, "error", err) + return fmt.Errorf("failed to read directory: %w", err) + } + + for _, file := range files { + path := filepath.Join(currentDir, file.Name()) + + if file.IsDir() { + err := callback(path, FileTypeDir, func() (FileSeeker, error) { + return nil, nil + }) + + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + } else if file.Mode().IsRegular() { + err := callback(path, FileTypeFile, func() (FileSeeker, error) { + handle, err := w.fs.Open(path) + + if err != nil { + w.logger.Error("error opening file", "path", path, "error", err) + return nil, fmt.Errorf("failed to open file: %w", err) + } + + return handle, nil + }) + + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + } + } + + if currentDir == endDir { + return nil + } else { + currentDir = filepath.Dir(currentDir) + } + } +} + +// NewFSReverseWalker creates a new FSReverseWalker with default +// settings and an optional logger. +func NewDefaultFSReverseWalker(logger *slog.Logger) FSReverseWalker { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return FSReverseWalker{ + fs: afero.NewOsFs(), + logger: logger, + } +} + +// NewFSReverseWalker creates a new FSReverseWalker with an +// optional logger. +func NewFSReverseWalker(logger *slog.Logger, fs afero.Fs) FSReverseWalker { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return FSReverseWalker{ + fs: fs, + logger: logger, + } +} diff --git a/blueprint/pkg/loader/walker_mock.go b/blueprint/pkg/loader/walker_mock.go new file mode 100644 index 00000000..0f49282b --- /dev/null +++ b/blueprint/pkg/loader/walker_mock.go @@ -0,0 +1,86 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package loader + +import ( + "sync" +) + +// Ensure, that ReverseWalkerMock does implement ReverseWalker. +// If this is not the case, regenerate this file with moq. +var _ ReverseWalker = &ReverseWalkerMock{} + +// ReverseWalkerMock is a mock implementation of ReverseWalker. +// +// func TestSomethingThatUsesReverseWalker(t *testing.T) { +// +// // make and configure a mocked ReverseWalker +// mockedReverseWalker := &ReverseWalkerMock{ +// WalkFunc: func(startPath string, endPath string, callback WalkerCallback) error { +// panic("mock out the Walk method") +// }, +// } +// +// // use mockedReverseWalker in code that requires ReverseWalker +// // and then make assertions. +// +// } +type ReverseWalkerMock struct { + // WalkFunc mocks the Walk method. + WalkFunc func(startPath string, endPath string, callback WalkerCallback) error + + // calls tracks calls to the methods. + calls struct { + // Walk holds details about calls to the Walk method. + Walk []struct { + // StartPath is the startPath argument value. + StartPath string + // EndPath is the endPath argument value. + EndPath string + // Callback is the callback argument value. + Callback WalkerCallback + } + } + lockWalk sync.RWMutex +} + +// Walk calls WalkFunc. +func (mock *ReverseWalkerMock) Walk(startPath string, endPath string, callback WalkerCallback) error { + if mock.WalkFunc == nil { + panic("ReverseWalkerMock.WalkFunc: method is nil but ReverseWalker.Walk was just called") + } + callInfo := struct { + StartPath string + EndPath string + Callback WalkerCallback + }{ + StartPath: startPath, + EndPath: endPath, + Callback: callback, + } + mock.lockWalk.Lock() + mock.calls.Walk = append(mock.calls.Walk, callInfo) + mock.lockWalk.Unlock() + return mock.WalkFunc(startPath, endPath, callback) +} + +// WalkCalls gets all the calls that were made to Walk. +// Check the length with: +// +// len(mockedReverseWalker.WalkCalls()) +func (mock *ReverseWalkerMock) WalkCalls() []struct { + StartPath string + EndPath string + Callback WalkerCallback +} { + var calls []struct { + StartPath string + EndPath string + Callback WalkerCallback + } + mock.lockWalk.RLock() + calls = mock.calls.Walk + mock.lockWalk.RUnlock() + return calls +} diff --git a/blueprint/pkg/loader/walker_test.go b/blueprint/pkg/loader/walker_test.go new file mode 100644 index 00000000..e3d8c092 --- /dev/null +++ b/blueprint/pkg/loader/walker_test.go @@ -0,0 +1,224 @@ +package loader + +import ( + "fmt" + "io" + "log/slog" + "maps" + "path/filepath" + "testing" + + "github.com/input-output-hk/catalyst-forge/blueprint/internal/testutils" + "github.com/spf13/afero" +) + +type wrapfs struct { + afero.Fs + + attempts int + failAfter int + trigger error +} + +func (w *wrapfs) Open(name string) (afero.File, error) { + w.attempts++ + if w.attempts == w.failAfter { + return nil, w.trigger + } + return afero.Fs.Open(w.Fs, name) +} + +func TestFSReverseWalkerWalk(t *testing.T) { + tests := []struct { + name string + fs afero.Fs + callbackErr error + startPath string + endPath string + files map[string]string + expectedFiles map[string]string + expectErr bool + expectedErr error + }{ + { + name: "single directory", + fs: afero.NewMemMapFs(), + callbackErr: nil, + startPath: "/test1", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + "/test1/file2": "file2", + }, + expectedFiles: map[string]string{ + "/test1/file1": "file1", + "/test1/file2": "file2", + }, + expectErr: false, + expectedErr: nil, + }, + { + name: "multiple directories", + fs: afero.NewMemMapFs(), + callbackErr: nil, + startPath: "/test1/test2", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + "/test1/file2": "file2", + "/test1/test2/file3": "file3", + }, + expectedFiles: map[string]string{ + "/test1/file1": "file1", + "/test1/file2": "file2", + "/test1/test2/file3": "file3", + }, + expectErr: false, + expectedErr: nil, + }, + { + name: "multiple scoped directories", + fs: afero.NewMemMapFs(), + callbackErr: nil, + startPath: "/test1/test2", + endPath: "/", + files: map[string]string{ + "/file0": "file0", + "/test0/file0": "file0", + "/test1/file1": "file1", + "/test1/file2": "file2", + "/test1/test2/file3": "file3", + "/test1/test3/file4": "file4", + }, + expectedFiles: map[string]string{ + "/file0": "file0", + "/test1/file1": "file1", + "/test1/file2": "file2", + "/test1/test2/file3": "file3", + }, + expectErr: false, + expectedErr: nil, + }, + { + name: "error reading directory", + fs: &wrapfs{Fs: afero.NewMemMapFs(), failAfter: 1, trigger: fmt.Errorf("failed")}, + callbackErr: nil, + startPath: "/test1", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + }, + expectedFiles: map[string]string{}, + expectErr: true, + expectedErr: fmt.Errorf("failed to read directory: failed"), + }, + { + name: "error reading file", + fs: &wrapfs{Fs: afero.NewMemMapFs(), failAfter: 2, trigger: fmt.Errorf("failed")}, + callbackErr: nil, + startPath: "/test1", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + }, + expectedFiles: map[string]string{}, + expectErr: true, + expectedErr: fmt.Errorf("failed to open file: failed"), + }, + { + name: "callback error", + fs: afero.NewMemMapFs(), + callbackErr: fmt.Errorf("callback error"), + startPath: "/test1", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + }, + expectedFiles: map[string]string{}, + expectErr: true, + expectedErr: fmt.Errorf("callback error"), + }, + { + name: "callback EOF", + fs: afero.NewMemMapFs(), + callbackErr: io.EOF, + startPath: "/test1", + endPath: "/test1", + files: map[string]string{ + "/test1/file1": "file1", + }, + expectedFiles: map[string]string{}, + expectErr: false, + expectedErr: nil, + }, + { + name: "start path is not a subdirectory of end path", + fs: afero.NewMemMapFs(), + callbackErr: nil, + startPath: "/test1", + endPath: "/test2", + files: map[string]string{ + "/test1/file1": "file1", + }, + expectedFiles: map[string]string{}, + expectErr: true, + expectedErr: fmt.Errorf("start path is not a subdirectory of end path"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + walker := FSReverseWalker{ + fs: tt.fs, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + for path, content := range tt.files { + dir := filepath.Dir(path) + if err := tt.fs.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create directory %s: %v", dir, err) + } + + if err := afero.WriteFile(tt.fs, path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write file %s: %v", path, err) + } + } + + callbackFiles := make(map[string]string) + err := walker.Walk(tt.startPath, tt.endPath, func(path string, fileType FileType, openFile func() (FileSeeker, error)) error { + if tt.callbackErr != nil { + return tt.callbackErr + } + + if fileType == FileTypeDir { + return nil + } + + file, err := openFile() + if err != nil { + return err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return err + } + + callbackFiles[path] = string(content) + return nil + }) + + ret, err := testutils.CheckError(t, err, tt.expectErr, tt.expectedErr) + if err != nil { + t.Fatal(err) + } else if ret { + return + } + + if !maps.Equal(callbackFiles, tt.expectedFiles) { + t.Fatalf("expected: %v, got: %v", tt.expectedFiles, callbackFiles) + } + }) + } +} diff --git a/blueprint/pkg/version/version.go b/blueprint/pkg/version/version.go new file mode 100644 index 00000000..c736e43f --- /dev/null +++ b/blueprint/pkg/version/version.go @@ -0,0 +1,44 @@ +package version + +import ( + "errors" + "fmt" + + "cuelang.org/go/cue" + "github.com/Masterminds/semver/v3" +) + +var ( + ErrMajorMismatch = errors.New("major version mismatch") + ErrMinorMismatch = errors.New("minor version mismatch") +) + +// getVersion extracts the version from the given CUE value. If the version is +// not found or invalid, an error is returned. +func GetVersion(v cue.Value) (*semver.Version, error) { + cueVersion := v.LookupPath(cue.ParsePath("version")) + if !cueVersion.Exists() || !cueVersion.IsConcrete() { + return nil, fmt.Errorf("version not found") + } + + strVersion, err := cueVersion.String() + if err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + + return semver.NewVersion(strVersion) +} + +// validateVersion validates the version of the given blueprint against the +// schema. If the blueprint major version is greater than the schema major +// version, an error is returned. If the blueprint minor version is greater than +// the schema minor version, an error is returned. +func ValidateVersions(blueprintVersion *semver.Version, schemaVersion *semver.Version) error { + if blueprintVersion.Major() != schemaVersion.Major() { + return ErrMajorMismatch + } else if blueprintVersion.Minor() > schemaVersion.Minor() { + return ErrMinorMismatch + } + + return nil +} diff --git a/blueprint/pkg/version/version_test.go b/blueprint/pkg/version/version_test.go new file mode 100644 index 00000000..dcff8395 --- /dev/null +++ b/blueprint/pkg/version/version_test.go @@ -0,0 +1,123 @@ +package version + +import ( + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/Masterminds/semver/v3" + "github.com/input-output-hk/catalyst-forge/blueprint/internal/testutils" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +func TestGetVersion(t *testing.T) { + tests := []struct { + name string + input string + expect string + expectErr bool + }{ + { + name: "version present", + input: ` + version: "1.0" + `, + expect: "1.0.0", + expectErr: false, + }, + { + name: "version not present", + input: ` + foo: "bar" + `, + expect: "", + expectErr: true, + }, + { + name: "version invalid", + input: ` + version: "foobar" + `, + expect: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := cuetools.Compile(cuecontext.New(), []byte(tt.input)) + if err != nil { + t.Fatalf("failed to compile cue: %v", err) + } + + got, err := GetVersion(v) + if r, err := testutils.CheckError(t, err, tt.expectErr, nil); r || err != nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + + if got.String() != tt.expect { + t.Errorf("got %v, want %v", got, tt.expect) + } + }) + } +} + +func TestValidateVersions(t *testing.T) { + tests := []struct { + name string + user *semver.Version + schema *semver.Version + expectErr bool + expectedErr error + }{ + { + name: "blueprint major version greater than schema", + user: semver.MustParse("2.0.0"), + schema: semver.MustParse("1.0.0"), + expectErr: true, + expectedErr: ErrMajorMismatch, + }, + { + name: "blueprint minor version greater than schema", + user: semver.MustParse("1.1.0"), + schema: semver.MustParse("1.0.0"), + expectErr: true, + expectedErr: ErrMinorMismatch, + }, + { + name: "blueprint major version equal to schema", + user: semver.MustParse("1.0.0"), + schema: semver.MustParse("1.0.0"), + expectErr: false, + expectedErr: nil, + }, + { + name: "schema major greater than blueprint", + user: semver.MustParse("1.0.0"), + schema: semver.MustParse("2.0.0"), + expectErr: true, + expectedErr: ErrMajorMismatch, + }, + { + name: "schema minor greater than blueprint", + user: semver.MustParse("1.0.0"), + schema: semver.MustParse("1.1.0"), + expectErr: false, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateVersions(tt.user, tt.schema) + if r, err := testutils.CheckError(t, err, tt.expectErr, tt.expectedErr); r || err != nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + }) + } +} diff --git a/blueprint/schema/README.md b/blueprint/schema/README.md new file mode 100644 index 00000000..c5d8913a --- /dev/null +++ b/blueprint/schema/README.md @@ -0,0 +1,18 @@ +# Blueprint Schema + +This directory contains the schema for the blueprint file. +The schema is created from a combination of generated and static code. +The "base" schema is defined as a series of Go structures located in [schema.go](./schema.go). +Schema properties that cannot be expressed via Go structure tags are contained in [schema_overrides.cue](./schema_overrides.cue). + +If you're looking for an authoritative source for the schema, see [_embed/schema.cue](./_embed/schema.cue). + +## Generation + +Generating the schema can be accomplished by running `go generate`. +This causes the following: + +1. The Go structures will have their respective CUE definitions generated in `schema_go_gen.cue` +2. All CUE files (including the generated file from the previous step) are consolidated to a single file in `_embed/schema.cue` + +The final generated file is embedded into the `SchemaFile` package variable. \ No newline at end of file diff --git a/blueprint/schema/_embed/schema.cue b/blueprint/schema/_embed/schema.cue new file mode 100644 index 00000000..2a79a64b --- /dev/null +++ b/blueprint/schema/_embed/schema.cue @@ -0,0 +1,36 @@ +package schema + +// Blueprint contains the schema for blueprint files. +#Blueprint: { + version: =~"^\\d+\\.\\d+" @go(Version) + global: #Global @go(Global) + registry: (_ | *"") & { + string + } @go(Registry) + targets: { + [string]: #Target + } @go(Targets,map[string]Target) +} + +// Global contains the global configuration. +#Global: { + satellite: (_ | *"") & { + string + } @go(Satellite) +} +version: "1.0" + +// Target contains the configuration for a single target. +#Target: { + args: (_ | *{}) & { + { + [string]: string + } + } @go(Args,map[string]string) + privileged: (_ | *false) & { + bool + } @go(Privileged) + retries: (_ | *0) & { + int + } @go(Retries) +} diff --git a/blueprint/schema/cue.mod/module.cue b/blueprint/schema/cue.mod/module.cue new file mode 100644 index 00000000..0283beea --- /dev/null +++ b/blueprint/schema/cue.mod/module.cue @@ -0,0 +1,4 @@ +module: "blueprint.schema" +language: { + version: "v0.9.2" +} diff --git a/blueprint/schema/schema.go b/blueprint/schema/schema.go new file mode 100644 index 00000000..789a05ca --- /dev/null +++ b/blueprint/schema/schema.go @@ -0,0 +1,31 @@ +package schema + +import ( + _ "embed" +) + +//go:generate go run cuelang.org/go/cmd/cue@v0.9.2 get go --package schema --local . +//go:generate go run cuelang.org/go/cmd/cue@v0.9.2 def -fo _embed/schema.cue + +//go:embed _embed/schema.cue +var RawSchemaFile []byte + +// Blueprint contains the schema for blueprint files. +type Blueprint struct { + Version string `json:"version"` + Global Global `json:"global"` + Registry string `json:"registry"` + Targets map[string]Target `json:"targets"` +} + +// Global contains the global configuration. +type Global struct { + Satellite string `json:"satellite"` +} + +// Target contains the configuration for a single target. +type Target struct { + Args map[string]string `json:"args"` + Privileged bool `json:"privileged"` + Retries int `json:"retries"` +} diff --git a/blueprint/schema/schema_go_gen.cue b/blueprint/schema/schema_go_gen.cue new file mode 100644 index 00000000..d0a562b7 --- /dev/null +++ b/blueprint/schema/schema_go_gen.cue @@ -0,0 +1,25 @@ +// Code generated by cue get go. DO NOT EDIT. + +//cue:generate cue get go github.com/input-output-hk/catalyst-forge/blueprint/schema + +package schema + +// Blueprint contains the schema for blueprint files. +#Blueprint: { + version: string @go(Version) + global: #Global @go(Global) + registry: string @go(Registry) + targets: {[string]: #Target} @go(Targets,map[string]Target) +} + +// Global contains the global configuration. +#Global: { + satellite: string @go(Satellite) +} + +// Target contains the configuration for a single target. +#Target: { + args: {[string]: string} @go(Args,map[string]string) + privileged: bool @go(Privileged) + retries: int @go(Retries) +} diff --git a/blueprint/schema/schema_overrides.cue b/blueprint/schema/schema_overrides.cue new file mode 100644 index 00000000..83927357 --- /dev/null +++ b/blueprint/schema/schema_overrides.cue @@ -0,0 +1,16 @@ +package schema + +#Blueprint: { + version: string & =~"^\\d+\\.\\d+" + registry: _ | *"" +} + +#Global: { + satellite: _ | *"" +} + +#Target: { + args: _ | *{} + privileged: _ | *false + retries: _ | *0 +} diff --git a/blueprint/schema/util.go b/blueprint/schema/util.go new file mode 100644 index 00000000..f5394d16 --- /dev/null +++ b/blueprint/schema/util.go @@ -0,0 +1,39 @@ +package schema + +import ( + "fmt" + + "cuelang.org/go/cue" + "github.com/Masterminds/semver/v3" + "github.com/input-output-hk/catalyst-forge/blueprint/pkg/version" + cuetools "github.com/input-output-hk/catalyst-forge/cuetools/pkg" +) + +// SchemaFile contains the schema for blueprint files. +type SchemaFile struct { + Value cue.Value + Version *semver.Version +} + +// Unify unifies the schema with the given value. +func (s SchemaFile) Unify(v cue.Value) cue.Value { + return s.Value.Unify(v) +} + +// LoadSchema loads the schema from the embedded schema file. +func LoadSchema(ctx *cue.Context) (SchemaFile, error) { + v, err := cuetools.Compile(ctx, RawSchemaFile) + if err != nil { + return SchemaFile{}, err + } + + version, err := version.GetVersion(v) + if err != nil { + return SchemaFile{}, fmt.Errorf("failed to get schema version: %w", err) + } + + return SchemaFile{ + Value: v.LookupPath(cue.ParsePath("#Blueprint")), + Version: version, + }, nil +} diff --git a/blueprint/schema/version.cue b/blueprint/schema/version.cue new file mode 100644 index 00000000..7b163c77 --- /dev/null +++ b/blueprint/schema/version.cue @@ -0,0 +1,3 @@ +package schema + +version: "1.0" From bdb1e9bb32d3467d36a05b024cb07bf07d046142 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 26 Aug 2024 21:56:19 -0400 Subject: [PATCH 2/4] fix: removes test file --- blueprint/main.go | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 blueprint/main.go diff --git a/blueprint/main.go b/blueprint/main.go deleted file mode 100644 index e47c936b..00000000 --- a/blueprint/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "fmt" - "log" - - "cuelang.org/go/cue/cuecontext" - "github.com/input-output-hk/catalyst-forge/blueprint/schema" -) - -func main() { - ctx := cuecontext.New() - schema, err := schema.LoadSchema(ctx) - if err != nil { - log.Fatalf("failed to load schema: %v", err) - } - - fmt.Printf("Schema version: %s\n", schema.Version) - - v := schema.Unify(ctx.CompileString(`{version: "1.0"}`)) - if v.Err() != nil { - log.Fatalf("failed to unify schema: %v", v.Err()) - } -} From 125b0aed4389b7a1f697981c35d26c9531453aff Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 26 Aug 2024 21:57:30 -0400 Subject: [PATCH 3/4] docs: fixes list in README --- blueprint/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprint/README.md b/blueprint/README.md index 85ffb8fa..4b094099 100644 --- a/blueprint/README.md +++ b/blueprint/README.md @@ -13,8 +13,8 @@ The `BlueprintLoader` can be used to load blueprint files from a given path. By default, the loader performs the following: 1. Walks the filesystem searching for `blueprint.cue` files - a. If the path is in a git repository, it walks up to the root of the repository - b. If the path is not in a git repository, it only searches the given path + 1. If the path is in a git repository, it walks up to the root of the repository + 2. If the path is not in a git repository, it only searches the given path 2. Loads and processes all found blueprint files (including things like injecting environment variables) 3. Unifies all blueprint files into a single blueprint (including handling versions) 4. Validates the final blueprint against the embedded schema From 3842b8635188e5a413943dc5184f09a4eff1650f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 26 Aug 2024 21:59:35 -0400 Subject: [PATCH 4/4] docs: adds statement about default blueprint --- blueprint/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/blueprint/README.md b/blueprint/README.md index 4b094099..fa69bd12 100644 --- a/blueprint/README.md +++ b/blueprint/README.md @@ -46,6 +46,8 @@ func main() { } ``` +If no blueprint files are found, the loader will return a `Blueprint` structure with default values provided for all fields. + ### Blueprint Schema The blueprint schema is embedded in the `schema` package and can be loaded using the included function: