Skip to content

feat(deploy): erp-deploy as Fallout Provision + Up targets - #265

Merged
ChrisonSimtian merged 4 commits into
mainfrom
feat/263-deploy-fallout-cli
May 28, 2026
Merged

feat(deploy): erp-deploy as Fallout Provision + Up targets#265
ChrisonSimtian merged 4 commits into
mainfrom
feat/263-deploy-fallout-cli

Conversation

@ChrisonSimtian

@ChrisonSimtian ChrisonSimtian commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #263.

Summary

  • New src/Deploy/Erp.Deploy/ library: typed CloudflareClient, reconcile primitives (ResourcePlan, FieldChange, PlanAction, PlanRenderer), TunnelReconciler / DnsRecordReconciler / IngressReconciler, Provisioner orchestrator.
  • Managed SSH/SFTP deploy path via Renci.SshNet — SshConnectionResolver, SshDeployer, Deployer. Sidesteps the heredoc/scp/quoting issues that bit the old deploy.ps1 (the stack.env content is written byte-for-byte over SFTP, never re-parsed by a remote shell).
  • Provision + Up targets in build/Build.cs, with [Secret] CloudflareApiToken and [Parameter] DryRun. Secrets ergonomics flow through Fallout's parameter machinery — CLOUDFLARE_API_TOKEN=$(bw get …) ./build.sh Provision works locally; CI sets the same env var from GitHub secrets.
  • deploy/erp-deploy.json — single source of truth for zone / tunnels / hostnames / remote host.
  • Self-healing pre-chmod in SshDeployer.UploadFiles so a stuck remote mode (file lacking owner-write) doesn't wedge subsequent deploys.
  • Phase 3 hostile-string round-trip pin: StackEnvBuilder extracted from Deployer.BuildStackEnv, covered by parameterised tests for $, quotes, backticks, embedded newlines, command-substitution syntax, and multibyte UTF-8.

Why Fallout, not a standalone CLI

Dogfoods Fallout (ADR-0021) as CD as well as CI — same C#-as-build-script story, no separate binary, no separate config language, same ./build.sh <Target> muscle memory.

