Skip to content

Commit fba987c

Browse files
feat(gitops): report the generation a render declared
The prior generation comes from the tree the render replaces, and that tree is per module, not per environment: rendering another environment replaces it whole, so rendering staging and then production again finds no prior production document and starts at 1 — which the production host refuses as stale. Inventing a number the renderer cannot know would be worse, so the render reports the generation it declared instead. A reset is then visible where it happens rather than at the host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0eb1908 commit fba987c

5 files changed

Lines changed: 102 additions & 17 deletions

File tree

‎cmd/deploy/gitops.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,8 @@ func printElidedNamespaces(elided []string) {
447447
// environment names no host both render zero bindings, and only this tells
448448
// them apart.
449449
func printSolutionHostBindings(result *gitops.RenderResult) {
450-
for _, path := range result.SolutionHostBindings {
451-
cli.Info("Declared solution host binding %s", path)
450+
for _, declared := range result.SolutionHostBindings {
451+
cli.Info("Declared solution host binding %s at generation %d (%s)", declared.Binding, declared.Generation, declared.Path)
452452
}
453453
if len(result.UndeclaredSolutions) == 0 {
454454
return

‎docs/solution-host-bindings.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ avoid it would be a renderer asserting a history it does not have.
139139

140140
An unreadable delivered document is an error, never a silent reset to 1.
141141

142+
The sharp edge: the render destination is per **module**, not per environment,
143+
and a render replaces it whole. Rendering staging and then production again
144+
finds no prior production document and starts over at 1. The render prints the
145+
generation it declared for exactly this reason — a reset is otherwise
146+
invisible until the host refuses the document. Render an environment from a
147+
checkout that holds that environment's delivered tree.
148+
142149
## Release digest
143150

144151
Empty in v1, and left empty deliberately. Signed releases do not exist yet; a

‎pkg/gitops/solution_host_binding.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ type SolutionModulePin struct {
152152
// validateTree so the ConfigMaps it writes are held to the same promotable
153153
// ruleset as everything else in the tree, and before buildInventory so they are
154154
// hashed into the render digest like any other delivered file.
155-
func renderSolutionHostBindings(owned, destination string, opts *RenderOptions) ([]string, error) {
155+
func renderSolutionHostBindings(owned, destination string, opts *RenderOptions) ([]DeclaredSolutionHostBinding, error) {
156156
if len(opts.SolutionInstances) == 0 {
157157
return nil, nil
158158
}
@@ -188,14 +188,16 @@ func renderSolutionHostBindings(owned, destination string, opts *RenderOptions)
188188
if _, err := (solutionhost.Host{}).Admit(documents...); err != nil {
189189
return nil, fmt.Errorf("rendered solution host bindings are not admissible: %w", err)
190190
}
191-
written := make([]string, 0, len(documents))
191+
written := make([]DeclaredSolutionHostBinding, 0, len(documents))
192192
names := make([]string, 0, len(documents))
193193
for index, document := range documents {
194194
relative, err := writeSolutionHostBinding(owned, opts, document, instances[index].Alias)
195195
if err != nil {
196196
return nil, err
197197
}
198-
written = append(written, relative)
198+
written = append(written, DeclaredSolutionHostBinding{
199+
Path: relative, Binding: document.Binding, Generation: document.Generation,
200+
})
199201
names = append(names, document.Binding+".yaml")
200202
}
201203
if err := writeSolutionHostBindingKustomization(owned, opts.Environment, names); err != nil {

‎pkg/gitops/solution_host_binding_test.go‎

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,13 @@ func TestRenderDeclaresOneBindingPerSolutionInstance(t *testing.T) {
9999
if err != nil {
100100
t.Fatal(err)
101101
}
102-
if len(result.SolutionHostBindings) != 1 || result.SolutionHostBindings[0] != "solution-host-bindings/overlays/prod/obin.prod.crm.yaml" {
103-
t.Fatalf("rendered bindings %v", result.SolutionHostBindings)
102+
if len(result.SolutionHostBindings) != 1 {
103+
t.Fatalf("rendered bindings %+v", result.SolutionHostBindings)
104+
}
105+
declared := result.SolutionHostBindings[0]
106+
if declared.Path != "solution-host-bindings/overlays/prod/obin.prod.crm.yaml" ||
107+
declared.Binding != "obin.prod.crm" || declared.Generation != 1 {
108+
t.Fatalf("declared binding %+v", declared)
104109
}
105110
if len(result.UndeclaredSolutions) != 0 {
106111
t.Fatalf("declared a host yet reported undeclared solutions %v", result.UndeclaredSolutions)
@@ -279,7 +284,7 @@ func TestRenderDeclaresNoBindingWithoutADeclaredHost(t *testing.T) {
279284
t.Fatal(err)
280285
}
281286
if len(result.SolutionHostBindings) != 0 {
282-
t.Fatalf("a binding was rendered with no host: %v", result.SolutionHostBindings)
287+
t.Fatalf("a binding was rendered with no host: %+v", result.SolutionHostBindings)
283288
}
284289
// Silence is not the report: the render says which solution it could not
285290
// declare, so an operator can tell this from a composition with none.
@@ -383,7 +388,7 @@ func TestRenderRefusesAMixedReleaseSet(t *testing.T) {
383388
options.SolutionInstances[0].Package = "obin/crm"
384389
result, err := RenderOwnedTree(context.Background(), options, renderWorkload(pinnedDeployment))
385390
if err != nil {
386-
t.Fatalf("two artifacts of one release were refused: %v (%v)", err, result.SolutionHostBindings)
391+
t.Fatalf("two artifacts of one release were refused: %v (%+v)", err, result.SolutionHostBindings)
387392
}
388393
document := deliveredBinding(t, destination, "obin.prod.crm")
389394
for _, artifact := range document.Artifacts {
@@ -458,7 +463,7 @@ func TestRenderSolutionDeclaresItsOwnPresence(t *testing.T) {
458463
t.Fatalf("RenderSolution: %v", err)
459464
}
460465
if len(result.SolutionHostBindings) != 1 {
461-
t.Fatalf("rendered bindings %v (undeclared %v)", result.SolutionHostBindings, result.UndeclaredSolutions)
466+
t.Fatalf("rendered bindings %+v (undeclared %v)", result.SolutionHostBindings, result.UndeclaredSolutions)
462467
}
463468
document := deliveredBindingIn(t, result.Path, "local", "hello.local.lastlogin-go")
464469
if document.Host.Coordinate != "obin/local/dev" {
@@ -596,9 +601,9 @@ func TestRenderedTreeDeliversItsBindings(t *testing.T) {
596601
if !strings.Contains(string(kustomization), "obin.prod.crm.yaml") {
597602
t.Fatalf("the kustomization does not name the delivered document:\n%s", kustomization)
598603
}
599-
for _, path := range result.SolutionHostBindings {
600-
if !strings.HasPrefix(path, solutionHostBindingOverlay("prod")+"/") {
601-
t.Fatalf("binding %q is outside the overlay Argo delivers", path)
604+
for _, declared := range result.SolutionHostBindings {
605+
if !strings.HasPrefix(declared.Path, solutionHostBindingOverlay("prod")+"/") {
606+
t.Fatalf("binding %q is outside the overlay Argo delivers", declared.Path)
602607
}
603608
}
604609
}
@@ -644,3 +649,58 @@ func TestReleaseIdentityRefusesAPackageItCannotName(t *testing.T) {
644649
t.Fatalf("releaseIdentity = %q, %q, %v", publisher, name, err)
645650
}
646651
}
652+
653+
// TestRenderingAnotherEnvironmentResetsTheGeneration pins the sharp edge of
654+
// taking the prior generation from the delivered tree. The render destination
655+
// is per module and NOT per environment, and a render replaces it whole, so
656+
// rendering staging and then production again finds no prior production
657+
// document and starts over at 1 — which the production host correctly refuses
658+
// as stale.
659+
//
660+
// It is a test rather than a note because the behaviour is load-bearing: the
661+
// alternative, inventing a generation the renderer cannot know, is worse. The
662+
// render reports the generation it declared so the reset is visible where it
663+
// happens.
664+
func TestRenderingAnotherEnvironmentResetsTheGeneration(t *testing.T) {
665+
destination := filepath.Join(t.TempDir(), "modules", "crm")
666+
production := solutionRenderOptions(destination)
667+
changed := strings.Replace(pinnedDeployment, "name: api\nspec:", "name: api\n labels:\n v: \"2\"\nspec:", 1)
668+
for _, body := range []string{pinnedDeployment, changed} {
669+
if _, err := RenderOwnedTree(context.Background(), production, renderWorkload(body)); err != nil {
670+
t.Fatal(err)
671+
}
672+
}
673+
if advanced := deliveredBinding(t, destination, "obin.prod.crm").Generation; advanced != 2 {
674+
t.Fatalf("production reached generation %d, want 2", advanced)
675+
}
676+
677+
staging := solutionRenderOptions(destination)
678+
staging.Environment = "staging"
679+
staging.SolutionInstances[0].Units[0].Path = "services/api"
680+
stagingRender := func(_ context.Context, root string) error {
681+
overlay := filepath.Join(root, "services", "api", "overlays", "staging")
682+
if err := os.MkdirAll(overlay, 0o755); err != nil {
683+
return err
684+
}
685+
return os.WriteFile(filepath.Join(overlay, "deployment.yaml"), []byte(pinnedDeployment), 0o644)
686+
}
687+
if _, err := RenderOwnedTree(context.Background(), staging, stagingRender); err != nil {
688+
t.Fatal(err)
689+
}
690+
// The whole owned tree was replaced, production's document with it.
691+
if _, err := os.Stat(filepath.Join(destination, filepath.FromSlash(solutionHostBindingOverlay("prod")))); !os.IsNotExist(err) {
692+
t.Fatalf("production's overlay survived a staging render: %v", err)
693+
}
694+
result, err := RenderOwnedTree(context.Background(), production, renderWorkload(changed))
695+
if err != nil {
696+
t.Fatal(err)
697+
}
698+
if got := deliveredBinding(t, destination, "obin.prod.crm").Generation; got != 1 {
699+
t.Fatalf("production resumed at generation %d; the prior document was gone, so 1 is the honest answer", got)
700+
}
701+
// And the render says so, rather than leaving the reset to be discovered
702+
// when the host refuses the document.
703+
if len(result.SolutionHostBindings) != 1 || result.SolutionHostBindings[0].Generation != 1 {
704+
t.Fatalf("the render did not report the generation it declared: %+v", result.SolutionHostBindings)
705+
}
706+
}

‎pkg/gitops/types.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,10 @@ type RenderResult struct {
278278
// ClearedDev are the dev deployments the tree this render replaced carried.
279279
// A full render re-derives every image, so they no longer run.
280280
ClearedDev []InventoryDevDeployment `json:"clearedDev,omitempty"`
281-
// SolutionHostBindings are the tree-relative paths of the rendered
282-
// SolutionHostBinding documents: what this render declares should be
283-
// present on the host, one per solution instance.
284-
SolutionHostBindings []string `json:"solutionHostBindings,omitempty"`
281+
// SolutionHostBindings are the SolutionHostBinding documents this render
282+
// declared: what it says should be present on the host, one per solution
283+
// instance.
284+
SolutionHostBindings []DeclaredSolutionHostBinding `json:"solutionHostBindings,omitempty"`
285285
// UndeclaredSolutions are the solution instances this render delivered
286286
// workloads for but declared no binding for, because the environment names
287287
// no host. It is reported rather than inferred from an empty list: a
@@ -290,6 +290,22 @@ type RenderResult struct {
290290
UndeclaredSolutions []string `json:"undeclaredSolutions,omitempty"`
291291
}
292292

293+
// DeclaredSolutionHostBinding is one rendered binding document: where it was
294+
// written, which binding it declares, and at which generation.
295+
//
296+
// The generation is reported rather than left in the file because a reset is
297+
// otherwise invisible. The prior generation comes from the tree this render
298+
// replaced, and that tree is per module and not per environment — rendering
299+
// another environment replaces it whole. So rendering staging and then
300+
// production again finds no prior production document and starts at 1, which
301+
// the production host refuses as stale. Printing the generation is what turns
302+
// that into something an operator sees at the render instead of at the host.
303+
type DeclaredSolutionHostBinding struct {
304+
Path string `json:"path"`
305+
Binding string `json:"binding"`
306+
Generation uint64 `json:"generation"`
307+
}
308+
293309
type PublishRequest struct {
294310
Module string
295311
Environment string

0 commit comments

Comments
 (0)