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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions blueprint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 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
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

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

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:

```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 ./...
```
21 changes: 21 additions & 0 deletions blueprint/go.mod
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions blueprint/go.sum
Original file line number Diff line number Diff line change
@@ -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=
24 changes: 24 additions & 0 deletions blueprint/internal/testutils/helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions blueprint/pkg/blueprint/blueprint.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading