Skip to content

Commit 6862b6f

Browse files
authored
feat(carvel): add BOSH link support via job-spec-overlay and values-overlay sidecars (#663)
## Summary Introduce two per-packageinstall sidecar conventions that kiln auto-detects in `packageinstalls/`: | Sidecar | Purpose | When it runs | |---|---|---| | `<entry>.job-spec-overlay.yml` | Declares additional BOSH link consumptions; kiln appends them to the generated `registry-data` job.MF | `kiln carvel bake` time | | `<entry>.values-overlay.erb` | ERB fragment injected before `YAML.dump(values)`; mutates the values hash via BOSH link objects | BOSH deploy time (ERB) | Missing sidecar files are silently skipped (no-op). Both sidecars are co-located with the packageinstall YAML they extend. ## Motivation Carvel tiles need to consume BOSH links from co-deployed products (e.g. the `binding_cache` link provided by CF's `loggr-syslog-binding-cache` job) to inject environment-specific runtime values at BOSH deploy time. Previously only the hardcoded `cluster-info` link was available in the generated job spec. The binding-cache has no BOSH DNS alias, so its address can only be discovered via a BOSH link. ## Approach — co-located sidecars (mirrors the values-overlay.erb pattern) Tile authors declare BOSH link consumptions in a `<name>.job-spec-overlay.yml` sidecar alongside their packageinstall YAML. kiln reads all such files and aggregates their `consumes:` entries into the generated `registry-data` job spec. The schema mirrors the BOSH job spec `consumes:` block directly. ``` packageinstalls/ tnz-ear-runtime-package-install.yml tnz-ear-runtime-package-install.job-spec-overlay.yml ← NEW: BOSH link declarations tnz-ear-runtime-package-install.values-overlay.erb ← NEW: values mutations at deploy time ``` Example `job-spec-overlay.yml`: ```yaml consumes: - name: binding_cache type: binding_cache optional: false ``` Example `values-overlay.erb`: ```erb <% addr = link("binding_cache").instances.first.address %> <% values["syslog_agent"]["cache"]["url"] = "https://#{addr}:9000" %> ``` **Kilnfile is not modified.** It stays focused on dependency management (release sources, releases, stemcell). The `BOSHLinkConsumer`/`BOSHLinks` types and `BoshLinks` field from the initial commit have been removed. ## Backward Compatibility - Tiles with no `*.job-spec-overlay.yml` files produce identical output to before (no-op) - Tiles with no `*.values-overlay.erb` files produce identical output to before (no-op) - All existing `cluster-info` link behaviour is preserved - Kilnfile schema unchanged ## Test Plan - [x] `jobSpecOverlay` — parses a `consumes:` list, handles empty list, handles missing key - [x] `buildRegistryDataSpec` — includes user-declared links after cluster-info - [x] `buildRegistryDataSpec` — no additional links when no sidecars present - [x] `buildRegistryDataSpec` — optional flag rendered correctly - [x] `generateManifestTemplate` with overlay — overlay content present and before `YAML.dump` - [x] `generateManifestTemplate` with empty overlay — still produces valid template - [x] Full suite: `go test ./...` — 0 failures (Docker integration test skipped due to no daemon) Made with [Cursor](https://cursor.com)
2 parents 8dc0354 + 6eb6217 commit 6862b6f

3 files changed

Lines changed: 292 additions & 17 deletions

File tree

internal/carvel/baker.go

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,48 @@ func (b *baker) progress(message string) {
255255
_, _ = fmt.Fprintln(b.progressWriter, message)
256256
}
257257

258+
// deduplicateConsumes removes duplicate BOSH link consumer entries by name.
259+
// Identical duplicates are dropped silently. If two entries share a name but
260+
// differ in type or optional, the first is kept and a WARNING is emitted —
261+
// BOSH rejects duplicate link names in job.MF, so the second is always ignored.
262+
func (b *baker) deduplicateConsumes(consumes []boshLinkConsumer) []boshLinkConsumer {
263+
seen := make(map[string]boshLinkConsumer)
264+
var deduped []boshLinkConsumer
265+
for _, c := range consumes {
266+
existing, ok := seen[c.Name]
267+
if !ok {
268+
seen[c.Name] = c
269+
deduped = append(deduped, c)
270+
continue
271+
}
272+
if existing != c {
273+
b.progress(fmt.Sprintf(
274+
"WARNING: duplicate BOSH link consumer name %q found across packageinstalls.\n"+
275+
" Keeping: {type: %s, optional: %v}\n"+
276+
" Ignoring: {type: %s, optional: %v}\n"+
277+
" Ensure all packageinstalls agree on the link definition.",
278+
c.Name, existing.Type, existing.Optional, c.Type, c.Optional,
279+
))
280+
}
281+
}
282+
return deduped
283+
}
284+
285+
// boshLinkConsumer declares a BOSH link the registry-data job should consume.
286+
// Populated from per-packageinstall *.job-spec-overlay.yml sidecar files.
287+
type boshLinkConsumer struct {
288+
Name string `yaml:"name"`
289+
Type string `yaml:"type"`
290+
Optional bool `yaml:"optional"`
291+
}
292+
293+
// jobSpecOverlay is the schema for <entry>.job-spec-overlay.yml sidecar files.
294+
// kiln reads these from packageinstalls/ and merges the consumes entries into
295+
// the generated registry-data job.MF alongside the hardcoded cluster-info link.
296+
type jobSpecOverlay struct {
297+
Consumes []boshLinkConsumer `yaml:"consumes"`
298+
}
299+
258300
func validateVariables(vars []proofing.Variable) error {
259301
var errs []error
260302
for i, v := range vars {
@@ -313,6 +355,7 @@ files:
313355

314356
registryDataTemplates := ""
315357
registryDataProperties := ""
358+
var allConsumes []boshLinkConsumer
316359

317360
b.progress(" Configuring package installs")
318361
for _, entry := range b.metadata.PackageInstalls {
@@ -362,7 +405,33 @@ files:
362405
return err
363406
}
364407

365-
manifestTemplate := generateManifestTemplate(entry)
408+
// Read optional values-overlay ERB file alongside the packageinstall YAML.
409+
overlayContent := ""
410+
overlayData, overlayErr := os.ReadFile(path.Join(b.source, "packageinstalls", entry+".values-overlay.erb"))
411+
if overlayErr != nil {
412+
if !errors.Is(overlayErr, os.ErrNotExist) {
413+
return overlayErr
414+
}
415+
} else {
416+
overlayContent = string(overlayData)
417+
}
418+
419+
// Read optional job-spec-overlay sidecar to discover additional BOSH link consumptions.
420+
jobSpecOverlayPath := path.Join(b.source, "packageinstalls", entry+".job-spec-overlay.yml")
421+
overlayData, overlayErr = os.ReadFile(jobSpecOverlayPath)
422+
if overlayErr != nil {
423+
if !errors.Is(overlayErr, os.ErrNotExist) {
424+
return overlayErr
425+
}
426+
} else {
427+
var overlay jobSpecOverlay
428+
if parseErr := yaml.Unmarshal(overlayData, &overlay); parseErr != nil {
429+
return fmt.Errorf("parsing %s: %w", jobSpecOverlayPath, parseErr)
430+
}
431+
allConsumes = append(allConsumes, overlay.Consumes...)
432+
}
433+
434+
manifestTemplate := generateManifestTemplate(entry, overlayContent)
366435

367436
err = os.WriteFile(
368437
path.Join(dirName, "jobs", "registry-data", "templates", "packageinstalls", entry+".yml.erb"),
@@ -374,18 +443,12 @@ files:
374443
}
375444
}
376445

377-
registryDataSpec := `---
378-
name: registry-data
379-
templates:
380-
` + registryDataTemplates +
381-
`packages:
382-
- registry-data
383-
consumes:
384-
- name: cluster
385-
type: cluster-info
386-
optional: true
387-
properties:
388-
` + registryDataProperties
446+
deduped := b.deduplicateConsumes(allConsumes)
447+
448+
registryDataSpec, err := buildRegistryDataSpec(registryDataTemplates, registryDataProperties, deduped)
449+
if err != nil {
450+
return err
451+
}
389452

390453
err = os.WriteFile(path.Join(dirName, "jobs", "registry-data", "spec"), []byte(registryDataSpec), 0644)
391454
if err != nil {
@@ -395,7 +458,35 @@ properties:
395458
return nil
396459
}
397460

398-
func generateManifestTemplate(entry string) string {
461+
// buildRegistryDataSpec constructs the job.MF content for the registry-data BOSH job.
462+
// It always includes the hardcoded cluster-info link and appends any additional links
463+
// collected from *.job-spec-overlay.yml sidecars in the packageinstalls/ directory.
464+
func buildRegistryDataSpec(templates, properties string, additionalLinks []boshLinkConsumer) (string, error) {
465+
extraLinks := ""
466+
if len(additionalLinks) > 0 {
467+
data, err := yaml.Marshal(additionalLinks)
468+
if err != nil {
469+
return "", err
470+
}
471+
extraLinks = string(data)
472+
}
473+
return `---
474+
name: registry-data
475+
templates:
476+
` + templates + `packages:
477+
- registry-data
478+
consumes:
479+
- name: cluster
480+
type: cluster-info
481+
optional: true
482+
` + extraLinks + `properties:
483+
` + properties, nil
484+
}
485+
486+
// generateManifestTemplate produces the ERB template for the registry-data BOSH job.
487+
// overlayContent is optional ERB code injected into the values manipulation block
488+
// before YAML.dump(values) is called, enabling BOSH link-based value overrides.
489+
func generateManifestTemplate(entry, overlayContent string) string {
399490
return `---
400491
apiVersion: v1
401492
kind: ServiceAccount
@@ -442,6 +533,7 @@ stringData:
442533
values["context"]["namespace"] = link("cluster").p("content-namespace") rescue "default"
443534
end
444535
%>
536+
` + overlayContent + `
445537
<%= YAML.dump(values).split("\n").map { |line| " " + line }.join("\n") %>
446538
---
447539
apiVersion: packaging.carvel.dev/v1alpha1

internal/carvel/baker_test.go

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ var _ = Describe("Carvel Baker", func() {
5858
var template string
5959

6060
BeforeEach(func() {
61-
template = generateManifestTemplate("test-install")
61+
template = generateManifestTemplate("test-install", "")
6262
})
6363

6464
It("generates a ServiceAccount", func() {
@@ -103,6 +103,183 @@ var _ = Describe("Carvel Baker", func() {
103103
It("handles YAML conversion for string values", func() {
104104
Expect(template).To(ContainSubstring(`values = YAML.load(values) if values.is_a?(String)`))
105105
})
106+
107+
Context("with overlay content", func() {
108+
It("includes overlay content before YAML.dump", func() {
109+
overlay := `<% values["syslog_agent"]["cache"]["url"] = "https://1.2.3.4:9000" %>`
110+
tmpl := generateManifestTemplate("test-install", overlay)
111+
Expect(tmpl).To(ContainSubstring(overlay))
112+
overlayIdx := strings.Index(tmpl, overlay)
113+
dumpIdx := strings.Index(tmpl, "YAML.dump(values)")
114+
Expect(overlayIdx).To(BeNumerically("<", dumpIdx), "overlay must appear before YAML.dump")
115+
})
116+
117+
It("produces valid output with empty overlay", func() {
118+
tmpl := generateManifestTemplate("test-install", "")
119+
Expect(tmpl).To(ContainSubstring("YAML.dump(values)"))
120+
Expect(tmpl).NotTo(BeEmpty())
121+
})
122+
})
123+
})
124+
125+
Context("buildRegistryDataSpec", func() {
126+
It("includes user-declared additional links after cluster-info", func() {
127+
links := []boshLinkConsumer{
128+
{Name: "binding_cache", Type: "binding_cache", Optional: false},
129+
}
130+
spec, err := buildRegistryDataSpec("", "", links)
131+
Expect(err).NotTo(HaveOccurred())
132+
Expect(spec).To(ContainSubstring("name: binding_cache"))
133+
Expect(spec).To(ContainSubstring("type: binding_cache"))
134+
Expect(spec).To(ContainSubstring("optional: false"))
135+
Expect(spec).To(ContainSubstring("name: cluster"))
136+
})
137+
138+
It("marks optional links correctly", func() {
139+
links := []boshLinkConsumer{
140+
{Name: "optional-link", Type: "some-type", Optional: true},
141+
}
142+
spec, err := buildRegistryDataSpec("", "", links)
143+
Expect(err).NotTo(HaveOccurred())
144+
Expect(spec).To(ContainSubstring("optional: true"))
145+
})
146+
147+
It("includes only cluster-info when no additional links are declared", func() {
148+
spec, err := buildRegistryDataSpec("", "", nil)
149+
Expect(err).NotTo(HaveOccurred())
150+
Expect(spec).To(ContainSubstring("name: cluster"))
151+
Expect(spec).NotTo(ContainSubstring("name: binding_cache"))
152+
})
153+
154+
It("safely encodes link names containing YAML-special characters", func() {
155+
// yaml.Marshal quotes/blocks the value so it cannot inject extra YAML keys.
156+
// The real type field ("legit-type") must still appear at the correct level.
157+
links := []boshLinkConsumer{
158+
{Name: "name: injected\ntype: evil", Type: "legit-type", Optional: false},
159+
}
160+
spec, err := buildRegistryDataSpec("", "", links)
161+
Expect(err).NotTo(HaveOccurred())
162+
Expect(spec).To(ContainSubstring("type: legit-type"))
163+
})
164+
165+
It("emits each unique link name only once given pre-deduplicated input", func() {
166+
links := []boshLinkConsumer{
167+
{Name: "binding_cache", Type: "binding_cache", Optional: false},
168+
}
169+
spec, err := buildRegistryDataSpec("", "", links)
170+
Expect(err).NotTo(HaveOccurred())
171+
Expect(strings.Count(spec, "name: binding_cache")).To(Equal(1))
172+
})
173+
})
174+
175+
Context("deduplicateConsumes", func() {
176+
var progressBuf strings.Builder
177+
var b *baker
178+
179+
BeforeEach(func() {
180+
progressBuf.Reset()
181+
b = &baker{progressWriter: &progressBuf}
182+
})
183+
184+
It("keeps all entries when names are unique", func() {
185+
input := []boshLinkConsumer{
186+
{Name: "link-a", Type: "type-a"},
187+
{Name: "link-b", Type: "type-b"},
188+
}
189+
result := b.deduplicateConsumes(input)
190+
Expect(result).To(HaveLen(2))
191+
Expect(progressBuf.String()).To(BeEmpty())
192+
})
193+
194+
It("silently drops exact duplicates without warning", func() {
195+
input := []boshLinkConsumer{
196+
{Name: "link-a", Type: "type-a", Optional: false},
197+
{Name: "link-a", Type: "type-a", Optional: false},
198+
}
199+
result := b.deduplicateConsumes(input)
200+
Expect(result).To(HaveLen(1))
201+
Expect(progressBuf.String()).To(BeEmpty())
202+
})
203+
204+
It("warns and keeps first when conflicting type definitions are found", func() {
205+
input := []boshLinkConsumer{
206+
{Name: "binding_cache", Type: "binding_cache"},
207+
{Name: "binding_cache", Type: "binding-cache-v2"},
208+
}
209+
result := b.deduplicateConsumes(input)
210+
Expect(result).To(HaveLen(1))
211+
Expect(result[0].Type).To(Equal("binding_cache"))
212+
Expect(progressBuf.String()).To(ContainSubstring("WARNING"))
213+
Expect(progressBuf.String()).To(ContainSubstring(`"binding_cache"`))
214+
Expect(progressBuf.String()).To(ContainSubstring("binding-cache-v2"))
215+
})
216+
217+
It("warns and keeps first when optional flag differs", func() {
218+
input := []boshLinkConsumer{
219+
{Name: "link-a", Type: "type-a", Optional: false},
220+
{Name: "link-a", Type: "type-a", Optional: true},
221+
}
222+
result := b.deduplicateConsumes(input)
223+
Expect(result).To(HaveLen(1))
224+
Expect(result[0].Optional).To(BeFalse())
225+
Expect(progressBuf.String()).To(ContainSubstring("WARNING"))
226+
})
227+
228+
It("emits one warning per conflict when multiple entries share a name", func() {
229+
input := []boshLinkConsumer{
230+
{Name: "link-a", Type: "type-a"},
231+
{Name: "link-a", Type: "type-b"},
232+
{Name: "link-a", Type: "type-c"},
233+
}
234+
result := b.deduplicateConsumes(input)
235+
Expect(result).To(HaveLen(1))
236+
Expect(result[0].Type).To(Equal("type-a"))
237+
Expect(strings.Count(progressBuf.String(), "WARNING")).To(Equal(2))
238+
})
239+
240+
It("returns nil without panicking when given a nil slice", func() {
241+
result := b.deduplicateConsumes(nil)
242+
Expect(result).To(BeNil())
243+
Expect(progressBuf.String()).To(BeEmpty())
244+
})
245+
})
246+
247+
Context("jobSpecOverlay", func() {
248+
It("parses a consumes list from YAML", func() {
249+
content := `
250+
consumes:
251+
- name: binding_cache
252+
type: binding_cache
253+
optional: false
254+
`
255+
var overlay jobSpecOverlay
256+
err := yaml.Unmarshal([]byte(content), &overlay)
257+
Expect(err).NotTo(HaveOccurred())
258+
Expect(overlay.Consumes).To(HaveLen(1))
259+
Expect(overlay.Consumes[0].Name).To(Equal("binding_cache"))
260+
Expect(overlay.Consumes[0].Type).To(Equal("binding_cache"))
261+
Expect(overlay.Consumes[0].Optional).To(BeFalse())
262+
})
263+
264+
It("handles an empty consumes list without error", func() {
265+
var overlay jobSpecOverlay
266+
err := yaml.Unmarshal([]byte("consumes: []"), &overlay)
267+
Expect(err).NotTo(HaveOccurred())
268+
Expect(overlay.Consumes).To(BeEmpty())
269+
})
270+
271+
It("handles a missing consumes key without error", func() {
272+
var overlay jobSpecOverlay
273+
err := yaml.Unmarshal([]byte("{}"), &overlay)
274+
Expect(err).NotTo(HaveOccurred())
275+
Expect(overlay.Consumes).To(BeNil())
276+
})
277+
278+
It("returns an error for malformed YAML", func() {
279+
var overlay jobSpecOverlay
280+
err := yaml.Unmarshal([]byte("consumes: [\ninvalid"), &overlay)
281+
Expect(err).To(HaveOccurred())
282+
})
106283
})
107284

108285
Context("Bake", func() {
@@ -242,6 +419,8 @@ var _ = Describe("Carvel Baker", func() {
242419
Expect(specStr).To(ContainSubstring("name: cluster"))
243420
Expect(specStr).To(ContainSubstring("type: cluster-info"))
244421
Expect(specStr).To(ContainSubstring("optional: true"))
422+
Expect(specStr).To(ContainSubstring("name: binding_cache"))
423+
Expect(specStr).To(ContainSubstring("type: binding_cache"))
245424
})
246425
It("generates runtime config referencing tanzu-content release", func() {
247426
rcPath := filepath.Join(outputPath, "runtime_configs", "k8s-tile-test-pkgr.yml")
@@ -576,7 +755,7 @@ var _ = Describe("Carvel Baker", func() {
576755

577756
Context("generateManifestTemplate with different entry names", func() {
578757
It("parameterizes the entry name throughout the template", func() {
579-
template := generateManifestTemplate("my-custom-pkg")
758+
template := generateManifestTemplate("my-custom-pkg", "")
580759

581760
Expect(template).To(ContainSubstring(`p("my-custom-pkg.name")`))
582761
Expect(template).To(ContainSubstring(`p("my-custom-pkg.version")`))
@@ -585,7 +764,7 @@ var _ = Describe("Carvel Baker", func() {
585764
})
586765

587766
It("contains exactly 6 K8s resource documents", func() {
588-
template := generateManifestTemplate("pkg")
767+
template := generateManifestTemplate("pkg", "")
589768
docs := strings.Split(template, "---")
590769
nonEmpty := 0
591770
for _, doc := range docs {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
consumes:
2+
- name: binding_cache
3+
type: binding_cache
4+
optional: false

0 commit comments

Comments
 (0)