Commit Graph

12 Commits

Author SHA1 Message Date
b9fc4f9cf3 Phase 2: droplet create/destroy saga
The most load-bearing write workflow — droplet provisioning is the spine
of phase 4a deployment onboarding.

DigitalOcean.Client: create_droplet, get_droplet, list_droplets_by_tag,
destroy_droplet. list_paginated/3 now threads caller-supplied params
(opts[:params]) through pagination so tag-filtered listing works.

Four droplet saga steps:
- CreateDroplet — POST a droplet, tagged arcadia-saga-<saga8> +
  managed-by-arcadia-cloud. Idempotency: re-run checks context for
  droplet_id, then queries DO by the saga tag, so a crash between POST
  and context-save adopts the existing droplet. compensate destroys it.
- WaitDropletActive — polls get_droplet until status "active" (96x5s);
  records the public IP. No compensation (waiting has no side effect).
- RegisterDroplet — fetches the droplet, upserts it into cloud_resources
  (inventory consistent immediately, not at next 15-min sync) and writes
  cloud_provisioned desired-state {size_slug, region, image}. compensate
  removes the DB rows (the droplet itself is destroyed by CreateDroplet's
  compensate).
- DestroyDroplet — DELETE the droplet + mark its cloud_resources row
  deleted. Terminal/irreversible: compensate is a logged noop, per the
  saga design destroy-class steps don't roll back.

Provisioning helpers:
- provision_droplet/1 — [CreateDroplet, WaitDropletActive, RegisterDroplet]
- destroy_droplet/2   — [DestroyDroplet]

Live smoke verified end-to-end (full create + destroy on a real
s-1vcpu-512mb-10gb droplet in syd1):
- provision saga completed: droplet 572017320 created, reached active
  with public IP, registered into cloud_resources (status=active) +
  cloud_provisioned (spec recorded).
- destroy saga completed: cloud_resources row marked deleted; droplet
  confirmed 404 on DO afterward. Account back to its original 5
  droplets, zero leftover, ~1 cent total cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:31:47 +10:00
e3bcd3fc77 Phase 2: DNS record write primitives
DigitalOcean.Client DNS record CRUD:
- list_domain_records/2, create_domain_record/3, update_domain_record/4,
  delete_domain_record/3 on /v2/domains/:domain/records.

Steps.UpsertDnsRecord — idempotent ensure-record:
- (type, name) absent      -> create
- present, data matches    -> no-op
- present, data differs    -> update, stashing prior state
Match comparison normalizes trailing dots (DO appends them to CNAMEs)
so a re-run doesn't false-positive into an update. compensate: deletes
records it created, restores prior data/ttl for records it updated.

Steps.DeleteDnsRecord — deletes a record by (type, name); idempotent
(already-absent succeeds); compensate recreates from stashed prior state.

Both read dns_domain / dns_record_type / dns_record_name / dns_record_data
/ dns_record_ttl from saga inputs.

Live smoke verified against sky-ai.com with TXT records (non-disruptive):
- [UpsertDnsRecord, Fail]: record created then compensation deleted it
  (saga rolled_back, record absent after).
- [UpsertDnsRecord] twice: first outcome "created", second "noop"
  (idempotency holds).
- [DeleteDnsRecord]: record deleted, absent after.
Both test records cleaned up — zero leftover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:10:33 +10:00
445b7b60d4 Phase 2: drift detection
Compares cloud_provisioned.spec (what we asked DO for) against the live
cloud_resources row (what DO actually has). Any divergence becomes an
operator-resolvable drift record.

cloud_drift table: one row per drifted field. status open/accepted/
reverted/stale. Partial unique index keeps at most one OPEN drift per
(resource, field); resolved rows are retained as history.

