Skip to content

feat: multi-cloud provider seam (Phase 0) - #19

Draft
MikaAK wants to merge 12 commits into
mainfrom
feat/multi-cloud-provider-seam
Draft

feat: multi-cloud provider seam (Phase 0)#19
MikaAK wants to merge 12 commits into
mainfrom
feat/multi-cloud-provider-seam

Conversation

@MikaAK

@MikaAK MikaAK commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Phase 0 of the multi-cloud refactor: establishes a provider seam without changing any
behaviour for existing AWS users.

Plan: docs/superpowers/plans/2026-08-03-multi-cloud-oci.md (uncommitted).

What's here

Render harness (P0.0)--render-dir on terraform.build/ansible.build plus injectable
pem_app_name, so rendered output can be diffed across revisions. Renders were previously
nondeterministic, which made "AWS output unchanged" unverifiable. This is the oracle every later
phase cites.

Provider seam (P0.1)DeployEx.Cloud dispatcher derived from provider descriptors, four
capability behaviours (Machine, ObjectStore, Infrastructure, Security), a
provider-neutral Cloud.Instance struct, and per-provider NimbleOptions config validation. The
:aws schema is permissive by contract so no config that works today can start failing.

Conformance (P0.2)AwsMachine, S3ObjectStore, AwsInfrastructure and AwsSecurityGroup
implement their behaviours. AwsIpWhitelister and AwsBucket now delegate and hold no ExAws
calls of their own.

Two truncation bugs found and fixed

Both returned {:ok, partial} — success, silently short:

  • S3ObjectStore.list_objects/2 made one S3 request: 1000 keys vs 7138 against the release
    bucket. Absorbing AwsManager into it, as the plan directs, would have made release discovery
    see one release in seven — breaking change detection and rollback with no error.
  • AwsMachine.fetch_instances/1 made one DescribeInstances request with no nextToken
    handling. Pre-existing; feeds tag filters, setup-state queries and mix deploy_ex.find_nodes.
    Verified by forcing max_results: 5 across 18 instances and getting all 18.

AwsAutoscaling.fetch_all_asgs/5 already paginated the ASG API, so both were oversights rather
than decisions.

Compatibility

  • parse_instance_info/1 is byte-identical to main — the frozen find_nodes --format json
    11-key contract is untouched, verified against live AWS
  • Zero bytes under priv/ changed; render output byte-identical
  • Config validation now runs on 58 task entry points and was tested against a real production
    config (space-containing resource group, tuple-valued llm_provider, nested
    terraform_default_args) — nothing rejected
  • Compiles clean on Elixir 1.17.3 and on 1.18.4/OTP 28, which a downstream consumer uses

Verification

544 tests, 6 failures — all 6 pre-existing on main (5 aws_infrastructure_test, 1
CommandRegistryTest). mix compile --warnings-as-errors clean.

Live AWS: qa.create -> qa.deploy -> qa.destroy round-trip completed and torn down;
find_nodes, instance.status and the S3/EC2 paths exercised against production.

Re-rendered a downstream consumer's 140-file committed deploys/ tree before and after: identical
diff, identical failure mode, identical exit code.

Not included

ReleaseTracker, TerraformState and AwsManager still hold their own S3 calls. Caller
rewiring, flag threading and provider guards (P0.3/P0.4) are separate. No OCI implementation —
every OCI descriptor slot returns :not_implemented.

MikaAK added 12 commits August 3, 2026 17:56
…ic-IP recert

Field findings from a live cfx_web QA resize: the naive re-run of the
letsencrypt role failed on the node. Three fixes to the IP-cert path:

1. Port 80 is held by the beam release (binds :80/:443 directly), not nginx —
   QA cfx_web has no nginx. The certbot --pre-hook/--post-hook now stop/start
   the app systemd unit ({{ app_name }}) instead of nginx, so standalone
   issuance can bind :80. The hooks persist into the renewal conf, so the
   hourly cert-renewal timer cycles the app the same way (else renewals fail
   to bind :80 forever).

