diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/annotations.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/annotations.yaml new file mode 100644 index 0000000..9cbdd86 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/annotations.yaml @@ -0,0 +1,23 @@ +category: deterministic +rationale: Simple 1.y values with only deterministic mappings. All keys should migrate cleanly. +expected_mappings: + image.registry: registry.redhat.io + image.repository: rhdh/rhdh-hub-rhel9 + image.tag: "1.10" + replicaCount: 2 + nodeSelector: + node-role.kubernetes.io/worker: "" + resources.limits.memory: 4Gi + serviceAccount.create: true + serviceAccount.name: rhdh-sa + service.type: ClusterIP + service.port: 7007 + postgresql.enabled: true + openshift.clusterRouterBase: apps.ocp.example.com + host: rhdh.apps.ocp.example.com + dynamicPlugins.includes: null + auth.backend.enabled: true + openshift.route.enabled: true + openshift.route.tls.termination: edge +expected_ambiguous_areas: [] +expected_removed: [] diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/input.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/input.yaml new file mode 100644 index 0000000..24aa32a --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/01-simple-deterministic/input.yaml @@ -0,0 +1 @@ +fixture: simple-1y-values.yaml diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/annotations.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/annotations.yaml new file mode 100644 index 0000000..ac13d29 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/annotations.yaml @@ -0,0 +1,30 @@ +category: ambiguous +rationale: > + Complex 1.y values with ingress, args, extraEnvFrom, auth secret, init containers, + networkPolicy, Lightspeed/IA, and Orchestrator. All ambiguous areas should be + flagged with MIGRATION-REVIEW markers. +expected_ambiguous_areas: + - ingress + - args + - extraEnvFrom + - authSecret + - initContainers + - networkPolicy + - intelligentAssistant + - orchestrator +expected_mappings: + image.registry: registry.redhat.io + replicaCount: 3 + deploymentAnnotations: + deployment.kubernetes.io/revision: "1" + commandOverride: null + extraEnv: null + autoscaling.enabled: true + autoscaling.maxReplicas: 10 + podDisruptionBudget.create: true + metrics.serviceMonitor.enabled: true + catalogIndex.image.tag: "1.10" +expected_removed: + - upstream.backstage.containerPorts.backend + - upstream.backstage.installDir + - upstream.diagnosticMode diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/input.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/input.yaml new file mode 100644 index 0000000..0075ed1 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/02-complex-with-ambiguous/input.yaml @@ -0,0 +1 @@ +fixture: complex-1y-values.yaml diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/annotations.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/annotations.yaml new file mode 100644 index 0000000..43ae086 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/annotations.yaml @@ -0,0 +1,8 @@ +category: removed +rationale: > + Verify that values with no 2.y equivalent are removed from output and + reported in the migration report. +expected_removed: + - upstream.backstage.containerPorts.backend + - upstream.backstage.installDir + - upstream.diagnosticMode diff --git a/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/input.yaml b/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/input.yaml new file mode 100644 index 0000000..0075ed1 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/cases/03-removed-values/input.yaml @@ -0,0 +1 @@ +fixture: complex-1y-values.yaml diff --git a/eval/rhdh-upgrade-helper/chart-migration/eval.yaml b/eval/rhdh-upgrade-helper/chart-migration/eval.yaml new file mode 100644 index 0000000..6fec35d --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/eval.yaml @@ -0,0 +1,35 @@ +name: rhdh-upgrade-helper-chart-migration +description: Verify the chart values migration script correctly transforms 1.y values to 2.y structure. + +execution: + mode: case + timeout: 120 + parallelism: 2 + +dataset: + path: cases + schema: | + Each case contains input.yaml with fixture_path and expected outcomes, + and annotations.yaml with the expected results. + +judges: + - name: deterministic_accuracy + description: All deterministic key mappings are applied correctly. + module: eval.rhdh-upgrade-helper.chart-migration.judges + function: check_deterministic_mappings + - name: ambiguous_detection + description: Ambiguous areas are flagged with MIGRATION-REVIEW markers, not silently transformed. + module: eval.rhdh-upgrade-helper.chart-migration.judges + function: check_ambiguous_detection + - name: removed_values + description: Removed values are excluded from output and reported. + module: eval.rhdh-upgrade-helper.chart-migration.judges + function: check_removed_values + - name: no_data_loss + description: No customer values are silently dropped during migration. + module: eval.rhdh-upgrade-helper.chart-migration.judges + function: check_no_data_loss + - name: valid_yaml + description: Output is valid YAML that can be parsed without errors. + module: eval.rhdh-upgrade-helper.chart-migration.judges + function: check_valid_yaml diff --git a/eval/rhdh-upgrade-helper/chart-migration/fixtures/complex-1y-values.yaml b/eval/rhdh-upgrade-helper/chart-migration/fixtures/complex-1y-values.yaml new file mode 100644 index 0000000..eb78333 --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/fixtures/complex-1y-values.yaml @@ -0,0 +1,175 @@ +# Complex 1.y values file with ambiguous areas +upstream: + backstage: + image: + registry: registry.redhat.io + repository: rhdh/rhdh-hub-rhel9 + tag: "1.10" + replicas: 3 + annotations: + deployment.kubernetes.io/revision: "1" + command: + - node + args: + - "--max-old-space-size=8192" + extraEnvVars: + - name: LOG_LEVEL + value: debug + - name: NODE_OPTIONS + value: "--max-http-header-size=32768" + extraEnvVarsSecrets: + - rhdh-github-token + - rhdh-gitlab-token + extraEnvVarsCM: + - rhdh-feature-flags + extraVolumes: + - name: custom-certs + secret: + secretName: custom-ca-bundle + extraVolumeMounts: + - name: custom-certs + mountPath: /etc/pki/tls/certs/custom + initContainers: + - name: install-dynamic-plugins + resources: + limits: + memory: 2Gi + securityContext: + runAsNonRoot: true + env: + - name: NPM_CONFIG_REGISTRY + value: https://npm.internal.example.com + - name: custom-init + image: busybox:1.36 + command: ["sh", "-c", "echo hello"] + resources: + limits: + memory: 8Gi + cpu: "4" + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + pdb: + create: true + minAvailable: 1 + containerPorts: + backend: 7007 + installDir: /opt/app-root/src + serviceAccount: + create: true + name: rhdh-sa + automountServiceAccountToken: true + service: + type: ClusterIP + ports: + backend: 7007 + annotations: + service.beta.kubernetes.io/aws-load-balancer-internal: "true" + ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + host: rhdh.example.com + path: / + extraHosts: + - name: rhdh-alt.example.com + path: / + tls: + enabled: true + secretName: rhdh-tls + extraTls: + - hosts: + - rhdh-alt.example.com + secretName: rhdh-alt-tls + networkPolicy: + enabled: true + ingressRules: + customRules: + - from: + - namespaceSelector: + matchLabels: + team: platform + egressRules: + denyConnectionsToExternal: false + postgresql: + enabled: true + image: + tag: "15.8" + auth: + existingSecret: rhdh-db-secret + metrics: + serviceMonitor: + enabled: true + interval: 30s + labels: + release: prometheus + diagnosticMode: + enabled: false + httpRoute: + enabled: false + +global: + clusterRouterBase: apps.ocp.example.com + host: rhdh.example.com + dynamic: + includes: + - dynamic-plugins.default.yaml + plugins: + - package: ./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-dynamic + disabled: false + - package: oci://registry.access.redhat.com/rhdh/backstage-community-plugin-argocd@sha256:abc123 + disabled: false + auth: + backend: + enabled: true + existingSecret: rhdh-auth-secret + lightspeed: + enabled: true + sidecar: + image: "quay.io/ai/lightspeed-service:1.5.0" + resources: + limits: + memory: 2Gi + imagePullPolicy: Always + configMaps: + - name: lightspeed-config + - name: lightspeed-stack + - name: lightspeed-profile + secret: + create: true + name: lightspeed-api-key + plugins: + - package: oci://registry.access.redhat.com/rhdh/lightspeed-plugin:1.0 + disabled: false + catalogIndex: + image: + registry: registry.redhat.io + repository: rhdh/rhdh-catalog-index + tag: "1.10" + +route: + enabled: true + host: rhdh.apps.ocp.example.com + tls: + enabled: true + termination: edge + certificate: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + +orchestrator: + sonataflowPlatform: + externalDBsecretRef: orch-db-secret + externalDBName: sonataflow + externalDBHost: postgres.internal.example.com + externalDBPort: "5432" + initContainerImage: "registry.redhat.io/rhdh/sonataflow-init:1.0" + createDBJobImage: "registry.redhat.io/rhdh/sonataflow-init:1.0" + dataIndexImage: "registry.redhat.io/rhdh/data-index:1.0" + jobServiceImage: "registry.redhat.io/rhdh/job-service:1.0" + dbCreationJobBackoffLimit: 6 + dbCreationJobTTLSecondsAfterFinished: 600 + dbCreationJobActiveDeadlineSeconds: 300 diff --git a/eval/rhdh-upgrade-helper/chart-migration/fixtures/simple-1y-values.yaml b/eval/rhdh-upgrade-helper/chart-migration/fixtures/simple-1y-values.yaml new file mode 100644 index 0000000..8945f2f --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/fixtures/simple-1y-values.yaml @@ -0,0 +1,53 @@ +# Simple 1.y values file with deterministic mappings only +upstream: + backstage: + image: + registry: registry.redhat.io + repository: rhdh/rhdh-hub-rhel9 + tag: "1.10" + replicas: 2 + podAnnotations: + sidecar.istio.io/inject: "true" + nodeSelector: + node-role.kubernetes.io/worker: "" + resources: + limits: + memory: 4Gi + cpu: "2" + requests: + memory: 2Gi + cpu: "1" + serviceAccount: + create: true + name: rhdh-sa + service: + type: ClusterIP + ports: + backend: 7007 + postgresql: + enabled: true + image: + tag: "15.8" + auth: + existingSecret: rhdh-db-secret + +global: + clusterRouterBase: apps.ocp.example.com + host: rhdh.apps.ocp.example.com + dynamic: + includes: + - dynamic-plugins.default.yaml + plugins: + - package: ./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-dynamic + disabled: false + auth: + backend: + enabled: true + value: supersecret123 + +route: + enabled: true + host: rhdh.apps.ocp.example.com + tls: + enabled: true + termination: edge diff --git a/eval/rhdh-upgrade-helper/chart-migration/judges.py b/eval/rhdh-upgrade-helper/chart-migration/judges.py new file mode 100644 index 0000000..32a171e --- /dev/null +++ b/eval/rhdh-upgrade-helper/chart-migration/judges.py @@ -0,0 +1,228 @@ +"""Judges for the chart-migration eval suite. + +Each judge receives the migration script's JSON report and the output YAML, +then checks a specific correctness property. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +SKILL_DIR = Path(__file__).resolve().parents[3] / "skills" / "rhdh-upgrade-helper" +SCRIPT = SKILL_DIR / "scripts" / "migrate-chart-values.py" +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" + + +def _run_migration(fixture_name: str) -> tuple[dict, dict, str]: + """Run the migration script on a fixture and return (report, output_data, raw_output).""" + fixture_path = FIXTURES_DIR / fixture_name + if not fixture_path.exists(): + raise FileNotFoundError(f"Fixture not found: {fixture_path}") + + result = subprocess.run( + [sys.executable, str(SCRIPT), str(fixture_path), "--json"], + capture_output=True, + text=True, + ) + report = json.loads(result.stdout) if result.stdout.strip() else {} + + result_yaml = subprocess.run( + [sys.executable, str(SCRIPT), str(fixture_path)], + capture_output=True, + text=True, + ) + raw_output = result_yaml.stdout + + # Strip comment lines for YAML parsing + yaml_lines = [line for line in raw_output.split("\n") if not line.lstrip().startswith("#")] + output_data = yaml.safe_load("\n".join(yaml_lines)) or {} + + return report, output_data, raw_output + + +def _deep_get(data: dict, dotpath: str): + """Get a value from a nested dict by dotted path.""" + keys = dotpath.split(".") + current = data + for key in keys: + if not isinstance(current, dict) or key not in current: + return None + current = current[key] + return current + + +def check_deterministic_mappings( + input_data: dict, + annotations: dict, + **kwargs, +) -> dict: + """Verify all expected deterministic mappings were applied.""" + fixture = input_data.get("fixture", "simple-1y-values.yaml") + expected_mappings = annotations.get("expected_mappings", {}) + + report, output_data, _ = _run_migration(fixture) + + failures = [] + for new_path, expected_value in expected_mappings.items(): + actual = _deep_get(output_data, new_path) + if actual is None: + failures.append(f"Missing key: {new_path}") + elif expected_value is not None and actual != expected_value: + failures.append( + f"Wrong value at {new_path}: expected {expected_value!r}, got {actual!r}" + ) + + # Verify no upstream.* keys remain in output + old_prefixes_in_output = [ + key for key in _flatten_keys(output_data) if key.startswith("upstream.") + ] + if old_prefixes_in_output: + failures.append(f"Old upstream.* keys still in output: {old_prefixes_in_output[:5]}") + + return { + "pass": len(failures) == 0, + "score": 1.0 + if not failures + else max(0, 1.0 - len(failures) / max(len(expected_mappings), 1)), + "details": "; ".join(failures) if failures else "All mappings correct", + "applied_count": report.get("summary", {}).get("total_deterministic", 0), + } + + +def check_ambiguous_detection( + input_data: dict, + annotations: dict, + **kwargs, +) -> dict: + """Verify expected ambiguous areas are flagged, not silently transformed.""" + fixture = input_data.get("fixture", "complex-1y-values.yaml") + expected_areas = annotations.get("expected_ambiguous_areas", []) + + report, _, raw_output = _run_migration(fixture) + + review_areas = [item.get("area") for item in report.get("review", [])] + + missing = [a for a in expected_areas if a not in review_areas] + markers_in_output = raw_output.count("MIGRATION-REVIEW") + + return { + "pass": len(missing) == 0, + "score": 1.0 if not missing else 1.0 - len(missing) / max(len(expected_areas), 1), + "details": f"Missing areas: {missing}" if missing else "All ambiguous areas detected", + "review_areas_found": review_areas, + "markers_in_output": markers_in_output, + } + + +def check_removed_values( + input_data: dict, + annotations: dict, + **kwargs, +) -> dict: + """Verify removed values are excluded from output and listed in report.""" + fixture = input_data.get("fixture", "complex-1y-values.yaml") + expected_removed = annotations.get("expected_removed", []) + + report, output_data, _ = _run_migration(fixture) + + removed_in_report = [item["old"] for item in report.get("removed", [])] + still_in_output = [] + for old_path in expected_removed: + if _deep_get(output_data, old_path) is not None: + still_in_output.append(old_path) + + not_reported = [r for r in expected_removed if r not in removed_in_report] + + failures = [] + if still_in_output: + failures.append(f"Removed values still in output: {still_in_output}") + if not_reported: + failures.append(f"Removed values not reported: {not_reported}") + + return { + "pass": len(failures) == 0, + "score": 1.0 if not failures else 0.5, + "details": "; ".join(failures) if failures else "All removed values handled correctly", + } + + +def check_no_data_loss( + input_data: dict, + annotations: dict, + **kwargs, +) -> dict: + """Verify no customer values are silently dropped.""" + fixture = input_data.get("fixture", "simple-1y-values.yaml") + + report, output_data, _ = _run_migration(fixture) + + summary = report.get("summary", {}) + total_accounted = ( + summary.get("total_deterministic", 0) + + summary.get("total_removed", 0) + + summary.get("total_review", 0) + + summary.get("total_unknown", 0) + ) + + # Load original to count leaf keys + fixture_path = FIXTURES_DIR / fixture + with open(fixture_path) as f: + original = yaml.safe_load(f) + original_keys = set(_flatten_keys(original or {})) + output_keys = set(_flatten_keys(output_data)) + + return { + "pass": total_accounted > 0 and len(output_keys) > 0, + "score": 1.0 if total_accounted > 0 else 0.0, + "details": f"Accounted for {total_accounted} transformations; output has {len(output_keys)} keys", + "original_key_count": len(original_keys), + "output_key_count": len(output_keys), + } + + +def check_valid_yaml( + input_data: dict, + annotations: dict, + **kwargs, +) -> dict: + """Verify the output is valid YAML.""" + fixture = input_data.get("fixture", "simple-1y-values.yaml") + + _, _, raw_output = _run_migration(fixture) + + # Strip comment lines + yaml_lines = [line for line in raw_output.split("\n") if not line.lstrip().startswith("#")] + yaml_str = "\n".join(yaml_lines) + + try: + parsed = yaml.safe_load(yaml_str) + is_valid = isinstance(parsed, dict) + except yaml.YAMLError as e: + return { + "pass": False, + "score": 0.0, + "details": f"Invalid YAML: {e}", + } + + return { + "pass": is_valid, + "score": 1.0 if is_valid else 0.0, + "details": "Valid YAML dict" if is_valid else "Output is not a YAML dict", + } + + +def _flatten_keys(data: dict, prefix: str = "") -> list[str]: + """Return all dotted key paths in a nested dict.""" + result = [] + for key, value in data.items(): + full = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + result.extend(_flatten_keys(value, full)) + else: + result.append(full) + return result diff --git a/skills/rhdh-upgrade-helper/SKILL.md b/skills/rhdh-upgrade-helper/SKILL.md index faf8bc5..7611da4 100644 --- a/skills/rhdh-upgrade-helper/SKILL.md +++ b/skills/rhdh-upgrade-helper/SKILL.md @@ -15,6 +15,7 @@ Invoke with: - Directory: `/rhdh-upgrade-helper --to 1.10 --config-path ./my-configs/` - Interactive: `/rhdh-upgrade-helper --to 1.10` - Skip-release: `/rhdh-upgrade-helper --from 1.8 --to 1.10 --config ./values.yaml` +- Chart migration: `/rhdh-upgrade-helper --from 1.10 --to 2.1 --config ./values.yaml` Arguments: `[--to X.Y] [--from X.Y] [--config /path/to/file] [--config-path /dir]` @@ -34,6 +35,8 @@ This skill uses two data sources: 3. **Known bug data** — For each plugin in your config, the skill searches the RHDHBUGS Jira project for open bugs affecting that plugin in the target release. Also queries GitHub Issues on `redhat-developer/rhdh` for community-reported upgrade issues. If Jira is not accessible, falls back to GitHub Issues and release notes only. +4. **Chart migration data** — For major version upgrades (1.y → 2.y), the chart values migration reference (`references/chart-migration-1y-2y.md`) provides deterministic key mappings and ambiguous area guidance. The migration script (`scripts/migrate-chart-values.py`) automates mechanical translations and flags areas needing AI-assisted resolution. + The skill correlates these to answer: "Of all the changes in the target release, which ones actually affect MY setup?" ### Config file @@ -52,11 +55,13 @@ All config files are scanned for embedded secrets before processing. See `refere | Condition | Workflow | |-----------|----------| +| Major version upgrade (`--from` 1.y, `--to` 2.y) with Helm values file | `workflows/chart-migration.md` (chart values migration + upgrade assessment) | | Config files resolved (via `.rhdh-upgrade-helper.yaml`, `--config`, or `--config-path`) | `workflows/full-report.md` (config-driven assessment) | | No config files resolved | `workflows/interactive.md` (ask intake questions, then assess) | | "help", "explain", "how" | `workflows/help.md` | **`--to` is always required.** If omitted, prompt for it before routing. `.rhdh-upgrade-helper.yaml` may provide it. +**Major version detection:** If the major version differs between `--from` and `--to` (e.g., 1.10 → 2.1) and a Helm values file is provided, route to `workflows/chart-migration.md` first. This handles chart values restructuring, then optionally runs `workflows/full-report.md` for the full upgrade assessment. **When no config files are resolved, always route to `workflows/interactive.md` — never produce a generic report without gathering environment context first.** @@ -73,6 +78,7 @@ All config files are scanned for embedded secrets before processing. See `refere | `references/rhdh-upgrade-helper-config.md` | `.rhdh-upgrade-helper.yaml` format, resolution order, file type auto-detection, Helm and Operator examples. | | `references/config-analysis.md` | How to parse customer config files — content-based auto-detection for Helm values, app-config, dynamic-plugins, and Backstage CR. | | `references/rhdh-architecture.md` | RHDH architecture context — what actually breaks on upgrade vs. common false positives. | +| `references/chart-migration-1y-2y.md` | Chart values migration tables for 1.y→2.y: deterministic mappings, ambiguous areas, behavioral changes, removed/new values. | | `references/release-notes/{X.Y}.md` | Per-release notes (new features, breaking changes, deprecated/removed features, known issues). One file per release. | @@ -80,6 +86,7 @@ All config files are scanned for embedded secrets before processing. See `refere | Workflow | Purpose | Data Sources Used | |----------|---------|-------------------| +| `workflows/chart-migration.md` | AI-assisted chart values migration for 1.y→2.y upgrades | Migration script + chart-migration-1y-2y reference + AI resolution | | `workflows/full-report.md` | Config-driven upgrade assessment with line-level migration steps | Config analysis + product context | | `workflows/interactive.md` | Guided Q&A to build environment profile, then runs full assessment | Intake questions + product context | | `workflows/help.md` | Explain the skill and its capabilities | None | @@ -137,4 +144,5 @@ The report is complete when: - "Does NOT Affect You" section included to reduce upgrade anxiety - RHDH Local recommended for pre-upgrade testing per `references/rhdh-local.md` - Upgrade checklist provided at end of report +- For major version upgrades: chart values migration completed via `workflows/chart-migration.md`, all ambiguous areas resolved with user confirmation, migrated values file validated with `helm template` diff --git a/skills/rhdh-upgrade-helper/references/chart-migration-1y-2y.md b/skills/rhdh-upgrade-helper/references/chart-migration-1y-2y.md new file mode 100644 index 0000000..424ab06 --- /dev/null +++ b/skills/rhdh-upgrade-helper/references/chart-migration-1y-2y.md @@ -0,0 +1,332 @@ +# Chart migration: RHDH 1.y to 2.y — skill-specific metadata + +This file contains the **skill-specific** metadata that the migration script and +AI workflow need on top of the upstream migration guide. For the complete mapping +tables (Old path → New path), behavioral changes, removed values, and new +features, read the **upstream migration guide**: + +> [upstream migration guide](https://github.com/redhat-developer/rhdh-chart/blob/release-2.1/charts/rhdh/docs/migration-from-backstage-chart.md) +> (RHIDP-16514) — authoritative source for all deterministic key mappings, +> before/after YAML examples, chart-managed defaults inventory, and behavioral +> change warnings. + +The migration script produces a **separate output file** — the original values +file is never modified, so customers can inspect and validate the draft before +using it. + +--- + +## Ambiguous areas (require AI-assisted resolution) + +These areas cannot be translated by simple key renaming. They require understanding the +customer's intent and producing a structurally different output. + +### 1. Ingress (structural reshape) + +**What changed:** The single `host`/`path` model becomes an array of host objects. + +**Old structure:** +```yaml +upstream: + ingress: + enabled: true + className: nginx + annotations: {} + host: my-rhdh.example.com + path: / + extraHosts: + - name: alt.example.com + path: /rhdh + tls: + enabled: true + secretName: rhdh-tls + extraTls: + - hosts: [alt.example.com] + secretName: alt-tls +``` + +**New structure:** +```yaml +ingress: + enabled: true + className: nginx + annotations: {} + hosts: + - host: my-rhdh.example.com + paths: + - path: / + - host: alt.example.com + paths: + - path: /rhdh + tls: + - hosts: [my-rhdh.example.com] + secretName: rhdh-tls + - hosts: [alt.example.com] + secretName: alt-tls +``` + +**Transformation rules:** +1. Move `host` + `path` into `hosts[0].host` + `hosts[0].paths[0].path` +2. Merge `extraHosts` into the `hosts[]` array; rename `name` → `host`, wrap `path` in `paths[]` +3. Convert `tls.enabled` + `tls.secretName` into `tls[0]` with `hosts: []` +4. Merge `extraTls` into the `tls[]` array + +**Why ambiguous:** The TLS-to-host association is not explicit in the old format. When +`tls.enabled: true` with a `secretName`, the script must decide which host(s) to associate +with that TLS entry. Default: associate with the primary `host` only. + +### 2. Intelligent Assistant / Lightspeed (significant restructuring) + +**What changed:** Rebranded from Lightspeed to Intelligent Assistant. Multiple structural +changes beyond simple renaming. + +**Sub-areas:** + +**a) Sidecar image (string → structured):** +```yaml +# Old: +global: + lightspeed: + sidecar: + image: "quay.io/ai/lightspeed-service:1.2.3" + +# New: +intelligentAssistant: + core: + image: + registry: quay.io + repository: ai/lightspeed-service + tag: "1.2.3" +``` +Split the image string at `/` boundaries: everything before the first `/` is the registry, +everything between first `/` and `:` is the repository, after `:` is the tag. If no tag, +omit it. If no registry prefix (e.g., `lightspeed-service:1.0`), omit registry. + +**b) ConfigMaps (array → structured):** +```yaml +# Old: array of 3 configMaps +global: + lightspeed: + configMaps: + - name: lightspeed-config + - name: lightspeed-stack + - name: lightspeed-profile + +# New: 2 structured entries +intelligentAssistant: + config: + stack: + existingConfigMap: lightspeed-stack + profile: + existingConfigMap: lightspeed-profile +``` +The separate `config.yaml` ConfigMap is no longer needed because the llama-stack configuration +is now inlined in `lightspeed-stack.yaml`. + +**Why ambiguous:** The old array was positional with no typed keys. The migration must +identify which ConfigMap is the stack config and which is the profile by name or position. +If names don't match expected patterns, flag for manual review. + +**c) Secret handling:** +```yaml +# Old: +global: + lightspeed: + secret: + create: true + name: lightspeed-secret + +# New: +intelligentAssistant: + existingSecret: lightspeed-secret +``` +The new chart does NOT create a placeholder secret. The customer must create it independently +before upgrading. Flag this as a **manual action** in the migration report. + +**d) Removed fields:** +- `global.lightspeed.initContainer.*` → removed (RAG init container gone) +- `global.lightspeed.ragVolume.*` → removed +- `global.lightspeed.sidecar.name` → hardcoded +- `global.lightspeed.sidecar.portName` → hardcoded +- `global.lightspeed.sidecar.containerPort` → hardcoded +- `global.lightspeed.runtimeVolume.name` → hardcoded +- `global.lightspeed.runtimeVolume.mountPath` → hardcoded +- `global.lightspeed.secret.optional` → removed + +**e) Plugin format:** +The new chart supports `ref://` as a convenience format to reference default catalog plugins by name (e.g., `ref://backstage-plugin-catalog-backend-module-gitlab`), but `oci://` references remain fully supported. No forced migration needed — existing `oci://` references continue to work. + +### 3. Container args (semantic choice) + +**What changed:** `upstream.backstage.args` maps to either `extraArgs` or `argsOverride`. + +- `extraArgs`: appends after the system `--config` flags. **Preferred** for most cases. +- `argsOverride`: replaces ALL arguments including system ones. Only for full control. + +**Default behavior:** Map to `extraArgs` with a review marker explaining the choice. +If the old args contain `--config` flags, suggest `argsOverride` instead since the user +was already managing config loading manually. + +### 4. Env from secrets/ConfigMaps (format change) + +**What changed:** `extraEnvVarsSecrets` and `extraEnvVarsCM` both map to `extraEnvFrom` +but the format changes from simple name strings to structured refs. + +```yaml +# Old: +upstream: + backstage: + extraEnvVarsSecrets: + - my-secret + - another-secret + extraEnvVarsCM: + - my-configmap + +# New: +extraEnvFrom: + - secretRef: + name: my-secret + - secretRef: + name: another-secret + - configMapRef: + name: my-configmap +``` + +**Why ambiguous:** Both old fields merge into one new field. The script must interleave +entries correctly and choose `secretRef` vs `configMapRef` based on source. + +### 5. Network policies (removed, replaced with defaults) + +**What changed:** `upstream.networkPolicy.*` has no equivalent. The new chart always deploys +default-deny NetworkPolicies allowing only DNS, PostgreSQL, and OpenShift ingress/monitoring. + +**Migration action:** Remove all `upstream.networkPolicy.*` keys. Flag as a **manual action**: +"Review your network connectivity requirements. If your RHDH deployment connects to external +APIs, custom sidecars, or cross-namespace services, add corresponding NetworkPolicy rules. +You can use `extraDeploy` to deploy custom NetworkPolicy resources alongside the chart. +See the chart's NetworkPolicies documentation." + +### 6. Init containers (structural change) + +**What changed:** System init containers are no longer specified as raw arrays. The +`install-dynamic-plugins` init container is configured via `dynamicPlugins.initContainer.*`. +Custom init containers use `preInitContainers` (before system) or `extraInitContainers` +(after system). + +**Migration action:** If the customer had only the dynamic-plugins init container, map its +`resources`, `securityContext`, and `env` to `dynamicPlugins.initContainer.*`. If they had +additional custom init containers in the array, move those to `preInitContainers` or +`extraInitContainers` depending on ordering intent. + +### 7. Orchestrator (structural splits) + +**What changed:** Multiple flat fields restructured into nested objects. + +```yaml +# Old: +orchestrator: + sonataflowPlatform: + externalDBsecretRef: my-db-secret + externalDBName: sonataflow + externalDBHost: postgres.example.com + externalDBPort: "5432" + initContainerImage: "registry.redhat.io/rhdh/init:1.0" + createDBJobImage: "registry.redhat.io/rhdh/init:1.0" + dataIndexImage: "registry.redhat.io/rhdh/data-index:1.0" + jobServiceImage: "registry.redhat.io/rhdh/job-service:1.0" + dbCreationJobBackoffLimit: 6 + dbCreationJobTTLSecondsAfterFinished: 600 + dbCreationJobActiveDeadlineSeconds: 300 + +# New: +orchestrator: + sonataflowPlatform: + externalDB: + existingSecret: my-db-secret + name: sonataflow + host: postgres.example.com + port: "5432" + dbCreationJob: + image: + registry: registry.redhat.io + repository: rhdh/init + tag: "1.0" + backoffLimit: 6 + ttlSecondsAfterFinished: 600 + activeDeadlineSeconds: 300 + dataIndex: + image: + registry: registry.redhat.io + repository: rhdh/data-index + tag: "1.0" + jobService: + image: + registry: registry.redhat.io + repository: rhdh/job-service + tag: "1.0" +``` + +**Why ambiguous:** Image strings must be split into `registry`/`repository`/`tag` components. +The `initContainerImage` and `createDBJobImage` may differ but merge into one `dbCreationJob.image`. + +### 8. Auth secret restructuring + +**What changed:** `global.auth.backend.existingSecret` (a string) becomes an object with +`name` and `key` fields. + +```yaml +# Old: +global: + auth: + backend: + existingSecret: my-auth-secret + +# New: +auth: + backend: + existingSecretRef: + name: my-auth-secret + key: backend-secret +``` + +The `key` defaults to `backend-secret` but may need adjustment if the customer's secret +uses a different key name. + +--- + +## Script-specific metadata + +The following sections document behavior specific to the migration script +(`scripts/migrate-chart-values.py`) and AI workflow. This metadata supplements +the upstream migration guide's `.Values.*` reference table with entries the +script needs that aren't directly derivable from the deterministic mapping tables. + +### Additional `.Values.*` rewriting entries + +The script's rewriting table is built from the deterministic mappings in the +upstream guide, plus these parent-path entries (needed because Go templates +may reference a parent object, not just leaf keys): + +| Old reference | New reference | +|---------------|---------------| +| `global.catalogIndex` | `catalogIndex` | +| `global.auth` | `auth` | +| `global.auth.backend` | `auth.backend` | +| `global.dynamic` | `dynamicPlugins` | +| `route` | `openshift.route` | +| `route.tls` | `openshift.route.tls` | + +### Decomposed fields (flagged, not rewritten) + +These fields were split from a single value into structured sub-fields. The +script flags them for manual update since a single find-and-replace is not +possible. See the upstream guide's "Go template `.Values.*` references" section +for context. + +| Old reference | Migration note | +|---------------|----------------| +| `global.lightspeed.sidecar.image` | Now `intelligentAssistant.core.image.{registry,repository,tag}` | +| `orchestrator.sonataflowPlatform.initContainerImage` | Now `orchestrator.sonataflowPlatform.dbCreationJob.image.{registry,repository,tag}` | +| `orchestrator.sonataflowPlatform.createDBJobImage` | Now `orchestrator.sonataflowPlatform.dbCreationJob.image.{registry,repository,tag}` | +| `orchestrator.sonataflowPlatform.dataIndexImage` | Now `orchestrator.sonataflowPlatform.dataIndex.image.{registry,repository,tag}` | +| `orchestrator.sonataflowPlatform.jobServiceImage` | Now `orchestrator.sonataflowPlatform.jobService.image.{registry,repository,tag}` | diff --git a/skills/rhdh-upgrade-helper/references/config-analysis.md b/skills/rhdh-upgrade-helper/references/config-analysis.md index c82a16f..ddb8b8a 100644 --- a/skills/rhdh-upgrade-helper/references/config-analysis.md +++ b/skills/rhdh-upgrade-helper/references/config-analysis.md @@ -60,14 +60,17 @@ For every config file (whether discovered by directory scan or provided individu | Content marker | Detected type | How to parse | |---|---|---| -| Contains `global.dynamic.plugins` or `upstream.backstage` | **Helm values** | Extract nested config — see "Parsing Helm Values" below | +| Contains `global.dynamic.plugins` or `upstream.backstage` | **Helm values (1.y)** | Extract nested config — see "Parsing Helm Values" below. If the target release is 2.y, route to `workflows/chart-migration.md` for values restructuring. | +| Contains top-level `dynamicPlugins.plugins` or `dynamicPlugins.includes` (without `global.dynamic` or `upstream`) | **Helm values (2.y)** | Already in 2.y structure. Parse `dynamicPlugins.plugins` directly for plugin analysis. | | Top-level `plugins:` array with `package:` entries | **Dynamic plugins config** | Parse `plugins:` array directly | | Top-level `auth:`, `catalog:`, `backend:`, or `proxy:` keys | **App-config** | Parse as root-level Backstage configuration | | `kind: Backstage` or `apiVersion: rhdh.redhat.com` | **Backstage CR (Operator)** | Extract environment facts — see "Parsing Backstage CR" below | | Top-level `services:` with an `image:` containing `rhdh` | **Compose file** | Extract RHDH image version — see "Parsing Compose Files" below | | `KEY=VALUE` pairs (no YAML structure) | **Environment file** | Parse as env vars — see `references/env-vars.md` | -When a file matches multiple markers (e.g., Helm values contain `auth:` under `upstream.backstage.appConfig`), use the most specific match. `global.dynamic.plugins` or `upstream.backstage` → Helm values takes precedence. +When a file matches multiple markers (e.g., Helm values contain `auth:` under `upstream.backstage.appConfig`), use the most specific match. `global.dynamic.plugins` or `upstream.backstage` → Helm values (1.y) takes precedence. + +**1.y vs 2.y Helm values detection:** If a values file contains `upstream.backstage` or `global.dynamic.plugins`, it is 1.y format. If it contains top-level `dynamicPlugins` without the `global.dynamic` or `upstream` wrapper, it is 2.y format. When a 1.y values file is detected and the target release is 2.y (currently only 1.10 → 2.1 is supported), the skill should route through `workflows/chart-migration.md` to migrate the values structure before proceeding with plugin and config analysis. ## Merging Multiple App-Config Files diff --git a/skills/rhdh-upgrade-helper/references/config-scoring.md b/skills/rhdh-upgrade-helper/references/config-scoring.md index 8bb8ddb..1a4c1ea 100644 --- a/skills/rhdh-upgrade-helper/references/config-scoring.md +++ b/skills/rhdh-upgrade-helper/references/config-scoring.md @@ -14,6 +14,8 @@ Start at 100 and subtract points for each finding category. The base score canno | Node.js version jump | -10 | Compare Node.js major versions. Same major = 0, +1 major = -5, +2 major = -10. | | Auth provider compatibility | -15 | Auth resolver changes between releases that affect the customer's configured providers. Each affected provider = -5, capped at -15. | | Custom plugins | -10 | Plugins in customer's config NOT in target release's `default.packages.yaml`. 1-2 = -3, 3-5 = -5, 6+ = -10. These need manual version compatibility checks. | +| Chart value key migrations | -20 | Major version upgrade only. Count of ambiguous areas in the user's config that required AI-assisted resolution. 0 = 0, 1-2 = -5, 3-4 = -10, 5+ = -20. | +| Major chart restructuring | -15 | Flat penalty when the RHDH major version differs (values structure changed from nested subchart layout to flat root-level keys). | **Formula:** `base = max(0, 100 - sum(deductions))` @@ -27,6 +29,7 @@ Amplifiers reduce the base score further. Each amplifier applies a percentage re | Deprecated auth resolver | -20% | Customer's auth config uses a resolver name deprecated between releases. Login may fail after upgrade. | | Large plugin version jump | -15% each | A configured plugin has 3+ minor version jump between releases. Higher chance of breaking API changes. Max 2 plugins counted. | | Support level downgrade | -10% each | A configured plugin's support level dropped (e.g., `generally-available` → `tech-preview` or `community`). | +| Removed chart values | -15% each | A chart value the customer uses has no 2.y equivalent (`installDir`, `containerPorts.backend`, `diagnosticMode`). | **Formula:** `amplifier_penalty = base * (1 - 1/(1 + sum(amplifier_rates)))` @@ -43,6 +46,7 @@ Mitigators add points back to the adjusted score. Each mitigator adds a flat bon | Single-version upgrade | +10 | `--from` is the release immediately before `--to` (no skipped releases). | | All plugins exist in target | +10 | Every plugin in customer's config exists in the target release's `default.packages.yaml`. | | No artifact-source migration needed | +10 | Zero configured plugins require a local/OCI/NPM source-type transition according to target `spec.dynamicArtifact`. A valid bundled local path does not prevent this mitigator. | +| Deterministic chart migration | +15 | Major version upgrade only. The migration script exited 0 — all chart value mappings were mechanical with no ambiguous areas. | **Formula:** `final = min(100, adjusted + sum(mitigator_bonuses))` diff --git a/skills/rhdh-upgrade-helper/scripts/migrate-chart-values.py b/skills/rhdh-upgrade-helper/scripts/migrate-chart-values.py new file mode 100755 index 0000000..95ece24 --- /dev/null +++ b/skills/rhdh-upgrade-helper/scripts/migrate-chart-values.py @@ -0,0 +1,1642 @@ +#!/usr/bin/env python3 +"""Migrate RHDH Helm chart values from 1.y to 2.y structure. + +Applies deterministic key mappings and flags ambiguous areas that need +AI-assisted or manual resolution. Original input files are never +modified — output is written next to the original with a versioned +suffix (e.g. values.yaml → values-2.1.yaml). + +Usage: + python3 migrate-chart-values.py values.yaml --to 2.1 # writes values-2.1.yaml next to the original + python3 migrate-chart-values.py values.yaml -o migrated.yaml # explicit output path + python3 migrate-chart-values.py values.yaml -o - # prints to stdout + cat values.yaml | python3 migrate-chart-values.py - # reads from stdin, prints to stdout + python3 migrate-chart-values.py base.yaml prod.yaml --to 2.1 # each output next to its original + +Multiple input files are migrated independently, preserving the +customer's file organization. When multiple files are given with -o, +it must be a directory (created if needed); each output keeps its +original filename with a versioned suffix. + +Exit codes: + 0 All mappings deterministic (no review needed) + 1 Has ambiguous areas needing review (MIGRATION-REVIEW markers in output) + 2 Error (bad input, missing file, etc.) + 3 Target version not supported by this script (skip, don't block) +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import re +import sys +from typing import Any + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required. Install with: pip install pyyaml", + file=sys.stderr, + ) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Supported target versions +# --------------------------------------------------------------------------- +# Only versions with tested mapping tables are supported. When the caller +# passes a --to version not in this set, the script exits 3 (skip) so +# the broader upgrade assessment can continue without blocking. +SUPPORTED_VERSIONS: set[str] = {"2.1"} + +# --------------------------------------------------------------------------- +# Deterministic mapping tables +# --------------------------------------------------------------------------- +# Each entry: (old_dotpath, new_dotpath, notes) +# A new_dotpath of None means the value is removed (no 2.y equivalent). + +DETERMINISTIC_MAPPINGS: list[tuple[str, str | None, str]] = [ + # Container image + ("upstream.backstage.image.registry", "image.registry", ""), + ("upstream.backstage.image.repository", "image.repository", ""), + ("upstream.backstage.image.tag", "image.tag", ""), + ("upstream.backstage.image.digest", "image.digest", ""), + ("upstream.backstage.image.pullPolicy", "image.pullPolicy", ""), + ("upstream.backstage.image.pullSecrets", "imagePullSecrets", "promoted to root"), + # Chart-level overrides + ("upstream.nameOverride", "nameOverride", "defaults to developer-hub"), + ("upstream.fullnameOverride", "fullnameOverride", ""), + ("upstream.commonLabels", "commonLabels", ""), + ("upstream.commonAnnotations", "commonAnnotations", ""), + ( + "upstream.extraDeploy", + "extraDeploy", + "can deploy additional resources alongside the chart (e.g., custom NetworkPolicy rules)", + ), + # Global parameters + ("global.clusterRouterBase", "openshift.clusterRouterBase", ""), + ("global.host", "host", "promoted to root"), + # App config + ("upstream.backstage.appConfig", "appConfig", "entire tree flattened"), + ("upstream.backstage.extraAppConfig", "extraAppConfig", ""), + # Authentication (simple moves — existingSecret handled in ambiguous) + ("global.auth.backend.enabled", "auth.backend.enabled", ""), + ("global.auth.backend.value", "auth.backend.value", ""), + # Dynamic plugins + ("global.dynamic.includes", "dynamicPlugins.includes", ""), + ("global.dynamic.plugins", "dynamicPlugins.plugins", ""), + # Pod scheduling, replicas, metadata + ("upstream.backstage.replicas", "replicaCount", "renamed"), + ("upstream.backstage.revisionHistoryLimit", "revisionHistoryLimit", ""), + ("upstream.backstage.strategy", "strategy", ""), + ("upstream.backstage.annotations", "deploymentAnnotations", "renamed"), + ("upstream.backstage.podAnnotations", "podAnnotations", ""), + ("upstream.backstage.podLabels", "podLabels", ""), + ("upstream.backstage.nodeSelector", "nodeSelector", ""), + ("upstream.backstage.tolerations", "tolerations", ""), + ("upstream.backstage.affinity", "affinity", ""), + ("upstream.backstage.topologySpreadConstraints", "topologySpreadConstraints", ""), + ("upstream.backstage.hostAliases", "hostAliases", ""), + ("upstream.backstage.priorityClassName", "priorityClassName", ""), + ( + "upstream.backstage.terminationGracePeriodSeconds", + "terminationGracePeriodSeconds", + "", + ), + ("upstream.backstage.lifecycleHooks", "lifecycleHooks", ""), + # Service account + ("upstream.serviceAccount.create", "serviceAccount.create", "defaults to false"), + ("upstream.serviceAccount.name", "serviceAccount.name", ""), + ("upstream.serviceAccount.annotations", "serviceAccount.annotations", ""), + ( + "upstream.serviceAccount.automountServiceAccountToken", + "serviceAccount.automount", + "renamed", + ), + ("upstream.serviceAccount.labels", "serviceAccount.labels", ""), + # Container command and env + ("upstream.backstage.command", "commandOverride", "renamed"), + ("upstream.backstage.extraEnvVars", "extraEnv", "renamed"), + # Volumes and mounts + ("upstream.backstage.extraVolumes", "extraVolumes", ""), + ("upstream.backstage.extraVolumeMounts", "extraVolumeMounts", ""), + # Sidecars + ("upstream.backstage.extraContainers", "extraContainers", ""), + # Security contexts and resources + ("upstream.backstage.podSecurityContext", "podSecurityContext", ""), + ("upstream.backstage.containerSecurityContext", "containerSecurityContext", ""), + ("upstream.backstage.resources", "resources", ""), + # Probes + ("upstream.backstage.startupProbe", "startupProbe", ""), + ("upstream.backstage.readinessProbe", "readinessProbe", ""), + ("upstream.backstage.livenessProbe", "livenessProbe", ""), + # Service + ("upstream.service.type", "service.type", ""), + ("upstream.service.ports.backend", "service.port", "flattened"), + ("upstream.service.nodePorts.backend", "service.nodePort", "flattened"), + ("upstream.service.extraPorts", "service.extraPorts", ""), + ("upstream.service.clusterIP", "service.clusterIP", ""), + ("upstream.service.loadBalancerIP", "service.loadBalancerIP", ""), + ("upstream.service.loadBalancerSourceRanges", "service.loadBalancerSourceRanges", ""), + ( + "upstream.service.externalTrafficPolicy", + "service.externalTrafficPolicy", + "", + ), + ("upstream.service.sessionAffinity", "service.sessionAffinity", ""), + ("upstream.service.annotations", "service.annotations", ""), + ("upstream.service.ipFamilyPolicy", "service.ipFamilyPolicy", ""), + ("upstream.service.ipFamilies", "service.ipFamilies", ""), + # OpenShift Route + ("route.enabled", "openshift.route.enabled", ""), + ("route.annotations", "openshift.route.annotations", ""), + ("route.host", "openshift.route.host", ""), + ("route.path", "openshift.route.path", ""), + ("route.wildcardPolicy", "openshift.route.wildcardPolicy", ""), + ("route.tls.enabled", "openshift.route.tls.enabled", ""), + ("route.tls.termination", "openshift.route.tls.termination", ""), + ("route.tls.certificate", "openshift.route.tls.certificate", ""), + ("route.tls.key", "openshift.route.tls.key", ""), + ("route.tls.caCertificate", "openshift.route.tls.caCertificate", ""), + ( + "route.tls.destinationCACertificate", + "openshift.route.tls.destinationCACertificate", + "", + ), + ( + "route.tls.insecureEdgeTerminationPolicy", + "openshift.route.tls.insecureEdgeTerminationPolicy", + "", + ), + # Autoscaling + ("upstream.backstage.autoscaling.enabled", "autoscaling.enabled", ""), + ("upstream.backstage.autoscaling.minReplicas", "autoscaling.minReplicas", ""), + ( + "upstream.backstage.autoscaling.maxReplicas", + "autoscaling.maxReplicas", + "default changed: 100 -> 3", + ), + ( + "upstream.backstage.autoscaling.targetCPUUtilizationPercentage", + "autoscaling.targetCPUUtilizationPercentage", + "", + ), + ( + "upstream.backstage.autoscaling.targetMemoryUtilizationPercentage", + "autoscaling.targetMemoryUtilizationPercentage", + "", + ), + # PDB + ("upstream.backstage.pdb.create", "podDisruptionBudget.create", ""), + ("upstream.backstage.pdb.minAvailable", "podDisruptionBudget.minAvailable", ""), + ( + "upstream.backstage.pdb.maxUnavailable", + "podDisruptionBudget.maxUnavailable", + "", + ), + # HTTPRoute + ("upstream.httpRoute.enabled", "httpRoute.enabled", ""), + ("upstream.httpRoute.labels", "httpRoute.labels", ""), + ("upstream.httpRoute.annotations", "httpRoute.annotations", ""), + ("upstream.httpRoute.parentRefs", "httpRoute.parentRefs", ""), + ("upstream.httpRoute.hostnames", "httpRoute.hostnames", ""), + ("upstream.httpRoute.rules", "httpRoute.rules", ""), + # Catalog index + ("global.catalogIndex.image.registry", "catalogIndex.image.registry", ""), + ("global.catalogIndex.image.repository", "catalogIndex.image.repository", ""), + ("global.catalogIndex.image.tag", "catalogIndex.image.tag", ""), + ("global.catalogIndex.extraImages", "catalogIndex.extraImages", ""), + # PostgreSQL + ("upstream.postgresql.enabled", "postgresql.enabled", ""), + ("upstream.postgresql.postgresqlDataDir", "postgresql.postgresqlDataDir", ""), + ( + "upstream.postgresql.serviceBindings.enabled", + "postgresql.serviceBindings.enabled", + "", + ), + ( + "upstream.postgresql.image", + "postgresql.image", + "default changed: PostgreSQL 15 -> 18", + ), + ("upstream.postgresql.auth", "postgresql.auth", ""), + ("upstream.postgresql.primary", "postgresql.primary", ""), + # Metrics + ( + "upstream.metrics.serviceMonitor.enabled", + "metrics.serviceMonitor.enabled", + "", + ), + ("upstream.metrics.serviceMonitor.path", "metrics.serviceMonitor.path", ""), + ("upstream.metrics.serviceMonitor.port", "metrics.serviceMonitor.port", ""), + ( + "upstream.metrics.serviceMonitor.interval", + "metrics.serviceMonitor.interval", + "", + ), + ( + "upstream.metrics.serviceMonitor.labels", + "metrics.serviceMonitor.labels", + "", + ), + ( + "upstream.metrics.serviceMonitor.annotations", + "metrics.serviceMonitor.annotations", + "", + ), + # Removed values + ("upstream.backstage.installDir", None, "hardcoded in new chart"), + ( + "upstream.backstage.containerPorts.backend", + None, + "hardcoded to 7007", + ), + ("upstream.diagnosticMode", None, "not supported in new chart"), + ("test.injectTestNpmrcSecret", None, "removed"), +] + +# Ambiguous area detector prefixes — keys starting with these trigger review +AMBIGUOUS_PREFIXES: dict[str, str] = { + "upstream.ingress": "ingress", + "upstream.backstage.args": "args", + "upstream.backstage.extraEnvVarsSecrets": "extraEnvFrom", + "upstream.backstage.extraEnvVarsCM": "extraEnvFrom", + "upstream.backstage.initContainers": "initContainers", + "upstream.networkPolicy": "networkPolicy", + "global.lightspeed": "intelligentAssistant", + "global.auth.backend.existingSecret": "authSecret", + "orchestrator.sonataflowPlatform.externalDBsecretRef": "orchestrator", + "orchestrator.sonataflowPlatform.externalDBName": "orchestrator", + "orchestrator.sonataflowPlatform.externalDBHost": "orchestrator", + "orchestrator.sonataflowPlatform.externalDBPort": "orchestrator", + "orchestrator.sonataflowPlatform.initContainerImage": "orchestrator", + "orchestrator.sonataflowPlatform.createDBJobImage": "orchestrator", + "orchestrator.sonataflowPlatform.dataIndexImage": "orchestrator", + "orchestrator.sonataflowPlatform.jobServiceImage": "orchestrator", + "orchestrator.sonataflowPlatform.dbCreationJobBackoffLimit": "orchestrator", + "orchestrator.sonataflowPlatform.dbCreationJobTTLSecondsAfterFinished": "orchestrator", + "orchestrator.sonataflowPlatform.dbCreationJobActiveDeadlineSeconds": "orchestrator", +} + +AMBIGUOUS_DESCRIPTIONS: dict[str, str] = { + "ingress": ( + "Ingress structure changed from single host/path to array of host objects. " + "extraHosts merge into hosts[]; TLS becomes a list of structured entries." + ), + "args": ( + "upstream.backstage.args maps to either extraArgs (appends after system --config flags, preferred) " + "or argsOverride (replaces all arguments). Review which is appropriate for your use case." + ), + "extraEnvFrom": ( + "extraEnvVarsSecrets and extraEnvVarsCM both map to extraEnvFrom. " + "Format changes from simple name strings to secretRef/configMapRef entries." + ), + "initContainers": ( + "System init containers are no longer raw arrays. Configure install-dynamic-plugins " + "via dynamicPlugins.initContainer.*; use preInitContainers/extraInitContainers for custom ones." + ), + "networkPolicy": ( + "upstream.networkPolicy.* has no equivalent. The new chart always deploys default-deny " + "NetworkPolicies. Review your connectivity requirements and add rules as needed. " + "You can use extraDeploy to deploy custom NetworkPolicy resources alongside the chart." + ), + "intelligentAssistant": ( + "Lightspeed rebranded to Intelligent Assistant with significant structural changes: " + "image string splits, configMap array restructuring, secret handling changes, " + "removed RAG init container. ref:// plugin format available as convenience shorthand " + "(oci:// still fully supported)." + ), + "authSecret": ( + "global.auth.backend.existingSecret (string) becomes auth.backend.existingSecretRef " + "with name and key fields. The key defaults to 'backend-secret'." + ), + "orchestrator": ( + "Orchestrator fields restructured: flat DB fields nest under externalDB.*, " + "image strings split into registry/repository/tag, job fields nest under dbCreationJob.*." + ), +} + +# --------------------------------------------------------------------------- +# .Values.* template reference mapping and chart-default detection +# --------------------------------------------------------------------------- + +# Build a lookup from old dotted paths to new paths for replacing Go template +# references like {{ .Values.global.host }} → {{ .Values.host }}. +_VALUES_REF_MAPPING: dict[str, str] = {} +for _old, _new, _ in DETERMINISTIC_MAPPINGS: + if _new is not None: + _VALUES_REF_MAPPING[_old] = _new +# Parent-path mappings for object-level references +_VALUES_REF_MAPPING["global.catalogIndex"] = "catalogIndex" +_VALUES_REF_MAPPING["global.auth"] = "auth" +_VALUES_REF_MAPPING["global.auth.backend"] = "auth.backend" +_VALUES_REF_MAPPING["global.dynamic"] = "dynamicPlugins" +_VALUES_REF_MAPPING["route"] = "openshift.route" +_VALUES_REF_MAPPING["route.tls"] = "openshift.route.tls" +# Pre-sort longest-first so specific paths match before shorter prefixes +_SORTED_VALUES_REFS: list[tuple[str, str]] = sorted( + _VALUES_REF_MAPPING.items(), key=lambda x: -len(x[0]) +) + +# Fields where a single old value is now decomposed into sub-fields. +_DECOMPOSED_FIELDS: dict[str, str] = { + "global.lightspeed.sidecar.image": ( + "now decomposed into intelligentAssistant.core.image.{registry,repository,tag}" + ), + "orchestrator.sonataflowPlatform.initContainerImage": ( + "now decomposed into orchestrator.sonataflowPlatform.dbCreationJob.image.{registry,repository,tag}" + ), + "orchestrator.sonataflowPlatform.createDBJobImage": ( + "now decomposed into orchestrator.sonataflowPlatform.dbCreationJob.image.{registry,repository,tag}" + ), + "orchestrator.sonataflowPlatform.dataIndexImage": ( + "now decomposed into orchestrator.sonataflowPlatform.dataIndex.image.{registry,repository,tag}" + ), + "orchestrator.sonataflowPlatform.jobServiceImage": ( + "now decomposed into orchestrator.sonataflowPlatform.jobService.image.{registry,repository,tag}" + ), +} + +# Chart-managed defaults — volumes, mounts, env vars, and init containers that +# the 2.y chart creates unconditionally. Entries in extra* that match these are +# removed deterministically. Source: templates/_backstage-pod-template.tpl. +_UNCONDITIONAL_VOLUME_NAMES: set[str] = { + "dynamic-plugins-root", + "dynamic-plugins", + "dynamic-plugins-npmrc", + "dynamic-plugins-registry-auth", + "npmcacache", + "extensions-catalog", + "temp", +} +_UNCONDITIONAL_MOUNT_NAMES: set[str] = _UNCONDITIONAL_VOLUME_NAMES +_UNCONDITIONAL_MOUNT_PATHS: set[str] = { + "/opt/app-root/src/dynamic-plugins-root", + "/dynamic-plugins-root", + "/opt/app-root/src/dynamic-plugins.yaml", + "/opt/app-root/src/.npmrc.dynamic-plugins", + "/opt/app-root/src/.npmrc.d", + "/opt/app-root/src/.config/containers", + "/opt/app-root/src/.npm/_cacache", + "/extensions", + "/tmp", +} +_UNCONDITIONAL_ENV_NAMES: set[str] = { + "APP_CONFIG_backend_listen_port", + "NPM_CONFIG_USERCONFIG", +} +_UNCONDITIONAL_INIT_CONTAINER_NAMES: set[str] = { + "install-dynamic-plugins", +} + +# Conditional chart-managed defaults — created only when certain features are +# enabled. These are flagged for review rather than removed automatically. +_CONDITIONAL_VOLUME_NAMES: set[str] = { + "backstage-app-config", + "lightspeed-data", + "lightspeed-config-stack", + "lightspeed-config-profile", +} +_CONDITIONAL_MOUNT_PATHS: set[str] = { + "/opt/app-root/src/app-config-from-configmap.yaml", +} +_CONDITIONAL_ENV_NAMES: set[str] = { + "BACKEND_SECRET", + "POSTGRES_HOST", + "POSTGRES_PORT", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "APP_CONFIG_app_baseUrl", + "APP_CONFIG_backend_baseUrl", + "APP_CONFIG_backend_cors_origin", +} +_CONDITIONAL_INIT_CONTAINER_NAMES: set[str] = { + "wait-for-db", +} + + +# --------------------------------------------------------------------------- +# YAML helpers +# --------------------------------------------------------------------------- + + +def deep_get(data: dict, dotpath: str) -> tuple[Any, bool]: + """Get a value from a nested dict by dotted path. Returns (value, found).""" + keys = dotpath.split(".") + current = data + for key in keys: + if not isinstance(current, dict) or key not in current: + return None, False + current = current[key] + return current, True + + +def deep_set(data: dict, dotpath: str, value: Any) -> None: + """Set a value in a nested dict by dotted path, creating intermediate dicts.""" + keys = dotpath.split(".") + current = data + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + current[key] = {} + current = current[key] + current[keys[-1]] = value + + +def deep_delete(data: dict, dotpath: str) -> bool: + """Delete a value from a nested dict by dotted path. Returns True if deleted.""" + keys = dotpath.split(".") + current = data + parents: list[tuple[dict, str]] = [] + for key in keys[:-1]: + if not isinstance(current, dict) or key not in current: + return False + parents.append((current, key)) + current = current[key] + if not isinstance(current, dict) or keys[-1] not in current: + return False + del current[keys[-1]] + # Clean up empty parent dicts + for parent, key in reversed(parents): + if isinstance(parent[key], dict) and not parent[key]: + del parent[key] + else: + break + return True + + +def flatten_keys(data: dict, prefix: str = "") -> list[str]: + """Return all dotted key paths in a nested dict.""" + result = [] + for key, value in data.items(): + full = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + result.extend(flatten_keys(value, full)) + else: + result.append(full) + return result + + +# --------------------------------------------------------------------------- +# Ambiguous area handlers +# --------------------------------------------------------------------------- + + +def handle_ingress(old_data: dict) -> tuple[dict, list[str]]: + """Transform 1.y ingress to 2.y structure.""" + ingress_data, found = deep_get(old_data, "upstream.ingress") + if not found or not isinstance(ingress_data, dict): + return {}, [] + + new_ingress: dict[str, Any] = {} + warnings: list[str] = [] + + for simple_key in ("enabled", "className", "annotations"): + if simple_key in ingress_data: + new_ingress[simple_key] = ingress_data[simple_key] + + hosts: list[dict] = [] + primary_host = ingress_data.get("host") + primary_path = ingress_data.get("path", "/") + if primary_host: + hosts.append( + { + "host": primary_host, + "paths": [{"path": primary_path}], + } + ) + + extra_hosts = ingress_data.get("extraHosts", []) + if isinstance(extra_hosts, list): + for eh in extra_hosts: + if isinstance(eh, dict): + hosts.append( + { + "host": eh.get("name", eh.get("host", "")), + "paths": [{"path": eh.get("path", "/")}], + } + ) + + if hosts: + new_ingress["hosts"] = hosts + + tls_entries: list[dict] = [] + old_tls = ingress_data.get("tls", {}) + if isinstance(old_tls, dict): + if old_tls.get("enabled") and old_tls.get("secretName"): + tls_hosts = [primary_host] if primary_host else [] + tls_entries.append( + { + "hosts": tls_hosts, + "secretName": old_tls["secretName"], + } + ) + if not primary_host: + warnings.append( + "TLS enabled but no primary host found to associate with the TLS entry" + ) + elif isinstance(old_tls, list): + tls_entries.extend(old_tls) + + extra_tls = ingress_data.get("extraTls", []) + if isinstance(extra_tls, list): + tls_entries.extend(extra_tls) + + if tls_entries: + new_ingress["tls"] = tls_entries + + return new_ingress, warnings + + +def handle_extra_env_from(old_data: dict) -> list[dict] | None: + """Merge extraEnvVarsSecrets and extraEnvVarsCM into extraEnvFrom.""" + entries: list[dict] = [] + + secrets, found_s = deep_get(old_data, "upstream.backstage.extraEnvVarsSecrets") + if found_s and isinstance(secrets, list): + for name in secrets: + if isinstance(name, str): + entries.append({"secretRef": {"name": name}}) + elif isinstance(name, dict): + entries.append(name) + + cms, found_c = deep_get(old_data, "upstream.backstage.extraEnvVarsCM") + if found_c and isinstance(cms, list): + for name in cms: + if isinstance(name, str): + entries.append({"configMapRef": {"name": name}}) + elif isinstance(name, dict): + entries.append(name) + + return entries if entries else None + + +def handle_args(old_data: dict) -> tuple[str, Any, str]: + """Decide whether args should map to extraArgs or argsOverride.""" + args, found = deep_get(old_data, "upstream.backstage.args") + if not found: + return "", None, "" + + if isinstance(args, list) and any(isinstance(a, str) and "--config" in a for a in args): + return ( + "argsOverride", + args, + ( + "Your args contain --config flags, suggesting you manage config loading manually. " + "Using argsOverride (replaces all arguments). Verify this is correct." + ), + ) + + return ( + "extraArgs", + args, + ( + "Using extraArgs (appends after system --config flags). " + "If you need full argument control, change to argsOverride." + ), + ) + + +def handle_auth_secret(old_data: dict) -> dict | None: + """Transform existingSecret string to existingSecretRef object.""" + secret, found = deep_get(old_data, "global.auth.backend.existingSecret") + if not found: + return None + if isinstance(secret, str): + return {"name": secret, "key": "backend-secret"} + return None + + +def split_image_string(image: str) -> dict[str, str]: + """Split a container image string into registry/repository/tag components.""" + result: dict[str, str] = {} + tag_part = "" + repo_part = image + + if ":" in image and not image.startswith("sha256:"): + repo_part, tag_part = image.rsplit(":", 1) + result["tag"] = tag_part + + parts = repo_part.split("/") + if len(parts) >= 3: + result["registry"] = parts[0] + result["repository"] = "/".join(parts[1:]) + elif len(parts) == 2: + if "." in parts[0] or ":" in parts[0]: + result["registry"] = parts[0] + result["repository"] = parts[1] + else: + result["repository"] = repo_part + else: + result["repository"] = repo_part + + return result + + +def handle_lightspeed(old_data: dict) -> tuple[dict, list[str]]: + """Transform global.lightspeed to intelligentAssistant.""" + ls_data, found = deep_get(old_data, "global.lightspeed") + if not found or not isinstance(ls_data, dict): + return {}, [] + + ia: dict[str, Any] = {} + warnings: list[str] = [] + + if "enabled" in ls_data: + ia["enabled"] = ls_data["enabled"] + + if "plugins" in ls_data: + ia["plugins"] = ls_data["plugins"] + warnings.append( + "The new chart supports ref:// as a convenience shorthand for default catalog plugins. " + "Your existing oci:// references still work — no migration needed, but you may " + "optionally simplify them to ref:// format." + ) + + sidecar = ls_data.get("sidecar", {}) + if isinstance(sidecar, dict): + core: dict[str, Any] = {} + if "image" in sidecar and isinstance(sidecar["image"], str): + core["image"] = split_image_string(sidecar["image"]) + for old_key, new_key in [ + ("resources", "resources"), + ("securityContext", "securityContext"), + ("command", "commandOverride"), + ("args", "argsOverride"), + ("env", "extraEnv"), + ("imagePullPolicy", "imagePullPolicy"), + ]: + if old_key in sidecar: + core[new_key] = sidecar[old_key] + if core: + ia["core"] = core + + rv = ls_data.get("runtimeVolume", {}) + if isinstance(rv, dict): + new_rv: dict[str, Any] = {} + for k in ("type", "emptyDir", "persistentVolumeClaim"): + if k in rv: + new_rv[k] = rv[k] + if new_rv: + ia["runtimeVolume"] = new_rv + + config_maps = ls_data.get("configMaps", []) + if isinstance(config_maps, list) and config_maps: + config: dict[str, Any] = {} + for cm in config_maps: + if not isinstance(cm, dict) or "name" not in cm: + continue + name = cm["name"] + if "stack" in name.lower(): + config.setdefault("stack", {})["existingConfigMap"] = name + elif "profile" in name.lower(): + config.setdefault("profile", {})["existingConfigMap"] = name + if len(config_maps) > len(config): + warnings.append( + "Could not automatically classify all Lightspeed configMaps. " + "Review intelligentAssistant.config entries." + ) + if config: + ia["config"] = config + + secret = ls_data.get("secret", {}) + if isinstance(secret, dict) and secret.get("name"): + ia["existingSecret"] = secret["name"] + warnings.append( + "The new chart does NOT create a placeholder secret. " + "You must create the secret independently before upgrading." + ) + + removed_keys = [ + "initContainer", + "ragVolume", + "secret.optional", + "sidecar.name", + "sidecar.portName", + "sidecar.containerPort", + "runtimeVolume.name", + "runtimeVolume.mountPath", + ] + for rk in removed_keys: + val, exists = deep_get(ls_data, rk) + if exists: + warnings.append( + f"global.lightspeed.{rk} is removed in 2.y (hardcoded or no longer needed)" + ) + + return ia, warnings + + +def handle_orchestrator(old_data: dict) -> tuple[dict, list[str]]: + """Transform orchestrator flat fields to nested structure.""" + orch, found = deep_get(old_data, "orchestrator.sonataflowPlatform") + if not found or not isinstance(orch, dict): + return {}, [] + + new_sfp: dict[str, Any] = {} + warnings: list[str] = [] + + ext_db: dict[str, Any] = {} + db_field_map = { + "externalDBsecretRef": "existingSecret", + "externalDBName": "name", + "externalDBHost": "host", + "externalDBPort": "port", + } + for old_key, new_key in db_field_map.items(): + if old_key in orch: + ext_db[new_key] = orch[old_key] + if ext_db: + new_sfp["externalDB"] = ext_db + + db_job: dict[str, Any] = {} + for img_key in ("initContainerImage", "createDBJobImage"): + if img_key in orch and isinstance(orch[img_key], str): + parsed = split_image_string(orch[img_key]) + if "image" in db_job and db_job["image"] != parsed: + warnings.append( + f"initContainerImage and createDBJobImage differ but merge into " + f"dbCreationJob.image. Using {img_key} value." + ) + db_job["image"] = parsed + + job_field_map = { + "dbCreationJobBackoffLimit": "backoffLimit", + "dbCreationJobTTLSecondsAfterFinished": "ttlSecondsAfterFinished", + "dbCreationJobActiveDeadlineSeconds": "activeDeadlineSeconds", + } + for old_key, new_key in job_field_map.items(): + if old_key in orch: + db_job[new_key] = orch[old_key] + if db_job: + new_sfp["dbCreationJob"] = db_job + + for img_field, nest_key in [ + ("dataIndexImage", "dataIndex"), + ("jobServiceImage", "jobService"), + ]: + if img_field in orch and isinstance(orch[img_field], str): + new_sfp[nest_key] = {"image": split_image_string(orch[img_field])} + + # Preserve any keys not covered by the migration + migrated_keys = ( + set(db_field_map) + | {"initContainerImage", "createDBJobImage"} + | set(job_field_map) + | {"dataIndexImage", "jobServiceImage"} + ) + for k, v in orch.items(): + if k not in migrated_keys: + new_sfp[k] = v + + return new_sfp, warnings + + +def handle_init_containers(old_data: dict) -> tuple[dict, list[str]]: + """Transform initContainers array to structured config.""" + containers, found = deep_get(old_data, "upstream.backstage.initContainers") + if not found or not isinstance(containers, list): + return {}, [] + + result: dict[str, Any] = {} + warnings: list[str] = [] + extra: list[dict] = [] + + for i, container in enumerate(containers): + if not isinstance(container, dict): + extra.append(container) + continue + name = container.get("name", "") + if "dynamic-plugin" in name or "install-dynamic" in name or i == 0: + dp_init: dict[str, Any] = {} + for k in ("resources", "securityContext"): + if k in container: + dp_init[k] = container[k] + if "env" in container: + dp_init["extraEnv"] = container["env"] + if dp_init: + result["dynamicPlugins"] = {"initContainer": dp_init} + else: + extra.append(container) + + if extra: + result["extraInitContainers"] = extra + warnings.append( + f"Moved {len(extra)} custom init container(s) to extraInitContainers. " + "If any should run before system init containers, move them to preInitContainers." + ) + + return result, warnings + + +# --------------------------------------------------------------------------- +# Core migration engine +# --------------------------------------------------------------------------- + + +class MigrationReport: + """Tracks all transformations and review items.""" + + def __init__(self) -> None: + self.applied: list[dict[str, str]] = [] + self.removed: list[dict[str, str]] = [] + self.review: list[dict[str, str]] = [] + self.warnings: list[str] = [] + self.unknown_upstream_keys: list[str] = [] + self.commented_defaults: dict[str, list[Any]] = {} + + @property + def has_review_items(self) -> bool: + return bool(self.review) or bool(self.unknown_upstream_keys) + + def to_dict(self) -> dict: + return { + "applied": self.applied, + "removed": self.removed, + "review": self.review, + "warnings": self.warnings, + "unknown_upstream_keys": self.unknown_upstream_keys, + "summary": { + "total_deterministic": len(self.applied), + "total_removed": len(self.removed), + "total_review": len(self.review), + "total_warnings": len(self.warnings), + "total_unknown": len(self.unknown_upstream_keys), + "needs_review": self.has_review_items, + }, + } + + +def _scan_janus_refs( + data: Any, path: str, pattern: re.Pattern[str], report: MigrationReport +) -> None: + """Recursively scan values for Helm template references to janus-idp.""" + if isinstance(data, dict): + for key, value in data.items(): + child = f"{path}.{key}" if path else key + _scan_janus_refs(value, child, pattern, report) + elif isinstance(data, list): + for i, item in enumerate(data): + _scan_janus_refs(item, f"{path}[{i}]", pattern, report) + elif isinstance(data, str): + matches = pattern.findall(data) + if matches: + unique = sorted(set(matches)) + replacements = ", ".join(f'"{m}" → "{m.replace("janus-idp", "rhdh")}"' for m in unique) + report.review.append( + { + "area": "janusIdpTemplate", + "description": ( + f"{path} references internal chart template(s): " + f"{replacements}. These are internal to the chart " + f"and may change between versions. Inspect the " + f"chart's _helpers.tpl to confirm the current names " + f"(pull the chart or ask the upgrade helper to check " + f"for you)." + ), + "mapped_to": path, + } + ) + + +def _fix_values_refs(data: Any, path: str, report: MigrationReport) -> Any: + """Recursively replace .Values.global.*/upstream.*/route.* Go template refs.""" + if isinstance(data, dict): + return { + k: _fix_values_refs(v, f"{path}.{k}" if path else k, report) for k, v in data.items() + } + if isinstance(data, list): + return [_fix_values_refs(item, f"{path}[{i}]", report) for i, item in enumerate(data)] + if not isinstance(data, str) or ".Values." not in data: + return data + + modified = data + # Flag decomposed fields first (before replacements eat the match) + for old_ref, explanation in _DECOMPOSED_FIELDS.items(): + pat = re.compile(r"\.Values\." + re.escape(old_ref) + r"(?![.\w])") + if pat.search(modified): + report.review.append( + { + "area": "valuesRef", + "description": ( + f"{path} references .Values.{old_ref} which is " + f"{explanation}. Update the reference to the specific " + f"sub-field you need." + ), + "mapped_to": path, + } + ) + + # Replace known mappings (longest paths first) + for old_path, new_path in _SORTED_VALUES_REFS: + old_pat = r"\.Values\." + re.escape(old_path) + r"(?![.\w])" + new_val = f".Values.{new_path}" + new_str, count = re.subn(old_pat, new_val, modified) + if count: + modified = new_str + report.warnings.append( + f"Updated template reference in {path}: .Values.{old_path} → .Values.{new_path}" + ) + + # Flag any remaining old-style references that weren't mapped + remaining = re.findall(r"\.Values\.(global\.\w[\w.]*|upstream\.\w[\w.]*)", modified) + for ref in sorted(set(remaining)): + report.review.append( + { + "area": "valuesRef", + "description": ( + f"{path} references .Values.{ref} which was not " + f"automatically mapped. Update manually to the 2.y " + f"equivalent." + ), + "mapped_to": path, + } + ) + return modified + + +def _strip_extra_defaults(new_data: dict, report: MigrationReport) -> None: + """Comment out unconditional chart defaults in extra* fields; flag conditional ones.""" + + def _partition_list( + key: str, + items: list, + name_field: str, + unconditional_names: set[str], + conditional_names: set[str], + path_field: str | None, + unconditional_paths: set[str], + conditional_paths: set[str], + ) -> list: + kept: list = [] + for item in items: + if not isinstance(item, dict): + kept.append(item) + continue + name = item.get(name_field, "") + path = item.get(path_field, "") if path_field else "" + if name in unconditional_names or path in unconditional_paths: + report.commented_defaults.setdefault(key, []).append(item) + elif name in conditional_names or path in conditional_paths: + report.review.append( + { + "area": "extraDefaults", + "description": ( + f"{key} entry '{name}' may duplicate a conditional " + f"chart default. Verify it is still needed." + ), + "mapped_to": key, + } + ) + kept.append(item) + else: + kept.append(item) + return kept + + def _process( + key: str, + name_field: str, + u_names: set[str], + c_names: set[str], + path_field: str | None = None, + u_paths: set[str] | None = None, + c_paths: set[str] | None = None, + ) -> None: + items = new_data.get(key, []) + if not isinstance(items, list) or not items: + return + kept = _partition_list( + key, + items, + name_field, + u_names, + c_names, + path_field, + u_paths or set(), + c_paths or set(), + ) + if kept: + new_data[key] = kept + else: + del new_data[key] + + _process("extraVolumes", "name", _UNCONDITIONAL_VOLUME_NAMES, _CONDITIONAL_VOLUME_NAMES) + _process( + "extraVolumeMounts", + "name", + _UNCONDITIONAL_MOUNT_NAMES, + _CONDITIONAL_VOLUME_NAMES, + "mountPath", + _UNCONDITIONAL_MOUNT_PATHS, + _CONDITIONAL_MOUNT_PATHS, + ) + _process("extraEnv", "name", _UNCONDITIONAL_ENV_NAMES, _CONDITIONAL_ENV_NAMES) + for ic_key in ("extraInitContainers", "preInitContainers"): + _process( + ic_key, "name", _UNCONDITIONAL_INIT_CONTAINER_NAMES, _CONDITIONAL_INIT_CONTAINER_NAMES + ) + + +def migrate(old_data: dict) -> tuple[dict, MigrationReport]: + """Apply all migrations to old_data and return (new_data, report).""" + data = copy.deepcopy(old_data) + new_data: dict[str, Any] = {} + report = MigrationReport() + + # Track which old paths we've handled + handled_prefixes: set[str] = set() + + # 1. Apply deterministic mappings + for old_path, new_path, notes in DETERMINISTIC_MAPPINGS: + value, found = deep_get(data, old_path) + if not found: + continue + if new_path is None: + report.removed.append( + { + "old": old_path, + "notes": notes, + } + ) + else: + deep_set(new_data, new_path, value) + report.applied.append( + { + "old": old_path, + "new": new_path, + "notes": notes, + } + ) + deep_delete(data, old_path) + handled_prefixes.add(old_path) + + # 2. Handle ambiguous areas + # Ingress + ingress_data, found = deep_get(data, "upstream.ingress") + if found: + new_ingress, ing_warnings = handle_ingress(data) + if new_ingress: + existing = new_data.get("ingress", {}) + existing.update(new_ingress) + new_data["ingress"] = existing + report.review.append( + { + "area": "ingress", + "description": AMBIGUOUS_DESCRIPTIONS["ingress"], + "original_keys": [ + k for k in flatten_keys({"upstream": {"ingress": ingress_data}}) + ], + } + ) + report.warnings.extend(ing_warnings) + deep_delete(data, "upstream.ingress") + handled_prefixes.add("upstream.ingress") + + # Args + args_key, args_value, args_note = handle_args(data) + if args_key and args_value is not None: + deep_set(new_data, args_key, args_value) + report.review.append( + { + "area": "args", + "description": args_note, + "mapped_to": args_key, + } + ) + deep_delete(data, "upstream.backstage.args") + handled_prefixes.add("upstream.backstage.args") + + # ExtraEnvFrom + env_from = handle_extra_env_from(data) + if env_from is not None: + existing_env_from = new_data.get("extraEnvFrom", []) + if isinstance(existing_env_from, list): + existing_env_from.extend(env_from) + else: + existing_env_from = env_from + new_data["extraEnvFrom"] = existing_env_from + report.review.append( + { + "area": "extraEnvFrom", + "description": AMBIGUOUS_DESCRIPTIONS["extraEnvFrom"], + } + ) + deep_delete(data, "upstream.backstage.extraEnvVarsSecrets") + deep_delete(data, "upstream.backstage.extraEnvVarsCM") + handled_prefixes.add("upstream.backstage.extraEnvVarsSecrets") + handled_prefixes.add("upstream.backstage.extraEnvVarsCM") + + # Auth secret + auth_ref = handle_auth_secret(data) + if auth_ref is not None: + deep_set(new_data, "auth.backend.existingSecretRef", auth_ref) + report.review.append( + { + "area": "authSecret", + "description": AMBIGUOUS_DESCRIPTIONS["authSecret"], + } + ) + deep_delete(data, "global.auth.backend.existingSecret") + handled_prefixes.add("global.auth.backend.existingSecret") + + # Init containers + init_result, init_warnings = handle_init_containers(data) + if init_result: + for k, v in init_result.items(): + if k in new_data and isinstance(new_data[k], dict) and isinstance(v, dict): + new_data[k].update(v) + else: + new_data[k] = v + report.review.append( + { + "area": "initContainers", + "description": AMBIGUOUS_DESCRIPTIONS["initContainers"], + } + ) + report.warnings.extend(init_warnings) + deep_delete(data, "upstream.backstage.initContainers") + handled_prefixes.add("upstream.backstage.initContainers") + + # NetworkPolicy + np_data, found = deep_get(data, "upstream.networkPolicy") + if found: + report.review.append( + { + "area": "networkPolicy", + "description": AMBIGUOUS_DESCRIPTIONS["networkPolicy"], + "removed_keys": flatten_keys({"upstream": {"networkPolicy": np_data}}), + } + ) + deep_delete(data, "upstream.networkPolicy") + handled_prefixes.add("upstream.networkPolicy") + + # Lightspeed / Intelligent Assistant + ia_data, ia_warnings = handle_lightspeed(data) + if ia_data: + new_data["intelligentAssistant"] = ia_data + report.review.append( + { + "area": "intelligentAssistant", + "description": AMBIGUOUS_DESCRIPTIONS["intelligentAssistant"], + } + ) + report.warnings.extend(ia_warnings) + deep_delete(data, "global.lightspeed") + handled_prefixes.add("global.lightspeed") + + # Orchestrator + orch_result, orch_warnings = handle_orchestrator(data) + if orch_result: + deep_set(new_data, "orchestrator.sonataflowPlatform", orch_result) + report.review.append( + { + "area": "orchestrator", + "description": AMBIGUOUS_DESCRIPTIONS["orchestrator"], + } + ) + report.warnings.extend(orch_warnings) + # Remove only the migrated keys from orchestrator + for key in list(AMBIGUOUS_PREFIXES): + if key.startswith("orchestrator."): + deep_delete(data, key) + handled_prefixes.add("orchestrator.sonataflowPlatform") + + # 2b. Post-migration warnings for image and air-gapped behavior + # Handle digest/tag interaction for all image references. + # The downstream chart ships images with explicit digests. When any of + # registry, repository, or tag is explicitly set, clear the digest to + # prevent the chart's default digest from being merged by Helm. + # Scan all *.image.{tag,registry,repository} paths dynamically. + all_keys = flatten_keys(new_data) + image_prefixes: set[str] = set() + for k in all_keys: + if k.endswith((".image.tag", ".image.registry", ".image.repository")): + image_prefixes.add(k.rsplit(".", 1)[0]) + elif k in ("image.tag", "image.registry", "image.repository"): + image_prefixes.add("image") + for prefix in sorted(image_prefixes): + digest_path = f"{prefix}.digest" + digest_val, has_digest = deep_get(new_data, digest_path) + if has_digest and digest_val == "": + continue + deep_set(new_data, digest_path, "") + overrides = [] + for field in ("registry", "repository", "tag"): + val, has = deep_get(new_data, f"{prefix}.{field}") + if has and val: + overrides.append(f"{field}={val}") + report.review.append( + { + "area": "imageDigest", + "description": ( + f"{prefix} has custom overrides ({', '.join(overrides)}). " + f'Set {digest_path} to "" to prevent the chart\'s default ' + f"digest from being merged by Helm. Verify this is correct, " + f"or set the digest to match your image." + ), + "mapped_to": digest_path, + } + ) + + # Warn about air-gapped plugin limitation + global_registry, has_gr = deep_get(new_data, "global.imageRegistry") + if has_gr and global_registry: + report.warnings.append( + "global.imageRegistry is set (air-gapped/disconnected environment). " + "Note: this applies to chart-managed container images only, NOT to dynamic " + "plugin references (oci:// or ref:// in dynamicPlugins.plugins). Plugin OCI " + "images must be mirrored separately and their references updated individually." + ) + + # Flag Helm template references to "janus-idp" — the 2.y chart renamed + # internal templates from janus-idp.* to rhdh.*. + _JANUS_IDP_RE = re.compile(r"janus-idp\.\w+") + _scan_janus_refs(new_data, "", _JANUS_IDP_RE, report) + + # 2c. Fix .Values.global.* / .Values.upstream.* Go template references + new_data = _fix_values_refs(new_data, "", report) + + # 2d. Strip unconditional chart defaults from extra* fields + _strip_extra_defaults(new_data, report) + + # 3. Pass through remaining keys + remaining_keys = flatten_keys(data) + for key in remaining_keys: + is_upstream = key.startswith(("upstream.", "global.")) + if is_upstream: + already_handled = any(key == p or key.startswith(p + ".") for p in handled_prefixes) + if not already_handled: + report.unknown_upstream_keys.append(key) + + value, _ = deep_get(data, key) + if not any(key == p or key.startswith(p + ".") for p in handled_prefixes): + deep_set(new_data, key, value) + + return new_data, report + + +# --------------------------------------------------------------------------- +# YAML output with review markers +# --------------------------------------------------------------------------- + + +def _place_comment_at_path(lines: list[str], dotted_path: str, comment: str) -> bool: + """Find the YAML line for a dotted path and insert a comment above it. + + Walks the path segments (e.g. "appConfig.app.baseUrl") through the YAML + lines, tracking indentation to match the nesting level. Inserts the + comment at the indentation of the matched line. Returns True if placed. + """ + segments = dotted_path.replace("[", ".[").split(".") + search_from = 0 + last_match = -1 + last_indent = -1 + + for seg in segments: + # List index segments like [0] — skip, stay at current position + if seg.startswith("["): + continue + target = seg + ":" + for i in range(search_from, len(lines)): + stripped = lines[i].lstrip() + if stripped.startswith("#"): + continue + indent = len(lines[i]) - len(stripped) + if indent <= last_indent and last_indent >= 0: + continue + if stripped.startswith(target): + last_match = i + last_indent = indent + search_from = i + 1 + break + else: + break + + if last_match < 0: + return False + + indent_str = lines[last_match][:last_indent] + lines.insert(last_match, f"{indent_str}{comment}") + return True + + +def add_review_comments(yaml_str: str, report: MigrationReport) -> str: + """Insert MIGRATION-REVIEW comments into the YAML output.""" + lines = yaml_str.split("\n") + header_comments: list[str] = [] + + header_comments.append("# ================================================================") + header_comments.append("# MIGRATION-REVIEW: This file was auto-generated from 1.y values.") + header_comments.append(f"# {len(report.applied)} keys migrated deterministically.") + if report.review: + header_comments.append( + f"# {len(report.review)} area(s) flagged for review (search for MIGRATION-REVIEW)." + ) + if report.commented_defaults: + total_commented = sum(len(v) for v in report.commented_defaults.values()) + header_comments.append(f"# {total_commented} chart-managed default(s) commented out.") + if report.removed: + header_comments.append(f"# {len(report.removed)} key(s) removed (no 2.y equivalent).") + if report.unknown_upstream_keys: + header_comments.append( + f"# {len(report.unknown_upstream_keys)} unknown upstream key(s) carried over with warnings." + ) + header_comments.append("#") + header_comments.append("# TIP: Only include values you have customized. Omitting chart") + header_comments.append("# defaults makes maintenance easier and reduces conflicts on") + header_comments.append("# future chart upgrades.") + header_comments.append("# ================================================================") + header_comments.append("") + + # Map areas to YAML search keys for inline placement. + # Areas with a "mapped_to" field use that dotted path to find the line. + area_key_map: dict[str, str | tuple[str, ...] | None] = { + "ingress": "ingress:", + "args": ("extraArgs:", "argsOverride:"), + "extraEnvFrom": "extraEnvFrom:", + "initContainers": ("dynamicPlugins:", "extraInitContainers:", "preInitContainers:"), + "networkPolicy": None, + "intelligentAssistant": "intelligentAssistant:", + "authSecret": "auth:", + "orchestrator": "orchestrator:", + "extraDefaults": ("extraVolumes:", "extraVolumeMounts:", "extraEnv:"), + } + + for item in report.review: + area = item["area"] + desc = item["description"] + comment_text = f"# MIGRATION-REVIEW [{area}]: {desc}" + placed = False + + # Try mapped_to first — find the deepest matching YAML key line + mapped_to = item.get("mapped_to", "") + if mapped_to: + placed = _place_comment_at_path(lines, mapped_to, comment_text) + + # Fall back to area_key_map + if not placed: + search_keys = area_key_map.get(area) + if search_keys is None: + header_comments.append(comment_text) + header_comments.append("") + continue + if isinstance(search_keys, str): + search_keys = (search_keys,) + for sk in search_keys: + for i, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith(sk): + indent = line[: len(line) - len(stripped)] + lines.insert(i, f"{indent}{comment_text}") + placed = True + break + if placed: + break + + if not placed: + header_comments.append(comment_text) + header_comments.append("") + + for key in report.unknown_upstream_keys: + top_key = key.split(".")[0] + ":" + for i, line in enumerate(lines): + if line.lstrip().startswith(top_key): + indent = line[: len(line) - len(line.lstrip())] + comment = f"{indent}# MIGRATION-REVIEW [unknown]: '{key}' is an upstream key not in the mapping tables. Verify it is still valid." + if i > 0 and "MIGRATION-REVIEW [unknown]" not in lines[i - 1]: + lines.insert(i, comment) + break + + # Insert commented-out chart defaults next to their parent key + for key, items in report.commented_defaults.items(): + item_yaml = yaml.dump( + items, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=10000, + ).rstrip() + commented = "\n".join(f"# {ln}" if ln.strip() else "#" for ln in item_yaml.split("\n")) + # Find the key line in the output and insert after it + target = key + ":" + inserted = False + for i, line in enumerate(lines): + if line.lstrip().startswith(target): + indent = line[: len(line) - len(line.lstrip())] + header_line = ( + f"{indent}# Chart-managed defaults (uncomment only if you need to customize):" + ) + lines.insert(i + 1, f"{indent}{commented}") + lines.insert(i + 1, header_line) + inserted = True + break + if not inserted: + # Key was removed entirely (all entries were defaults) — add as + # a top-level commented-out section + lines.append("") + lines.append( + f"# {key}: (chart-managed defaults — uncomment only if you need to customize)" + ) + lines.append(commented) + + if report.removed: + lines.append("") + lines.append("# Removed values (no 2.y equivalent):") + for item in report.removed: + lines.append(f"# {item['old']} — {item['notes']}") + + return "\n".join(header_comments + lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _read_yaml(path: str) -> dict: + """Read and parse a YAML file, returning the top-level mapping.""" + if path == "-": + raw = sys.stdin.read() + else: + with open(path) as f: + raw = f.read() + data = yaml.safe_load(raw) + if not isinstance(data, dict): + raise ValueError(f"{path}: input must be a YAML mapping (dict)") + return data + + +def _migrate_one( + input_path: str, + old_data: dict, + output_path: str | None, + json_mode: bool, + target_version: str | None = None, +) -> tuple[dict, MigrationReport]: + """Migrate a single values file and write output. Returns (report_dict, report).""" + new_data, report = migrate(old_data) + + if json_mode: + print(json.dumps(report.to_dict(), indent=2)) + else: + yaml_str = yaml.dump( + new_data, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=10000, + ) + output_str = add_review_comments(yaml_str, report) + if target_version: + header = f"# Migrated from: {input_path} (target: RHDH {target_version})\n" + else: + header = f"# Migrated from: {input_path}\n" + output_str = header + output_str + + if output_path: + with open(output_path, "w") as f: + f.write(output_str) + print( + f"Wrote migrated values to {output_path}", + file=sys.stderr, + ) + else: + print(output_str) + + return report.to_dict(), report + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Migrate RHDH Helm chart values from 1.y to 2.y structure.", + ) + parser.add_argument( + "input", + nargs="+", + help="Path(s) to 1.y values.yaml file(s) (use '-' for stdin). " + "Multiple files are migrated independently, preserving file " + "separation. Your original files are never modified.", + ) + parser.add_argument( + "-o", + "--output", + help="Output path: a file when migrating a single input, or a " + "directory (created if needed) when migrating multiple inputs. " + "When omitted, writes next to the original file with a versioned " + "suffix (e.g. values.yaml → values-2.1.yaml). Use '-' for stdout.", + ) + parser.add_argument( + "--to", + metavar="VERSION", + help="Target RHDH version (e.g. 2.1). Used in multi-file output " + "filenames (base.yaml -> base-2.1.yaml). Falls back to " + "'-migrated' suffix if omitted.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output the report as JSON to stdout instead of YAML", + ) + args = parser.parse_args() + + # Check target version is supported + if args.to: + major_minor = ".".join(args.to.split(".")[:2]) + if major_minor not in SUPPORTED_VERSIONS: + supported = ", ".join(sorted(SUPPORTED_VERSIONS)) + print( + f"Chart migration not yet available for RHDH {args.to}. Supported: {supported}.", + file=sys.stderr, + ) + return 3 + + inputs: list[str] = args.input + multi = len(inputs) > 1 + + if multi and "-" in inputs: + print( + "Error: stdin ('-') cannot be combined with other input files", + file=sys.stderr, + ) + return 2 + + if multi and args.output: + os.makedirs(args.output, exist_ok=True) + + any_review = False + all_reports: list[dict] = [] + + for input_path in inputs: + try: + old_data = _read_yaml(input_path) + except (FileNotFoundError, PermissionError) as e: + print(f"Error: {e}", file=sys.stderr) + return 2 + except yaml.YAMLError as e: + print(f"Error parsing YAML: {e}", file=sys.stderr) + return 2 + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 2 + + if args.output == "-": + out_path = None + elif args.output and multi: + base = os.path.basename(input_path) + name, ext = os.path.splitext(base) + suffix = args.to if args.to else "migrated" + out_path = os.path.join(args.output, f"{name}-{suffix}{ext}") + elif args.output: + out_path = args.output + elif input_path == "-": + out_path = None + else: + name, ext = os.path.splitext(input_path) + suffix = args.to if args.to else "migrated" + out_path = f"{name}-{suffix}{ext}" + + report_dict, report = _migrate_one( + input_path, + old_data, + out_path, + args.json, + args.to, + ) + report_dict["file"] = input_path + all_reports.append(report_dict) + if report.has_review_items: + any_review = True + + # Print summary to stderr + if multi: + totals = { + k: sum(r["summary"][k] for r in all_reports) + for k in ("total_deterministic", "total_removed", "total_review", "total_unknown") + } + print( + f"\nMigration summary ({len(all_reports)} files): " + f"{totals['total_deterministic']} keys migrated, " + f"{totals['total_removed']} removed, " + f"{totals['total_review']} areas for review, " + f"{totals['total_unknown']} unknown upstream keys", + file=sys.stderr, + ) + else: + summary = all_reports[0]["summary"] + print( + f"\nMigration summary: " + f"{summary['total_deterministic']} keys migrated, " + f"{summary['total_removed']} removed, " + f"{summary['total_review']} areas for review, " + f"{summary['total_unknown']} unknown upstream keys", + file=sys.stderr, + ) + + return 1 if any_review else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/rhdh-upgrade-helper/workflows/chart-migration.md b/skills/rhdh-upgrade-helper/workflows/chart-migration.md new file mode 100644 index 0000000..c92e017 --- /dev/null +++ b/skills/rhdh-upgrade-helper/workflows/chart-migration.md @@ -0,0 +1,334 @@ +# Workflow: Chart migration (1.y to 2.y) + +This workflow handles RHDH Helm chart major version upgrades where the values structure +changes. It combines a deterministic migration script with AI-assisted resolution of +ambiguous areas. + + +Read these references before proceeding: + +- [Upstream migration guide](https://github.com/redhat-developer/rhdh-chart/blob/release-2.1/charts/rhdh/docs/migration-from-backstage-chart.md) — authoritative mapping tables, behavioral changes, chart-managed defaults inventory, before/after YAML examples +- `references/chart-migration-1y-2y.md` — ambiguous area transformation algorithms, `.Values.*` rewriting rules, decomposed fields (skill-specific metadata not in the upstream guide) +- `references/secrets-detection.md` — secret scanning patterns +- `references/output-format.md` — report template (for the final combined report) + + +## Step 0: Validate prerequisites + +1. Confirm this is a major version upgrade: the RHDH major version in `--from` differs + from `--to` (e.g., 1.10 → 2.1). +2. Note the prerequisite: Kubernetes 1.31+ / OpenShift 4.18+. +3. Identify the Helm values file to migrate. Sources (in priority order): + - `--config` flag pointing to a values.yaml + - `.rhdh-upgrade-helper.yaml` config listing a values file + - `--config-path` directory scan finding a `values*.yaml` + - Ask the user for the file path + +If no values file can be found, inform the user: +"Chart migration requires your 1.y Helm values file. Provide it with `--config ./values.yaml` +or export from a running release: `helm get values -n -o yaml > old-values.yaml`" + +## Step 1: Secrets scan + +Before any processing, scan the values file for embedded secrets per +`references/secrets-detection.md`. Same rules as `workflows/full-report.md` Step 0. + +## Chart version resolution + +All `helm` commands in this workflow — `helm template`, `helm pull`, `helm upgrade` — +need the correct **chart version** and **repo URL**. Resolve these once and reuse +throughout. + +**Always check the official repo first.** Only fall back to the upstream repo if the +target version is not yet published at `charts.openshift.io` (e.g., pre-GA builds). + +### Official repo (`charts.openshift.io`) — preferred + +In the official repo, the RHDH product version aligns with the chart version: +- Product `2.1` → latest chart `2.1.x` (use `--version 2.1` and Helm picks the latest patch) +- Product `2.1.0` → exactly chart `2.1.0` + +```bash +helm template redhat-developer-hub --repo https://charts.openshift.io --version -f values.yaml +helm pull redhat-developer-hub --repo https://charts.openshift.io --version --untar +``` + +### Upstream repo (`redhat-developer.github.io/rhdh-chart`) — pre-GA fallback + +In the upstream repo, product versions do **not** map directly to chart versions. +The chart has its own versioning scheme. To resolve: + +- Product `2.1` (no patch) → find the highest `2.1.*` tag in the repo (e.g., `2.1.1`). + Read the chart version from `Chart.yaml` at that tag: + ``` + https://github.com/redhat-developer/rhdh-chart/blob/2.1.1/charts/rhdh/Chart.yaml + ``` + Fall back to the `release-2.1` branch (or `main`) only if no tags exist yet. +- Product `2.1.1` (with patch) → use the `2.1.1` tag directly. + Read the chart version from `Chart.yaml` at that tag: + ``` + https://github.com/redhat-developer/rhdh-chart/blob/2.1.1/charts/rhdh/Chart.yaml + ``` + +Then use the chart version from `Chart.yaml`: +```bash +helm template redhat-developer-hub --repo https://redhat-developer.github.io/rhdh-chart --version -f values.yaml +helm pull redhat-developer-hub --repo https://redhat-developer.github.io/rhdh-chart --version --untar +``` + +### What you can inspect after pulling + +After `helm pull --untar`, inspect: +- `redhat-developer-hub/templates/_helpers.tpl` — internal template names +- `redhat-developer-hub/values.yaml` — chart defaults +- `redhat-developer-hub/Chart.yaml` — version and appVersion + +## Step 2: Run deterministic migration + +The migration script **never modifies the original values file**. It reads the +1.y file and writes a separate draft 2.y file for the customer to inspect and +validate before using it in an upgrade. + +Execute the migration script on the user's values file(s). If the customer uses +multiple values files (e.g., base + environment overrides), pass them all — each +is migrated independently and the output preserves the file separation. + +By default, the script writes output **next to the original file** with a versioned +suffix (e.g., `values.yaml` → `values-2.1.yaml`). No `-o` flag is needed: + +```bash +# Single file — output written next to the original +python3 "$SKILL_DIR/scripts/migrate-chart-values.py" "$VALUES_FILE" \ + --to "$TARGET_VERSION" + +# Multiple files — same default behavior, each output next to its original +python3 "$SKILL_DIR/scripts/migrate-chart-values.py" $VALUES_FILES \ + --to "$TARGET_VERSION" + +# Explicit output directory (overrides default, which is the same folder as the input $VALUES_FILES) +python3 "$SKILL_DIR/scripts/migrate-chart-values.py" $VALUES_FILES \ + --to "$TARGET_VERSION" \ + -o /tmp/migrated/ + +MIGRATION_EXIT=$? +``` + +The script prints the output path and a summary to stderr (e.g., +`Wrote migrated values to /path/to/values-2.1.yaml`). + +Present a summary to the user: + +``` +## Chart values migration: RHDH {from} → {to} + +**Deterministic mappings applied:** {N} keys migrated automatically +**Template references updated:** .Values.global.* / .Values.upstream.* refs rewritten to 2.y paths +**Removed values:** {M} keys with no 2.y equivalent +**Areas requiring review:** {R} (listed below) +**Unknown upstream keys:** {U} carried over with warnings +``` + +If `MIGRATION_EXIT == 3` (target version not supported by the script), inform the +user that chart structure migration is not yet available for this version, then +continue to Steps 4 and 5 — the rest of the upgrade assessment (behavioral +warnings, plugin analysis, release notes) should not be blocked. + +If `MIGRATION_EXIT == 0` (no review needed), skip to Step 4. + +## Step 3: AI-assisted resolution of ambiguous areas + +For each review item in the migration report, walk the user through the resolution. +Read the corresponding section of `references/chart-migration-1y-2y.md` for guidance. + +### Resolution protocol + +For each flagged area: + +1. **Show the original 1.y values** for that section (from the user's input file) +2. **Explain what changed** — one paragraph, plain language, no internal jargon +3. **Show the proposed 2.y equivalent** — the draft output from the script +4. **Flag any manual actions** — things the user must do outside the values file + (e.g., create a secret, add NetworkPolicy rules) +5. **Ask for confirmation**: "Does this look correct? Adjust anything?" + +### Area-specific guidance + +**ingress** — Show the structural transformation. If the user had `extraHosts`, confirm the +merged `hosts[]` array is correct. If TLS was enabled, verify the host association. If the +user's ingress has complex rules or path types, flag for manual review. + +**args** — Explain the `extraArgs` vs `argsOverride` choice. If the script chose `extraArgs`, +explain that system `--config` flags will be prepended automatically. If the user's args +contained `--config`, the script chose `argsOverride` — explain that this replaces ALL +arguments. + +**extraEnvFrom** — Show the merged list. Verify the user recognizes all secret/configmap names. +Note that the format changed from name strings to structured refs. + +**authSecret** — Show the `existingSecretRef` object. Ask if the user's secret key is +`backend-secret` or something else. If different, they need to update the `key` field. + +**initContainers** — If custom init containers were moved to `extraInitContainers`, ask whether +they should run before (→ `preInitContainers`) or after (→ `extraInitContainers`) system init +containers. Show the dynamic-plugins init container config under `dynamicPlugins.initContainer.*`. + +**networkPolicy** — Explain that the old `networkPolicy.*` config is removed. The new chart +deploys default-deny policies. Ask: "Does your RHDH connect to external APIs, custom sidecars, +or cross-namespace services? If so, you'll need to add NetworkPolicy rules after migration." + +**intelligentAssistant** — Walk through each sub-area: +- Image string split: show the parsed components, ask if correct +- ConfigMap mapping: show which CMs were classified as stack/profile +- Secret: warn that they must create it independently before upgrading +- Removed fields: list what was dropped and why +- Plugin format: the new chart supports `ref://` as a convenience shorthand for referencing default catalog plugins by name. Existing `oci://` references remain fully supported — no forced migration needed. Optionally suggest simplifying to `ref://` where applicable. + +**orchestrator** — Show the restructured fields: +- External DB fields nested under `externalDB.*` +- Image strings split into components +- Job config fields nested under `dbCreationJob.*` +If `initContainerImage` and `createDBJobImage` differed, ask which to use. + +**valuesRef** — The script automatically replaces known `.Values.global.*` and +`.Values.upstream.*` Go template references with their 2.y equivalents (e.g., +`.Values.global.host` → `.Values.host`). For flagged references: +- If the reference was to a decomposed field (e.g., `global.lightspeed.sidecar.image` + which is now `intelligentAssistant.core.image.{registry,repository,tag}`), explain the + structural change and help the user update to the specific sub-field they need. +- If the reference was not automatically mapped, look up the correct 2.y path from + `references/chart-migration-1y-2y.md` and apply it. + +**extraDefaults** — The script automatically removes unconditional chart-managed defaults +(e.g., `dynamic-plugins-root` volume, `APP_CONFIG_backend_listen_port` env var) and flags +conditional ones for review (e.g., `BACKEND_SECRET`, `backstage-app-config` volume). For +each flagged entry: +- Explain that the 2.y chart may manage this resource automatically depending on + configuration (e.g., `BACKEND_SECRET` is set when `auth.backend.enabled`). +- Recommend removing it unless the user has a custom value that differs from the chart + default. +- If the user confirms removal, delete the entry from the output file. +- See `references/chart-migration-1y-2y.md` "Chart-managed defaults" for the full list. + +### After all areas resolved + +Apply the user's confirmed changes to the draft file. The final 2.y values file should +have no remaining `MIGRATION-REVIEW` markers. + +## Step 4: Behavioral change warnings + +Regardless of whether ambiguous areas existed, warn about behavioral changes: + +1. **PostgreSQL default version**: If the user's values don't explicitly set + `postgresql.image.tag`, warn: "The default PostgreSQL image changed from v15 to v18. + If you have an existing data directory, add `postgresql.image.tag: ''` + to avoid data compatibility issues." + +2. **HPA maxReplicas default**: If autoscaling is enabled and `maxReplicas` is not explicitly + set, warn: "The default maxReplicas changed from 100 to 3." + +3. **Database env vars**: If the user had `POSTGRESQL_ADMIN_PASSWORD` in `extraEnvVars`, + note it should be `POSTGRES_PASSWORD` now. + +4. **Image digest/tag interaction**: If the user's values set a custom `image.tag` (or any + `*.image.tag`), warn: "The downstream chart ships images with explicit digests by default. + When both tag and digest are set, the chart renders `tag@digest`, which can fail to resolve + at pull time if they don't match (the chart's default digest is merged by Helm). When + overriding with a tag, either set `digest: ""` to clear the default, or set `digest` to + the correct digest for your tag." + +5. **Air-gapped / disconnected environments**: If `global.imageRegistry` is set, note: + "`global.imageRegistry` and `global.imagePullSecrets` apply to all chart-managed container + images (RHDH, PostgreSQL, catalog index, etc.), but do NOT apply to dynamic plugin + references (`oci://` or `ref://` in `dynamicPlugins.plugins`). Plugin OCI images must be + mirrored separately and their references updated individually in your dynamic plugins + configuration." + +6. **Schema validation**: Recommend running `helm template` before upgrading. + If multiple values files were migrated, pass them in the same order the customer + used with the 1.y chart (Helm merges values files in order — last wins). + + Use the chart version resolved in the **Chart version resolution** section above: + ```bash + # Single file + helm template redhat-developer-hub --repo --version -f new-values.yaml + # Multiple files — same order as the original 1.y install + helm template redhat-developer-hub --repo --version -f base-2.1.yaml -f prod-2.1.yaml + ``` + +## Step 5: Produce combined report + +If the user provided config files beyond just the Helm values (e.g., app-config.yaml, +dynamic-plugins.yaml), run `workflows/full-report.md` with the **migrated** values file +as input alongside the other config files. This produces the standard upgrade assessment +(plugin analysis, release notes correlation, scoring) combined with the chart migration. + +If only the Helm values file was provided, produce a standalone chart migration report: + +``` +## Chart migration report: RHDH {from} → {to} +### Generated: {date} + +> **Migration status:** {Complete | Complete with manual actions} +> **Keys migrated:** {N} deterministic + {R} reviewed +> **Removed:** {M} (no 2.y equivalent) +> **Manual actions required:** {count} + +--- + +### Deterministic migrations applied + +{N} values keys automatically translated to the 2.y structure. +No action needed for these. + +### Reviewed and confirmed + +{For each resolved ambiguous area, show what was decided} + +### Manual actions required + +{List any post-migration steps: create secrets, add NetworkPolicy rules, etc.} + +### Behavioral change warnings + +{PostgreSQL version, HPA defaults, env var renames, etc.} + +### Pre-upgrade validation + +Validate the migrated values before upgrading. If multiple values files were +migrated, pass them in the same order as the original 1.y install. + +Use the chart version and repo URL from the **Chart version resolution** section: + +```bash +helm template redhat-developer-hub --repo --version {chart-version} -f {output-file(s)} +``` + +Then upgrade: + +```bash +helm upgrade --install redhat-developer-hub --repo --version {chart-version} -n -f {output-file(s)} +``` + +### Migrated values file + +The migrated 2.y values file has been written to: `{output-path}` +``` + +### RHDH Local + +Do **not** recommend RHDH Local for validating Helm values files. RHDH Local uses +`podman compose` with app-config files — it cannot process Helm values or resolve +Go template expressions (e.g., `{{ include "rhdh.hostname" . }}`). The correct +validation path for chart migrations is `helm template` (shown above). + +RHDH Local is still relevant when the customer also has app-config files to +validate — in that case, defer to `workflows/full-report.md` which includes the +RHDH Local recommendation for the app-config portion. + +## Report rules + +- Customer-facing language — no internal jargon +- Every change traces to a specific mapping from `references/chart-migration-1y-2y.md` +- No secrets echoed in output — apply `[REDACTED]` per `references/secrets-detection.md` +- Show the exact file path of the migrated values file +- Include `helm template` validation command diff --git a/skills/rhdh-upgrade-helper/workflows/full-report.md b/skills/rhdh-upgrade-helper/workflows/full-report.md index ec12521..21b1dae 100644 --- a/skills/rhdh-upgrade-helper/workflows/full-report.md +++ b/skills/rhdh-upgrade-helper/workflows/full-report.md @@ -113,7 +113,7 @@ echo "$RELEASE_VERSIONS" # e.g., references/release-notes/1.8.md says "upstream Backstage 1.42.5" ``` -**Major version handling (e.g., 1.x → 2.x):** +**Major version handling (1.10 → 2.1):** - If the major version differs between FROM and TO, flag this as a **major version upgrade** in the report header - Major version upgrades may introduce fundamental architecture changes (e.g., frontend system migration, backend system migration) — surface these prominently