Coverage vs. #263 acceptance

  • ./build.sh Provision --dry-run against live Cloudflare renders the plan.
  • ./build.sh Up performs a full deploy from a clean checkout; https://satisfactory.erp-for-factory.games serves the app. Verified end-to-end against 10.10.107.175 on 2026-05-28; all three containers (erp-cloudflared, erp-web, erp-api) report healthy.
  • Re-run of Up after a previous deploy succeeds (validates the self-healing chmod).
  • Hostile-string round-trip test pins the regression that motivated the move off PowerShell.
  • deploy/Homelab.Stacks.ErpForFactoryGames/bin/*.ps1 deletion + docs update — follow-up.
  • Doctor target — follow-up.

Out of scope

  • Deletion of the old .ps1 scripts and docs/operations/deploy.md rewrite — keeping in this PR scope only the new code + the test that pins the original bug.
  • Doctor target — separate PR.
  • Replacing deploy/bootstrap-lxc.sh / harden-ssh.sh (different lifecycle).

Test plan

  • ./build.sh Provision --dry-run against live Cloudflare account renders the plan.
  • ./build.sh Up performs a full deploy from a clean checkout.
  • Re-run of Up after a previous deploy succeeds.
  • dotnet test test/Deploy/Erp.Deploy.Tests — 13/13 pass locally.
  • CI green.

🤖 Generated with Claude Code

ChrisonSimtian and others added 4 commits May 28, 2026 00:20
Replaces the PowerShell deploy scripts (`deploy/Homelab.Stacks.ErpForFactoryGames/bin/*.ps1`)
with Fallout build targets backed by a new Erp.Deploy library at
src/Deploy/Erp.Deploy/. Dogfoods Fallout (ADR-0021) by extending the build
system from CI into CD — same C#-as-build-script story, no separate CLI
binary, no separate config language. POC for Fallout's deploy-agent direction.

## Phase 1 — Provision (verified live)

`./build.sh Provision [--dry-run] [--deploy-output json]`

- Hand-rolled typed CloudflareClient over HttpClient (7 endpoints).
  Soenneker.Cloudflare.OpenApiClient considered but rejected — its
  Kiota-generated types funnel data through `AdditionalData[]` dicts,
  paying the dep cost without the typed benefit.
- Reconcile primitives: ResourcePlan / FieldChange / PlanAction with
  PlanRenderer rendering both an AnsiConsole table and structured JSON.
  Plans carry their own Apply closure so dry-run and apply share the
  diff path — zero risk of "dry-run lied".
- TunnelReconciler (find-or-create) / DnsRecordReconciler (positional
  diff on content/proxied/ttl) / IngressReconciler (positional rule
  diff — Cloudflare evaluates ingress top-down).
- Provisioner orchestrator with Provisioner.Create(token) factory.

## Phase 2 — Up (scaffolded, blocked on SFTP perms)

`./build.sh Up [--dry-run] [--image-tag <tag>]` — DependsOn(Provision).

- SSH.NET 2025.1.0 for managed SSH + SFTP. The whole point: stack.env is
  written as raw bytes via SftpClient.UploadFile, never re-parsed by a
  remote shell — fixing the heredoc/scp quoting bug that broke deploy.ps1.
- SshConnectionResolver shells out to `ssh -G` so the alias-driven UX
  (~/.ssh/config) keeps working; collects all candidate identityfiles,
  filters to those that exist on disk, and hands them all to Renci.SshNet
  (which tries them in order like OpenSSH).
- SshDeployer wraps SftpClient (UploadFile + ChangePermissions) and
  SshClient.RunCommand for the remote compose pull/up -d.
- Deployer orchestrator assembles uploads (compose.yml + ingress.json
  from the homelab-stacks submodule, plus an in-memory stack.env body
  carrying TUNNEL_TOKEN + ERP_IMAGE_TAG) and drives execution.

## Secrets

CloudflareApiToken flows via Fallout's [Parameter] [Secret] mechanism.
Locally: `CLOUDFLARE_API_TOKEN=$(bw get item "<vault-item>" | jq -r ...) ./build.sh ...`.
In CI: GitHub Actions secret → env → Fallout. Same parameter, no extra code.
Bitwarden integration stays shell-level — no `bw`-aware C# (#264 closed).

## Status

- Provision: dry-run + live apply work end-to-end against the real
  Cloudflare account. The whole reconcile/diff/JSON-output pipeline is
  exercised.
- Up: gets through SSH auth + SFTP connect, fails on first UploadFile
  with SftpPermissionDeniedException. Resume tomorrow with
  `ssh chris@10.10.107.175 'ls -la /home/chris/stacks/'` to inspect
  ownership; likely a chown is all that's needed.

## Removed / deferred

- `src/Deploy/Erp.Deploy.Cli/` (the standalone Spectre.Console.Cli skeleton
  briefly prototyped here) was binned in favour of the Fallout-target shape.
- `appsettings.Deploy.json` moved out of the CLI into `deploy/erp-deploy.json`
  next to bootstrap-lxc.sh — natural home for deploy config.
- Hostile-string round-trip test (acceptance criterion in #263) deferred
  to Phase 3 along with Smoke / Doctor targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UploadFile(canOverride: true) still fails with EACCES if the existing
remote file lacks owner-write. Chmod first so a stuck mode from a prior
deploy can't wedge subsequent runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Whitespace cleanup flagged by the Lint CI job. Format-only — no
behavioural changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Phase 3)

Extract StackEnvBuilder from Deployer.BuildStackEnv and pin it with
parameterised tests covering shell metachars, embedded newlines, quotes,
backticks, command-substitution syntax, and multibyte UTF-8.

Renci.SshNet writes SFTP bytes verbatim by spec, so the only place a
hostile token could still get mangled is in our byte-production code.
Pinning that surface here is enough to keep the original PowerShell-era
regression closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deploy CD via Fallout: ./build.sh Provision reconciles Cloudflare; Up does the SSH deploy

1 participant