Provider protocol, migration and plan switches
## Requirement
Original requirement (epic, verbatim): "Add support for GCP and Azure with the same functionality as AWS:
- Your Cloud (former Your AWS)
- NodeLink may work on GCP and Azure
- What else we have AWS specific
Out of Scope: Jaah Hosted (still remains AWS only)"
Operator constraint (PRD R9, verbatim): "Make sure as much code as possible is shared between all three providers with minimum specific."
PRD R8 — Plan switch per provider (D8), verbatim:
- AC8.1 Three plan switches — AWS, GCP, Azure "Your Cloud" — in `SWITCHES` and the console twin, editable per plan and per org override. AWS is ON for every plan and org that can launch Your AWS today (no regression); GCP and Azure ship OFF by `(default)` per the Task 6010 rule. → verify: Billing › Plans shows the three switches with those values.
## Scope (this child = the foundation every other child builds against; Architecture §2.1, §2.3)
1. New package `apps/api/app/clouds/`: `base.py` with the pinned contract below, `__init__.py` with `REGISTRY: Mapping[ProviderKey, CloudProvider]`, `provider_of(key) -> CloudProvider` (422 on unknown) and `require_provider(session, org_id, key) -> CloudProvider` (403 "<Label> is not on your plan. Upgrade in Billing." when the org's switch for that provider is OFF). A shared `FakeProvider` test double (tests/) every later shared test uses. Register nothing but what exists: an AWS entry is wired by the next child; leave GCP/Azure registration to their adapter children (the registry must accept late registration).
Pinned contract (verbatim from Architecture §2.1):
```python
ProviderKey = Literal["aws", "gcp", "azure"]
@dataclass(frozen=True)
class InstanceRef: provider: ProviderKey; instance_id: str; region: str; zone: str | None = None; scope: str | None = None # persisted in Node.infra JSONB
@dataclass(frozen=True)
class Placement: zone: str | None; subnet: str
@dataclass(frozen=True)
class LaunchSpec: node_id: str; node_name: str; org_id: UUID; instance_type: str; os: Literal["ubuntu","windows"]; image: str; root_gb: int; nested: bool; user_data: str; labels: Mapping[str,str]
@dataclass(frozen=True)
class InstanceFacts: state: Literal["pending","running","stopping","stopped","terminated"]; instance_type: str; disk_gb: int; zone: str | None; private_ip: str | None
@dataclass(frozen=True)
class Option: value: str; label: str
@dataclass(frozen=True)
class ConnectionDraft: provider: ProviderKey; credential_id: UUID; region: str; network: Mapping[str, Any]
@dataclass(frozen=True)
class ResolvedNetwork: network: dict[str, Any]; warnings: list[str]
@dataclass(frozen=True)
class TypeSpecs: instance_type: str; family: str; vcpu: int; memory_mib: int; architecture: Literal["x86_64","arm64"]; gpu_count: int; bare_metal: bool; nested_virtualization: bool; windows_capable: bool; network_performance: str | None
@dataclass(frozen=True)
class TypePrice: on_demand_usd_h: Decimal | None; spot_usd_h: Decimal | None; reason: str | None
@dataclass(frozen=True)
class PriceResult: prices: Mapping[str, TypePrice]; reason: str | None
class CloudProvider(Protocol):
key: ProviderKey; label: str; credential_type: str; switch: str; account_noun: str; user_data_limit: int
def connection_fields(self) -> list[ConnectionField]: ...
def regions(self, cred) -> list[str]: ...
def lookup(self, cred, field: str, region: str, **parent: str) -> list[Option]: ...
def resolve_network(self, cred, conn: ConnectionDraft) -> ResolvedNetwork: ...
def placements(self, conn) -> list[Placement]: ...
def resolve_image(self, cred, conn, os: str, arch: str) -> str: ...
def launch(self, cred, conn, spec: LaunchSpec, at: Placement) -> InstanceRef: ... # idempotent on node_id
def find(self, cred, conn, node_id: str) -> InstanceRef | None: ...
def is_capacity_error(self, exc) -> bool: ...
def describe(self, cred, ref) -> InstanceFacts: ... # raises InstanceGone
def terminate(self, cred, ref) -> None: ... # also deletes the boot disk
def stop(self, cred, ref) -> None: ...
def start(self, cred, ref) -> None: ...
def set_instance_type(self, cred, ref, instance_type: str) -> None: ...
def grow_disk(self, cred, ref, size_gb: int) -> None: ...
def labels(self, *, node_name, org_id, purpose) -> dict[str,str]: ...
def ensure_break_glass(self, cred, conn) -> str | None: ... # warning, never raises
def after_enrolled(self, cred, ref) -> None: ...
def catalogue(self, cred, region: str) -> list[TypeSpecs]: ...
def prices(self, cred, region: str, types: list[str]) -> PriceResult: ...
```
`ConnectionField = {name, label, kind: "text"|"select"|"multiselect"|"int", required, hint, auto_label, lookup: str|null, parent: str|null}`. Error types stay in `cloud_node_launch` (`CloudNodeError`, `CloudNodeUpstreamError`, `InstanceGone`, `NodeDeletedDuringLaunch`); import them, do not duplicate.
2. ONE additive Alembic migration (two-release-safe; the ONLY schema migration in this epic — later children add none):
- `cloud_node_defaults`: add `network JSONB NOT NULL DEFAULT '{}'` (AWS keeps `subnet_id`/`security_group_id`/`extra_subnet_ids`).
- `allowed_instance_types`: add `provider TEXT NOT NULL DEFAULT 'aws'`; replace unique(`instance_type`) with unique(`provider`,`instance_type`); add `windows_capable BOOL NOT NULL DEFAULT false`, AWS rows backfilled by today's `instance_types.windows_capable` rule.
- `org_shared_stores`: add `provider TEXT NOT NULL DEFAULT 'aws'`, `provider_state JSONB NOT NULL DEFAULT '{}'`.
- `nodelinks`: add `provider_state JSONB NOT NULL DEFAULT '{}'`.
- `billing_plans`, `orgs`: `aws_cloud_allowed` / `gcp_cloud_allowed` / `azure_cloud_allowed` + their `_override` columns; `(default)` row AWS=true, GCP=false, Azure=false; AWS set true on EVERY existing plan row (AS1, N7).
- `Node.infra` JSONB: no column; key `provider` absent ⇒ `aws`, plus `zone`, `scope` — add the read helper here.
3. Three `SwitchSpec` entries in `apps/api/app/plan_terms.py` `SWITCHES` — labels "Your Cloud: AWS" (default True), "Your Cloud: GCP", "Your Cloud: Azure" — and the TS twin `apps/web/src/lib/planSwitches.tsx`, editable per plan and per org override in Billing › Plans.
Recon: `apps/api/app/plan_terms.py:167-199` (SWITCHES), `apps/api/app/models/hosting.py:77` (allowed_instance_types unique), `apps/api/app/models/credentials.py:618` (CloudConnection), `apps/api/app/models/nodelinks.py:61-147`, `(default)` row migration 202610211200, `apps/api/app/durable/cloud_node.py:444-451` (Node.infra keys). Migration revision ids/additive rule: `docs/standards/coding-standards.md`.
Credentials & reach: Unit/integration tests in children | none beyond the worktree (`./dc check quick`/`full`, httpx MockTransport fixtures) | **verified**: nothing external. The dev-CP Billing › Plans oracle needs `jaah/debug/main/operator-login` (operator; dev CP `app.dev.jaah.ai`) | **verified** by the Architect stage (credential_get succeeded, value not recorded).
Out of scope: Do NOT touch Jaah Cloud / Jaah Hosted (`apps/api/app/hosting_*.py`, `HostingPicker.tsx`, `infra/jaah/terraform/hosting/`, rental billing) — remain AWS only (N1). Do NOT touch Jaah's platform AWS usage: `apps/api/app/bedrock/`, CloudWatch node journal, SQS alarm relay, WAF, ECS, KMS, Athena, Cost Explorer under `apps/api/app/aws/` (N2). No GCP/Azure equivalent of the hosting account, headscale, or control-plane infra in `infra/jaah/terraform/` (N3). No change to how AWS behaves for the user beyond the "Your Cloud" wording and the AWS plan switch (default ON) (N7). Do NOT implement adapters, routes, UI or NodeLink here — later children own them. Do NOT fix the pre-existing `nodelink_enabled` vs `nodelink_allowed` TS/Python switch-name drift (F5). No cloud SDK (google-cloud-*, azure-mgmt-*, boto3) — Architecture D1.
Done means:
- AC8.1 Three plan switches in `SWITCHES` and the TS twin, editable per plan and per org override; AWS ON for every existing plan/org, GCP/Azure OFF by `(default)` — verify: a migration test asserts every pre-existing `billing_plans` row has `aws_cloud_allowed = true` and `(default)` has GCP/Azure false; the TS twin and Python SWITCHES list the same three names (test); on dev CP after deploy, Billing › Plans shows the three switches with those values (operator-login).
- `require_provider` returns 403 with exactly "<Label> is not on your plan. Upgrade in Billing." for a switched-off provider and the provider for a switched-on one; `provider_of('nope')` → 422 — verify: unit tests.
- The migration upgrades and downgrades cleanly on a populated DB; existing `allowed_instance_types` rows keep working (now `provider='aws'`) and two rows with the same `instance_type` but different providers are accepted — verify: migration test.
- `FakeProvider` implements every protocol method and is importable by later tests; `mypy` types the protocol (a class missing a method fails type-check) — verify: `./dc check quick` green.
- Existing AWS/hosting suites pass unmodified (`test_cloud_node_launch.py`, `test_cloud_node_workflow.py`, `test_aws_ec2*.py`, `test_hosting_*.py`) — verify: `./dc check full` via the merge lane.
Outcome
Shipped app/clouds (CloudProvider protocol, registry, provider_of 422, require_provider 403), FakeProvider, the epic's one migration (202610211437), and three Your Cloud switches (AWS on, GCP/Azure off) plus the TS twin. Quick gate and changed-area tests green; review lane passed; visual check shows the switches correct on desktop and phone. PR #6548 queued in the merge lane. Deferred to the AWS child: AWS routes calling require_provider. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)