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

@@ -1,12 +1,62 @@
defmodule ArcadiaCloudWeb.InventoryController do
@moduledoc """
Cloud resource inventory. Phase 0 stub — returns an empty list.
Phase 1 wires this to `cloud_resources` filtered by tenant scope.
Cloud resource inventory.
Scope rules (phase 1):
- platform_admin tenants see every non-deleted resource
- other tenants see only resources tagged to their tenant_id
Filters: `?kind=droplet`, `?deployment_id=...`
"""
use ArcadiaCloudWeb, :controller
def index(conn, _params) do
json(conn, %{resources: []})
alias ArcadiaCloud.Cloud
def index(conn, params) do
identity = conn.assigns.current_identity
base_opts =
[]
|> maybe_put(:kind, params["kind"])
|> maybe_put(:deployment_id, params["deployment_id"])
opts =
if platform_admin?(identity) do
base_opts
else
Keyword.put(base_opts, :tenant_id, identity.tenant_id)
end
resources =
Cloud.list_resources(opts)
|> Enum.map(&shape/1)
json(conn, %{resources: resources, count: length(resources)})
end
defp platform_admin?(%{roles: roles}) when is_list(roles), do: "platform_admin" in roles
defp platform_admin?(_), do: false
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, _key, ""), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp shape(r) do
%{
id: r.id,
provider: r.provider,
provider_id: r.provider_id,
kind: r.kind,
name: r.name,
region: r.region,
status: r.status,
size_slug: r.size_slug,
tenant_id: r.tenant_id,
deployment_id: r.deployment_id,
tags: r.tags,
first_seen_at: r.first_seen_at,
last_seen_at: r.last_seen_at
}
end
end