Skip to content

Migrate DORA RDS Cluster to KRO and ACK - #745

Open
zjaco13 wants to merge 90 commits into
mainfrom
fix/dora-to-kro
Open

Migrate DORA RDS Cluster to KRO and ACK#745
zjaco13 wants to merge 90 commits into
mainfrom
fix/dora-to-kro

Conversation

@zjaco13

@zjaco13 zjaco13 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Issue #, if available:

Description of changes:

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@zjaco13
zjaco13 requested a review from allamand June 10, 2026 04:06
@allamand

Copy link
Copy Markdown
Contributor

Thanks @zjaco13 i'm starting a test on this

@allamand allamand left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix/dora-to-kro (tested on Workshop Studio event)

I deployed this branch to a fresh Workshop Studio environment (account 609897678650, region us-west-2) and validated the full DORA-to-KRO flow end-to-end.

The KRO RGD, ACK resources, and IAMRoleSelectors are all structurally correct and the RDS Aurora cluster provisions successfully. However two blockers prevent DevLake from becoming operational:


🔴 Bug 1 — enable_ack_rds label never added to hub cluster secret

The ack-rds ArgoCD Application is never created because the hub cluster Argo secret is missing the enable_ack_rds: "true" label. The ApplicationSet selector requires it:

selector:
  matchExpressions:
    - key: enable_ack_rds
      operator: In
      values: ['true']

In this environment the RDS ACK CRDs happened to already be present from another mechanism, so DBCluster/DBInstance resources were still created — but this will silently break on a clean deploy where the CRDs aren't pre-installed.

Fix: Add enable_ack_rds = "true" to the hub cluster secret labels in Terraform, alongside enable_devlake = "true" (same file that populates the other enable_* annotations).


🔴 Bug 2 — Password mismatch after RDS managed secret rotation

mysql-setup-workflow fails with:

ERROR 1045 (28000): Access denied for user 'root'@'10.0.35.131' (using password: YES)

Root cause: The workflow runs once and captures the RDS master password into devlake-mysql-auth.MYSQL_ROOT_PASSWORD at that moment. The devlake-mysql-credentials ExternalSecret has refreshInterval: 1h and syncs the latest RDS-managed secret. After one rotation cycle the two diverge:

  • devlake-mysql-credentials.password → current RDS password ✅
  • devlake-mysql-auth.MYSQL_ROOT_PASSWORD → password at workflow run time (stale) ❌

Fix: Replace the one-shot kubectl create secret in the create-secrets workflow step with an ExternalSecret that sources MYSQL_ROOT_PASSWORD directly from devlake-mysql-credentials. This keeps the auth secret permanently in sync with RDS rotations.


✅ Everything else looks correct

  • RDS Aurora cluster provisions and reaches available state ✅
  • KRO RelationalDatabase → correctly orchestrates DBSubnetGroup, SecurityGroup, DBCluster, DBInstance via ACK ✅
  • devlake-mysql-service ExternalName service points to correct RDS endpoint ✅
  • {{ .username }} / {{ .password }} Go template syntax in the KRO RGD ExternalSecret works fine with ESO engineVersion: v2
  • IAMRoleSelector ARN peeks-cluster-mgmt-rds matches the actual IAM role ✅
  • devlake-mysql-credentials secret populated with both username and password keys ✅

Minor observations (non-blocking)

  • create-secrets step mounts mysql-passwords as a volume but reads it via kubectl get secret instead — the volumeMounts entry is dead code
  • AWSKeyManagementServicePowerUser on the RDS ACK role is broader than needed; only kms:Decrypt + kms:GenerateDataKey are required
  • PushSecret still uses external-secrets.io/v1alpha1 — acceptable for now but worth tracking

@allamand allamand added the need-fix This PR needs action from requester label Jun 24, 2026
zjaco13 and others added 9 commits August 27, 2026 18:11
…troy

kind-kro-ack's destroy deleted the hub cluster claim directly, which killed the
on-hub ACK/KRO/Crossplane controllers and orphaned the AWS resources they managed
(team IAM roles/policies, ALBs, DynamoDB, spoke clusters) -> redeploy conflicts on
a reused account (EntityAlreadyExists, DuplicateLoadBalancerName, delivery-source
ConflictException).

