From 22782d03e521e31bbd82a57e6277fe2ffa7d428e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Wed, 29 Jul 2026 15:54:05 +0200 Subject: [PATCH] Complete promotable GitOps publication (#156) --- cmd/deploy/gitops.go | 13 +- docs/commands.md | 25 +- go.mod | 2 +- go.sum | 4 +- pkg/deployments/kubernetes_test.go | 18 + pkg/deployments/manager.go | 28 +- pkg/gitops/observe.go | 139 ++++-- pkg/gitops/observe_test.go | 49 ++- pkg/gitops/orchestrate.go | 152 ++++++- pkg/gitops/orchestrate_test.go | 31 ++ pkg/gitops/publish.go | 617 ++++++++++++++++++++++++++- pkg/gitops/publish_test.go | 230 +++++++++- pkg/gitops/qualification_k3d_test.go | 194 +++++++-- pkg/gitops/render.go | 256 ++++++++++- pkg/gitops/render_test.go | 75 +++- pkg/gitops/types.go | 107 +++-- pkg/orchestration/builder_deploy.go | 127 +++++- pkg/orchestration/builder_test.go | 93 ++++ 18 files changed, 1942 insertions(+), 218 deletions(-) diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index d943f132..89cc4502 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -108,6 +108,7 @@ var gitOpsPublishCmd = &cobra.Command{ return fmt.Errorf("publication returned no result") } result := *mutationResult.GitOpsPublish + cli.Info("Service snapshot %s", result.SnapshotRevision) cli.Info("Signed commit %s", result.Commit) cli.Info("Tree %s", result.Tree) cli.Info("Pull request %s", result.PullRequest) @@ -130,6 +131,9 @@ var gitOpsObserveCmd = &cobra.Command{ if err != nil { return err } + if gitOpsRevision != "" && gitOpsRevision != publication.SnapshotRevision { + return fmt.Errorf("requested revision %s differs from published service snapshot %s", gitOpsRevision, publication.SnapshotRevision) + } plane, err := control.NewAt(workspace.Dir()) if err != nil { return err @@ -137,10 +141,10 @@ var gitOpsObserveCmd = &cobra.Command{ defer plane.Close() result, err := plane.ObserveGitOps(ctx, &gitops.ObserveRequest{ Module: module.Name, Environment: gitOpsEnv, AppProject: gitOpsProject, - Applications: gitOpsApplications, Revision: gitOpsRevision, + Applications: gitOpsApplications, Revision: publication.SnapshotRevision, Commit: publication.Commit, Tree: publication.Tree, RenderDigest: publication.RenderDigest, Repository: publication.Repository, Path: publication.Path, - PullRequest: publication.PullRequest, Timeout: gitOpsTimeout, + PullRequest: publication.PullRequest, Local: gitOpsLocal, Timeout: gitOpsTimeout, }) if err != nil { return err @@ -218,6 +222,7 @@ func printPublishPlan(plan *gitops.PublishPlan) { cli.Info("Base %s@%s", plan.BaseBranch, plan.BaseRevision) cli.Info("Promotion branch %s", plan.PromotionBranch) cli.Info("Render digest %s", plan.RenderDigest) + cli.Info("Service snapshot %s", plan.SnapshotRevision) cli.Info("Changed files:") for _, path := range plan.Changed { cli.Info(" %s", path) @@ -260,11 +265,11 @@ func init() { } gitOpsObserveCmd.Flags().StringVar(&gitOpsProject, "app-project", "", "Selected Argo CD AppProject") gitOpsObserveCmd.Flags().StringSliceVar(&gitOpsApplications, "application", nil, "Argo CD application to observe (repeatable)") - gitOpsObserveCmd.Flags().StringVar(&gitOpsRevision, "revision", "", "Exact reviewed Git revision Argo CD must reconcile") + gitOpsObserveCmd.Flags().StringVar(&gitOpsRevision, "revision", "", "Expected immutable service snapshot revision") + gitOpsObserveCmd.Flags().BoolVar(&gitOpsLocal, "local", false, "Observe a disposable local GitOps qualification") gitOpsObserveCmd.Flags().DurationVar(&gitOpsTimeout, "timeout", 10*time.Minute, "Maximum time to wait for Synced and Healthy") gitOpsRollbackCmd.Flags().StringVar(&gitOpsRollbackRevision, "to-revision", "", "Previously reviewed Git revision to re-promote") _ = gitOpsObserveCmd.MarkFlagRequired("app-project") _ = gitOpsObserveCmd.MarkFlagRequired("application") - _ = gitOpsObserveCmd.MarkFlagRequired("revision") _ = gitOpsRollbackCmd.MarkFlagRequired("to-revision") } diff --git a/docs/commands.md b/docs/commands.md index 8a8cdebe..47c1f3d2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -191,8 +191,7 @@ codefly deploy gitops publish payments --env production # After review and merge: codefly deploy gitops observe payments --env production \ --app-project payments \ - --application payments-api \ - --revision + --application payments-api # Recovery is another reviewed promotion, never a direct cluster mutation: codefly deploy gitops rollback payments --env production \ @@ -210,17 +209,19 @@ gitops: ``` Render first writes to a temporary sibling, rejects unsafe or non-promotable -manifests, and installs only -`deployments/environments//modules/`. The installed +manifests, and installs the selected environment bootstrap and exact service +graph under `deployments/modules/`. The installed `.codefly-render.json` contains the sorted file inventory and aggregate digest. -Publish clones `workspace.gitops.repo-url`, stages only -`//modules/`, prints a stable plan -and diff, then -uses a single-use prepared mutation to create a signed commit, push without -force, and open or update a pull request. Observe requires an approved, merged -pull request and verifies the published repository subtree digest, exact Argo -CD revision, source path, project authority, cluster identity, sync, operation, -and Healthy status before writing evidence under `.codefly/gitops/evidence/`. +Publish clones `workspace.gitops.repo-url`, commits and advertises the immutable +service snapshot under +`/deployments/modules//services`, invokes the +module generator against that exact snapshot, then creates the signed +publication commit and opens or updates a pull request. Planning does not +advertise the snapshot or mutate the remote. Observe requires an +approved, merged pull request and verifies the publication digest, the +snapshot revision bound into every Application, exact service paths, project +authority, cluster identity, sync, operation, and Healthy status before writing +evidence under `.codefly/gitops/evidence/`. Publishing requires configured Git commit signing and an authenticated `gh` session; observation uses the active authenticated `argocd` context. Rollback refuses a target revision unless a prior Healthy reviewed evidence receipt diff --git a/go.mod b/go.mod index b2b2531b..625f9ad8 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.51 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..1229bfe5 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.51 h1:DheTksUA9gqjipzAEoeBaV2LlR/IVHjfxzxXIAMm+bU= +github.com/codefly-dev/core v0.2.51/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/deployments/kubernetes_test.go b/pkg/deployments/kubernetes_test.go index eefd6972..8a07b7e2 100644 --- a/pkg/deployments/kubernetes_test.go +++ b/pkg/deployments/kubernetes_test.go @@ -35,6 +35,24 @@ func TestVerifyLocalK3dTargetRejectsRemoteKindsBeforeInspectingKubeconfig(t *tes } } +func TestKubernetesOutputProfileReservesEphemeralForVerifiedLocalApply(t *testing.T) { + require.Equal( + t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + KubernetesOutputProfile(nil), + ) + require.Equal( + t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + KubernetesOutputProfile(&RenderManager{}), + ) + require.Equal( + t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + KubernetesOutputProfile(&LocalApplyManager{}), + ) +} + func TestVerifyLocalK3dTargetRejectsStaleCurrentContext(t *testing.T) { harness := newKubernetesCommandHarness(t) harness.writeSelected(kubeconfigDocument("eks-production", "eks-production", "production", "https://eks.example.com")) diff --git a/pkg/deployments/manager.go b/pkg/deployments/manager.go index 073c0e06..fa83ce1b 100644 --- a/pkg/deployments/manager.go +++ b/pkg/deployments/manager.go @@ -70,18 +70,38 @@ func (r *evidenceRecorder) renderedTrees() []RenderedTreeEvidence { return trees } -func GetKubernetesDeployment(ctx context.Context, dockerBuildContext *builderv0.DockerBuildContext, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, namespace string) (*builderv0.Deployment, error) { +func GetKubernetesDeployment( + ctx context.Context, + dockerBuildContext *builderv0.DockerBuildContext, + workspace *resources.Workspace, + module *resources.Module, + service *resources.Service, + env *resources.Environment, + namespace string, + profile builderv0.KubernetesOutputProfile, + secretReferences map[string]*builderv0.KubernetesSecretKeyReference, +) (*builderv0.Deployment, error) { return &builderv0.Deployment{ Kind: &builderv0.Deployment_Kubernetes{ Kubernetes: &builderv0.KubernetesDeployment{ - BuildContext: dockerBuildContext, - Namespace: namespace, - Destination: KustomizeDir(ctx, workspace, module, service), + BuildContext: dockerBuildContext, + Namespace: namespace, + Destination: KustomizeDir(ctx, workspace, module, service), + Profile: profile, + SecretReferences: secretReferences, + ValidateServerSide: profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, }, }, }, nil } +func KubernetesOutputProfile(manager Manager) builderv0.KubernetesOutputProfile { + if _, directLocalApply := manager.(*LocalApplyManager); directLocalApply { + return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 + } + return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 +} + func NewLocalApplyManager(ctx context.Context, workspace *resources.Workspace, env *resources.Environment) (*LocalApplyManager, error) { target, err := VerifyLocalK3dTarget(ctx, env) if err != nil { diff --git a/pkg/gitops/observe.go b/pkg/gitops/observe.go index 10153db3..4be8c0c8 100644 --- a/pkg/gitops/observe.go +++ b/pkg/gitops/observe.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "regexp" "sort" "strings" @@ -103,10 +104,18 @@ func Observe(ctx context.Context, input *ObserveRequest) (ObserveResult, error) if err != nil { return ObserveResult{}, err } - if err := verifyPublishedRevision(ctx, request); err != nil { + inventory, err := verifyPublishedRevision(ctx, request) + if err != nil { return ObserveResult{}, err } - review, err := observeReview(ctx, request.PullRequest, request.Revision, request.Commit, request.Repository, request.Local) + servicePaths := make(map[string]struct{}, len(inventory.ServiceGraph)) + for _, service := range inventory.ServiceGraph { + if service.Managed { + continue + } + servicePaths[filepath.ToSlash(filepath.Join(request.Path, service.Path, "overlays", request.Environment))] = struct{}{} + } + review, err := observeReview(ctx, request.PullRequest, request.Commit, request.Repository, request.Local) if err != nil { return ObserveResult{}, err } @@ -133,7 +142,7 @@ func Observe(ctx context.Context, input *ObserveRequest) (ObserveResult, error) current := map[string]ApplicationEvidence{} allHealthy := true for _, name := range names { - _, evidence, done, err := observeApplication(observeCtx, &project, name, request) + _, evidence, done, err := observeApplication(observeCtx, &project, name, request, servicePaths) if err != nil { return ObserveResult{}, err } @@ -173,6 +182,7 @@ func Observe(ctx context.Context, input *ObserveRequest) (ObserveResult, error) Review: review, Repository: request.Repository, Path: request.Path, ArgoRevision: request.Revision, Health: healthyStatus, ObservedAt: time.Now().UTC(), } + observedPaths := make(map[string]struct{}, len(evidence.Applications)) for _, name := range names { item := last[name] if evidence.Cluster == "" { @@ -180,8 +190,22 @@ func Observe(ctx context.Context, input *ObserveRequest) (ObserveResult, error) } else if evidence.Cluster != item.Cluster { return ObserveResult{}, fmt.Errorf("applications reconcile to different clusters: %s and %s", evidence.Cluster, item.Cluster) } + if _, exists := observedPaths[item.Path]; exists { + return ObserveResult{}, fmt.Errorf("multiple Argo CD applications reconcile %s", item.Path) + } + observedPaths[item.Path] = struct{}{} evidence.Applications = append(evidence.Applications, item) } + if len(observedPaths) != len(servicePaths) { + missing := make([]string, 0, len(servicePaths)-len(observedPaths)) + for path := range servicePaths { + if _, exists := observedPaths[path]; !exists { + missing = append(missing, path) + } + } + sort.Strings(missing) + return ObserveResult{}, fmt.Errorf("no Argo CD application reconciled service paths %v", missing) + } clusterIdentity, err := loadClusterIdentity(ctx, evidence.Cluster) if err != nil { return ObserveResult{}, err @@ -304,7 +328,13 @@ func loadArgoProject(ctx context.Context, name string) (argoProject, error) { return project, nil } -func observeApplication(ctx context.Context, project *argoProject, name string, request *ObserveRequest) (argoApplication, ApplicationEvidence, bool, error) { +func observeApplication( + ctx context.Context, + project *argoProject, + name string, + request *ObserveRequest, + servicePaths map[string]struct{}, +) (argoApplication, ApplicationEvidence, bool, error) { output, err := command(ctx, "", "argocd", "app", "get", name, "--refresh", "-o", "json") if err != nil { return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("observe Argo CD application %s: %w", name, err) @@ -316,7 +346,7 @@ func observeApplication(ctx context.Context, project *argoProject, name string, if app.Metadata.Name != name { return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD returned application %q, expected %q", app.Metadata.Name, name) } - sourcePath, err := validateApplicationSource(project, name, &app, request) + sourcePath, err := validateApplicationSource(project, name, &app, request, servicePaths) if err != nil { return argoApplication{}, ApplicationEvidence{}, false, err } @@ -355,13 +385,27 @@ func observeApplication(ctx context.Context, project *argoProject, name string, return app, evidence, true, nil } -func validateApplicationSource(project *argoProject, name string, app *argoApplication, request *ObserveRequest) (string, error) { +func validateApplicationSource( + project *argoProject, + name string, + app *argoApplication, + request *ObserveRequest, + servicePaths map[string]struct{}, +) (string, error) { if len(app.Spec.Sources) > 0 { return "", fmt.Errorf("Argo CD application %s uses multiple sources; exact publication identity is ambiguous", name) } if app.Spec.Source.RepoURL == "" || app.Spec.Source.Path == "" { return "", fmt.Errorf("Argo CD application %s source repository and path are required", name) } + if app.Spec.Source.TargetRevision != request.Revision { + return "", fmt.Errorf( + "Argo CD application %s targets revision %q, expected immutable service snapshot %s", + name, + app.Spec.Source.TargetRevision, + request.Revision, + ) + } if !projectAllowsSource(project, app.Spec.Source.RepoURL) { return "", fmt.Errorf("Argo CD application %s source repository is outside AppProject %s", name, project.Metadata.Name) } @@ -378,12 +422,13 @@ func validateApplicationSource(project *argoProject, name string, app *argoAppli if err != nil { return "", fmt.Errorf("Argo CD application %s source path: %w", name, err) } - expectedPath, err := validateRelativePath(request.Path) - if err != nil { - return "", fmt.Errorf("published path: %w", err) - } - if sourcePath != expectedPath { - return "", fmt.Errorf("Argo CD application %s observes path %s, expected %s", name, sourcePath, expectedPath) + if _, exists := servicePaths[sourcePath]; !exists { + expected := make([]string, 0, len(servicePaths)) + for path := range servicePaths { + expected = append(expected, path) + } + sort.Strings(expected) + return "", fmt.Errorf("Argo CD application %s observes path %s, expected one of %v", name, sourcePath, expected) } return sourcePath, nil } @@ -497,69 +542,86 @@ func repositoriesMatch(left, right string) (bool, error) { return leftURL == rightURL, nil } -func verifyPublishedRevision(ctx context.Context, request *ObserveRequest) error { +func verifyPublishedRevision(ctx context.Context, request *ObserveRequest) (Inventory, error) { if _, err := validateRepositoryURL(request.Repository, request.Local); err != nil { - return fmt.Errorf("published repository: %w", err) + return Inventory{}, fmt.Errorf("published repository: %w", err) } targetPath, err := validateRelativePath(request.Path) if err != nil { - return fmt.Errorf("published path: %w", err) + return Inventory{}, fmt.Errorf("published path: %w", err) } temp, err := os.MkdirTemp("", "codefly-gitops-observe-") if err != nil { - return fmt.Errorf("create observation checkout: %w", err) + return Inventory{}, fmt.Errorf("create observation checkout: %w", err) } defer os.RemoveAll(temp) repo := filepath.Join(temp, "repo") if _, err := gitCommand(ctx, temp, "clone", "--quiet", "--no-checkout", "--", request.Repository, repo); err != nil { - return fmt.Errorf("clone published repository: %w", err) + return Inventory{}, fmt.Errorf("clone published repository: %w", err) } revision, err := gitCommand(ctx, repo, "rev-parse", request.Revision+"^{commit}") if err != nil { - return fmt.Errorf("resolve published revision %s: %w", request.Revision, err) + return Inventory{}, fmt.Errorf("resolve published revision %s: %w", request.Revision, err) } if revision != request.Revision { - return fmt.Errorf("published revision resolved to %s, expected %s", revision, request.Revision) + return Inventory{}, fmt.Errorf("published revision resolved to %s, expected %s", revision, request.Revision) } commit, err := gitCommand(ctx, repo, "rev-parse", request.Commit+"^{commit}") if err != nil { - return fmt.Errorf("resolve signed publication commit %s: %w", request.Commit, err) + return Inventory{}, fmt.Errorf("resolve signed publication commit %s: %w", request.Commit, err) } if commit != request.Commit { - return fmt.Errorf("signed publication commit resolved to %s, expected %s", commit, request.Commit) + return Inventory{}, fmt.Errorf("signed publication commit resolved to %s, expected %s", commit, request.Commit) } - if _, err := gitCommand(ctx, repo, "merge-base", "--is-ancestor", request.Commit, request.Revision); err != nil { - return fmt.Errorf("signed publication commit %s is not contained in reviewed revision %s", request.Commit, request.Revision) + if _, err := gitCommand(ctx, repo, "merge-base", "--is-ancestor", request.Revision, request.Commit); err != nil { + return Inventory{}, fmt.Errorf("service snapshot %s is not contained in signed publication commit %s", request.Revision, request.Commit) } tree, err := gitCommand(ctx, repo, "rev-parse", request.Commit+"^{tree}") if err != nil { - return err + return Inventory{}, err } if tree != request.Tree { - return fmt.Errorf("signed publication commit tree is %s, expected %s", tree, request.Tree) + return Inventory{}, fmt.Errorf("signed publication commit tree is %s, expected %s", tree, request.Tree) } rawCommit, err := gitCommand(ctx, repo, "cat-file", "-p", request.Commit) if err != nil { - return err + return Inventory{}, err } if !strings.Contains(rawCommit, "\ngpgsig ") { - return fmt.Errorf("publication commit %s is not signed", request.Commit) + return Inventory{}, fmt.Errorf("publication commit %s is not signed", request.Commit) } - if _, err := gitCommand(ctx, repo, "checkout", "--quiet", request.Revision, "--", targetPath); err != nil { - return fmt.Errorf("checkout published path %s at %s: %w", targetPath, request.Revision, err) + if _, err := gitCommand(ctx, repo, "checkout", "--quiet", request.Commit, "--", targetPath); err != nil { + return Inventory{}, fmt.Errorf("checkout published path %s at %s: %w", targetPath, request.Commit, err) } target := filepath.Join(repo, filepath.FromSlash(targetPath)) if err := ValidateRenderedTree(target, request.AppProject, true); err != nil { - return fmt.Errorf("validate reconciled Git tree: %w", err) + return Inventory{}, fmt.Errorf("validate reconciled Git tree: %w", err) } inventory, err := LoadInventory(target) if err != nil { - return err + return Inventory{}, err } if inventory.Digest != request.RenderDigest { - return fmt.Errorf("reconciled Git tree digest is %s, expected %s", inventory.Digest, request.RenderDigest) + return Inventory{}, fmt.Errorf("reconciled Git tree digest is %s, expected %s", inventory.Digest, request.RenderDigest) } - return nil + if _, err := gitCommand(ctx, repo, "checkout", "--quiet", request.Revision, "--", targetPath); err != nil { + return Inventory{}, fmt.Errorf("checkout service snapshot %s at %s: %w", targetPath, request.Revision, err) + } + if err := ValidateServiceSnapshot(target); err != nil { + return Inventory{}, fmt.Errorf("validate immutable service snapshot: %w", err) + } + snapshotInventory, err := LoadInventory(target) + if err != nil { + return Inventory{}, err + } + if snapshotInventory.Module != inventory.Module || + snapshotInventory.Environment != inventory.Environment || + snapshotInventory.AppProject != inventory.AppProject || + snapshotInventory.OwnedPath != inventory.OwnedPath || + !reflect.DeepEqual(snapshotInventory.ServiceGraph, inventory.ServiceGraph) { + return Inventory{}, fmt.Errorf("immutable service snapshot identity differs from the reviewed publication") + } + return inventory, nil } func loadClusterIdentity(ctx context.Context, cluster string) (string, error) { @@ -589,7 +651,7 @@ func loadClusterIdentity(ctx context.Context, cluster string) (string, error) { return "sha256:" + hex.EncodeToString(sum[:]), nil } -func observeReview(ctx context.Context, pullRequest, expectedRevision, publishedCommit, repository string, local bool) (ReviewEvidence, error) { +func observeReview(ctx context.Context, pullRequest, publishedCommit, repository string, local bool) (ReviewEvidence, error) { if pullRequest == "" { return ReviewEvidence{}, fmt.Errorf("promotion pull request is required") } @@ -610,12 +672,12 @@ func observeReview(ctx context.Context, pullRequest, expectedRevision, published return ReviewEvidence{}, fmt.Errorf("verify local promotion review ref: %w", err) } fields := strings.Fields(output) - if len(fields) != 2 || fields[0] != publishedCommit || publishedCommit != expectedRevision { - return ReviewEvidence{}, fmt.Errorf("local promotion review ref resolves to %q, expected %s", output, expectedRevision) + if len(fields) != 2 || fields[0] != publishedCommit { + return ReviewEvidence{}, fmt.Errorf("local promotion review ref resolves to %q, expected %s", output, publishedCommit) } return ReviewEvidence{ URL: pullRequest, State: "LOCAL_REVIEW_REF", ReviewDecision: "LOCAL_QUALIFIED", - MergeCommit: expectedRevision, + MergeCommit: publishedCommit, }, nil } if !githubPullPattern.MatchString(pullRequest) { @@ -667,9 +729,6 @@ func observeReview(ctx context.Context, pullRequest, expectedRevision, published if response.ReviewDecision != approvedReviewDecision { return ReviewEvidence{}, fmt.Errorf("promotion pull request review decision is %s, expected APPROVED", response.ReviewDecision) } - if response.MergeCommit.OID != expectedRevision { - return ReviewEvidence{}, fmt.Errorf("promotion merge revision is %s, expected %s", response.MergeCommit.OID, expectedRevision) - } published := false for _, commit := range response.Commits { if commit.OID == publishedCommit { diff --git a/pkg/gitops/observe_test.go b/pkg/gitops/observe_test.go index 65716e5f..df422d6f 100644 --- a/pkg/gitops/observe_test.go +++ b/pkg/gitops/observe_test.go @@ -19,7 +19,7 @@ const ( func TestObserveStoresExactHealthyArgoEvidence(t *testing.T) { request := observedPublication(t) installFakeArgo(t, argoProjectJSON(request.Repository), argoApplicationJSON( - "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", + "payments-api", request.Repository, observedServicePath(request), request.Revision, "Healthy", "Succeeded", )) result, err := Observe(context.Background(), &request) if err != nil { @@ -46,18 +46,18 @@ func TestObserveRejectsRevisionMismatchAndSharedResources(t *testing.T) { { name: "revision", application: func(request ObserveRequest) string { - return argoApplicationJSON("payments-api", request.Repository, request.Path, wrongRevision, "Healthy", "Succeeded") + return argoApplicationJSON("payments-api", request.Repository, observedServicePath(request), wrongRevision, "Healthy", "Succeeded") }, - want: "reconciled revision " + wrongRevision, + want: "targets revision", }, { name: "shared", application: func(request ObserveRequest) string { return fmt.Sprintf(`{ "metadata":{"name":"payments-api"}, - "spec":{"project":"payments","source":{"repoURL":%q,"path":%q},"destination":{"server":"https://cluster.example.com","namespace":"payments"}}, + "spec":{"project":"payments","source":{"repoURL":%q,"path":%q,"targetRevision":%q},"destination":{"server":"https://cluster.example.com","namespace":"payments"}}, "status":{"conditions":[{"type":"SharedResourceWarning","message":"Deployment/api is shared"}]} -}`, request.Repository, request.Path) +}`, request.Repository, observedServicePath(request), request.Revision) }, want: "shared resources", }, @@ -95,7 +95,7 @@ func TestObserveRejectsSourceAndProjectAuthorityViolations(t *testing.T) { return strings.Replace(argoProjectJSON(request.Repository), request.Repository, "*", 1) }, app: func(request ObserveRequest) string { - return argoApplicationJSON("payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded") + return argoApplicationJSON("payments-api", request.Repository, observedServicePath(request), request.Revision, "Healthy", "Succeeded") }, want: "wildcard source repository authority", }, @@ -103,7 +103,7 @@ func TestObserveRejectsSourceAndProjectAuthorityViolations(t *testing.T) { name: "cluster resource outside whitelist", project: func(request ObserveRequest) string { return argoProjectJSON(request.Repository) }, app: func(request ObserveRequest) string { - app := argoApplicationJSON("payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded") + app := argoApplicationJSON("payments-api", request.Repository, observedServicePath(request), request.Revision, "Healthy", "Succeeded") return strings.Replace(app, `"resources":[]`, `"resources":[{"group":"rbac.authorization.k8s.io","kind":"ClusterRole","name":"admin"}]`, 1) }, want: "outside AppProject", @@ -168,10 +168,10 @@ fi } t.Setenv("CODEFLY_TEST_ARGO_PROJECT", argoProjectJSON(request.Repository)) t.Setenv("CODEFLY_TEST_ARGO_APPLICATION", argoApplicationJSON( - "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", + "payments-api", request.Repository, observedServicePath(request), request.Revision, "Healthy", "Succeeded", )) t.Setenv("CODEFLY_TEST_ARGO_DEGRADED", argoApplicationJSON( - "payments-api", request.Repository, request.Path, request.Revision, "Degraded", "Succeeded", + "payments-api", request.Repository, observedServicePath(request), request.Revision, "Degraded", "Succeeded", )) t.Setenv("CODEFLY_TEST_ARGO_CLUSTER", `{"server":"https://cluster.example.com","name":"test","config":{"tls":true}}`) t.Setenv("CODEFLY_TEST_ARGO_COUNT", counter) @@ -192,7 +192,7 @@ func TestObserveRejectsUnverifiedLocalReviewReference(t *testing.T) { request := observedPublication(t) request.PullRequest = request.Repository + "#refs/codefly/reviews/missing" installFakeArgo(t, argoProjectJSON(request.Repository), argoApplicationJSON( - "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", + "payments-api", request.Repository, observedServicePath(request), request.Revision, "Healthy", "Succeeded", )) if _, err := Observe(context.Background(), &request); err == nil || !strings.Contains(err.Error(), "verify local promotion review ref") { t.Fatalf("unverified local review error = %v", err) @@ -222,7 +222,7 @@ printf '%s\n' "$CODEFLY_TEST_GH_RESPONSE" "commits":[{"oid":"cccccccccccccccccccccccccccccccccccccccc"}] }`) review, err := observeReview(context.Background(), - "https://github.com/codefly-dev/manifests/pull/42", observedRevision, signedCommit, + "https://github.com/codefly-dev/manifests/pull/42", signedCommit, "https://github.com/codefly-dev/manifests.git", false) if err != nil { t.Fatal(err) @@ -231,12 +231,12 @@ printf '%s\n' "$CODEFLY_TEST_GH_RESPONSE" t.Fatalf("review evidence = %+v", review) } if _, err := observeReview(context.Background(), - "https://github.com/codefly-dev/manifests/pull/42", observedRevision, wrongRevision, + "https://github.com/codefly-dev/manifests/pull/42", wrongRevision, "https://github.com/codefly-dev/manifests.git", false); err == nil { t.Fatal("review accepted a commit not present in the pull request") } if _, err := observeReview(context.Background(), - "https://github.com/codefly-dev/manifests/pull/42", observedRevision, signedCommit, + "https://github.com/codefly-dev/manifests/pull/42", signedCommit, "https://github.com/codefly-dev/other.git", false); err == nil || !strings.Contains(err.Error(), "repository differs") { t.Fatalf("cross-repository review error = %v", err) } @@ -246,11 +246,16 @@ 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, + Destination: destination, Module: "payments", Services: []string{"api"}, Environment: "local", + AppProject: "payments", OwnedPath: "environments/deployments/modules/payments", + ServiceGraph: promotableServiceGraph("payments", []string{"api"}), Promotable: true, }, func(ctx context.Context, stage string) error { + service := filepath.Join(stage, "services", "api", "overlays", "local") + if err := os.MkdirAll(service, 0o755); err != nil { + return err + } manifests := pinnedDeployment + `--- apiVersion: argoproj.io/v1alpha1 kind: AppProject @@ -264,7 +269,7 @@ spec: - namespace: payments server: https://cluster.example.com ` - return os.WriteFile(filepath.Join(stage, "manifests.yaml"), []byte(manifests), 0o644) + return os.WriteFile(filepath.Join(service, "manifests.yaml"), []byte(manifests), 0o644) }) if err != nil { t.Fatal(err) @@ -286,12 +291,16 @@ spec: WorkspaceRoot: workspace.Dir(), Module: "payments", Environment: "local", AppProject: "payments", Applications: []string{"payments-api"}, Repository: result.Repository, Path: result.Path, - Revision: result.Commit, Commit: result.Commit, Tree: result.Tree, + Revision: result.SnapshotRevision, Commit: result.Commit, Tree: result.Tree, RenderDigest: result.RenderDigest, PullRequest: result.PullRequest, Local: true, Timeout: time.Second, PollInterval: time.Millisecond, } } +func observedServicePath(request ObserveRequest) string { + return filepath.ToSlash(filepath.Join(request.Path, "services", "api", "overlays", request.Environment)) +} + func argoProjectJSON(repository string) string { return fmt.Sprintf(`{ "metadata":{"name":"payments"}, @@ -309,7 +318,7 @@ func argoApplicationJSON(name, repository, path, revision, health, operation str "metadata":{"name":%q}, "spec":{ "project":"payments", - "source":{"repoURL":%q,"path":%q,"targetRevision":"main"}, + "source":{"repoURL":%q,"path":%q,"targetRevision":%q}, "destination":{"server":"https://cluster.example.com","namespace":"payments"} }, "status":{ @@ -318,7 +327,7 @@ func argoApplicationJSON(name, repository, path, revision, health, operation str "operationState":{"phase":%q,"syncResult":{"revision":%q}}, "resources":[] } -}`, name, repository, path, revision, health, operation, revision) +}`, name, repository, path, revision, revision, health, operation, revision) } func installFakeArgo(t *testing.T, project, application string) { diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index cd7c52a4..0b00d437 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -7,48 +7,148 @@ import ( "path/filepath" "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" + "google.golang.org/protobuf/proto" + "gopkg.in/yaml.v3" ) 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 RenderOwnedTree(ctx, &RenderOptions{ + destination := filepath.Join(workspace.Dir(), "deployments", "modules", module.Name) + managed, err := selectedManagedServices(workspace, env.Name) + if err != nil { + return RenderResult{}, err + } + services := make([]string, 0, len(module.ServiceReferences)) + serviceGraph := make([]InventoryService, 0, len(module.ServiceReferences)) + declared := make(map[string]struct{}, len(module.ServiceReferences)) + for _, reference := range module.ServiceReferences { + declared[reference.Name] = struct{}{} + _, isManaged := managed[reference.Name] + service := InventoryService{Module: module.Name, Service: reference.Name, Managed: isManaged} + if !isManaged { + services = append(services, reference.Name) + service.Path = filepath.ToSlash(filepath.Join("services", reference.Name)) + } + serviceGraph = append(serviceGraph, service) + } + for service := range managed { + if _, exists := declared[service]; !exists { + return RenderResult{}, fmt.Errorf("managed service %q is outside module %q", service, module.Name) + } + } + ownedPath := filepath.ToSlash(filepath.Join("deployments", "modules", module.Name)) + if workspace.Gitops != nil { + ownedPath = filepath.ToSlash(filepath.Join(workspace.Gitops.Path, ownedPath)) + } + options := &RenderOptions{ Destination: destination, - Module: module.Name, Environment: env.Name, AppProject: project, - Promotable: !env.IsK3d(), - }, func(ctx context.Context, stage string) error { + Module: module.Name, Services: services, OwnedPath: ownedPath, ServiceGraph: serviceGraph, + Environment: env.Name, AppProject: project, + Promotable: true, + } + return RenderOwnedTree(ctx, options, 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) + if err := copyEnvironmentBootstrap(static, env.Name, filepath.Join(stage, "bootstrap")); err != nil { + return fmt.Errorf("copy module environment bootstrap: %w", err) } } else if err != nil && !os.IsNotExist(err) { return fmt.Errorf("inspect module kustomize tree: %w", err) } for _, reference := range module.ServiceReferences { + if _, isManaged := managed[reference.Name]; isManaged { + continue + } service, err := module.LoadServiceFromName(ctx, reference.Name) if err != nil { return fmt.Errorf("load service %s: %w", reference.Name, err) } target := filepath.Join(stage, "services", service.Name) - if err := renderServiceFlow(ctx, workspace, module, service, env, true, sink, func(_ *resources.Module, _ *resources.Service) string { + output, err := renderServiceFlow(ctx, workspace, module, service, env, true, sink, func(_ *resources.Module, _ *resources.Service) string { return target - }); err != nil { + }) + if err != nil { return fmt.Errorf("render service %s: %w", service.Name, err) } + for index := range options.ServiceGraph { + if options.ServiceGraph[index].Service == service.Name { + options.ServiceGraph[index].Output = kubernetesOutputInventory(output) + break + } + } } return nil }) } +func selectedManagedServices(workspace *resources.Workspace, environment string) (map[string]struct{}, error) { + data, err := os.ReadFile(filepath.Join(workspace.Dir(), resources.WorkspaceConfigurationName)) + if err != nil { + return nil, err + } + var document struct { + Environments []struct { + Name string `yaml:"name"` + ManagedServices map[string]any `yaml:"managed-services"` + } `yaml:"environments"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + return nil, fmt.Errorf("decode managed service graph: %w", err) + } + managed := map[string]struct{}{} + for _, candidate := range document.Environments { + if candidate.Name != environment { + continue + } + for service := range candidate.ManagedServices { + managed[service] = struct{}{} + } + break + } + return managed, nil +} + +func kubernetesOutputInventory(output *builderv0.KubernetesDeploymentOutput) *KubernetesOutputInventory { + if output == nil { + return nil + } + validation := output.GetValidation() + violations := append([]string{}, validation.GetViolations()...) + return &KubernetesOutputInventory{ + Kind: output.GetKind().String(), + Profile: output.GetProfile().String(), + ContractVersion: output.GetContractVersion(), + Validation: KubernetesValidationInventory{ + StaticValidation: validation.GetStaticValidation().String(), + ServerSideValidation: validation.GetServerSideValidation().String(), + Promotable: validation.GetPromotable(), + Violations: violations, + }, + } +} + +func copyEnvironmentBootstrap(source, environment, destination string) error { + selected := filepath.Join(source, "overlays", environment) + info, err := os.Stat(selected) + if err != nil { + return fmt.Errorf("select environment overlay %q: %w", environment, err) + } + if !info.IsDir() { + return fmt.Errorf("environment overlay %q is not a directory", environment) + } + return copyTree(selected, destination) +} + 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)) + _, err := renderServiceFlow(ctx, workspace, module, service, env, standAlone, sink, serviceRenderDestinations(stage)) + return err }) } @@ -67,10 +167,10 @@ func renderServiceFlow( standAlone bool, sink orchestration.OutputSink, destination func(*resources.Module, *resources.Service) string, -) (result error) { +) (_ *builderv0.KubernetesDeploymentOutput, result error) { flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode) if err != nil { - return err + return nil, err } if sink != nil { flow.WithOutputSink(sink) @@ -82,14 +182,34 @@ func renderServiceFlow( } }() if err := flow.InitManagers(ctx); err != nil { - return err + return nil, err } if err := flow.Load(ctx); err != nil { - return err + return nil, err } + capture := &deploymentOutputCapture{} + flow.WithDeploymentManager(capture) flow.WithDeploymentDestination(destination) if err := flow.Deploy(ctx); err != nil { - return err + return nil, err + } + return capture.output, nil +} + +type deploymentOutputCapture struct { + output *builderv0.KubernetesDeploymentOutput +} + +func (capture *deploymentOutputCapture) Handle( + _ context.Context, + _ *resources.Service, + _ *resources.Module, + output *builderv0.DeploymentOutput, +) error { + kubernetes := output.GetKubernetes() + if kubernetes == nil { + return fmt.Errorf("plugin returned no Kubernetes deployment output") } + capture.output = proto.Clone(kubernetes).(*builderv0.KubernetesDeploymentOutput) return nil } diff --git a/pkg/gitops/orchestrate_test.go b/pkg/gitops/orchestrate_test.go index bcb8ad6a..adf0a015 100644 --- a/pkg/gitops/orchestrate_test.go +++ b/pkg/gitops/orchestrate_test.go @@ -1,6 +1,7 @@ package gitops import ( + "os" "path/filepath" "testing" @@ -21,3 +22,33 @@ func TestServiceRenderDestinationsKeepDependenciesInDistinctOwnedPaths(t *testin t.Fatal("origin and dependency render destinations collide") } } + +func TestCopyEnvironmentBootstrapCopiesOnlySelectedEnvironment(t *testing.T) { + source := t.TempDir() + for _, environment := range []string{"local", "aws"} { + root := filepath.Join(source, "overlays", environment) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(root, "kustomization.yaml"), + []byte("resources:\n - "+environment+".yaml\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, environment+".yaml"), []byte(pinnedDeployment), 0o644); err != nil { + t.Fatal(err) + } + } + destination := filepath.Join(t.TempDir(), "bootstrap") + if err := copyEnvironmentBootstrap(source, "local", destination); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "local.yaml")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "aws.yaml")); !os.IsNotExist(err) { + t.Fatalf("unselected environment copied: %v", err) + } +} diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index 7f50870a..91fb5845 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -18,6 +18,7 @@ import ( "github.com/codefly-dev/cli/pkg/internal/mutationauthority" "github.com/codefly-dev/core/resources" + "gopkg.in/yaml.v3" ) var ( @@ -38,7 +39,7 @@ type preparedRepository struct { } func PlanPublish(ctx context.Context, workspace *resources.Workspace, request *PublishRequest) (PublishPlan, error) { - prepared, err := preparePublish(ctx, workspace, request, "") + prepared, err := preparePublish(ctx, workspace, request, "", false) if err != nil { return PublishPlan{}, err } @@ -53,13 +54,23 @@ func Publish(ctx context.Context, workspace *resources.Workspace, mutation *Publ if mutation.PlanID == "" { return PublishResult{}, fmt.Errorf("publish requires an inspected plan ID") } - prepared, err := preparePublish(ctx, workspace, &mutation.Request, "") + inspected, err := preparePublish(ctx, workspace, &mutation.Request, "", false) + if err != nil { + return PublishResult{}, err + } + if inspected.plan.ID != mutation.PlanID { + current := inspected.plan.ID + inspected.cleanup() + return PublishResult{}, fmt.Errorf("publish plan is stale: prepared %s, current %s", mutation.PlanID, current) + } + inspected.cleanup() + prepared, err := preparePublish(ctx, workspace, &mutation.Request, "", true) if err != nil { return PublishResult{}, err } defer prepared.cleanup() if prepared.plan.ID != mutation.PlanID { - return PublishResult{}, fmt.Errorf("publish plan is stale: prepared %s, current %s", mutation.PlanID, prepared.plan.ID) + return PublishResult{}, fmt.Errorf("publish plan changed while advertising its snapshot: prepared %s, current %s", mutation.PlanID, prepared.plan.ID) } return commitAndPublish(ctx, workspace, prepared, &mutation.Request) } @@ -95,7 +106,13 @@ func Rollback(ctx context.Context, workspace *resources.Workspace, mutation *Rol return commitAndPublish(ctx, workspace, prepared, &request) } -func preparePublish(ctx context.Context, workspace *resources.Workspace, request *PublishRequest, restoreRevision string) (*preparedRepository, error) { +func preparePublish( + ctx context.Context, + workspace *resources.Workspace, + request *PublishRequest, + restoreRevision string, + publishSnapshot bool, +) (*preparedRepository, error) { if err := validatePublishRequest(request); err != nil { return nil, err } @@ -103,7 +120,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 { @@ -116,6 +133,13 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request if inventory.Module != request.Module || inventory.Environment != request.Environment || inventory.Service != "" { return nil, fmt.Errorf("render inventory targets module %q environment %q service %q", inventory.Module, inventory.Environment, inventory.Service) } + expectedOwnedPath := filepath.ToSlash(filepath.Join(pathRoot, "deployments", "modules", request.Module)) + if inventory.OwnedPath != expectedOwnedPath { + return nil, fmt.Errorf("render inventory owns path %q, expected %q", inventory.OwnedPath, expectedOwnedPath) + } + if err := validateModuleServiceGraph(ctx, workspace, request.Module, request.Environment, inventory.ServiceGraph); err != nil { + return nil, err + } } promotionBranch := request.PromotionBranch @@ -130,7 +154,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) @@ -146,14 +170,45 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request } } } + startRevision := baseRevision + if branchRevision != "" { + startRevision = branchRevision + } + snapshotRevision := restoreRevision if restoreRevision == "" { - if err := replaceCloneTree(rendered, target); err != nil { - return fail(fmt.Errorf("stage rendered tree: %w", err)) + module, err := workspace.LoadModuleFromName(ctx, request.Module) + if err != nil { + return fail(fmt.Errorf("load rendered module %q: %w", request.Module, err)) + } + snapshotRevision, inventory, err = prepareServicePublication( + ctx, + repo, + startRevision, + target, + targetPath, + rendered, + inventory, + workspace, + module, + request.Environment, + config, + promotionBranch, + publishSnapshot, + ) + if err != nil { + return fail(err) } } else { if err := restoreCloneTree(ctx, repo, targetPath, restoreRevision); err != nil { return fail(err) } + snapshotRevision, err = bootstrapRevision(filepath.Join(target, "bootstrap")) + if err != nil { + return fail(err) + } + if snapshotRevision == "" { + snapshotRevision = restoreRevision + } if err := ValidateRenderedTree(target, "", true); err != nil { return fail(fmt.Errorf("validate rollback render: %w", err)) } @@ -165,7 +220,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request if _, err := gitCommand(ctx, repo, "add", "-A", "--", targetPath); err != nil { return fail(err) } - changed, err := stagedPaths(ctx, repo, targetPath) + changed, err := stagedPathsSince(ctx, repo, startRevision, targetPath) if err != nil { return fail(err) } @@ -174,7 +229,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request return fail(fmt.Errorf("promotion has no changes")) } } - diff, err := gitCommand(ctx, repo, "diff", "--cached", "--binary", "--", targetPath) + diff, err := gitCommand(ctx, repo, "diff", "--cached", "--binary", startRevision, "--", targetPath) if err != nil { return fail(err) } @@ -184,7 +239,8 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request PromotionBranch: promotionBranch, BranchRevision: branchRevision, ExistingCommit: branchRevision, Module: request.Module, Environment: request.Environment, - RenderDigest: inventory.Digest, Changed: changed, Diff: diff, + RenderDigest: inventory.Digest, SnapshotRevision: snapshotRevision, + Changed: changed, Diff: diff, } plan.ID, err = publishPlanID(&plan, restoreRevision) if err != nil { @@ -195,6 +251,496 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request }, nil } +func validateModuleServiceGraph( + ctx context.Context, + workspace *resources.Workspace, + moduleName string, + environment string, + rendered []InventoryService, +) error { + module, err := workspace.LoadModuleFromName(ctx, moduleName) + if err != nil { + return fmt.Errorf("load rendered module %q: %w", moduleName, err) + } + managed, err := selectedManagedServices(workspace, environment) + if err != nil { + return err + } + declared := make([]string, 0, len(module.ServiceReferences)) + for _, reference := range module.ServiceReferences { + declared = append(declared, reference.Name) + } + sort.Strings(declared) + actual := make([]string, 0, len(rendered)) + for _, service := range rendered { + if service.Module != moduleName { + return fmt.Errorf("rendered service %q belongs to module %q, expected %q", service.Service, service.Module, moduleName) + } + _, expectedManaged := managed[service.Service] + if service.Managed != expectedManaged { + return fmt.Errorf("rendered service %q managed state differs from environment %q", service.Service, environment) + } + actual = append(actual, service.Service) + } + sort.Strings(actual) + if len(actual) != len(declared) { + return fmt.Errorf("rendered service graph %v differs from module service graph %v", actual, declared) + } + for index := range declared { + if actual[index] != declared[index] { + return fmt.Errorf("rendered service graph %v differs from module service graph %v", actual, declared) + } + } + return nil +} + +func prepareServicePublication( + ctx context.Context, + repo string, + startRevision string, + target string, + targetPath string, + rendered string, + renderedInventory Inventory, + workspace *resources.Workspace, + module *resources.Module, + environment string, + config *resources.WorkspaceGitops, + promotionBranch string, + publishSnapshot bool, +) (string, Inventory, error) { + renderedServices := filepath.Join(rendered, "services") + if info, err := os.Stat(renderedServices); err != nil || !info.IsDir() { + return "", Inventory{}, fmt.Errorf("rendered module contains no service snapshot") + } + if err := replaceCloneTree(renderedServices, filepath.Join(target, "services")); err != nil { + return "", Inventory{}, fmt.Errorf("stage rendered services: %w", err) + } + servicePath := filepath.ToSlash(filepath.Join(targetPath, "services")) + if _, err := gitCommand(ctx, repo, "add", "-A", "--", servicePath); err != nil { + return "", Inventory{}, err + } + serviceChanges, err := stagedPathsSince(ctx, repo, "HEAD", servicePath) + if err != nil { + return "", Inventory{}, err + } + existingSnapshot, err := bootstrapRevision(filepath.Join(target, "bootstrap")) + if err != nil { + return "", Inventory{}, err + } + if existingSnapshot == "" { + existingSnapshot, _ = gitCommand(ctx, repo, "rev-parse", "HEAD^") + } + if err := removePublicationRemainder(target); err != nil { + return "", Inventory{}, err + } + serviceNames := inventoryServiceNames(renderedInventory.ServiceGraph) + snapshotOptions := &RenderOptions{ + Module: renderedInventory.Module, + Services: serviceNames, + OwnedPath: targetPath, + ServiceGraph: renderedInventory.ServiceGraph, + Environment: renderedInventory.Environment, + AppProject: renderedInventory.AppProject, + Promotable: true, + } + snapshotInventory, err := buildInventory(target, snapshotOptions) + if err != nil { + return "", Inventory{}, err + } + snapshotData, err := canonicalInventory(snapshotInventory) + if err != nil { + return "", Inventory{}, err + } + if err := os.WriteFile(filepath.Join(target, InventoryFilename), snapshotData, 0o644); err != nil { + return "", Inventory{}, fmt.Errorf("write service snapshot inventory: %w", err) + } + if err := ValidateServiceSnapshot(target); err != nil { + return "", Inventory{}, fmt.Errorf("validate service snapshot: %w", err) + } + snapshotChanged := len(serviceChanges) > 0 || existingSnapshot == "" + if !snapshotChanged { + existingData, showErr := gitCommandBytes(ctx, repo, "show", existingSnapshot+":"+targetPath+"/"+InventoryFilename) + snapshotChanged = showErr != nil || !bytes.Equal(existingData, snapshotData) + } + if !snapshotChanged { + _, diffErr := gitCommand(ctx, repo, "diff", "--quiet", existingSnapshot, "HEAD", "--", servicePath) + snapshotChanged = diffErr != nil + } + snapshotRevision := existingSnapshot + if snapshotChanged { + if _, err := gitCommand(ctx, repo, "add", "-A", "--", targetPath); err != nil { + return "", Inventory{}, err + } + snapshotRevision, err = commitServiceSnapshot(ctx, repo, module.Name, environment) + if err != nil { + return "", Inventory{}, err + } + } else if snapshotRevision == "" { + snapshotRevision = startRevision + } + if publishSnapshot { + if err := publishServiceSnapshot(ctx, repo, module.Name, environment, snapshotRevision); err != nil { + return "", Inventory{}, err + } + } + + if err := removePublicationRemainder(target); err != nil { + return "", Inventory{}, err + } + if module.Agent != nil { + if err := generateModuleBootstrap( + ctx, + workspace, + module, + environment, + config, + promotionBranch, + repo, + snapshotRevision, + filepath.ToSlash(filepath.Join(targetPath, InventoryFilename)), + filepath.Join(target, "bootstrap"), + !publishSnapshot, + ); err != nil { + return "", Inventory{}, err + } + } else { + renderedBootstrap := filepath.Join(rendered, "bootstrap") + if info, err := os.Stat(renderedBootstrap); err == nil && info.IsDir() { + if err := copyTree(renderedBootstrap, filepath.Join(target, "bootstrap")); err != nil { + return "", Inventory{}, fmt.Errorf("stage rendered bootstrap: %w", err) + } + } else if err != nil && !os.IsNotExist(err) { + return "", Inventory{}, fmt.Errorf("inspect rendered bootstrap: %w", err) + } + } + if err := validateBootstrapRevision(filepath.Join(target, "bootstrap"), snapshotRevision); err != nil { + return "", Inventory{}, err + } + if module.Agent != nil { + if err := validateBootstrapServiceGraph( + filepath.Join(target, "bootstrap"), + targetPath, + serviceNames, + environment, + ); err != nil { + return "", Inventory{}, err + } + } + + options := &RenderOptions{ + Module: renderedInventory.Module, + Services: serviceNames, + OwnedPath: targetPath, + ServiceGraph: renderedInventory.ServiceGraph, + Environment: renderedInventory.Environment, + AppProject: renderedInventory.AppProject, + Promotable: true, + } + if err := validateTree(target, options); err != nil { + return "", Inventory{}, fmt.Errorf("validate generated publication: %w", err) + } + finalInventory, err := buildInventory(target, options) + if err != nil { + return "", Inventory{}, err + } + if err := writeCanonicalInventory(filepath.Join(target, InventoryFilename), finalInventory); err != nil { + return "", Inventory{}, err + } + if err := ValidateRenderedTree(target, renderedInventory.AppProject, true); err != nil { + return "", Inventory{}, fmt.Errorf("validate generated publication inventory: %w", err) + } + return snapshotRevision, finalInventory, nil +} + +func writeCanonicalInventory(path string, inventory Inventory) error { + data, err := canonicalInventory(inventory) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write render inventory: %w", err) + } + return nil +} + +func canonicalInventory(inventory Inventory) ([]byte, error) { + data, err := json.MarshalIndent(inventory, "", " ") + if err != nil { + return nil, fmt.Errorf("encode render inventory: %w", err) + } + return append(data, '\n'), nil +} + +func inventoryServiceNames(graph []InventoryService) []string { + services := make([]string, 0, len(graph)) + for _, service := range graph { + if !service.Managed { + services = append(services, service.Service) + } + } + sort.Strings(services) + return services +} + +func commitServiceSnapshot(ctx context.Context, repo, module, environment string) (string, error) { + rawTimestamp, err := gitCommand(ctx, repo, "show", "-s", "--format=%ct", "HEAD") + if err != nil { + return "", err + } + timestamp, err := strconv.ParseInt(rawTimestamp, 10, 64) + if err != nil { + return "", fmt.Errorf("parse parent commit timestamp %q: %w", rawTimestamp, err) + } + date := fmt.Sprintf("@%d +0000", timestamp+1) + if _, err := gitCommandWithEnv( + ctx, + repo, + []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}, + "commit", + "--no-gpg-sign", + "-m", + fmt.Sprintf("Snapshot %s services for %s", module, environment), + ); err != nil { + return "", fmt.Errorf("create immutable service snapshot: %w", err) + } + revision, err := gitCommand(ctx, repo, "rev-parse", "HEAD^{commit}") + if err != nil { + return "", err + } + return revision, nil +} + +func publishServiceSnapshot(ctx context.Context, repo, module, environment, revision string) error { + snapshotBranch := "codefly/snapshot-" + sanitizeRef(module) + "-" + sanitizeRef(environment) + refspec := revision + ":refs/heads/" + snapshotBranch + if _, err := gitCommand(ctx, repo, "push", "--porcelain", "--", "origin", refspec); err != nil { + return fmt.Errorf("publish immutable service snapshot without force: %w", err) + } + remote, err := gitCommand(ctx, repo, "ls-remote", "--exit-code", "--refs", "origin", "refs/heads/"+snapshotBranch) + if err != nil { + return fmt.Errorf("verify immutable service snapshot: %w", err) + } + fields := strings.Fields(remote) + if len(fields) != 2 || fields[0] != revision { + return fmt.Errorf("service snapshot ref resolved to %q, expected %s", remote, revision) + } + return nil +} + +func removePublicationRemainder(target string) error { + entries, err := os.ReadDir(target) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == "services" { + continue + } + if err := os.RemoveAll(filepath.Join(target, entry.Name())); err != nil { + return err + } + } + return nil +} + +func generateModuleBootstrap( + ctx context.Context, + workspace *resources.Workspace, + module *resources.Module, + environment string, + config *resources.WorkspaceGitops, + promotionBranch string, + checkout string, + snapshotRevision string, + inventoryPath string, + destination string, + planning bool, +) error { + binary, err := module.Agent.Path(ctx) + if err != nil { + return fmt.Errorf("resolve module generator: %w", err) + } + stage, err := os.MkdirTemp("", "codefly-module-gitops-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + relativeModule, err := filepath.Rel(workspace.Dir(), module.Dir()) + if err != nil || !filepath.IsLocal(relativeModule) { + return fmt.Errorf("module %q is outside the workspace", module.Name) + } + stagedModule := filepath.Join(stage, relativeModule) + if err := copyTree(module.Dir(), stagedModule); err != nil { + return fmt.Errorf("stage module generator input: %w", err) + } + workspaceSource := filepath.Join(workspace.Dir(), resources.WorkspaceConfigurationName) + data, err := os.ReadFile(workspaceSource) + if err != nil { + return err + } + var document map[string]any + if err := yaml.Unmarshal(data, &document); err != nil { + return fmt.Errorf("decode workspace for module generator: %w", err) + } + gitops, _ := document["gitops"].(map[string]any) + if gitops == nil { + gitops = map[string]any{} + document["gitops"] = gitops + } + repository := config.RepoURL + if planning && strings.HasPrefix(repository, "file://") { + fetchRepository, _ := gitops["fetch-repo-url"].(string) + repository, err = planningRepositoryURL(fetchRepository) + if err != nil { + return err + } + original, err := gitCommand(ctx, checkout, "remote", "get-url", "origin") + if err != nil { + return err + } + if _, err := gitCommand(ctx, checkout, "remote", "set-url", "origin", repository); err != nil { + return err + } + defer func() { + _, _ = gitCommand(context.WithoutCancel(ctx), checkout, "remote", "set-url", "origin", original) + }() + } + gitops["repo-url"] = repository + gitops["path"] = config.Path + gitops["branch"] = promotionBranch + gitops["revision"] = snapshotRevision + gitops["checkout"] = checkout + gitops["inventory"] = inventoryPath + gitops["environment"] = environment + encoded, err := yaml.Marshal(document) + if err != nil { + return fmt.Errorf("encode workspace for module generator: %w", err) + } + if err := os.WriteFile(filepath.Join(stage, resources.WorkspaceConfigurationName), encoded, 0o644); err != nil { + return err + } + if _, err := command(ctx, stage, binary, stagedModule, module.Name); err != nil { + return fmt.Errorf("generate module bootstrap: %w", err) + } + generated := filepath.Join(stagedModule, "deployment", "kustomize") + if err := copyEnvironmentBootstrap(generated, environment, destination); err != nil { + return fmt.Errorf("select generated module bootstrap: %w", err) + } + return nil +} + +func planningRepositoryURL(fetchRepository string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(fetchRepository)) + if err != nil || parsed.Host == "" || parsed.Path == "" { + return "", fmt.Errorf("local module generation requires workspace.gitops.fetch-repo-url") + } + if parsed.Scheme == "http" { + parsed.Scheme = "https" + } + if parsed.Scheme != "https" { + return "", fmt.Errorf("local module generation requires an HTTP(S) workspace.gitops.fetch-repo-url") + } + return parsed.String(), nil +} + +func bootstrapRevision(root string) (string, error) { + revision := "" + err := walkBootstrapApplications(root, func(path, current, sourcePath string) error { + if revision == "" { + revision = current + return nil + } + if current != revision { + return fmt.Errorf("bootstrap Applications use different snapshot revisions %s and %s", revision, current) + } + return nil + }) + return revision, err +} + +func validateBootstrapRevision(root, expected string) error { + return walkBootstrapApplications(root, func(path, revision, sourcePath string) error { + if revision != expected { + return fmt.Errorf("bootstrap Application %s targets revision %q, expected service snapshot %s", path, revision, expected) + } + return nil + }) +} + +func validateBootstrapServiceGraph(root, targetPath string, services []string, environment string) error { + expected := make(map[string]struct{}, len(services)) + for _, service := range services { + path := filepath.ToSlash(filepath.Join(targetPath, "services", service, "overlays", environment)) + expected[path] = struct{}{} + } + err := walkBootstrapApplications(root, func(path, revision, sourcePath string) error { + if _, exists := expected[sourcePath]; !exists { + return fmt.Errorf("bootstrap Application %s targets service path %q outside the rendered service graph", path, sourcePath) + } + delete(expected, sourcePath) + return nil + }) + if err != nil { + return err + } + if len(expected) > 0 { + missing := make([]string, 0, len(expected)) + for path := range expected { + missing = append(missing, path) + } + sort.Strings(missing) + return fmt.Errorf("module bootstrap is missing Applications for service paths %v", missing) + } + return nil +} + +func walkBootstrapApplications(root string, visit func(path, revision, sourcePath string) error) error { + info, err := os.Stat(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("module bootstrap is not a directory") + } + return walkRegularFiles(root, func(path, relative string, _ os.FileInfo) error { + extension := strings.ToLower(filepath.Ext(relative)) + if extension != ".yaml" && extension != ".yml" && extension != ".json" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + manifests, _, err := decodeYAML(relative, data) + if err != nil { + return err + } + for _, item := range manifests { + if item.group != argoAPIGroup || item.kind != "Application" { + continue + } + spec, _ := item.value["spec"].(map[string]any) + source, _ := spec["source"].(map[string]any) + revision, _ := source["targetRevision"].(string) + sourcePath, _ := source["path"].(string) + if !gitObjectPattern.MatchString(revision) { + return fmt.Errorf("bootstrap Application %s has non-immutable target revision %q", item.path, revision) + } + if err := visit(item.path, revision, sourcePath); err != nil { + return err + } + } + return nil + }) +} + func validatePublishRequest(request *PublishRequest) error { if request == nil || request.Module == "" || request.Environment == "" { return fmt.Errorf("module and environment are required") @@ -237,7 +783,7 @@ func prepareRollback(ctx context.Context, workspace *resources.Workspace, reques if err != nil { return nil, "", fmt.Errorf("resolve rollback revision: %w", err) } - prepared, err := preparePublish(ctx, workspace, &request.PublishRequest, revision) + prepared, err := preparePublish(ctx, workspace, &request.PublishRequest, revision, false) if err != nil { return nil, "", err } @@ -271,7 +817,7 @@ func requireReviewedRevision(root, module, environment, revision string) error { evidence.Review.State == "LOCAL_REVIEW_REF" && evidence.Review.ReviewDecision == "LOCAL_QUALIFIED" if evidence.SchemaVersion == SchemaVersion && evidence.Module == module && evidence.Environment == environment && evidence.Health == healthyStatus && reviewed && - (evidence.ArgoRevision == revision || evidence.SignedCommit == revision) { + evidence.SignedCommit == revision { return nil } } @@ -285,7 +831,7 @@ func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepa } commit := prepared.plan.ExistingCommit if len(prepared.plan.Changed) > 0 { - if _, err := gitCommand(ctx, prepared.dir, "commit", "-S", "-m", message); err != nil { + if _, err := gitCommand(ctx, prepared.dir, "commit", "--allow-empty", "-S", "-m", message); err != nil { return PublishResult{}, fmt.Errorf("create signed promotion commit: %w", err) } var err error @@ -326,7 +872,8 @@ func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepa result := PublishResult{ PlanID: prepared.plan.ID, Repository: prepared.plan.Repository, Path: prepared.plan.Path, BaseBranch: prepared.plan.BaseBranch, PromotionBranch: prepared.plan.PromotionBranch, - RenderDigest: prepared.plan.RenderDigest, Commit: commit, Tree: tree, Signed: true, + RenderDigest: prepared.plan.RenderDigest, SnapshotRevision: prepared.plan.SnapshotRevision, + Commit: commit, Tree: tree, Signed: true, PullRequest: prURL, PullRequestID: prID, } if err := writeReceipt(workspace.Dir(), "publications", request.Module+"-"+request.Environment+".json", result); err != nil { @@ -406,19 +953,21 @@ func replaceCloneTree(source, destination string) error { return copyTree(source, destination) } -func stagedPaths(ctx context.Context, repo, targetPath string) ([]string, error) { - output, err := gitCommandBytes(ctx, repo, "diff", "--cached", "--name-only", "-z", "--", targetPath) +func stagedPathsSince(ctx context.Context, repo, revision string, paths ...string) ([]string, error) { + args := []string{"diff", "--cached", "--name-only", "-z", revision, "--"} + args = append(args, paths...) + output, err := gitCommandBytes(ctx, repo, args...) if err != nil { return nil, err } - var paths []string + var changed []string for _, raw := range bytes.Split(output, []byte{0}) { if len(raw) > 0 { - paths = append(paths, string(raw)) + changed = append(changed, string(raw)) } } - sort.Strings(paths) - return paths, nil + sort.Strings(changed) + return changed, nil } func changedPathsBetween(ctx context.Context, repo, baseRevision, branchRevision string) ([]string, error) { @@ -451,7 +1000,12 @@ func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, } body := strings.TrimSpace(request.Body) if body == "" { - body = fmt.Sprintf("Render digest: `%s`\n\nSigned commit: `%s`", prepared.plan.RenderDigest, commit) + body = fmt.Sprintf( + "Render digest: `%s`\n\nService snapshot: `%s`\n\nSigned commit: `%s`", + prepared.plan.RenderDigest, + prepared.plan.SnapshotRevision, + commit, + ) } output, err := command(ctx, "", "gh", "pr", "list", "--repo", prepared.plan.RepositorySlug, "--head", prepared.plan.PromotionBranch, @@ -708,7 +1262,7 @@ func LoadPublishResult(root, module, environment string) (PublishResult, error) if err := json.Unmarshal(data, &result); err != nil { return PublishResult{}, fmt.Errorf("decode publication receipt: %w", err) } - if result.Commit == "" || result.Tree == "" || result.RenderDigest == "" || !result.Signed { + if result.SnapshotRevision == "" || result.Commit == "" || result.Tree == "" || result.RenderDigest == "" || !result.Signed { return PublishResult{}, fmt.Errorf("publication receipt is incomplete") } return result, nil @@ -718,6 +1272,23 @@ func gitCommand(ctx context.Context, dir string, args ...string) (string, error) return command(ctx, dir, "git", args...) } +func gitCommandWithEnv(ctx context.Context, dir string, environment []string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), environment...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + message := strings.TrimSpace(stderr.String()) + if message == "" { + message = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message) + } + return strings.TrimSpace(stdout.String()), nil +} + func gitCommandBytes(ctx context.Context, dir string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir diff --git a/pkg/gitops/publish_test.go b/pkg/gitops/publish_test.go index 57c0527c..261041de 100644 --- a/pkg/gitops/publish_test.go +++ b/pkg/gitops/publish_test.go @@ -33,22 +33,32 @@ 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) } + snapshotRef := "refs/heads/codefly/snapshot-payments-production" + if err := exec.Command("git", "--git-dir", remote, "show-ref", "--verify", snapshotRef).Run(); err == nil { + t.Fatal("publication plan advertised the service snapshot") + } if _, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, mutationauthority.PreparedPermit{}); err == nil || !strings.Contains(err.Error(), "prepared authority") { t.Fatalf("unprepared publication error = %v", err) } if _, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: "sha256:stale"}, preparedPermit); err == nil || !strings.Contains(err.Error(), "stale") { t.Fatalf("stale plan error = %v", err) } + if err := exec.Command("git", "--git-dir", remote, "show-ref", "--verify", snapshotRef).Run(); err == nil { + t.Fatal("stale publication advertised the service snapshot") + } result, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } - if !result.Signed || result.Commit == "" || result.Tree == "" { + if !result.Signed || result.SnapshotRevision == "" || result.Commit == "" || result.Tree == "" { t.Fatalf("publication identities are incomplete: %+v", result) } + if result.SnapshotRevision == result.Commit { + t.Fatalf("service snapshot and signed publication commit are not distinct: %+v", result) + } if !strings.Contains(result.PullRequest, "#refs/codefly/reviews/") { t.Fatalf("local review ref = %q", result.PullRequest) } @@ -57,6 +67,22 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { if branch != result.Commit || review != result.Commit { t.Fatalf("published refs branch=%s review=%s, want %s", branch, review, result.Commit) } + snapshot := gitOutput(t, "", "--git-dir", remote, "rev-parse", snapshotRef+"^{commit}") + if snapshot != result.SnapshotRevision { + t.Fatalf("published snapshot ref = %s, want %s", snapshot, result.SnapshotRevision) + } + gitRun(t, "", "--git-dir", remote, "merge-base", "--is-ancestor", result.SnapshotRevision, result.Commit) + serviceInventory := gitOutput( + t, + "", + "--git-dir", + remote, + "show", + result.SnapshotRevision+":"+result.Path+"/"+InventoryFilename, + ) + if !strings.Contains(serviceInventory, `"serviceGraph": [`) || !strings.Contains(serviceInventory, `"service": "api"`) { + t.Fatalf("immutable service inventory = %s", serviceInventory) + } raw := gitOutput(t, "", "--git-dir", remote, "cat-file", "-p", result.Commit) if !strings.Contains(raw, "\ngpgsig ") { t.Fatalf("commit %s has no signature", result.Commit) @@ -65,11 +91,110 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { if err != nil { t.Fatal(err) } - if receipt.Commit != result.Commit || receipt.Tree != result.Tree { + if receipt.SnapshotRevision != result.SnapshotRevision || receipt.Commit != result.Commit || receipt.Tree != result.Tree { t.Fatalf("receipt = %+v, publication = %+v", receipt, result) } } +func TestBootstrapApplicationsRequireTheImmutableServiceSnapshot(t *testing.T) { + root := t.TempDir() + application := `apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: payments-api +spec: + source: + targetRevision: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +` + if err := os.WriteFile(filepath.Join(root, "application.yaml"), []byte(application), 0o644); err != nil { + t.Fatal(err) + } + if err := validateBootstrapRevision(root, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil { + t.Fatal(err) + } + err := validateBootstrapRevision(root, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + if err == nil || !strings.Contains(err.Error(), "expected service snapshot") { + t.Fatalf("snapshot mismatch error = %v", err) + } +} + +func TestPublishInvokesModuleGeneratorAgainstCommittedServiceSnapshot(t *testing.T) { + ctx := context.Background() + remote := createBareRepository(t) + workspace := loadGitopsWorkspaceWithAgent(t, remote) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + configureSSHSigning(t) + + home := t.TempDir() + t.Setenv(resources.CodeflyHomeEnv, home) + agent := &resources.Agent{ + Kind: resources.ModuleAgent, Publisher: "codefly.dev", Name: "gitops-test", Version: "1.0.0", + } + binary, err := agent.Path(ctx) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil { + t.Fatal(err) + } + generator := `#!/bin/sh +set -eu +module_dir="$1" +revision="$(sed -n 's/^[[:space:]]*revision: //p' workspace.codefly.yaml | head -n 1)" +checkout="$(sed -n 's/^[[:space:]]*checkout: //p' workspace.codefly.yaml | head -n 1)" +inventory="$(sed -n 's/^[[:space:]]*inventory: //p' workspace.codefly.yaml | head -n 1)" +test "$inventory" = "environments/deployments/modules/payments/.codefly-render.json" +git -C "$checkout" cat-file -e "$revision:$inventory" +git -C "$checkout" cat-file -e "$revision:environments/deployments/modules/payments/services/api/overlays/production/deployment.yaml" +destination="$module_dir/deployment/kustomize/overlays/production" +mkdir -p "$destination" +{ + printf '%s\n' \ + 'apiVersion: argoproj.io/v1alpha1' \ + 'kind: Application' \ + 'metadata:' \ + ' name: payments-api' \ + ' namespace: argocd' \ + 'spec:' \ + ' project: payments' \ + ' source:' \ + ' repoURL: https://github.com/codefly-dev/manifests.git' \ + " targetRevision: $revision" \ + ' path: environments/deployments/modules/payments/services/api/overlays/production' \ + ' destination:' \ + ' server: https://kubernetes.default.svc' \ + ' namespace: payments' +} > "$destination/application.yaml" +` + if err := os.WriteFile(binary, []byte(generator), 0o755); err != nil { + t.Fatal(err) + } + + request := PublishRequest{ + Module: "payments", Environment: "production", Local: true, + PromotionBranch: "codefly/promote-payments-production", + } + plan, err := PlanPublish(ctx, workspace, &request) + if err != nil { + t.Fatal(err) + } + result, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + application := gitOutput( + t, + "", + "--git-dir", + remote, + "show", + result.Commit+":"+result.Path+"/bootstrap/application.yaml", + ) + if !strings.Contains(application, "targetRevision: "+result.SnapshotRevision) { + t.Fatalf("generated Application = %s", application) + } +} + func TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit(t *testing.T) { ctx := context.Background() remote := createBareRepository(t) @@ -137,6 +262,19 @@ func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { } } +func TestPublishRejectsRenderOutsideTheExactModuleServiceGraph(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspaceWithServices(t, remote, []string{"api", "worker"}) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + + _, err := PlanPublish(context.Background(), workspace, &PublishRequest{ + Module: "payments", Environment: "production", Local: true, + }) + if err == nil || !strings.Contains(err.Error(), "differs from module service graph") { + t.Fatalf("service graph error = %v", err) + } +} + func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { ctx := context.Background() remote := createBareRepository(t) @@ -224,6 +362,25 @@ func TestRollbackRequiresEvidenceForSelectedModuleAndEnvironment(t *testing.T) { } } +func TestRollbackRequiresTheReviewedPublicationCommitNotItsServiceSnapshot(t *testing.T) { + root := t.TempDir() + snapshot := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if err := writeReceipt(root, "evidence", "snapshot.json", Evidence{ + SchemaVersion: SchemaVersion, Module: "payments", Environment: "production", + SignedCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ArgoRevision: snapshot, Health: "Healthy", + Review: ReviewEvidence{ + State: "LOCAL_REVIEW_REF", ReviewDecision: "LOCAL_QUALIFIED", + }, + }); err != nil { + t.Fatal(err) + } + err := requireReviewedRevision(root, "payments", "production", snapshot) + if err == nil || !strings.Contains(err.Error(), "no reviewed Healthy promotion evidence") { + t.Fatalf("snapshot rollback error = %v", err) + } +} + func TestRemotePublishRequiresSafeGitHubRepository(t *testing.T) { tests := []string{ "https://token@github.com/codefly-dev/manifests.git", @@ -283,18 +440,70 @@ func createBareRepository(t *testing.T) string { } func loadGitopsWorkspace(t *testing.T, remote string) *resources.Workspace { + t.Helper() + return loadGitopsWorkspaceWithServices(t, remote, []string{"api"}) +} + +func loadGitopsWorkspaceWithServices(t *testing.T, remote string, services []string) *resources.Workspace { t.Helper() root := t.TempDir() - config := fmt.Sprintf(`name: test + var serviceReferences strings.Builder + for _, service := range services { + fmt.Fprintf(&serviceReferences, " - name: %s\n", service) + } + config := fmt.Sprintf(`name: payments layout: flat +services: +%s gitops: repo-url: file://%s + fetch-repo-url: https://host.k3d.internal/manifests.git + path: environments + branch: main +`, serviceReferences.String(), remote) + if err := os.WriteFile(filepath.Join(root, resources.WorkspaceConfigurationName), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) + if err != nil { + t.Fatal(err) + } + return workspace +} + +func loadGitopsWorkspaceWithAgent(t *testing.T, remote string) *resources.Workspace { + t.Helper() + root := t.TempDir() + config := fmt.Sprintf(`name: workspace +layout: modules +modules: + - name: payments +gitops: + repo-url: file://%s + fetch-repo-url: https://host.k3d.internal/manifests.git path: environments branch: main `, remote) if err := os.WriteFile(filepath.Join(root, resources.WorkspaceConfigurationName), []byte(config), 0o644); err != nil { t.Fatal(err) } + module := `kind: module +name: payments +agent: + kind: codefly:module + publisher: codefly.dev + name: gitops-test + version: 1.0.0 +services: + - name: api +` + moduleDir := filepath.Join(root, "modules", "payments") + if err := os.MkdirAll(moduleDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(moduleDir, resources.ModuleConfigurationName), []byte(module), 0o644); err != nil { + t.Fatal(err) + } workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) if err != nil { t.Fatal(err) @@ -304,12 +513,19 @@ 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, + Destination: destination, Module: module, Services: []string{"api"}, + OwnedPath: filepath.ToSlash(filepath.Join("environments", "deployments", "modules", module)), + ServiceGraph: promotableServiceGraph(module, []string{"api"}), + Environment: environment, AppProject: "payments", Promotable: true, }, func(ctx context.Context, stage string) error { manifest := strings.Replace(pinnedDeployment, "name: api", "name: "+name, 2) - return os.WriteFile(filepath.Join(stage, "deployment.yaml"), []byte(manifest), 0o644) + service := filepath.Join(stage, "services", "api", "overlays", environment) + if err := os.MkdirAll(service, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(service, "deployment.yaml"), []byte(manifest), 0o644) }) if err != nil { t.Fatal(err) diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go index c6e08cb0..53361c69 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -9,8 +9,89 @@ import ( "strings" "testing" "time" + + "github.com/codefly-dev/core/resources" ) +var mindShapedServices = []string{ + "accounts", + "cache", + "forge-edge", + "frontend", + "object-storage", + "store", + "vault", +} + +var mindShapedAWSManagedServices = map[string]struct{}{ + "cache": {}, + "object-storage": {}, + "store": {}, + "vault": {}, +} + +func TestMindShapedAWSRenderPlanPublishDoesNotApplyKubernetes(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspaceWithServices(t, remote, mindShapedServices) + workspaceConfiguration := filepath.Join(workspace.Dir(), resources.WorkspaceConfigurationName) + file, err := os.OpenFile(workspaceConfiguration, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString(`environments: + - name: aws + managed-services: + cache: {} + object-storage: {} + store: {} + vault: {} +`); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + renderMindShapedFixture(t, workspace.Dir(), "aws") + configureSSHSigning(t) + + bin := t.TempDir() + kubectlCalled := filepath.Join(t.TempDir(), "kubectl-called") + kubectl := filepath.Join(bin, "kubectl") + if err := os.WriteFile(kubectl, []byte("#!/bin/sh\ntouch \"$CODEFLY_TEST_KUBECTL_CALLED\"\nexit 97\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEFLY_TEST_KUBECTL_CALLED", kubectlCalled) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + request := PublishRequest{ + Module: "payments", Environment: "aws", Local: true, + PromotionBranch: "codefly/promote-payments-aws", + } + plan, err := PlanPublish(context.Background(), workspace, &request) + if err != nil { + t.Fatal(err) + } + if len(plan.Changed) == 0 || plan.SnapshotRevision == "" { + t.Fatalf("AWS publication plan = %+v", plan) + } + result, err := Publish( + context.Background(), + workspace, + &PublishMutation{Request: request, PlanID: plan.ID}, + preparedPermit, + ) + if err != nil { + t.Fatal(err) + } + if result.SnapshotRevision == "" || result.Commit == "" { + t.Fatalf("AWS publication = %+v", result) + } + if _, err := os.Stat(kubectlCalled); !os.IsNotExist(err) { + t.Fatalf("AWS GitOps publication invoked kubectl: %v", err) + } +} + func TestLocalK3dDisposableGitQualification(t *testing.T) { if os.Getenv("CODEFLY_GITOPS_K3D_QUALIFY") != "1" { t.Skip("set CODEFLY_GITOPS_K3D_QUALIFY=1 to run the disposable k3d qualification") @@ -22,30 +103,8 @@ 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"), - 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 -kind: Kustomization -resources: - - configmap.yaml -`), 0o644); err != nil { - return err - } - return os.WriteFile(filepath.Join(root, "configmap.yaml"), []byte(`apiVersion: v1 -kind: ConfigMap -metadata: - name: codefly-gitops-qualification - namespace: payments -data: - release: qualified -`), 0o644) - }) - if err != nil { - t.Fatal(err) - } + workspace := loadGitopsWorkspaceWithServices(t, remote, mindShapedServices) + renderMindShapedFixture(t, workspace.Dir(), "local") configureSSHSigning(t) request := PublishRequest{ Module: "payments", Environment: "local", Local: true, @@ -95,7 +154,8 @@ data: kubectl(nil, "create", "namespace", "payments") repository := "git://" + gitServer + "/" + filepath.Base(remote) - argoResources := fmt.Sprintf(`apiVersion: argoproj.io/v1alpha1 + var argoResources strings.Builder + fmt.Fprintf(&argoResources, `apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: name: payments @@ -106,18 +166,20 @@ spec: destinations: - namespace: payments server: https://kubernetes.default.svc ---- +`, repository) + for _, service := range mindShapedServices { + fmt.Fprintf(&argoResources, `--- apiVersion: argoproj.io/v1alpha1 kind: Application metadata: - name: payments + name: payments-%s namespace: argocd spec: project: payments source: repoURL: %s - targetRevision: main - path: environments/local/modules/payments + targetRevision: %s + path: environments/deployments/modules/payments/services/%s/overlays/local destination: server: https://kubernetes.default.svc namespace: payments @@ -125,8 +187,9 @@ spec: automated: prune: true selfHeal: true -`, repository, repository) - kubectl([]byte(argoResources), "apply", "-f", "-") +`, service, repository, published.SnapshotRevision, service) + } + kubectl([]byte(argoResources.String()), "apply", "-f", "-") bin := t.TempDir() argocd := filepath.Join(bin, "argocd") @@ -149,10 +212,14 @@ exit 2 t.Setenv("CODEFLY_TEST_KUBECONFIG", kubeconfig) t.Setenv("CODEFLY_TEST_CLUSTER", cluster) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + applications := make([]string, 0, len(mindShapedServices)) + for _, service := range mindShapedServices { + applications = append(applications, "payments-"+service) + } observed, err := Observe(context.Background(), &ObserveRequest{ WorkspaceRoot: workspace.Dir(), Module: "payments", Environment: "local", - AppProject: "payments", Applications: []string{"payments"}, - Revision: published.Commit, Commit: published.Commit, Tree: published.Tree, + AppProject: "payments", Applications: applications, + Revision: published.SnapshotRevision, Commit: published.Commit, Tree: published.Tree, RenderDigest: published.RenderDigest, Repository: published.Repository, Path: published.Path, PullRequest: published.PullRequest, Local: true, Timeout: 5 * time.Minute, PollInterval: 2 * time.Second, @@ -160,9 +227,68 @@ exit 2 if err != nil { t.Fatal(err) } - if observed.Evidence.Health != "Healthy" || observed.Evidence.ArgoRevision != published.Commit { + if observed.Evidence.Health != "Healthy" || observed.Evidence.ArgoRevision != published.SnapshotRevision { t.Fatalf("qualification evidence = %+v", observed.Evidence) } + for _, service := range mindShapedServices { + name := "codefly-gitops-" + service + if value := kubectl(nil, "get", "configmap", name, "-n", "payments", "-o", "jsonpath={.data.release}"); value != "qualified" { + t.Fatalf("ConfigMap %s release = %q", name, value) + } + } +} + +func renderMindShapedFixture(t *testing.T, root, environment string) { + t.Helper() + services := append([]string(nil), mindShapedServices...) + graph := promotableServiceGraph("payments", mindShapedServices) + if environment == "aws" { + services = services[:0] + for index := range graph { + if _, managed := mindShapedAWSManagedServices[graph[index].Service]; managed { + graph[index].Managed = true + graph[index].Path = "" + graph[index].Output = nil + continue + } + services = append(services, graph[index].Service) + } + } + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ + Destination: filepath.Join(root, "deployments", "modules", "payments"), + Module: "payments", Services: services, Environment: environment, + AppProject: "payments", OwnedPath: "environments/deployments/modules/payments", + ServiceGraph: graph, Promotable: true, + }, func(ctx context.Context, root string) error { + for _, service := range services { + overlay := filepath.Join(root, "services", service, "overlays", environment) + if err := os.MkdirAll(overlay, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(overlay, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - configmap.yaml +`), 0o644); err != nil { + return err + } + manifest := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: codefly-gitops-%s + namespace: payments +data: + release: qualified +`, service) + if err := os.WriteFile(filepath.Join(overlay, "configmap.yaml"), []byte(manifest), 0o644); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } } func runExternal(t *testing.T, dir string, input []byte, name string, args ...string) string { diff --git a/pkg/gitops/render.go b/pkg/gitops/render.go index b40d8de6..10749695 100644 --- a/pkg/gitops/render.go +++ b/pkg/gitops/render.go @@ -17,6 +17,8 @@ import ( "strings" "unicode/utf8" + coreservices "github.com/codefly-dev/core/agents/services" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "gopkg.in/yaml.v3" "sigs.k8s.io/kustomize/api/krusty" "sigs.k8s.io/kustomize/kyaml/filesys" @@ -105,24 +107,28 @@ func RenderOwnedTree(ctx context.Context, opts *RenderOptions, generate func(con } func LoadInventory(root string) (Inventory, error) { - data, err := os.ReadFile(filepath.Join(root, InventoryFilename)) + return loadInventory(filepath.Join(root, InventoryFilename), "render") +} + +func loadInventory(path, label string) (Inventory, error) { + data, err := os.ReadFile(path) if err != nil { - return Inventory{}, fmt.Errorf("read render inventory: %w", err) + return Inventory{}, fmt.Errorf("read %s inventory: %w", label, err) } var inventory Inventory if err := json.Unmarshal(data, &inventory); err != nil { - return Inventory{}, fmt.Errorf("decode render inventory: %w", err) + return Inventory{}, fmt.Errorf("decode %s inventory: %w", label, err) } if inventory.SchemaVersion != SchemaVersion { - return Inventory{}, fmt.Errorf("unsupported render inventory schema %d", inventory.SchemaVersion) + return Inventory{}, fmt.Errorf("unsupported %s inventory schema %d", label, inventory.SchemaVersion) } canonical, err := json.MarshalIndent(inventory, "", " ") if err != nil { - return Inventory{}, fmt.Errorf("encode render inventory: %w", err) + return Inventory{}, fmt.Errorf("encode %s inventory: %w", label, err) } canonical = append(canonical, '\n') if !bytes.Equal(data, canonical) { - return Inventory{}, fmt.Errorf("render inventory is not canonical") + return Inventory{}, fmt.Errorf("%s inventory is not canonical", label) } return inventory, nil } @@ -137,10 +143,19 @@ func ValidateRenderedTree(root, project string, promotable bool) error { } else if inventory.AppProject != project { return fmt.Errorf("render inventory AppProject %q differs from selected AppProject %q", inventory.AppProject, project) } + if err := validateInventoryServiceGraph(inventory); err != nil { + return err + } opts := &RenderOptions{ Module: inventory.Module, Service: inventory.Service, + OwnedPath: inventory.OwnedPath, ServiceGraph: inventory.ServiceGraph, Environment: inventory.Environment, AppProject: project, Promotable: promotable, } + for _, service := range inventory.ServiceGraph { + if !service.Managed { + opts.Services = append(opts.Services, service.Service) + } + } if err := validateTree(root, opts); err != nil { return err } @@ -148,21 +163,167 @@ func ValidateRenderedTree(root, project string, promotable bool) error { if err != nil { return err } + return validateInventory(inventory, actual, "render") +} + +func ValidateServiceSnapshot(root string) error { + inventory, err := LoadInventory(root) + if err != nil { + return err + } + if err := validateInventoryServiceGraph(inventory); err != nil { + return err + } + entries, err := os.ReadDir(root) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() != InventoryFilename && entry.Name() != "services" { + return fmt.Errorf("service snapshot contains unexpected path %s", entry.Name()) + } + } + services := filepath.Join(root, "services") + var names []string + for _, service := range inventory.ServiceGraph { + if !service.Managed { + names = append(names, service.Service) + } + } + if err := validateServiceDirectories(root, names, inventory.Environment); err != nil { + return err + } + for _, service := range names { + opts := &RenderOptions{ + Module: inventory.Module, Service: service, + Environment: inventory.Environment, AppProject: inventory.AppProject, Promotable: true, + } + if err := validateTree(filepath.Join(services, service), opts); err != nil { + return fmt.Errorf("validate service %s: %w", service, err) + } + } + if err := validateServiceSnapshotCoverage(inventory); err != nil { + return err + } + opts := &RenderOptions{ + Module: inventory.Module, Services: names, + OwnedPath: inventory.OwnedPath, ServiceGraph: inventory.ServiceGraph, + Environment: inventory.Environment, AppProject: inventory.AppProject, Promotable: true, + } + actual, err := buildInventory(root, opts) + if err != nil { + return err + } + return validateInventory(inventory, actual, "service snapshot") +} + +func validateServiceSnapshotCoverage(inventory Inventory) error { + covered := make(map[string]bool) + for _, service := range inventory.ServiceGraph { + if !service.Managed { + covered[service.Path] = false + } + } + for _, file := range inventory.Files { + owner := "" + for servicePath := range covered { + if file.Path == servicePath || strings.HasPrefix(file.Path, servicePath+"/") { + if owner != "" { + return fmt.Errorf("service snapshot file %s belongs to overlapping service paths", file.Path) + } + owner = servicePath + } + } + if owner == "" { + return fmt.Errorf("service snapshot file %s is outside the exact service graph", file.Path) + } + covered[owner] = true + } + for servicePath, present := range covered { + if !present { + return fmt.Errorf("service snapshot path %s contains no files", servicePath) + } + } + return nil +} + +func validateInventoryServiceGraph(inventory Inventory) error { + if inventory.Service != "" { + if len(inventory.ServiceGraph) != 0 { + return fmt.Errorf("service render inventory must not contain a module service graph") + } + return nil + } + previous := "" + for _, service := range inventory.ServiceGraph { + if service.Service == "" || service.Module != inventory.Module { + return fmt.Errorf("render inventory contains invalid service graph entry %q/%q", service.Module, service.Service) + } + if previous != "" && service.Service <= previous { + return fmt.Errorf("render inventory service graph is not strictly sorted") + } + previous = service.Service + if service.Managed { + if service.Path != "" || service.Output != nil { + return fmt.Errorf("managed service %s must not contain a rendered path or output", service.Service) + } + continue + } + expectedPath := filepath.ToSlash(filepath.Join("services", service.Service)) + if service.Path != expectedPath { + return fmt.Errorf("service %s render path is %q, expected %q", service.Service, service.Path, expectedPath) + } + if inventory.OwnedPath == "" { + continue + } + if err := validateInventoryKubernetesOutput(service.Service, service.Output); err != nil { + return err + } + } + return nil +} + +func validateInventoryKubernetesOutput(service string, output *KubernetesOutputInventory) error { + if output == nil { + return fmt.Errorf("service %s has no promotable Kubernetes output evidence", service) + } + if output.Kind != builderv0.KubernetesDeploymentOutput_KUSTOMIZE.String() || + output.Profile != builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1.String() || + output.ContractVersion != coreservices.KubernetesManifestContractVersion { + return fmt.Errorf("service %s has incompatible Kubernetes output evidence", service) + } + passed := builderv0.KubernetesManifestValidation_STATUS_PASSED.String() + if output.Validation.StaticValidation != passed || + output.Validation.ServerSideValidation != passed || + !output.Validation.Promotable || + output.Validation.Violations == nil || + len(output.Validation.Violations) != 0 { + return fmt.Errorf("service %s has failed promotable Kubernetes validation evidence", service) + } + return nil +} + +func validateInventory(inventory, actual Inventory, label string) error { if actual.Digest != inventory.Digest { - return fmt.Errorf("render digest changed: inventory has %s, tree has %s", inventory.Digest, actual.Digest) + return fmt.Errorf("%s digest changed: inventory has %s, tree has %s", label, inventory.Digest, actual.Digest) } if len(actual.Files) != len(inventory.Files) { - return fmt.Errorf("render inventory changed: inventory has %d files, tree has %d", len(inventory.Files), len(actual.Files)) + return fmt.Errorf("%s inventory changed: inventory has %d files, tree has %d", label, len(inventory.Files), len(actual.Files)) } for i := range actual.Files { if actual.Files[i] != inventory.Files[i] { - return fmt.Errorf("render inventory changed at %s", actual.Files[i].Path) + return fmt.Errorf("%s inventory changed at %s", label, actual.Files[i].Path) } } return nil } func validateTree(root string, opts *RenderOptions) error { + if opts.Service == "" && len(opts.Services) > 0 { + if err := validateServiceDirectories(root, opts.Services, opts.Environment); err != nil { + return err + } + } var manifests []manifest var kustomizations []kustomization err := walkRegularFiles(root, func(path, relative string, _ os.FileInfo) error { @@ -232,6 +393,46 @@ func validateTree(root string, opts *RenderOptions) error { return nil } +func validateServiceDirectories(root string, services []string, environment string) error { + serviceRoot := filepath.Join(root, "services") + entries, err := os.ReadDir(serviceRoot) + if err != nil { + return fmt.Errorf("read rendered service graph: %w", err) + } + expected := make(map[string]struct{}, len(services)) + for _, service := range services { + expected[service] = struct{}{} + } + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("rendered service graph contains unexpected file %s", entry.Name()) + } + if _, exists := expected[entry.Name()]; !exists { + return fmt.Errorf("rendered service graph contains unexpected service %s", entry.Name()) + } + delete(expected, entry.Name()) + } + if len(expected) > 0 { + missing := make([]string, 0, len(expected)) + for service := range expected { + missing = append(missing, service) + } + sort.Strings(missing) + return fmt.Errorf("rendered service graph is missing services %v", missing) + } + for _, service := range services { + overlay := filepath.Join(serviceRoot, service, "overlays", environment) + info, err := os.Stat(overlay) + if err != nil { + return fmt.Errorf("service %s environment overlay %s: %w", service, environment, err) + } + if !info.IsDir() { + return fmt.Errorf("service %s environment overlay %s is not a directory", service, environment) + } + } + return nil +} + func decodeYAML(path string, data []byte) ([]manifest, *kustomization, error) { var manifests []manifest var customization *kustomization @@ -491,6 +692,9 @@ func selectProjectContract(manifests []manifest, selected string) (*projectContr func validateManifest(item manifest, contract *projectContract, promotable bool) error { if item.kind == "Secret" { + if promotable { + return fmt.Errorf("Kubernetes Secret resources are not allowed") + } for _, key := range []string{"data", "stringData"} { if values, ok := item.value[key].(map[string]any); ok && len(values) > 0 { return fmt.Errorf("Kubernetes Secret values are not allowed") @@ -567,8 +771,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 isURLBearingPath(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 +783,21 @@ func inspectValue(value any, path []string, promotable bool) error { return nil } +func isURLBearingPath(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 normalized == "server" || + normalized == "sourcerepos" || + strings.HasSuffix(normalized, "url") || + strings.HasSuffix(normalized, "uri") + } + return false +} + func extendPath(path []string, part string) []string { extended := make([]string, len(path)+1) copy(extended, path) @@ -652,8 +873,19 @@ func buildInventory(root string, opts *RenderOptions) (Inventory, error) { inventory := Inventory{ SchemaVersion: SchemaVersion, Module: opts.Module, Service: opts.Service, Environment: opts.Environment, - AppProject: opts.AppProject, + AppProject: opts.AppProject, OwnedPath: opts.OwnedPath, + ServiceGraph: append([]InventoryService(nil), opts.ServiceGraph...), } + if len(inventory.ServiceGraph) == 0 { + for _, service := range opts.Services { + inventory.ServiceGraph = append(inventory.ServiceGraph, InventoryService{ + Module: opts.Module, Service: service, Path: filepath.ToSlash(filepath.Join("services", service)), + }) + } + } + sort.Slice(inventory.ServiceGraph, func(i, j int) bool { + return inventory.ServiceGraph[i].Service < inventory.ServiceGraph[j].Service + }) hash := sha256.New() err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { if relative == InventoryFilename { diff --git a/pkg/gitops/render_test.go b/pkg/gitops/render_test.go index 3d8b4068..a08eb8a4 100644 --- a/pkg/gitops/render_test.go +++ b/pkg/gitops/render_test.go @@ -20,6 +20,24 @@ spec: image: ghcr.io/codefly-dev/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ` +func promotableServiceGraph(module string, services []string) []InventoryService { + graph := make([]InventoryService, 0, len(services)) + for _, service := range services { + graph = append(graph, InventoryService{ + Module: module, Service: service, Path: filepath.ToSlash(filepath.Join("services", service)), + Output: &KubernetesOutputInventory{ + Kind: "KUSTOMIZE", Profile: "KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1", + ContractVersion: "codefly.dev/kubernetes-manifest/v1", + Validation: KubernetesValidationInventory{ + StaticValidation: "STATUS_PASSED", ServerSideValidation: "STATUS_PASSED", + Promotable: true, Violations: []string{}, + }, + }, + }) + } + return graph +} + func TestRenderOwnedTreeIsDeterministicAndReplacesOnlyOwnedDestination(t *testing.T) { parent := t.TempDir() destination := filepath.Join(parent, "modules", "payments") @@ -34,13 +52,15 @@ func TestRenderOwnedTreeIsDeterministicAndReplacesOnlyOwnedDestination(t *testin t.Fatal(err) } render := func(ctx context.Context, root string) error { - if err := os.MkdirAll(filepath.Join(root, "services", "api"), 0o755); err != nil { + overlay := filepath.Join(root, "services", "api", "overlays", "production") + if err := os.MkdirAll(overlay, 0o755); err != nil { return err } - return os.WriteFile(filepath.Join(root, "services", "api", "deployment.yaml"), []byte(pinnedDeployment), 0o644) + return os.WriteFile(filepath.Join(overlay, "deployment.yaml"), []byte(pinnedDeployment), 0o644) } options := RenderOptions{ - Destination: destination, Module: "payments", Environment: "production", Promotable: true, + Destination: destination, Module: "payments", Services: []string{"api"}, + Environment: "production", Promotable: true, } first, err := RenderOwnedTree(context.Background(), &options, render) if err != nil { @@ -92,6 +112,22 @@ stringData: } } +func TestPromotableRenderRejectsIdentifierOnlyKubernetesSecret(t *testing.T) { + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), + Module: "payments", Service: "api", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + return os.WriteFile(filepath.Join(root, "secret.yaml"), []byte(`apiVersion: v1 +kind: Secret +metadata: + name: api +`), 0o644) + }) + if err == nil || !strings.Contains(err.Error(), "Secret resources are not allowed") { + t.Fatalf("error = %v", err) + } +} + func TestRenderRejectsSecretInJSONAndKubernetesList(t *testing.T) { tests := []struct { name string @@ -147,6 +183,16 @@ func TestRenderValidatesEffectiveKustomizeImagesWithinTheirOwnTree(t *testing.T) images: - name: example/api digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +`, + }, + { + name: "OCI selector containing tag", + kustomization: `resources: + - deployment.yaml +images: + - name: image:tag + newName: ghcr.io/codefly-dev/api + digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa `, }, { @@ -187,6 +233,9 @@ images: "example/api:build", 1, ) + if test.name == "OCI selector containing tag" { + deployment = strings.Replace(deployment, "example/api:build", "image:tag", 1) + } if err := os.WriteFile(filepath.Join(service, "deployment.yaml"), []byte(deployment), 0o644); err != nil { return err } @@ -212,6 +261,26 @@ images: } } +func TestRenderAppliesURLPolicyOnlyToURLBearingFields(t *testing.T) { + manifest := pinnedDeployment + `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: selectors +data: + image-selector: image:tag + command-argument: http://handled-by-the-workload.example +` + if _, err := RenderOwnedTree(context.Background(), &RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), + Module: "payments", Environment: "production", Promotable: true, + }, func(_ context.Context, root string) error { + return os.WriteFile(filepath.Join(root, "manifests.yaml"), []byte(manifest), 0o644) + }); err != nil { + t.Fatal(err) + } +} + func TestRenderInventoryMustRemainCanonical(t *testing.T) { destination := filepath.Join(t.TempDir(), "owned") _, err := RenderOwnedTree(context.Background(), &RenderOptions{ diff --git a/pkg/gitops/types.go b/pkg/gitops/types.go index c58c9736..4aa684fa 100644 --- a/pkg/gitops/types.go +++ b/pkg/gitops/types.go @@ -4,17 +4,41 @@ import "time" const ( InventoryFilename = ".codefly-render.json" - SchemaVersion = 1 + SchemaVersion = 2 ) type Inventory struct { - SchemaVersion int `json:"schemaVersion"` - Module string `json:"module"` - Service string `json:"service,omitempty"` - Environment string `json:"environment"` - AppProject string `json:"appProject,omitempty"` - Files []InventoryFile `json:"files"` - Digest string `json:"digest"` + SchemaVersion int `json:"schemaVersion"` + Module string `json:"module"` + Service string `json:"service,omitempty"` + Environment string `json:"environment"` + AppProject string `json:"appProject"` + OwnedPath string `json:"ownedPath"` + ServiceGraph []InventoryService `json:"serviceGraph"` + Files []InventoryFile `json:"files"` + Digest string `json:"digest"` +} + +type InventoryService struct { + Module string `json:"module"` + Service string `json:"service"` + Path string `json:"path,omitempty"` + Managed bool `json:"managed,omitempty"` + Output *KubernetesOutputInventory `json:"output,omitempty"` +} + +type KubernetesOutputInventory struct { + Kind string `json:"kind"` + Profile string `json:"profile"` + ContractVersion string `json:"contractVersion"` + Validation KubernetesValidationInventory `json:"validation"` +} + +type KubernetesValidationInventory struct { + StaticValidation string `json:"staticValidation"` + ServerSideValidation string `json:"serverSideValidation"` + Promotable bool `json:"promotable"` + Violations []string `json:"violations"` } type InventoryFile struct { @@ -24,12 +48,15 @@ type InventoryFile struct { } type RenderOptions struct { - Destination string - Module string - Service string - Environment string - AppProject string - Promotable bool + Destination string + Module string + Service string + Services []string + OwnedPath string + ServiceGraph []InventoryService + Environment string + AppProject string + Promotable bool } type RenderResult struct { @@ -48,20 +75,21 @@ type PublishRequest struct { } type PublishPlan struct { - ID string `json:"id"` - Repository string `json:"repository"` - RepositorySlug string `json:"repositorySlug,omitempty"` - Path string `json:"path"` - BaseBranch string `json:"baseBranch"` - BaseRevision string `json:"baseRevision"` - PromotionBranch string `json:"promotionBranch"` - BranchRevision string `json:"branchRevision,omitempty"` - ExistingCommit string `json:"existingCommit,omitempty"` - Module string `json:"module"` - Environment string `json:"environment"` - RenderDigest string `json:"renderDigest"` - Changed []string `json:"changed"` - Diff string `json:"diff"` + ID string `json:"id"` + Repository string `json:"repository"` + RepositorySlug string `json:"repositorySlug,omitempty"` + Path string `json:"path"` + BaseBranch string `json:"baseBranch"` + BaseRevision string `json:"baseRevision"` + PromotionBranch string `json:"promotionBranch"` + BranchRevision string `json:"branchRevision,omitempty"` + ExistingCommit string `json:"existingCommit,omitempty"` + Module string `json:"module"` + Environment string `json:"environment"` + RenderDigest string `json:"renderDigest"` + SnapshotRevision string `json:"snapshotRevision"` + Changed []string `json:"changed"` + Diff string `json:"diff"` } type PublishMutation struct { @@ -70,17 +98,18 @@ type PublishMutation struct { } type PublishResult struct { - PlanID string `json:"planId"` - Repository string `json:"repository"` - Path string `json:"path"` - BaseBranch string `json:"baseBranch"` - PromotionBranch string `json:"promotionBranch"` - RenderDigest string `json:"renderDigest"` - Commit string `json:"commit"` - Tree string `json:"tree"` - Signed bool `json:"signed"` - PullRequest string `json:"pullRequest"` - PullRequestID int `json:"pullRequestId,omitempty"` + PlanID string `json:"planId"` + Repository string `json:"repository"` + Path string `json:"path"` + BaseBranch string `json:"baseBranch"` + PromotionBranch string `json:"promotionBranch"` + RenderDigest string `json:"renderDigest"` + SnapshotRevision string `json:"snapshotRevision"` + Commit string `json:"commit"` + Tree string `json:"tree"` + Signed bool `json:"signed"` + PullRequest string `json:"pullRequest"` + PullRequestID int `json:"pullRequestId,omitempty"` } type RollbackRequest struct { diff --git a/pkg/orchestration/builder_deploy.go b/pkg/orchestration/builder_deploy.go index c3b3a50b..4a15ba7d 100644 --- a/pkg/orchestration/builder_deploy.go +++ b/pkg/orchestration/builder_deploy.go @@ -2,11 +2,16 @@ package orchestration import ( "context" + "fmt" "github.com/codefly-dev/cli/pkg/builder" "github.com/codefly-dev/cli/pkg/deployments" + coreservices "github.com/codefly-dev/core/agents/services" + 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) { @@ -27,6 +32,19 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if err != nil { return nil, w.Wrapf(err, "cannot get configuration") } + profile := deployments.KubernetesOutputProfile(b.world.RemoteManager) + var secretReferences map[string]*builderv0.KubernetesSecretKeyReference + if profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + secretName := "secret-" + b.instance.Service.Name + conf, dependenciesConfigurations, secretReferences, err = promotableDeploymentConfigurations( + conf, + dependenciesConfigurations, + secretName, + ) + if err != nil { + return nil, w.Wrapf(err, "cannot prepare promotable configuration") + } + } networkMappings, err := b.world.RemoteNetworkManager.GenerateNetworkMappings(ctx, b.world.Env, b.world.Workspace, b.instance.Identity, b.endpoints) if err != nil { @@ -54,7 +72,17 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { return nil, w.Wrapf(err, "cannot create build context") } - deploy, err := deployments.GetKubernetesDeployment(ctx, dockerContext, b.world.Workspace, b.instance.Module, b.instance.Service, b.world.Env, namespace) + deploy, err := deployments.GetKubernetesDeployment( + ctx, + dockerContext, + b.world.Workspace, + b.instance.Module, + b.instance.Service, + b.world.Env, + namespace, + profile, + secretReferences, + ) if err != nil { return nil, w.Wrapf(err, "cannot load service instance") } @@ -80,6 +108,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(profile, resp.GetDeployment()); err != nil { + return nil, w.Wrapf(err, "cannot verify service deployment output") + } err = b.world.ConfigurationManager.ExposeConfiguration(ctx, b.instance.Identity, resp.Configuration) if err != nil { @@ -112,3 +143,97 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { } return outputProperty, nil } + +func promotableDeploymentConfigurations( + configuration *basev0.Configuration, + dependencies []*basev0.Configuration, + secretName string, +) (*basev0.Configuration, []*basev0.Configuration, map[string]*builderv0.KubernetesSecretKeyReference, error) { + references := map[string]*builderv0.KubernetesSecretKeyReference{} + own, err := promotableConfiguration(configuration, secretName, references) + if err != nil { + return nil, nil, nil, err + } + safeDependencies := make([]*basev0.Configuration, 0, len(dependencies)) + for _, dependency := range dependencies { + safe, err := promotableConfiguration(dependency, secretName, references) + if err != nil { + return nil, nil, nil, err + } + safeDependencies = append(safeDependencies, safe) + } + return own, safeDependencies, references, nil +} + +func promotableConfiguration( + configuration *basev0.Configuration, + secretName string, + references map[string]*builderv0.KubernetesSecretKeyReference, +) (*basev0.Configuration, error) { + if configuration == nil { + return nil, nil + } + safe := proto.Clone(configuration).(*basev0.Configuration) + safe.Infos = safe.Infos[:0] + for _, sourceInfo := range configuration.GetInfos() { + if sourceInfo.GetData().GetSecret() { + return nil, fmt.Errorf("structured secret configuration %q requires typed Kubernetes key references", sourceInfo.GetName()) + } + info := proto.Clone(sourceInfo).(*basev0.ConfigurationInformation) + info.ConfigurationValues = info.ConfigurationValues[:0] + for _, sourceValue := range sourceInfo.GetConfigurationValues() { + if !sourceValue.GetSecret() && !resources.IsSensitiveKey(sourceValue.GetKey()) { + info.ConfigurationValues = append(info.ConfigurationValues, proto.Clone(sourceValue).(*basev0.ConfigurationValue)) + continue + } + secretConfiguration := &basev0.Configuration{ + Origin: configuration.GetOrigin(), + Infos: []*basev0.ConfigurationInformation{{ + Name: sourceInfo.GetName(), + ConfigurationValues: []*basev0.ConfigurationValue{{ + Key: sourceValue.GetKey(), Secret: true, + }}, + }}, + } + environmentVariables := resources.ConfigurationAsEnvironmentVariables(secretConfiguration, true) + if len(environmentVariables) != 1 { + return nil, fmt.Errorf("secret configuration %q/%q has no environment identity", sourceInfo.GetName(), sourceValue.GetKey()) + } + key := environmentVariables[0].Key + references[key] = &builderv0.KubernetesSecretKeyReference{Name: secretName, Key: key} + } + if len(info.GetConfigurationValues()) > 0 || info.GetData() != nil { + safe.Infos = append(safe.Infos, info) + } + } + return safe, nil +} + +func validateKubernetesDeploymentOutput( + requested builderv0.KubernetesOutputProfile, + output *builderv0.DeploymentOutput, +) error { + kubernetes := output.GetKubernetes() + if kubernetes == nil { + return fmt.Errorf("plugin returned no Kubernetes deployment output") + } + if kubernetes.GetProfile() != requested { + return fmt.Errorf("plugin returned Kubernetes output profile %s, requested %s", kubernetes.GetProfile(), requested) + } + if requested == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + if kubernetes.GetContractVersion() != coreservices.KubernetesManifestContractVersion { + return fmt.Errorf( + "plugin returned Kubernetes manifest contract %q, expected %q", + kubernetes.GetContractVersion(), + coreservices.KubernetesManifestContractVersion, + ) + } + validation := kubernetes.GetValidation() + if !validation.GetPromotable() || + validation.GetStaticValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED || + validation.GetServerSideValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { + return fmt.Errorf("plugin did not return a successfully validated promotable Kubernetes output") + } + } + return nil +} diff --git a/pkg/orchestration/builder_test.go b/pkg/orchestration/builder_test.go index 4def550b..36921a2a 100644 --- a/pkg/orchestration/builder_test.go +++ b/pkg/orchestration/builder_test.go @@ -1,8 +1,11 @@ package orchestration import ( + "strings" "testing" + coreservices "github.com/codefly-dev/core/agents/services" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" ) @@ -19,3 +22,93 @@ func TestBuildResultKindAssertionIsSafeForNonDockerResults(t *testing.T) { t.Fatalf("got %#v, want %#v", got, want) } } + +func TestPromotableDeploymentConfigurationsReplaceSecretBytesWithTypedReferences(t *testing.T) { + configuration := &basev0.Configuration{ + Origin: "users/accounts", + Infos: []*basev0.ConfigurationInformation{{ + Name: "authentication", + ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: "issuer", Value: "https://auth.example.com"}, + {Key: "client-secret", Value: "must-not-pass", Secret: true}, + {Key: "api-token", Value: "also-must-not-pass"}, + }, + }}, + } + + safe, dependencies, references, err := promotableDeploymentConfigurations( + configuration, + nil, + "secret-accounts", + ) + if err != nil { + t.Fatal(err) + } + if len(dependencies) != 0 { + t.Fatalf("dependencies = %+v", dependencies) + } + if got := safe.GetInfos()[0].GetConfigurationValues(); len(got) != 1 || got[0].GetKey() != "issuer" { + t.Fatalf("safe configuration = %+v", safe) + } + if !strings.Contains(configuration.String(), "must-not-pass") { + t.Fatal("source configuration was mutated") + } + for _, key := range []string{ + "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__AUTHENTICATION__CLIENT_SECRET", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__AUTHENTICATION__API_TOKEN", + } { + reference := references[key] + if reference == nil || reference.GetName() != "secret-accounts" || reference.GetKey() != key { + t.Fatalf("reference %q = %+v", key, reference) + } + } + for _, reference := range references { + if strings.Contains(reference.String(), "must-not-pass") { + t.Fatalf("secret bytes reached typed reference: %+v", reference) + } + } +} + +func TestPromotableDeploymentConfigurationsRejectStructuredSecretBytes(t *testing.T) { + _, _, _, err := promotableDeploymentConfigurations(&basev0.Configuration{ + Origin: "users/accounts", + Infos: []*basev0.ConfigurationInformation{{ + Name: "certificate", + Data: &basev0.ConfigurationData{ + Secret: true, Content: []byte("must-not-pass"), + }, + }}, + }, nil, "secret-accounts") + if err == nil || !strings.Contains(err.Error(), "typed Kubernetes key references") { + t.Fatalf("error = %v", err) + } +} + +func TestValidateKubernetesDeploymentOutputRequiresRequestedProfile(t *testing.T) { + requested := builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 + output := &builderv0.DeploymentOutput{ + Kind: &builderv0.DeploymentOutput_Kubernetes{ + Kubernetes: &builderv0.KubernetesDeploymentOutput{ + Profile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + }, + }, + } + err := validateKubernetesDeploymentOutput(requested, output) + if err == nil || !strings.Contains(err.Error(), "requested") { + t.Fatalf("error = %v", err) + } + output.GetKubernetes().Profile = requested + output.GetKubernetes().ContractVersion = coreservices.KubernetesManifestContractVersion + output.GetKubernetes().Validation = &builderv0.KubernetesManifestValidation{ + StaticValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, + ServerSideValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, + Promotable: true, + } + if err := validateKubernetesDeploymentOutput(requested, output); err != nil { + t.Fatal(err) + } + output.GetKubernetes().Validation.Promotable = false + if err := validateKubernetesDeploymentOutput(requested, output); err == nil || !strings.Contains(err.Error(), "successfully validated") { + t.Fatalf("validation error = %v", err) + } +}