2. Release configs bake the cert lineage path at COMPILE time, so a cert under
   a new lineage name is invisible to the running release. Keep the lineage
   NAME stable and let the SAN follow the current IP: reuse the first existing
   IPv4-named lineage as --cert-name, falling back to the current IP only on
   first issuance. This supersedes the stale-lineage delete task (removed) —
   with reuse there is nothing stale to prune.

3. The `creates:` guard can't fire under lineage reuse; gate issuance on SAN
   coverage instead (openssl x509 subjectAltName — only (re)issue when the cert
   does not already cover the live IP), and restart the app after issuance via
   a handler so its TLS listener serves the new cert.

Role edited in deploy_ex's priv/ansible (source of truth; AnsibleRoles.sync
File.cp_r!s it over consumer umbrellas' deploys/ansible/roles at runtime).
Adds `--render-dir` to `mix terraform.build` and `mix ansible.build` so the
full AWS terraform + ansible set can be rendered into a scratch directory
without touching the live `./deploys/` tree, without the tool preflight, and
without `terraform init`.

For ansible, `--render-dir` also redirects the three path defaults
(hosts_file / config_file / group_vars_file) and bypasses the terraform pem
glob, rendering `ansible.cfg` with a fixed placeholder pem path — a scratch
dir has no sibling `terraform/*.pem` and the glob would otherwise raise.

Randomness is injectable: `--pem-app-name` / `--db-password` on
`terraform.build` and `pem_app_name:` on `PrivRenderer.render_to_temp/1`,
both defaulting to today's random generators. `bin/render_harness.sh` pins
them so two renders of the same revision are byte-identical and any diff
between two revisions is a real output change.

No priv template bytes change; no existing switch changes meaning.
Introduces the provider seam every later multi-cloud phase builds on:

- DeployEx.Cloud dispatcher derived from descriptors, holding no
  capability module literals (pinned by an executable source test)
- DeployEx.Cloud.Provider descriptor behaviour; Aws and Oci descriptors
- Four capability behaviours: Machine, ObjectStore, Infrastructure,
  Security. Machine tag filters are a list of {key, matcher} where
  matcher is scalar | [scalar] | Regex, preserving AND semantics and
  the regex arm instance.status depends on
- Provider-neutral Cloud.Instance struct, deliberately not JSON-encodable
- Per-provider NimbleOptions config validation at task start; the :aws
  schema is permissive so no existing config can start failing
- Config.cloud_provider/0 defaulting to :aws

No ExAws call moves and no rendered byte changes.
Adversarial review found three real defects:

- A non-atom provider (config :deploy_ex, cloud_provider: "aws") raised
  FunctionClauseError from all 58 task entry points instead of returning
  an ErrorMessage. Adds catch-all clauses for a non-atom provider and a
  non-keyword config, and lets validate_config/1 take a bare provider
  atom, which previously died inside Access.get/3.
- The OCI schema rejected nil for 7 of its 14 keys while its own
  moduledoc promised every key was optional, so the idiomatic
  region: System.get_env("OCI_REGION") would fail task start.
- bin/render_harness.sh rm -rf'd its argument unguarded, so running it
  with a relative path from the repo root would delete the working tree.
  It now requires an absolute path and refuses anything holding .git or
  mix.exs.

Also adds the missing PrivRenderer non-vacuity test: nothing previously
asserted that two DIFFERENT pinned values render different bytes.
is_list/1 admits [:atom], which NimbleOptions raises on. Guards with
Keyword.keyword?/1 so the errors-not-crashes contract holds for any
list shape.
Adversarial architecture review found two blocking defects:

- The dispatcher-purity test was a bare string grep for
  Config.cloud_provider(), which cloud.ex contained twice. Hardcoding
  the provider in capability/2 left the string present elsewhere and
  the suite green, so a user on :oci would silently get AWS modules.
  Provider resolution now funnels through active_provider/1 and the
  test asserts each dispatch path's own body calls it. Verified by
  mutation: hardcoding either call site now fails the suite.