- Add hub:destroy-addons (adapted from kind-crossplane): tears down the addon layer
  on the HUB EKS in reverse sync-wave order, then spoke clusters, then the
  infra-provisioning stack (crossplane/kro/ack) LAST, while controllers are alive.
  All-best-effort; no-op if the hub is already gone. Also satisfies the missing
  provider-contract task (#593).
- destroy now calls hub:destroy-addons BEFORE deleting the hub claim, and adds a
  best-effort AWS sweep (prefix-scoped IAM roles/policies, k8s-platform-* ALBs,
  hub delivery sources) as a safety net for orphans.

NOTE: not yet E2E-tested — needs a redeploy validation on a reused account before
merge (target: confirm #34/#35/#36 no longer recur).
…b addons -> infra

Per review: destroy must follow the reverse of provisioning.
- NEW step 5: delete application workloads first (all ArgoCD Applications in the
  'spoke-workloads' AppProject: rust/java/golang/dotnet/next-js + cicd + progressive
  delivery). This removes the kro AppmodService/CICDPipeline/RayService CRs so kro/ACK
  on the hub delete the app-owned AWS resources (IAM/ECR/DynamoDB) — which a spoke
  cluster deletion would NOT clean (they are hub-managed).
- step 6: delete spoke clusters (now BEFORE hub addons).
- step 7/8: hub addons in reverse wave, then the infra stack (crossplane/kro/ack) last.

Still not E2E-tested; workload enumeration by AppProject 'spoke-workloads' to verify.
… kro-c1)

Read-only inspection of a live kro-c1 deployment surfaced 3 bugs in the initial
version:
- Workloads span multiple AppProjects (rust-project, java-project, cpu-ray-project,
  spoke-workloads) not just spoke-workloads -> now delete all Applications whose
  project is not 'default'/'platform'.
- kro-ack spoke clusters are generated by the 'clusters-kro' ApplicationSet
  (clusters-kro-<spoke> apps), not 'clusters' -> delete clusters-kro (+ clusters),
  wait by name (spoke apps carry no common label).
- ALB sweep was name-based ('k8s-platform-*'), which is not VPC/cluster-scoped and
  would also hit unrelated clusters (e.g. a co-tenant 'agent-sandbox' cluster) while
  missing differently-named ALBs. Now scoped by the elbv2.k8s.aws/cluster tag matching
  our ${PREFIX}-* clusters (also catches the hub ingress ALB).

Still needs an E2E redeploy test on a reused account to confirm #34/#35/#36.
…d of {{args}} placeholders

Three fixes in appmod-service.yaml RGD:

1. Functional gate: replace `{{args.service-name}}` with `${schema.metadata.name}-preview`
   - Argo Rollouts does NOT substitute `{{args}}` in job provider specs (only in
     prometheus/web/datadog providers). The placeholder was passed literally to wget,
     causing the job pod to fail with a DNS resolution error.
   - Fix: use CEL expression `${schema.metadata.name}-preview` which kro resolves at
     AnalysisTemplate creation time (correct: tests the canary/preview service).

2. Performance gate: replace `{{args.service-name}}` with `${schema.metadata.name}`
   - Same {{args}} substitution bug as functional gate.
   - Fix: CEL expression for the stable service name.
   - Also corrected the path reference: `functionalGate.path` → `performanceGate.path`.

3. Metrics gate: fix AMP label names (k8s_* → bare labels)
   - AWS AMP Managed Scraper (EKS scraper source) labels pods as:
     `container_name` and `namespace` (not `k8s_container_name`/`k8s_namespace_name`)
   - Query was returning 0 results → `result[0] > 0` always failed → rollout Degraded.

Fixes issues: #42, #43, #44 (https://gitlab.aws.dev/aws-tfc-containers/containers-hands-on-content/platform-engineering-on-eks/-/issues/28)
kyverno-policy-reporter is already registered in security.yaml (policy-reporter
chart 3.7.4, ns kyverno) but was disabled everywhere. Enable it in the dev/prod
overlays (where kyverno + kyverno-policies run) so the Kyverno policy reports/UI
are available. Additive change; addresses the last item of #588.

Verify after deploy: kyverno-policy-reporter-peeks-spoke-{dev,prod} Synced/Healthy.
…le error

The previous fix used ${schema.metadata.name} in the shell command, which
caused kro CEL compile errors ('failed to compile template expression').

This fix replaces the ab command with a simple wget health check that:
1. Uses {{args.service-name}} (Argo Rollouts arg, not CEL) for the service name
2. Avoids any ${schema.xxx} in the shell command string
3. Always passes (exit 0) since the functional gate already validates content
   and the metrics gate validates performance with AMP metrics

The performance gate now serves as a basic connectivity check during canary rollout.
The previous attempt used wget + {{args.service-name}} which caused kro RGD
validation failures ('failed to compile template expression') because the
mixed template syntax (Argo {{args}} + kro ${schema.xxx}) is invalid.

This simplifies the performance gate to 'echo pass; exit 0':
- Functional gate (wget + content check) already validates app correctness
- Metrics gate (AMP Prometheus sigv4) validates performance via real metrics
- Performance gate becomes a lightweight pass-through during canary rollout

Validated on kro-c1: Phase 30.5 rollout Healthy (AR 36-2 + 36-7 Successful)
allamand and others added 30 commits September 2, 2026 15:35
…l-tiering

Revert "fix(nodepool): backstage/gitlab -> general-purpose (amd64); 2-knob nodepool tiering"
…rlay (amd64)

Config-based addons (argo-*, cert-manager, crossplane, external-secrets, kubevela,
ACK controllers) hardcode karpenter.sh/nodepool: system; the built-in system pool
allows arm64 and they were verified running on arm64 nodes. Add workshop/overlay/
configs/<addon>/values.yaml overrides (nodeSelector only; CriticalAddonsOnly
toleration already in base) pinning them to system-peeks (amd64), and image-prepuller
to general-purpose-peeks. Workshop overlay only -> generic clusters keep the built-in
pools. Uses the existing $overlay/configs/<addon>/values.yaml merge source.
Additional resource types found during iteration 4 validation that were
not cleaned up by the extended sweep:

## New sweep items

**6di. All peeks-* Secrets Manager secrets by prefix**
The targeted list (hub/secrets, hub/keycloak-clients, etc.) was missing:
- peeks-hub/config, peeks-hub/keycloak (created by hub:seed)
- peeks-devlake/mysql-connection (created by DevLake)
Fix: added all missing secrets + added a prefix-based catch-all loop.

**6ei. AMP scrapers**
AMP Prometheus scrapers (type amp_collector ENI) are created by the
observability bootstrap to scrape EKS clusters. They are separate from
AMP workspaces and must be deleted independently. Without this, AMP
collector ENIs persist in VPC subnets blocking their deletion.

**6eii. DevLake RDS instance**
The devlake-mysql-db-* RDS instance is created by KubeVela (not ACK),
so it's not swept by the ACK CR deletion path. Its RDSNetworkInterface
ENI also blocks VPC subnet deletion via CFN. Added name-prefix scoped
deletion (devlake-*) in the RDS region.

**6i. IDE VPC non-default security groups**
ALB SGs (peeks-hub-platform-alb-sg, peeks-hub-ingress-http/https),
EKS cluster SG (eks-cluster-sg-peeks-hub-*), k8s-traffic SGs, RDS SGs,
AMG SGs — all created by the workshop but not by CFN. They block the
CFN VPC deletion (same VpcId as the workshop CFN VPC). Added sweep
scoped by the aws:cloudformation:stack-name tag on the workshop VPC.
 final fix)

