From 10b4e6639b0975709dac2fa0d635d2844b4797a7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:12:02 -0400 Subject: [PATCH] fix(gitops): the cell file never silently drops a composed module's workloads The cell file is regenerated whole from the per-module render records (deployments/modules//.codefly-render.json), which are uncommitted render outputs. After a clean of deployments/, rendering saas and robin wrote a cell file with 4 insertions and 1026 deletions: 25 workloads of 9 other modules gone, and nothing warned. - A render refuses to write the cell file while a module the workspace composes has no render record for the environment (missing, or rendered for another environment), naming each module and the command that renders it. It does not carry the module's entries over from the file on disk: an entry is derived from the whole composition, so a carried-over entry is a claim about a composition that may no longer exist. - plan/publish hold the workspace's cell file to the delivery base branch's and refuse one that removes a namespace, workload, endpoint or endpoint consumer the base admits, unless --allow-cell-removal names the module the removal concerns; the plan lists every removal it lets through. Co-Authored-By: Claude Opus 5.5 --- cmd/deploy/gitops.go | 9 ++ docs/cli-reference.md | 2 + docs/commands.md | 32 ++++++- pkg/gitops/cell.go | 42 ++++++++- pkg/gitops/cell_removals.go | 150 +++++++++++++++++++++++++++++++ pkg/gitops/cell_removals_test.go | 83 +++++++++++++++++ pkg/gitops/cell_test.go | 44 +++++++-- pkg/gitops/publish.go | 5 ++ pkg/gitops/types.go | 8 ++ 9 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 pkg/gitops/cell_removals.go create mode 100644 pkg/gitops/cell_removals_test.go diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index 5991efd8..fa92d4e8 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -486,6 +486,7 @@ func publishRequest(module string) gitops.PublishRequest { Title: gitOpsTitle, Body: gitOpsBody, Local: gitOpsLocal, AllowUnresolvedContracts: gitOpsAllowUnresolvedContracts, Resign: gitOpsResign, + AllowCellRemovals: gitOpsAllowCellRemovals, } } @@ -580,6 +581,9 @@ func printPublishPlan(plan *gitops.PublishPlan) { cli.Info("Contract checks:") cli.Info("%s", formatContractChecks(plan.ContractChecks)) } + for _, removal := range plan.CellRemovals { + cli.Warning("The cell file removes %s, as --allow-cell-removal names", removal) + } for _, stale := range plan.StaleConsumers { cli.Warning("Delivered consumer %s bakes in another value of a workspace configuration group this publication carries; publish it next, so %s does not keep delivering two values of one group", stale, plan.Environment) } @@ -603,6 +607,7 @@ var ( gitOpsLocal bool gitOpsAllowUnresolvedContracts bool gitOpsResign bool + gitOpsAllowCellRemovals []string gitOpsValidateCluster bool gitOpsRebuild bool ) @@ -640,6 +645,10 @@ func init() { command.Flags().BoolVar(&gitOpsResign, "resign", false, "Sign again a delivered document whose carrier the release policy no longer admits (a rotated signing identity); deliberate, never implied") } + for _, command := range []*cobra.Command{gitOpsPlanCmd, gitOpsPublishCmd} { + command.Flags().StringSliceVar(&gitOpsAllowCellRemovals, "allow-cell-removal", nil, + "Module whose removal from the environment's cell file, relative to the base branch's, is intended (repeatable); any other removal refuses") + } for _, command := range []*cobra.Command{gitOpsPublishCmd, gitOpsRollbackCmd} { command.Flags().StringVar(&gitOpsMessage, "message", "", "Signed commit message") command.Flags().StringVar(&gitOpsTitle, "title", "", "Promotion pull request title") diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ddfdbcd5..ac011a22 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2090,6 +2090,7 @@ codefly deploy gitops plan [module] Flags: ``` + --allow-cell-removal strings Module whose removal from the environment's cell file, relative to the base branch's, is intended (repeatable); any other removal refuses --allow-unresolved-contracts Downgrade a consumed contract whose exposing module is not yet deployed to a warning, for bootstrap ordering --env string Environment to promote (default "local") --local Use a disposable local file Git remote for k3d qualification @@ -2109,6 +2110,7 @@ codefly deploy gitops publish [module] Flags: ``` + --allow-cell-removal strings Module whose removal from the environment's cell file, relative to the base branch's, is intended (repeatable); any other removal refuses --allow-unresolved-contracts Downgrade a consumed contract whose exposing module is not yet deployed to a warning, for bootstrap ordering --body string Promotion pull request body --env string Environment to promote (default "local") diff --git a/docs/commands.md b/docs/commands.md index 55913561..d0027868 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -888,6 +888,34 @@ and verifier allows from, instead of transcribing them. The consumer validates it as another repository's output; this side writes it deterministically and reads it back through its own reader. +Because absence is removal, **the cell file is never written without a module +the workspace composes.** The per-module render records +(`deployments/modules//.codefly-render.json`) are the file's only +source, and they are uncommitted render outputs: a `git clean` of +`deployments/` removes them. A render that finds a composed module with no +record for the environment — none at all, or one rendered for another +environment — writes no cell file, leaves the one on disk as it was, and names +each such module with the command that renders it (`codefly deploy gitops +render --env `, or `render --all`). It does not keep that +module's entries from the file already on disk: an entry is derived from the +whole composition (its consumers are other modules' dependencies, its bindings +and egress the environment's), so an entry carried over is a claim about a +composition that may no longer exist, and no digest is invented for a module +that was not rendered. + +**A publish holds the cell file to the base branch's.** The cell file is +committed with the rendered trees, and a cell admits only what it names, so +`plan` and `publish` read the workspace's cell file for a hosted environment +and the one the delivery base branch carries at +`/cells//cell.yaml`, and refuse when the workspace's +removes a namespace, a workload, an endpoint or a consumer of an endpoint the +base admits — naming each removal and the modules it concerns. A removal that +is intended (a module the workspace no longer composes, a dependency dropped) +is named with `--allow-cell-removal ` (repeatable); a consumer edge is +covered by naming either the endpoint's module or the consumer's, and the plan +lists every removal it lets through. A base with no cell file has nothing to +hold the workspace's to. + For a complete module promotion, use the GitOps lifecycle: ```bash @@ -912,7 +940,9 @@ one command renders one composition and a stale sibling tree is never a reason to run the render again. A single-module `render` re-derives the cell file from its own tree and the sibling trees already rendered; a sibling the cell file cannot read, or one left at another inventory schema, is reported as -stale by name rather than failing the render. An image whose inputs did not +stale by name rather than failing the render, and so is a composed module with +no render at all: the module's tree is written, the cell file is not, and the +warning names the renders still owed. An image whose inputs did not change is kept rather than rebuilt, and that decision is taken before any Go module graph is fetched, so a render of an up-to-date composition fetches nothing; `--rebuild` builds every image regardless. A service that declares diff --git a/pkg/gitops/cell.go b/pkg/gitops/cell.go index 556d2334..f19256a3 100644 --- a/pkg/gitops/cell.go +++ b/pkg/gitops/cell.go @@ -34,7 +34,10 @@ import ( // environment, in the shape the consumer reads (pkg/gitops/cellfile). // // It is regenerated whole after every module render, so absence is correct: a -// workload not in the file is not delivered. It cannot live in a module's owned +// workload not in the file is not delivered. That is why it is never written +// while a module the workspace composes has no render for the environment +// (refuseUnrenderedModules), and why a publication holds it to the delivery +// base branch's file (holdCellToBase). It cannot live in a module's owned // tree, where the promotable ruleset admits only Kubernetes manifests and // Kustomizations; it lives beside the module trees, under // /cells//cell.yaml, and is committed with them. @@ -159,6 +162,9 @@ func deriveCell(ctx context.Context, workspace *resources.Workspace, env *enviro if len(file.Namespaces) == 0 { return cellfile.File{}, CellResult{}, fmt.Errorf("no module is rendered for environment %s under %s", env.Name, modulesDir) } + if err = refuseUnrenderedModules(workspace, env, result.Modules); err != nil { + return cellfile.File{}, CellResult{}, err + } sort.Slice(file.Namespaces, func(i, j int) bool { return file.Namespaces[i].Name < file.Namespaces[j].Name }) sort.Strings(result.Modules) sort.Strings(result.Skipped) @@ -168,6 +174,40 @@ func deriveCell(ctx context.Context, workspace *resources.Workspace, env *enviro return file, result, nil } +// refuseUnrenderedModules refuses a cell file that would leave out a module +// the workspace composes because no render of it for this environment is in +// the checkout: the record is missing (never rendered, or cleaned away with +// the rest of the uncommitted render outputs) or describes another +// environment. The file is regenerated whole and absence is removal, so such +// a file would withdraw every workload that module delivers from the cell's +// admission set, and nothing would say so. +// +// It refuses rather than keeping the module's entries from the cell file +// already on disk: an entry is derived from the composition as a whole — its +// consumers are other modules' declared dependencies, its bindings and egress +// the environment's — so an entry carried over from an earlier render is a +// claim about a composition that may no longer exist, and the file it was +// read from is as likely to have been cleaned as the records were. No digest +// is invented either: the render of the module is what knows it. +func refuseUnrenderedModules(workspace *resources.Workspace, env *environments.Environment, rendered []string) error { + var missing []string + for _, reference := range workspace.Modules { + if reference != nil && !slices.Contains(rendered, reference.Name) { + missing = append(missing, reference.Name) + } + } + if len(missing) == 0 { + return nil + } + sort.Strings(missing) + commands := make([]string, 0, len(missing)) + for _, module := range missing { + commands = append(commands, fmt.Sprintf("codefly deploy gitops render %s --env %s", module, env.Name)) + } + return fmt.Errorf("environment %s's cell file is not written: this checkout holds no render for %s of the composed module(s) %s, and a cell file without them would withdraw every workload they deliver from the cell; render them (%s), or every module at once with `codefly deploy gitops render --all --env %s`", + env.Name, env.Name, strings.Join(missing, ", "), strings.Join(commands, "; "), env.Name) +} + // writeCell encodes the cell file and writes it at path. func writeCell(file *cellfile.File, path string) error { body, err := file.Encode() diff --git a/pkg/gitops/cell_removals.go b/pkg/gitops/cell_removals.go new file mode 100644 index 00000000..1fa3823c --- /dev/null +++ b/pkg/gitops/cell_removals.go @@ -0,0 +1,150 @@ +package gitops + +// A render is a claim about a cell: the cell file is regenerated whole and a +// cell admits only what it names, so a workload, a namespace or a consumer +// absent from it is withdrawn. A publication promotes a render, so before it +// does, the workspace's cell file is held to the one the delivery base branch +// carries, and a removal it does not name is refused: a cell file derived +// from a checkout missing some modules' renders would otherwise remove those +// modules from admission without anyone having said so. + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/gitops/cellfile" + "github.com/codefly-dev/core/resources" +) + +// cellRemoval is one thing the base cell file admits that the next one does +// not, and the modules it concerns: the namespace's module, and for a +// consumer, the consumer's module too. +type cellRemoval struct { + modules []string + what string +} + +// cellRemovals names every namespace, workload, endpoint and endpoint +// consumer base carries that next does not, in base's order. +func cellRemovals(base, next *cellfile.File) []cellRemoval { + var removals []cellRemoval + for n := range base.Namespaces { + was := &base.Namespaces[n] + index := slices.IndexFunc(next.Namespaces, func(candidate cellfile.Namespace) bool { return candidate.Name == was.Name }) + if index < 0 { + removals = append(removals, cellRemoval{modules: []string{was.Module}, what: fmt.Sprintf("namespace %s (module %s, %d workload(s))", was.Name, was.Module, len(was.Workloads))}) + continue + } + is := &next.Namespaces[index] + for w := range was.Workloads { + workload := &was.Workloads[w] + found := slices.IndexFunc(is.Workloads, func(candidate cellfile.Workload) bool { + return candidate.Name == workload.Name && candidate.Kind == workload.Kind + }) + if found < 0 { + removals = append(removals, cellRemoval{modules: []string{was.Module}, what: fmt.Sprintf("workload %s/%s %s", was.Name, workload.Kind, workload.Name)}) + continue + } + removals = append(removals, endpointRemovals(was, workload, &is.Workloads[found])...) + } + } + return removals +} + +// endpointRemovals names the endpoints of one workload, and the consumers of +// each, that the next file no longer carries. +func endpointRemovals(namespace *cellfile.Namespace, was, is *cellfile.Workload) []cellRemoval { + var removals []cellRemoval + for e := range was.Endpoints { + endpoint := &was.Endpoints[e] + where := fmt.Sprintf("%s/%s/%s", namespace.Name, was.Name, endpoint.Name) + found := slices.IndexFunc(is.Endpoints, func(candidate cellfile.Endpoint) bool { return candidate.Name == endpoint.Name }) + if found < 0 { + removals = append(removals, cellRemoval{modules: []string{namespace.Module}, what: "endpoint " + where}) + continue + } + for _, consumer := range endpoint.Consumers { + if slices.Contains(is.Endpoints[found].Consumers, consumer) { + continue + } + consumerModule, _, _ := strings.Cut(consumer, "/") + removals = append(removals, cellRemoval{modules: []string{namespace.Module, consumerModule}, what: fmt.Sprintf("consumer %s of %s", consumer, where)}) + } + } + return removals +} + +// deliveryCellPath is the environment's cell file in the delivery +// repository: beside the modules tree, as the render lays it out in the +// workspace (CellPath). +func deliveryCellPath(gitopsPath, environment string) string { + if gitopsPath == "" { + gitopsPath = DefaultGitopsPath + } + return filepath.ToSlash(filepath.Join(gitopsPath, cellsDir, environment, cellfile.FileName)) +} + +// holdCellToBase refuses the publication when the workspace's cell file for a +// hosted environment removes anything the delivery base revision's file +// carries, unless every removal concerns a module allowed names. It returns +// the removals allowed, for the plan to show. A workspace with no cell file, +// or a base with none, has nothing to hold. +func holdCellToBase(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, repo, baseBranch, baseRevision, gitopsPath string, allowed []string) ([]string, error) { + if env.Host == nil { + return nil, nil + } + path, err := CellPath(workspace, env) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read environment %s's cell file: %w", env.Name, err) + } + next, err := cellfile.Parse(data) + if err != nil { + return nil, fmt.Errorf("environment %s's cell file %s cannot be read, so it cannot be held to %s's: %w", env.Name, path, baseBranch, err) + } + basePath := deliveryCellPath(gitopsPath, env.Name) + baseData, err := gitCommandBytes(ctx, repo, "show", baseRevision+":"+basePath) + if err != nil { + if gitSaysAbsent(err) { + return nil, nil + } + return nil, fmt.Errorf("read %s's cell file %s: %w", baseBranch, basePath, err) + } + base, err := cellfile.Parse(baseData) + if err != nil { + return nil, fmt.Errorf("%s's cell file %s cannot be read: %w", baseBranch, basePath, err) + } + var refused, admitted []string + unnamed := map[string]bool{} + for _, removal := range cellRemovals(base, next) { + if slices.ContainsFunc(removal.modules, func(module string) bool { return slices.Contains(allowed, module) }) { + admitted = append(admitted, removal.what) + continue + } + refused = append(refused, removal.what) + unnamed[removal.modules[len(removal.modules)-1]] = true + } + if len(refused) > 0 { + modules := make([]string, 0, len(unnamed)) + for module := range unnamed { + modules = append(modules, module) + } + slices.Sort(modules) + return nil, fmt.Errorf("environment %s's cell file %s removes from the cell what %s's admits, and says nothing of it: %s. Publishing it would withdraw those from the cell's admission set. If a module's render is missing from this checkout, render it (`codefly deploy gitops render --all --env %s`); if the removal is intended — a module the workspace no longer composes, a dependency dropped — name each module it concerns with --allow-cell-removal (%s)", + env.Name, path, baseBranch, strings.Join(refused, "; "), env.Name, strings.Join(modules, ", ")) + } + return admitted, nil +} diff --git a/pkg/gitops/cell_removals_test.go b/pkg/gitops/cell_removals_test.go new file mode 100644 index 00000000..09d9702c --- /dev/null +++ b/pkg/gitops/cell_removals_test.go @@ -0,0 +1,83 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/cli/pkg/gitops/cellfile" + "github.com/stretchr/testify/require" +) + +// cellBase is a delivery repository whose base revision carries the cell file +// given, at the path a publication reads it from. +func cellBase(t *testing.T, data []byte) (string, string) { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "--initial-branch=main") + path := filepath.Join(repo, filepath.FromSlash(deliveryCellPath("", "staging"))) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o644)) + runGit(t, repo, "add", "-A") + runGit(t, repo, "commit", "-m", "base") + return repo, runGit(t, repo, "rev-parse", "HEAD") +} + +// A publication holds the workspace's cell file to the one its delivery base +// branch carries: a file that drops a module's namespace (and with it that +// module's consumer edges on another's endpoints) is refused, naming each +// removal and the module to name; naming it lets the removal through and the +// plan shows it; a file that removes nothing, or a base with no cell file, +// publishes as before. +func TestPublishRefusesACellFileThatRemovesWhatTheBaseAdmits(t *testing.T) { + ctx := context.Background() + workspace := writeCellWorkspace(t) + env := selectedEnvironment(t, workspace, "staging") + renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging") + renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging") + full, err := RenderCell(ctx, workspace, env) + require.NoError(t, err) + complete, err := os.ReadFile(full.Path) + require.NoError(t, err) + repo, base := cellBase(t, complete) + + removals, err := holdCellToBase(ctx, workspace, env, repo, "main", base, "", nil) + require.NoError(t, err, "a full render removes nothing") + require.Empty(t, removals) + + // The cell file a render of shop alone wrote before it refused to: billing + // is gone, and so is billing/ledger as a caller of shop's grpc endpoint. + file, err := cellfile.Parse(complete) + require.NoError(t, err) + require.Equal(t, "acme-billing", file.Namespaces[0].Name) + file.Namespaces = file.Namespaces[1:] + shopAPI := &file.Namespaces[0].Workloads[0] + require.Equal(t, []string{"billing/ledger", "shop/api"}, shopAPI.Endpoints[0].Consumers) + shopAPI.Endpoints[0].Consumers = []string{"shop/api"} + dropped, err := file.Encode() + require.NoError(t, err) + require.NoError(t, os.WriteFile(full.Path, dropped, 0o600)) + + _, err = holdCellToBase(ctx, workspace, env, repo, "main", base, "", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "namespace acme-billing (module billing, 2 workload(s))") + require.Contains(t, err.Error(), "consumer billing/ledger of acme-shop/api/grpc") + require.Contains(t, err.Error(), "--allow-cell-removal (billing)") + + _, err = holdCellToBase(ctx, workspace, env, repo, "main", base, "", []string{"shop"}) + require.Error(t, err, "naming the endpoint's module covers its consumer edge, but not billing's namespace") + require.NotContains(t, err.Error(), "consumer billing/ledger") + require.Contains(t, err.Error(), "namespace acme-billing") + removals, err = holdCellToBase(ctx, workspace, env, repo, "main", base, "", []string{"billing"}) + require.NoError(t, err) + require.Equal(t, []string{"namespace acme-billing (module billing, 2 workload(s))", "consumer billing/ledger of acme-shop/api/grpc"}, removals) + + empty, _ := cellBase(t, nil) + require.NoError(t, os.Remove(filepath.Join(empty, filepath.FromSlash(deliveryCellPath("", "staging"))))) + runGit(t, empty, "commit", "-am", "no cell file") + emptyBase := runGit(t, empty, "rev-parse", "HEAD") + removals, err = holdCellToBase(ctx, workspace, env, empty, "main", emptyBase, "", nil) + require.NoError(t, err, "a base with no cell file has nothing to hold the workspace's to") + require.Empty(t, removals) +} diff --git a/pkg/gitops/cell_test.go b/pkg/gitops/cell_test.go index 6b214baa..1a171ad9 100644 --- a/pkg/gitops/cell_test.go +++ b/pkg/gitops/cell_test.go @@ -280,17 +280,51 @@ func TestCellFileInventoriesEveryRenderedWorkload(t *testing.T) { require.Equal(t, string(data), string(same), "a second render with nothing changed writes identical bytes") } -// A tree rendered for another environment describes another cell: it is left -// out and reported, never merged in. +// A tree rendered for another environment describes another cell: it is never +// merged in, and a composed module whose only render is for another +// environment has none for this one, so the file is refused rather than +// written without it. func TestCellFileSkipsTreesRenderedForAnotherEnvironment(t *testing.T) { workspace := writeCellWorkspace(t) env := selectedEnvironment(t, workspace, "staging") renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging") renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "production") - result, err := RenderCell(context.Background(), workspace, env) + _, err := RenderCell(context.Background(), workspace, env) + require.Error(t, err) + require.Contains(t, err.Error(), "composed module(s) billing") + require.Contains(t, err.Error(), "codefly deploy gitops render billing --env staging") +} + +// A composed module with no render record in the checkout (the records are +// uncommitted render outputs, so a clean of deployments/ removes them) refuses +// the cell file by name, with the command that renders it, and leaves the file +// already on disk as it was: a file regenerated whole without that module +// would withdraw every workload it delivers. Once it is rendered, the file is +// written as before. +func TestCellFileRefusesToDropAComposedModuleWithNoRender(t *testing.T) { + workspace := writeCellWorkspace(t) + env := selectedEnvironment(t, workspace, "staging") + renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging") + renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging") + full, err := RenderCell(context.Background(), workspace, env) + require.NoError(t, err) + before, err := os.ReadFile(full.Path) + require.NoError(t, err) + + require.NoError(t, os.RemoveAll(moduleRenderDestination(workspace, "billing"))) + _, err = RenderCell(context.Background(), workspace, env) + require.Error(t, err) + require.Contains(t, err.Error(), "cell file is not written") + require.Contains(t, err.Error(), "composed module(s) billing") + require.Contains(t, err.Error(), "codefly deploy gitops render billing --env staging") + after, err := os.ReadFile(full.Path) + require.NoError(t, err) + require.Equal(t, string(before), string(after), "a refused cell file leaves the one on disk untouched") + + renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging") + again, err := RenderCell(context.Background(), workspace, env) require.NoError(t, err) - require.Equal(t, []string{"shop"}, result.Modules) - require.Equal(t, []string{"billing"}, result.Skipped) + require.Equal(t, []string{"billing", "shop"}, again.Modules) } // A workload whose authenticating container cannot be told from a sidecar is diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index fe3ee67f..9a668665 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -251,6 +251,10 @@ func preparePublish( return fail(err) } staleConsumers := describeStaleConsumers(staleOnBase) + allowedRemovals, err := holdCellToBase(ctx, workspace, env, repo, baseBranch, baseRevision, pathRoot, request.AllowCellRemovals) + if err != nil { + return fail(err) + } changed, err := stagedPathsSince(ctx, repo, startRevision, publishedPaths...) if err != nil { return fail(err) @@ -274,6 +278,7 @@ func preparePublish( Changed: changed, Diff: diff, ContractChecks: contractChecks, Delivery: inventory.Delivery, StaleConsumers: staleConsumers, + CellRemovals: allowedRemovals, Carriers: publication.options.Carriers, SelfPromotes: selfPromotes, LocalRepository: bound, diff --git a/pkg/gitops/types.go b/pkg/gitops/types.go index 5e19f335..1e345e20 100644 --- a/pkg/gitops/types.go +++ b/pkg/gitops/types.go @@ -460,6 +460,11 @@ type PublishRequest struct { // release policy no longer admits — after a signing identity rotated. // Deliberate: without it such a carrier refuses the publish by name. Resign bool `json:"resign,omitempty"` + // AllowCellRemovals names the modules whose removal from the hosted + // environment's cell file this publish intends: a namespace, workload, + // endpoint or consumer the delivery base branch's cell file carries and + // the workspace's does not. Any other removal refuses the publish. + AllowCellRemovals []string `json:"allowCellRemovals,omitempty"` } type PublishPlan struct { @@ -494,6 +499,9 @@ type PublishPlan struct { // Reported rather than refused: refusing would hold every consumer's // publish on every other's. StaleConsumers []string `json:"staleConsumers,omitempty"` + // CellRemovals are the removals from the base branch's cell file the + // request named (AllowCellRemovals), for the reviewer to see. + CellRemovals []string `json:"cellRemovals,omitempty"` // Carriers are the carriers this plan signed, keyed by kind, ID and // generation. The publish that executes the plan delivers exactly these // bytes — signing is once, here — after holding each to its document.