diff --git a/blueprint.cue b/blueprint.cue index aa4c2b94..422e544e 100644 --- a/blueprint.cue +++ b/blueprint.cue @@ -70,7 +70,7 @@ global: { ] } deployment: { - registry: ci.providers.aws.ecr.registry + registry: ci.providers.aws.ecr.registry + "/catalyst-deployments" repo: { url: "https://github.com/input-output-hk/catalyst-world" ref: "master" diff --git a/cli/cmd/cmds/deploy/template.go b/cli/cmd/cmds/deploy/template.go index 4ae70463..3e46ccc6 100644 --- a/cli/cmd/cmds/deploy/template.go +++ b/cli/cmd/cmds/deploy/template.go @@ -9,6 +9,7 @@ import ( type TemplateCmd struct { Project string `arg:"" help:"The path to the project." kong:"arg,predictor=path"` + Values bool `help:"Only print the values.yml for the main module"` } func (c *TemplateCmd) Run(ctx run.RunContext) error { @@ -17,22 +18,23 @@ func (c *TemplateCmd) Run(ctx run.RunContext) error { return fmt.Errorf("could not load project: %w", err) } - bundle, err := deployment.GenerateBundle(&project) - if err != nil { - return fmt.Errorf("could not generate bundle: %w", err) - } + runner := deployment.NewKCLRunner(ctx.Logger) - templater, err := deployment.NewDefaultBundleTemplater(ctx.Logger) - if err != nil { - return fmt.Errorf("could not create bundle templater: %w", err) + if c.Values { + values, err := runner.GetMainValues(&project) + if err != nil { + return fmt.Errorf("could not get values: %w", err) + } + + fmt.Print(values) + return nil } - out, err := templater.Render(bundle) + out, err := runner.RunDeployment(&project) if err != nil { - return fmt.Errorf("could not render bundle: %w", err) + return fmt.Errorf("could not run deployment: %w", err) } - fmt.Println(out) - + fmt.Print(out) return nil } diff --git a/cli/pkg/deployment/kcl.go b/cli/pkg/deployment/kcl.go new file mode 100644 index 00000000..ba74ab70 --- /dev/null +++ b/cli/pkg/deployment/kcl.go @@ -0,0 +1,171 @@ +package deployment + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "gopkg.in/yaml.v3" +) + +// KCLModuleArgs contains the arguments to pass to the KCL module. +type KCLModuleArgs struct { + // InstanceName is the name to use for the deployment instance. + InstanceName string + + // Namespace is the namespace to deploy the module to. + Namespace string + + // Values contains the values to pass to the module. + Values string + + // Version is the version of the module to deploy. + Version string +} + +// Serialize serializes the KCLModuleArgs to a list of arguments. +func (k *KCLModuleArgs) Serialize() []string { + return []string{ + "-D", + fmt.Sprintf("name=%s", k.InstanceName), + "-D", + fmt.Sprintf("namespace=%s", k.Namespace), + "-D", + fmt.Sprintf("values=%s", k.Values), + "-D", + k.Version, + } +} + +// KCLRunner is used to run KCL commands. +type KCLRunner struct { + kcl executor.WrappedExecuter + logger *slog.Logger +} + +// GetMainValues returns the values (in YAML) for the main module in the project. +func (k *KCLRunner) GetMainValues(p *project.Project) (string, error) { + if p.Blueprint.Project.Deployment.Modules == nil { + return "", fmt.Errorf("no deployment modules found in project blueprint") + } else if p.Blueprint.Global.Deployment.Registry == "" { + return "", fmt.Errorf("no deployment registry found in project blueprint") + } + + ctx := cuecontext.New() + module := p.Blueprint.Project.Deployment.Modules.Main + + json, err := encodeValues(ctx, module) + if err != nil { + return "", fmt.Errorf("failed to encode module values: %w", err) + } + + yaml, err := jsonToYaml(json) + if err != nil { + return "", fmt.Errorf("failed to convert values to YAML: %w", err) + } + + return string(yaml), nil +} + +// RunDeployment runs the deployment modules in the project and returns the +// combined output. +func (k *KCLRunner) RunDeployment(p *project.Project) (string, error) { + ctx := cuecontext.New() + if p.Blueprint.Project.Deployment.Modules == nil { + return "", fmt.Errorf("no deployment modules found in project blueprint") + } else if p.Blueprint.Global.Deployment.Registry == "" { + return "", fmt.Errorf("no deployment registry found in project blueprint") + } + + modules := map[string]schema.Module{"main": p.Blueprint.Project.Deployment.Modules.Main} + for k, v := range p.Blueprint.Project.Deployment.Modules.Support { + modules[k] = v + } + + var final string + for _, module := range modules { + json, err := encodeValues(ctx, module) + if err != nil { + return "", fmt.Errorf("failed to encode module values: %w", err) + } + + args := KCLModuleArgs{ + InstanceName: p.Blueprint.Project.Name, + Namespace: module.Namespace, + Values: string(json), + Version: module.Version, + } + + container := fmt.Sprintf("%s/%s", strings.TrimSuffix(p.Blueprint.Global.Deployment.Registry, "/"), module.Module) + out, err := k.run(container, args) + if err != nil { + k.logger.Error("Failed to run KCL module", "module", module.Module, "error", err, "output", string(out)) + return "", fmt.Errorf("failed to run KCL module: %w", err) + } + + final += strings.Trim(string(out), "\n") + "\n---\n" + } + + return strings.TrimSuffix(final, "---\n"), nil +} + +// encodeValues encodes the values of a module to JSON. +func encodeValues(ctx *cue.Context, module schema.Module) ([]byte, error) { + v := ctx.Encode(module.Values) + if err := v.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate module values: %w", err) + } + + j, err := v.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal module values: %w", err) + } + + return j, nil +} + +// run runs a KCL module with the given module container and arguments. +func (k *KCLRunner) run(container string, moduleArgs KCLModuleArgs) ([]byte, error) { + args := []string{"run", "-q"} + args = append(args, moduleArgs.Serialize()...) + args = append(args, fmt.Sprintf("oci://%s", container)) + + k.logger.Debug("Running KCL module", "container", container, "args", args) + return k.kcl.Execute(args...) +} + +// jsonToYaml converts a JSON string to a YAML string. +func jsonToYaml(j []byte) ([]byte, error) { + var jsonObject map[string]interface{} + err := json.Unmarshal(j, &jsonObject) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + y, err := yaml.Marshal(jsonObject) + if err != nil { + return nil, fmt.Errorf("failed to marshal YAML: %w", err) + } + + return y, nil +} + +// NewKCLRunner creates a new KCLRunner. +func NewKCLRunner(logger *slog.Logger) KCLRunner { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + kcl := executor.NewLocalWrappedExecutor(executor.NewLocalExecutor(logger), "kcl") + return KCLRunner{ + kcl: kcl, + logger: logger, + } +} diff --git a/cli/pkg/deployment/kcl_test.go b/cli/pkg/deployment/kcl_test.go new file mode 100644 index 00000000..8ae1534a --- /dev/null +++ b/cli/pkg/deployment/kcl_test.go @@ -0,0 +1,228 @@ +package deployment + +import ( + "fmt" + "strings" + "testing" + + "github.com/input-output-hk/catalyst-forge/cli/pkg/executor/mocks" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" + "github.com/stretchr/testify/assert" +) + +func TestKCLRunnerGetMainValues(t *testing.T) { + newProject := func(name string, modules *schema.DeploymentModules) project.Project { + return project.Project{ + Name: name, + Blueprint: schema.Blueprint{ + Project: schema.Project{ + Deployment: schema.Deployment{ + Environment: "test", + Modules: modules, + }, + }, + Global: schema.Global{ + Deployment: schema.GlobalDeployment{ + Registry: "test", + }, + }, + }, + } + } + + tests := []struct { + name string + project project.Project + validate func(t *testing.T, values string, err error) + }{ + { + name: "full", + project: newProject( + "test", + &schema.DeploymentModules{ + Main: schema.Module{ + Module: "module", + Namespace: "default", + Values: map[string]string{ + "key": "value", + }, + Version: "1.0.0", + }, + }, + ), + validate: func(t *testing.T, values string, err error) { + assert.NoError(t, err) + assert.Equal(t, values, "key: value\n") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runner := KCLRunner{ + kcl: newWrappedExecuterMock("", nil, false), + logger: testutils.NewNoopLogger(), + } + + values, err := runner.GetMainValues(&tt.project) + tt.validate(t, values, err) + }) + } +} + +func TestKCLRunnerRunDeployment(t *testing.T) { + type testResults struct { + calls []string + err error + output string + } + + newProject := func(name, environment, registry string, modules *schema.DeploymentModules) project.Project { + return project.Project{ + Name: name, + Blueprint: schema.Blueprint{ + Project: schema.Project{ + Deployment: schema.Deployment{ + Environment: environment, + Modules: modules, + }, + }, + Global: schema.Global{ + Deployment: schema.GlobalDeployment{ + Registry: registry, + }, + }, + }, + } + } + + tests := []struct { + name string + project project.Project + output string + execFail bool + validate func(t *testing.T, r testResults) + }{ + { + name: "full", + project: newProject( + "test", + "dev", + "test.com", + &schema.DeploymentModules{ + Main: schema.Module{ + Module: "module", + Namespace: "default", + Values: map[string]string{ + "key": "value", + }, + Version: "1.0.0", + }, + Support: map[string]schema.Module{ + "support": { + Module: "module1", + Namespace: "default", + Values: map[string]string{ + "key1": "value1", + }, + Version: "1.0.0", + }, + }, + }, + ), + output: "output", + execFail: false, + validate: func(t *testing.T, r testResults) { + assert.NoError(t, r.err) + assert.Equal(t, "output\n---\noutput\n", r.output) + assert.Contains(t, r.calls, "run -q -D name= -D namespace=default -D values={\"key\":\"value\"} -D 1.0.0 oci://test.com/module") + assert.Contains(t, r.calls, "run -q -D name= -D namespace=default -D values={\"key1\":\"value1\"} -D 1.0.0 oci://test.com/module1") + }, + }, + { + name: "run failed", + project: newProject( + "test", + "dev", + "test.com", + &schema.DeploymentModules{ + Main: schema.Module{ + Module: "module", + Namespace: "default", + Values: map[string]string{ + "key": "value", + }, + Version: "1.0.0", + }, + }, + ), + output: "output", + execFail: true, + validate: func(t *testing.T, r testResults) { + assert.Error(t, r.err) + assert.ErrorContains(t, r.err, "failed to execute command") + }, + }, + { + name: "no modules", + project: newProject( + "test", + "dev", + "test.com", + nil, + ), + output: "", + execFail: false, + validate: func(t *testing.T, r testResults) { + assert.Error(t, r.err) + }, + }, + { + name: "no registry", + project: newProject( + "test", + "dev", + "", + nil, + ), + output: "", + execFail: false, + validate: func(t *testing.T, r testResults) { + assert.Error(t, r.err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls []string + runner := KCLRunner{ + kcl: newWrappedExecuterMock(tt.output, &calls, tt.execFail), + logger: testutils.NewNoopLogger(), + } + + output, err := runner.RunDeployment(&tt.project) + tt.validate(t, testResults{ + calls: calls, + err: err, + output: string(output), + }) + }) + } +} + +func newWrappedExecuterMock(output string, calls *[]string, fail bool) *mocks.WrappedExecuterMock { + return &mocks.WrappedExecuterMock{ + ExecuteFunc: func(args ...string) ([]byte, error) { + call := strings.Join(args, " ") + *calls = append(*calls, call) + + if fail { + return nil, fmt.Errorf("failed to execute command") + } + return []byte(output), nil + }, + } +} diff --git a/foundry/api/blueprint.cue b/foundry/api/blueprint.cue index 12672fb3..8aecde0a 100644 --- a/foundry/api/blueprint.cue +++ b/foundry/api/blueprint.cue @@ -19,13 +19,18 @@ project: { tag: {} } environment: "dev" - modules: main: { - container: "foundry-api-deployment" - version: "0.1.1" - values: { - environment: name: "dev" - server: image: { - tag: _ @forge(name="GIT_HASH_OR_TAG") + modules: { + main: { + module: "app" + version: "0.2.0" + values: { + deployment: containers: main: { + image: { + name: "nginx" + tag: "latest" + } + port: 80 + } } } } diff --git a/lib/project/schema/_embed/schema.cue b/lib/project/schema/_embed/schema.cue index f432b89a..cf3ccf9b 100644 --- a/lib/project/schema/_embed/schema.cue +++ b/lib/project/schema/_embed/schema.cue @@ -241,6 +241,10 @@ version: "1.0" // +optional container?: null | string @go(Container,*string) + // Module contains the name of the module to deploy. + // +optional + module?: string @go(Module) + // Namespace contains the namespace to deploy the module to. namespace: (_ | *"default") & { string diff --git a/lib/project/schema/deployment.go b/lib/project/schema/deployment.go index 9143153e..f7e4dc82 100644 --- a/lib/project/schema/deployment.go +++ b/lib/project/schema/deployment.go @@ -30,6 +30,10 @@ type Module struct { // +optional Container *string `json:"container"` + // Module contains the name of the module to deploy. + // +optional + Module string `json:"module"` + // Namespace contains the namespace to deploy the module to. Namespace string `json:"namespace"` diff --git a/lib/project/schema/deployment_go_gen.cue b/lib/project/schema/deployment_go_gen.cue index 28fab201..4412db14 100644 --- a/lib/project/schema/deployment_go_gen.cue +++ b/lib/project/schema/deployment_go_gen.cue @@ -34,6 +34,10 @@ package schema // +optional container?: null | string @go(Container,*string) + // Module contains the name of the module to deploy. + // +optional + module?: string @go(Module) + // Namespace contains the namespace to deploy the module to. namespace: string @go(Namespace)