From 609db2f45c6febb5a55b5c020dcd8c0ec4f541f8 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Wed, 29 Jul 2026 16:02:08 +0200 Subject: [PATCH] Complete promotable GitOps lifecycle --- cmd/deploy/gitops.go | 33 ++++- cmd/deploy/service_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- pkg/gitops/observe_test.go | 2 +- pkg/gitops/orchestrate.go | 108 ++++++++++++-- pkg/gitops/orchestrate_test.go | 40 ++++-- pkg/gitops/publish.go | 4 +- pkg/gitops/publish_test.go | 4 +- pkg/gitops/qualification_k3d_test.go | 4 +- pkg/gitops/render.go | 23 ++- pkg/gitops/render_test.go | 11 ++ pkg/orchestration/builder_deploy.go | 160 +++++++++++++++++++++ pkg/orchestration/builder_deploy_test.go | 174 +++++++++++++++++++++++ pkg/orchestration/flow.go | 13 +- 15 files changed, 540 insertions(+), 44 deletions(-) create mode 100644 pkg/orchestration/builder_deploy_test.go diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index d943f132..23fad219 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -43,6 +43,31 @@ var gitOpsRenderCmd = &cobra.Command{ }, } +var gitOpsSnapshotCmd = &cobra.Command{ + Use: "snapshot [module]", + Short: "Render and validate the immutable service snapshot consumed by module generators", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + env, err := orchestration.SelectEnvironment(workspace, gitOpsEnv) + if err != nil { + return err + } + result, err := gitops.RenderModuleSnapshot(ctx, workspace, module, env, gitOpsProject, cli.NewOutputSink()) + if err != nil { + return err + } + cli.Info("Rendered service snapshot %s", result.Path) + cli.Info("Digest %s", result.Inventory.Digest) + return nil + }, +} + var gitOpsPlanCmd = &cobra.Command{ Use: "plan [module]", Short: "Inspect the exact GitOps publication diff", @@ -243,11 +268,13 @@ var ( ) func init() { - GitOpsCmd.AddCommand(gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd) - for _, command := range []*cobra.Command{gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} { + GitOpsCmd.AddCommand(gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd) + for _, command := range []*cobra.Command{gitOpsSnapshotCmd, gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} { command.Flags().StringVar(&gitOpsEnv, "env", "local", "Environment to promote") } - gitOpsRenderCmd.Flags().StringVar(&gitOpsProject, "app-project", "", "AppProject contract for cluster-scoped resources") + for _, command := range []*cobra.Command{gitOpsSnapshotCmd, gitOpsRenderCmd} { + command.Flags().StringVar(&gitOpsProject, "app-project", "", "AppProject contract for cluster-scoped resources") + } for _, command := range []*cobra.Command{gitOpsPlanCmd, gitOpsPublishCmd, gitOpsRollbackCmd} { command.Flags().StringVar(&gitOpsBranch, "promotion-branch", "", "Promotion branch (deterministic default when empty)") command.Flags().BoolVar(&gitOpsLocal, "local", false, "Use a disposable local file Git remote for k3d qualification") diff --git a/cmd/deploy/service_test.go b/cmd/deploy/service_test.go index 1f6e7f0e..1cdfd44c 100644 --- a/cmd/deploy/service_test.go +++ b/cmd/deploy/service_test.go @@ -28,7 +28,7 @@ func TestGitOpsCommandExposesCompletePromotionLifecycle(t *testing.T) { t.Fatalf("gitops %s is not exclusively RunE", command.Name()) } } - for _, name := range []string{"render", "plan", "publish", "observe", "rollback"} { + for _, name := range []string{"snapshot", "render", "plan", "publish", "observe", "rollback"} { if !names[name] { t.Errorf("gitops %s command is missing", name) } diff --git a/go.mod b/go.mod index b2b2531b..3580445d 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6 + github.com/codefly-dev/core v0.2.52 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.0 github.com/codefly-dev/sdk-go v0.1.58 diff --git a/go.sum b/go.sum index e41ab070..39216ed1 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6 h1:kxbKE3GNzNw2GkoRgLI9tI+WnD2mQO4rWmYGT4M7uzk= -github.com/codefly-dev/core v0.2.51-0.20260728162331-e971e885abd6/go.mod h1:cTztO7gmPNZuvjGfAedqRuyTjowYOkvMCVXcZydkEFg= +github.com/codefly-dev/core v0.2.52 h1:bHudneVK/yLMxEc163v9Hm590E1Z6x7HWkZVdoA3CIA= +github.com/codefly-dev/core v0.2.52/go.mod h1:hHJm+wOsHxpxKn4UMiFqBrGy0BE56iby9yptfygbdR4= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/gitops/observe_test.go b/pkg/gitops/observe_test.go index 65716e5f..e4192fa9 100644 --- a/pkg/gitops/observe_test.go +++ b/pkg/gitops/observe_test.go @@ -246,7 +246,7 @@ func observedPublication(t *testing.T) ObserveRequest { t.Helper() remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) - destination := filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments") + destination := filepath.Join(workspace.Dir(), "deployments", "modules", "payments") _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index cd7c52a4..20f31822 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -4,27 +4,38 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/codefly-dev/cli/pkg/orchestration" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/resources" ) func RenderModule(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) { - destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "modules", module.Name) + return renderModuleTree(ctx, workspace, module, env, project, sink, true) +} + +func RenderModuleSnapshot(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) { + return renderModuleTree(ctx, workspace, module, env, project, sink, false) +} + +func renderModuleTree( + ctx context.Context, + workspace *resources.Workspace, + module *resources.Module, + env *resources.Environment, + project string, + sink orchestration.OutputSink, + includeBootstrap bool, +) (RenderResult, error) { + destination := filepath.Join(workspace.Dir(), "deployments", "modules", module.Name) return RenderOwnedTree(ctx, &RenderOptions{ Destination: destination, Module: module.Name, Environment: env.Name, AppProject: project, - Promotable: !env.IsK3d(), + Promotable: true, }, func(ctx context.Context, stage string) error { - static := filepath.Join(module.Dir(), "deployment", "kustomize") - if info, err := os.Stat(static); err == nil && info.IsDir() { - if err := copyTree(static, filepath.Join(stage, "kustomize")); err != nil { - return fmt.Errorf("copy module kustomize tree: %w", err) - } - } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("inspect module kustomize tree: %w", err) - } for _, reference := range module.ServiceReferences { service, err := module.LoadServiceFromName(ctx, reference.Name) if err != nil { @@ -37,16 +48,86 @@ func RenderModule(ctx context.Context, workspace *resources.Workspace, module *r return fmt.Errorf("render service %s: %w", service.Name, err) } } - return nil + if !includeBootstrap { + return nil + } + return generateEnvironmentBootstrap(ctx, workspace, module, env.Name, stage) }) } +func generateEnvironmentBootstrap( + ctx context.Context, + workspace *resources.Workspace, + module *resources.Module, + environment, + destination string, +) error { + if module.Agent == nil { + _, err := copySelectedEnvironmentBootstrap(module.Dir(), environment, destination) + return err + } + binary, err := module.Agent.Path(ctx) + if err != nil { + return fmt.Errorf("resolve module generator %s: %w", module.Agent.Identifier(), err) + } + target := filepath.Join(destination, "kustomize") + command := exec.CommandContext( + ctx, + binary, + "gitops", + module.Dir(), + workspace.Dir(), + environment, + target, + ) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf( + "generate %s module bootstrap with %s: %w: %s", + environment, + module.Agent.Identifier(), + err, + strings.TrimSpace(string(output)), + ) + } + return nil +} + +func copySelectedEnvironmentBootstrap(moduleDir, environment, destination string) (bool, error) { + static := filepath.Join(moduleDir, "deployment", "kustomize") + info, err := os.Stat(static) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect module kustomize tree: %w", err) + } + if !info.IsDir() { + return false, fmt.Errorf("module kustomize path is not a directory") + } + environmentBootstrap := filepath.Join(static, "overlays", environment) + info, err = os.Stat(environmentBootstrap) + if err != nil { + return false, fmt.Errorf("inspect generated %s module bootstrap: %w", environment, err) + } + if !info.IsDir() { + return false, fmt.Errorf("generated %s module bootstrap is not a directory", environment) + } + if err := copyTree( + environmentBootstrap, + filepath.Join(destination, "kustomize", "overlays", environment), + ); err != nil { + return false, fmt.Errorf("copy generated %s module bootstrap: %w", environment, err) + } + return true, nil +} + func RenderService(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, project string, standAlone bool, sink orchestration.OutputSink) (RenderResult, error) { destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "services", module.Name, service.Name) return RenderOwnedTree(ctx, &RenderOptions{ Destination: destination, Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project, - Promotable: !env.IsK3d(), + Promotable: true, }, func(ctx context.Context, stage string) error { return renderServiceFlow(ctx, workspace, module, service, env, standAlone, sink, serviceRenderDestinations(stage)) }) @@ -88,6 +169,9 @@ func renderServiceFlow( return err } flow.WithDeploymentDestination(destination) + flow.WithKubernetesOutputProfile( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + ) if err := flow.Deploy(ctx); err != nil { return err } diff --git a/pkg/gitops/orchestrate_test.go b/pkg/gitops/orchestrate_test.go index bcb8ad6a..d506ec5f 100644 --- a/pkg/gitops/orchestrate_test.go +++ b/pkg/gitops/orchestrate_test.go @@ -1,23 +1,39 @@ package gitops import ( + "os" "path/filepath" "testing" - - "github.com/codefly-dev/core/resources" ) -func TestServiceRenderDestinationsKeepDependenciesInDistinctOwnedPaths(t *testing.T) { - resolve := serviceRenderDestinations("/render") - api := resolve(&resources.Module{Name: "payments"}, &resources.Service{Name: "api"}) - database := resolve(&resources.Module{Name: "platform"}, &resources.Service{Name: "postgres"}) - if api != filepath.Join("/render", "modules", "payments", "services", "api") { - t.Fatalf("origin destination = %q", api) +func TestCopySelectedEnvironmentBootstrapExcludesOtherEnvironments(t *testing.T) { + module := t.TempDir() + for _, environment := range []string{"local", "aws"} { + root := filepath.Join(module, "deployment", "kustomize", "overlays", environment) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(environment+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + destination := t.TempDir() + + copied, err := copySelectedEnvironmentBootstrap(module, "local", destination) + if err != nil { + t.Fatal(err) + } + if !copied { + t.Fatal("selected environment bootstrap was not copied") + } + data, err := os.ReadFile(filepath.Join(destination, "kustomize", "overlays", "local", "kustomization.yaml")) + if err != nil { + t.Fatal(err) } - if database != filepath.Join("/render", "modules", "platform", "services", "postgres") { - t.Fatalf("dependency destination = %q", database) + if string(data) != "local\n" { + t.Fatalf("selected bootstrap = %q", data) } - if api == database { - t.Fatal("origin and dependency render destinations collide") + if _, err := os.Stat(filepath.Join(destination, "kustomize", "overlays", "aws")); !os.IsNotExist(err) { + t.Fatalf("unselected bootstrap was copied: %v", err) } } diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index 7f50870a..2eedb14e 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -103,7 +103,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request if err != nil { return nil, err } - rendered := filepath.Join(workspace.Dir(), "deployments", "environments", request.Environment, "modules", request.Module) + rendered := filepath.Join(workspace.Dir(), "deployments", "modules", request.Module) var inventory Inventory if restoreRevision == "" { if err := ValidateRenderedTree(rendered, "", true); err != nil { @@ -130,7 +130,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request cleanup() return nil, err } - targetPath := filepath.ToSlash(filepath.Join(pathRoot, request.Environment, "modules", request.Module)) + targetPath := filepath.ToSlash(filepath.Join(pathRoot, "deployments", "modules", request.Module)) target, err := confinedJoin(repo, targetPath) if err != nil { return fail(err) diff --git a/pkg/gitops/publish_test.go b/pkg/gitops/publish_test.go index 57c0527c..356886be 100644 --- a/pkg/gitops/publish_test.go +++ b/pkg/gitops/publish_test.go @@ -33,7 +33,7 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { if plan.ID == "" || plan.Diff == "" || len(plan.Changed) == 0 { t.Fatalf("publication plan is not inspectable: %+v", plan) } - if plan.Path != "environments/production/modules/payments" { + if plan.Path != "environments/deployments/modules/payments" { t.Fatalf("publication path = %q", plan.Path) } if _, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, mutationauthority.PreparedPermit{}); err == nil || !strings.Contains(err.Error(), "prepared authority") { @@ -304,7 +304,7 @@ gitops: func renderPublishFixture(t *testing.T, root, module, environment, name string) { t.Helper() - destination := filepath.Join(root, "deployments", "environments", environment, "modules", module) + destination := filepath.Join(root, "deployments", "modules", module) _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: module, Environment: environment, Promotable: true, }, func(ctx context.Context, stage string) error { diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go index c6e08cb0..a36a73df 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -24,7 +24,7 @@ func TestLocalK3dDisposableGitQualification(t *testing.T) { remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) _, err := RenderOwnedTree(context.Background(), &RenderOptions{ - Destination: filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments"), + Destination: filepath.Join(workspace.Dir(), "deployments", "modules", "payments"), Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, }, func(ctx context.Context, root string) error { if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 @@ -117,7 +117,7 @@ spec: source: repoURL: %s targetRevision: main - path: environments/local/modules/payments + path: environments/deployments/modules/payments destination: server: https://kubernetes.default.svc namespace: payments diff --git a/pkg/gitops/render.go b/pkg/gitops/render.go index b40d8de6..0fd84a9e 100644 --- a/pkg/gitops/render.go +++ b/pkg/gitops/render.go @@ -567,8 +567,10 @@ func inspectValue(value any, path []string, promotable bool) error { if placeholderPattern.MatchString(typed) { return fmt.Errorf("%s contains an unresolved placeholder", strings.Join(path, ".")) } - if err := validateURLValue(strings.Join(path, "."), typed); err != nil { - return err + if isURLPath(path) { + if err := validateURLValue(strings.Join(path, "."), typed); err != nil { + return err + } } if isAuthorityPath(path) && strings.Contains(typed, "*") { return fmt.Errorf("%s contains wildcard authority", strings.Join(path, ".")) @@ -577,6 +579,23 @@ func inspectValue(value any, path []string, promotable bool) error { return nil } +func isURLPath(path []string) bool { + for index := len(path) - 1; index >= 0; index-- { + part := path[index] + if strings.HasPrefix(part, "[") { + continue + } + normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(part)) + return strings.Contains(normalized, "url") || + strings.Contains(normalized, "uri") || + normalized == "server" || + normalized == "repository" || + normalized == "repo" || + normalized == "sourcerepos" + } + return false +} + func extendPath(path []string, part string) []string { extended := make([]string, len(path)+1) copy(extended, path) diff --git a/pkg/gitops/render_test.go b/pkg/gitops/render_test.go index 3d8b4068..ff8e63d6 100644 --- a/pkg/gitops/render_test.go +++ b/pkg/gitops/render_test.go @@ -350,6 +350,17 @@ rules: [] } } +func TestRenderAcceptsOCIImageSelectorsWithoutTreatingThemAsURLs(t *testing.T) { + err := inspectValue(map[string]any{ + "images": []any{ + map[string]any{"name": "image:tag"}, + }, + }, nil, false) + if err != nil { + t.Fatal(err) + } +} + func TestRenderAllowsOnlyClusterScopeDeclaredBySelectedProject(t *testing.T) { manifests := pinnedDeployment + `--- apiVersion: argoproj.io/v1alpha1 diff --git a/pkg/orchestration/builder_deploy.go b/pkg/orchestration/builder_deploy.go index c3b3a50b..bba08ef8 100644 --- a/pkg/orchestration/builder_deploy.go +++ b/pkg/orchestration/builder_deploy.go @@ -2,11 +2,16 @@ package orchestration import ( "context" + "fmt" + "path/filepath" "github.com/codefly-dev/cli/pkg/builder" "github.com/codefly-dev/cli/pkg/deployments" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/wool" + "google.golang.org/protobuf/proto" ) func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { @@ -61,6 +66,28 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if b.world.DeploymentDestination != nil { deploy.GetKubernetes().Destination = b.world.DeploymentDestination(b.instance.Module, b.instance.Service) } + profile := kubernetesOutputProfile(b.world) + deploy.GetKubernetes().Profile = profile + deploy.GetKubernetes().ValidateServerSide = + profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 && + b.world.Env.IsK3d() + validationContext := "" + if profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + if deploy.GetKubernetes().GetValidateServerSide() { + kubeconfig, contextName, targetErr := kubernetesValidationTarget(ctx, b.world.Env) + if targetErr != nil { + return nil, w.Wrapf(targetErr, "cannot resolve promotable GitOps validation target") + } + deploy.GetKubernetes().ValidationKubeconfig = kubeconfig + deploy.GetKubernetes().ValidationContext = contextName + validationContext = contextName + } + conf, dependenciesConfigurations, deploy.GetKubernetes().SecretReferences, err = + promotableDeploymentInputs(b.instance.Service.Name, conf, dependenciesConfigurations) + if err != nil { + return nil, w.Wrapf(err, "cannot prepare promotable GitOps inputs") + } + } // Build the request w.Debug("deployments", wool.Field("deployments", deploy)) @@ -80,6 +107,9 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if resp.State != nil && resp.State.State != builderv0.DeploymentStatus_SUCCESS { return nil, w.NewError("cant deploy service instance") } + if err = validateKubernetesDeploymentOutput(resp.GetDeployment(), profile, validationContext); err != nil { + return nil, w.Wrapf(err, "cannot accept Kubernetes deployment output") + } err = b.world.ConfigurationManager.ExposeConfiguration(ctx, b.instance.Identity, resp.Configuration) if err != nil { @@ -112,3 +142,133 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { } return outputProperty, nil } + +func kubernetesValidationTarget(ctx context.Context, environment *resources.Environment) (string, string, error) { + if environment == nil || environment.Cluster == nil { + return "", "", fmt.Errorf("environment must declare a Kubernetes cluster") + } + if environment.Cluster.Context == "" { + return "", "", fmt.Errorf("environment %q must declare cluster.context", environment.Name) + } + kubeconfig, err := deployments.GetK8sConfig(ctx, environment) + if err != nil { + return "", "", err + } + if len(filepath.SplitList(kubeconfig)) != 1 { + return "", "", fmt.Errorf("environment %q must declare exactly one kubeconfig, got %q", environment.Name, kubeconfig) + } + kubeconfig, err = filepath.Abs(kubeconfig) + if err != nil { + return "", "", fmt.Errorf("resolve kubeconfig %q: %w", kubeconfig, err) + } + return kubeconfig, environment.Cluster.Context, nil +} + +func kubernetesOutputProfile(world *World) builderv0.KubernetesOutputProfile { + if world.KubernetesOutputProfile != builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_UNSPECIFIED { + return world.KubernetesOutputProfile + } + if world.Env.IsK3d() { + return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 + } + return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 +} + +func promotableDeploymentInputs( + secretName string, + configuration *basev0.Configuration, + dependencies []*basev0.Configuration, +) (*basev0.Configuration, []*basev0.Configuration, map[string]*builderv0.KubernetesSecretKeyReference, error) { + references := make(map[string]*builderv0.KubernetesSecretKeyReference) + sanitized, err := sanitizePromotableConfiguration(secretName, configuration, references) + if err != nil { + return nil, nil, nil, err + } + sanitizedDependencies := make([]*basev0.Configuration, len(dependencies)) + for index, dependency := range dependencies { + sanitizedDependencies[index], err = sanitizePromotableConfiguration(secretName, dependency, references) + if err != nil { + return nil, nil, nil, err + } + } + return sanitized, sanitizedDependencies, references, nil +} + +func sanitizePromotableConfiguration( + secretName string, + configuration *basev0.Configuration, + references map[string]*builderv0.KubernetesSecretKeyReference, +) (*basev0.Configuration, error) { + if configuration == nil { + return nil, nil + } + sanitized := proto.Clone(configuration).(*basev0.Configuration) + for _, information := range sanitized.GetInfos() { + if information.GetData().GetSecret() { + return nil, fmt.Errorf("secret configuration data %q has no Kubernetes Secret key reference", information.GetName()) + } + values := information.GetConfigurationValues() + kept := values[:0] + for _, value := range values { + if value.GetSecret() || resources.IsSensitiveKey(value.GetKey()) { + value.Secret = true + continue + } + kept = append(kept, value) + } + information.ConfigurationValues = kept + } + + referenceSource := proto.Clone(configuration).(*basev0.Configuration) + for _, information := range referenceSource.GetInfos() { + for _, value := range information.GetConfigurationValues() { + value.Secret = value.GetSecret() || resources.IsSensitiveKey(value.GetKey()) + } + } + for _, environmentVariable := range resources.ConfigurationAsEnvironmentVariables(referenceSource, true) { + references[environmentVariable.Key] = &builderv0.KubernetesSecretKeyReference{ + Name: secretName + "-secrets", + Key: environmentVariable.Key, + } + } + return sanitized, nil +} + +func validateKubernetesDeploymentOutput( + deployment *builderv0.DeploymentOutput, + profile builderv0.KubernetesOutputProfile, + validationContext string, +) error { + kubernetes := deployment.GetKubernetes() + if kubernetes == nil { + return fmt.Errorf("builder returned no Kubernetes deployment output") + } + if kubernetes.GetProfile() != profile { + return fmt.Errorf("builder returned profile %s for requested profile %s", kubernetes.GetProfile(), profile) + } + if kubernetes.GetContractVersion() == "" { + return fmt.Errorf("builder returned no Kubernetes contract version") + } + validation := kubernetes.GetValidation() + if validation.GetStaticValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { + return fmt.Errorf("builder did not pass static Kubernetes validation") + } + if profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + if validationContext != "" { + if validation.GetServerSideValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { + return fmt.Errorf("builder did not pass server-side Kubernetes validation") + } + if validation.GetValidatedContext() != validationContext { + return fmt.Errorf( + "builder validated Kubernetes context %q for requested context %q", + validation.GetValidatedContext(), + validationContext, + ) + } + } + if !validation.GetPromotable() { + return fmt.Errorf("builder did not return a promotable Kubernetes deployment") + } + } + return nil +} diff --git a/pkg/orchestration/builder_deploy_test.go b/pkg/orchestration/builder_deploy_test.go new file mode 100644 index 00000000..9334582a --- /dev/null +++ b/pkg/orchestration/builder_deploy_test.go @@ -0,0 +1,174 @@ +package orchestration + +import ( + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestPromotableDeploymentInputsReplaceSecretValuesWithReferences(t *testing.T) { + configuration := &basev0.Configuration{ + Origin: "users/accounts", + Infos: []*basev0.ConfigurationInformation{{ + Name: "database", + ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: "host", Value: "postgres.users.svc"}, + {Key: "password", Value: "own-secret", Secret: true}, + }, + }}, + } + dependency := &basev0.Configuration{ + Origin: "infra/postgres", + Infos: []*basev0.ConfigurationInformation{{ + Name: "postgres", + ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: "connection", Value: "postgres://credential-bearing-value"}, + {Key: "port", Value: "5432"}, + }, + }}, + } + originalConfiguration := proto.Clone(configuration) + originalDependency := proto.Clone(dependency) + + sanitized, dependencies, references, err := promotableDeploymentInputs( + "accounts", + configuration, + []*basev0.Configuration{dependency}, + ) + require.NoError(t, err) + + require.Equal(t, originalConfiguration, configuration) + require.Equal(t, originalDependency, dependency) + require.Equal(t, []*basev0.ConfigurationValue{ + {Key: "host", Value: "postgres.users.svc"}, + }, sanitized.GetInfos()[0].GetConfigurationValues()) + require.Equal(t, []*basev0.ConfigurationValue{ + {Key: "port", Value: "5432"}, + }, dependencies[0].GetInfos()[0].GetConfigurationValues()) + require.Equal(t, map[string]*builderv0.KubernetesSecretKeyReference{ + "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__DATABASE__PASSWORD": { + Name: "accounts-secrets", + Key: "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__DATABASE__PASSWORD", + }, + "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__CONNECTION": { + Name: "accounts-secrets", + Key: "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__CONNECTION", + }, + }, references) +} + +func TestPromotableDeploymentInputsRejectSecretStructuredData(t *testing.T) { + configuration := &basev0.Configuration{ + Origin: "users/accounts", + Infos: []*basev0.ConfigurationInformation{{ + Name: "certificate", + Data: &basev0.ConfigurationData{ + Kind: "pem", + Content: []byte("secret certificate"), + Secret: true, + }, + }}, + } + + _, _, _, err := promotableDeploymentInputs("accounts", configuration, nil) + require.EqualError(t, err, `secret configuration data "certificate" has no Kubernetes Secret key reference`) +} + +func TestKubernetesOutputProfileDefaultsByClusterAndHonorsExplicitGitOps(t *testing.T) { + require.Equal(t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + kubernetesOutputProfile(&World{Env: resources.LocalEnvironment()}), + ) + require.Equal(t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + kubernetesOutputProfile(&World{Env: &resources.Environment{ + Name: "aws", + Cluster: &resources.EnvironmentCluster{Kind: "eks"}, + }}), + ) + require.Equal(t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + kubernetesOutputProfile(&World{ + Env: resources.LocalEnvironment(), + KubernetesOutputProfile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + }), + ) +} + +func TestValidateKubernetesDeploymentOutputRejectsProfileMismatch(t *testing.T) { + output := validKubernetesDeploymentOutput( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + ) + + err := validateKubernetesDeploymentOutput( + output, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + "k3d-codefly-local", + ) + require.EqualError(t, err, + "builder returned profile KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 for requested profile KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1", + ) +} + +func TestValidateKubernetesDeploymentOutputAcceptsPromotableContract(t *testing.T) { + output := validKubernetesDeploymentOutput( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + ) + + require.NoError(t, validateKubernetesDeploymentOutput( + output, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + "k3d-codefly-local", + )) +} + +func TestValidateKubernetesDeploymentOutputRejectsDifferentValidationContext(t *testing.T) { + output := validKubernetesDeploymentOutput( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + ) + + err := validateKubernetesDeploymentOutput( + output, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + "mind-aws", + ) + require.EqualError(t, err, + `builder validated Kubernetes context "k3d-codefly-local" for requested context "mind-aws"`, + ) +} + +func TestValidateKubernetesDeploymentOutputAcceptsOfflinePromotableContract(t *testing.T) { + output := validKubernetesDeploymentOutput( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + ) + output.GetKubernetes().Validation.ServerSideValidation = + builderv0.KubernetesManifestValidation_STATUS_NOT_RUN + output.GetKubernetes().Validation.ValidatedContext = "" + + require.NoError(t, validateKubernetesDeploymentOutput( + output, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + "", + )) +} + +func validKubernetesDeploymentOutput(profile builderv0.KubernetesOutputProfile) *builderv0.DeploymentOutput { + return &builderv0.DeploymentOutput{ + Kind: &builderv0.DeploymentOutput_Kubernetes{ + Kubernetes: &builderv0.KubernetesDeploymentOutput{ + Profile: profile, + ContractVersion: "kubernetes-output/v1", + Validation: &builderv0.KubernetesManifestValidation{ + StaticValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, + ServerSideValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, + Promotable: true, + ValidatedContext: "k3d-codefly-local", + }, + }, + }, + } +} diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index b10b42cf..a33b34c7 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -146,10 +146,11 @@ func MapValues[K comparable, V any](m map[K]V) []V { } type World struct { - Env *resources.Environment - Mode Mode - Workspace *resources.Workspace - DeploymentDestination func(*resources.Module, *resources.Service) string + Env *resources.Environment + Mode Mode + Workspace *resources.Workspace + DeploymentDestination func(*resources.Module, *resources.Service) string + KubernetesOutputProfile builderv0.KubernetesOutputProfile // DAG Dependencies *architecture.ServiceDependencies @@ -1577,6 +1578,10 @@ func (flow *Flow) WithDeploymentDestination(destination func(*resources.Module, flow.world.DeploymentDestination = destination } +func (flow *Flow) WithKubernetesOutputProfile(profile builderv0.KubernetesOutputProfile) { + flow.world.KubernetesOutputProfile = profile +} + func (flow *Flow) WithStandAlone(alone bool) { flow.standAlone = alone }