Skip to content

feat(oci): multi-cloud provider seam with a working Oracle Cloud target - #21

Open
MikaAK wants to merge 30 commits into
mainfrom
feat/oci-multi-cloud
Open

feat(oci): multi-cloud provider seam with a working Oracle Cloud target#21
MikaAK wants to merge 30 commits into
mainfrom
feat/oci-multi-cloud

Conversation

@MikaAK

@MikaAK MikaAK commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Re-opens the OCI multi-cloud work as a PR: #20's squash merge was reverted on main (e06649a) so this lives in PR form until sign-off; the running opgg prod fleet pins the branch tip directly, so nothing deployed changes.

Everything from #20 plus the follow-ups proven live on the opgg prod fleet: provider seam, OCI object storage via the native CLI, S3-compat terraform state backend, managed PostgreSQL, opt-in ClickHouse and RabbitMQ nodes, Ubuntu-aware redis role, OCI host-firewall and intra-VCN fixes, stable pem names, static-file and setup-playbook sync fixes, upload timeout fix. 733 tests.

MikaAK added 30 commits August 11, 2026 19:48
Adds a provider seam to deploy_ex and implements Oracle Cloud behind it, without
changing a byte of what an AWS user renders. The full OCI chain is verified live
against a real tenancy:

  mix terraform.build → init → plan → apply → drop
  mix ansible.build → ping → setup → deploy
  mix deploy_ex.upload

A release was fetched by the node itself via instance principals, unpacked,
swapped into place, started under systemd, and its release-state written back to
object storage — with no credential on disk.

## The seam

Dispatch is DERIVED from provider descriptor modules rather than written into a
dispatcher: `DeployEx.Cloud` holds no capability module names at all, so adding a
provider costs one registry entry plus one descriptor. Capabilities a provider
has not implemented return `{:error, %ErrorMessage{code: :not_implemented}}`
instead of crashing on a nil module.

Provider-specific templates live under `priv/**/providers/<name>/`, and the AWS
file set is defined by ONE complement rule — everything not under a `providers/`
directory — so gates never need per-provider rewording. The rule tests a path
COMPONENT, not a substring, because an AWS tree legitimately contains a root
`providers.tf`.

## Object storage on OCI

Goes through the `oci` CLI's native API, not OCI's S3-compatibility endpoint.
Three measured reasons: ExAws cannot reach OCI at all (its partition table is
compile-time, so an OCI region can never be registered, and signing with an AWS
region returns 403 because OCI requires the OCI region inside the SigV4
credential scope); the compat endpoint does NOT accept instance principals, so
using it would put a long-lived Customer Secret Key on every node; and no usable
OCI SDK exists for Elixir or Erlang (`ex_oci_sdk` covers only the Queue service,
and "OCI" in the Erlang ecosystem means Oracle Call Interface, a database driver).

`ReleaseUploader.AwsManager` now resolves through `Cloud.capability(:object_store)`,
so `mix deploy_ex.upload` works on whichever provider is configured. Verified in
both directions in one run: 93 keys from an AWS bucket and 11 from an OCI bucket
through the same function.

ClickHouse is the exception that proves the rule — its `s3` disk is a compiled
binary that only speaks S3, so its cold tier must use the compat endpoint with
Customer Secret Keys. Two different auth surfaces on the same storage.

## Silent-failure bugs fixed along the way (these affect AWS too)

Four separate cases of "the work failed, the tool said fine":

- A failed ansible play exited 0. `Task.async_stream` reports task COMPLETION, so
  a run_fn returning `{:error, _}` arrived as `{:ok, {:error, _}}` and matched the
  reducer's SUCCESS clause. Both `ansible.setup` and `ansible.deploy`, both the
  console and TUI paths. In CI: a green pipeline that deployed nothing.
- `mix terraform.drop` exited 0 when the destroy failed, leaving resources alive
  and billing while reporting success. `terraform.refresh` had the same shape.
  Mix does not fail a task on run/1's return value — only a raise does.
