From a082a8928a8589fdbd65b59175f96b28725cb5d4 Mon Sep 17 00:00:00 2001 From: David Perza Date: Mon, 20 Apr 2026 10:51:19 -0400 Subject: [PATCH] Implement composable bundles Signed-off-by: David Perza --- docs/composable-bundles-design.md | 89 ++++++ docs/composable-bundles-epic-google-doc.txt | 132 +++++++++ docs/configuration.md | 12 +- docs/installer-structure.md | 25 +- docs/templating.md | 49 ++- docs/topology.md | 8 + framework/app.go | 33 ++- framework/options.go | 19 ++ internal/annotations/annotations.go | 24 +- internal/annotations/bundle_types.go | 67 +++++ internal/annotations/bundle_types_test.go | 60 ++++ internal/chartfs/chartfs.go | 141 ++++++++- internal/chartfs/chartfs_test.go | 26 ++ internal/config/bundle_integration_outputs.go | 280 ++++++++++++++++++ .../config/bundle_integration_outputs_test.go | 72 +++++ internal/config/config.go | 16 +- internal/config/config_test.go | 3 +- internal/config/load_create.go | 30 ++ internal/config/merge_distributed.go | 216 ++++++++++++++ internal/config/merge_distributed_test.go | 53 ++++ internal/config/node.go | 22 +- internal/config/product.go | 54 +++- .../charts/tssc-quay/Chart.yaml | 6 + .../config/settings.yaml | 3 + .../helmet.yaml | 3 + .../charts/tssc-acs/config.yaml | 4 + .../merge-distributed/config/settings.yaml | 3 + .../testdata/merge-distributed/helmet.yaml | 3 + internal/engine/engine.go | 2 +- internal/engine/engine_test.go | 19 ++ internal/engine/variables.go | 8 +- internal/flags/installer.go | 2 +- internal/installer/installer.go | 14 +- internal/mcptools/configtools.go | 4 +- internal/resolver/collection.go | 66 ++++- internal/resolver/dependency.go | 84 +++++- internal/resolver/helper.go | 31 ++ internal/resolver/resolver.go | 90 +++++- internal/subcmd/config.go | 39 ++- internal/subcmd/deploy.go | 84 +++++- internal/subcmd/integration.go | 11 +- internal/subcmd/integration_test.go | 17 +- internal/subcmd/template.go | 30 +- test/charts/testing/values.yaml.tpl | 4 + test/config.yaml | 4 - test/values.yaml.tpl | 6 +- 46 files changed, 1861 insertions(+), 107 deletions(-) create mode 100644 docs/composable-bundles-design.md create mode 100644 docs/composable-bundles-epic-google-doc.txt create mode 100644 internal/annotations/bundle_types.go create mode 100644 internal/annotations/bundle_types_test.go create mode 100644 internal/config/bundle_integration_outputs.go create mode 100644 internal/config/bundle_integration_outputs_test.go create mode 100644 internal/config/load_create.go create mode 100644 internal/config/merge_distributed.go create mode 100644 internal/config/merge_distributed_test.go create mode 100644 internal/config/testdata/merge-distributed-invalid-quay-product/charts/tssc-quay/Chart.yaml create mode 100644 internal/config/testdata/merge-distributed-invalid-quay-product/config/settings.yaml create mode 100644 internal/config/testdata/merge-distributed-invalid-quay-product/helmet.yaml create mode 100644 internal/config/testdata/merge-distributed/charts/tssc-acs/config.yaml create mode 100644 internal/config/testdata/merge-distributed/config/settings.yaml create mode 100644 internal/config/testdata/merge-distributed/helmet.yaml create mode 100644 test/charts/testing/values.yaml.tpl diff --git a/docs/composable-bundles-design.md b/docs/composable-bundles-design.md new file mode 100644 index 000000000..a4dffb25e --- /dev/null +++ b/docs/composable-bundles-design.md @@ -0,0 +1,89 @@ +# Composable chart bundles — design overview + +This document describes the goals and shape of **composable bundles**: treating each installer chart as an independent unit with its own configuration surface, while allowing a single **blueprint** (`helmet.yaml`) to declare how charts participate in an install. + +--- + +## 1. Problem we are solving + +Installers today often ship as **one monolithic config** and **one root values template**, which couples charts together and makes it hard to: + +- Enable only part of the stack (for example “integration-only” registry credentials vs full product install). +- Evolve one chart’s config or templating without touching unrelated charts. +- Reuse the same chart in **different roles** (minimal integration bundle vs full product) without duplicating installers. + +We want **composition**: each chart remains a **bundle** with its own **`config.yaml`** (schema fragment), **`values.yaml.tpl`**, and Helm templates, while the overall install is **declared** in one place and stays **predictable** for CI/CD and humans. + +--- + +## 2. Composable bundles per chart + +### Per-chart ownership + +Each chart under `charts//` is treated as an independent bundle that owns: + +| Artifact | Role | +|----------|------| +| **`config.yaml`** | Declares that chart’s slice of installer configuration (products, settings keys, or integration defaults as applicable). | +| **`values.yaml.tpl`** | Renders Helm values for **that chart only**, using installer context (namespace, settings, products, integrations). | +| **`Chart.yaml`** | Framework annotations: dependencies, bundle behavior, integration names for topology/CEL, optional documentation of installer integration **id** vs topology name. | + +The **root** installer may still provide shared defaults or glue (`settings`, shared `values.yaml.tpl` for cross-cutting keys), but **bundle-specific** behavior and templates live **with the chart**. + +### Why it matters + +- Teams can change one chart’s templates or config contract without a giant merge conflict in a single root template. +- Testing and review can focus on **one bundle** at a time. + +--- + +## 3. Driving the install from `helmet.yaml` + +For installers that adopt a **distributed layout**, a **`helmet.yaml`** blueprint lists **which charts participate** and **how**: + +- **`products`**: `local://` references to charts that should appear as **products** (full bundle path when the chart supports it). +- **`integrations`**: `local://` references to charts that should be deployed as **integration bundles** when the chart supports that mode. + +The suffix of each reference (for example `local://quay`, `local://tpa`) aligns with the chart directory naming convention (`charts/tssc-quay`, `charts/tssc-tpa`) and drives generated **`installer.integrations`** entries (including merged **properties** defaults) where applicable. + +This gives a **single declarative file** for “what is in this install” without encoding all low-level YAML by hand in one mega-file. + +--- + +## 4. Bundle mode / bundle type (concept) + +Each chart advertises **which placements are valid** for that bundle — for example: + +- **Integration**: minimal footprint (often secrets, connectivity checks, or slim resources) suitable for listing under **`integrations`** in `helmet.yaml`. +- **Product**: full install path suitable for listing under **`products`**. +- **Both**: the chart may be listed in either list; **runtime behavior** (integration vs product) follows config and template logic, not only the list name. + +We refer to this informally as **bundle mode** or **bundle type** support on the chart (declared via framework annotations). The blueprint (`helmet.yaml`) must not place a chart in a role the chart does not support (the framework validates this during merge / resolution). + +--- + +## 5. Integration bundles and the ConfigMap + +Charts that contribute **integration** configuration need a stable place for **non-secret** or **placeholder** values that operators replace before or after **`deploy`**: + +- Merged installer configuration is persisted in the cluster (typically a **ConfigMap**). +- **Integration** entries gain **`properties`** (and display metadata) merged from chart **`values.yaml`** defaults and/or the blueprint flow. +- Operators still replace placeholders (URLs, tokens, flags) with **real environment-specific values** before a successful deploy — same operational model as classic installs, but **scoped per integration** in config. + +So: **integration charts both participate in topology and populate config surfaces that must be filled with real values**; the design does not assume secrets live in Git — only that the **shape** of config is clear and mergeable. + +--- + +## 6. Backward compatibility (classic structure) + +**Helmet remains compatible with the classic installer layout**: a single embedded **`config.yaml`**, root **`values.yaml.tpl`**, and **`charts/`** described only by framework annotations — **without** `helmet.yaml`, **without** distributed merge, and **without** listing integrations in config when operators manage integrations solely via CLI or cluster secrets. + +Composable bundles and `helmet.yaml` are **additive**; consumers adopt them incrementally. Existing semantics for chart discovery, dependency resolution, and ConfigMap-backed configuration continue to apply unless a consumer explicitly enables the new paths. + +--- + +## References + +- [Configuration](configuration.md) — schema, integrations list, template variables. +- [Topology](topology.md) — annotations, namespace assignment, `install-release-in-installer-namespace`. +- [Installer structure](installer-structure.md) — directory layout, optional distributed merge files. diff --git a/docs/composable-bundles-epic-google-doc.txt b/docs/composable-bundles-epic-google-doc.txt new file mode 100644 index 000000000..00dc8e3f2 --- /dev/null +++ b/docs/composable-bundles-epic-google-doc.txt @@ -0,0 +1,132 @@ +COMPOSABLE CHART BUNDLES — EPIC SUMMARY (COPY INTO GOOGLE DOCS) + +Purpose of this note +Attach this content to the Jira Epic. It summarizes what Helmet provides for composable bundles, optional installer bundle directories (bundles/ grouping related charts), the problem being solved, how helmet.yaml and bundle modes work, integration ConfigMap behavior, the proposed story list (Helmet + tssc-cli), and backward compatibility with classic installers. + + +PROBLEM WE ARE SOLVING + +Today many installers rely on one large config file and one root values template. That couples every chart together and makes it hard to: + +• Turn on only part of the stack (for example integration-only credentials versus a full product install). +• Change one chart’s templates or config without touching unrelated charts. +• Use the same chart in different roles (minimal integration bundle versus full product) without forking the installer. + +Goal: composition. Each chart stays a bundle with its own config fragment, its own values.yaml.tpl, and its own Helm templates, while the overall install is declared in one predictable place for CI/CD and operators. + + +COMPOSABLE BUNDLES (PER CHART) + +Each chart under charts// or bundles//charts// owns its own Helm artifacts (templates, Chart.yaml). Installer configuration for that chart may live in a per-chart config.yaml (typical for charts/ only), or—for bundle layouts—in a shared bundles//config.yaml that applies to the whole group (see next section). + +• config.yaml — either per-chart under charts/, or shared at bundle level under bundles// for distributed merge; may also include per-chart fragments where used. +• values.yaml.tpl — either per-chart, or shared at bundles//values.yaml.tpl for all charts in that bundle, with installer context (namespace, settings, products, integrations). +• Chart.yaml — framework annotations: dependencies (global charts, bundle siblings, or whole bundles), bundle behavior, integration names for topology and CEL expressions, optional installer integration id versus topology-facing integration names. + +The root installer can still ship shared settings and cross-cutting values.yaml.tpl glue. + +Why it matters: smaller blast radius per change, fewer giant merge conflicts in one root template, and review scoped to one bundle at a time. + + +BUNDLE DIRECTORIES (GROUPING RELATED CHARTS) + +Beyond a single chart folder under charts/, an installer may use a bundle directory that groups one or more Helm charts that share the same product configuration surface and the same values template: + +Directory layout (alongside the existing charts/ tree): + +• bundles//config.yaml — shared installer fragment for that product group (used by distributed merge when helmet.yaml lists local:// as a product; bundle-id matches the suffix after local:// when using short ids such as dh, pipelines, tas). +• bundles//values.yaml.tpl — shared Go template for rendering Helm values for every chart in that bundle (Helmet loads this before any per-chart values.yaml.tpl under the bundle). +• bundles//charts// — each Helm chart still has its own Chart.yaml, templates/, and optional chart-local values.yaml and values.yaml.tpl. + +Global charts stay under charts/ (for example shared infrastructure, IAM, Quay integration-only chart). Bundle charts may depend on: + +• Global charts — listed in Chart.yaml using depends-on-global-charts (charts installed from the top-level charts/ directory). +• Sibling charts in the same bundle — depends-on-bundle-charts (other charts under the same bundles//charts/ path). +• Other bundles — depends-on-bundles lists bundle ids; the framework expands that to an ordering constraint so all charts in those bundles appear before this chart in the deployment topology. + +Legacy depends-on (single comma-separated list) remains supported when none of the split annotations are used. + +Helmet discovers charts whether they live under charts/ or bundles//charts/. Values rendering and distributed merge follow that layout so topology and ConfigMap merge stay consistent. + + +HELMET FEATURE: DRIVE THE INSTALL FROM helmet.yaml + +Installers that adopt a distributed layout use a helmet.yaml blueprint listing which charts participate and how: + +• products — local:// references for charts that should be deployed as products (full path when the chart supports it). +• integrations — local:// references for charts that should be deployed as integration bundles when supported. + +The suffix after local:// (for example quay, tpa) matches the chart directory naming convention (for example charts/tssc-quay, charts/tssc-tpa). That suffix becomes the stable integration id in merged config and ties to template keys such as .Installer.Integrations.. + +This yields one declarative file for “what is in this install” instead of hand-maintaining all low-level YAML in a single mega-file. + + +HELMET FEATURE: BUNDLE MODE / BUNDLE TYPE + +Charts declare which placements are valid: + +• Integration — lighter footprint (often secrets, connectivity checks, slim resources); listed under integrations in helmet.yaml when applicable. +• Product — full install path; listed under products when applicable. +• Both — chart may appear in either list; actual runtime behavior follows merged config and templates, not only which list was used. + +Charts advertise supported placements via framework annotations (bundle-types-supported and related). The framework validates that helmet.yaml does not list a chart in a role it does not support. + + +INTEGRATION BUNDLES AND THE CONFIGMAP + +Merged installer configuration is stored in the cluster (typically a ConfigMap). Integration entries include properties and labels merged from chart values defaults and the blueprint flow. + +Operators still replace placeholders (URLs, tokens, flags) with real environment-specific values before a successful deploy. Secrets are not assumed to live in Git; the design focuses on a clear, mergeable shape in config and predictable updates before deploy. + + +BACKWARD COMPATIBILITY (CLASSIC STRUCTURE) + +Helmet remains compatible with the classic installer layout: a single embedded config.yaml, a root values.yaml.tpl, and charts described only by framework annotations — without helmet.yaml, without distributed merge, and without listing integrations in YAML when operators manage integrations only via CLI or existing cluster workflows. + +Composable bundles and helmet.yaml are additive. Consumers adopt them incrementally. + + +— STORY LIST — + +Stories below are sized for small, independent PRs. Ordering may still be logical (framework before consumer wiring). + + +Helmet (framework) + +Bundle types annotation and validation — Parse and validate which placements (integration / product / both) a chart supports; reject invalid blueprint placements. + +Distributed merge: blueprint and fragments — Merge config/settings.yaml, helmet.yaml, per-bundle bundles//config.yaml (when present), or legacy charts//config.yaml, into one installer payload; generate integrations entries from chart metadata and values defaults. + +Integration spec: stable id and YAML aliases — Standardize installer.integrations on id (chart suffix / template keys), with backward-compatible aliasing for legacy field names. + +Resolver: integration bundles by chart id — Resolve installer.integrations to charts by naming convention (for example tssc-) and bundle-type rules; keep topology and CEL integration names separate from installer id where needed. + +Template variables: .Installer.Integrations — Expose integration entries to values.yaml.tpl keyed by id, with stable fields for name and properties. + +Release namespace annotation for Helm ownership — Allow charts that emit installer-namespace resources to pin Helm release namespace so ownership metadata stays consistent when switching product versus integration bundle deploys. + +Documentation: topology, configuration, distributed layout — Document helmet.yaml, local:// references, bundle modes, bundle directory layout, dependency annotations (global charts vs bundle charts vs depends-on-bundles), and backward-compatible single-file config. + + +tssc-cli (first consumer) + +Chart annotations: bundle types and installer id — Set bundle-types-supported, optional installer-integration-id, and related metadata on representative charts (Quay, TPA, and others as needed). + +Root values.yaml.tpl: optional product versus integration-only paths — Remove hard failures on product-only assumptions when a chart runs integration-only; align shared glue with bundle modes. + +Per-chart values.yaml.tpl and integration Secret ownership — Align TPA and Quay templates with installer namespace, integration id keys in templates, and Helm ownership when switching between product and integration modes. + +IAM and shared charts: conditional assumptions — Adjust charts that assumed TPA always as a full product so integration-only installs still render (Keycloak and realm toggles as needed). + +Distributed installer layout — Wire WithDistributedInstallerMergeLayout, helmet.yaml, bundles//config.yaml and shared values.yaml.tpl where applicable, and per-chart values keys for merged properties; document operator workflow for replacing placeholders. + +E2E or manual runbook — Short runbook covering classic versus distributed config, deploy prerequisites, and placeholder replacement before production deploy. + + +REFERENCES (HELMET REPO DOCS) + +configuration.md — schema, integrations list, template variables. +topology.md — annotations, namespace assignment, bundle dependency annotations, install-release-in-installer-namespace. +installer-structure.md — directory layout (charts/ and bundles/), optional distributed merge files. + +Source markdown design doc (repo): docs/composable-bundles-design.md diff --git a/docs/configuration.md b/docs/configuration.md index 335b55c67..cbaaecb60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,7 @@ This document covers the `config.yaml` schema, default values, ConfigMap storage ## Configuration Schema -The configuration file uses a top-level key (matching the installer name) containing two sections: `settings` and `products`. +The configuration file uses a top-level key (matching the installer name) containing `settings` and `products`. ### Example Configuration @@ -51,6 +51,12 @@ The `settings` section is a freeform key-value map for installer-wide configurat The `products` section is a list of product specifications. Each product represents a deployable component with its own Helm chart and configuration. +### Integrations (credentials and templates) + +Helm installs are driven only by **`products`**. Integration credentials are created with the **`integration`** CLI subcommands (Kubernetes Secrets). Charts still use **`integrations-provided`** / **`integrations-required`** annotations for topology and CEL validation; see [topology.md](topology.md) and [integrations.md](integrations.md). + +For backward compatibility, Helm templates expose **`.Installer.Integrations`** as an empty map unless your application supplies values another way. + ## Product Field Reference | Field | Type | Required | Description | @@ -175,8 +181,9 @@ Configuration is exposed to Helm templates via the `values.yaml.tpl` template sy | `.Installer.Products..Enabled` | boolean | Product enabled state | | `.Installer.Products..Namespace` | string | Product target namespace | | `.Installer.Products..Properties` | map | Product-specific properties | +| `.Installer.Integrations.` | object | Integration entry with `Name`, `ID`, `Properties` (and `Integration` as a deprecated alias for `id`) | -Products are keyed by `KeyName()`, not the original `name` field. See [Product Name and KeyName](#product-name-and-keyname). +Products are keyed by `KeyName()`, not the original `name` field. See [Product Name and KeyName](#product-name-and-keyname). Integrations are keyed by `id` (for example `quay`, `tpa`). ### Example Template Usage @@ -244,6 +251,7 @@ The `ApplyDefaults()` method propagates the installer namespace to products with | Rule | Error | |------|-------| | `settings` section must exist | `missing settings` | +| Each integration entry has `name` and `id` (or legacy `integration`) | `integration entry missing name` / `integration entry missing id` | | Enabled products must have namespace | `product : missing namespace` | | Configuration must unmarshal successfully | `failed to unmarshal configuration` | diff --git a/docs/installer-structure.md b/docs/installer-structure.md index 2e50e186c..bcb373f0e 100644 --- a/docs/installer-structure.md +++ b/docs/installer-structure.md @@ -12,13 +12,13 @@ Every Helmet installer follows this convention-based structure: installer/ ├── config.yaml # Configuration schema (required) ├── values.yaml.tpl # Go template for Helm values (required) -├── charts/ # Helm charts with framework annotations -│ ├── product-a/ -│ │ ├── Chart.yaml -│ │ └── templates/ -│ └── product-b/ -│ ├── Chart.yaml -│ └── templates/ +├── charts/ # Global / shared Helm charts (framework annotations) +├── bundles/ # Optional: grouped charts sharing bundle config + values.yaml.tpl +│ └── / +│ ├── config.yaml +│ ├── values.yaml.tpl +│ └── charts/ +│ └── / └── instructions.md # MCP server context (optional) ``` @@ -36,6 +36,17 @@ installer/ |------|---------|-------------------| | `instructions.md` | Context and guidance for the MCP server, provided to AI assistants | `constants.InstructionsFilename` | +### Distributed installer layout (for config merge) + +Installers that use `WithDistributedInstallerMergeLayout()` may add: + +| File | Purpose | +|------|---------| +| `config/settings.yaml` | Shared installer settings merged into `config.yaml` | +| `helmet.yaml` | Blueprint listing `products` as `local://` references; `` is the bundle id or chart suffix (`bundles//` or `charts/tssc-`) | + +Product config fragments are merged from `bundles//config.yaml` or `charts//config.yaml`. Integration Secrets are created via **`integration`** CLI commands, not from `helmet.yaml`. See [configuration.md](configuration.md). + The framework discovers charts automatically by walking the filesystem and looking for directories containing `Chart.yaml`. ## Embedding the Installer diff --git a/docs/templating.md b/docs/templating.md index 997c559dc..208239f52 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -233,12 +233,16 @@ ingress: ### Building Namespace Lists -Collect namespaces for all enabled products: +Collect namespaces for all active products (`Enabled` omitted or true means on; only explicit `false` opts out). Do not use `and .Enabled .Namespace` alone—`Enabled` is a `*bool` in config and is omitted from JSON when nil, which templates treat as falsy. ```yaml projects: {{- range .Installer.Products }} - {{- if and .Enabled .Namespace }} + {{- $active := true }} + {{- if and (kindIs "bool" .Enabled) (eq .Enabled false) }} + {{- $active = false }} + {{- end }} + {{- if and $active .Namespace }} - {{ .Namespace }} {{- end }} {{- end }} @@ -277,34 +281,53 @@ openshift: minorVersion: {{ .OpenShift.MinorVersion }} {{- end }} -# Product-specific values -{{- if .Installer.Products.Product_A.Enabled }} +# Product-specific values (explicit enabled:false opts out; omitted Enabled means on) +{{- $a := index .Installer.Products "Product_A" }} +{{- $aActive := true }} +{{- if and (kindIs "bool" $a.Enabled) (eq $a.Enabled false) }} + {{- $aActive = false }} +{{- end }} +{{- if $aActive }} productA: - namespace: {{ .Installer.Products.Product_A.Namespace }} + namespace: {{ $a.Namespace }} enabled: true {{- end }} -{{- if .Installer.Products.Product_B.Enabled }} +{{- $b := index .Installer.Products "Product_B" }} +{{- $bActive := true }} +{{- if and (kindIs "bool" $b.Enabled) (eq $b.Enabled false) }} + {{- $bActive = false }} +{{- end }} +{{- if $bActive }} productB: - namespace: {{ .Installer.Products.Product_B.Namespace }} + namespace: {{ $b.Namespace }} enabled: true properties: -{{- .Installer.Products.Product_B.Properties | toYaml | nindent 4 }} +{{- $b.Properties | toYaml | nindent 4 }} {{- end }} -{{- if .Installer.Products.Product_D.Enabled }} +{{- $d := index .Installer.Products "Product_D" }} +{{- $dActive := true }} +{{- if and (kindIs "bool" $d.Enabled) (eq $d.Enabled false) }} + {{- $dActive = false }} +{{- end }} +{{- if $dActive }} productD: - namespace: {{ .Installer.Products.Product_D.Namespace }} + namespace: {{ $d.Namespace }} enabled: true - catalogURL: {{ required "catalogURL" .Installer.Products.Product_D.Properties.catalogURL }} - authProvider: {{ .Installer.Products.Product_D.Properties.authProvider | default "oidc" }} + catalogURL: {{ required "catalogURL" $d.Properties.catalogURL }} + authProvider: {{ $d.Properties.authProvider | default "oidc" }} {{- end }} # Namespace list for foundation chart foundation: projects: {{- range .Installer.Products }} - {{- if and .Enabled .Namespace }} + {{- $active := true }} + {{- if and (kindIs "bool" .Enabled) (eq .Enabled false) }} + {{- $active = false }} + {{- end }} + {{- if and $active .Namespace }} - {{ .Namespace }} {{- end }} {{- end }} diff --git a/docs/topology.md b/docs/topology.md index 0e68bf1a2..7d42276c2 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -163,6 +163,10 @@ Each chart is assigned a target namespace using a three-tier priority system: | 2 | Chart has `use-product-namespace` | Referenced product's namespace from `config.yaml` | | 3 | Neither annotation | Installer's default namespace | +**Bundle layout**: Charts may live under `bundles//charts//` with shared `bundles//config.yaml` and `values.yaml.tpl`. Dependencies may use `depends-on-global-charts` (charts under `charts/`), `depends-on-bundle-charts` (siblings in the same bundle), `depends-on-bundles` (all charts in listed bundles must precede this chart in topology), or the legacy `depends-on` when none of the split annotations are set. + +If `helmet.redhat-appstudio.github.com/install-release-in-installer-namespace` is `"true"`, the Helm release is installed in the **installer namespace** anyway. Use this when the chart creates namespaced resources in the installer namespace (for example integration Secrets) while application workloads are rendered into another namespace via template fields: Helm ownership labels (`meta.helm.sh/release-namespace`) must match the release namespace, so the release should not run in the product namespace if those resources live in the installer namespace. + ### Example From [`helmet-ex/installer/config.yaml`](../example/helmet-ex/installer/config.yaml): @@ -194,6 +198,10 @@ The [`helmet-ex`](../example/helmet-ex/) example includes 10 charts demonstratin ## Troubleshooting +### Helm secret ownership / `meta.helm.sh/release-namespace` + +If a chart was previously installed in the product namespace but places integration Secrets in the installer namespace, switching to `install-release-in-installer-namespace` (or changing product vs integration bundle) can leave a Secret that Helm refuses to adopt. Remove the affected Secret in the installer namespace once, or uninstall the old release in the previous namespace, then deploy again. + ### Missing Dependency ```text diff --git a/framework/app.go b/framework/app.go index 2c6bcb339..00ae78b47 100644 --- a/framework/app.go +++ b/framework/app.go @@ -6,6 +6,7 @@ import ( "github.com/redhat-appstudio/helmet/api" "github.com/redhat-appstudio/helmet/internal/chartfs" + "github.com/redhat-appstudio/helmet/internal/config" "github.com/redhat-appstudio/helmet/internal/flags" "github.com/redhat-appstudio/helmet/internal/integrations" "github.com/redhat-appstudio/helmet/internal/k8s" @@ -32,6 +33,9 @@ type App struct { mcpToolsBuilder mcptools.MCPToolsBuilder // tools builder mcpImage string // installer image installerTarball []byte // embedded installer tarball + + loadCreateConfig config.CreateConfigLoader // optional config --create loader + mergedInstallerConfig bool // empty path means merge fragments } // Command exposes the Cobra command. @@ -83,6 +87,11 @@ func (a *App) setupRootCmd() error { return fmt.Errorf("failed to load modules: %w", err) } + if a.mergedInstallerConfig && a.loadCreateConfig == nil { + return fmt.Errorf( + "merged installer layout requires WithLoadCreateConfig (see framework options)") + } + // Register standard subcommands. a.rootCmd.AddCommand(subcmd.NewIntegration( a.AppCtx, runCtx, a.integrationManager, a.flags, @@ -102,7 +111,13 @@ func (a *App) setupRootCmd() error { // Other subcommands via api.Runner. subs := []api.SubCommand{ - subcmd.NewConfig(a.AppCtx, runCtx, a.flags), + subcmd.NewConfig( + a.AppCtx, + runCtx, + a.flags, + a.mergedInstallerConfig, + a.loadCreateConfig, + ), subcmd.NewDeploy(a.AppCtx, runCtx, a.flags, a.integrationManager, a.installerTarball), subcmd.NewInstaller(a.AppCtx, runCtx, a.flags, a.installerTarball), subcmd.NewMCPServer(a.AppCtx, runCtx, a.flags, a.integrationManager, mcpBuilder, a.mcpImage), @@ -181,6 +196,22 @@ func NewAppFromTarball( return NewApp(appCtx, cfs, opts...) } +func distributedInstallerMergeLoader( + cfs *chartfs.ChartFS, + explicitPath string, + namespace string, + appIdentifier string, +) (*config.Config, error) { + if explicitPath != "" { + return config.NewConfigFromFile(cfs, explicitPath, namespace, appIdentifier) + } + payload, err := config.MergeDistributedInstallerYAML(cfs, appIdentifier) + if err != nil { + return nil, err + } + return config.NewConfigFromBytes(payload, namespace, appIdentifier) +} + // StandardIntegrations returns the list of standard integration modules. // This exposes the standard integrations (GitHub, GitLab, Quay, etc.) // through the public API for use with WithIntegrations option. diff --git a/framework/options.go b/framework/options.go index 9406e4787..2bf35c62b 100644 --- a/framework/options.go +++ b/framework/options.go @@ -2,6 +2,7 @@ package framework import ( "github.com/redhat-appstudio/helmet/api" + "github.com/redhat-appstudio/helmet/internal/config" "github.com/redhat-appstudio/helmet/internal/mcptools" ) @@ -17,6 +18,24 @@ func WithIntegrations(modules ...api.IntegrationModule) Option { } } +// WithLoadCreateConfig replaces the default "config --create" loader. +func WithLoadCreateConfig(fn config.CreateConfigLoader) Option { + return func(a *App) { + a.loadCreateConfig = fn + } +} + +// WithDistributedInstallerMergeLayout configures "config --create" without a file +// argument to merge installer/config/settings.yaml, installer/helmet.yaml, +// and charts//config.yaml fragments (see config.MergeDistributedInstallerYAML). +// Overrides the default single-file loader. +func WithDistributedInstallerMergeLayout() Option { + return func(a *App) { + a.mergedInstallerConfig = true + a.loadCreateConfig = distributedInstallerMergeLoader + } +} + // WithMCPImage sets the container image for the MCP server. func WithMCPImage(image string) Option { return func(a *App) { diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go index 79789b5bd..fe45bda50 100644 --- a/internal/annotations/annotations.go +++ b/internal/annotations/annotations.go @@ -8,10 +8,32 @@ const RepoURI = "helmet.redhat-appstudio.github.com" const ( ProductName = RepoURI + "/product-name" DependsOn = RepoURI + "/depends-on" + // DependsOnGlobalCharts lists charts under the installer charts/ directory (global charts). + DependsOnGlobalCharts = RepoURI + "/depends-on-global-charts" + // DependsOnBundleCharts lists sibling charts in the same bundles//charts/ directory. + DependsOnBundleCharts = RepoURI + "/depends-on-bundle-charts" + // DependsOnBundles lists bundle directory names (bundles/) whose charts must be ordered before this chart. + DependsOnBundles = RepoURI + "/depends-on-bundles" Weight = RepoURI + "/weight" UseProductNamespace = RepoURI + "/use-product-namespace" + // InstallReleaseInInstallerNamespace, when "true", forces the Helm release + // namespace to the installer namespace even when the chart is tied to a product + // that uses a different product namespace. Use when chart templates place + // resources (e.g. integration Secrets) in the installer namespace so Helm + // ownership metadata stays consistent. + InstallReleaseInInstallerNamespace = RepoURI + "/install-release-in-installer-namespace" IntegrationsProvided = RepoURI + "/integrations-provided" + // InstallerIntegrationID documents a chart id suffix (local:// in helmet.yaml; + // must match Chart name tssc- when set). Optional. + InstallerIntegrationID = RepoURI + "/installer-integration-id" + // IntegrationDisplayName is an optional human-readable title (defaults to Chart description). + IntegrationDisplayName = RepoURI + "/integration-display-name" IntegrationsRequired = RepoURI + "/integrations-required" - PostDeploy = RepoURI + "/post-deploy" + // BundleTypesSupported lists product vs legacy integration tokens (comma-separated, + // or the keyword "both"). Only product placement is used for Helm installs. + BundleTypesSupported = RepoURI + "/bundle-types-supported" + // BundleType is legacy; use BundleTypesSupported. Values: integration, product, dual. + BundleType = RepoURI + "/bundle-type" + PostDeploy = RepoURI + "/post-deploy" Config = RepoURI + "/config" ) diff --git a/internal/annotations/bundle_types.go b/internal/annotations/bundle_types.go new file mode 100644 index 000000000..0f0b79eac --- /dev/null +++ b/internal/annotations/bundle_types.go @@ -0,0 +1,67 @@ +package annotations + +import ( + "fmt" + "strings" +) + +// ParseBundleTypesSupported reports whether a chart declares product bundle support +// and/or legacy "integration" bundle tokens (integration Helm installs are unused; +// prefer listing charts under installer.products only). +// +// New annotation helmet.../bundle-types-supported (preferred): +// - "product" — may appear under installer.products +// - "integration" — legacy token (charts are not installed via installer.integrations) +// - "integration,product" or "both" — product listing allowed; integration token ignored for topology +// +// Legacy helmet.../bundle-type (used when bundle-types-supported is empty): +// - (empty) — product-only (same as charts without annotations) +// - "integration" — legacy integration-only token +// - "product" — product-only +// - "dual" — legacy dual token (product install still uses product support bits) +func ParseBundleTypesSupported(bundleTypesSupported, legacyBundleType string) (integration, product bool, err error) { + s := strings.TrimSpace(bundleTypesSupported) + if s != "" { + return parseNewBundleTypesSupported(s) + } + return parseLegacyBundleType(strings.TrimSpace(legacyBundleType)) +} + +func parseLegacyBundleType(legacy string) (integration, product bool, err error) { + switch strings.ToLower(legacy) { + case "": + return false, true, nil + case "integration": + return true, false, nil + case "dual": + return true, true, nil + case "product": + return false, true, nil + default: + return false, false, fmt.Errorf("unknown legacy %s value %q", BundleType, legacy) + } +} + +func parseNewBundleTypesSupported(s string) (integration, product bool, err error) { + parts := strings.Split(s, ",") + for _, p := range parts { + tok := strings.ToLower(strings.TrimSpace(p)) + if tok == "" { + continue + } + switch tok { + case "integration": + integration = true + case "product": + product = true + case "both": + integration, product = true, true + default: + return false, false, fmt.Errorf("unknown %s token %q", BundleTypesSupported, tok) + } + } + if !integration && !product { + return false, false, fmt.Errorf("%s must list at least one of integration, product, or both", BundleTypesSupported) + } + return integration, product, nil +} diff --git a/internal/annotations/bundle_types_test.go b/internal/annotations/bundle_types_test.go new file mode 100644 index 000000000..e84da649b --- /dev/null +++ b/internal/annotations/bundle_types_test.go @@ -0,0 +1,60 @@ +package annotations + +import ( + "testing" + + o "github.com/onsi/gomega" +) + +func TestParseBundleTypesSupported(t *testing.T) { + g := o.NewWithT(t) + + t.Run("new format", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("integration, product", "") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeTrue()) + g.Expect(p).To(o.BeTrue()) + }) + + t.Run("both alias", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("both", "") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeTrue()) + g.Expect(p).To(o.BeTrue()) + }) + + t.Run("integration only", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("integration", "") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeTrue()) + g.Expect(p).To(o.BeFalse()) + }) + + t.Run("product only", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("product", "") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeFalse()) + g.Expect(p).To(o.BeTrue()) + }) + + t.Run("legacy dual", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("", "dual") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeTrue()) + g.Expect(p).To(o.BeTrue()) + }) + + t.Run("legacy empty is product only", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("", "") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeFalse()) + g.Expect(p).To(o.BeTrue()) + }) + + t.Run("new format wins over legacy", func(t *testing.T) { + i, p, err := ParseBundleTypesSupported("product", "dual") + g.Expect(err).To(o.Succeed()) + g.Expect(i).To(o.BeFalse()) + g.Expect(p).To(o.BeTrue()) + }) +} diff --git a/internal/chartfs/chartfs.go b/internal/chartfs/chartfs.go index 0d5d77da5..a45b73052 100644 --- a/internal/chartfs/chartfs.go +++ b/internal/chartfs/chartfs.go @@ -1,9 +1,15 @@ package chartfs import ( + "errors" + "fmt" "io/fs" "os" + "path" "path/filepath" + "strings" + + "github.com/redhat-appstudio/helmet/internal/constants" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" @@ -35,11 +41,135 @@ func (c *ChartFS) ReadFile(name string) ([]byte, error) { return fs.ReadFile(c.fsys, name) } +// LoadedChart pairs a Helm chart with its source directory path relative to the +// ChartFS root (e.g. "charts/product-a"). +type LoadedChart struct { + Path string + Chart *chart.Chart +} + +// ReadValuesTemplate loads values for chartDir: first bundles//values.yaml.tpl +// when chartDir lives under bundles//charts/, then chartDir/values.yaml.tpl, +// otherwise rootValuesPath. +func (c *ChartFS) ReadValuesTemplate(chartDir, rootValuesPath string) ([]byte, string, error) { + if chartDir != "" { + if br := bundleRootFromChartDir(chartDir); br != "" { + p := path.Join(br, constants.ValuesFilename) + b, err := c.ReadFile(p) + if err == nil { + return b, p, nil + } + if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, os.ErrNotExist) { + return nil, "", err + } + } + p := path.Join(chartDir, constants.ValuesFilename) + b, err := c.ReadFile(p) + if err == nil { + return b, p, nil + } + if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, os.ErrNotExist) { + return nil, "", err + } + } + b, err := c.ReadFile(rootValuesPath) + if err != nil { + return nil, "", err + } + return b, rootValuesPath, nil +} + +func bundleRootFromChartDir(chartDir string) string { + chartDir = filepath.ToSlash(chartDir) + const sep = "/charts/" + i := strings.Index(chartDir, sep) + if i < 0 { + return "" + } + return chartDir[:i] +} + +// FindChartDir returns the directory containing Chart.yaml for a chart named chartName +// (Helm metadata name), searching charts/ then bundles/*/charts/. +func (c *ChartFS) FindChartDir(chartName string) (string, error) { + if chartName == "" || strings.Contains(chartName, "..") { + return "", fmt.Errorf("invalid chart name %q", chartName) + } + primary := path.Join("charts", chartName) + cy := path.Join(primary, chartutil.ChartfileName) + if _, err := fs.Stat(c.fsys, cy); err == nil { + return filepath.ToSlash(primary), nil + } + entries, err := fs.ReadDir(c.fsys, "bundles") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("%w: chart %q: not found under charts/ or bundles/*/charts/", fs.ErrNotExist, chartName) + } + return "", err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + bid := e.Name() + p := path.Join("bundles", bid, "charts", chartName) + if _, err := fs.Stat(c.fsys, path.Join(p, chartutil.ChartfileName)); err == nil { + return filepath.ToSlash(p), nil + } + } + return "", fmt.Errorf("%w: chart %q: not found under charts/ or bundles/*/charts/", fs.ErrNotExist, chartName) +} + +// ResolveChartDir maps a user-provided chart reference to the chart directory path +// relative to the ChartFS root. It accepts: +// - an exact path where Chart.yaml exists (e.g. charts/tssc-quay or bundles/tpa/charts/tssc-tpa); +// - a path whose last segment is the Helm chart metadata name (e.g. charts/tssc-tpa); +// - a chart metadata name alone (e.g. tssc-tpa); +// - a short product id when the chart is named tssc- (e.g. tpa → tssc-tpa, charts/tpa). +func (c *ChartFS) ResolveChartDir(arg string) (string, error) { + arg = filepath.ToSlash(strings.TrimSpace(arg)) + arg = strings.TrimSuffix(arg, "/") + if arg == "" { + return "", fmt.Errorf("empty chart path") + } + if _, err := c.GetChartFiles(arg); err == nil { + return arg, nil + } + base := path.Base(arg) + if base == "." || base == "" { + return "", fmt.Errorf("invalid chart path %q", arg) + } + if dir, err := c.FindChartDir(base); err == nil { + return dir, nil + } + if !strings.HasPrefix(base, "tssc-") { + if dir, err := c.FindChartDir("tssc-" + base); err == nil { + return dir, nil + } + } + return "", fmt.Errorf("%w: chart %q: use path under charts/ or bundles/*/charts/, or chart metadata name", fs.ErrNotExist, arg) +} + +// ListChartsUnder returns chart directory paths under relRoot that contain Chart.yaml. +func (c *ChartFS) ListChartsUnder(relRoot string) ([]string, error) { + relRoot = filepath.ToSlash(strings.Trim(relRoot, "/")) + if relRoot == "" { + return nil, fmt.Errorf("empty chart root") + } + return c.walkAndFindChartDirs(c.fsys, relRoot) +} + // Open opens the named file. Implements "fs.FS" interface. func (c *ChartFS) Open(name string) (fs.File, error) { return c.fsys.Open(name) } +// WalkDir walks file names under root (delegates to io/fs.WalkDir on the chart tree). +func (c *ChartFS) WalkDir(root string, fn fs.WalkDirFunc) error { + root = filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(root), "/")) + return fs.WalkDir(c.fsys, root, fn) +} + // walkChartDir walks through the chart directory, and loads the chart files. func (c *ChartFS) walkChartDir(fsys fs.FS, chartPath string) (*chart.Chart, error) { bf := NewBufferedFiles(fsys, chartPath) @@ -83,18 +213,21 @@ func (c *ChartFS) walkAndFindChartDirs( } // GetAllCharts retrieves all Helm charts from the filesystem. -func (c *ChartFS) GetAllCharts() ([]chart.Chart, error) { - charts := []chart.Chart{} +func (c *ChartFS) GetAllCharts() ([]LoadedChart, error) { + charts := []LoadedChart{} chartDirs, err := c.walkAndFindChartDirs(c.fsys, ".") if err != nil { return nil, err } for _, chartDir := range chartDirs { - chart, err := c.GetChartFiles(chartDir) + hc, err := c.GetChartFiles(chartDir) if err != nil { return nil, err } - charts = append(charts, *chart) + charts = append(charts, LoadedChart{ + Path: filepath.ToSlash(chartDir), + Chart: hc, + }) } return charts, nil } diff --git a/internal/chartfs/chartfs_test.go b/internal/chartfs/chartfs_test.go index 19f3a3f8d..91fe4c5b5 100644 --- a/internal/chartfs/chartfs_test.go +++ b/internal/chartfs/chartfs_test.go @@ -43,5 +43,31 @@ func TestNewChartFS(t *testing.T) { g.Expect(err).To(o.Succeed()) g.Expect(charts).ToNot(o.BeNil()) g.Expect(len(charts)).To(o.BeNumerically(">", 1)) + found := false + for _, lc := range charts { + if lc.Path == "charts/testing" { + found = true + g.Expect(lc.Chart.Name()).To(o.Equal("testing")) + break + } + } + g.Expect(found).To(o.BeTrueBecause("charts/testing fixture must be indexed with Path")) + }) + + t.Run("ReadValuesTemplate prefers per-chart file", func(t *testing.T) { + b, used, err := c.ReadValuesTemplate("charts/testing", "values.yaml.tpl") + g.Expect(err).To(o.Succeed()) + g.Expect(used).To(o.Equal("charts/testing/values.yaml.tpl")) + g.Expect(string(b)).To(o.ContainSubstring("helmet-chart-specific-values-marker")) + }) + + t.Run("ReadValuesTemplate falls back to root", func(t *testing.T) { + b, used, err := c.ReadValuesTemplate( + "charts/helmet-product-a", + "values.yaml.tpl", + ) + g.Expect(err).To(o.Succeed()) + g.Expect(used).To(o.Equal("values.yaml.tpl")) + g.Expect(string(b)).ToNot(o.ContainSubstring("helmet-chart-specific-values-marker")) }) } diff --git a/internal/config/bundle_integration_outputs.go b/internal/config/bundle_integration_outputs.go new file mode 100644 index 000000000..e4331fa66 --- /dev/null +++ b/internal/config/bundle_integration_outputs.go @@ -0,0 +1,280 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/redhat-appstudio/helmet/internal/chartfs" + "github.com/redhat-appstudio/helmet/internal/constants" +) + +const ( + distributedBundlesRelDir = "bundles" + distributedChartsRelDir = "charts" +) + +// OutputTypeIntegrationSecret is the bundle output kind for integration Secrets — +// Secrets downstream charts consume through the integrations framework (names like +// tssc--integration), not unrelated operator Secrets. +const OutputTypeIntegrationSecret = "integration_secret" + +var integrationSecretFullNamePattern = regexp.MustCompile( + `^tssc-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?-integration$`, +) + +var ( + integrationSecretMetadataNameRX = regexp.MustCompile( + `(?m)^\s*name:\s*(tssc-[a-z0-9-]+-integration)\s*$`, + ) + kindSecretLine = regexp.MustCompile(`(?mi)^kind:\s*Secret\s*$`) + yamlDocSplit = regexp.MustCompile(`(?m)^---\s*$`) + + fromLiteralKeys = regexp.MustCompile(`--from-literal=(?:"([^"]+)=|([^=]+)=)`) + + argocdEnvKeysRX = regexp.MustCompile(`(?m)^(ARGOCD_[A-Z0-9_]+)=\$\{`) +) + +func looksLikeBundledDiscoveryFile(rel string) bool { + rel = filepath.ToSlash(rel) + switch { + case strings.Contains(rel, "/templates/"): + return strings.HasSuffix(rel, ".yaml") || + strings.HasSuffix(rel, ".yml") || + strings.HasSuffix(rel, ".tpl") + case strings.Contains(rel, "/scripts/"): + return strings.HasSuffix(rel, ".sh") + default: + return false + } +} + +func mergeSecretsFromScripts(content string, out map[string][]string) { + if gn := guessStackroxIntegrationSecret(content); gn != "" { + from := extractFromLiteralsAcrossScript(content) + appendKeysMerge(out, gn, from) + } + if strings.Contains(content, "argocd-helper.sh") || + strings.Contains(content, "argocd_store_credentials") { + for _, mk := range argocdEnvKeysRX.FindAllStringSubmatch(content, -1) { + if len(mk) > 1 { + appendKeysMerge(out, "tssc-argocd-integration", []string{mk[1]}) + } + } + } +} + +func guessStackroxIntegrationSecret(content string) string { + if strings.Contains(content, "stackrox-helper.sh") || + strings.Contains(content, "StackRox API token") || + strings.Contains(content, "store_api_token_in_secret") { + return "tssc-acs-integration" + } + return "" +} + +func extractFromLiteralsAcrossScript(content string) []string { + set := map[string]struct{}{} + for _, m := range fromLiteralKeys.FindAllStringSubmatch(content, -1) { + var k string + switch { + case len(m) > 1 && m[1] != "": + k = m[1] + case len(m) > 2 && m[2] != "": + k = strings.TrimSpace(m[2]) + default: + continue + } + if k != "" && !strings.ContainsAny(k, "$`\"\t ") { + set[k] = struct{}{} + } + } + return sortedStrings(set) +} + +func sortedStrings(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func appendKeysMerge(dest map[string][]string, secretName string, keys []string) { + if secretName == "" || len(keys) == 0 { + return + } + have := dest[secretName] + seen := map[string]struct{}{} + for _, k := range have { + seen[k] = struct{}{} + } + for _, k := range keys { + if _, ok := seen[k]; !ok { + have = append(have, k) + seen[k] = struct{}{} + } + } + sort.Strings(have) + dest[secretName] = have +} + +func mergeSecretsFromYAMLDocs(content string, dest map[string][]string) { + blocks := yamlDocSplit.Split(content, -1) + for _, blk := range blocks { + raw := strings.TrimSpace(blk) + if raw == "" || !kindSecretLine.MatchString(raw) { + continue + } + nameMatch := integrationSecretMetadataNameRX.FindStringSubmatch(raw) + if len(nameMatch) < 2 { + continue + } + appendKeysMerge(dest, nameMatch[1], extractSecretManifestKeys(raw)) + } +} + +func extractSecretManifestKeys(secretDoc string) []string { + lower := strings.ToLower(secretDoc) + var remainder string + if i := strings.Index(lower, "\nstringdata:"); i >= 0 { + remainder = secretDoc[i+1:] + } else if i := strings.Index(lower, "\ndata:"); i >= 0 { + remainder = secretDoc[i+1:] + } else { + return nil + } + keyLine := regexp.MustCompile(`^ ([a-zA-Z0-9_.-]+):\s`) + set := map[string]struct{}{} + for _, ln := range strings.Split(remainder, "\n")[1:] { + if ts := strings.TrimSpace(ln); ts == "" || strings.HasPrefix(ts, "#") { + continue + } + if !strings.HasPrefix(ln, " ") || len(ln) < 5 { + break + } + if m := keyLine.FindStringSubmatch(ln); m != nil { + k := m[1] + if strings.Contains(k, "{{") || strings.Contains(k, "}}") { + continue + } + set[k] = struct{}{} + } + } + return sortedStrings(set) +} + +func isMissingPathErr(err error) bool { + return err != nil && errors.Is(err, fs.ErrNotExist) +} + +func scanChartsFSRoot(cfs *chartfs.ChartFS, root string, dest map[string][]string) error { + err := cfs.WalkDir(filepath.ToSlash(strings.TrimPrefix(root, "/")), func(rel string, ent fs.DirEntry, e error) error { + if e != nil { + return e + } + if ent.IsDir() { + return nil + } + if !looksLikeBundledDiscoveryFile(rel) { + return nil + } + body, err := cfs.ReadFile(rel) + if err != nil { + return err + } + s := string(body) + mergeSecretsFromYAMLDocs(s, dest) + mergeSecretsFromScripts(s, dest) + return nil + }) + if err != nil && isMissingPathErr(err) { + return nil + } + return err +} + +// discoverProductIntegrationSecrets scans bundle charts and legacy charts/ +// layouts for manifests that declare integration Secret names (`tssc-*-integration`). +func discoverProductIntegrationSecrets(cfs *chartfs.ChartFS, bundleID string, helmChartName string) (map[string][]string, error) { + dest := map[string][]string{} + roots := []string{ + path.Join(distributedBundlesRelDir, bundleID, "charts"), + path.Join(distributedChartsRelDir, helmChartName), + } + for _, root := range roots { + if err := scanChartsFSRoot(cfs, root, dest); err != nil { + return nil, err + } + } + return dest, nil +} + +func stringSlicesEqualIgnoringOrder(a, b []string) bool { + if len(a) != len(b) { + return false + } + cp := append([]string(nil), a...) + cq := append([]string(nil), b...) + sort.Strings(cp) + sort.Strings(cq) + for i := range cp { + if cp[i] != cq[i] { + return false + } + } + return true +} + +// ValidateProductIntegrationOutputs verifies config.yaml `.outputs` describes only integration +// Secrets (`type` integration_secret) and lists names/keys declared by the product charts. +func ValidateProductIntegrationOutputs(cfs *chartfs.ChartFS, bundleID string, helmChartName string, outputs []BundleOutput) error { + if len(outputs) == 0 { + return nil + } + discovered, err := discoverProductIntegrationSecrets(cfs, bundleID, helmChartName) + if err != nil { + return fmt.Errorf("%s bundles/%s / %s outputs: %w", constants.ConfigFilename, bundleID, helmChartName, err) + } + for i := range outputs { + o := outputs[i] + if strings.TrimSpace(o.Type) != OutputTypeIntegrationSecret { + return fmt.Errorf( + "%s product %s outputs[%d].type=%q invalid: use %q", + constants.ConfigFilename, bundleID, i, strings.TrimSpace(o.Type), OutputTypeIntegrationSecret) + } + name := strings.TrimSpace(o.Name) + if name == "" { + return fmt.Errorf("%s product %s outputs[%d]: empty name", constants.ConfigFilename, bundleID, i) + } + if !integrationSecretFullNamePattern.MatchString(name) { + return fmt.Errorf( + "%s product %s outputs[%d].name=%q must look like tssc--integration", + constants.ConfigFilename, bundleID, i, name) + } + keysSeen, exists := discovered[name] + if !exists || len(keysSeen) == 0 { + return fmt.Errorf( + "%s product %s outputs[%d]: integration secret %q not found under bundles/%s/charts or %s/%s", + constants.ConfigFilename, bundleID, i, name, bundleID, distributedChartsRelDir, helmChartName) + } + if len(o.Data) > 0 && !stringSlicesEqualIgnoringOrder(keysSeen, o.Data) { + return fmt.Errorf( + "%s product %s outputs[%d] secret %q: data keys %#v ≠ discovered %#v", + constants.ConfigFilename, bundleID, i, name, sortedCopy(o.Data), keysSeen) + } + } + return nil +} + +func sortedCopy(s []string) []string { + cp := append([]string(nil), s...) + sort.Strings(cp) + return cp +} diff --git a/internal/config/bundle_integration_outputs_test.go b/internal/config/bundle_integration_outputs_test.go new file mode 100644 index 000000000..4167702a9 --- /dev/null +++ b/internal/config/bundle_integration_outputs_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "strings" + "testing" + "testing/fstest" + + "github.com/redhat-appstudio/helmet/internal/chartfs" + "gopkg.in/yaml.v3" +) + +func TestDiscoverProductIntegrationSecrets_fromBundleTemplates(t *testing.T) { + fs := fstest.MapFS{ + "bundles/tpa/charts/tssc-tpa/templates/integration-secret.yaml": &fstest.MapFile{ + Data: []byte(`--- +apiVersion: v1 +kind: Secret +metadata: + name: tssc-trustification-integration +stringData: + bombastic_api_url: "x" + supported_cyclonedx_version: "y" +`), + }, + } + cfs := chartfs.New(fs) + got, err := discoverProductIntegrationSecrets(cfs, "tpa", "tssc-tpa") + if err != nil { + t.Fatal(err) + } + want := []string{"bombastic_api_url", "supported_cyclonedx_version"} + gotKeys := got["tssc-trustification-integration"] + if len(gotKeys) != len(want) { + t.Fatalf("keys %v want %v", gotKeys, want) + } + for i := range want { + if gotKeys[i] != want[i] { + t.Fatalf("keys[%d]=%q want %q", i, gotKeys[i], want[i]) + } + } +} + +func TestValidateProductIntegrationOutputs_typeEnforced(t *testing.T) { + fs := fstest.MapFS{} + cfs := chartfs.New(fs) + err := ValidateProductIntegrationOutputs(cfs, "demo", "tssc-demo", []BundleOutput{{ + Name: "wrong", + Type: "secret", + }}) + if err == nil { + t.Fatal("expected error for invalid type/name") + } +} + +func TestProductMarshalYAML_omitsOutputs(t *testing.T) { + p := Product{ + Name: "Demo Product", + Properties: map[string]interface{}{"k": true}, + Outputs: []BundleOutput{{ + Name: "tssc-demo-integration", + Type: OutputTypeIntegrationSecret, + Data: []string{"secret"}, + }}, + } + b, err := yaml.Marshal(&p) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "outputs") { + t.Fatalf("cluster-facing YAML must omit outputs:\n%s", b) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 566d8dd60..55db761fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,11 +63,23 @@ func (c *Config) GetProduct(name string) (*Product, error) { return nil, fmt.Errorf("product '%s' not found", name) } -// GetEnabledProducts returns a map of enabled products. +// FindProduct returns the product with the given name, or nil if it is not +// listed under installer.products. +func (c *Config) FindProduct(name string) *Product { + for i := range c.Installer.Products { + if c.Installer.Products[i].Name == name { + return &c.Installer.Products[i] + } + } + return nil +} + +// GetEnabledProducts returns products that are active for deployment (omitted +// enabled or true). func (c *Config) GetEnabledProducts() Products { enabled := Products{} for _, product := range c.Installer.Products { - if product.Enabled { + if product.IsActive() { enabled = append(enabled, product) } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 39cded9fb..bfde2eb59 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -132,7 +132,8 @@ func TestNewConfigFromFile(t *testing.T) { g.Expect(product).NotTo(o.BeNil()) // Modify it - product.Enabled = false + off := false + product.Enabled = &off newNamespace := "new-productD-namespace" product.Namespace = &newNamespace product.Properties["catalogURL"] = "http://new.url/catalog.yaml" diff --git a/internal/config/load_create.go b/internal/config/load_create.go new file mode 100644 index 000000000..200f3d433 --- /dev/null +++ b/internal/config/load_create.go @@ -0,0 +1,30 @@ +package config + +import ( + "github.com/redhat-appstudio/helmet/internal/chartfs" +) + +// CreateConfigLoader loads installer configuration used by the "config --create" +// command. Implementations may merge distributed fragments when explicitPath is +// empty; otherwise they should load that path from ChartFS. +type CreateConfigLoader func( + chartFS *chartfs.ChartFS, + explicitPath string, + namespace string, + appIdentifier string, +) (*Config, error) + +// DefaultCreateConfigLoader loads a single YAML file (embedded or overlay). +// When explicitPath is empty it uses DefaultRelativeConfigPath. +func DefaultCreateConfigLoader( + cfs *chartfs.ChartFS, + explicitPath string, + namespace string, + appIdentifier string, +) (*Config, error) { + path := explicitPath + if path == "" { + path = DefaultRelativeConfigPath + } + return NewConfigFromFile(cfs, path, namespace, appIdentifier) +} diff --git a/internal/config/merge_distributed.go b/internal/config/merge_distributed.go new file mode 100644 index 000000000..470c6c7ba --- /dev/null +++ b/internal/config/merge_distributed.go @@ -0,0 +1,216 @@ +package config + +import ( + "errors" + "fmt" + "io/fs" + "path" + "strings" + + "github.com/redhat-appstudio/helmet/internal/annotations" + "github.com/redhat-appstudio/helmet/internal/chartfs" + "github.com/redhat-appstudio/helmet/internal/constants" + + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/chartutil" +) + +const ( + distributedSettingsPath = "config/settings.yaml" + distributedBlueprintPath = "helmet.yaml" + distributedChartsDir = "charts" + distributedBundlesDir = "bundles" + localRefPrefix = "local://" +) + +// helmInstallerBlueprint mirrors the installer helmet.yaml blueprint. +type helmInstallerBlueprint struct { + Name string `yaml:"name"` + Products []string `yaml:"products"` +} + +// MergeDistributedInstallerYAML builds one config.yaml payload from settings, +// helmet blueprint, and per-bundle or per-chart config.yaml fragments. +func MergeDistributedInstallerYAML(cfs *chartfs.ChartFS, appIdentifier string) ([]byte, error) { + settingsBytes, err := cfs.ReadFile(distributedSettingsPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", distributedSettingsPath, err) + } + helmBytes, err := cfs.ReadFile(distributedBlueprintPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", distributedBlueprintPath, err) + } + + var blueprint helmInstallerBlueprint + if err := yaml.Unmarshal(helmBytes, &blueprint); err != nil { + return nil, fmt.Errorf("parse helmet blueprint: %w", err) + } + + if err := validateProductBundleTypes(cfs, blueprint.Products); err != nil { + return nil, err + } + + var settings map[string]interface{} + if err := yaml.Unmarshal(settingsBytes, &settings); err != nil { + return nil, fmt.Errorf("parse settings: %w", err) + } + + var products []Product + for _, ref := range blueprint.Products { + id, err := integrationIDFromLocalRef(ref) + if err != nil { + return nil, err + } + chartName, err := chartNameFromLocalRef(ref) + if err != nil { + return nil, err + } + cfgPath := path.Join(distributedBundlesDir, id, constants.ConfigFilename) + body, err := cfs.ReadFile(cfgPath) + if err != nil { + cfgPath = path.Join(distributedChartsDir, chartName, constants.ConfigFilename) + body, err = cfs.ReadFile(cfgPath) + if err != nil { + return nil, fmt.Errorf( + "read product config (try bundles/%s/%s or charts/%s/%s): %w", + id, constants.ConfigFilename, chartName, constants.ConfigFilename, err) + } + } + var p Product + if err := yaml.Unmarshal(body, &p); err != nil { + return nil, fmt.Errorf("parse product config %s: %w", cfgPath, err) + } + if err := ValidateProductIntegrationOutputs(cfs, id, chartName, p.Outputs); err != nil { + return nil, err + } + stripped := p + stripped.Outputs = nil + products = append(products, stripped) + } + + doc := map[string]interface{}{ + appIdentifier: map[string]interface{}{ + "settings": settings, + "products": products, + }, + } + + out, err := yaml.Marshal(doc) + if err != nil { + return nil, err + } + return append([]byte("---\n"), out...), nil +} + +func chartNameFromLocalRef(ref string) (string, error) { + id, err := integrationIDFromLocalRef(ref) + if err != nil { + return "", err + } + return fmt.Sprintf("tssc-%s", id), nil +} + +func integrationIDFromLocalRef(ref string) (string, error) { + if !strings.HasPrefix(ref, localRefPrefix) { + return "", fmt.Errorf("expected %q reference, got %q", localRefPrefix, ref) + } + return strings.TrimPrefix(ref, localRefPrefix), nil +} + +// mapAtDotPath returns the mapping at the given dot-separated path (e.g. "quay" +// or "tpa.trustedProfileAnalyzer.integrationSecret"). +func mapAtDotPath(root map[string]interface{}, dotPath string) (map[string]interface{}, error) { + if dotPath == "" { + return nil, fmt.Errorf("empty values path") + } + parts := strings.Split(dotPath, ".") + var cur interface{} = root + for i, seg := range parts { + if seg == "" { + continue + } + m, ok := cur.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("segment %q is not a mapping", strings.Join(parts[:i], ".")) + } + cur, ok = m[seg] + if !ok { + return nil, fmt.Errorf("missing key %q", seg) + } + } + out, ok := cur.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("path must resolve to a mapping, got %T", cur) + } + return out, nil +} + +func readHelmChartYAML(cfs *chartfs.ChartFS, chartName string) (ann map[string]string, description, nameField string, err error) { + dir, err := cfs.FindChartDir(chartName) + if err != nil { + return nil, "", "", err + } + chartPath := path.Join(dir, chartutil.ChartfileName) + body, err := cfs.ReadFile(chartPath) + if err != nil { + return nil, "", "", err + } + return parseHelmChartYAML(body) +} + +func parseHelmChartYAML(body []byte) (ann map[string]string, description, nameField string, err error) { + var doc struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Metadata struct { + Annotations map[string]string `yaml:"annotations"` + } `yaml:"metadata"` + Annotations map[string]string `yaml:"annotations"` + } + if err := yaml.Unmarshal(body, &doc); err != nil { + return nil, "", "", err + } + ann = map[string]string{} + for k, v := range doc.Metadata.Annotations { + ann[k] = v + } + for k, v := range doc.Annotations { + ann[k] = v + } + return ann, doc.Description, doc.Name, nil +} + +// validateProductBundleTypes rejects charts that do not support a product bundle +// when listed under products (e.g. integration-only charts). +func validateProductBundleTypes(cfs *chartfs.ChartFS, productRefs []string) error { + for _, ref := range productRefs { + chartName, err := chartNameFromLocalRef(ref) + if err != nil { + return err + } + _, supportsProduct, err := readChartBundleSupport(cfs, chartName) + if err != nil { + return fmt.Errorf("read chart %s: %w", chartName, err) + } + if !supportsProduct { + return fmt.Errorf( + "helmet.yaml: product %q (chart %s) does not support a product bundle; set %s to include product", + ref, chartName, annotations.BundleTypesSupported) + } + } + return nil +} + +func readChartBundleSupport(cfs *chartfs.ChartFS, chartName string) (integration, product bool, err error) { + ann, _, _, err := readHelmChartYAML(cfs, chartName) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, true, nil + } + return false, false, err + } + return annotations.ParseBundleTypesSupported( + ann[annotations.BundleTypesSupported], + ann[annotations.BundleType], + ) +} diff --git a/internal/config/merge_distributed_test.go b/internal/config/merge_distributed_test.go new file mode 100644 index 000000000..83d63df9f --- /dev/null +++ b/internal/config/merge_distributed_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "os" + "testing" + + "github.com/redhat-appstudio/helmet/internal/chartfs" + + o "github.com/onsi/gomega" +) + +func TestMergeDistributedInstallerYAML(t *testing.T) { + g := o.NewWithT(t) + cfs := chartfs.New(os.DirFS("testdata/merge-distributed")) + + out, err := MergeDistributedInstallerYAML(cfs, "tssc") + g.Expect(err).To(o.Succeed()) + g.Expect(string(out)).To(o.ContainSubstring("tssc:")) + // Default merged products must not emit enabled: false (listing in blueprint = active). + g.Expect(string(out)).NotTo(o.ContainSubstring("enabled: false")) + g.Expect(string(out)).To(o.ContainSubstring("Advanced Cluster Security")) + + cfg, err := NewConfigFromBytes(out, "tssc", "tssc") + g.Expect(err).To(o.Succeed()) + g.Expect(cfg.Installer.Products).To(o.HaveLen(1)) + g.Expect(cfg.Installer.Products[0].Name).To(o.Equal("Advanced Cluster Security")) +} + +func TestMergeDistributedRejectsIntegrationOnlyUnderProducts(t *testing.T) { + g := o.NewWithT(t) + cfs := chartfs.New(os.DirFS("testdata/merge-distributed-invalid-quay-product")) + + _, err := MergeDistributedInstallerYAML(cfs, "tssc") + g.Expect(err).To(o.HaveOccurred()) + g.Expect(err.Error()).To(o.ContainSubstring("does not support a product bundle")) + g.Expect(err.Error()).To(o.ContainSubstring("bundle-types-supported")) +} + +func TestMapAtDotPath(t *testing.T) { + g := o.NewWithT(t) + root := map[string]interface{}{ + "a": map[string]interface{}{ + "b": map[string]interface{}{ + "c": map[string]interface{}{"k": "v"}, + }, + }, + } + m, err := mapAtDotPath(root, "a.b.c") + g.Expect(err).To(o.Succeed()) + g.Expect(m).To(o.HaveKeyWithValue("k", "v")) + _, err = mapAtDotPath(root, "a.missing") + g.Expect(err).To(o.HaveOccurred()) +} diff --git a/internal/config/node.go b/internal/config/node.go index dd57b1d36..af62eacd1 100644 --- a/internal/config/node.go +++ b/internal/config/node.go @@ -58,7 +58,7 @@ func FindNode(node *yaml.Node, key string) (*yaml.Node, error) { // - If the node is a MappingNode, it searches for the specified key. // If found, it marshals the newValue to YAML and unmarshals it back // into a new yaml.Node to preserve type fidelity, then replaces the -// existing value node. If the key is not found, it returns nil. +// existing value node. If the key is not found, it appends a new entry. // - For any other node kind, it returns an error. func UpdateMappingValue(node *yaml.Node, key string, newValue any) error { switch node.Kind { @@ -100,6 +100,26 @@ func UpdateMappingValue(node *yaml.Node, key string, newValue any) error { return nil } + // Key not found: append a new key-value pair (e.g. optional fields like + // "enabled" omitted from YAML until toggled). + var insertDoc yaml.Node + bs, err := yaml.Marshal(newValue) + if err != nil { + return err + } + if err := yaml.Unmarshal(bs, &insertDoc); err != nil { + return err + } + if len(insertDoc.Content) == 0 { + return fmt.Errorf("invalid new value for key %q", key) + } + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + insertedValue := insertDoc.Content[0] + node.Content = append(node.Content, keyNode, insertedValue) return nil default: return fmt.Errorf("cannot set value on node kind: %v", node.Kind) diff --git a/internal/config/product.go b/internal/config/product.go index f1381ffde..3c6f4b59a 100644 --- a/internal/config/product.go +++ b/internal/config/product.go @@ -6,17 +6,41 @@ import ( "strings" ) +// BundleOutput documents an integration Secret bundle charts expose to dependents +// (name must look like tssc--integration). Not written to the merged cluster ConfigMap; +// type must be OutputTypeIntegrationSecret and values are validated against chart templates. +type BundleOutput struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Notes string `yaml:"notes,omitempty"` + Data []string `yaml:"data,omitempty"` +} + +// productYAML is the wire shape for merged/saved YAML: omit "enabled" unless the +// user has explicitly opted out (false). Absent or true means active without a key. +type productYAML struct { + Name string `yaml:"name"` + Enabled *bool `yaml:"enabled,omitempty"` + Namespace *string `yaml:"namespace,omitempty"` + Properties map[string]interface{} `yaml:"properties,omitempty"` +} + // ProductSpec contains the configuration for a specific product. type Product struct { // Name of the product. Name string `yaml:"name"` - // Enabled product toggle. - Enabled bool `yaml:"enabled"` + // Enabled, when set to false, excludes the product from deployment while + // keeping its entry in the config (e.g. MCP / integration flows). When nil + // or omitted in YAML, the product is active—listing in the installer config + // is the primary enablement signal. + Enabled *bool `yaml:"enabled,omitempty"` // Namespace target namespace for product's dependency (Helm chart). If empty, // it defaults to the installer's namespace. Namespace *string `yaml:"namespace,omitempty"` // Properties contains the product specific configuration. Properties map[string]interface{} `yaml:"properties"` + // Outputs lists bundle-provided artifacts for dependents (see BundleOutput). + Outputs []BundleOutput `yaml:"outputs,omitempty"` } // KeyName returns a sanitized key name for the product. @@ -40,6 +64,27 @@ func (p *Product) KeyName() string { return key } +// MarshalYAML emits optional "enabled" only when explicitly false. Omitted key and +// true both mean active; this keeps default merged config free of enabled flags. +func (p Product) MarshalYAML() (interface{}, error) { + out := productYAML{ + Name: p.Name, + Namespace: p.Namespace, + Properties: p.Properties, + } + if p.Enabled != nil && !*p.Enabled { + v := false + out.Enabled = &v + } + return out, nil +} + +// IsActive reports whether this product should be deployed. Omitted or nil +// Enabled means true. +func (p *Product) IsActive() bool { + return p.Enabled == nil || *p.Enabled +} + // GetNamespace returns the product namespace, or an empty string if not set. func (p *Product) GetNamespace() string { if p.Namespace == nil { @@ -50,7 +95,10 @@ func (p *Product) GetNamespace() string { // Validate validates the product configuration, checking for missing fields. func (p *Product) Validate() error { - if p.Enabled && p.GetNamespace() == "" { + if !p.IsActive() { + return nil + } + if p.GetNamespace() == "" { return fmt.Errorf("%w: product %q: missing namespace", ErrInvalidConfig, p.Name) } diff --git a/internal/config/testdata/merge-distributed-invalid-quay-product/charts/tssc-quay/Chart.yaml b/internal/config/testdata/merge-distributed-invalid-quay-product/charts/tssc-quay/Chart.yaml new file mode 100644 index 000000000..e90d8b1fc --- /dev/null +++ b/internal/config/testdata/merge-distributed-invalid-quay-product/charts/tssc-quay/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: tssc-quay +version: 0.0.0 +metadata: + annotations: + helmet.redhat-appstudio.github.com/bundle-types-supported: integration diff --git a/internal/config/testdata/merge-distributed-invalid-quay-product/config/settings.yaml b/internal/config/testdata/merge-distributed-invalid-quay-product/config/settings.yaml new file mode 100644 index 000000000..59d393dde --- /dev/null +++ b/internal/config/testdata/merge-distributed-invalid-quay-product/config/settings.yaml @@ -0,0 +1,3 @@ +crc: false +ci: + debug: false diff --git a/internal/config/testdata/merge-distributed-invalid-quay-product/helmet.yaml b/internal/config/testdata/merge-distributed-invalid-quay-product/helmet.yaml new file mode 100644 index 000000000..c2163084e --- /dev/null +++ b/internal/config/testdata/merge-distributed-invalid-quay-product/helmet.yaml @@ -0,0 +1,3 @@ +name: tssc +products: + - local://quay diff --git a/internal/config/testdata/merge-distributed/charts/tssc-acs/config.yaml b/internal/config/testdata/merge-distributed/charts/tssc-acs/config.yaml new file mode 100644 index 000000000..c4ffe8d73 --- /dev/null +++ b/internal/config/testdata/merge-distributed/charts/tssc-acs/config.yaml @@ -0,0 +1,4 @@ +name: Advanced Cluster Security +namespace: tssc-acs +properties: + manageSubscription: true diff --git a/internal/config/testdata/merge-distributed/config/settings.yaml b/internal/config/testdata/merge-distributed/config/settings.yaml new file mode 100644 index 000000000..59d393dde --- /dev/null +++ b/internal/config/testdata/merge-distributed/config/settings.yaml @@ -0,0 +1,3 @@ +crc: false +ci: + debug: false diff --git a/internal/config/testdata/merge-distributed/helmet.yaml b/internal/config/testdata/merge-distributed/helmet.yaml new file mode 100644 index 000000000..344bd3902 --- /dev/null +++ b/internal/config/testdata/merge-distributed/helmet.yaml @@ -0,0 +1,3 @@ +name: tssc +products: + - local://acs diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9087b53ca..9cec29bf4 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -2,7 +2,7 @@ package engine import ( "bytes" - "html/template" + "text/template" "github.com/redhat-appstudio/helmet/internal/constants" "github.com/redhat-appstudio/helmet/internal/k8s" diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index d5533f485..9fc1ece59 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -9,6 +9,7 @@ import ( o "github.com/onsi/gomega" "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/chartutil" ) // testYamlTmpl is a template to render a YAML payload based on the information @@ -76,3 +77,21 @@ func TestEngine_Render(t *testing.T) { g.Expect(err).To(o.Succeed()) g.Expect(root["catalogURL"]).To(o.Equal(product.Properties["catalogURL"])) } + +// Regression: values templates must use text/template so Sprig toJson/quote output is not +// HTML-escaped (which breaks YAML and chartutil.ReadValues — often "line 3"). +func TestEngine_Render_ToJsonParsesAsHelmValues(t *testing.T) { + g := o.NewWithT(t) + + const tmpl = ` +--- +quay: + url: {{ "https://registry.example.com:8443/api" | toJson }} +` + e := NewEngine(nil, tmpl) + payload, err := e.Render(&Variables{}) + g.Expect(err).To(o.Succeed()) + + _, err = chartutil.ReadValues(payload) + g.Expect(err).To(o.Succeed()) +} diff --git a/internal/engine/variables.go b/internal/engine/variables.go index f1b11c5d6..37c4b147a 100644 --- a/internal/engine/variables.go +++ b/internal/engine/variables.go @@ -30,7 +30,13 @@ func (v *Variables) SetInstaller(cfg *config.Config) error { products[product.KeyName()] = product } v.Installer["Products"], err = UnstructuredType(products) - return err + if err != nil { + return err + } + // Templates may reference .Installer.Integrations for backward compatibility; + // runtime integration Secrets come from the integration CLI subcommands. + v.Installer["Integrations"] = map[string]interface{}{} + return nil } func getMinorVersion(version string) (string, error) { diff --git a/internal/flags/installer.go b/internal/flags/installer.go index 68b7ed4f6..8292f9fc0 100644 --- a/internal/flags/installer.go +++ b/internal/flags/installer.go @@ -15,6 +15,6 @@ func SetValuesTmplFlag(p *pflag.FlagSet, v *string) { v, ValuesTemplateFlag, constants.ValuesFilename, - "Path to the values template file", + "Path to the default values template; a chart may use /values.yaml.tpl instead", ) } diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 7ed2e7efc..f21ff3676 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -66,7 +66,19 @@ func (i *Installer) RenderValues() error { i.logger.Debug("Preparing rendered values for Helm installation") var err error i.values, err = chartutil.ReadValues(i.valuesBytes) - return err + if err != nil { + return err + } + // Expose CLI dry-run to charts as .Values.helmet.dryRun so templates can skip + // cluster-dependent checks (e.g. lookup integration secrets not created yet). + if i.flags != nil && i.flags.DryRun { + i.values = chartutil.CoalesceTables(i.values, chartutil.Values{ + "helmet": chartutil.Values{ + "dryRun": true, + }, + }) + } + return nil } // PrintValues prints the parsed values to the console. diff --git a/internal/mcptools/configtools.go b/internal/mcptools/configtools.go index 95a68fe60..9d862b157 100644 --- a/internal/mcptools/configtools.go +++ b/internal/mcptools/configtools.go @@ -316,9 +316,9 @@ You must inform the %q argument, with the status of the product %q`, return res, nil } // Toggle the product status. - spec.Enabled = enabled + spec.Enabled = &enabled - if res = c.setProduct(ctx, cfg, name, config.Product{Enabled: enabled}); res != nil { + if res = c.setProduct(ctx, cfg, name, *spec); res != nil { return res, nil } diff --git a/internal/resolver/collection.go b/internal/resolver/collection.go index 8629cac6f..dd26fd614 100644 --- a/internal/resolver/collection.go +++ b/internal/resolver/collection.go @@ -3,10 +3,13 @@ package resolver import ( "errors" "fmt" + "path" + "path/filepath" "slices" + "strings" "github.com/redhat-appstudio/helmet/api" - "helm.sh/helm/v3/pkg/chart" + "github.com/redhat-appstudio/helmet/internal/chartfs" ) // Collection represents a collection of dependencies the Resolver can utilize. @@ -74,6 +77,21 @@ func (c *Collection) GetProductDependency(product string) (*Dependency, error) { return productDependency, nil } +// FindChartProvidingIntegration returns the first chart that declares the given +// integration in integrations-provided (e.g. "quay", "trustification"). +func (c *Collection) FindChartProvidingIntegration(integrationName string) (*Dependency, error) { + if integrationName == "" { + return nil, fmt.Errorf("%w: empty integration name", ErrDependencyNotFound) + } + for chartName, dep := range c.dependencies { + if slices.Contains(dep.IntegrationsProvided(), integrationName) { + return c.dependencies[chartName], nil + } + } + return nil, fmt.Errorf("%w: no chart provides integration %q", + ErrDependencyNotFound, integrationName) +} + // GetProductNameForIntegration searches and returns the product name by integration name. // It goes though all charts and search for annotation "integrations-provided". // If it matches integration name, then returns product name, which is from @@ -95,15 +113,55 @@ func (c *Collection) GetProductNameForIntegration(integrationName string) string return productName } +// dedupeLoadedChartsPreferChartsDir collapses multiple LoadedCharts that share the +// same Helm chart name (e.g. duplicate paths). charts/ wins so topology has +// a single Dependency per Helm release name. +func dedupeLoadedChartsPreferChartsDir(charts []chartfs.LoadedChart) []chartfs.LoadedChart { + byName := map[string][]chartfs.LoadedChart{} + for _, lc := range charts { + n := lc.Chart.Name() + byName[n] = append(byName[n], lc) + } + out := make([]chartfs.LoadedChart, 0, len(byName)) + for _, group := range byName { + if len(group) == 1 { + out = append(out, group[0]) + continue + } + preferred := path.Join("charts", group[0].Chart.Name()) + var chosen chartfs.LoadedChart + found := false + for _, lc := range group { + if filepath.ToSlash(lc.Path) == preferred { + chosen = lc + found = true + break + } + } + if !found { + slices.SortFunc(group, func(a, b chartfs.LoadedChart) int { + return strings.Compare(filepath.ToSlash(a.Path), filepath.ToSlash(b.Path)) + }) + chosen = group[0] + } + out = append(out, chosen) + } + slices.SortFunc(out, func(a, b chartfs.LoadedChart) int { + return strings.Compare(a.Chart.Name(), b.Chart.Name()) + }) + return out +} + // NewCollection creates a new Collection from the given charts. It returns an // error if there are duplicate charts and product names. -func NewCollection(_ *api.AppContext, charts []chart.Chart) (*Collection, error) { +func NewCollection(_ *api.AppContext, charts []chartfs.LoadedChart) (*Collection, error) { + charts = dedupeLoadedChartsPreferChartsDir(charts) c := &Collection{dependencies: map[string]*Dependency{}} // Stores the product names found in the slice of Helm charts. productNames := []string{} // Populating the collection with dependencies. - for _, hc := range charts { - d := NewDependency(&hc) + for _, lc := range charts { + d := NewDependencyWithChartPath(lc.Chart, lc.Path) // Asserting the weight annotation is a valid integer. if _, err := d.Weight(); err != nil { return nil, fmt.Errorf("%w: %w", ErrInvalidCollection, err) diff --git a/internal/resolver/dependency.go b/internal/resolver/dependency.go index 1ba80219e..93e2dcc07 100644 --- a/internal/resolver/dependency.go +++ b/internal/resolver/dependency.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "strconv" + "strings" "github.com/redhat-appstudio/helmet/internal/annotations" "helm.sh/helm/v3/pkg/chart" @@ -15,6 +16,7 @@ import ( type Dependency struct { chart *chart.Chart // helm chart instance namespace string // target namespace + chartPath string // chart directory relative to ChartFS (for per-chart values.yaml.tpl) } // Dependencies represents a slice of Dependency instances. @@ -25,6 +27,7 @@ func (d *Dependency) LoggerWith(logger *slog.Logger) *slog.Logger { return logger.With( "dependency-name", d.Name(), "dependency-namespace", d.Namespace(), + "dependency-chart-dir", d.ChartPath(), ) } @@ -43,6 +46,12 @@ func (d *Dependency) Namespace() string { return d.namespace } +// ChartPath returns the filesystem path of the chart directory relative to ChartFS, +// used to resolve chart-local values.yaml.tpl. +func (d *Dependency) ChartPath() string { + return d.chartPath +} + // SetNamespace sets the namespace for this dependency. func (d *Dependency) SetNamespace(namespace string) { d.namespace = namespace @@ -57,7 +66,7 @@ func (d *Dependency) getAnnotation(annotation string) string { return "" } -// DependsOn returns a slice of dependencies names from the chart's annotation. +// DependsOn returns legacy depends-on annotation only (full list before bundle split). func (d *Dependency) DependsOn() []string { dependsOn := d.getAnnotation(annotations.DependsOn) if dependsOn == "" { @@ -66,6 +75,26 @@ func (d *Dependency) DependsOn() []string { return commaSeparatedToSlice(dependsOn) } +// DependsOnGlobalCharts lists dependencies on charts under the installer charts/ directory. +func (d *Dependency) DependsOnGlobalCharts() []string { + return commaSeparatedToSlice(d.getAnnotation(annotations.DependsOnGlobalCharts)) +} + +// DependsOnBundleCharts lists sibling charts in the same bundles//charts/ directory. +func (d *Dependency) DependsOnBundleCharts() []string { + return commaSeparatedToSlice(d.getAnnotation(annotations.DependsOnBundleCharts)) +} + +// DependsOnBundles lists bundle ids (bundles/) this chart must follow in topology. +func (d *Dependency) DependsOnBundles() []string { + return commaSeparatedToSlice(d.getAnnotation(annotations.DependsOnBundles)) +} + +// BundleID returns the bundle directory id when the chart lives under bundles//charts/. +func (d *Dependency) BundleID() string { + return bundleIDFromChartPath(d.ChartPath()) +} + // Weight returns the weight of this dependency. If no weight is specified, zero // is returned. The weight must be specified as an integer value. func (d *Dependency) Weight() (int, error) { @@ -90,6 +119,19 @@ func (d *Dependency) UseProductNamespace() string { return d.getAnnotation(annotations.UseProductNamespace) } +// InstallReleaseInInstallerNamespace is true when the chart requests the Helm +// release to be installed in the installer namespace regardless of product namespace. +func (d *Dependency) InstallReleaseInInstallerNamespace() bool { + v := strings.TrimSpace(strings.ToLower( + d.getAnnotation(annotations.InstallReleaseInInstallerNamespace))) + switch v { + case "true", "1", "yes", "y": + return true + default: + return false + } +} + // IntegrationsProvided returns the integrations provided. func (d *Dependency) IntegrationsProvided() []string { provided := d.getAnnotation(annotations.IntegrationsProvided) @@ -101,16 +143,52 @@ func (d *Dependency) IntegrationsRequired() string { return d.getAnnotation(annotations.IntegrationsRequired) } +// BundleType returns the legacy bundle-type annotation (integration, product, dual). +func (d *Dependency) BundleType() string { + return d.getAnnotation(annotations.BundleType) +} + +// BundleSupport returns whether this chart declares legacy bundle-type tokens for +// integration vs product placement (integration bundle installs are no longer used; +// only product placement remains). +func (d *Dependency) BundleSupport() (integration, product bool, err error) { + return annotations.ParseBundleTypesSupported( + d.getAnnotation(annotations.BundleTypesSupported), + d.getAnnotation(annotations.BundleType), + ) +} + +// SupportsProductBundle is true when the chart may be listed under products. +func (d *Dependency) SupportsProductBundle() bool { + _, p, err := d.BundleSupport() + if err != nil { + return false + } + return p +} + // NewDependency creates a new Dependency for the Helm chart and initially using // empty target namespace. func NewDependency(hc *chart.Chart) *Dependency { - return &Dependency{chart: hc} + return NewDependencyWithChartPath(hc, "") +} + +// NewDependencyWithChartPath creates a Dependency that knows its chart directory +// on ChartFS for per-chart values templates. +func NewDependencyWithChartPath(hc *chart.Chart, chartPath string) *Dependency { + return &Dependency{chart: hc, chartPath: chartPath} } // NewDependencyWithNamespace creates a new Dependency for the Helm chart and sets // the target namespace. func NewDependencyWithNamespace(hc *chart.Chart, ns string) *Dependency { - d := NewDependency(hc) + return NewDependencyWithNamespaceAndChartPath(hc, ns, "") +} + +// NewDependencyWithNamespaceAndChartPath creates a Dependency with namespace and +// chart directory path. +func NewDependencyWithNamespaceAndChartPath(hc *chart.Chart, ns, chartPath string) *Dependency { + d := NewDependencyWithChartPath(hc, chartPath) d.SetNamespace(ns) return d } diff --git a/internal/resolver/helper.go b/internal/resolver/helper.go index 740d48e8e..f82c2bbbc 100644 --- a/internal/resolver/helper.go +++ b/internal/resolver/helper.go @@ -1,6 +1,7 @@ package resolver import ( + "path/filepath" "strings" ) @@ -23,3 +24,33 @@ func commaSeparatedToSlice(commaSeparated string) []string { } return slice } + +// bundleIDFromChartPath returns the bundles/ directory name when chartPath is +// under bundles//charts/, otherwise "". +func bundleIDFromChartPath(chartPath string) string { + chartPath = filepath.ToSlash(chartPath) + parts := strings.Split(chartPath, "/") + for i, p := range parts { + if p == "bundles" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +func dedupeOrdered(parts []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return out +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 6fe267baa..849e70ea3 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -6,14 +6,16 @@ import ( "strings" "text/tabwriter" + "github.com/redhat-appstudio/helmet/internal/annotations" "github.com/redhat-appstudio/helmet/internal/config" ) // Resolver represents the actor that resolves dependencies between charts. type Resolver struct { - cfg *config.Config // installer configuration - collection *Collection // collection of charts - topology *Topology // topology of dependencies + cfg *config.Config // installer configuration + collection *Collection // collection of charts + topology *Topology // topology of dependencies + bundleMembers map[string][]string // bundle id -> chart names (bundles//charts/) } // ErrCircularDependency reports a circular dependency. @@ -43,16 +45,28 @@ func (r *Resolver) setDependencyNamespace(d *Dependency) error { if product == "" { namespace = r.cfg.Namespace() } else { - spec, err := r.cfg.GetProduct(product) - if err != nil { - return err + spec := r.cfg.FindProduct(product) + if spec == nil { + // Chart carries product-name but that product is not under installer.products: + // release in the installer namespace. + namespace = r.cfg.Namespace() + } else { + namespace = *spec.Namespace } - namespace = *spec.Namespace } d.SetNamespace(namespace) + r.maybeInstallReleaseInInstallerNamespace(d) return nil } +// maybeInstallReleaseInInstallerNamespace overrides the dependency namespace to the +// installer namespace when the chart declares install-release-in-installer-namespace. +func (r *Resolver) maybeInstallReleaseInInstallerNamespace(d *Dependency) { + if d != nil && d.InstallReleaseInInstallerNamespace() { + d.SetNamespace(r.cfg.Namespace()) + } +} + // dependsOn checks if the chart has dependencies and resolves them. The // dependencies are prepended to the parent chart and when more dependencies are // found, they are also resolved. @@ -70,19 +84,17 @@ func (r *Resolver) dependsOn( visited[dependencyName] = true defer delete(visited, dependencyName) - for _, dependsOn := range d.DependsOn() { + for _, dependsOn := range r.effectiveDependsOn(d) { // Picking up the dependency from the collection by name. dependsOnDep, err := r.collection.Get(dependsOn) if err != nil { return err } - // Skiping when the next dependency is associated with a disabled product. + // Skip only when the product exists in config and is disabled. If the + // product is not listed in installer.products, still resolve depends-on + // so ordering stays correct; namespace uses installer namespace. if product := dependsOnDep.ProductName(); product != "" { - productSpec, err := r.cfg.GetProduct(product) - if err != nil { - return err - } - if !productSpec.Enabled { + if spec := r.cfg.FindProduct(product); spec != nil && !spec.IsActive() { continue } } @@ -110,6 +122,7 @@ func (r *Resolver) resolveEnabledProducts() error { } // Products uses the namespace specified in the configuration. d.SetNamespace(*product.Namespace) + r.maybeInstallReleaseInInstallerNamespace(d) // Product charts are added to the topology before required charts. r.topology.Append(*d) // Recursively resolving the dependencies, added before this chart. @@ -133,7 +146,7 @@ func (r *Resolver) resolveDependencies() error { // Collecting the last dependency name that is required by the current // chart (dependency), if any. requiredDependency := "" - for _, dependsOn := range d.DependsOn() { + for _, dependsOn := range r.effectiveDependsOn(&d) { // Ensure the required dependency is in the topology, when not in the // topology it is skipped. if !r.topology.Contains(dependsOn) { @@ -168,12 +181,57 @@ func (r *Resolver) resolveDependencies() error { // Resolve resolves the all dependencies in the collection to create the topology. func (r *Resolver) Resolve() error { + r.bundleMembers = r.buildBundleMembersIndex() if err := r.resolveEnabledProducts(); err != nil { return err } return r.resolveDependencies() } +func (r *Resolver) buildBundleMembersIndex() map[string][]string { + idx := map[string][]string{} + _ = r.collection.Walk(func(name string, d Dependency) error { + bid := bundleIDFromChartPath(d.ChartPath()) + if bid != "" { + idx[bid] = append(idx[bid], name) + } + return nil + }) + return idx +} + +// effectiveDependsOn merges global charts, bundle-local charts, expanded bundle +// dependencies, or the legacy depends-on annotation when no split annotations are set. +func (r *Resolver) effectiveDependsOn(d *Dependency) []string { + g := d.DependsOnGlobalCharts() + bc := d.DependsOnBundleCharts() + bb := d.DependsOnBundles() + legacy := d.getAnnotation(annotations.DependsOn) + if len(g) == 0 && len(bc) == 0 && len(bb) == 0 && legacy != "" { + return commaSeparatedToSlice(legacy) + } + var parts []string + parts = append(parts, g...) + parts = append(parts, bc...) + for _, bid := range bb { + bid = strings.TrimSpace(bid) + if bid == "" { + continue + } + parts = append(parts, r.bundleMembers[bid]...) + } + return dedupeOrdered(parts) +} + +func (r *Resolver) isActiveProduct(name string) bool { + for _, p := range r.cfg.Installer.Products { + if p.Name == name && p.IsActive() { + return true + } + } + return false +} + // Print prints the resolved topology to the writer formatted as a table. func (r *Resolver) Print(w io.Writer) { table := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) @@ -189,7 +247,7 @@ func (r *Resolver) Print(w io.Writer) { d.Name(), d.Namespace(), d.ProductName(), - strings.Join(d.DependsOn(), ", "), + strings.Join(r.effectiveDependsOn(&d), ", "), fmt.Sprintf("%d", weight), strings.Join(d.IntegrationsProvided(), ", "), d.IntegrationsRequired(), diff --git a/internal/subcmd/config.go b/internal/subcmd/config.go index a83bc312d..3fa7208ea 100644 --- a/internal/subcmd/config.go +++ b/internal/subcmd/config.go @@ -25,6 +25,9 @@ type Config struct { manager *config.ConfigMapManager // cluster configuration manager configPath string // configuration file relative path + mergedInstaller bool // when set, empty path means "load merged layout" (not an error) + loadCreate config.CreateConfigLoader + namespace string // installer's namespace create bool // create a new configuration force bool // overrides existing configuration @@ -111,10 +114,14 @@ func (c *Config) Complete(args []string) error { } // Storing the configuration file reference, when empty using the embedded // default configuration path. - if len(args) == 1 { + switch { + case len(args) == 1: c.configPath = args[0] c.log().Debug("Using local configuration file") - } else { + case c.mergedInstaller: + c.configPath = "" + c.log().Debug("Using merged installer configuration (no path argument).") + default: c.configPath = config.DefaultRelativeConfigPath c.log().Debug("Using embedded configuration file, default settings.") } @@ -123,7 +130,7 @@ func (c *Config) Complete(args []string) error { // Validate make sure all items are in place. func (c *Config) Validate() error { - if c.create && c.configPath == "" { + if c.create && c.configPath == "" && !c.mergedInstaller { return fmt.Errorf("configuration file is not informed") } if err := c.validateFlags(); err != nil { @@ -136,8 +143,18 @@ func (c *Config) Validate() error { // cluster and update when using the --force flag. func (c *Config) runCreate() error { c.log().Debug("Loading configuration from file") - cfg, err := config.NewConfigFromFile( - c.runCtx.ChartFS, c.configPath, c.namespace, c.appCtx.IdentifierName()) + + loader := c.loadCreate + if loader == nil { + loader = config.DefaultCreateConfigLoader + } + + cfg, err := loader( + c.runCtx.ChartFS, + c.configPath, + c.namespace, + c.appCtx.IdentifierName(), + ) if err != nil { return err } @@ -256,6 +273,8 @@ func NewConfig( appCtx *api.AppContext, runCtx *runcontext.RunContext, f *flags.Flags, + mergedInstaller bool, + loadCreate config.CreateConfigLoader, ) api.SubCommand { configDesc := fmt.Sprintf(` Manages installer's cluster configuration. @@ -283,10 +302,12 @@ retrieved using a unique label selector. Long: configDesc, SilenceUsage: true, }, - appCtx: appCtx, - runCtx: runCtx, - flags: f, - manager: config.NewConfigMapManager(runCtx.Kube, appCtx.Name), + appCtx: appCtx, + runCtx: runCtx, + flags: f, + manager: config.NewConfigMapManager(runCtx.Kube, appCtx.Name), + mergedInstaller: mergedInstaller, + loadCreate: loadCreate, } c.PersistentFlags(c.cmd.PersistentFlags()) diff --git a/internal/subcmd/deploy.go b/internal/subcmd/deploy.go index 7e5925224..4af708e54 100644 --- a/internal/subcmd/deploy.go +++ b/internal/subcmd/deploy.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" "log/slog" + "path" + "path/filepath" "strings" "github.com/redhat-appstudio/helmet/api" + "github.com/redhat-appstudio/helmet/internal/chartfs" "github.com/redhat-appstudio/helmet/internal/config" "github.com/redhat-appstudio/helmet/internal/flags" "github.com/redhat-appstudio/helmet/internal/installer" @@ -76,12 +79,6 @@ func (d *Deploy) Validate() error { // Run deploys the enabled dependencies listed on the configuration. func (d *Deploy) Run() error { - d.log().Debug("Reading values template file") - valuesTmpl, err := d.runCtx.ChartFS.ReadFile(d.valuesTemplatePath) - if err != nil { - return err - } - topology, err := d.topologyBuilder.Build(d.cmd.Context(), d.cfg) if err != nil { if errors.Is(err, resolver.ErrMissingIntegrations) || @@ -103,9 +100,20 @@ subcommand to configure them. For example: if d.chartPath == "" { d.log().Debug("Installing all dependencies...") deps = topology.Dependencies() + } else if bundleID, ok := parseBundleDeployArg(d.chartPath); ok { + d.log().Debug("Installing all charts in bundle...", "bundle", bundleID) + var err error + deps, err = dependenciesForBundle(d.runCtx.ChartFS, topology, bundleID) + if err != nil { + return err + } } else { d.log().Debug("Installing a single Helm chart...") - hc, err := d.runCtx.ChartFS.GetChartFiles(d.chartPath) + chartDir, err := d.runCtx.ChartFS.ResolveChartDir(d.chartPath) + if err != nil { + return err + } + hc, err := d.runCtx.ChartFS.GetChartFiles(chartDir) if err != nil { return err } @@ -129,8 +137,17 @@ subcommand to configure them. For example: i := installer.NewInstaller(d.log(), d.flags, d.runCtx.Kube, &dep, d.installerTarball) + valuesTmpl, templatePath, err := d.runCtx.ChartFS.ReadValuesTemplate( + dep.ChartPath(), + d.valuesTemplatePath, + ) + if err != nil { + return err + } + d.log().Debug("Using values template", "path", templatePath) + ctx := d.cmd.Context() - err := i.SetValues(ctx, d.cfg, string(valuesTmpl)) + err = i.SetValues(ctx, d.cfg, string(valuesTmpl)) if err != nil { return err } @@ -180,15 +197,19 @@ installed, and the dependencies to be resolved. The deployment configuration file describes the sequence of Helm charts to be applied, on the attribute '%s.dependencies[]'. -The platform configuration is rendered from the values template file -(--values-template), this configuration payload is given to all Helm charts. +The platform configuration is rendered from values templates: each chart may +supply charts//values.yaml.tpl; otherwise the root file (--values-template) +is used for that chart. The installer resources are embedded in the executable, these resources are employed by default. -A single chart can be deployed by specifying its path. E.g.: +Deploy one chart by path or chart name (charts/ and bundles/*/charts/ are resolved automatically). E.g.: %s deploy charts/%s-openshift -`, appCtx.Name, appCtx.IdentifierName(), appCtx.Name, appCtx.IdentifierName()) + %s deploy %s-tpa +Or deploy every chart in a composable bundle (in topology order): + %s deploy bundles/ +`, appCtx.Name, appCtx.IdentifierName(), appCtx.Name, appCtx.IdentifierName(), appCtx.Name, appCtx.IdentifierName(), appCtx.Name) d := &Deploy{ cmd: &cobra.Command{ @@ -207,3 +228,42 @@ A single chart can be deployed by specifying its path. E.g.: flags.SetValuesTmplFlag(d.cmd.PersistentFlags(), &d.valuesTemplatePath) return d } + +// parseBundleDeployArg returns bundleID when arg is exactly bundles/ (deploy all charts in that bundle). +func parseBundleDeployArg(arg string) (bundleID string, ok bool) { + arg = filepath.ToSlash(strings.TrimSpace(arg)) + arg = strings.TrimSuffix(arg, "/") + const p = "bundles/" + if !strings.HasPrefix(arg, p) { + return "", false + } + rest := strings.TrimPrefix(arg, p) + if rest == "" || strings.Contains(rest, "/") { + return "", false + } + return rest, true +} + +// dependenciesForBundle returns topology dependencies whose chart dirs lie under bundles//charts/, in install order. +func dependenciesForBundle(cfs *chartfs.ChartFS, topology *resolver.Topology, bundleID string) (resolver.Dependencies, error) { + prefix := path.Join("bundles", bundleID, "charts") + dirs, err := cfs.ListChartsUnder(prefix) + if err != nil { + return nil, fmt.Errorf("bundle %q: list charts: %w", bundleID, err) + } + inBundle := make(map[string]struct{}, len(dirs)) + for _, dir := range dirs { + inBundle[filepath.ToSlash(dir)] = struct{}{} + } + var out resolver.Dependencies + for _, dep := range topology.Dependencies() { + cp := filepath.ToSlash(dep.ChartPath()) + if _, ok := inBundle[cp]; ok { + out = append(out, dep) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("bundle %q: no charts from this bundle appear in the deployment topology (check config and bundle path)", bundleID) + } + return out, nil +} diff --git a/internal/subcmd/integration.go b/internal/subcmd/integration.go index 7266063ef..7114b86b6 100644 --- a/internal/subcmd/integration.go +++ b/internal/subcmd/integration.go @@ -51,15 +51,16 @@ func disableProductForIntegration( return nil // no product provides this integration } - spec, err := cfg.GetProduct(productName) - if err != nil { - return err + spec := cfg.FindProduct(productName) + if spec == nil { + return nil // product not in config (e.g. integration-only bundle) } - if !spec.Enabled { + if !spec.IsActive() { return nil // already disabled } - spec.Enabled = false + disabled := false + spec.Enabled = &disabled if err := cfg.SetProduct(productName, *spec); err != nil { return err } diff --git a/internal/subcmd/integration_test.go b/internal/subcmd/integration_test.go index 261b02d0b..8aa5a2eb8 100644 --- a/internal/subcmd/integration_test.go +++ b/internal/subcmd/integration_test.go @@ -186,17 +186,17 @@ func TestDisableProductForIntegration_ScopedDisablement(t *testing.T) { productA, err := cfg.GetProduct("Product A") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(productA.Enabled).To(gomega.BeFalse(), + g.Expect(productA.IsActive()).To(gomega.BeFalse(), "Product A should be disabled (provides acs)") productB, err := cfg.GetProduct("Product B") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(productB.Enabled).To(gomega.BeTrue(), + g.Expect(productB.IsActive()).To(gomega.BeTrue(), "Product B should remain enabled (provides quay, not touched)") productC, err := cfg.GetProduct("Product C") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(productC.Enabled).To(gomega.BeTrue(), + g.Expect(productC.IsActive()).To(gomega.BeTrue(), "Product C should remain enabled (provides nexus, not touched)") } @@ -223,7 +223,7 @@ func TestDisableProductForIntegration_NoProvidingProduct(t *testing.T) { } { spec, err := cfg.GetProduct(name) g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(spec.Enabled).To(gomega.BeTrue(), + g.Expect(spec.IsActive()).To(gomega.BeTrue(), "%s should remain enabled", name) } } @@ -246,7 +246,7 @@ func TestDisableProductForIntegration_SecretNotCreated(t *testing.T) { productA, err := cfg.GetProduct("Product A") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(productA.Enabled).To(gomega.BeTrue(), + g.Expect(productA.IsActive()).To(gomega.BeTrue(), "Product A should remain enabled (acs secret not created)") } @@ -261,7 +261,8 @@ func TestDisableProductForIntegration_AlreadyDisabled(t *testing.T) { // Disable Product A before calling. productA, err := cfg.GetProduct("Product A") g.Expect(err).ToNot(gomega.HaveOccurred()) - productA.Enabled = false + off := false + productA.Enabled = &off err = cfg.SetProduct("Product A", *productA) g.Expect(err).ToNot(gomega.HaveOccurred()) @@ -275,11 +276,11 @@ func TestDisableProductForIntegration_AlreadyDisabled(t *testing.T) { updatedA, err := cfg.GetProduct("Product A") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(updatedA.Enabled).To(gomega.BeFalse(), + g.Expect(updatedA.IsActive()).To(gomega.BeFalse(), "Product A should remain disabled") productB, err := cfg.GetProduct("Product B") g.Expect(err).ToNot(gomega.HaveOccurred()) - g.Expect(productB.Enabled).To(gomega.BeTrue(), + g.Expect(productB.IsActive()).To(gomega.BeTrue(), "Product B should remain enabled") } diff --git a/internal/subcmd/template.go b/internal/subcmd/template.go index 3a238b081..4d739fa32 100644 --- a/internal/subcmd/template.go +++ b/internal/subcmd/template.go @@ -2,6 +2,8 @@ package subcmd import ( "fmt" + "path/filepath" + "strings" "github.com/redhat-appstudio/helmet/api" "github.com/redhat-appstudio/helmet/internal/config" @@ -46,11 +48,16 @@ func (t *Template) Complete(args []string) error { return fmt.Errorf("expecting one chart, got %d", len(args)) } - hc, err := t.runCtx.ChartFS.GetChartFiles(args[0]) + chartDir, err := t.runCtx.ChartFS.ResolveChartDir(args[0]) if err != nil { return err } - t.dep = *resolver.NewDependencyWithNamespace(hc, t.namespace) + chartDir = filepath.ToSlash(strings.TrimSuffix(chartDir, "/")) + hc, err := t.runCtx.ChartFS.GetChartFiles(chartDir) + if err != nil { + return err + } + t.dep = *resolver.NewDependencyWithNamespaceAndChartPath(hc, t.namespace, chartDir) if t.cfg, err = bootstrapConfig(t.cmd.Context(), t.appCtx, t.runCtx); err != nil { return err @@ -74,7 +81,10 @@ func (t *Template) Validate() error { // Run Renders the templates. func (t *Template) Run() error { - valuesTmplPayload, err := t.runCtx.ChartFS.ReadFile(t.valuesTemplatePath) + valuesTmplPayload, _, err := t.runCtx.ChartFS.ReadValuesTemplate( + t.dep.ChartPath(), + t.valuesTemplatePath, + ) if err != nil { return fmt.Errorf("failed to read values template file: %w", err) } @@ -132,21 +142,33 @@ The installer resources are embedded in the executable, these resources are employed by default, to use local files just use the last argument with the path to the local Helm Chart. +Chart path resolution: a bare chart name resolves to charts/ when present, +otherwise FindChartDir scans bundles/*/charts/. Bundle-local charts use a +distinct Helm chart name (e.g. %s-tpa-subscriptions under bundles/tpa/charts/). +Use an explicit path to render a bundle chart so values come from +bundles//values.yaml.tpl. + Examples: # Only showing the global values as YAML. $ %s template --show-manifests=false - # Rendering only the templates of a single Helm Chart. + # Global subscriptions chart (installer/charts/). $ %s template --show-values=false charts/%s-subscriptions + # Bundle chart + bundle values (bundles/tpa/values.yaml.tpl). + $ %s template --show-values=false bundles/tpa/charts/%s-tpa-subscriptions + # Rendering all resources of a Helm Chart. $ %s template charts/%s-subscriptions`, + appCtx.IdentifierName(), appCtx.Name, appCtx.Name, appCtx.IdentifierName(), appCtx.Name, appCtx.IdentifierName(), + appCtx.Name, + appCtx.IdentifierName(), ) t := &Template{ diff --git a/test/charts/testing/values.yaml.tpl b/test/charts/testing/values.yaml.tpl new file mode 100644 index 000000000..0c8cd7669 --- /dev/null +++ b/test/charts/testing/values.yaml.tpl @@ -0,0 +1,4 @@ +# helmet-chart-specific-values-marker +# Test fixture: per-chart values template (see chartfs ReadValuesTemplate tests). +testing: + fromChartTemplate: true diff --git a/test/config.yaml b/test/config.yaml index 6857f7cec..72c16df71 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -6,18 +6,14 @@ helmet_ex: debug: false products: - name: Product C - enabled: true namespace: helmet-product-c - name: Product A - enabled: true namespace: helmet-product-a - name: Product B - enabled: true namespace: helmet-product-b properties: storageClass: standard - name: Product D - enabled: true namespace: helmet-product-d properties: catalogURL: https://github.com/redhat-appstudio/tssc-dev-multi-ci/blob/pre-release-v1.9.x/samples/all.yaml diff --git a/test/values.yaml.tpl b/test/values.yaml.tpl index a687f57a5..65fa44186 100644 --- a/test/values.yaml.tpl +++ b/test/values.yaml.tpl @@ -8,7 +8,11 @@ debug: helmet_foundation: projects: {{- range .Installer.Products }} - {{- if (and .Enabled .Namespace) }} + {{- $active := true }} + {{- if and (kindIs "bool" .Enabled) (eq .Enabled false) }} + {{- $active = false }} + {{- end }} + {{- if and $active .Namespace }} - {{ .Namespace }} {{- end }} {{- end }}