Commit Graph

6 Commits

Author SHA1 Message Date
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
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
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
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