- `mix ansible.build` silently skipped template writes whose contents differed and
  still exited 0, so a rebuilt tree kept a stale inventory and setup playbook.
- A timed-out playbook died with a bare "(EXIT) time out" naming nothing, because
  `Task.async_stream` defaults to killing the caller rather than yielding.

Plus two paginated list calls that returned partial results as if complete — S3
`list_objects` returning 1000 of 7138 keys, and EC2 `describe_instances` returning
one page of four.

## Ansible fixes, mostly AWS-affecting

- `grafana_alloy` passed `args: warn: false`, removed in ansible-core 2.14 and a
  hard error since.
- `grafana_alloy` needs `unzip`, which it never declared — the `awscli` role
  happened to install it, so swapping in `oci_cli` on OCI exposed the dependency.
- `ipv6` wrote `~/.aws/config` into a directory nothing created, for the same
  reason.
- Setup started right after `terraform.apply` raced cloud-init's
  unattended-upgrades for the dpkg lock; `beam_linux_tuning` now waits.
- `save_ami` reads the EC2 metadata endpoint and has no OCI counterpart, so it is
  gated on provider like `awscli`/`oci_cli`.
- The OCI release-lookup scripts printed the CLI's error banner to stdout on
  failure, and the role took it as the release name — an auth failure surfaced as
  a 404 on an object literally named "ServiceError:".

## Terraform

OCI renders per-app instances from the same `<app>_project` map the AWS templates
use, plus a release bucket, and a dynamic group and policy for instance
principals. Identity resources go through a second provider aliased to the HOME
region, because OCI routes every IAM write there regardless of where resources
live — without it an apply fails partway, after the network already exists.

The generated map now emits provider-appropriate keys. It previously shipped
AWS-only fields into OCI trees that the module never read, so
`instance_type = "t3.micro"` sat there looking authoritative and changed nothing.

Terraform also generates the SSH keypair on OCI, matching AWS, because
`mix ansible.build` looks for one under a `<kebab-project-name>*pem` glob and the
failure mode without it is opaque.

## ClickHouse

New role: pinned apt install, config.d/users.d fragments, systemd, bound to
`database_*_clickhouse`. Cold tier off by default and rendered whole-or-not-at-all,
since a fragment naming a dead endpoint is a boot risk and a table naming an
undefined policy fails NO_SUCH_POLICY. Verified live: SELECT 1 over TCP and HTTP,
default user reachable over the configured CIDR, and cold-tier-enabled boot with
the expected tiered/hot/s3_cold policy shape.

Loopback is listed unconditionally in allowed networks: ClickHouse's `<networks>`
check is literal and never implicitly permits 127.0.0.1, so on-box healthchecks
fail AUTHENTICATION_FAILED without it.

## Compatibility

The AWS render is byte-identical to before this branch except three deliberate
shared-role fixes above. Verified by rendering both revisions and diffing, not by
inspection. 699 tests; the 6 failures are pre-existing and unrelated.
…ble generators

mix deploy_ex.export_priv produced a wrong tree on any non-AWS project.
PrivRenderer copied all of priv/terraform — including a raw, unrendered
providers/oci/ — and rendered only top-level *.eex, so an OCI project got the
AWS templates plus unrendered .eex files, and an AWS project got
providers/oci/*.tf in its terraform root where tofu would try to load them.

It also carried its OWN copies of the six variable generators. Those had drifted:
terraform.build's were made provider-aware, PrivRenderer's still emitted AWS-only
keys, so an OCI export got instance_type/enable_ebs fields the OCI instance
module never reads — values that look authoritative and do nothing. Extracted to
DeployEx.TerraformVariables so there is one copy and the next change cannot
diverge again.

Ansible export is provider-aware too: it renders the provider's ansible.cfg and
inventory rather than always AWS's, and strips the providers/ subtree so one
provider's exclusive files never land in another's export. The OCI inventory is
written EMPTY on purpose — it is a point-in-time snapshot of a live compartment
that an export cannot know, and mix ansible.build fills it in. Emitting invented
hosts would be worse than an obviously empty file.

Also passes cloud_provider into the setup-playbook render, which raised
"assign @cloud_provider not available" once that template became provider-aware.

VERIFIED: exports for both providers contain only their own files with zero
unrendered .eex; the OCI variables.tf has 0 AWS-only sizing keys and 16 OCI ones;
and the AWS render from terraform.build/ansible.build is byte-identical to before
the extraction.
…al works

Every oci os call relied on the CLI resolving the namespace internally. That
resolution costs a tenancy read, which a correctly-scoped CI credential does not
have — MEASURED with a user holding only object permissions:

  Error: Unable to retrieve namespace internally. Please provide the namespace
  using the option "--['namespace-name']".

Auto-resolution only appears to work when the credential is over-privileged, so
the bug is invisible until someone does the right thing and scopes the CI user
down. Verified end to end afterwards: mix deploy_ex.upload succeeds under
env-var auth as a user that can write release objects and nothing else.

Mutation-tested — removing the flag from list_objects fails the new test.
MEASURED in CI against an umbrella whose six releases are role-named (server,
ingestion, service, pipeline, polling, migrate):

  ** (FunctionClauseError) no function clause matching in IO.chardata_to_string/1
      # 1
      nil
      (elixir) lib/path.ex:671: Path.join/2
      (deploy_ex) lib/mix/tasks/deploy_ex.release.ex:223

ProjectContext.app_path/2 looks the name up in apps_paths() and returns nil when
a release is not also an app directory. A release BUNDLES applications and is
commonly named for its role rather than for any one app, so this is normal
project structure, not misconfiguration — but it killed the entire build on the
first release attempted.

Both call sites now skip the asset steps when there is no app directory to search
in. A release whose name does match an app resolves exactly as before, so
projects where the two coincide are unaffected.
… tagging

MEASURED in CI: every release on a qa/ branch failed with "oci object storage has
no object tagging" AFTER the object had already uploaded, so the job went red
with all six artifacts sitting correctly in the bucket.

Object tagging is an OPTIONAL ObjectStore capability. OCI has none — its nearest
equivalent is user metadata, settable only at put time — so a provider without it
must not fail the upload.

Nothing is lost by skipping it. The tag is write-only inside deploy_ex:
@qa_tag_key appears at its definition and at the single write site and nowhere
else. What actually marks a QA release is the  key prefix, which drives
remote-release lookup and is provider independent — the uploaded keys are already
qa/server/..., qa/ingestion/... and so on. The tag exists for external tooling,
so its absence is logged rather than swallowed.

Not unit-tested: AwsManager.upload/4 and tag_object/4 build their own store opts
and drop the caller's, so there is no seam to inject a stub store through, and a
test would have to shell out to a real oci CLI. Verified by the live CI run
instead. Threading opts through AwsManager would make this testable and is worth
doing separately.
…n OCI

Adds DeployEx.Cloud.OciSecurityGroup behind DeployEx.Cloud.Security and routes
mix deploy_ex.ssh.authorize through Cloud.capability(:security) rather than
calling AwsSecurityGroup/AwsIpWhitelister directly. This was a live blocker: the
GitHub Actions deploy workflow had to drop its ssh.authorize/-r pair because the
task was AWS-only, which meant a runner could not open its own IP and instances
would have to leave SSH permanently open instead.

Uses network security groups, not security lists. The deciding reason is
mutation safety, not blast radius: / act on
individual rules, while a security list has no such API — updating one REPLACES
the whole rule set, so any implementation on that path risks silently deleting
the rules terraform created. On blast radius the two are a wash as wired here,
since the same NSG is attached to every instance's VNIC, mirroring both the
shared subnet and AWS's single shared security group.

Idempotency diverges from AWS deliberately. MEASURED live:  is
idempotent by rule content — adding twice yields one rule and the same id — so
authorize does not pre-check and returns :ok. AWS instead reports a conflict.
Revoking a CIDR with no matching rule is :ok on both.

Terraform gains an NSG with no default rules plus an nsg_ids wiring through the
oci-instance module, so the group exists for the task to target.

Live-verified end to end against the real tenancy by driving the compiled task:
authorize added the SSH rule and left a pre-existing HTTPS rule untouched, revoke
removed exactly that rule, and re-running each was a harmless no-op. Every
resource created was torn down and the compartment audited empty with table
output. 718 tests, the same 6 pre-existing failures, and the AWS render is
byte-identical.
The wizard-parity test asserts every mix task switch is exposed in the TUI. It
has been failing on main, which means it caught nothing — including the switches
this branch added. The provider flag was invisible in the wizard, so there was no
way to pick a cloud from the TUI at all.

Adds the missing inputs: provider on terraform.build, ansible.build,
ansible.deploy and ansible.setup, the five oci_* options on ansible.build, and
the pre-existing gaps this branch did not create (wait/timeout/poll_interval on
autoscale.refresh_status, only_local_release on qa.deploy, and
aws_region/instance_id/git_branch on ansible.setup).

The test now passes, so drift from here on is caught rather than absorbed into an
already-red assertion. Suite goes from 6 pre-existing failures to 5; the
remaining five are AwsInfrastructureTest calling functions that no longer exist,
which is a separate pre-existing issue on main.
…tually exists

The suite has been red on main for a while. Both causes were tests that did not
follow their code:

find_iam_instance_profile/1 accepts a :request_fn seam, but its test passed none,
so `mix test` HIT THE REAL AWS IAM API. It failed with a list of actual instance
profiles from whatever account was configured — meaning the suite depended on
network, credentials, and the contents of someone's AWS account.

parse_key_pairs_response/2 was deleted when key-pair parsing moved inline into
find_key_pair_name/1, but its four tests stayed behind calling a function that no
longer exists.

find_key_pair_name/1 had no injection seam at all — it called ExAws.request
directly — so the current behaviour could not be tested without a live account.
Added :request_fn there, matching find_subnet_ids/1 and find_iam_instance_profile/1.

The tests now exercise the current public API against stubbed responses: newest
matching key pair wins, a single-element key set (which AWS returns unwrapped)
parses, another project's keys are ignored, an empty set is not_found rather than
a crash, and the IAM lookup short-circuits on a configured profile and reports
what IS available when the default is missing.

Suite goes from 6 failures to 0. A green suite is the point — with it red I spent
this session unable to tell my own regressions from inherited ones.
Renders a backend "s3" block into the OCI providers.tf pointed at the
configured state bucket via the region's compat endpoint. Gated on both
terraform_backend :s3 and an :oci release_state_bucket being configured,
so existing OCI projects keep local state until they opt in.

The default state key follows the oracle/<region>/<stack>/terraform.tfstate
layout already in use in the bucket, so deploy_ex state sits alongside
states written by other tooling. Auth is a Customer Secret Key surfaced as
an AWS credentials profile (state_profile) — the compat endpoint accepts
neither OCI signatures nor instance principals. No lock table: OCI has no
DynamoDB, so concurrent applies are unguarded.

Oci.backend_template/0 flips nil -> :s3 accordingly.
Adds a resource_databases map to the OCI render backed by OCI Database
with PostgreSQL — the managed analogue of the AWS RDS path. Regionally
durable storage (no AD pinning), E5 flex shape sized via ocpus/memory,
admin password generated into (remote, private) state like the AWS
random_password approach.

Reachability is a dedicated NSG admitting 5432 from the VCN CIDR: the
subnet security list only admits SSH and OCI filters intra-subnet
traffic too, so subnet membership alone would not connect the nodes.

Static .tf rather than .eex — an empty map creates nothing, so the
template needs no render-time gating.
…-time captures

Module attributes capture DeployEx.Config values when the DEP compiles, so a
consuming project changing its config silently renders stale defaults until a
forced deps.compile. Mix.env() had the same problem for :env — always :dev in
a local shell regardless of `config :deploy_ex, :env`, which rendered an
environment="dev" default while the state key (via Config.env) said prod.
Oracle prices E6 identically to E5 ($0.03/OCPU-hr + $0.002/GB-hr,
measured from the public price API) with newer silicon, so E5 as a
default is strictly dominated. E4 stays available via tfvars for
cost-floor cases.
…irst seed

The static (non-.eex) copy ran only when ./deploys/terraform did not
exist, so an existing tree never received new provider files or module
updates while the .eex renders around it DID update — producing a
render that passes module variables its stale modules do not declare
(measured: instance.tf's nsg_ids against a module missing the
variable, and database.tf never arriving at all).

Static files now sync through write_file every build: identical
contents skip silently, changed contents prompt (or --force), declined
overwrites warn loudly instead of vanishing.
…AD region

Single-AD regions (ap-seoul-1, ap-chuncheon-1) reject
isRegionallyDurable=true with 400-InvalidParameter and require an
explicit availabilityDomain. Default flips to AD-pinned with a
regionally_durable opt-in key for 3-AD regions. Measured on a live
apply against ap-seoul-1.
The psql service rejects a public subnet with 400 "is not a private
subnet" (measured on live apply). The DB now gets a dedicated private
subnet gated on resource_databases: no public IPs and a route table
with no routes, reachable only intra-VCN through the database NSG.
A fresh random pem name every build made each rebuild+apply replace the
key file under a new name while ansible.cfg still pointed at the
previous one — every node UNREACHABLE with "no such identity"
(measured live). The build now reuses whatever name the existing render
carries; the random suffix only seeds the first build.
The role was Debian-only in three ways, all measured on the first prod
setup against OCI Ubuntu 24.04: redis_version pins a +deb13u1 revision
that Ubuntu's archive does not carry; the stack path bolts a bullseye
repo on for libssl1.1, which Ubuntu 24.04 does not ship; and
packages.redis.io publishes no redis-stack-server for noble at all.

Redis 8 folded the stack modules into redis-server itself, so an Ubuntu
host with redis_stack_enabled now installs redis-server 8.x from
packages.redis.io under its own codename and runs it under the plain
redis-server unit (restarted, so the managed conf takes effect over the
package's stock start). Every Debian task keeps its exact prior
behaviour behind a distribution guard; the stack-binary service dance
is keyed off one derived fact, redis_use_stack_binary.
The Debian units run redis as root and /data arrives from the EBS
mount role. Ubuntu's package unit runs as User=redis under
ProtectSystem, so redis.conf's `dir /data` failed the restart with a
bare "No such file or directory" (measured on the prod redis node).
Creates /data owned by redis and whitelists it for the unit via a
systemd drop-in.
Ubuntu's redis-server unit is Type=notify; with `supervised no` redis
comes up (PONG) but never signals ready and systemd times the start
out. Debian's units are Type=simple, where `supervised no` is right, so
the template branches on distribution and Debian renders unchanged.
Renders a DatabaseKey-tagged clickhouse project entry (E6, 2 OCPU/16GB,
200GB boot) into the OCI project map, which routes the node into the
database_*_clickhouse inventory group the existing setup playbook
targets. Opt-in rather than opt-out like redis: ClickHouse is not part
of the default stack on either provider — an AWS user hand-writes the
entry — so an OCI render must not grow a node nobody asked for. AWS
rendering is unchanged.
…nstances

Unlike AWS, OCI applies the security list to traffic between instances
on the same subnet. With only the SSH rule, an app node could not reach
redis, clickhouse or any BEAM peer (measured: 6379 refused server →
redis on the prod fleet). Adds the analogue of the AWS app security
group's self rule: all protocols from the VCN CIDR; the public edge
stays gated.
OCI platform images ship an iptables INPUT chain that REJECTs
everything but SSH, persisted via netfilter-persistent — so with the
VCN wide open an app node still gets "No route to host" from redis,
clickhouse and every BEAM peer (measured on the prod fleet). Debian
AMIs on AWS have no such default. beam_linux_tuning now flushes the
chain to accept-all and rewrites the persisted ruleset, guarded on
Ubuntu + rules.v4 present + a REJECT actually in INPUT, so Debian and
already-open hosts are untouched. Network policy stays with the cloud.
…S VPC default

clickhouse_allowed_network_cidr defaulted to 10.0.0.0/16 (the AWS VPC),
so on OCI the server got AUTHENTICATION_FAILED from clickhouse purely
because 10.20.x was not in <networks> (measured, same class as the
monitoring 10.0.1.x constants). vcn_cidr is now a config :deploy_ex,
:oci key that BOTH the terraform variable default and the OCI ansible
group_vars render from, so they cannot drift.
Redis 8 enforces protected mode against every non-loopback client
when no password is set — the server node's first boot got -DENIED
from redis over the VCN (measured). Reachability is the cloud's job
(VCN security list, no public 6379 at the edge), so protected mode is
off on the Ubuntu path; Debian's stack path keeps the upstream default.
…mq_server role

Single node, on purpose: the app declares quorum queues, which are correct
on one node (Raft majority of 1); more nodes buy HA and a 2-node layout
is the one to avoid (majority of 2 is 2, losing either halts every queue).

Install path is GitHub Releases artifacts — Erlang 27.3 from
rabbitmq/erlang-debian-package (tarball of debs per Ubuntu codename) and
rabbitmq-server 4.1.8 — because RabbitMQ's apt mirrors were unresolvable
or 404 from both a laptop and an OCI node, and Ubuntu's archive carries
3.12. Chain verified live on an Ubuntu 24.04 node before being written
down: Erlang 27 + 4.1.8 active, management plugin, vhost, users,
permissions, HTTP API auth semantics.

Provisioning is plain rabbitmqctl (deploy_ex takes no collection deps),
guarded so reruns do not churn passwords. The management-API user gets
the `management` tag plus read+write on the vhost — BrokerMgmt publishes
through the HTTP API — and no configure. Empty passwords fail the play
loudly instead of shipping a guessable credential.
… seed

Setup playbooks were copied only when ./deploys/ansible was first
created, so a role added to deploy_ex later synced its role tree into
an existing project but never its setup/<name>.yaml — `mix ansible.setup
--only rabbitmq` matched no file and exited 0 having done nothing
(measured). Playbooks already on disk are user-owned and stay untouched;
only absent ones are seeded.
…ad-timeout

Seven parallel ~30MB uploads to OCI from a GitHub runner raced a hard
60s Task.async_stream ceiling: six landed, the seventh's timeout killed
the whole stream (`Task.Supervised.stream(60000) ** (EXIT) time out`,
measured on a real prod build), so the run went red AFTER most
artifacts were in the bucket — a partial release set. Timeout is now
generous, per release, kills only the offending task, and is a switch.
…ad_balancer.enable

Sprint OCI-LB-1: load_balancer.tf (NSG + rules, NLB with explicit
is_private=false + optional reserved_ips, http/https backend sets with
TCP-fallback health checks, listeners), module inputs with null-passthrough
health knobs, load_balancer_public_ips output, nsg_ids splat concat in
main.tf. 14 render-content tests, every assertion mutation-proven.
Sprint OCI-LB-2: instance.tf.eex passes vcn_id + the 8 load_balancer keys
+ reserved_ip_ocid via try() with contract defaults (neither port nor
instance_port — dead on AWS, fixed 80/443 both providers);
load_balancer_public_ips root output keyed by app; load_balancer added to
the <app>_project recognized keys. Render tests T11-T17.
…guide

Sprint OCI-LB-3: commented load_balancer block in the :oci release
variables (AWS clause byte-identical, pinned by test); providers/oci
README documents the NLB shape, every ignored AWS key with reason, the
enable-only gate divergence, NSG posture, and adopted-tree operator rules
(never --force, rebuild flags are tree identity, enable-bit persistence);
guide drops the dead instance_port example.
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