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>
This commit is contained in:
2026-05-19 22:07:29 +10:00
parent a66dde6618
commit c1cbd434ac
10 changed files with 658 additions and 4 deletions

View File

@@ -0,0 +1,65 @@
defmodule ArcadiaCloud.Repo.Migrations.CreateInventory do
use Ecto.Migration
def change do
create table(:cloud_projects, primary_key: false) do
add :id, :binary_id, primary_key: true
add :provider, :string, null: false
add :provider_id, :string, null: false
add :name, :string, null: false
add :tenant_id, :binary_id
add :purpose, :string, null: false
add :metadata, :map, default: %{}, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:cloud_projects, [:provider, :provider_id])
create index(:cloud_projects, [:tenant_id])
create index(:cloud_projects, [:purpose])
create table(:cloud_resources, primary_key: false) do
add :id, :binary_id, primary_key: true
add :provider, :string, null: false
add :provider_id, :string, null: false
add :kind, :string, null: false
add :name, :string, null: false
add :region, :string
add :status, :string, null: false
add :size_slug, :string
add :cloud_project_id, references(:cloud_projects, type: :binary_id, on_delete: :nilify_all)
add :tenant_id, :binary_id
add :deployment_id, :binary_id
add :tags, {:array, :string}, default: [], null: false
add :attrs, :map, default: %{}, null: false
add :first_seen_at, :utc_datetime, null: false
add :last_seen_at, :utc_datetime, null: false
add :stale_strike_count, :integer, default: 0, null: false
add :deleted_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:cloud_resources, [:provider, :provider_id])
create index(:cloud_resources, [:kind])
create index(:cloud_resources, [:tenant_id])
create index(:cloud_resources, [:deployment_id])
create index(:cloud_resources, [:cloud_project_id])
create index(:cloud_resources, [:last_seen_at])
create index(:cloud_resources, [:deleted_at])
create table(:cloud_resource_events, primary_key: false) do
add :id, :binary_id, primary_key: true
add :resource_id, references(:cloud_resources, type: :binary_id, on_delete: :delete_all),
null: false
add :event, :string, null: false
add :before, :map
add :after, :map
add :source, :string, null: false
add :occurred_at, :utc_datetime, null: false
end
create index(:cloud_resource_events, [:resource_id, :occurred_at])
create index(:cloud_resource_events, [:event])
end
end