feat(deploy): erp-deploy as Fallout Provision + Up targets - #265
Merged
Conversation
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>
This was referenced May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #263.
Summary
src/Deploy/Erp.Deploy/library: typedCloudflareClient, reconcile primitives (ResourcePlan,FieldChange,PlanAction,PlanRenderer),TunnelReconciler/DnsRecordReconciler/IngressReconciler,Provisionerorchestrator.SshConnectionResolver,SshDeployer,Deployer. Sidesteps the heredoc/scp/quoting issues that bit the olddeploy.ps1(thestack.envcontent is written byte-for-byte over SFTP, never re-parsed by a remote shell).Provision+Uptargets inbuild/Build.cs, with[Secret] CloudflareApiTokenand[Parameter] DryRun. Secrets ergonomics flow through Fallout's parameter machinery —CLOUDFLARE_API_TOKEN=$(bw get …) ./build.sh Provisionworks locally; CI sets the same env var from GitHub secrets.deploy/erp-deploy.json— single source of truth for zone / tunnels / hostnames / remote host.SshDeployer.UploadFilesso a stuck remote mode (file lacking owner-write) doesn't wedge subsequent deploys.StackEnvBuilderextracted fromDeployer.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-runagainst live Cloudflare renders the plan../build.sh Upperforms a full deploy from a clean checkout;https://satisfactory.erp-for-factory.gamesserves the app. Verified end-to-end against10.10.107.175on 2026-05-28; all three containers (erp-cloudflared,erp-web,erp-api) report healthy.Upafter a previous deploy succeeds (validates the self-healing chmod).deploy/Homelab.Stacks.ErpForFactoryGames/bin/*.ps1deletion + docs update — follow-up.Doctortarget — follow-up.Out of scope
.ps1scripts anddocs/operations/deploy.mdrewrite — keeping in this PR scope only the new code + the test that pins the original bug.Doctortarget — separate PR.deploy/bootstrap-lxc.sh/harden-ssh.sh(different lifecycle).Test plan
./build.sh Provision --dry-runagainst live Cloudflare account renders the plan../build.sh Upperforms a full deploy from a clean checkout.Upafter a previous deploy succeeds.dotnet test test/Deploy/Erp.Deploy.Tests— 13/13 pass locally.🤖 Generated with Claude Code