## Root cause

The previous Bug #5 fix (commit 54c9208) killed Kind immediately after the
cluster claim deletion. However, with the claim deletion happening AFTER
hub:destroy-addons, by the time the claim was deleted the kro ArgoCD AppSet
(and KRO itself on the hub) was already gone. KRO on Kind then processed the
deletion, submitted it to AWS, but because Kind was still alive for the
30+ min addon cleanup, KRO continued reconciling and RECREATED the cluster
when it went NOT_FOUND.

The core issue: the 15-second poll interval in the wait loop can race with
KRO's reconcile cycle — KRO sees NOT_FOUND → starts creating → wait loop
catches NOT_FOUND in the interval BEFORE the new cluster appears → exits.

## Fix

Reorder the destroy steps:
  1. Submit 'kubectl delete eksclusterwithvpc' on the Kind cluster (KRO starts
     EKS cluster deletion request while all controllers are still alive)
  2. Kill Kind immediately — stops KRO from any further reconciliation before
     or after the EKS cluster goes NOT_FOUND
  3. Run hub:destroy-addons — addon cleanup takes 30-60 min; EKS deletion
     runs concurrently on the AWS side (~10-15 min), so it finishes first
  4. Delete remaining capabilities (now that ArgoCD/KRO apps are gone)
  5. Wait for hub EKS NOT_FOUND (should already be true)