- Cloud.Machine had no equivalent of find_instance_details/3 and so
  dropped its three load-bearing clauses. Adds find_app_instances/3
  with unconditional project scope, running-only, and non-nil
  instance-group documented as contract, since a P0.2 implementer
  mapping onto the raw filter primitive would lose project scope and
  target another project's instances in a shared account.

Also pins the exact callback set of all four behaviours plus the
descriptor; the previous test passed at one callback per module, so
deleting eight of ObjectStore's nine went unnoticed.
Absorbs AwsIpWhitelister's two EC2 ingress calls, so that module now holds
zero ExAws references and leaves the P0 expected-ExAws-file list (18 -> 17
files). AwsIpWhitelister keeps its public API and its two ssh.authorize call
sites are untouched; it widens a bare address to a /32 CIDR and delegates.

Extracts classify_ingress_error/3 as the pure, testable half of the ingress
path — the already-exists/does-not-exist mapping needed no live account but
had no coverage.

Drops list_ingress/2 from the behaviour: it had no caller and returned an
un-normalized [map()] across a provider-neutral boundary, which section 3.2
forbids. Re-add with a normalized struct when something needs it.
First implementation of the object-store contract. AwsBucket now delegates
its five S3 calls here and holds none of its own; it stays because its call
sites pass region as the first positional argument, which the neutral
behaviour does not.

The behaviour's shape was checked against real usage rather than guessed:
get/put/delete/list/upload_file/put_object_tags plus the container triple
cover every S3 call in AwsBucket, AwsManager, ReleaseTracker and
TerraformState.

delete_all_objects/3 is deliberately NOT a callback — it is built on S3's
bulk-delete API, which has no portable equivalent; a provider without one
would loop delete_object/3.

Fills the AWS descriptor's object_store slot.
Neutral callbacks over the existing AWS-specific functions: a network is a
VPC, an identity is an IAM instance profile, an image is an AMI. find_subnet
narrows find_subnet_ids to one id and returns not_found on an empty list
rather than an empty-list success.

No existing function changes; the seven public functions its call sites use
are pinned by test.
Adds to_instance/1, the neutral normalizer, alongside parse_instance_info/1,
which stays untouched because its key set is the frozen find_nodes --format
json contract.

The neutral tag-filter callback is named list_instances, not
find_instances_by_tags: AwsMachine already exports the latter returning
provider-shaped maps to seven Mix-task call sites, and two return types must
not share one name. The caller sweep renames them together.

find_app_instances/3 enforces the three clauses find_instance_details/3 has
always had but never stated — unconditional project scope, running-only, and
non-nil instance group. Verified live: 3 cfx_web instances, all running.

run_instance, terminate_instance, put_tags and delete_tags are optional
callbacks. Those calls live in the QA-node and load-test subsystems that
Phase 5 extracts; implementing them here now would be unused code with no
test that could fail.
S3 caps a list response at 1000 keys and signals more via is_truncated.
S3ObjectStore.list_objects issued a single request, so it silently
truncated while its own docstring promised pagination to completion.

MEASURED against cfx-deploys-prod: 1000 keys returned vs AwsManager's
7138. Absorbing AwsManager into this module, as the plan directs, would
have made release discovery see one release in seven — breaking change
detection and rollback with no error.

Now matches AwsManager exactly (7138, identical key sets) and still
honours prefix filtering.
EC2 caps a DescribeInstances response and signals more via nextToken.
fetch_instances/1 issued a single request, so on a large account it
returned {:ok, partial} — success, silently short. Every caller inherits
it: tag filters, setup-state queries, find_instance_details, the new
Cloud.Machine callbacks, and mix deploy_ex.find_nodes.

Not a deliberate choice: AwsAutoscaling.fetch_all_asgs/5 already
paginates the ASG API for the same reason.

MEASURED against the live account: forcing max_results: 5 across 18
instances returns all 18 with identical instance ids, where a single
request would have returned 5. Second bug of this class after
S3ObjectStore.list_objects.
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.

1 participant