feat: multi-cloud provider seam (Phase 0) - #19
Draft
MikaAK wants to merge 12 commits into
Draft
Conversation
…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.
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.
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-dironterraform.build/ansible.buildplus injectablepem_app_name, so rendered output can be diffed across revisions. Renders were previouslynondeterministic, which made "AWS output unchanged" unverifiable. This is the oracle every later
phase cites.
Provider seam (P0.1) —
DeployEx.Clouddispatcher derived from provider descriptors, fourcapability behaviours (
Machine,ObjectStore,Infrastructure,Security), aprovider-neutral
Cloud.Instancestruct, and per-provider NimbleOptions config validation. The:awsschema is permissive by contract so no config that works today can start failing.Conformance (P0.2) —
AwsMachine,S3ObjectStore,AwsInfrastructureandAwsSecurityGroupimplement their behaviours.
AwsIpWhitelisterandAwsBucketnow delegate and hold no ExAwscalls of their own.
Two truncation bugs found and fixed
Both returned
{:ok, partial}— success, silently short:S3ObjectStore.list_objects/2made one S3 request: 1000 keys vs 7138 against the releasebucket. Absorbing
AwsManagerinto it, as the plan directs, would have made release discoverysee one release in seven — breaking change detection and rollback with no error.
AwsMachine.fetch_instances/1made oneDescribeInstancesrequest with nonextTokenhandling. Pre-existing; feeds tag filters, setup-state queries and
mix deploy_ex.find_nodes.Verified by forcing
max_results: 5across 18 instances and getting all 18.AwsAutoscaling.fetch_all_asgs/5already paginated the ASG API, so both were oversights ratherthan decisions.
Compatibility
parse_instance_info/1is byte-identical tomain— the frozenfind_nodes --format json11-key contract is untouched, verified against live AWS
priv/changed; render output byte-identicalconfig (space-containing resource group, tuple-valued
llm_provider, nestedterraform_default_args) — nothing rejectedVerification
544 tests, 6 failures — all 6 pre-existing on
main(5aws_infrastructure_test, 1CommandRegistryTest).mix compile --warnings-as-errorsclean.Live AWS:
qa.create->qa.deploy->qa.destroyround-trip completed and torn down;find_nodes,instance.statusand the S3/EC2 paths exercised against production.Re-rendered a downstream consumer's 140-file committed
deploys/tree before and after: identicaldiff, identical failure mode, identical exit code.
Not included
ReleaseTracker,TerraformStateandAwsManagerstill hold their own S3 calls. Callerrewiring, flag threading and provider guards (P0.3/P0.4) are separate. No OCI implementation —
every OCI descriptor slot returns
:not_implemented.