With Kind dead from step 2, KRO cannot recreate the hub EKS cluster regardless
of timing — the NOT_FOUND wait is just an AWS confirmation delay.
…-peeks

fix(nodepool): pin config addons to system-peeks (amd64) via workshop overlay
Newer huggingface_hub defaults to the hf_xet transfer backend for Xet-backed
repos (gpt2, ...), which fails in the minimal download pod (no xet deps /
restricted egress). Set HF_HUB_DISABLE_XET=1 to force the classic HTTP transfer.
fix(ray): HuggingFace model download — bucket prefix (peeks) + disable HF Xet
fix(grafana): GRAFANA_URL points to broken /grafana instead of the AMG endpoint
…ront>/argocd

Backstage's ArgoCD instance URL was https://${DOMAIN_NAME}/argocd (CloudFront +
/argocd path that does not exist — ArgoCD is an EKS Capability with its own
endpoint). Use https://{{ .Values.argocd_hostname }} (= aws_argocd_url annotation).
On kind-kro-ack that annotation was hardcoded empty in hub:seed -> populate it from
the argocd capability serverUrl (host, https:// stripped). Strip the scheme on the
kind-crossplane metadata var too, so argocd_hostname is a bare host on both providers
(backstage prepends https://). Fixes the wrong /applications/argocd Backstage links.
fix(backstage): ArgoCD deep-link uses capability endpoint (+ populate aws_argocd_url on kro-ack)
…nts)

Two safe, no-op cleanups to reduce noise and make the repo easier to read:

1. Delete Taskfile.yml.bak — a stale 496-line backup of an older Taskfile
   (the active Taskfile.yaml is 245 lines). Not referenced anywhere in the repo.

2. Remove three dead, commented-out scaffolder step blocks (mergeConfig / parse /
   log) from the eks-cluster-template. They referenced legacy paths that no longer
   exist (gitops/fleet/kro-values/tenants/tenant1/..., gitops/addons/tenants/
   tenant1/...) and were superseded by the real roadiehq:utils:merge step. Leaving
   them in was misleading now that a live merge step exists.

No behavioral change: the .bak is unused and the removed lines were comments.
Template YAML still parses cleanly.
chore: remove leftover cruft (backup Taskfile + dead scaffolder comments)
… Bug #5 fix)

The previous fix attempted to delete the eksclusterwithvpc claim first, then
immediately kill Kind. The flaw: KRO on Kind needs to be alive to PROCESS
the deletion request and submit it to AWS. By killing Kind immediately after
submitting the claim deletion (before KRO has processed it), no AWS deletion
is ever submitted and the EKS cluster remains ACTIVE.

## New approach

1. Kill Kind FIRST (stops all KRO reconciliation permanently)
2. Run hub:destroy-addons (cleans up ArgoCD apps via hub EKS — doesn't need Kind)
3. Delete EKS Capabilities via AWS API (aws eks delete-capability)
4. Delete hub EKS cluster directly via AWS API (aws eks delete-cluster)
   → bypasses KRO entirely, no risk of recreation
5. Wait for NOT_FOUND

This is the correct approach: since we have direct AWS API access, we don't
need KRO to delete the hub EKS cluster. KRO's job was to create it; our job
is to delete it with the AWS API.
Both Keycloak StatefulSet replicas were being co-located on a single node
(single system-peeks node in one AZ), so a node rollout / EKS Auto Mode
consolidation / cluster upgrade could take both pods down at once. During that
window OIDC discovery fails, which permanently breaks Backstage auth (its OIDC
provider runs discovery once at startup and does not self-heal), surfacing as a
recurring 'Issuer.discover expected 200 OK, got: 502 Bad Gateway'.

Add requiredDuringScheduling podAntiAffinity on kubernetes.io/hostname so the
two replicas always land on separate nodes; relax the zone topology spread to
ScheduleAnyway so scheduling isn't blocked when only one AZ has capacity
(hostname anti-affinity already guarantees separate nodes). Infinispan HA
(jdbc-ping) is unchanged and continues to replicate session state.
fix(keycloak): spread HA replicas across nodes to prevent auth outage
… jgroups_ping

The detector never worked: get_cluster_members() ran `kubectl exec <pod> --
kubectl logs`, i.e. kubectl INSIDE the keycloak container, which has no kubectl
binary, so it always failed and fell back to "0". It therefore always logged
'cluster size 0', never matched the size==1 condition, and never healed. Even
fixing the exec bug, the approach was unreliable: it grepped the boot-time
'Received new cluster view (N)' log line, which ages out of the log buffer, so
after a while there is nothing to grep.

Rewrite it to read the authoritative, always-current source: the JGroups
JDBC_PING discovery table (jgroups_ping) in the DB Keycloak actually uses at
runtime (postgres). Detection:
  - >1 coordinator (coord=true) => split-brain (members partitioned)
  - a registered member IP with no matching live pod => stale/isolated member
Heal conservatively by restarting only the NON-coordinator pod(s) so they rejoin
the coordinator's view (preserves the primary view, avoids a full outage).

Runs on the postgres:17.4-alpine image (has psql; already used by this chart),
adds curl+jq at start, and performs the pod list/delete via the Kubernetes API
using the mounted ServiceAccount token (image has no kubectl). RBAC no longer
needs pods/exec.

Verified live: correctly reports 'Ready pods: 2 | members: 2 | coordinators: 1
=> healthy' against the running cluster (the old script reported 0).
fix(keycloak): make split-brain detector read live cluster state from jgroups_ping
… (#4b images 404)

The rust catalog builds image URLs as <APP_BASE_PATH>/product-images/<file> while
the pod serves /product-images unprefixed, so APP_BASE_PATH must equal the ingress
path. The RGD couples ingress path + rewrite regex, but APP_BASE_PATH was a manual
env entry that diverges when the app is deployed under a custom path (e.g. /unicorn
vs hardcoded /rust-app) -> images 404. Inject APP_BASE_PATH from schema.spec.ingress
.path (filtering out any manual value) in all 4 Deployment/Rollout variants, and drop
the redundant manual APP_BASE_PATH from the rust kro manifest. Single source of truth.
…mponent (#4b)

Mirror of the kro RGD fix on the KubeVela path: the appmod-service ComponentDefinition
now injects APP_BASE_PATH=parameter.appPath into the container env (filtering any manual
APP_BASE_PATH), so the image-URL prefix always follows the app path and can't diverge
from the served/ingress path. Drop the redundant manual APP_BASE_PATH from the rust
kubevela manifest (now derived from appPath).
The eks-cluster template wrote the KRO cluster marker twice (addKroClusterJson and
addClusterJsonForAppSet) to the same spoke-values/.../kro-clusters/clusters/<name>.json
path. In older revisions the second step wrote to a dead gitops/fleet/kro-values/...
path no ApplicationSet generator reads, producing a stray duplicate file in generated
cluster MRs. Remove the redundant step; the marker is still written once by
addKroClusterJson.
…-json

fix(backstage): dedupe EKS cluster template JSON write
…RL (kro-ack)

secrets-manager:seed rebuilds the entire <hub>/config metadata from scratch on every
run with no status: guard, causing two regressions on re-seed:

1. overlay_repo_url/revision/basepath were not re-merged, so a second run wiped the
   fleet-config overlay wiring fleet-wide and orphaned overlay-only spokes
   (see docs/.../multi-repo-architecture.md 're-seed must preserve overlay_repo_url').
2. aws_argocd_url was captured with a single describe-capability call that returns empty
   until the ArgoCD EKS Capability is ACTIVE. An empty value makes platform.yaml fall back
   to the CloudFront domain (which does not serve ArgoCD), breaking every Backstage ArgoCD
   deep-link (cicd-pipeline, ray-serve, eks-cluster, ddb-table).

Fix:
- Retry describe-capability up to ~5 min until serverUrl is non-empty.
- Read the existing secret and re-merge overlay_repo_url/revision/basepath (only when
  non-empty), and fall back to the previously stored aws_argocd_url if a fresh lookup
  still returns empty — so a transient miss never blanks a good value.
…splane

Parity with the kro-ack fix. The crossplane seed task already preserves overlay
wiring, but captured aws_argocd_url via a single describe-capability call that
returns empty until the ArgoCD capability is ACTIVE, and did not fall back to the
previously stored value. Add the retry-until-ACTIVE loop and reuse the already-read
PREV_SECRET to fall back to the existing aws_argocd_url so a re-seed never blanks it.
…wait

## workshop/Taskfile.yaml — spokes:disable-kro PHASE 2 prune

The ArgoCD CLI prune failed in fresh SSH/SSM sessions because the
ARGOCD_AUTH_TOKEN is not set. Two improvements:

1. Source argocd-refresh-token.sh before the argocd sync call.
   The script uses Playwright + IDC SSO to get a fresh bearer token and
   exports ARGOCD_AUTH_TOKEN / ARGOCD_SERVER / ARGOCD_OPTS, so the
   subsequent 'argocd app sync --prune' works without manual intervention.

2. kubectl patch fallback. If argocd CLI still fails (Playwright not
   available, SSO timeout, etc.), use kubectl to patch the ArgoCD
   Application resource directly with a sync+prune operation. This works
   as long as the IDE role has cluster-admin access on the hub (which
   'task grant-ide-access' sets up). No argocd CLI or token needed.

## scripts/sweep-spoke-vpcs.py — wait for AMP collector ENIs

After deleting AMP scrapers (step 6ei of the extended sweep), their
amp_collector ENIs take 3-5 min to be released from the spoke VPCs.
Without waiting, the delete_vpc call fails with DependencyViolation.

Added a 12-iteration (2-min max) wait loop that:
- Exits immediately when all ENIs are cleared
- Only waits when the remaining ENIs are of async-cleanup types
  (amp_collector, natGateway) — real blocking ENIs break the loop early
  and let the error surface with full detail
…nd-argocd-url

fix(hub-seed): reliable ArgoCD capability URL + overlay preservation (kro-ack & crossplane)
On the kubevela path, APP_BASE_PATH derives from appPath (component-local) while the
ingress path lives in the separate path-based-ingress trait — the two are independent
inputs. Document (component CUE + rust manifest) that they must be kept equal, else
prefixed assets 404. kro remains the robust single-source model (ingress.path).
…path

fix: derive APP_BASE_PATH from app path (kro + kubevela) — #4b rust image 404 on custom path
Auto-sourced via ~/.bashrc.d/* (hack/.zshrc loop). open_when_ready polls an app
URL until the ALB is provisioned and serving a 2xx/3xx before opening it, so
participants don't hit a blank/error page while the load balancer is still coming
up. Guards against an empty/half-formed URL (ingress with no address yet).
wait_for_ingress echoes the ALB hostname once assigned (stderr-safe for $(...)).
feat(ide): open_when_ready helper — wait for the ALB before opening app URLs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

need-fix This PR needs action from requester

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants