Skip to content

Multi-stage Backstage template chain (VM → Ansible → Flux) in backstage-template-execution #21

Description

@patrick-hermann-sva

Goal

Extend the backstage-template-execution workflow to chain multiple Backstage scaffolder templates in one Dapr workflow instance, with three target shapes:

  • a) create-terraform-vm only
  • b) create-terraform-vmansible-provisioning
  • c) create-terraform-vmansible-provisioningflux-bootstrap

A single Go workflow handles all three; the shape is selected by the input file (stages: [...]).

Design

Input schema

{
  "backstageURL": "...",
  "stages": [
    { "name": "vm",      "templateRef": "...", "values": {...},
      "watch": { "kind": "pr", "workflowFile": "pr-vm-deploy.yaml",
                 "branch": "...", "merge": {"enabled": true} },
      "exports": { "vm_ip": "fromGithubJobOutput:deploy.vm_ip" } },
    { "name": "ansible", "templateRef": "...",
      "values": { "targets": ["${stages.vm.vm_ip}"], ... },
      "watch": { "kind": "pr", "workflowFile": "pr-ansible.yaml",
                 "branch": "...", "merge": {"enabled": true} } },
    { "name": "flux",    "templateRef": "...",
      "values": { "kube_config": "secrets/kubeconfigs/<name>.yaml", ... },
      "watch": { "kind": "dispatch", "workflowFile": "dispatch-flux-bootstrap.yaml" } }
  ]
}

Trigger kinds

  • pr — current behavior. Backstage opens a PR; we poll runs filtered by head branch; optional auto-merge.
  • dispatch — Backstage calls github:actions:dispatch. No PR, no merge. Run identification: time-based filter on event=workflow_dispatch + created>=<dispatchedAt> (strategy "a"). Upgrade to a correlation_id workflow input (strategy "b") if races bite.

Data hand-off

  • Stage 1 → 2 (VM IP): pr-vm-deploy.yaml exposes vm_ip as a job output. New activity FetchGitHubJobOutputs reads it; resolver replaces ${stages.vm.vm_ip} in stage 2's values.
  • Stage 2 → 3 (kubeconfig path): deterministic from vm_name — no runtime fetch, just a literal secrets/kubeconfigs/<name>.yaml in stage 3's values.

Code changes (main.go, ~150 LOC)

  • Replace top-level templateRef/values/watch with Stages []Stage.
  • Lift current scaffolder→poll→watch→merge body into runStage(...).
  • Outer loop over stages; export map keyed <stageName>.<key>.
  • resolveValues(values, exports) — recursive ${stages.X.Y} replacement.
  • FetchGitHubJobOutputs activity (gated on exports != nil).
  • watch.kind discriminator: pr keeps existing logic; dispatch uses time-window filter and skips merge.
  • Hard-cut input shape (no compat layer for the old single-template format).

External changes

  • stuttgart-things/stuttgart-thingspr-vm-deploy.yaml: upload stage-outputs artifact containing outputs.json with vm_ip (and any other stage-2 inputs). Original plan said "job output", corrected — see comment on this issue.

Plan

  • 0. Baseline real run: single-stage create-terraform-vm with current code (no refactor) to validate tokens/network/PR-watch/merge end-to-end. Done 2026-04-25, ~28 min, PR #2092 merged in stuttgart-things/stuttgart-things.
  • 1. Refactor main.go to stages: [...] + watch.kind discriminator, strategy (a) for dispatch correlation. Done — commit 15b6d63 on feat/multi-stage-backstage-chain.
  • 1b. Re-run case (a) against the refactored code. Skipped full E2E in favor of unit tests (4 pass: interpolation, type preservation, nested resolveValues, no-exports passthrough) + live worker boot + structural smoke. Burning a real VM cycle for this would have been wasteful.
  • 2. Add stage-outputs artifact upload to pr-vm-deploy.yaml (separate PR in stuttgart-things/stuttgart-things); implement FetchStageExports activity to download + parse it. Done — stuttgart-things/blueprints v1.83.0 writes outputs.json; stuttgart-things/stuttgart-things workflow change merged; activity verified end-to-end on 2026-04-26 ({"vm_ips":["10.31.101.109"]} retrieved + injected into stage 2).
  • 3. Verify case (b) VM + ansible. Framework parts validated end-to-end on 2026-04-26 (instance run-1777182898): blueprint outputs.json, artifact upload + download, FetchStageExports, type-aware ${stages.vm.vm_ips} list passthrough into Backstage's array-typed targets, stage 1 PR auto-merge (#2096), stage 2 PR open + GH run watch + failure detected (no auto-merge on conclusion=failure). Underlying ansible playbook fails at kind create cluster with permission denied on /var/run/docker.sock — newly-added docker group not picked up by same SSH session. Out of scope for this issue (lives in stuttgart-things/ansible roles); 219/220 ansible tasks succeeded.
  • 4. Verify case (c) — flux-bootstrap with kind: dispatch watch (the only framework piece still untested). Will use action: check against an existing cluster to keep it lab-light.
  • 5. If concurrency becomes a problem: upgrade dispatch correlation to strategy (b) (workflow-input correlation_id + templated run-name).

Out of scope

  • Replacing the in-repo SOPS kubeconfig pattern with a runtime kubeconfig fetch.
  • Per-stage retry/backoff policy (today: stop on first failure).
  • Fixing the kind create cluster permission failure surfaced in step 3 — it's a stuttgart-things ansible playbook ordering bug (docker install adds user to docker group, but the same SSH session doesn't see it). Track separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions