Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
51bc808
feat(oci): multi-cloud provider seam with a working Oracle Cloud target
MikaAK Aug 12, 2026
39f82d0
fix(priv): make export_priv provider-aware and de-duplicate the varia…
MikaAK Aug 16, 2026
de5a069
fix(oci): pass the namespace explicitly so a least-privilege credenti…
MikaAK Aug 16, 2026
2568f2b
fix(release): stop crashing on releases not named after an umbrella app
MikaAK Aug 17, 2026
2a60868
fix(release): do not fail a QA upload when the provider has no object…
MikaAK Aug 17, 2026
de1673a
feat(oci): implement the security capability so ssh.authorize works o…
MikaAK Aug 17, 2026
233dc33
fix(tui): close the wizard parity gaps so the guard actually guards
MikaAK Aug 17, 2026
27fe4ac
fix(test): stop the suite calling live AWS, and test the code that ac…
MikaAK Aug 17, 2026
28f6cbd
feat(oci): wire terraform state to the S3-compatibility backend
MikaAK Aug 17, 2026
dc0bd56
feat(oci): managed PostgreSQL via oci_psql_db_system
MikaAK Aug 17, 2026
7bb7a61
fix(terraform): read render defaults from runtime config, not compile…
MikaAK Aug 17, 2026
116ca97
chore(oci): default compute and postgres to the E6 flex generation
MikaAK Aug 17, 2026
13aec24
fix(terraform): sync static provider files on every build, not only f…
MikaAK Aug 17, 2026
fcaf66e
fix(oci): AD-pinned postgres storage — regional durability needs a 3-…
MikaAK Aug 17, 2026
8c75fd0
fix(oci): managed postgres requires a private subnet
MikaAK Aug 17, 2026
509cb6e
fix(terraform): keep the pem name stable across rebuilds
MikaAK Aug 17, 2026
06e5a89
fix(ansible): make the redis role run on Ubuntu
MikaAK Aug 18, 2026
3cb385d
fix(ansible): give the Ubuntu redis unit a writable /data
MikaAK Aug 18, 2026
16ad874
fix(ansible): tell redis to notify systemd on Ubuntu
MikaAK Aug 18, 2026
e161b42
feat(oci): opt-in ClickHouse node via terraform.build --clickhouse
MikaAK Aug 18, 2026
248ecaf
fix(oci): allow intra-VCN traffic — OCI filters between same-subnet i…
MikaAK Aug 18, 2026
11153ac
fix(ansible): open the OCI image's default host firewall
MikaAK Aug 18, 2026
cb455f9
fix(oci): clickhouse network allowlist reads the VCN CIDR, not the AW…
MikaAK Aug 18, 2026
29991a3
fix(ansible): disable redis protected mode on Ubuntu
MikaAK Aug 18, 2026
5a31a6b
feat(oci): opt-in RabbitMQ node — terraform.build --rabbitmq + rabbit…
MikaAK Aug 18, 2026
6ed8a1a
fix(ansible): seed NEW setup playbooks on every build, not only first…
MikaAK Aug 18, 2026
30f6cd7
fix(upload): per-release timeout 60s -> 10min, overridable via --uplo…
MikaAK Aug 18, 2026
6855c60
feat(oci): NLB module — per-app NSG + network load balancer behind lo…
MikaAK Aug 21, 2026
5b4fcfa
feat(oci): wire load_balancer.* project-map keys into the NLB module
MikaAK Aug 21, 2026
35db936
docs(oci): NLB discoverability — commented tfvars example + README + …
MikaAK Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions bin/render_harness.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
#
# Deterministic-render diff harness.
#
# Renders the AWS terraform + ansible set into <out_dir> with every random input
# pinned, so two renders of the SAME revision are byte-identical and any diff
# between two revisions is a real output change.
#
# Two-revision recipe:
#
# git checkout <commit-A> && bash bin/render_harness.sh /tmp/base
# git checkout <commit-B> && bash bin/render_harness.sh /tmp/head
# diff -r /tmp/base /tmp/head # empty == no AWS output change
#
# The operator (or CI) supplies the two commits; this script renders one.
set -euo pipefail

OUT_DIR="${1:?usage: render_harness.sh <out_dir>}"

PINNED_PEM_APP_NAME="render-harness-pinned"
PINNED_DB_PASSWORD="RenderHarnessPinnedPassword"

# Never rm -rf the caller's argument. An earlier version did, behind a guard that only
# rejected relative paths and directories containing .git/mix.exs — so `/`, `$HOME`, `/opt`
# and any other absolute path all sailed through it. Instead, refuse to touch anything that
# already exists and delete only the two subdirectories this script creates itself.
case "$OUT_DIR" in
/*) ;;
*) echo "render_harness.sh: <out_dir> must be an absolute path, got '$OUT_DIR'" >&2; exit 2 ;;
esac

if [ -e "$OUT_DIR" ] && [ ! -d "$OUT_DIR/terraform" ] && [ ! -d "$OUT_DIR/ansible" ]; then
echo "render_harness.sh: '$OUT_DIR' already exists and is not a previous render dir." >&2
echo " Refusing to touch it. Pass a fresh path." >&2
exit 2
fi

mkdir -p "$OUT_DIR"
rm -rf "${OUT_DIR:?}/terraform" "${OUT_DIR:?}/ansible"

mix terraform.build \
--render-dir "$OUT_DIR/terraform" \
--pem-app-name "$PINNED_PEM_APP_NAME" \
--db-password "$PINNED_DB_PASSWORD" \
--quiet < /dev/null

mix ansible.build --render-dir "$OUT_DIR/ansible" --quiet < /dev/null
34 changes: 0 additions & 34 deletions deploys/ansible/aws_ec2.yaml

This file was deleted.

89 changes: 0 additions & 89 deletions deploys/ansible/roles/save_ami/tasks/main.yaml

This file was deleted.

55 changes: 47 additions & 8 deletions guides/reference/terraform_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,20 @@ my_app_project = {

### `load_balancer`

Listener and forwarded ports are **fixed at 80 (and 443 when `enable_https = true`) on both
providers** — there is no key that changes them. AWS declares `port` / `instance_port` in its
schema but never reads them (dead keys, kept only for backward compatibility); OCI's schema has
no such keys at all.

`enable_https` defaults to `true` on both providers — the examples below set it `false`
deliberately (no TLS cert wired up yet).

**AWS**

```hcl
load_balancer = {
enable = true
enable_https = false
port = 80
instance_port = 4000

health_check = {
path = "/health"
Expand All @@ -111,16 +119,49 @@ load_balancer = {
| Field | Default | Notes |
|-------|---------|-------|
| `enable` | `false` | Required when `instance_count > 1` or autoscaling |
| `enable_https` | `false` | Adds a 443 listener; needs an ACM cert (set up separately) |
| `port` | `80` | LB listener port (the URL clients hit) |
| `instance_port` | `4000` | Forwarded port on the instance |
| `enable_https` | `true` | Set `false` to skip the 443 listener; needs an ACM cert (set up separately) |
| `health_check.path` | `/` | Endpoint hit by the LB |
| `health_check.matcher` | `200-299,301` | HTTP status codes considered healthy |
| `health_check.unhealthy_threshold` | `2` | Failed checks before unhealthy |
| `health_check.healthy_threshold` | `2` | Successful checks before healthy |
| `health_check.timeout` | `5` | Seconds per check |
| `health_check.interval` | `20` | Seconds between checks |

**OCI**

```hcl
load_balancer = {
enable = true
enable_https = false
reserved_ip_ocid = null

health_check = {
path = "/health"
return_code = 200
https_return_code = 200
unhealthy_threshold = 3
timeout = 3
interval = 10
}
}
```

| Field | Default | Notes |
|-------|---------|-------|
| `enable` | `false` | Gates on this alone — OCI has no autoscaling and no `instance_count > 1` requirement the way AWS does |
| `enable_https` | `true` | Set `false` to skip the 443 listener/backend set/NSG rule |
| `reserved_ip_ocid` | `null` | OCI-only. Pins the NLB's public IP to a pre-created reserved IP; unset leaves it ephemeral |
| `health_check.path` | `""` | Empty performs a TCP connect-only check; set performs an HTTP/HTTPS check |
| `health_check.return_code` / `https_return_code` | `200` | OCI-only — a single expected status code, not a range like AWS's `matcher` |
| `health_check.unhealthy_threshold` | `3` | Retries before a backend flips unhealthy (and before recovering it — OCI has one threshold, not two) |
| `health_check.timeout` | `3` | Seconds per check |
| `health_check.interval` | `10` | Seconds between checks |

AWS keys ignored on OCI: `port`, `instance_port`, `health_check.protocol`, `health_check.matcher`,
`health_check.https_matcher`, `health_check.healthy_threshold` — see
`priv/terraform/providers/oci/README.md` for the full AWS-key-to-OCI mapping, the reason each is
ignored, and the per-app NSG security posture.

### `autoscaling`

```hcl
Expand Down Expand Up @@ -280,8 +321,6 @@ my_app = {
instance_count = 2 # bump capacity first
load_balancer = {
enable = true
port = 80
instance_port = 4000
health_check = { path = "/health" }
}
}
Expand All @@ -308,7 +347,7 @@ my_app = {
desired_capacity = 3
cpu_target_percent = 60
}
load_balancer = { enable = true, port = 80, instance_port = 4000 }
load_balancer = { enable = true }
}
```

Expand Down
105 changes: 22 additions & 83 deletions lib/deploy_ex/aws_bucket.ex
Original file line number Diff line number Diff line change
@@ -1,105 +1,44 @@
defmodule DeployEx.AwsBucket do
alias ExAws.S3
@moduledoc """
Region-first bucket helpers for the terraform state-bucket tasks.

@type bucket_res :: %{name: String.t, creation_date: String.t}
The S3 calls now live in `DeployEx.Cloud.S3ObjectStore`, which implements
`DeployEx.Cloud.ObjectStore`. This module stays because its call sites pass the region as the
FIRST argument, which the provider-neutral behaviour does not — it takes region in opts.
"""

alias DeployEx.Cloud.S3ObjectStore

@type bucket_res :: %{name: String.t(), creation_date: String.t()}

@spec create_bucket(String.t()) :: ErrorMessage.t_res(any)
@spec create_bucket(String.t(), String.t()) :: ErrorMessage.t_res(any)
def create_bucket(region \\ DeployEx.Config.aws_region(), bucket_name) do
case ExAws.request(S3.put_bucket(bucket_name, region), region: region) do
{:ok, _} -> :ok
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{bucket: bucket_name})}
end
S3ObjectStore.create_container(bucket_name, region: region)
end

@spec list_buckets() :: ErrorMessage.t_res(bucket_res)
@spec list_buckets(String.t()) :: ErrorMessage.t_res(bucket_res)
def list_buckets(region \\ DeployEx.Config.aws_region()) do
case ExAws.request(S3.list_buckets(), region: region) do
{:ok, %{body: %{buckets: buckets}}} -> {:ok, buckets}
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region})}
end
S3ObjectStore.list_containers(region: region)
end

def list_objects(region \\ DeployEx.Config.aws_region(), bucket_name) do
case ExAws.request(S3.list_objects(bucket_name), region: region) do
{:ok, _} = res -> res
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region, bucket: bucket_name})}
end
S3ObjectStore.list_objects(bucket_name, region: region)
end

def delete_all_objects(region \\ DeployEx.Config.aws_region(), bucket_name, continuation_token \\ nil) do
list_opts = if continuation_token, do: [continuation_token: continuation_token], else: []

case ExAws.request(S3.list_objects_v2(bucket_name, list_opts), region: region) do
{:ok, %{body: %{contents: objects, is_truncated: is_truncated, next_continuation_token: next_token}}} when objects !== [] ->
object_keys = Enum.map(objects, & &1.key)

case ExAws.request(S3.delete_multiple_objects(bucket_name, object_keys), region: region) do
{:ok, _} ->
if is_truncated do
delete_all_objects(region, bucket_name, next_token)
else
:ok
end
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region, bucket: bucket_name})}
end
@doc """
Empties a bucket entirely.

{:ok, %{body: %{contents: [], is_truncated: is_truncated, next_continuation_token: next_token}}} ->
if is_truncated do
delete_all_objects(region, bucket_name, next_token)
else
:ok
end

{:ok, %{body: %{contents: objects}}} when objects !== [] ->
object_keys = Enum.map(objects, & &1.key)

case ExAws.request(S3.delete_multiple_objects(bucket_name, object_keys), region: region) do
{:ok, _} -> :ok
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region, bucket: bucket_name})}
end

{:ok, _} -> :ok
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region, bucket: bucket_name})}
end
`all: true` is passed deliberately — the object store refuses an unscoped delete, and emptying
the bucket is exactly what this function is for. Its only caller drops the terraform state
bucket, whose name comes from config rather than an argument.
"""
def delete_all_objects(region \\ DeployEx.Config.aws_region(), bucket_name, continuation_token \\ nil) do
S3ObjectStore.delete_all_objects(bucket_name, [region: region, all: true], continuation_token)
end

def delete_bucket(region \\ DeployEx.Config.aws_region(), bucket_name) do
case ExAws.request(S3.delete_bucket(bucket_name), region: region) do
{:ok, _} -> :ok
{:error, {:http_error, code, message}} ->
{:error, handle_error(code, message, %{region: region, bucket: bucket_name})}
end
end

defp handle_error(409, message, %{bucket: bucket_name}) do
ErrorMessage.conflict("bucket already exists", %{bucket: bucket_name, message: message})
end

defp handle_error(404, message, %{bucket: bucket_name}) do
ErrorMessage.not_found("bucket not found", %{bucket: bucket_name, message: message})
end

defp handle_error(code, message, %{region: region, bucket: bucket_name}) do
%ErrorMessage{
code: ErrorMessage.http_code_reason_atom(code),
message: message,
details: %{region: region, bucket: bucket_name}
}
end

defp handle_error(code, message, %{region: region}) do
%ErrorMessage{
code: ErrorMessage.http_code_reason_atom(code),
message: message,
details: %{region: region}
}
S3ObjectStore.delete_container(bucket_name, region: region)
end
end
Loading