diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index 23fad219..eba0bf98 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -133,6 +133,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) @@ -155,6 +156,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 @@ -162,10 +166,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 @@ -243,6 +247,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) @@ -287,11 +292,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/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..c510884f 100644 --- a/pkg/deployments/manager.go +++ b/pkg/deployments/manager.go @@ -18,6 +18,15 @@ type Manager interface { Handle(ctx context.Context, service *resources.Service, module *resources.Module, deploy *builderv0.DeploymentOutput) error } +type DeploymentOutputRequirement interface { + RequiresDeploymentOutput() bool +} + +func RequiresDeploymentOutput(manager Manager) bool { + requirement, ok := manager.(DeploymentOutputRequirement) + return ok && requirement.RequiresDeploymentOutput() +} + type RenderedTreeEvidence struct { Module string Service string @@ -70,18 +79,37 @@ 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, + 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 eb7121d0..d8da7f43 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,106 @@ 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) + } + servicePath := filepath.ToSlash(filepath.Join(targetPath, "services")) + changedServices, err := gitCommand( + ctx, + repo, + "diff", + "--name-only", + request.Revision, + request.Commit, + "--", + servicePath, + ) + if err != nil { + return Inventory{}, fmt.Errorf("compare reviewed service snapshot: %w", err) } - 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 changedServices != "" { + return Inventory{}, fmt.Errorf( + "signed publication changes immutable service snapshot files: %s", + strings.Join(strings.Fields(changedServices), ", "), + ) + } + 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 +671,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 +692,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 +749,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 e4192fa9..c723a557 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", @@ -139,6 +139,60 @@ func TestObserveRejectsPublishedSubtreeDigestMismatchBeforePollingArgo(t *testin } } +func TestObserveRejectsSignedPublicationWithDifferentServiceBytes(t *testing.T) { + request := observedPublication(t) + work := t.TempDir() + gitRun(t, "", "clone", request.Repository, work) + gitRun(t, work, "checkout", "codefly/promote-payments-local") + serviceManifest := filepath.Join( + work, + filepath.FromSlash(request.Path), + "services", + "api", + "overlays", + "local", + "manifests.yaml", + ) + data, err := os.ReadFile(serviceManifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(serviceManifest, []byte(strings.Replace(string(data), "name: api", "name: changed-api", 1)), 0o644); err != nil { + t.Fatal(err) + } + target := filepath.Join(work, filepath.FromSlash(request.Path)) + inventory, err := LoadInventory(target) + if err != nil { + t.Fatal(err) + } + updated, err := buildInventory(target, &RenderOptions{ + Module: inventory.Module, + Services: inventoryServiceNames(inventory.ServiceGraph), + OwnedPath: inventory.OwnedPath, + ServiceGraph: inventory.ServiceGraph, + Environment: inventory.Environment, + AppProject: inventory.AppProject, + Promotable: true, + }) + if err != nil { + t.Fatal(err) + } + if err := writeCanonicalInventory(filepath.Join(target, InventoryFilename), &updated); err != nil { + t.Fatal(err) + } + gitRun(t, work, "add", request.Path) + gitRun(t, work, "commit", "-S", "-m", "change reviewed service bytes") + gitRun(t, work, "push", "origin", "codefly/promote-payments-local") + request.Commit = gitOutput(t, work, "rev-parse", "HEAD^{commit}") + request.Tree = gitOutput(t, work, "rev-parse", "HEAD^{tree}") + request.RenderDigest = updated.Digest + + _, err = verifyPublishedRevision(context.Background(), &request) + if err == nil || !strings.Contains(err.Error(), "changes immutable service snapshot files") { + t.Fatalf("service snapshot byte mismatch error = %v", err) + } +} + func TestObserveRechecksHealthyApplicationsUntilOneStableSweep(t *testing.T) { request := observedPublication(t) bin := t.TempDir() @@ -168,10 +222,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 +246,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 +276,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 +285,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) } @@ -248,9 +302,14 @@ func observedPublication(t *testing.T) ObserveRequest { workspace := loadGitopsWorkspace(t, remote) 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 +323,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 +345,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 +372,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 +381,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 5554133e..afeaa7c0 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "sort" "strings" @@ -33,14 +32,20 @@ func renderModuleTree( includeBootstrap bool, ) (RenderResult, error) { destination := filepath.Join(workspace.Dir(), "deployments", "modules", 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: true, - OwnedPath: filepath.ToSlash(filepath.Join("deployments", "modules", module.Name)), + Module: module.Name, + Environment: env.Name, + AppProject: project, + Promotable: true, + OwnedPath: ownedPath, } return RenderOwnedTree(ctx, options, func(ctx context.Context, stage string) error { - var services []*resources.Service + services := make([]*resources.Service, 0, len(module.ServiceReferences)) for _, reference := range module.ServiceReferences { service, err := module.LoadServiceFromName(ctx, reference.Name) if err != nil { @@ -54,13 +59,23 @@ func renderModuleTree( } outputs := make(map[string]*builderv0.DeploymentOutput) for _, service := range roots { - if err := renderServiceFlow(ctx, workspace, module, service, env, false, sink, func(_ *resources.Module, rendered *resources.Service) string { - return filepath.Join(stage, "services", rendered.Name) - }, func(rendered map[string]*builderv0.DeploymentOutput) { - for unique, output := range rendered { - outputs[unique] = output - } - }); err != nil { + if err := renderServiceFlow( + ctx, + workspace, + module, + service, + env, + false, + sink, + func(_ *resources.Module, rendered *resources.Service) string { + return filepath.Join(stage, "services", rendered.Name) + }, + func(rendered map[string]*builderv0.DeploymentOutput) { + for unique, output := range rendered { + outputs[unique] = output + } + }, + ); err != nil { return fmt.Errorf("render service %s: %w", service.Name, err) } } @@ -87,10 +102,24 @@ func renderModuleTree( sort.Slice(options.ServiceGraph, func(i, j int) bool { return options.ServiceGraph[i].Service < options.ServiceGraph[j].Service }) - if !includeBootstrap { + if !includeBootstrap || module.Agent != nil { + return nil + } + static := filepath.Join(module.Dir(), "deployment", "kustomize") + info, err := os.Stat(static) + if os.IsNotExist(err) { return nil } - return generateEnvironmentBootstrap(ctx, workspace, module, env.Name, stage) + if err != nil { + return fmt.Errorf("inspect module kustomize tree: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("module kustomize path is not a directory") + } + if err := copyEnvironmentBootstrap(static, env.Name, filepath.Join(stage, "bootstrap")); err != nil { + return fmt.Errorf("copy module environment bootstrap: %w", err) + } + return nil }) } @@ -119,44 +148,6 @@ func moduleRenderRoots(module string, services []*resources.Service) ([]*resourc return roots, nil } -func generateEnvironmentBootstrap( - ctx context.Context, - workspace *resources.Workspace, - module *resources.Module, - environment, - destination string, -) error { - if module.Agent == nil { - _, err := copySelectedEnvironmentBootstrap(module.Dir(), environment, destination) - return err - } - binary, err := module.Agent.Path(ctx) - if err != nil { - return fmt.Errorf("resolve module generator %s: %w", module.Agent.Identifier(), err) - } - target := filepath.Join(destination, "kustomize") - command := exec.CommandContext( - ctx, - binary, - "gitops", - module.Dir(), - workspace.Dir(), - environment, - target, - ) - output, err := command.CombinedOutput() - if err != nil { - return fmt.Errorf( - "generate %s module bootstrap with %s: %w: %s", - environment, - module.Agent.Identifier(), - err, - strings.TrimSpace(string(output)), - ) - } - return nil -} - func copySelectedEnvironmentBootstrap(moduleDir, environment, destination string) (bool, error) { static := filepath.Join(moduleDir, "deployment", "kustomize") info, err := os.Stat(static) @@ -169,31 +160,64 @@ func copySelectedEnvironmentBootstrap(moduleDir, environment, destination string if !info.IsDir() { return false, fmt.Errorf("module kustomize path is not a directory") } - environmentBootstrap := filepath.Join(static, "overlays", environment) - info, err = os.Stat(environmentBootstrap) + if err := copyEnvironmentBootstrap( + static, + environment, + filepath.Join(destination, "kustomize"), + ); err != nil { + return false, err + } + return true, nil +} + +func copyEnvironmentBootstrap(source, environment, destination string) error { + selected := filepath.Join(source, "overlays", environment) + info, err := os.Stat(selected) if err != nil { - return false, fmt.Errorf("inspect generated %s module bootstrap: %w", environment, err) + return fmt.Errorf("select environment overlay %q: %w", environment, err) } if !info.IsDir() { - return false, fmt.Errorf("generated %s module bootstrap is not a directory", environment) + return fmt.Errorf("environment overlay %q is not a directory", environment) } - if err := copyTree( - environmentBootstrap, - filepath.Join(destination, "kustomize", "overlays", environment), - ); err != nil { - return false, fmt.Errorf("copy generated %s module bootstrap: %w", environment, err) + entries, err := os.ReadDir(source) + if err != nil { + return err } - return true, nil + for _, entry := range entries { + sourcePath := filepath.Join(source, entry.Name()) + destinationPath := filepath.Join(destination, entry.Name()) + if entry.Name() == "overlays" { + sourcePath = selected + destinationPath = filepath.Join(destinationPath, environment) + } + if err := copyTree(sourcePath, destinationPath); err != nil { + return err + } + } + return nil } func RenderService(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, project string, standAlone bool, sink orchestration.OutputSink) (RenderResult, error) { destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "services", module.Name, service.Name) return RenderOwnedTree(ctx, &RenderOptions{ Destination: destination, - Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project, - Promotable: true, + Module: module.Name, + Service: service.Name, + Environment: env.Name, + AppProject: project, + Promotable: true, }, func(ctx context.Context, stage string) error { - return renderServiceFlow(ctx, workspace, module, service, env, standAlone, sink, serviceRenderDestinations(stage), nil) + return renderServiceFlow( + ctx, + workspace, + module, + service, + env, + standAlone, + sink, + serviceRenderDestinations(stage), + nil, + ) }) } @@ -245,6 +269,7 @@ func renderServiceFlow( if err := flow.Load(ctx); err != nil { return err } + flow.WithDeploymentManager(gitOpsDeploymentOutputManager{}) flow.WithDeploymentDestination(destination) flow.WithKubernetesOutputProfile( builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, @@ -257,3 +282,18 @@ func renderServiceFlow( } return nil } + +type gitOpsDeploymentOutputManager struct{} + +func (gitOpsDeploymentOutputManager) RequiresDeploymentOutput() bool { + return true +} + +func (gitOpsDeploymentOutputManager) Handle( + context.Context, + *resources.Service, + *resources.Module, + *builderv0.DeploymentOutput, +) error { + return nil +} diff --git a/pkg/gitops/orchestrate_test.go b/pkg/gitops/orchestrate_test.go index 459ff248..5ab3a64b 100644 --- a/pkg/gitops/orchestrate_test.go +++ b/pkg/gitops/orchestrate_test.go @@ -9,35 +9,50 @@ import ( "github.com/codefly-dev/core/resources" ) -func TestCopySelectedEnvironmentBootstrapExcludesOtherEnvironments(t *testing.T) { - module := t.TempDir() +func TestCopyEnvironmentBootstrapPreservesSharedBaseAndExcludesOtherEnvironments(t *testing.T) { + source := t.TempDir() + base := filepath.Join(source, "base") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(base, "kustomization.yaml"), + []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - deployment.yaml\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "deployment.yaml"), []byte(pinnedDeployment), 0o644); err != nil { + t.Fatal(err) + } for _, environment := range []string{"local", "aws"} { - root := filepath.Join(module, "deployment", "kustomize", "overlays", environment) + 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(environment+"\n"), 0o644); err != nil { + if err := os.WriteFile( + filepath.Join(root, "kustomization.yaml"), + []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - ../../base\n"), + 0o644, + ); err != nil { t.Fatal(err) } } - destination := t.TempDir() - - copied, err := copySelectedEnvironmentBootstrap(module, "local", destination) - if err != nil { + destination := filepath.Join(t.TempDir(), "bootstrap") + if err := copyEnvironmentBootstrap(source, "local", destination); err != nil { t.Fatal(err) } - if !copied { - t.Fatal("selected environment bootstrap was not copied") + if _, err := os.Stat(filepath.Join(destination, "base", "deployment.yaml")); err != nil { + t.Fatal(err) } - data, err := os.ReadFile(filepath.Join(destination, "kustomize", "overlays", "local", "kustomization.yaml")) - if err != nil { + if _, err := os.Stat(filepath.Join(destination, "overlays", "local", "kustomization.yaml")); err != nil { t.Fatal(err) } - if string(data) != "local\n" { - t.Fatalf("selected bootstrap = %q", data) + if _, err := os.Stat(filepath.Join(destination, "overlays", "aws")); !os.IsNotExist(err) { + t.Fatalf("unselected environment copied: %v", err) } - if _, err := os.Stat(filepath.Join(destination, "kustomize", "overlays", "aws")); !os.IsNotExist(err) { - t.Fatalf("unselected bootstrap was copied: %v", err) + if err := validateTree(destination, &RenderOptions{Promotable: true}); err != nil { + t.Fatalf("selected bootstrap dependency graph is invalid: %v", err) } } diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index 1f43d001..200e6a74 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -17,7 +17,9 @@ import ( "strings" "github.com/codefly-dev/cli/pkg/internal/mutationauthority" + "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/core/resources" + "gopkg.in/yaml.v3" ) var ( @@ -27,8 +29,9 @@ var ( ) const ( - httpsScheme = "https" - sshScheme = "ssh" + httpsScheme = "https" + sshScheme = "ssh" + jsonExtension = ".json" ) type preparedRepository struct { @@ -38,7 +41,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 +56,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,8 +108,14 @@ 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) { - if err := validatePublishRequest(request); err != nil { +func preparePublish( + ctx context.Context, + workspace *resources.Workspace, + request *PublishRequest, + restoreRevision string, + publishSnapshot bool, +) (*preparedRepository, error) { + if err := validatePublishRequest(workspace, request); err != nil { return nil, err } config, repositorySlug, baseBranch, pathRoot, err := resolveGitops(workspace, request.Local) @@ -106,16 +125,10 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request rendered := filepath.Join(workspace.Dir(), "deployments", "modules", request.Module) var inventory Inventory if restoreRevision == "" { - if err := ValidateRenderedTree(rendered, "", true); err != nil { - return nil, fmt.Errorf("validate promotable render: %w", err) - } - inventory, err = LoadInventory(rendered) + inventory, err = loadPublicationInventory(ctx, workspace, request, rendered, pathRoot) if err != nil { return nil, err } - if inventory.Module != request.Module || inventory.Environment != request.Environment { - return nil, fmt.Errorf("render inventory targets module %q environment %q", inventory.Module, inventory.Environment) - } } promotionBranch := request.PromotionBranch @@ -146,14 +159,44 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request } } } + startRevision := baseRevision + if branchRevision != "" { + startRevision = branchRevision + } + var snapshotRevision string 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, + 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 +208,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 +217,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 +227,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,14 +239,677 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request }, nil } -func validatePublishRequest(request *PublishRequest) error { +func loadPublicationInventory( + ctx context.Context, + workspace *resources.Workspace, + request *PublishRequest, + rendered, + pathRoot string, +) (Inventory, error) { + if err := ValidateRenderedTree(rendered, "", true); err != nil { + return Inventory{}, fmt.Errorf("validate promotable render: %w", err) + } + inventory, err := LoadInventory(rendered) + if err != nil { + return Inventory{}, err + } + if inventory.Module != request.Module || inventory.Environment != request.Environment || inventory.Service != "" { + return Inventory{}, 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 Inventory{}, 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 Inventory{}, err + } + return inventory, 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) + } + selectedEnvironment, err := orchestration.SelectEnvironment(workspace, environment) + if err != nil { + return err + } + managed := selectedEnvironment.ManagedServices + 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, + 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) { + snapshot, err := prepareServiceSnapshot( + ctx, + repo, + target, + targetPath, + rendered, + renderedInventory, + module, + environment, + publishSnapshot, + ) + if 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, + snapshot.revision, + filepath.ToSlash(filepath.Join(targetPath, InventoryFilename)), + filepath.ToSlash(filepath.Join(targetPath, "bootstrap")), + !publishSnapshot, + ); err != nil { + return "", Inventory{}, err + } + } else { + renderedBootstrap := filepath.Join(rendered, "bootstrap") + if info, statErr := os.Stat(renderedBootstrap); statErr == nil && info.IsDir() { + if err := copyTree(renderedBootstrap, filepath.Join(target, "bootstrap")); err != nil { + return "", Inventory{}, fmt.Errorf("stage rendered bootstrap: %w", err) + } + } else if statErr != nil && !os.IsNotExist(statErr) { + return "", Inventory{}, fmt.Errorf("inspect rendered bootstrap: %w", statErr) + } + } + if err := verifyServiceSnapshotBinding(ctx, repo, snapshot.revision, snapshot.servicePath); err != nil { + return "", Inventory{}, err + } + if err := validateBootstrapRevision(filepath.Join(target, "bootstrap"), snapshot.revision); err != nil { + return "", Inventory{}, err + } + if module.Agent != nil { + if err := validateBootstrapServiceGraph( + filepath.Join(target, "bootstrap"), + targetPath, + snapshot.services, + environment, + ); err != nil { + return "", Inventory{}, err + } + } + + options := &RenderOptions{ + Module: renderedInventory.Module, + Services: snapshot.services, + 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 snapshot.revision, finalInventory, nil +} + +type serviceSnapshotPreparation struct { + revision string + services []string + servicePath string +} + +func prepareServiceSnapshot( + ctx context.Context, + repo, + target, + targetPath, + rendered string, + renderedInventory *Inventory, + module *resources.Module, + environment string, + publishSnapshot bool, +) (serviceSnapshotPreparation, error) { + renderedServices := filepath.Join(rendered, "services") + if info, err := os.Stat(renderedServices); err != nil || !info.IsDir() { + return serviceSnapshotPreparation{}, fmt.Errorf("rendered module contains no service snapshot") + } + servicePath := filepath.ToSlash(filepath.Join(targetPath, "services")) + if err := replaceCloneTree(renderedServices, repo, servicePath); err != nil { + return serviceSnapshotPreparation{}, fmt.Errorf("stage rendered services: %w", err) + } + if _, err := gitCommand(ctx, repo, "add", "-A", "--", servicePath); err != nil { + return serviceSnapshotPreparation{}, err + } + existingSnapshot, err := existingServiceSnapshot(ctx, repo, module.Name, environment, filepath.Join(target, "bootstrap")) + if err != nil { + return serviceSnapshotPreparation{}, err + } + if err := removePublicationRemainder(target); err != nil { + return serviceSnapshotPreparation{}, 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 serviceSnapshotPreparation{}, err + } + if err := writeCanonicalInventory(filepath.Join(target, InventoryFilename), &snapshotInventory); err != nil { + return serviceSnapshotPreparation{}, fmt.Errorf("write service snapshot inventory: %w", err) + } + if err := ValidateServiceSnapshot(target); err != nil { + return serviceSnapshotPreparation{}, fmt.Errorf("validate service snapshot: %w", err) + } + if _, err := gitCommand(ctx, repo, "add", "-A", "--", targetPath); err != nil { + return serviceSnapshotPreparation{}, err + } + snapshotChanged := existingSnapshot == "" + if !snapshotChanged { + snapshotChanges, diffErr := stagedPathsSince( + ctx, + repo, + existingSnapshot, + servicePath, + filepath.ToSlash(filepath.Join(targetPath, InventoryFilename)), + ) + if diffErr != nil { + return serviceSnapshotPreparation{}, diffErr + } + snapshotChanged = len(snapshotChanges) > 0 + } + lineageMissing := false + if existingSnapshot != "" { + lineageMissing, err = snapshotLineageMissing(ctx, repo, existingSnapshot) + if err != nil { + return serviceSnapshotPreparation{}, err + } + } + snapshotRevision := existingSnapshot + if snapshotChanged || lineageMissing { + snapshotRevision, err = commitServiceSnapshot(ctx, repo, module.Name, environment, existingSnapshot) + if err != nil { + return serviceSnapshotPreparation{}, err + } + } + if publishSnapshot { + if err := publishServiceSnapshot(ctx, repo, module.Name, environment, snapshotRevision); err != nil { + return serviceSnapshotPreparation{}, err + } + } + return serviceSnapshotPreparation{ + revision: snapshotRevision, + services: serviceNames, + servicePath: servicePath, + }, nil +} + +func verifyServiceSnapshotBinding(ctx context.Context, repo, snapshotRevision, servicePath string) error { + if _, err := gitCommand(ctx, repo, "add", "-A", "--", servicePath); err != nil { + return err + } + changed, err := stagedPathsSince(ctx, repo, snapshotRevision, servicePath) + if err != nil { + return err + } + if len(changed) > 0 { + return fmt.Errorf("module generation changed immutable service snapshot files %v", changed) + } + return 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, 0o600); 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 existingServiceSnapshot( + ctx context.Context, + repo, + module, + environment, + bootstrapRoot string, +) (string, error) { + remoteRef := "refs/remotes/origin/" + serviceSnapshotBranch(module, environment) + revision, err := gitCommand(ctx, repo, "for-each-ref", "--format=%(objectname)", remoteRef) + if err != nil { + return "", err + } + if revision != "" { + return gitCommand(ctx, repo, "rev-parse", revision+"^{commit}") + } + return bootstrapRevision(bootstrapRoot) +} + +func snapshotLineageMissing(ctx context.Context, repo, snapshot string) (bool, error) { + if _, err := gitCommand(ctx, repo, "merge-base", "--is-ancestor", snapshot, "HEAD"); err == nil { + return false, nil + } + if _, err := gitCommand(ctx, repo, "cat-file", "-e", snapshot+"^{commit}"); err != nil { + return false, fmt.Errorf("resolve previous service snapshot %s: %w", snapshot, err) + } + return true, nil +} + +func commitServiceSnapshot(ctx context.Context, repo, module, environment, previousSnapshot string) (string, error) { + head, err := gitCommand(ctx, repo, "rev-parse", "HEAD^{commit}") + if err != nil { + return "", err + } + parents := []string{head} + if previousSnapshot != "" { + missing, lineageErr := snapshotLineageMissing(ctx, repo, previousSnapshot) + if lineageErr != nil { + return "", lineageErr + } + if missing { + parents = append(parents, previousSnapshot) + } + } + var timestamp int64 + for _, parent := range parents { + rawTimestamp, showErr := gitCommand(ctx, repo, "show", "-s", "--format=%ct", parent) + if showErr != nil { + return "", showErr + } + parentTimestamp, parseErr := strconv.ParseInt(rawTimestamp, 10, 64) + if parseErr != nil { + return "", fmt.Errorf("parse parent commit timestamp %q: %w", rawTimestamp, parseErr) + } + if parentTimestamp > timestamp { + timestamp = parentTimestamp + } + } + tree, err := gitCommand(ctx, repo, "write-tree") + if err != nil { + return "", err + } + args := []string{"commit-tree", tree} + for _, parent := range parents { + args = append(args, "-p", parent) + } + args = append(args, "-m", fmt.Sprintf("Snapshot %s services for %s", module, environment)) + date := fmt.Sprintf("@%d +0000", timestamp+1) + revision, err := gitCommandWithEnv( + ctx, + repo, + []string{ + "GIT_AUTHOR_NAME=Codefly GitOps", + "GIT_AUTHOR_EMAIL=gitops@codefly.dev", + "GIT_COMMITTER_NAME=Codefly GitOps", + "GIT_COMMITTER_EMAIL=gitops@codefly.dev", + "GIT_AUTHOR_DATE=" + date, + "GIT_COMMITTER_DATE=" + date, + }, + args..., + ) + if err != nil { + return "", fmt.Errorf("create immutable service snapshot: %w", err) + } + if _, err := gitCommand(ctx, repo, "reset", "--soft", revision); err != nil { + return "", err + } + return revision, nil +} + +func publishServiceSnapshot(ctx context.Context, repo, module, environment, revision string) error { + snapshotBranch := serviceSnapshotBranch(module, 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 serviceSnapshotBranch(module, environment string) string { + return "codefly/snapshot-" + sanitizeRef(module) + "-" + sanitizeRef(environment) +} + +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, + destinationPath 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, 0o600); err != nil { + return err + } + if _, err := command(ctx, stage, binary, stagedModule, module.Name); err != nil { + return fmt.Errorf("generate module bootstrap: %w", err) + } + destination, err := confinedJoin(checkout, destinationPath) + if err != nil { + return 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 = httpsScheme + } + if parsed.Scheme != httpsScheme { + 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(_, current, _ 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, _ 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, _ string, 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 != jsonExtension { + 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(workspace *resources.Workspace, request *PublishRequest) error { if request == nil || request.Module == "" || request.Environment == "" { return fmt.Errorf("module and environment are required") } + if workspace == nil { + return fmt.Errorf("workspace is required") + } if err := validatePathComponent("module", request.Module); err != nil { return err } - return validatePathComponent("environment", request.Environment) + if err := validatePathComponent("environment", request.Environment); err != nil { + return err + } + environment, err := orchestration.SelectEnvironment(workspace, request.Environment) + if err != nil { + return err + } + if request.Local && !environment.IsK3d() { + return fmt.Errorf("local GitOps qualification requires a k3d environment, got %q", request.Environment) + } + return nil } func prepareRollback(ctx context.Context, workspace *resources.Workspace, request *RollbackRequest) (*preparedRepository, string, error) { @@ -218,6 +925,9 @@ func prepareRollback(ctx context.Context, workspace *resources.Workspace, reques if !gitObjectPattern.MatchString(request.ToRevision) { return nil, "", fmt.Errorf("rollback target must be an exact Git object ID") } + if err := validatePublishRequest(workspace, &request.PublishRequest); err != nil { + return nil, "", err + } if err := requireReviewedRevision(workspace.Dir(), request.Module, request.Environment, request.ToRevision); err != nil { return nil, "", err } @@ -237,7 +947,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 } @@ -256,7 +966,7 @@ func requireReviewedRevision(root, module, environment, revision string) error { return fmt.Errorf("load reviewed promotion evidence: %w", err) } for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + if entry.IsDir() || filepath.Ext(entry.Name()) != jsonExtension { continue } data, err := os.ReadFile(filepath.Join(directory, entry.Name())) @@ -271,7 +981,7 @@ func requireReviewedRevision(root, module, environment, revision string) error { evidence.Review.State == "LOCAL_REVIEW_REF" && evidence.Review.ReviewDecision == "LOCAL_QUALIFIED" if evidence.SchemaVersion == EvidenceSchemaVersion && evidence.Module == module && evidence.Environment == environment && evidence.Health == healthyStatus && reviewed && - (evidence.ArgoRevision == revision || evidence.SignedCommit == revision) { + evidence.SignedCommit == revision { return nil } } @@ -285,7 +995,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,10 +1036,11 @@ 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 { + if err := writeReceipt(workspace.Dir(), "publications", request.Module+"-"+request.Environment+jsonExtension, result); err != nil { return PublishResult{}, err } return result, nil @@ -393,10 +1104,17 @@ func restoreCloneTree(ctx context.Context, repo, targetPath, revision string) er if _, err := gitCommand(ctx, repo, "checkout", revision, "--", targetPath); err != nil { return fmt.Errorf("restore GitOps tree from %s: %w", revision, err) } + if _, err := confinedJoin(repo, targetPath); err != nil { + return err + } return nil } -func replaceCloneTree(source, destination string) error { +func replaceCloneTree(source, root, destinationPath string) error { + destination, err := confinedJoin(root, destinationPath) + if err != nil { + return err + } if err := os.RemoveAll(destination); err != nil { return err } @@ -406,19 +1124,22 @@ 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 := make([]string, 0, 6+len(paths)) + args = append(args, "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) { @@ -438,7 +1159,7 @@ func changedPathsBetween(ctx context.Context, repo, baseRevision, branchRevision func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, request *PublishRequest, commit string) (string, int, error) { if prepared.plan.RepositorySlug == "" { - reviewRef := "refs/codefly/reviews/" + strings.ReplaceAll(prepared.plan.PromotionBranch, "/", "-") + reviewRef := localReviewRef(prepared.plan.PromotionBranch, commit) refspec := commit + ":" + reviewRef if _, err := gitCommand(ctx, prepared.dir, "push", "--porcelain", "--", "origin", refspec); err != nil { return "", 0, fmt.Errorf("publish local review ref: %w", err) @@ -451,7 +1172,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, @@ -490,6 +1216,10 @@ func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, return verifyPullRequest(ctx, prepared.plan.RepositorySlug, strings.TrimSpace(url), prepared.plan.BaseBranch, commit) } +func localReviewRef(promotionBranch, commit string) string { + return "refs/codefly/reviews/" + strings.ReplaceAll(promotionBranch, "/", "-") + "/" + commit +} + func verifyPullRequest(ctx context.Context, repository, pullRequest, baseBranch, commit string) (string, int, error) { output, err := command(ctx, "", "gh", "pr", "view", pullRequest, "--repo", repository, "--json", "number,url,headRefOid,baseRefName") @@ -603,6 +1333,26 @@ func confinedJoin(root, relative string) (string, error) { if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("GitOps destination %q escapes repository", relative) } + current := root + for _, component := range strings.Split(rel, string(filepath.Separator)) { + if component == "." || component == "" { + continue + } + current = filepath.Join(current, component) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + break + } + if statErr != nil { + return "", fmt.Errorf("inspect GitOps destination %q: %w", relative, statErr) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("GitOps destination %q traverses symbolic link %s", relative, current) + } + if !info.IsDir() { + return "", fmt.Errorf("GitOps destination %q traverses non-directory %s", relative, current) + } + } return target, nil } @@ -699,7 +1449,7 @@ func LoadPublishResult(root, module, environment string) (PublishResult, error) if err := validatePathComponent("environment", environment); err != nil { return PublishResult{}, err } - path := filepath.Join(root, ".codefly", "gitops", "publications", module+"-"+environment+".json") + path := filepath.Join(root, ".codefly", "gitops", "publications", module+"-"+environment+jsonExtension) data, err := os.ReadFile(path) if err != nil { return PublishResult{}, fmt.Errorf("read publication receipt: %w", err) @@ -708,7 +1458,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 +1468,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 0f6dcae0..6a51b768 100644 --- a/pkg/gitops/publish_test.go +++ b/pkg/gitops/publish_test.go @@ -36,27 +36,53 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { 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) } branch := gitOutput(t, "", "--git-dir", remote, "rev-parse", "refs/heads/"+request.PromotionBranch+"^{commit}") - review := gitOutput(t, "", "--git-dir", remote, "rev-parse", "refs/codefly/reviews/codefly-promote-payments-production^{commit}") + review := gitOutput(t, "", "--git-dir", remote, "rev-parse", localReviewRef(request.PromotionBranch, result.Commit)+"^{commit}") 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,166 @@ 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/overlays/production/application.yaml", + ) + if !strings.Contains(application, "targetRevision: "+result.SnapshotRevision) { + t.Fatalf("generated Application = %s", application) + } +} + +func TestPlanPublishRejectsModuleGeneratorMutationOfServiceSnapshot(t *testing.T) { + ctx := context.Background() + remote := createBareRepository(t) + workspace := loadGitopsWorkspaceWithAgent(t, remote) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + 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)" +printf '\n# changed by generator\n' >> "$checkout/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) + } + + _, err = PlanPublish(ctx, workspace, &PublishRequest{ + Module: "payments", Environment: "production", Local: true, + PromotionBranch: "codefly/promote-payments-production", + }) + if err == nil || !strings.Contains(err.Error(), "changed immutable service snapshot files") { + t.Fatalf("module generator service mutation error = %v", err) + } +} + func TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit(t *testing.T) { ctx := context.Background() remote := createBareRepository(t) @@ -110,6 +291,50 @@ func TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit(t *testing.T) { } } +func TestPublishPreservesSnapshotLineageAfterSquashAndBranchDeletion(t *testing.T) { + ctx := context.Background() + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + configureSSHSigning(t) + request := PublishRequest{ + Module: "payments", Environment: "production", Local: true, + PromotionBranch: "codefly/promote-payments-production", + } + firstPlan, err := PlanPublish(ctx, workspace, &request) + if err != nil { + t.Fatal(err) + } + first, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: firstPlan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + + work := t.TempDir() + gitRun(t, "", "clone", remote, work) + gitRun(t, work, "config", "user.name", "Codefly Test") + gitRun(t, work, "config", "user.email", "codefly@example.com") + gitRun(t, work, "config", "commit.gpgsign", "false") + gitRun(t, work, "merge", "--squash", "origin/"+request.PromotionBranch) + gitRun(t, work, "commit", "-m", "squash promotion") + gitRun(t, work, "push", "origin", "main") + gitRun(t, work, "push", "origin", "--delete", request.PromotionBranch) + + renderPublishFixture(t, workspace.Dir(), "payments", "production", "worker") + secondPlan, err := PlanPublish(ctx, workspace, &request) + if err != nil { + t.Fatal(err) + } + second, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: secondPlan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + gitRun(t, "", "--git-dir", remote, "merge-base", "--is-ancestor", first.SnapshotRevision, second.SnapshotRevision) + if first.SnapshotRevision == second.SnapshotRevision { + t.Fatal("changed service tree reused the previous snapshot") + } +} + func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) @@ -137,6 +362,57 @@ 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 TestPlanPublishRejectsRepositorySymlinkWithoutTouchingItsTarget(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + + victim := t.TempDir() + victimFile := filepath.Join(victim, "keep.txt") + if err := os.WriteFile(victimFile, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + work := t.TempDir() + gitRun(t, "", "clone", remote, work) + gitRun(t, work, "config", "user.name", "Codefly Test") + gitRun(t, work, "config", "user.email", "codefly@example.com") + gitRun(t, work, "config", "commit.gpgsign", "false") + targetParent := filepath.Join(work, "environments", "deployments", "modules") + if err := os.MkdirAll(targetParent, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, filepath.Join(targetParent, "payments")); err != nil { + t.Fatal(err) + } + gitRun(t, work, "add", "environments") + gitRun(t, work, "commit", "-m", "seed hostile publication path") + gitRun(t, work, "push", "origin", "main") + + _, err := PlanPublish(context.Background(), workspace, &PublishRequest{ + Module: "payments", Environment: "production", Local: true, + }) + if err == nil || !strings.Contains(err.Error(), "traverses symbolic link") { + t.Fatalf("symlink publication error = %v", err) + } + data, readErr := os.ReadFile(victimFile) + if readErr != nil || string(data) != "keep\n" { + t.Fatalf("external target changed: data=%q err=%v", data, readErr) + } +} + func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { ctx := context.Background() remote := createBareRepository(t) @@ -224,6 +500,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", @@ -252,6 +547,23 @@ func TestRemotePublishRequiresSafeGitHubRepository(t *testing.T) { } } +func TestPlanPublishRejectsLocalQualificationForRemoteEnvironment(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + workspace.Environments = append(workspace.Environments, &resources.Environment{ + Name: "aws", + Cluster: &resources.EnvironmentCluster{Kind: "eks"}, + }) + renderPublishFixture(t, workspace.Dir(), "payments", "aws", "api") + + _, err := PlanPublish(context.Background(), workspace, &PublishRequest{ + Module: "payments", Environment: "aws", Local: true, + }) + if err == nil || !strings.Contains(err.Error(), "requires a k3d environment") { + t.Fatalf("remote environment local qualification error = %v", err) + } +} + func mergePromotionToMain(t *testing.T, remote, branch string) { t.Helper() work := t.TempDir() @@ -283,18 +595,78 @@ 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 +environments: + - name: production + cluster: + kind: k3d 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 +environments: + - name: production + cluster: + kind: k3d +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) @@ -306,10 +678,17 @@ func renderPublishFixture(t *testing.T, root, module, environment, name string) t.Helper() 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 a36a73df..f0ae9cd4 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -9,8 +9,125 @@ 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) + data, err := os.ReadFile(workspaceConfiguration) + if err != nil { + t.Fatal(err) + } + updated := strings.Replace(string(data), "gitops:\n", ` - name: aws + cluster: + kind: eks + managed-services: + cache: {} + object-storage: {} + store: {} + vault: {} +gitops: +`, 1) + if err := os.WriteFile(workspaceConfiguration, []byte(updated), 0o644); err != nil { + t.Fatal(err) + } + workspace.Environments = append(workspace.Environments, &resources.Environment{ + Name: "aws", + Cluster: &resources.EnvironmentCluster{Kind: "eks"}, + ManagedServices: map[string]resources.EnvironmentManagedService{ + "cache": {}, + "object-storage": {}, + "store": {}, + "vault": {}, + }, + }) + renderMindShapedFixture(t, workspace.Dir(), "aws") + configureSSHSigning(t) + repository := "https://github.com/codefly-test/manifests.git" + workspace.Gitops.RepoURL = repository + t.Setenv("GIT_CONFIG_COUNT", "4") + t.Setenv("GIT_CONFIG_KEY_3", "url.file://"+remote+".insteadOf") + t.Setenv("GIT_CONFIG_VALUE_3", repository) + + 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) + } + gh := filepath.Join(bin, "gh") + ghScript := `#!/bin/sh +set -eu +if [ "$1 $2" = "pr list" ]; then + printf '%s\n' '[]' + exit 0 +fi +if [ "$1 $2" = "pr create" ]; then + printf '%s\n' 'https://github.com/codefly-test/manifests/pull/1' + exit 0 +fi +if [ "$1 $2" = "pr view" ]; then + revision="$(git --git-dir "$CODEFLY_TEST_REMOTE" rev-parse refs/heads/codefly/promote-payments-aws)" + printf '{"number":1,"url":"https://github.com/codefly-test/manifests/pull/1","headRefOid":"%s","baseRefName":"main"}\n' "$revision" + exit 0 +fi +exit 2 +` + if err := os.WriteFile(gh, []byte(ghScript), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEFLY_TEST_KUBECTL_CALLED", kubectlCalled) + t.Setenv("CODEFLY_TEST_REMOTE", remote) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + request := PublishRequest{ + Module: "payments", Environment: "aws", + 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 +139,8 @@ func TestLocalK3dDisposableGitQualification(t *testing.T) { } remote := createBareRepository(t) - workspace := loadGitopsWorkspace(t, remote) - _, err := RenderOwnedTree(context.Background(), &RenderOptions{ - Destination: filepath.Join(workspace.Dir(), "deployments", "modules", "payments"), - Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, - }, func(ctx context.Context, root string) error { - if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 -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 +190,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 +202,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/deployments/modules/payments + targetRevision: %s + path: environments/deployments/modules/payments/services/%s/overlays/local destination: server: https://kubernetes.default.svc namespace: payments @@ -125,8 +223,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 +248,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 +263,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 cbc97d16..50d088b8 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,18 @@ 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, - Environment: inventory.Environment, AppProject: project, Promotable: promotable, + 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 @@ -149,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 { @@ -233,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 @@ -492,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("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") @@ -568,7 +771,7 @@ 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 isURLPath(path) { + if isURLBearingPath(path) { if err := validateURLValue(strings.Join(path, "."), typed); err != nil { return err } @@ -580,19 +783,17 @@ func inspectValue(value any, path []string, promotable bool) error { return nil } -func isURLPath(path []string) bool { +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 strings.Contains(normalized, "url") || - strings.Contains(normalized, "uri") || - normalized == "server" || - normalized == "repository" || - normalized == "repo" || - normalized == "sourcerepos" + return normalized == "server" || + normalized == "sourcerepos" || + strings.HasSuffix(normalized, "url") || + strings.HasSuffix(normalized, "uri") } return false } @@ -671,12 +872,20 @@ func metadataString(value map[string]any, key string) string { func buildInventory(root string, opts *RenderOptions) (Inventory, error) { inventory := Inventory{ SchemaVersion: SchemaVersion, - Module: opts.Module, - Environment: opts.Environment, - AppProject: opts.AppProject, - OwnedPath: filepath.ToSlash(opts.OwnedPath), - ServiceGraph: append([]InventoryService{}, opts.ServiceGraph...), + Module: opts.Module, Service: opts.Service, Environment: opts.Environment, + AppProject: opts.AppProject, OwnedPath: filepath.ToSlash(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 b160def6..e7815856 100644 --- a/pkg/gitops/render_test.go +++ b/pkg/gitops/render_test.go @@ -22,6 +22,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") @@ -36,13 +54,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 { @@ -166,6 +186,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 @@ -221,6 +257,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 `, }, { @@ -261,6 +307,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 } @@ -286,6 +335,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 f273b4de..e3fc1e36 100644 --- a/pkg/gitops/types.go +++ b/pkg/gitops/types.go @@ -15,8 +15,9 @@ const ( 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"` + AppProject string `json:"appProject"` OwnedPath string `json:"ownedPath"` ServiceGraph []InventoryService `json:"serviceGraph"` Files []InventoryFile `json:"files"` @@ -45,6 +46,9 @@ type InventoryKubernetesValidation struct { Violations []string `json:"violations"` } +type KubernetesOutputInventory = InventoryKubernetesOutput +type KubernetesValidationInventory = InventoryKubernetesValidation + type InventoryFile struct { Path string `json:"path"` SHA256 string `json:"sha256"` @@ -55,6 +59,7 @@ type RenderOptions struct { Destination string Module string Service string + Services []string Environment string AppProject string Promotable bool @@ -101,20 +106,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 { @@ -123,17 +129,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 b1568ce1..ccfbbf59 100644 --- a/pkg/orchestration/builder_deploy.go +++ b/pkg/orchestration/builder_deploy.go @@ -7,6 +7,7 @@ import ( "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" @@ -41,6 +42,19 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { return nil, w.Wrapf(err, "cannot get configuration") } dependenciesConfigurations = append(workspaceConfigurations, dependenciesConfigurations...) + profile := kubernetesOutputProfile(b.world) + 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 { @@ -62,44 +76,42 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { return nil, w.Wrapf(err, "cannot get namespace") } - // Build the request dockerContext, err := builder.DockerBuildContext(ctx, b.world.Workspace) if err != nil { return nil, w.Wrapf(err, "cannot create build context") } dockerContext.ImageDigest = b.imageDigest - 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, + namespace, + profile, + secretReferences, + ) if err != nil { return nil, w.Wrapf(err, "cannot load service instance") } if b.world.DeploymentDestination != nil { deploy.GetKubernetes().Destination = b.world.DeploymentDestination(b.instance.Module, b.instance.Service) } - profile := kubernetesOutputProfile(b.world) - deploy.GetKubernetes().Profile = profile deploy.GetKubernetes().ValidateServerSide = profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 && b.world.Env.IsK3d() validationContext := "" - if profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { - if deploy.GetKubernetes().GetValidateServerSide() { - kubeconfig, contextName, targetErr := kubernetesValidationTarget(ctx, b.world.Env) - if targetErr != nil { - return nil, w.Wrapf(targetErr, "cannot resolve promotable GitOps validation target") - } - deploy.GetKubernetes().ValidationKubeconfig = kubeconfig - deploy.GetKubernetes().ValidationContext = contextName - validationContext = contextName - } - conf, dependenciesConfigurations, deploy.GetKubernetes().SecretReferences, err = - promotableDeploymentInputs(b.instance.Service.Name, conf, dependenciesConfigurations) - if err != nil { - return nil, w.Wrapf(err, "cannot prepare promotable GitOps inputs") + if deploy.GetKubernetes().GetValidateServerSide() { + kubeconfig, contextName, targetErr := kubernetesValidationTarget(ctx, b.world.Env) + if targetErr != nil { + return nil, w.Wrapf(targetErr, "cannot resolve promotable GitOps validation target") } + deploy.GetKubernetes().ValidationKubeconfig = kubeconfig + deploy.GetKubernetes().ValidationContext = contextName + validationContext = contextName } - // Build the request w.Debug("deployments", wool.Field("deployments", deploy)) resp, err := b.instance.Builder.Deploy(ctx, &builderv0.DeploymentRequest{ @@ -117,10 +129,17 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if resp.State != nil && resp.State.State != builderv0.DeploymentStatus_SUCCESS { return nil, w.NewError("cant deploy service instance") } - if err = validateKubernetesDeploymentOutput(resp.GetDeployment(), profile, validationContext); err != nil { - return nil, w.Wrapf(err, "cannot accept Kubernetes deployment output") + if err := validateDeploymentOutput( + b.world.RemoteManager, + profile, + resp.Deployment, + validationContext, + ); err != nil { + return nil, w.Wrapf(err, "cannot verify service deployment output") + } + if resp.Deployment != nil { + b.deploymentOutput = proto.Clone(resp.Deployment).(*builderv0.DeploymentOutput) } - b.deploymentOutput = proto.Clone(resp.GetDeployment()).(*builderv0.DeploymentOutput) err = b.world.ConfigurationManager.ExposeConfiguration(ctx, b.instance.Identity, resp.Configuration) if err != nil { @@ -137,14 +156,7 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { return nil, w.Wrapf(err, "cannot process outputProperty for deploy") } - if resp.Deployment == nil { - return outputProperty, nil - } - // Render-only mode: caller (cli/cmd/deploy) skipped wiring a - // deployment manager so manifests get written to disk by the - // agent's KustomizeDeploy but no kubectl apply runs. Used by - // the gitops flow where ArgoCD picks up the rendered tree. - if b.world.RemoteManager == nil { + if resp.Deployment == nil || b.world.RemoteManager == nil { return outputProperty, nil } err = b.world.RemoteManager.Handle(ctx, b.instance.Service, b.instance.Module, resp.Deployment) @@ -179,107 +191,127 @@ func kubernetesOutputProfile(world *World) builderv0.KubernetesOutputProfile { if world.KubernetesOutputProfile != builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_UNSPECIFIED { return world.KubernetesOutputProfile } - if world.Env.IsK3d() { - return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 - } - return builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 + return deployments.KubernetesOutputProfile(world.RemoteManager) } -func promotableDeploymentInputs( - secretName string, +func promotableDeploymentConfigurations( configuration *basev0.Configuration, dependencies []*basev0.Configuration, + secretName string, ) (*basev0.Configuration, []*basev0.Configuration, map[string]*builderv0.KubernetesSecretKeyReference, error) { - references := make(map[string]*builderv0.KubernetesSecretKeyReference) - sanitized, err := sanitizePromotableConfiguration(secretName, configuration, references) + references := map[string]*builderv0.KubernetesSecretKeyReference{} + own, err := promotableConfiguration(configuration, secretName, references) if err != nil { return nil, nil, nil, err } - sanitizedDependencies := make([]*basev0.Configuration, len(dependencies)) - for index, dependency := range dependencies { - sanitizedDependencies[index], err = sanitizePromotableConfiguration(secretName, dependency, references) + 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 sanitized, sanitizedDependencies, references, nil + return own, safeDependencies, references, nil } -func sanitizePromotableConfiguration( - secretName string, +func promotableConfiguration( configuration *basev0.Configuration, + secretName string, references map[string]*builderv0.KubernetesSecretKeyReference, ) (*basev0.Configuration, error) { if configuration == nil { return nil, nil } - sanitized := proto.Clone(configuration).(*basev0.Configuration) - for _, information := range sanitized.GetInfos() { - if information.GetData().GetSecret() { - return nil, fmt.Errorf("secret configuration data %q has no Kubernetes Secret key reference", information.GetName()) + 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()) } - values := information.GetConfigurationValues() - kept := values[:0] - for _, value := range values { - if value.GetSecret() || resources.IsSensitiveKey(value.GetKey()) { - value.Secret = true + 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 } - kept = append(kept, value) - } - information.ConfigurationValues = kept - } - - referenceSource := proto.Clone(configuration).(*basev0.Configuration) - for _, information := range referenceSource.GetInfos() { - for _, value := range information.GetConfigurationValues() { - value.Secret = value.GetSecret() || resources.IsSensitiveKey(value.GetKey()) + 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} } - } - for _, environmentVariable := range resources.ConfigurationAsEnvironmentVariables(referenceSource, true) { - references[environmentVariable.Key] = &builderv0.KubernetesSecretKeyReference{ - Name: secretName + "-secrets", - Key: environmentVariable.Key, + if len(info.GetConfigurationValues()) > 0 || info.GetData() != nil { + safe.Infos = append(safe.Infos, info) } } - return sanitized, nil + return safe, nil } func validateKubernetesDeploymentOutput( - deployment *builderv0.DeploymentOutput, - profile builderv0.KubernetesOutputProfile, + requested builderv0.KubernetesOutputProfile, + output *builderv0.DeploymentOutput, validationContext string, ) error { - kubernetes := deployment.GetKubernetes() + kubernetes := output.GetKubernetes() if kubernetes == nil { - return fmt.Errorf("builder returned no Kubernetes deployment output") + 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 kubernetes.GetProfile() != profile { - return fmt.Errorf("builder returned profile %s for requested profile %s", kubernetes.GetProfile(), profile) + if requested != builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + return nil } - if kubernetes.GetContractVersion() == "" { - return fmt.Errorf("builder returned no Kubernetes contract version") + 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.GetStaticValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { - return fmt.Errorf("builder did not pass static Kubernetes validation") + if !validation.GetPromotable() || + validation.GetStaticValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { + return fmt.Errorf("plugin did not return a successfully validated promotable Kubernetes output") } - if profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { - if validationContext != "" { - if validation.GetServerSideValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { - return fmt.Errorf("builder did not pass server-side Kubernetes validation") - } - if validation.GetValidatedContext() != validationContext { - return fmt.Errorf( - "builder validated Kubernetes context %q for requested context %q", - validation.GetValidatedContext(), - validationContext, - ) - } + if validationContext != "" { + if validation.GetServerSideValidation() != builderv0.KubernetesManifestValidation_STATUS_PASSED { + return fmt.Errorf("plugin did not pass server-side Kubernetes validation") } - if !validation.GetPromotable() { - return fmt.Errorf("builder did not return a promotable Kubernetes deployment") + if validation.GetValidatedContext() != validationContext { + return fmt.Errorf( + "plugin validated Kubernetes context %q, requested %q", + validation.GetValidatedContext(), + validationContext, + ) } } return nil } + +func validateDeploymentOutput( + manager deployments.Manager, + requested builderv0.KubernetesOutputProfile, + output *builderv0.DeploymentOutput, + validationContext string, +) error { + if output == nil { + if deployments.RequiresDeploymentOutput(manager) { + return fmt.Errorf("plugin returned no Kubernetes deployment output") + } + return nil + } + return validateKubernetesDeploymentOutput(requested, output, validationContext) +} diff --git a/pkg/orchestration/builder_deploy_test.go b/pkg/orchestration/builder_deploy_test.go index c91434ae..d299b9bb 100644 --- a/pkg/orchestration/builder_deploy_test.go +++ b/pkg/orchestration/builder_deploy_test.go @@ -1,8 +1,11 @@ package orchestration import ( + "context" "testing" + "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" @@ -34,31 +37,28 @@ func TestPromotableDeploymentInputsReplaceSecretValuesWithReferences(t *testing. originalConfiguration := proto.Clone(configuration) originalDependency := proto.Clone(dependency) - sanitized, dependencies, references, err := promotableDeploymentInputs( - "accounts", + sanitized, dependencies, references, err := promotableDeploymentConfigurations( configuration, []*basev0.Configuration{dependency}, + "accounts-secrets", ) require.NoError(t, err) - require.Equal(t, originalConfiguration, configuration) - require.Equal(t, originalDependency, dependency) - require.Equal(t, []*basev0.ConfigurationValue{ - {Key: "host", Value: "postgres.users.svc"}, - }, sanitized.GetInfos()[0].GetConfigurationValues()) - require.Equal(t, []*basev0.ConfigurationValue{ - {Key: "port", Value: "5432"}, - }, dependencies[0].GetInfos()[0].GetConfigurationValues()) - require.Equal(t, map[string]*builderv0.KubernetesSecretKeyReference{ - "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__DATABASE__PASSWORD": { - Name: "accounts-secrets", - Key: "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__DATABASE__PASSWORD", - }, - "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__CONNECTION": { - Name: "accounts-secrets", - Key: "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__CONNECTION", - }, - }, references) + require.True(t, proto.Equal(originalConfiguration, configuration)) + require.True(t, proto.Equal(originalDependency, dependency)) + require.Len(t, sanitized.GetInfos()[0].GetConfigurationValues(), 1) + require.Equal(t, "host", sanitized.GetInfos()[0].GetConfigurationValues()[0].GetKey()) + require.Equal(t, "postgres.users.svc", sanitized.GetInfos()[0].GetConfigurationValues()[0].GetValue()) + require.Len(t, dependencies[0].GetInfos()[0].GetConfigurationValues(), 1) + require.Equal(t, "port", dependencies[0].GetInfos()[0].GetConfigurationValues()[0].GetKey()) + require.Equal(t, "5432", dependencies[0].GetInfos()[0].GetConfigurationValues()[0].GetValue()) + for _, key := range []string{ + "CODEFLY__SERVICE_SECRET_CONFIGURATION__USERS__ACCOUNTS__DATABASE__PASSWORD", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__CONNECTION", + } { + require.Equal(t, "accounts-secrets", references[key].GetName()) + require.Equal(t, key, references[key].GetKey()) + } } func TestPromotableDeploymentInputsRejectSecretStructuredData(t *testing.T) { @@ -74,8 +74,8 @@ func TestPromotableDeploymentInputsRejectSecretStructuredData(t *testing.T) { }}, } - _, _, _, err := promotableDeploymentInputs("accounts", configuration, nil) - require.EqualError(t, err, `secret configuration data "certificate" has no Kubernetes Secret key reference`) + _, _, _, err := promotableDeploymentConfigurations(configuration, nil, "accounts-secrets") + require.EqualError(t, err, `structured secret configuration "certificate" requires typed Kubernetes key references`) } func TestPromotableDeploymentInputsPreserveWorkspaceConfigurationAndExtractSecrets(t *testing.T) { @@ -90,15 +90,17 @@ func TestPromotableDeploymentInputsPreserveWorkspaceConfigurationAndExtractSecre }}, } - _, dependencies, references, err := promotableDeploymentInputs( - "accounts", + _, dependencies, references, err := promotableDeploymentConfigurations( nil, []*basev0.Configuration{workspaceConfiguration}, + "accounts-secrets", ) require.NoError(t, err) - require.Equal(t, []*basev0.ConfigurationValue{ - {Key: "MODE", Value: "production"}, - }, dependencies[0].GetInfos()[0].GetConfigurationValues()) + values := dependencies[0].GetInfos()[0].GetConfigurationValues() + require.Len(t, values, 1) + require.Equal(t, "MODE", values[0].GetKey()) + require.Equal(t, "production", values[0].GetValue()) + require.False(t, values[0].GetSecret()) require.Equal(t, map[string]*builderv0.KubernetesSecretKeyReference{ "CODEFLY__WORKSPACE_SECRET_CONFIGURATION__WORKOS__WORKOS_CLIENT_SECRET": { Name: "accounts-secrets", @@ -107,11 +109,18 @@ func TestPromotableDeploymentInputsPreserveWorkspaceConfigurationAndExtractSecre }, references) } -func TestKubernetesOutputProfileDefaultsByClusterAndHonorsExplicitGitOps(t *testing.T) { +func TestKubernetesOutputProfileReservesEphemeralForDirectLocalApply(t *testing.T) { require.Equal(t, - builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, kubernetesOutputProfile(&World{Env: resources.LocalEnvironment()}), ) + require.Equal(t, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, + kubernetesOutputProfile(&World{ + Env: resources.LocalEnvironment(), + RemoteManager: &deployments.LocalApplyManager{}, + }), + ) require.Equal(t, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, kubernetesOutputProfile(&World{Env: &resources.Environment{ @@ -134,12 +143,12 @@ func TestValidateKubernetesDeploymentOutputRejectsProfileMismatch(t *testing.T) ) err := validateKubernetesDeploymentOutput( - output, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + output, "k3d-codefly-local", ) require.EqualError(t, err, - "builder returned profile KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 for requested profile KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1", + "plugin returned Kubernetes output profile KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, requested KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1", ) } @@ -149,8 +158,8 @@ func TestValidateKubernetesDeploymentOutputAcceptsPromotableContract(t *testing. ) require.NoError(t, validateKubernetesDeploymentOutput( - output, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + output, "k3d-codefly-local", )) } @@ -161,12 +170,12 @@ func TestValidateKubernetesDeploymentOutputRejectsDifferentValidationContext(t * ) err := validateKubernetesDeploymentOutput( - output, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + output, "mind-aws", ) require.EqualError(t, err, - `builder validated Kubernetes context "k3d-codefly-local" for requested context "mind-aws"`, + `plugin validated Kubernetes context "k3d-codefly-local", requested "mind-aws"`, ) } @@ -179,10 +188,40 @@ func TestValidateKubernetesDeploymentOutputAcceptsOfflinePromotableContract(t *t output.GetKubernetes().Validation.ValidatedContext = "" require.NoError(t, validateKubernetesDeploymentOutput( + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, output, + "", + )) +} + +func TestValidateDeploymentOutputAllowsOptionalNoDeploymentResponse(t *testing.T) { + require.NoError(t, validateDeploymentOutput( + &deployments.RenderManager{}, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + nil, "", )) + require.EqualError(t, validateDeploymentOutput( + requiredDeploymentOutputManager{}, + builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + nil, + "", + ), "plugin returned no Kubernetes deployment output") +} + +type requiredDeploymentOutputManager struct{} + +func (requiredDeploymentOutputManager) RequiresDeploymentOutput() bool { + return true +} + +func (requiredDeploymentOutputManager) Handle( + context.Context, + *resources.Service, + *resources.Module, + *builderv0.DeploymentOutput, +) error { + return nil } func validKubernetesDeploymentOutput(profile builderv0.KubernetesOutputProfile) *builderv0.DeploymentOutput { @@ -190,7 +229,7 @@ func validKubernetesDeploymentOutput(profile builderv0.KubernetesOutputProfile) Kind: &builderv0.DeploymentOutput_Kubernetes{ Kubernetes: &builderv0.KubernetesDeploymentOutput{ Profile: profile, - ContractVersion: "kubernetes-output/v1", + ContractVersion: coreservices.KubernetesManifestContractVersion, Validation: &builderv0.KubernetesManifestValidation{ StaticValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, ServerSideValidation: builderv0.KubernetesManifestValidation_STATUS_PASSED, diff --git a/pkg/orchestration/builder_test.go b/pkg/orchestration/builder_test.go index 4def550b..6acb8c0e 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) + } +}