ArcadiaCloud.Drift context:
- detect_all/0 — sweeps every provisioned resource. Per field in spec,
  resolves the actual value (top-level schema field first, then attrs),
  compares with loose equality (stringified scalars; lists as sets so
  JSON round-trips don't false-positive). Mismatches upsert a cloud_drift
  row + emit a drift_detected event in the resource event log.
- close_stale_drift — an open drift whose field no longer mismatches
  (fixed elsewhere) closes as "stale" on the next sweep.
- accept_drift/2 — the live value becomes the new desired-state: parent
  cloud_provisioned.spec is updated, spec_version bumped, drift closed
  "accepted". Revert (mutating live infra back to spec) is intentionally
  NOT here — it needs a saga and lands with the droplet-resize work.

DriftDetectionWorker — Oban cron at :20 past the hour, offset past the
:15 resource syncs so it compares against fresh inventory.

Provisioning.record_provisioned/3 — populates cloud_provisioned desired-
state (upsert on resource_id, bumps spec_version). Future provisioning
sagas call this; for now it's how drift gets something to detect.

API (platform_admin only):
- GET  /api/v1/drift            — open drift inbox
- POST /api/v1/drift/:id/accept — adopt live value as desired-state

Smoke verified: recorded a droplet's desired spec with a deliberately
wrong size_slug + a correct region; detect_all flagged only size_slug,
wrote the drift_detected event; accept updated the spec to the live
value and closed the drift; re-detect found zero drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:08:27 +10:00
b1a124f044 Phase 2: first real DO write step — CreateDropletSnapshot
DigitalOcean.Client write methods:
- create_droplet_snapshot/3 — POST a snapshot action (async)
- get_droplet_action/3      — poll action status
- list_droplet_snapshots/2  — snapshots for a droplet
- delete_snapshot/2         — DELETE (used by compensation)
All use the "provisioning" token purpose.

Steps.CreateDropletSnapshot — the first saga step that touches real
infra:
- execute: deterministic snapshot name (arcadia-snap-<droplet>-<saga8>);
  checks context for a prior snapshot_id, then checks DO for a snapshot
  already carrying that name (crash-between-post-and-save recovery),
  then posts the action, polls to completion, finds the resulting
  snapshot, records snapshot_id + snapshot_name in context.
- compensate: deletes the snapshot; treats HTTP 404 as success.

Provisioning.snapshot_droplet/2 — convenience saga starter.

Two DO eventual-consistency gotchas surfaced + handled:
- After a snapshot action reports "completed", the snapshot lags a few
  seconds before appearing in /droplets/:id/snapshots. The step now
  retries the lookup (find_snapshot_with_retry, 12x5s) instead of
  failing with :snapshot_not_found_after_completion.
- Deletion has the same lag the other way — a deleted snapshot lingers
  in the listing briefly. compensate just trusts the DELETE 2xx/404;
  no post-delete verification needed.

Live smoke verified end-to-end against holyspiritbraypark.com:
[CreateDropletSnapshot, Fail] saga — the step created real snapshot
229305609, the Fail step triggered compensation, compensation deleted
the snapshot. Final: saga rolled_back, ledger
[create_droplet_snapshot: compensated, fail: failed], zero leftover on DO.

Test-harness note: smoke tests create sagas via Provisioning.create_saga
(no Oban enqueue) so a single manual Runner.perform/1 owns execution —
start_saga/1 enqueues an Oban job, and running both racing the same saga
corrupts the step ledger. Production only ever runs via Oban.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:38:35 +10:00
3274a4adab Phase 2 saga engine: compensation-based runner + step contract
The architectural spine of every write workflow phase 2+ — provisioning,
suspension, offboarding, updates, rollback — all ride this engine. Each
becomes a step list, not new orchestration code.

Schemas:
- saga_runs           — kind, status (pending/running/completed/failed/
                        compensating/rolled_back), step_modules, current
                        step idx, accumulating context (jsonb), cancel
                        flag, error.
- saga_step_results   — per-step audit ledger: status (running/completed/
                        failed/compensated), output, attempts, timings,
                        unique (saga_id, step_idx).
- cloud_provisioned   — desired-state for resources WE provisioned. spec
                        + spec_version + saga_id; FK to cloud_resources.
                        Phase 2's drift-detection diff lands here.

Step contract (ArcadiaCloud.Provisioning.Step):
- execute(state) -> {:ok, state} | {:error, reason}
- compensate(state) -> :ok | {:error, _}   (optional)
- name() -> String.t()

SagaState carries the live execution state — accumulating context,
immutable inputs, current step_idx. Helpers: get_output/put_output
(context r/w), get_input (inputs read-only).

Runner (Oban worker, queue: provisioning, max_attempts: 1):
- kick_off: pending -> running, run_step(0)
- run_step: idempotent re-entry on saga.current_step_idx; persists step
  result + saga context after each step; recursive forward walk through
  the whole step list within one perform/1 call.
- safe_execute: try/rescue/catch around module.execute so a raised
  exception triggers compensation rather than blowing up the worker.
- start_compensation: status=compensating, walk from idx-1 down to 0,
  calling compensate/1 where it's exported; logs but doesn't halt on
  compensate failure (best-effort + audit log).
- cancellation: checked between steps; cancel_requested=true -> trigger
  compensation from current idx.
- crash recovery: max_attempts: 1 + run_step keyed on
  saga.current_step_idx means Oban requeue picks up at the right place,
  but full crash-resume infra is deferred to phase 2.5 (manual re-enqueue
  works for now).

Two proof-of-concept steps (Steps.Echo, Steps.Fail) demonstrate the
engine without any DO API exposure. First real DO write step lands in
the next chunk.

Provisioning context provides start_saga/1, list_sagas/1,
list_step_results/1, cancel_saga/1, upsert_step_result/3.

Live smoke verified end-to-end:
- [Echo, Echo, Echo] happy path: all 3 completed, context accumulated
  echoed_at_step_0/1/2 = "hello".
- [Echo, Echo, Fail] failure path: step 2 failed, compensation walked
  back through step 1 then step 0; final status rolled_back with error
  {compensate_from_idx: 1, reason: "step_failed:fail"}; ledger shows
  echo/echo/fail with statuses compensated/compensated/failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:24:30 +10:00
ea3101ca2f Close inventory gaps: Spaces buckets (URN-discover), droplet backups, snapshot URN aliases
Two patterns added:

1. ProjectsWorker now does URN-discover for kinds without a dedicated
   sync worker (spaces_bucket, managed_db, k8s_cluster, etc.). For these,
   it inserts a minimal placeholder row when the URN points to something
   not yet in inventory. Kinds with dedicated workers (droplet, snapshot,
   volume, etc.) still get attribution-only — the worker is source of
   truth for richer attrs. Implemented by splitting attribute_or_discover/4
   on a @dedicated_kinds whitelist.

2. New BackupsWorker pulls /v2/droplets/:id/backups for each active
   droplet. DO automated backups aren't in /v2/snapshots; they live per
   droplet. Cron: hourly at :41. Kind="droplet_backup".

URN normalization extended for two more aliases DO emits:
  "volumesnapshot" → snapshot   (was creating a duplicate row)
  "image"          → snapshot   (DO droplet snapshots show as do:image:id)

Billing.find_resource/1 gets a kind-specific clause for droplet_backup
that matches to the parent droplet by name, since invoice lines for
backups read "<droplet-name> (Weekly Backup Services)" — the line is a
per-droplet subscription, not a per-backup-snapshot fee.

Live verified on the same April 2026 invoice:
- 6 Spaces buckets discovered via URN (account has 6, only 1 visible in
  the invoice as the $5 subscription line — that's account-level so it
  can't tie to a specific bucket, expected).
- 4 droplet backups discovered via BackupsWorker; the git.sky-ai.com
  backup line now matches (repo.sky-ai.com backup line can't match — that
  droplet was destroyed).
- Of 16 unmatched lines: 11 are destroyed historic resources, 1 is GST,
  1 is the account-level Spaces subscription, 3 are likely tiny snapshot
  name variances. Effectively ~100% of currently-existing billable
  resources match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:46:29 +10:00
8bdf500214 Round out DO sync workers: volumes, snapshots, floating IPs, firewalls, LBs
Five new Oban workers, all on cloud_sync_full following the established
droplet/domains pattern (list → normalize → upsert → mark_stale):

- VolumesWorker         — block storage
- SnapshotsWorker       — both droplet and volume snapshots (kind="snapshot"
                          with attrs.resource_type to differentiate)
- FloatingIpsWorker     — provider_id is the IP; status assigned/unassigned
- FirewallsWorker       — inbound/outbound rules + droplet_ids in attrs
- LoadBalancersWorker   — name + region + algorithm + forwarding rules

DigitalOcean.Client gains list_snapshots / list_firewalls / list_load_balancers.

ProjectsWorker URN normalization extended: "floatingip" → floating_ip,
"loadbalancer" → load_balancer, "dbaas" → managed_db. URNs DO emits don't
have underscores for these.

Cron updated: new workers run every 15min on cloud_sync_full; snapshots
moved to hourly (at :33) since they change slowly and listing them is
the most-paginated call we make.

InvoiceIngestWorker.derive_kind/2 reordered to check specific phrases
before generic products — "Droplet Snapshots"/"Droplet Backups" no longer
get bucketed as kind=droplet ahead of the actual snapshot check. Also
adds kind="droplet_backup" for DO's automated backup billing (separate
from the snapshot kind because backups aren't exposed via /v2/snapshots).

Live verified: 12 snapshots discovered + 1 firewall (account has no
volumes / floating IPs / LBs at the moment, so those workers ran clean).
April 2026 invoice match rate jumped from 18.2% → 51.5%. Of the
unmatched: 10 historic droplets that no longer exist on DO, 2 backups
(separate API surface), 1 Spaces bucket (S3 API, deferred), 1 GST
(correctly no kind). Effectively ~95% of currently-extant resources match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:41:12 +10:00
4f2e52af01 Wire skyai-finance push: cloud invoices flow into Sky AI's books
After InvoiceIngestWorker writes cost lines and matches resources, it
pushes a normalized invoice payload to skyai-finance-svc's new endpoint
POST /api/v1/integrations/cloud/import-invoice. Idempotent — finance
dedups on (tenant_id, source, external_id), so re-runs return "updated"
not duplicate rows.

ArcadiaCloud.Integrations.SkyaiFinance is the HTTP client. Auth is a
short-lived JWT minted via Guardian (shared secret per memory) with
identity {tenant_id: "platform-admin", roles: ["admin"]} — the role
satisfies finance's RequireWriteRole plug. Identity + base URL are
configurable; SKYAI_FINANCE_URL env var can override the default
http://localhost:4010 for arcadia-dev / prod.

GST line detection: lines whose description contains "gst" or "tax"
get summed into amount_tax_minor; everything else into amount_net_minor;
sum stays gross. Phase 1 enough — proper tax handling lands when
real per-tenant invoices flow.

cloud_invoices gains pushed_to_finance_at + finance_invoice_id so we
don't re-push uselessly (Billing.mark_invoice_pushed/2 records both).
A missing finance config (no :skyai_finance app env) makes the push a
silent skip rather than a worker failure — environments without finance
configured still get a working ingest.

Live verified end-to-end against both services:
- April 2026 DO invoice (33 lines, $86.92) lands in finance as a row
  with gross=$86.92, tax=$7.90, source=digitalocean, tenant=platform-admin
- DigitalOcean vendor auto-created (category=cloud) under platform-admin
- Re-running the worker returns action: "updated" not "created";
  finance still has exactly 1 row for the invoice

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:35:52 +10:00
0079f98bb5 Phase 1 cost ingestion: balance + invoices + CSV parse + resource match
Three new schemas:
- cloud_balance_snapshots — hourly MTD balance/usage poll for live-accrual.
- cloud_invoices — header per provider invoice, with ingest status flags.
- cloud_cost_lines — per-line-item COGS, FK to cloud_resources where matched.

Three new Oban workers (queue: cloud_billing):
- BalanceWorker (hourly) records a snapshot.
- BillingHistoryWorker (daily) discovers invoices via /v2/customers/my/
  billing_history, upserts headers, enqueues an InvoiceIngestWorker for
  each not-yet-ingested invoice.
- InvoiceIngestWorker (per-invoice) fetches /invoices/:uuid/csv, parses
  with NimbleCSV (header-keyed so column order shifts don't break us),
  replaces the invoice's line set, then matches lines to cloud_resources
  by (kind, name) — case-insensitive, name extracted from "name (size)"
  description format.

DigitalOcean.Client gains get_balance / list_billing_history /
get_invoice_summary / fetch_invoice_csv. The CSV endpoint returns text/csv
so we bypass Req's body decoder.

Cron additions: BalanceWorker hourly at :07, BillingHistoryWorker daily
at 02:23.

API:
- GET /api/v1/billing/balance — latest snapshot, platform_admin only.
- GET /api/v1/billing/cost-lines?period=YYYY-MM-DD&kind&limit — per-line
  COGS, platform_admin only.

Live smoke against real DO billing API surfaced and fixed three CSV-format
gotchas: column headers use underscores not spaces (group_description,
project_name), USD column has $ prefix, dates use "YYYY-MM-DD HH:MM:SS
+0000" format (space separator + RFC822 offset).

Verified: 137 historical invoices discovered going back to 2014;
April 2026 invoice (33 lines, $86.92 total) ingested with 6/33 lines
matched to current cloud_resources. Unmatched lines are correctly
historic droplets, Spaces buckets (not yet synced), and GST.

NimbleCSV ~> 1.2 added as a dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:20:50 +10:00
53b664558d Phase 1 continued: ProjectsWorker, DomainsWorker, Oban Cron schedule
ProjectsWorker mirrors DO Projects to cloud_projects table in a two-pass
sweep: upsert projects, then walk each project's resource membership
(list_project_resources) and update cloud_resources.cloud_project_id +
tenant_id. DO URN kinds get normalized via normalize_kind/1 (domain →
dns_zone, space → spaces_bucket) so attribution matches local naming.

DomainsWorker syncs DNS zones (DO Domains). Same upsert chokepoint, same
three-strike stale handling. Zones are global to the account; attribution
happens via ProjectsWorker if a domain is in a DO project, else stays
NULL pending operator classification.

Oban.Plugins.Cron added with 15-minute schedules for ProjectsWorker,
DropletsWorker, DomainsWorker — workers run automatically once a token
is configured. Phase 0/1 cadence; phase 2 moves droplets to cloud_sync_fast
(1-min) for real-time status visibility.

DigitalOcean.Client gains list_domains / list_volumes / list_floating_ips.
Volumes and floating IPs not yet wired to workers; trivial follow-on.

Live smoke test: 5 droplets + 7 DNS zones discovered, all attributed to
their existing DO projects via membership lookup (skyai-internal becomes
the fallback only for genuinely orphan resources).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:11:40 +10:00
c1cbd434ac Phase 1 first chunk: inventory schema + DO droplet sync
Models:
- cloud_projects: arcadia-cloud's mirror of DO Projects, indexed by
  (provider, provider_id); tenant_id + purpose classify each project.
- cloud_resources: single unified resource table; kind-specific bits in
  attrs JSONB; first_seen_at / last_seen_at / stale_strike_count drive
  three-strike deletion.
- cloud_resource_events: append-only audit (discovered, updated, deleted,
  drift_detected, tagged, restored).

ArcadiaCloud.Cloud context owns the single upsert chokepoint that:
- inserts new with `discovered` event
- updates existing only when meaningful fields change
- restores tombstoned rows seen again
- bumps last_seen_at and resets strike count
mark_stale/3 implements the three-strike rule.

ArcadiaCloud.DigitalOcean.Client is a Req wrapper with auto-pagination.
Per-purpose token resolution via .Tokens (phase 1: env DO_API_TOKEN;
phase 2: vault). Per project_arcadia_cloud memory the long-term shape
is one PAT per queue purpose for rate-limit isolation.

ArcadiaCloud.Sync.Bootstrap ensures the skyai-internal DO Project exists
on first sync, idempotent thereafter. ArcadiaCloud.Sync.DropletsWorker
runs full droplet sync on the cloud_sync_full Oban queue.

InventoryController wired to real data: platform_admin sees all,
tenants see only their scope.

Live smoke test against real DO: 5 droplets synced; skyai-internal
project auto-created; events written; endpoint returns scoped results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:07:29 +10:00
5959479ce1 Phase 0 scaffold: arcadia-cloud Phoenix service
API-only Phoenix 1.8 project for cloud-ops, inventory, billing, and
provisioning sagas. Validates arcadia JWTs via shared Guardian secret
(verify-only; arcadia-app remains the issuer).

Deps beyond default Phoenix: guardian, cors_plug, oban, req.
Postgres on local port 5433 per arcadia stack convention.
Endpoint runs on :4005.

Endpoints:
- GET /api/health         — public, returns service identifier
- GET /api/v1/inventory   — auth-gated, returns empty list (phase 0 stub)

Oban configured with the queues phase 1+ will need:
provisioning / cloud_sync_fast|full|slow / cloud_billing / metering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:51:11 +10:00