From ed2eaaf35c9781322b819e1df0ae1e65433f76b2 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 17 Jan 2025 16:15:03 -0800 Subject: [PATCH] feat: integrates ECR generation into docker release and adds deployment runtime (#121) --- blueprint.cue | 5 +- cli/pkg/deployment/gitops_test.go | 11 ++- cli/pkg/deployment/kcl.go | 12 +-- cli/pkg/deployment/kcl_test.go | 16 ++-- cli/pkg/release/providers/common.go | 29 ------- cli/pkg/release/providers/docker.go | 30 ++++++- cli/pkg/release/providers/docker_test.go | 81 +++++++++++++----- cli/pkg/release/providers/kcl.go | 2 +- foundry/api/blueprint.cue | 4 +- lib/project/blueprint/defaults/defaults.go | 1 - lib/project/blueprint/defaults/deployment.go | 62 -------------- lib/project/project/container.go | 43 ++++++++++ lib/project/project/container_test.go | 87 ++++++++++++++++++++ lib/project/project/loader.go | 1 + lib/project/project/runtime.go | 47 +++++++++++ lib/project/project/runtime_test.go | 50 +++++++++++ lib/project/schema/_embed/schema.cue | 22 +++-- lib/project/schema/deployment.go | 8 +- lib/project/schema/deployment_go_gen.cue | 9 +- lib/project/schema/global.go | 13 ++- lib/project/schema/global_go_gen.cue | 13 ++- 21 files changed, 385 insertions(+), 161 deletions(-) delete mode 100644 lib/project/blueprint/defaults/deployment.go create mode 100644 lib/project/project/container.go create mode 100644 lib/project/project/container_test.go diff --git a/blueprint.cue b/blueprint.cue index 4856ecc5..fc504c04 100644 --- a/blueprint.cue +++ b/blueprint.cue @@ -71,7 +71,10 @@ global: { ] } deployment: { - registry: ci.providers.aws.ecr.registry + "/catalyst-deployments" + registries: { + containers: "ghcr.io/input-output-hk/catalyst-forge" + modules: ci.providers.aws.ecr.registry + "/catalyst-deployments" + } repo: { url: "https://github.com/input-output-hk/catalyst-world" ref: "master" diff --git a/cli/pkg/deployment/gitops_test.go b/cli/pkg/deployment/gitops_test.go index 5b95b66f..77a47467 100644 --- a/cli/pkg/deployment/gitops_test.go +++ b/cli/pkg/deployment/gitops_test.go @@ -14,7 +14,6 @@ import ( "github.com/input-output-hk/catalyst-forge/lib/project/schema" "github.com/input-output-hk/catalyst-forge/lib/project/secrets" "github.com/input-output-hk/catalyst-forge/lib/project/secrets/mocks" - "github.com/input-output-hk/catalyst-forge/lib/tools/pointers" "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -50,7 +49,9 @@ func TestDeploy(t *testing.T) { defaultParams := projectParams{ projectName: "test", globalDeploy: schema.GlobalDeployment{ - Registry: "registry.myserver.com", + Registries: schema.GlobalDeploymentRegistries{ + Modules: "registry.myserver.com", + }, Repo: schema.GlobalDeploymentRepo{ Ref: "main", Url: "https://github.com/foo/bar", @@ -191,7 +192,9 @@ func TestLoad(t *testing.T) { defaultParams := projectParams{ projectName: "test", globalDeploy: schema.GlobalDeployment{ - Registry: "registry.myserver.com", + Registries: schema.GlobalDeploymentRegistries{ + Modules: "registry.myserver.com", + }, Repo: schema.GlobalDeploymentRepo{ Ref: "main", Url: "https://github.com/foo/bar", @@ -294,7 +297,7 @@ func newTestProject(p projectParams) *project.Project { Environment: p.enviroment, Modules: &schema.DeploymentModules{ Main: schema.Module{ - Container: pointers.String(p.container), + Name: p.container, Namespace: p.namespace, Values: ctx.CompileString(p.values), Version: p.version, diff --git a/cli/pkg/deployment/kcl.go b/cli/pkg/deployment/kcl.go index 77023327..e4a57a57 100644 --- a/cli/pkg/deployment/kcl.go +++ b/cli/pkg/deployment/kcl.go @@ -60,8 +60,8 @@ type KCLRunner struct { 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") + } else if p.Blueprint.Global.Deployment.Registries.Modules == "" { + return "", fmt.Errorf("no module deployment registry found in project blueprint") } ctx := cuecontext.New() @@ -86,8 +86,8 @@ func (k *KCLRunner) RunDeployment(p *project.Project) (map[string]KCLRunResult, ctx := cuecontext.New() if p.Blueprint.Project.Deployment.Modules == nil { return nil, fmt.Errorf("no deployment modules found in project blueprint") - } else if p.Blueprint.Global.Deployment.Registry == "" { - return nil, fmt.Errorf("no deployment registry found in project blueprint") + } else if p.Blueprint.Global.Deployment.Registries.Modules == "" { + return nil, fmt.Errorf("no module deployment registry found in project blueprint") } modules := map[string]schema.Module{"main": p.Blueprint.Project.Deployment.Modules.Main} @@ -109,10 +109,10 @@ func (k *KCLRunner) RunDeployment(p *project.Project) (map[string]KCLRunResult, Version: module.Version, } - container := fmt.Sprintf("%s/%s", strings.TrimSuffix(p.Blueprint.Global.Deployment.Registry, "/"), module.Module) + container := fmt.Sprintf("%s/%s", strings.TrimSuffix(p.Blueprint.Global.Deployment.Registries.Modules, "/"), module.Name) 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)) + k.logger.Error("Failed to run KCL module", "module", module.Name, "error", err, "output", string(out)) return nil, fmt.Errorf("failed to run KCL module: %w", err) } diff --git a/cli/pkg/deployment/kcl_test.go b/cli/pkg/deployment/kcl_test.go index e2c1c663..88d342ca 100644 --- a/cli/pkg/deployment/kcl_test.go +++ b/cli/pkg/deployment/kcl_test.go @@ -25,7 +25,9 @@ func TestKCLRunnerGetMainValues(t *testing.T) { }, Global: schema.Global{ Deployment: schema.GlobalDeployment{ - Registry: "test", + Registries: schema.GlobalDeploymentRegistries{ + Modules: "test.com", + }, }, }, }, @@ -43,7 +45,7 @@ func TestKCLRunnerGetMainValues(t *testing.T) { "test", &schema.DeploymentModules{ Main: schema.Module{ - Module: "module", + Name: "module", Namespace: "default", Values: map[string]string{ "key": "value", @@ -91,7 +93,9 @@ func TestKCLRunnerRunDeployment(t *testing.T) { }, Global: schema.Global{ Deployment: schema.GlobalDeployment{ - Registry: registry, + Registries: schema.GlobalDeploymentRegistries{ + Modules: registry, + }, }, }, }, @@ -113,7 +117,7 @@ func TestKCLRunnerRunDeployment(t *testing.T) { "test.com", &schema.DeploymentModules{ Main: schema.Module{ - Module: "module", + Name: "module", Namespace: "default", Values: map[string]string{ "key": "value", @@ -122,7 +126,7 @@ func TestKCLRunnerRunDeployment(t *testing.T) { }, Support: map[string]schema.Module{ "support": { - Module: "module1", + Name: "module1", Namespace: "default", Values: map[string]string{ "key1": "value1", @@ -152,7 +156,7 @@ func TestKCLRunnerRunDeployment(t *testing.T) { "test.com", &schema.DeploymentModules{ Main: schema.Module{ - Module: "module", + Name: "module", Namespace: "default", Values: map[string]string{ "key": "value", diff --git a/cli/pkg/release/providers/common.go b/cli/pkg/release/providers/common.go index c2de759e..fc4e4e4d 100644 --- a/cli/pkg/release/providers/common.go +++ b/cli/pkg/release/providers/common.go @@ -34,40 +34,11 @@ func createECRRepoIfNotExists(client aws.ECRClient, p *project.Project, registry return nil } -// generateContainerName generates the container name for the project. -// If the name is not provided, the project name is used. -func generateContainerName(p *project.Project, name string, registry string) string { - var n string - if name == "" { - n = p.Name - } else { - n = name - } - - if isGHCRRegistry(registry) { - return fmt.Sprintf("%s/%s", strings.TrimSuffix(registry, "/"), n) - } else { - var repo string - if strings.Contains(p.Blueprint.Global.Repo.Name, "/") { - repo = strings.Split(p.Blueprint.Global.Repo.Name, "/")[1] - } else { - repo = p.Blueprint.Global.Repo.Name - } - - return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(registry, "/"), repo, n) - } -} - // isECRRegistry checks if the registry is an ECR registry. func isECRRegistry(registry string) bool { return regexp.MustCompile(`^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com`).MatchString(registry) } -// isGHCRRegistry checks if the registry is a GHCR registry. -func isGHCRRegistry(registry string) bool { - return regexp.MustCompile(`^ghcr\.io/[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$`).MatchString(registry) -} - // parseConfig parses the configuration for the release. func parseConfig(p *project.Project, release string, config any) error { err := p.Raw().DecodePath(fmt.Sprintf("project.release.%s.config", release), &config) diff --git a/cli/pkg/release/providers/docker.go b/cli/pkg/release/providers/docker.go index 280bff28..ef62d607 100644 --- a/cli/pkg/release/providers/docker.go +++ b/cli/pkg/release/providers/docker.go @@ -8,6 +8,7 @@ import ( "github.com/input-output-hk/catalyst-forge/cli/pkg/earthly" "github.com/input-output-hk/catalyst-forge/cli/pkg/events" "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" + "github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws" "github.com/input-output-hk/catalyst-forge/cli/pkg/run" "github.com/input-output-hk/catalyst-forge/lib/project/project" "github.com/input-output-hk/catalyst-forge/lib/project/schema" @@ -26,6 +27,7 @@ type DockerReleaserConfig struct { type DockerReleaser struct { config DockerReleaserConfig docker executor.WrappedExecuter + ecr aws.ECRClient force bool handler events.EventHandler logger *slog.Logger @@ -56,9 +58,7 @@ func (r *DockerReleaser) Release() error { return fmt.Errorf("no registries found") } - container := r.project.Blueprint.Project.Container registries := r.project.Blueprint.Global.CI.Registries - imageTag := r.config.Tag if imageTag == "" { return fmt.Errorf("no image tag specified") @@ -69,10 +69,18 @@ func (r *DockerReleaser) Release() error { for _, registry := range registries { var pushed []string + container := project.GenerateContainerName(&r.project, r.project.Blueprint.Project.Container, registry) + if isECRRegistry(registry) { + r.logger.Info("Detected ECR registry, checking if repository exists", "repository", container) + if err := createECRRepoIfNotExists(r.ecr, &r.project, container, r.logger); err != nil { + return fmt.Errorf("failed to create ECR repository: %w", err) + } + } + for _, platform := range platforms { platformSuffix := strings.Replace(platform, "/", "_", -1) curImage := fmt.Sprintf("%s:%s_%s", CONTAINER_NAME, TAG_NAME, platformSuffix) - newImage := fmt.Sprintf("%s/%s:%s_%s", registry, container, imageTag, platformSuffix) + newImage := fmt.Sprintf("%s:%s_%s", container, imageTag, platformSuffix) r.logger.Debug("Tagging image", "tag", newImage) if err := r.tagImage(curImage, newImage); err != nil { @@ -95,8 +103,16 @@ func (r *DockerReleaser) Release() error { } } else { for _, registry := range registries { + container := project.GenerateContainerName(&r.project, r.project.Blueprint.Project.Container, registry) + if isECRRegistry(registry) { + r.logger.Info("Detected ECR registry, checking if repository exists", "repository", container) + if err := createECRRepoIfNotExists(r.ecr, &r.project, container, r.logger); err != nil { + return fmt.Errorf("failed to create ECR repository: %w", err) + } + } + curImage := fmt.Sprintf("%s:%s", CONTAINER_NAME, TAG_NAME) - newImage := fmt.Sprintf("%s/%s:%s", registry, container, imageTag) + newImage := fmt.Sprintf("%s:%s", container, imageTag) r.logger.Info("Tagging image", "old", curImage, "new", newImage) if err := r.tagImage(curImage, newImage); err != nil { @@ -215,12 +231,18 @@ func NewDockerReleaser( return nil, fmt.Errorf("failed to parse release config: %w", err) } + ecr, err := aws.NewECRClient(ctx.Logger) + if err != nil { + return nil, fmt.Errorf("failed to create ECR client: %w", err) + } + docker := executor.NewLocalWrappedExecutor(exec, "docker") handler := events.NewDefaultEventHandler(ctx.Logger) runner := run.NewDefaultProjectRunner(ctx, &project) return &DockerReleaser{ config: config, docker: docker, + ecr: ecr, force: force, handler: &handler, logger: ctx.Logger, diff --git a/cli/pkg/release/providers/docker_test.go b/cli/pkg/release/providers/docker_test.go index f2ec377a..ad897b6e 100644 --- a/cli/pkg/release/providers/docker_test.go +++ b/cli/pkg/release/providers/docker_test.go @@ -1,11 +1,15 @@ package providers import ( + "context" "fmt" "strings" "testing" + "github.com/aws/aws-sdk-go-v2/service/ecr" exmocks "github.com/input-output-hk/catalyst-forge/cli/pkg/executor/mocks" + "github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws" + "github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws/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" @@ -26,6 +30,9 @@ func TestDockerReleaserRelease(t *testing.T) { CI: schema.GlobalCI{ Registries: registries, }, + Repo: schema.GlobalRepo{ + Name: "owner/repo", + }, }, Project: schema.Project{ Container: container, @@ -56,7 +63,7 @@ func TestDockerReleaserRelease(t *testing.T) { force bool runFail bool execFailOn string - validate func(t *testing.T, calls []string, err error) + validate func(t *testing.T, calls []string, repoName string, err error) }{ { name: "full", @@ -72,11 +79,33 @@ func TestDockerReleaserRelease(t *testing.T) { firing: true, force: false, runFail: false, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { + require.NoError(t, err) + assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, fmt.Sprintf("tag %s:%s test.com/repo/test:test", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, "push test.com/repo/test:test") + }, + }, + { + name: "ecr", + project: newProject( + "test", + []string{"123456789012.dkr.ecr.us-west-2.amazonaws.com"}, + []string{}, + ), + release: newRelease(), + config: DockerReleaserConfig{ + Tag: "test", + }, + firing: true, + force: false, + runFail: false, + validate: func(t *testing.T, calls []string, repoName string, err error) { require.NoError(t, err) assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, fmt.Sprintf("tag %s:%s test.com/test:test", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, "push test.com/test:test") + assert.Contains(t, calls, fmt.Sprintf("tag %s:%s 123456789012.dkr.ecr.us-west-2.amazonaws.com/repo/test:test", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, "push 123456789012.dkr.ecr.us-west-2.amazonaws.com/repo/test:test") + assert.Equal(t, "repo/test", repoName) }, }, { @@ -93,17 +122,17 @@ func TestDockerReleaserRelease(t *testing.T) { firing: true, force: false, runFail: false, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.NoError(t, err) assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s_linux", CONTAINER_NAME, TAG_NAME)) assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s_windows", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, fmt.Sprintf("tag %s:%s_linux test.com/test:test_linux", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, "push test.com/test:test_linux") + assert.Contains(t, calls, fmt.Sprintf("tag %s:%s_linux test.com/repo/test:test_linux", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, "push test.com/repo/test:test_linux") - assert.Contains(t, calls, fmt.Sprintf("tag %s:%s_windows test.com/test:test_windows", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, "push test.com/test:test_windows") + assert.Contains(t, calls, fmt.Sprintf("tag %s:%s_windows test.com/repo/test:test_windows", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, "push test.com/repo/test:test_windows") }, }, { @@ -118,7 +147,7 @@ func TestDockerReleaserRelease(t *testing.T) { firing: true, force: false, runFail: false, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.Error(t, err) assert.ErrorContains(t, err, "no image tag specified") }, @@ -130,7 +159,7 @@ func TestDockerReleaserRelease(t *testing.T) { firing: true, force: false, runFail: true, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.Error(t, err) assert.NotContains(t, calls, fmt.Sprintf("inspect %s:%s", CONTAINER_NAME, TAG_NAME)) }, @@ -147,10 +176,10 @@ func TestDockerReleaserRelease(t *testing.T) { force: false, runFail: false, execFailOn: "inspect", - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.Error(t, err) assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s", CONTAINER_NAME, TAG_NAME)) - assert.NotContains(t, calls, "push test.com/test:test") + assert.NotContains(t, calls, "push test.com/repo/test:test") }, }, { @@ -164,9 +193,9 @@ func TestDockerReleaserRelease(t *testing.T) { firing: false, force: false, runFail: false, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.NoError(t, err) - assert.NotContains(t, calls, "push test.com/test:test") + assert.NotContains(t, calls, "push test.com/repo/test:test") }, }, { @@ -183,21 +212,35 @@ func TestDockerReleaserRelease(t *testing.T) { firing: false, force: true, runFail: false, - validate: func(t *testing.T, calls []string, err error) { + validate: func(t *testing.T, calls []string, repoName string, err error) { require.NoError(t, err) assert.Contains(t, calls, fmt.Sprintf("inspect %s:%s", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, fmt.Sprintf("tag %s:%s test.com/test:test", CONTAINER_NAME, TAG_NAME)) - assert.Contains(t, calls, "push test.com/test:test") + assert.Contains(t, calls, fmt.Sprintf("tag %s:%s test.com/repo/test:test", CONTAINER_NAME, TAG_NAME)) + assert.Contains(t, calls, "push test.com/repo/test:test") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var repoName string var calls []string + + mock := mocks.AWSECRClientMock{ + CreateRepositoryFunc: func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) { + repoName = *params.RepositoryName + return &ecr.CreateRepositoryOutput{}, nil + }, + DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { + return nil, fmt.Errorf("RepositoryNotFoundException") + }, + } + ecr := aws.NewCustomECRClient(&mock, testutils.NewNoopLogger()) + releaser := DockerReleaser{ config: tt.config, docker: newWrappedExecuterMock(&calls, tt.execFailOn), + ecr: ecr, force: tt.force, handler: newReleaseEventHandlerMock(tt.firing), logger: testutils.NewNoopLogger(), @@ -207,7 +250,7 @@ func TestDockerReleaserRelease(t *testing.T) { } err := releaser.Release() - tt.validate(t, calls, err) + tt.validate(t, calls, repoName, err) }) } } diff --git a/cli/pkg/release/providers/kcl.go b/cli/pkg/release/providers/kcl.go index fab47f11..965c49b5 100644 --- a/cli/pkg/release/providers/kcl.go +++ b/cli/pkg/release/providers/kcl.go @@ -44,7 +44,7 @@ func (r *KCLReleaser) Release() error { } for _, registry := range registries { - container := generateContainerName(&r.project, r.config.Container, registry) + container := project.GenerateContainerName(&r.project, r.config.Container, registry) path, err := r.project.GetRelativePath() if err != nil { return fmt.Errorf("failed to get relative path: %w", err) diff --git a/foundry/api/blueprint.cue b/foundry/api/blueprint.cue index da8f400b..08c13a66 100644 --- a/foundry/api/blueprint.cue +++ b/foundry/api/blueprint.cue @@ -21,12 +21,12 @@ project: { environment: "dev" modules: { main: { - module: "app" + name: "app" version: "0.2.0" values: { deployment: containers: main: { image: { - name: "ghcr.io/input-output-hk/catalyst-forge/foundry-api" + name: _ @forge(name="CONTAINER_IMAGE") tag: _ @forge(name="GIT_HASH_OR_TAG") } port: 8080 diff --git a/lib/project/blueprint/defaults/defaults.go b/lib/project/blueprint/defaults/defaults.go index 29e0c30f..d08f5007 100644 --- a/lib/project/blueprint/defaults/defaults.go +++ b/lib/project/blueprint/defaults/defaults.go @@ -12,7 +12,6 @@ type DefaultSetter interface { // GetDefaultSetters returns a list of all default setters. func GetDefaultSetters() []DefaultSetter { return []DefaultSetter{ - DeploymentModuleSetter{}, ReleaseTargetSetter{}, } } diff --git a/lib/project/blueprint/defaults/deployment.go b/lib/project/blueprint/defaults/deployment.go deleted file mode 100644 index 81863eb6..00000000 --- a/lib/project/blueprint/defaults/deployment.go +++ /dev/null @@ -1,62 +0,0 @@ -package defaults - -import ( - "fmt" - - "cuelang.org/go/cue" -) - -// DeploymentModuleSetter sets default values for deployment modules. -type DeploymentModuleSetter struct{} - -func (d DeploymentModuleSetter) SetDefault(v cue.Value) (cue.Value, error) { - var err error - main := v.LookupPath(cue.ParsePath("project.deployment.modules.main")) - if main.Exists() { - v, err = setMain(main, v) - if err != nil { - return v, fmt.Errorf("failed to set defaults for main module: %w", err) - } - } - - support := v.LookupPath(cue.ParsePath("project.deployment.modules.support")) - if support.Exists() { - v, err = setSupport(support, v) - if err != nil { - return v, err - } - } - - return v, nil -} - -func setMain(main cue.Value, v cue.Value) (cue.Value, error) { - container := main.LookupPath(cue.ParsePath("container")) - if !container.Exists() { - projectName, err := v.LookupPath(cue.ParsePath("project.name")).String() - if err != nil { - return v, fmt.Errorf("failed to get project name: %w", err) - } - - containerName := fmt.Sprintf("%s-deployment", projectName) - v = v.FillPath(cue.ParsePath("project.deployment.modules.main.container"), containerName) - } - - return v, nil -} - -func setSupport(support cue.Value, v cue.Value) (cue.Value, error) { - fields, err := support.Fields() - if err != nil { - return v, fmt.Errorf("failed to get support modules: %w", err) - } - - for fields.Next() { - container := fields.Value().LookupPath(cue.ParsePath("container")) - if !container.Exists() { - return v, fmt.Errorf("support module %s does not have a container field", fields.Selector()) - } - } - - return v, nil -} diff --git a/lib/project/project/container.go b/lib/project/project/container.go new file mode 100644 index 00000000..70206f52 --- /dev/null +++ b/lib/project/project/container.go @@ -0,0 +1,43 @@ +package project + +import ( + "fmt" + "regexp" + "strings" +) + +// GenerateContainerName generates the container name for the project. +// If the name is not provided, the project name is used. +func GenerateContainerName(p *Project, name string, registry string) string { + var n string + if name == "" { + n = p.Name + } else { + n = name + } + + var repo string + if strings.Contains(p.Blueprint.Global.Repo.Name, "/") { + repo = strings.Split(p.Blueprint.Global.Repo.Name, "/")[1] + } else { + repo = p.Blueprint.Global.Repo.Name + } + + var container string + if registry != "" { + if isGHCRRegistry(registry) { + container = fmt.Sprintf("%s/%s", strings.TrimSuffix(registry, "/"), n) + } else { + container = fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(registry, "/"), repo, n) + } + } else { + container = n + } + + return container +} + +// isGHCRRegistry checks if the registry is a GHCR registry. +func isGHCRRegistry(registry string) bool { + return regexp.MustCompile(`^ghcr\.io/[a-zA-Z0-9](?:-?[a-zA-Z0-9])*`).MatchString(registry) +} diff --git a/lib/project/project/container_test.go b/lib/project/project/container_test.go new file mode 100644 index 00000000..851ea016 --- /dev/null +++ b/lib/project/project/container_test.go @@ -0,0 +1,87 @@ +package project + +import ( + "testing" + + "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "github.com/stretchr/testify/assert" +) + +func TestGenerateContainerName(t *testing.T) { + tests := []struct { + name string + projectName string + containerName string + repoName string + registry string + validate func(*testing.T, string) + }{ + { + name: "full", + projectName: "test", + containerName: "test-container", + repoName: "test/repo", + registry: "test-registry", + validate: func(t *testing.T, container string) { + assert.Equal(t, "test-registry/repo/test-container", container) + }, + }, + { + name: "partial repo", + projectName: "test", + containerName: "test-container", + repoName: "repo", + registry: "test-registry", + validate: func(t *testing.T, container string) { + assert.Equal(t, "test-registry/repo/test-container", container) + }, + }, + { + name: "no container name", + projectName: "test", + containerName: "", + repoName: "test/repo", + registry: "test-registry", + validate: func(t *testing.T, container string) { + assert.Equal(t, "test-registry/repo/test", container) + }, + }, + { + name: "no registry", + projectName: "test", + containerName: "test-container", + repoName: "test/repo", + validate: func(t *testing.T, container string) { + assert.Equal(t, "test-container", container) + }, + }, + { + name: "GHCR registry", + projectName: "test", + containerName: "test-container", + repoName: "test/repo", + registry: "ghcr.io/org/repo", + validate: func(t *testing.T, container string) { + assert.Equal(t, "ghcr.io/org/repo/test-container", container) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Project{ + Name: tt.projectName, + Blueprint: schema.Blueprint{ + Global: schema.Global{ + Repo: schema.GlobalRepo{ + Name: tt.repoName, + }, + }, + }, + } + + container := GenerateContainerName(p, tt.containerName, tt.registry) + tt.validate(t, container) + }) + } +} diff --git a/lib/project/project/loader.go b/lib/project/project/loader.go index 4ad77628..87489fa7 100644 --- a/lib/project/project/loader.go +++ b/lib/project/project/loader.go @@ -207,6 +207,7 @@ func NewDefaultProjectLoader( logger: logger, repoLoader: &rl, runtimes: []RuntimeData{ + NewDeploymentRuntime(logger), NewGitRuntime(&ghp, logger), }, } diff --git a/lib/project/project/runtime.go b/lib/project/project/runtime.go index 5af1c3eb..4414e888 100644 --- a/lib/project/project/runtime.go +++ b/lib/project/project/runtime.go @@ -8,6 +8,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/google/go-github/v66/github" "github.com/input-output-hk/catalyst-forge/lib/project/providers" + "github.com/input-output-hk/catalyst-forge/lib/project/schema" ) // RuntimeData is an interface for runtime data loaders. @@ -15,6 +16,52 @@ type RuntimeData interface { Load(project *Project) map[string]cue.Value } +// DeploymentRuntime is a runtime data loader for deployment related data. +type DeploymentRuntime struct { + logger *slog.Logger +} + +func (g *DeploymentRuntime) Load(project *Project) map[string]cue.Value { + g.logger.Debug("Loading deployment runtime data") + data := make(map[string]cue.Value) + + var registry string + dc, err := project.RawBlueprint.Get("global.deployment.registries.containers").String() + if err != nil { + g.logger.Warn("Failed to get containers registry", "error", err) + } else { + registry = dc + } + + var repo string + rc, err := project.RawBlueprint.Get("global.repo.name").String() + if err != nil { + g.logger.Warn("Failed to get repository name", "error", err) + } else { + repo = rc + } + + project.Blueprint = schema.Blueprint{ + Global: schema.Global{ + Repo: schema.GlobalRepo{ + Name: repo, + }, + }, + } + + container := GenerateContainerName(project, project.Name, registry) + data["CONTAINER_IMAGE"] = project.ctx.CompileString(fmt.Sprintf(`"%s"`, container)) + + return data +} + +// NewDeploymentRuntime creates a new DeploymentRuntime. +func NewDeploymentRuntime(logger *slog.Logger) *DeploymentRuntime { + return &DeploymentRuntime{ + logger: logger, + } +} + // GitRuntime is a runtime data loader for git related data. type GitRuntime struct { provider *providers.GithubProvider diff --git a/lib/project/project/runtime_test.go b/lib/project/project/runtime_test.go index cba700a0..ce7fde25 100644 --- a/lib/project/project/runtime_test.go +++ b/lib/project/project/runtime_test.go @@ -1,6 +1,7 @@ package project import ( + "fmt" "os" "testing" @@ -9,12 +10,61 @@ import ( "github.com/go-git/go-git/v5" "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" "github.com/input-output-hk/catalyst-forge/lib/project/providers" + lc "github.com/input-output-hk/catalyst-forge/lib/tools/cue" "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestDeploymentRuntimeLoad(t *testing.T) { + ctx := cuecontext.New() + + tests := []struct { + name string + projectName string + registry string + repo string + validate func(*testing.T, map[string]cue.Value) + }{ + { + name: "full", + projectName: "test", + registry: "test-registry", + repo: "test-repo", + validate: func(t *testing.T, data map[string]cue.Value) { + assert.Contains(t, data, "CONTAINER_IMAGE") + assert.Equal(t, "test-registry/test-repo/test", getString(t, data["CONTAINER_IMAGE"])) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv := fmt.Sprintf(` + project: name: "%s" + global: { + deployment: registries: containers: "%s" + repo: name: "%s" + } + `, tt.name, tt.registry, tt.repo) + + v, err := lc.Compile(ctx, []byte(rv)) + require.NoError(t, err) + + p := &Project{ + ctx: ctx, + Name: tt.projectName, + RawBlueprint: blueprint.NewRawBlueprint(v), + } + + runtime := &DeploymentRuntime{logger: testutils.NewNoopLogger()} + data := runtime.Load(p) + tt.validate(t, data) + }) + } +} + func TestGitRuntimeLoad(t *testing.T) { ctx := cuecontext.New() prPayload, err := os.ReadFile("testdata/event_pr.json") diff --git a/lib/project/schema/_embed/schema.cue b/lib/project/schema/_embed/schema.cue index cf3ccf9b..a4bb208d 100644 --- a/lib/project/schema/_embed/schema.cue +++ b/lib/project/schema/_embed/schema.cue @@ -34,8 +34,8 @@ package schema // GlobalDeployment contains the configuration for the global deployment of projects. #GlobalDeployment: { - // Registry contains the URL of the container registry holding the deployment code. - registry: string @go(Registry) + // Registries contains the configuration for the global deployment registries. + registries: #GlobalDeploymentRegistries @go(Registries) // Repo contains the configuration for the global deployment repository. repo: #GlobalDeploymentRepo @go(Repo) @@ -44,6 +44,15 @@ package schema root: string @go(Root) } +// GlobalDeploymentRegistries contains the configuration for the global deployment registries. +#GlobalDeploymentRegistries: { + // Containers contains the default container registry to use for deploying containers. + containers: string @go(Containers) + + // Modules contains the container registry that holds deployment modules. + modules: string @go(Modules) +} + // GlobalDeploymentRepo contains the configuration for the global deployment repository. #GlobalDeploymentRepo: { // Ref contains the ref to use for the deployment repository. @@ -236,14 +245,9 @@ version: "1.0" // Module contains the configuration for a deployment module. #Module: { - // Container contains the name of the container holding the deployment code. - // Defaults to -deployment). For the main module, is the project name. - // +optional - container?: null | string @go(Container,*string) - - // Module contains the name of the module to deploy. + // Name contains the name of the module to deploy. // +optional - module?: string @go(Module) + name?: string @go(Name) // Namespace contains the namespace to deploy the module to. namespace: (_ | *"default") & { diff --git a/lib/project/schema/deployment.go b/lib/project/schema/deployment.go index f7e4dc82..156c7386 100644 --- a/lib/project/schema/deployment.go +++ b/lib/project/schema/deployment.go @@ -25,14 +25,10 @@ type DeploymentModules struct { // Module contains the configuration for a deployment module. type Module struct { - // Container contains the name of the container holding the deployment code. - // Defaults to -deployment). For the main module, is the project name. - // +optional - Container *string `json:"container"` - // Module contains the name of the module to deploy. + // Name contains the name of the module to deploy. // +optional - Module string `json:"module"` + Name string `json:"name"` // 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 4412db14..fc1ad6d3 100644 --- a/lib/project/schema/deployment_go_gen.cue +++ b/lib/project/schema/deployment_go_gen.cue @@ -29,14 +29,9 @@ package schema // Module contains the configuration for a deployment module. #Module: { - // Container contains the name of the container holding the deployment code. - // Defaults to -deployment). For the main module, is the project name. + // Name contains the name of the module to deploy. // +optional - container?: null | string @go(Container,*string) - - // Module contains the name of the module to deploy. - // +optional - module?: string @go(Module) + name?: string @go(Name) // Namespace contains the namespace to deploy the module to. namespace: string @go(Namespace) diff --git a/lib/project/schema/global.go b/lib/project/schema/global.go index c8ff2276..6741c444 100644 --- a/lib/project/schema/global.go +++ b/lib/project/schema/global.go @@ -34,8 +34,8 @@ type GlobalCI struct { // GlobalDeployment contains the configuration for the global deployment of projects. type GlobalDeployment struct { - // Registry contains the URL of the container registry holding the deployment code. - Registry string `json:"registry"` + // Registries contains the configuration for the global deployment registries. + Registries GlobalDeploymentRegistries `json:"registries"` // Repo contains the configuration for the global deployment repository. Repo GlobalDeploymentRepo `json:"repo"` @@ -44,6 +44,15 @@ type GlobalDeployment struct { Root string `json:"root"` } +// GlobalDeploymentRegistries contains the configuration for the global deployment registries. +type GlobalDeploymentRegistries struct { + // Containers contains the default container registry to use for deploying containers. + Containers string `json:"containers"` + + // Modules contains the container registry that holds deployment modules. + Modules string `json:"modules"` +} + // GlobalDeploymentRepo contains the configuration for the global deployment repository. type GlobalDeploymentRepo struct { // Ref contains the ref to use for the deployment repository. diff --git a/lib/project/schema/global_go_gen.cue b/lib/project/schema/global_go_gen.cue index ba4641f4..39a70daa 100644 --- a/lib/project/schema/global_go_gen.cue +++ b/lib/project/schema/global_go_gen.cue @@ -38,8 +38,8 @@ package schema // GlobalDeployment contains the configuration for the global deployment of projects. #GlobalDeployment: { - // Registry contains the URL of the container registry holding the deployment code. - registry: string @go(Registry) + // Registries contains the configuration for the global deployment registries. + registries: #GlobalDeploymentRegistries @go(Registries) // Repo contains the configuration for the global deployment repository. repo: #GlobalDeploymentRepo @go(Repo) @@ -48,6 +48,15 @@ package schema root: string @go(Root) } +// GlobalDeploymentRegistries contains the configuration for the global deployment registries. +#GlobalDeploymentRegistries: { + // Containers contains the default container registry to use for deploying containers. + containers: string @go(Containers) + + // Modules contains the container registry that holds deployment modules. + modules: string @go(Modules) +} + // GlobalDeploymentRepo contains the configuration for the global deployment repository. #GlobalDeploymentRepo: { // Ref contains the ref to use for the deployment repository.