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