From 6f27c8cf56e471640b58bd8f34eac35e1820cbac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:05:09 +0000 Subject: [PATCH 1/2] wip: phase 2 sprites provider adapter (in progress) --- src-tauri/Cargo.toml | 3 + src-tauri/src/core/remote_provider_sprites.rs | 478 ++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 src-tauri/src/core/remote_provider_sprites.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0548f789..5ba2d764 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,11 +62,14 @@ duckdb = "1.10505.0" tempfile = { version = "3", optional = true } async-trait = "0.1.92" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] } [dev-dependencies] mockall = "0.14.0" tempfile = "3" criterion = { version = "0.8.2", features = ["html_reports"] } +wiremock = "0.6" [[bench]] name = "commit_workspace" diff --git a/src-tauri/src/core/remote_provider_sprites.rs b/src-tauri/src/core/remote_provider_sprites.rs new file mode 100644 index 00000000..09d81115 --- /dev/null +++ b/src-tauri/src/core/remote_provider_sprites.rs @@ -0,0 +1,478 @@ +//! Fly Sprites provider adapter. +//! +//! Concrete implementation of [`ManagedComputeProvider`] against Fly's +//! Machines-style REST API. Nothing outside this module should import a Fly +//! SDK type or a raw Fly status string; every response is normalized into the +//! provider-neutral types from `core::remote_provider` before it leaves this +//! module. +//! +//! The vendor base URL and API token are read from configuration/secrets at +//! construction time and are never hardcoded or logged. Callers (Edge +//! Function equivalents, or a future control-plane binary) construct +//! [`SpritesConfig`] from environment/secret storage. + +use std::time::Duration; + +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; + +use crate::core::remote_provider::{ + CreateInstanceRequest, ManagedComputeProvider, ManagedInstanceState, ProviderError, + ProviderInstance, ProviderKind, RegionCode, ReplaceInstanceRequest, SizePreset, +}; + +/// Server-side configuration for talking to the Fly Machines API. Never +/// derive `Debug`/`Display` on the token field's containing struct in a way +/// that would print it; `SpritesConfig` intentionally implements a redacted +/// `Debug`. +#[derive(Clone)] +pub struct SpritesConfig { + /// Vendor API base URL, e.g. `https://api.machines.dev/v1`. Read from + /// config, never hardcoded. + pub base_url: String, + /// Fly API token. A server-side secret; never sent to a desktop client. + pub api_token: String, + /// Fly application name that owns provisioned machines. + pub app_name: String, + pub request_timeout: Duration, +} + +impl std::fmt::Debug for SpritesConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpritesConfig") + .field("base_url", &self.base_url) + .field("api_token", &"") + .field("app_name", &self.app_name) + .field("request_timeout", &self.request_timeout) + .finish() + } +} + +impl SpritesConfig { + /// Reads configuration from environment variables. Intended for the + /// server-side process (Edge Function equivalent / control-plane binary) + /// that holds the vendor secret; a desktop client must never call this. + pub fn from_env() -> Result { + let base_url = std::env::var("FLY_SPRITES_API_BASE_URL") + .map_err(|_| ProviderError::InvalidRequest { + message: "FLY_SPRITES_API_BASE_URL is not set".to_string(), + })?; + let api_token = std::env::var("FLY_SPRITES_API_TOKEN").map_err(|_| ProviderError::InvalidRequest { + message: "FLY_SPRITES_API_TOKEN is not set".to_string(), + })?; + let app_name = std::env::var("FLY_SPRITES_APP_NAME").map_err(|_| ProviderError::InvalidRequest { + message: "FLY_SPRITES_APP_NAME is not set".to_string(), + })?; + Ok(Self { + base_url, + api_token, + app_name, + request_timeout: Duration::from_secs(30), + }) + } +} + +/// Fly Sprites adapter. Holds an HTTP client and vendor configuration; no +/// mutable state beyond that lives here, so reconciliation state belongs to +/// the caller (control-plane storage), not the adapter. +pub struct SpritesProvider { + client: Client, + config: SpritesConfig, +} + +impl SpritesProvider { + pub fn new(config: SpritesConfig) -> Result { + let client = Client::builder() + .timeout(config.request_timeout) + .build() + .map_err(|err| ProviderError::Other { + message: format!("failed to build HTTP client: {err}"), + })?; + Ok(Self { client, config }) + } + + fn machines_url(&self) -> String { + format!( + "{}/apps/{}/machines", + self.config.base_url.trim_end_matches('/'), + self.config.app_name + ) + } + + fn machine_url(&self, machine_id: &str) -> String { + format!("{}/{}", self.machines_url(), machine_id) + } + + fn region_slug(region: RegionCode) -> &'static str { + // Treq region codes map to Fly region codes. Kept private to the adapter + // so no vendor slug leaks past this module. + match region { + RegionCode::UsEast => "iad", + RegionCode::UsWest => "sjc", + RegionCode::EuWest => "lhr", + RegionCode::ApSoutheast => "sin", + // Unmapped future regions fall back to a sane default rather than + // panicking; the control plane should reject unsupported regions + // before reaching this adapter. + } + } + + fn size_to_guest(preset: SizePreset) -> MachineGuestConfig { + match preset { + SizePreset::Small => MachineGuestConfig { + cpu_kind: "shared".to_string(), + cpus: 1, + memory_mb: 2048, + }, + SizePreset::Medium => MachineGuestConfig { + cpu_kind: "shared".to_string(), + cpus: 2, + memory_mb: 4096, + }, + SizePreset::Large => MachineGuestConfig { + cpu_kind: "shared".to_string(), + cpus: 4, + memory_mb: 8192, + }, + } + } + + fn boot_manifest_env(manifest_version: u32) -> std::collections::HashMap { + // The boot manifest itself is looked up by version inside the bootstrap + // script (see `remote_bootstrap`); only the version is passed as machine + // metadata so the vendor init step knows which manifest to apply. + let mut env = std::collections::HashMap::new(); + env.insert( + "TREQ_BOOT_MANIFEST_VERSION".to_string(), + manifest_version.to_string(), + ); + env + } + + fn auth_headers(&self, idempotency_key: Option<&str>) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", self.config.api_token) + .parse() + .expect("bearer header value is always valid ASCII"), + ); + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + // Fly Machines' create/update APIs treat repeated identical requests as + // safe to retry when tagged with the same key; some vendor deployments + // read this from a bespoke header rather than a standard Idempotency-Key. + // We send both so retries are safe regardless of which the target + // deployment honors. + if let Some(key) = idempotency_key { + if let Ok(value) = reqwest::header::HeaderValue::from_str(key) { + headers.insert("Idempotency-Key", value.clone()); + headers.insert("Fly-Idempotency-Key", value); + } + } + headers + } + + fn map_transport_error(err: reqwest::Error) -> ProviderError { + if err.is_timeout() { + ProviderError::Timeout + } else if err.is_connect() { + ProviderError::Unavailable { + message: "could not connect to Fly Machines API".to_string(), + } + } else { + ProviderError::Other { + message: "provider request failed".to_string(), + } + } + } + + fn map_status_error(status: StatusCode, body: &str) -> ProviderError { + match status { + StatusCode::NOT_FOUND => ProviderError::NotFound, + StatusCode::CONFLICT => ProviderError::AlreadyExists, + StatusCode::TOO_MANY_REQUESTS => ProviderError::QuotaExceeded, + StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => ProviderError::InvalidRequest { + message: truncate_body(body), + }, + s if s.is_server_error() => ProviderError::Unavailable { + message: truncate_body(body), + }, + _ => ProviderError::Other { + message: truncate_body(body), + }, + } + } + + fn normalize_state(machine: &MachineResponse) -> ManagedInstanceState { + match machine.state.as_str() { + "created" | "starting" => ManagedInstanceState::Provisioning, + "started" => ManagedInstanceState::Ready, + "stopping" | "stopped" | "suspended" => ManagedInstanceState::Suspended, + "replacing" => ManagedInstanceState::Reprovisioning, + "destroying" => ManagedInstanceState::Deleting, + "destroyed" => ManagedInstanceState::Deleted, + _ => ManagedInstanceState::Degraded, + } + } + + fn normalize_instance(machine: MachineResponse) -> ProviderInstance { + let region = parse_region_slug(&machine.region); + let size_preset = parse_guest_config(&machine.config.guest); + let address = machine.private_ip.clone(); + ProviderInstance { + provider_resource_id: machine.id, + state: Self::normalize_state(&machine), + region, + size_preset, + address, + } + } +} + +fn truncate_body(body: &str) -> String { + const MAX: usize = 500; + if body.len() > MAX { + format!("{}…", &body[..MAX]) + } else { + body.to_string() + } +} + +fn parse_region_slug(slug: &str) -> RegionCode { + match slug { + "iad" => RegionCode::UsEast, + "sjc" => RegionCode::UsWest, + "lhr" => RegionCode::EuWest, + "sin" => RegionCode::ApSoutheast, + _ => RegionCode::UsEast, + } +} + +fn parse_guest_config(guest: &MachineGuestConfig) -> SizePreset { + if guest.memory_mb <= 2048 { + SizePreset::Small + } else if guest.memory_mb <= 4096 { + SizePreset::Medium + } else { + SizePreset::Large + } +} + +// -- Vendor wire types -------------------------------------------------------- +// These shapes mirror (a documented subset of) Fly's Machines API. They must +// never be exported from this module; `normalize_instance` is the only bridge +// to the provider-neutral domain types. + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MachineGuestConfig { + cpu_kind: String, + cpus: u32, + memory_mb: u32, +} + +#[derive(Debug, Clone, Serialize)] +struct MachineConfigRequest { + image: String, + guest: MachineGuestConfig, + env: std::collections::HashMap, + init: MachineInit, +} + +#[derive(Debug, Clone, Serialize)] +struct MachineInit { + /// Bootstrap entrypoint invoked on boot; installs the versioned boot + /// manifest (see `remote_bootstrap::bootstrap_script`). + exec: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct CreateMachineRequest { + name: String, + region: String, + config: MachineConfigRequest, +} + +#[derive(Debug, Clone, Deserialize)] +struct MachineResponse { + id: String, + region: String, + state: String, + config: MachineResponseConfig, + #[serde(default)] + private_ip: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MachineResponseConfig { + guest: MachineGuestConfig, +} + +/// Boot image reference. Fixed here rather than user-configurable: the image +/// is Treq's own base image, versioned alongside the boot manifest. +const SPRITES_BASE_IMAGE: &str = "registry.fly.io/treq-remote-base:latest"; + +#[async_trait::async_trait] +impl ManagedComputeProvider for SpritesProvider { + fn provider_kind(&self) -> ProviderKind { + ProviderKind::FlySprites + } + + async fn create_instance( + &self, + request: CreateInstanceRequest, + ) -> Result { + let body = CreateMachineRequest { + // Deterministic from the owner id so a retried create (same + // idempotency key, same owner) targets the same machine name even if + // the idempotency header is dropped by an intermediary. + name: format!("treq-{}", request.owner_user_id), + region: Self::region_slug(request.region).to_string(), + config: MachineConfigRequest { + image: SPRITES_BASE_IMAGE.to_string(), + guest: Self::size_to_guest(request.size_preset), + env: Self::boot_manifest_env(request.manifest_version), + init: MachineInit { + exec: crate::core::remote_bootstrap::bootstrap_command(request.manifest_version), + }, + }, + }; + + let response = self + .client + .post(self.machines_url()) + .headers(self.auth_headers(Some(&request.idempotency_key))) + .json(&body) + .send() + .await + .map_err(Self::map_transport_error)?; + + let status = response.status(); + if status == StatusCode::CONFLICT { + // The vendor treats a repeated create with the same idempotency key + // (or same machine name) as already-existing; fetch and return the + // current state instead of surfacing an error, so create is + // effectively idempotent for the caller. + let text = response.text().await.unwrap_or_default(); + if let Ok(existing) = serde_json::from_str::(&text) { + return Ok(Self::normalize_instance(existing)); + } + return Err(ProviderError::AlreadyExists); + } + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(Self::map_status_error(status, &text)); + } + + let machine: MachineResponse = response.json().await.map_err(|_| ProviderError::Other { + message: "could not parse provider response".to_string(), + })?; + Ok(Self::normalize_instance(machine)) + } + + async fn get_instance(&self, provider_id: &str) -> Result { + let response = self + .client + .get(self.machine_url(provider_id)) + .headers(self.auth_headers(None)) + .send() + .await + .map_err(Self::map_transport_error)?; + + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(Self::map_status_error(status, &text)); + } + let machine: MachineResponse = response.json().await.map_err(|_| ProviderError::Other { + message: "could not parse provider response".to_string(), + })?; + Ok(Self::normalize_instance(machine)) + } + + async fn wake_instance(&self, provider_id: &str) -> Result<(), ProviderError> { + let url = format!("{}/start", self.machine_url(provider_id)); + let response = self + .client + .post(url) + .headers(self.auth_headers(None)) + .send() + .await + .map_err(Self::map_transport_error)?; + + let status = response.status(); + // Fly returns 200/202 on accepted, and treats "already started" as a + // success too (some deployments return 400 with a specific message for + // that case); accept both to keep wake idempotent. + if status.is_success() { + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + if status == StatusCode::BAD_REQUEST && text.to_lowercase().contains("already") { + return Ok(()); + } + Err(Self::map_status_error(status, &text)) + } + + async fn replace_instance( + &self, + request: ReplaceInstanceRequest, + ) -> Result { + let body = MachineConfigRequest { + image: SPRITES_BASE_IMAGE.to_string(), + guest: Self::size_to_guest(request.size_preset), + env: Self::boot_manifest_env(request.manifest_version), + init: MachineInit { + exec: crate::core::remote_bootstrap::bootstrap_command(request.manifest_version), + }, + }; + + // Fly Machines models an in-place config update as POST .../update; a + // region change is not supported in place (matches the PRD's "Region + // migration is not supported" non-goal), so a region change must go + // through delete+create at the control-plane level rather than this + // adapter call. We still forward the requested region for validation. + let url = format!("{}/update", self.machine_url(&request.provider_resource_id)); + let response = self + .client + .post(url) + .headers(self.auth_headers(Some(&request.idempotency_key))) + .json(&body) + .send() + .await + .map_err(Self::map_transport_error)?; + + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(Self::map_status_error(status, &text)); + } + let machine: MachineResponse = response.json().await.map_err(|_| ProviderError::Other { + message: "could not parse provider response".to_string(), + })?; + Ok(Self::normalize_instance(machine)) + } + + async fn delete_instance(&self, provider_id: &str) -> Result<(), ProviderError> { + let response = self + .client + .delete(self.machine_url(provider_id)) + .query(&[("force", "true")]) + .headers(self.auth_headers(None)) + .send() + .await + .map_err(Self::map_transport_error)?; + + let status = response.status(); + // A delete of an already-deleted (404) instance is a no-op success, so + // repeated delete calls remain idempotent. + if status.is_success() || status == StatusCode::NOT_FOUND { + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + Err(Self::map_status_error(status, &text)) + } +} + From 2a3e755af506f4756b0e4340249fdc0a3ff39a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:28:11 +0000 Subject: [PATCH 2/2] Implement Sprites provisioning for Remote SSH Control (Phase 2) Adds the Fly Sprites ManagedComputeProvider adapter (create/get/wake/ replace/delete against the Fly Machines REST API, idempotency headers, normalized state/error mapping) plus unit tests against a mocked HTTP server. Adds a versioned, idempotent boot-manifest bootstrap script mechanism tied to instance generation. Adds the remote-instance Supabase Edge Function (ensure/status/wake/reprovision/delete/list catalogs) with server-side ownership checks, idempotency-key deduplication via remote_instance_operations, managed endpoint and host-key rotation recording, and audit event logging, plus a Deno-side stub adapter for local testing without live Fly credentials. --- Cargo.lock | 269 ++++++++- src-tauri/src/core/mod.rs | 2 + src-tauri/src/core/remote_bootstrap.rs | 177 ++++++ src-tauri/src/core/remote_provider_sprites.rs | 241 +++++++- supabase/functions/_shared/remote/audit.ts | 50 ++ .../functions/_shared/remote/boot-manifest.ts | 97 ++++ supabase/functions/_shared/remote/catalog.ts | 28 + .../_shared/remote/instance-store.ts | 256 ++++++++ .../_shared/remote/sprites-adapter.ts | 289 +++++++++ .../_shared/remote/stub-sprites-adapter.ts | 88 +++ supabase/functions/remote-instance/index.ts | 549 ++++++++++++++++++ 11 files changed, 2033 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/core/remote_bootstrap.rs create mode 100644 supabase/functions/_shared/remote/audit.ts create mode 100644 supabase/functions/_shared/remote/boot-manifest.ts create mode 100644 supabase/functions/_shared/remote/catalog.ts create mode 100644 supabase/functions/_shared/remote/instance-store.ts create mode 100644 supabase/functions/_shared/remote/sprites-adapter.ts create mode 100644 supabase/functions/_shared/remote/stub-sprites-adapter.ts create mode 100644 supabase/functions/remote-instance/index.ts diff --git a/Cargo.lock b/Cargo.lock index b4de8f5e..5b9a35f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1338,6 +1348,24 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deranged" version = "0.5.8" @@ -2130,8 +2158,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2153,11 +2183,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -3239,6 +3271,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -3420,6 +3471,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "human_format" version = "1.2.1" @@ -3445,9 +3502,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3455,6 +3514,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -4286,6 +4361,12 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -4672,6 +4753,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -5578,6 +5669,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.1", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -5616,7 +5763,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -5742,6 +5889,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5855,6 +6011,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -6070,6 +6264,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -6310,6 +6505,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -6889,7 +7096,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -7411,9 +7618,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7703,6 +7932,7 @@ dependencies = [ "portable-pty", "rayon", "regex", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", @@ -7717,12 +7947,14 @@ dependencies = [ "tauri-plugin-opener", "tauri-test", "tempfile", + "tokio", "tracing", "tracing-appender", "tracing-subscriber", "url", "urlencoding", "uuid", + "wiremock", ] [[package]] @@ -8173,6 +8405,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -8775,6 +9017,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 4df58398..b12dc5c5 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -7,8 +7,10 @@ pub mod checks; pub mod checks_logs; pub mod commits; pub mod remote; +pub mod remote_bootstrap; pub mod remote_control_plane; pub mod remote_provider; +pub mod remote_provider_sprites; pub mod repo; pub mod resolve; pub mod stash; diff --git a/src-tauri/src/core/remote_bootstrap.rs b/src-tauri/src/core/remote_bootstrap.rs new file mode 100644 index 00000000..69d46f37 --- /dev/null +++ b/src-tauri/src/core/remote_bootstrap.rs @@ -0,0 +1,177 @@ +//! Boot manifest bootstrap mechanism. +//! +//! Fixes the "repeatable, tied to instance generation" installation step +//! described in the PRD's "Boot manifest and readiness" section. This is not +//! a full base-image build pipeline: it is a small, versioned, idempotent +//! shell bootstrap that a Fly Machine's `init.exec` runs on boot to install +//! (or verify) the pinned dependency versions recorded in a [`BootManifest`]. +//! +//! The mechanism is intentionally simple: the machine boots with +//! `TREQ_BOOT_MANIFEST_VERSION` set as an env var (see +//! `remote_provider_sprites::SpritesProvider::boot_manifest_env`), and the +//! bootstrap entrypoint looks up the manifest for that version and installs +//! it. Running it twice against an already-bootstrapped machine is a no-op +//! check, not a reinstall, which is what makes wake/reprovision safe to +//! retry. + +use crate::core::remote_provider::BootManifest; + +/// Registry of known boot manifest versions. In a real deployment this would +/// likely be loaded from a control-plane config table; Phase 2 fixes it as a +/// small static table so the mechanism is concrete and testable without a +/// remote fetch. +pub fn manifest_for_version(version: u32) -> Option { + match version { + 1 => Some(BootManifest { + manifest_version: 1, + treq_version: "0.3.0".to_string(), + jj_version: "0.24.0".to_string(), + git_version: "2.45".to_string(), + agents: vec![ + crate::core::remote_provider::BootManifestAgent { + name: "claude".to_string(), + version: "1.0.0".to_string(), + }, + crate::core::remote_provider::BootManifestAgent { + name: "codex".to_string(), + version: "1.0.0".to_string(), + }, + ], + }), + _ => None, + } +} + +/// The exec entrypoint passed to the vendor's machine `init.exec`. Kept as a +/// single command invoking a versioned bootstrap script rather than an +/// inline multi-line shell blob, so the same script can be exercised outside +/// the adapter (unit tests, manual reprovision debugging) without +/// reconstructing vendor request shapes. +pub fn bootstrap_command(manifest_version: u32) -> Vec { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + bootstrap_script(manifest_version), + ] +} + +/// Renders the idempotent bootstrap shell script for a given manifest +/// version. Every install step is guarded so re-running the script against a +/// machine that already has the target version installed is a fast no-op — +/// this is what makes wake and reprovision safe to run the bootstrap again. +pub fn bootstrap_script(manifest_version: u32) -> String { + let manifest = manifest_for_version(manifest_version); + // An unknown version falls back to the current manifest's *contents and + // version number*, so the rendered script is internally consistent (the + // TREQ_MANIFEST_VERSION it records matches the manifest it actually + // installed) rather than stamping an unregistered version number. + let (manifest_version, manifest) = match manifest { + Some(manifest) => (manifest_version, manifest), + None => ( + BootManifest::CURRENT_VERSION, + manifest_for_version(BootManifest::CURRENT_VERSION) + .expect("current boot manifest version must be registered"), + ), + }; + + let agent_lines: String = manifest + .agents + .iter() + .map(|agent| { + format!( + "install_agent \"{name}\" \"{version}\"\n", + name = agent.name, + version = agent.version + ) + }) + .collect(); + + format!( + r#"#!/bin/sh +# Treq boot manifest bootstrap, generation-tied version {manifest_version}. +# Idempotent: safe to re-run on wake or reprovision without duplicating work. +set -eu + +TREQ_MANIFEST_VERSION="{manifest_version}" +TREQ_VERSION="{treq_version}" +JJ_VERSION="{jj_version}" +GIT_VERSION="{git_version}" +STATE_FILE="/var/lib/treq/bootstrap-version" + +mkdir -p /var/lib/treq + +current_version="" +if [ -f "$STATE_FILE" ]; then + current_version="$(cat "$STATE_FILE")" +fi + +if [ "$current_version" = "$TREQ_MANIFEST_VERSION" ]; then + echo "treq bootstrap: manifest version $TREQ_MANIFEST_VERSION already installed" + exit 0 +fi + +install_binary() {{ + name="$1" + version="$2" + echo "treq bootstrap: ensuring $name $version" + # Real installation is package/version specific and lives in the base + # image build; this hook exists so drift-repair (reprovision) can reinstall + # a pinned version without a full image rebuild. +}} + +install_agent() {{ + name="$1" + version="$2" + echo "treq bootstrap: ensuring agent $name $version" +}} + +install_binary treq "$TREQ_VERSION" +install_binary jj "$JJ_VERSION" +install_binary git "$GIT_VERSION" +{agent_lines} +echo "$TREQ_MANIFEST_VERSION" > "$STATE_FILE" +echo "treq bootstrap: manifest version $TREQ_MANIFEST_VERSION installed" +"#, + manifest_version = manifest_version, + treq_version = manifest.treq_version, + jj_version = manifest.jj_version, + git_version = manifest.git_version, + agent_lines = agent_lines, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_manifest_version_resolves() { + assert!(manifest_for_version(1).is_some()); + assert!(manifest_for_version(999).is_none()); + } + + #[test] + fn bootstrap_command_wraps_script_in_shell() { + let command = bootstrap_command(1); + assert_eq!(command[0], "/bin/sh"); + assert_eq!(command[1], "-c"); + assert!(command[2].contains("TREQ_MANIFEST_VERSION=\"1\"")); + } + + #[test] + fn bootstrap_script_is_idempotent_by_checking_state_file() { + let script = bootstrap_script(1); + assert!(script.contains("STATE_FILE")); + assert!(script.contains("already installed")); + assert!(script.contains("install_agent \"claude\"")); + } + + #[test] + fn unknown_version_falls_back_to_current() { + let script = bootstrap_script(999); + assert!(script.contains(&format!( + "TREQ_MANIFEST_VERSION=\"{}\"", + BootManifest::CURRENT_VERSION + ))); + } +} diff --git a/src-tauri/src/core/remote_provider_sprites.rs b/src-tauri/src/core/remote_provider_sprites.rs index 09d81115..49ca9fb0 100644 --- a/src-tauri/src/core/remote_provider_sprites.rs +++ b/src-tauri/src/core/remote_provider_sprites.rs @@ -53,16 +53,18 @@ impl SpritesConfig { /// server-side process (Edge Function equivalent / control-plane binary) /// that holds the vendor secret; a desktop client must never call this. pub fn from_env() -> Result { - let base_url = std::env::var("FLY_SPRITES_API_BASE_URL") - .map_err(|_| ProviderError::InvalidRequest { + let base_url = + std::env::var("FLY_SPRITES_API_BASE_URL").map_err(|_| ProviderError::InvalidRequest { message: "FLY_SPRITES_API_BASE_URL is not set".to_string(), })?; - let api_token = std::env::var("FLY_SPRITES_API_TOKEN").map_err(|_| ProviderError::InvalidRequest { - message: "FLY_SPRITES_API_TOKEN is not set".to_string(), - })?; - let app_name = std::env::var("FLY_SPRITES_APP_NAME").map_err(|_| ProviderError::InvalidRequest { - message: "FLY_SPRITES_APP_NAME is not set".to_string(), - })?; + let api_token = + std::env::var("FLY_SPRITES_API_TOKEN").map_err(|_| ProviderError::InvalidRequest { + message: "FLY_SPRITES_API_TOKEN is not set".to_string(), + })?; + let app_name = + std::env::var("FLY_SPRITES_APP_NAME").map_err(|_| ProviderError::InvalidRequest { + message: "FLY_SPRITES_APP_NAME is not set".to_string(), + })?; Ok(Self { base_url, api_token, @@ -219,15 +221,15 @@ impl SpritesProvider { } fn normalize_instance(machine: MachineResponse) -> ProviderInstance { + let state = Self::normalize_state(&machine); let region = parse_region_slug(&machine.region); let size_preset = parse_guest_config(&machine.config.guest); - let address = machine.private_ip.clone(); ProviderInstance { provider_resource_id: machine.id, - state: Self::normalize_state(&machine), + state, region, size_preset, - address, + address: machine.private_ip, } } } @@ -476,3 +478,220 @@ impl ManagedComputeProvider for SpritesProvider { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::core::remote_provider::{ManagedInstanceState, RegionCode, SizePreset}; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_config(base_url: String) -> SpritesConfig { + SpritesConfig { + base_url, + api_token: "test-token".to_string(), + app_name: "treq-remote".to_string(), + request_timeout: Duration::from_secs(5), + } + } + + fn machine_json(id: &str, state: &str) -> serde_json::Value { + json!({ + "id": id, + "region": "iad", + "state": state, + "config": { "guest": { "cpu_kind": "shared", "cpus": 1, "memory_mb": 2048 } }, + "private_ip": "fdaa:0:1::1" + }) + } + + #[tokio::test] + async fn create_instance_normalizes_a_started_machine() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines")) + .respond_with(ResponseTemplate::new(200).set_body_json(machine_json("m1", "started"))) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let result = provider + .create_instance(CreateInstanceRequest { + owner_user_id: "user-1".to_string(), + region: RegionCode::UsEast, + size_preset: SizePreset::Small, + manifest_version: 1, + idempotency_key: "key-1".to_string(), + }) + .await + .unwrap(); + + assert_eq!(result.provider_resource_id, "m1"); + assert_eq!(result.state, ManagedInstanceState::Ready); + assert_eq!(result.region, RegionCode::UsEast); + assert_eq!(result.size_preset, SizePreset::Small); + assert_eq!(result.address.as_deref(), Some("fdaa:0:1::1")); + } + + #[tokio::test] + async fn create_instance_sends_idempotency_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines")) + .and(wiremock::matchers::header("Idempotency-Key", "key-42")) + .respond_with(ResponseTemplate::new(200).set_body_json(machine_json("m2", "created"))) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let result = provider + .create_instance(CreateInstanceRequest { + owner_user_id: "user-1".to_string(), + region: RegionCode::EuWest, + size_preset: SizePreset::Medium, + manifest_version: 1, + idempotency_key: "key-42".to_string(), + }) + .await + .unwrap(); + + assert_eq!(result.state, ManagedInstanceState::Provisioning); + } + + #[tokio::test] + async fn create_instance_conflict_returns_existing_machine_instead_of_erroring() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines")) + .respond_with(ResponseTemplate::new(409).set_body_json(machine_json("m1", "started"))) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let result = provider + .create_instance(CreateInstanceRequest { + owner_user_id: "user-1".to_string(), + region: RegionCode::UsEast, + size_preset: SizePreset::Small, + manifest_version: 1, + idempotency_key: "key-1".to_string(), + }) + .await + .unwrap(); + + assert_eq!(result.provider_resource_id, "m1"); + } + + #[tokio::test] + async fn get_instance_maps_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/apps/treq-remote/machines/missing")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let err = provider.get_instance("missing").await.unwrap_err(); + assert_eq!(err, ProviderError::NotFound); + } + + #[tokio::test] + async fn get_instance_maps_suspended_state() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/apps/treq-remote/machines/m1")) + .respond_with(ResponseTemplate::new(200).set_body_json(machine_json("m1", "stopped"))) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let result = provider.get_instance("m1").await.unwrap(); + assert_eq!(result.state, ManagedInstanceState::Suspended); + } + + #[tokio::test] + async fn wake_instance_succeeds_on_accepted() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines/m1/start")) + .respond_with(ResponseTemplate::new(202)) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + provider.wake_instance("m1").await.unwrap(); + } + + #[tokio::test] + async fn wake_instance_is_idempotent_when_already_started() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines/m1/start")) + .respond_with(ResponseTemplate::new(400).set_body_string("machine already started")) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + provider.wake_instance("m1").await.unwrap(); + } + + #[tokio::test] + async fn replace_instance_normalizes_updated_machine() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/treq-remote/machines/m1/update")) + .respond_with(ResponseTemplate::new(200).set_body_json(machine_json("m1", "starting"))) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let result = provider + .replace_instance(ReplaceInstanceRequest { + provider_resource_id: "m1".to_string(), + region: RegionCode::UsEast, + size_preset: SizePreset::Large, + manifest_version: 1, + idempotency_key: "replace-1".to_string(), + }) + .await + .unwrap(); + + assert_eq!(result.state, ManagedInstanceState::Provisioning); + } + + #[tokio::test] + async fn delete_instance_is_idempotent_on_repeat_calls() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/apps/treq-remote/machines/m1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + provider.delete_instance("m1").await.unwrap(); + } + + #[tokio::test] + async fn delete_instance_maps_server_error() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/apps/treq-remote/machines/m1")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let provider = SpritesProvider::new(test_config(server.uri())).unwrap(); + let err = provider.delete_instance("m1").await.unwrap_err(); + assert!(matches!(err, ProviderError::Unavailable { .. })); + } + + #[test] + fn config_debug_redacts_token() { + let config = test_config("https://example.test".to_string()); + let debug = format!("{:?}", config); + assert!(!debug.contains("test-token")); + assert!(debug.contains("")); + } +} diff --git a/supabase/functions/_shared/remote/audit.ts b/supabase/functions/_shared/remote/audit.ts new file mode 100644 index 00000000..7342695c --- /dev/null +++ b/supabase/functions/_shared/remote/audit.ts @@ -0,0 +1,50 @@ +// Audit event helper for remote_audit_events, per the PRD's "Observability +// and audit" section. `detail` must never contain a provider token, CA key +// material, or repository content — callers pass only normalized lifecycle +// fields (region, size, generation, manifest version, provider request ids, +// readiness stage). + +import type { SupabaseClient } from "@supabase/supabase-js"; + +export type RemoteAuditEventType = + | "instance_create_requested" + | "instance_create_succeeded" + | "instance_create_failed" + | "instance_wake_requested" + | "instance_wake_succeeded" + | "instance_wake_failed" + | "instance_replace_requested" + | "instance_replace_succeeded" + | "instance_replace_failed" + | "instance_delete_requested" + | "instance_delete_succeeded" + | "instance_delete_failed" + | "host_key_registered" + | "host_key_rotated" + | "readiness_stage_failed"; + +export async function recordAuditEvent( + supabase: SupabaseClient, + params: { + ownerUserId: string; + instanceId?: string | null; + endpointId?: string | null; + eventType: RemoteAuditEventType; + // deno-lint-ignore no-explicit-any + detail?: Record; + }, +): Promise { + const { error } = await supabase.from("remote_audit_events").insert({ + owner_user_id: params.ownerUserId, + instance_id: params.instanceId ?? null, + endpoint_id: params.endpointId ?? null, + event_type: params.eventType, + detail: params.detail ?? {}, + }); + // Audit recording failures must not silently vanish, but they also must + // not fail the underlying lifecycle operation the caller already + // committed to the provider — log and continue. + if (error) { + console.error(`failed to record audit event ${params.eventType}: ${error.message}`); + } +} diff --git a/supabase/functions/_shared/remote/boot-manifest.ts b/supabase/functions/_shared/remote/boot-manifest.ts new file mode 100644 index 00000000..b610c688 --- /dev/null +++ b/supabase/functions/_shared/remote/boot-manifest.ts @@ -0,0 +1,97 @@ +// Boot manifest registry, mirroring +// `core::remote_bootstrap::manifest_for_version` in src-tauri. Kept as a +// small static table (not fetched from the vendor) so the control plane can +// pin exactly which dependency versions a given `manifest_version` installs. + +export interface BootManifestAgent { + name: string; + version: string; +} + +export interface BootManifest { + manifest_version: number; + treq_version: string; + jj_version: string; + git_version: string; + agents: BootManifestAgent[]; +} + +export const CURRENT_MANIFEST_VERSION = 1; + +const MANIFESTS: Record = { + 1: { + manifest_version: 1, + treq_version: "0.3.0", + jj_version: "0.24.0", + git_version: "2.45", + agents: [ + { name: "claude", version: "1.0.0" }, + { name: "codex", version: "1.0.0" }, + ], + }, +}; + +export function manifestForVersion(version: number): BootManifest | null { + return MANIFESTS[version] ?? null; +} + +// The exec entrypoint installed as a Fly machine's `init.exec`. Mirrors +// `core::remote_bootstrap::bootstrap_command` so a machine created through +// this Edge Function boots with the same idempotent bootstrap script as one +// created by the Rust adapter directly. +export function bootstrapCommand(manifestVersion: number): string[] { + return ["/bin/sh", "-c", bootstrapScript(manifestVersion)]; +} + +export function bootstrapScript(requestedVersion: number): string { + const manifest = manifestForVersion(requestedVersion) ?? manifestForVersion(CURRENT_MANIFEST_VERSION)!; + // An unknown version falls back to the current manifest's contents *and* + // version number, so the rendered script stays internally consistent. + const manifestVersion = manifestForVersion(requestedVersion) ? requestedVersion : CURRENT_MANIFEST_VERSION; + const agentLines = manifest.agents + .map((agent) => `install_agent "${agent.name}" "${agent.version}"\n`) + .join(""); + + return `#!/bin/sh +# Treq boot manifest bootstrap, generation-tied version ${manifestVersion}. +# Idempotent: safe to re-run on wake or reprovision without duplicating work. +set -eu + +TREQ_MANIFEST_VERSION="${manifestVersion}" +TREQ_VERSION="${manifest.treq_version}" +JJ_VERSION="${manifest.jj_version}" +GIT_VERSION="${manifest.git_version}" +STATE_FILE="/var/lib/treq/bootstrap-version" + +mkdir -p /var/lib/treq + +current_version="" +if [ -f "$STATE_FILE" ]; then + current_version="$(cat "$STATE_FILE")" +fi + +if [ "$current_version" = "$TREQ_MANIFEST_VERSION" ]; then + echo "treq bootstrap: manifest version $TREQ_MANIFEST_VERSION already installed" + exit 0 +fi + +install_binary() { + name="$1" + version="$2" + echo "treq bootstrap: ensuring $name $version" +} + +install_agent() { + name="$1" + version="$2" + echo "treq bootstrap: ensuring agent $name $version" +} + +install_binary treq "$TREQ_VERSION" +install_binary jj "$JJ_VERSION" +install_binary git "$GIT_VERSION" +${agentLines} +echo "$TREQ_MANIFEST_VERSION" > "$STATE_FILE" +echo "treq bootstrap: manifest version $TREQ_MANIFEST_VERSION installed" +`; +} diff --git a/supabase/functions/_shared/remote/catalog.ts b/supabase/functions/_shared/remote/catalog.ts new file mode 100644 index 00000000..13c038a3 --- /dev/null +++ b/supabase/functions/_shared/remote/catalog.ts @@ -0,0 +1,28 @@ +// Closed sets of region codes and size presets offered to users, mirroring +// `core::remote_provider::REGIONS` / `SIZE_PRESETS` in src-tauri. Keeping +// these as a literal list (rather than reading them from the provider) means +// an Edge Function can validate a request before ever calling the vendor. + +export const REGION_CODES = ["us_east", "us_west", "eu_west", "ap_southeast"] as const; +export type RegionCode = (typeof REGION_CODES)[number]; + +export const SIZE_PRESETS = ["small", "medium", "large"] as const; +export type SizePreset = (typeof SIZE_PRESETS)[number]; + +export function isRegionCode(value: unknown): value is RegionCode { + return typeof value === "string" && (REGION_CODES as readonly string[]).includes(value); +} + +export function isSizePreset(value: unknown): value is SizePreset { + return typeof value === "string" && (SIZE_PRESETS as readonly string[]).includes(value); +} + +// Fly region slugs, matching `SpritesProvider::region_slug` in +// src-tauri/src/core/remote_provider_sprites.rs. Kept in one place so the +// mapping cannot drift between the Rust adapter and this Edge Function. +export const REGION_TO_FLY_SLUG: Record = { + us_east: "iad", + us_west: "sjc", + eu_west: "lhr", + ap_southeast: "sin", +}; diff --git a/supabase/functions/_shared/remote/instance-store.ts b/supabase/functions/_shared/remote/instance-store.ts new file mode 100644 index 00000000..4f6243be --- /dev/null +++ b/supabase/functions/_shared/remote/instance-store.ts @@ -0,0 +1,256 @@ +// Storage helpers for remote_instances / remote_instance_operations / +// remote_endpoints / remote_endpoint_host_keys, shared by the remote-instance +// Edge Function's ensure/status/wake/reprovision/delete actions. +// +// Idempotency follows the PRD's "Idempotency" section: the operation record +// is written before the provider call, and a repeated request with the same +// (owner_user_id, idempotency_key) returns the existing operation rather +// than invoking the provider again. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { RegionCode, SizePreset } from "./catalog.ts"; + +export type OperationType = "provision" | "wake" | "reprovision" | "delete"; +export type OperationStatus = "pending" | "in_progress" | "succeeded" | "failed"; + +export interface OperationRow { + id: string; + owner_user_id: string; + instance_id: string | null; + operation_type: OperationType; + status: OperationStatus; + idempotency_key: string; + provider_request_id: string | null; + error_message: string | null; +} + +export interface InstanceRow { + id: string; + owner_user_id: string; + provider_kind: string; + provider_resource_id: string | null; + region: RegionCode; + size_preset: SizePreset; + status: string; + generation: number; + endpoint_id: string | null; + image_manifest_version: number; + ready_at: string | null; +} + +// Looks up an existing operation for this idempotency key. When found, the +// caller must not invoke the provider again — this is what makes a repeated +// mutating request safe to retry (PRD "Idempotency"). +export async function findExistingOperation( + supabase: SupabaseClient, + ownerUserId: string, + idempotencyKey: string, +): Promise { + const { data, error } = await supabase + .from("remote_instance_operations") + .select("*") + .eq("owner_user_id", ownerUserId) + .eq("idempotency_key", idempotencyKey) + .maybeSingle(); + if (error) throw new Error(`failed to look up operation: ${error.message}`); + return data as OperationRow | null; +} + +// Records the operation before the provider call, per the PRD's "The server +// records the operation before invoking the provider" requirement. A unique +// violation on (owner_user_id, idempotency_key) means a concurrent request +// already claimed it; the caller should re-read and treat it as existing. +export async function beginOperation( + supabase: SupabaseClient, + params: { + ownerUserId: string; + instanceId: string | null; + operationType: OperationType; + idempotencyKey: string; + }, +): Promise { + const { data, error } = await supabase + .from("remote_instance_operations") + .insert({ + owner_user_id: params.ownerUserId, + instance_id: params.instanceId, + operation_type: params.operationType, + idempotency_key: params.idempotencyKey, + status: "in_progress", + }) + .select() + .single(); + if (error) throw new Error(`failed to begin operation: ${error.message}`); + return data as OperationRow; +} + +export async function completeOperation( + supabase: SupabaseClient, + operationId: string, + outcome: { status: "succeeded" | "failed"; providerRequestId?: string | null; errorMessage?: string | null }, +): Promise { + const { error } = await supabase + .from("remote_instance_operations") + .update({ + status: outcome.status, + provider_request_id: outcome.providerRequestId ?? null, + error_message: outcome.errorMessage ?? null, + finished_at: new Date().toISOString(), + }) + .eq("id", operationId); + if (error) throw new Error(`failed to complete operation: ${error.message}`); +} + +export async function getInstanceForOwner( + supabase: SupabaseClient, + ownerUserId: string, +): Promise { + const { data, error } = await supabase + .from("remote_instances") + .select("*") + .eq("owner_user_id", ownerUserId) + .maybeSingle(); + if (error) throw new Error(`failed to read instance: ${error.message}`); + return data as InstanceRow | null; +} + +export async function getInstanceById( + supabase: SupabaseClient, + ownerUserId: string, + instanceId: string, +): Promise { + const { data, error } = await supabase + .from("remote_instances") + .select("*") + .eq("owner_user_id", ownerUserId) + .eq("id", instanceId) + .maybeSingle(); + if (error) throw new Error(`failed to read instance: ${error.message}`); + return data as InstanceRow | null; +} + +export async function createProvisioningInstance( + supabase: SupabaseClient, + params: { ownerUserId: string; region: RegionCode; sizePreset: SizePreset; manifestVersion: number }, +): Promise { + const { data, error } = await supabase + .from("remote_instances") + .insert({ + owner_user_id: params.ownerUserId, + provider_kind: "fly_sprites", + region: params.region, + size_preset: params.sizePreset, + status: "provisioning", + generation: 0, + image_manifest_version: params.manifestVersion, + }) + .select() + .single(); + if (error) throw new Error(`failed to create instance record: ${error.message}`); + return data as InstanceRow; +} + +export async function updateInstance( + supabase: SupabaseClient, + instanceId: string, + // deno-lint-ignore no-explicit-any + fields: Record, +): Promise { + const { error } = await supabase + .from("remote_instances") + .update({ ...fields, updated_at: new Date().toISOString() }) + .eq("id", instanceId); + if (error) throw new Error(`failed to update instance: ${error.message}`); +} + +// Upserts the managed endpoint for an instance and records its host key +// under the instance's current generation. Reprovisioning calls this again +// with a new generation; a differing fingerprint at a higher generation is +// the explicit host-key rotation record required by the PRD's "Host-key +// verification" section (verification of the new key against a client is +// Phase 3's job — this only durably records the transition). +export async function recordManagedEndpoint( + supabase: SupabaseClient, + params: { + ownerUserId: string; + instanceId: string; + hostname: string; + port: number; + username: string; + existingEndpointId: string | null; + }, +): Promise { + if (params.existingEndpointId) { + const { error } = await supabase + .from("remote_endpoints") + .update({ + hostname: params.hostname, + port: params.port, + username: params.username, + updated_at: new Date().toISOString(), + }) + .eq("id", params.existingEndpointId); + if (error) throw new Error(`failed to update endpoint: ${error.message}`); + return params.existingEndpointId; + } + + const { data, error } = await supabase + .from("remote_endpoints") + .insert({ + owner_user_id: params.ownerUserId, + instance_id: params.instanceId, + source: "managed", + display_name: "Treq-managed VM", + hostname: params.hostname, + port: params.port, + username: params.username, + }) + .select("id") + .single(); + if (error) throw new Error(`failed to create endpoint: ${error.message}`); + return data.id as string; +} + +// Records a host key fingerprint for an endpoint at a given generation. +// Called once a fingerprint is available (obtained through a trusted +// provisioning path); when unavailable at create time, callers should audit +// a `readiness_stage_failed` event for the host-key stage instead of +// fabricating a value. +export async function recordEndpointHostKey( + supabase: SupabaseClient, + params: { + ownerUserId: string; + endpointId: string; + algorithm: string; + fingerprintSha256: string; + generation: number; + }, +): Promise { + const { error } = await supabase.from("remote_endpoint_host_keys").upsert( + { + owner_user_id: params.ownerUserId, + endpoint_id: params.endpointId, + algorithm: params.algorithm, + fingerprint_sha256: params.fingerprintSha256, + generation: params.generation, + }, + { onConflict: "endpoint_id,fingerprint_sha256" }, + ); + if (error) throw new Error(`failed to record host key: ${error.message}`); +} + +export async function previousHostKeyFingerprint( + supabase: SupabaseClient, + endpointId: string, +): Promise { + const { data, error } = await supabase + .from("remote_endpoint_host_keys") + .select("fingerprint_sha256") + .eq("endpoint_id", endpointId) + .is("revoked_at", null) + .order("generation", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw new Error(`failed to read previous host key: ${error.message}`); + return data?.fingerprint_sha256 ?? null; +} diff --git a/supabase/functions/_shared/remote/sprites-adapter.ts b/supabase/functions/_shared/remote/sprites-adapter.ts new file mode 100644 index 00000000..1830ae86 --- /dev/null +++ b/supabase/functions/_shared/remote/sprites-adapter.ts @@ -0,0 +1,289 @@ +// Fly Sprites provider adapter for the control plane, mirroring +// `core::remote_provider_sprites::SpritesProvider` in src-tauri. This is the +// Deno-side equivalent used by Edge Functions: same vendor API shape, same +// state normalization, same idempotency-header convention. Vendor status +// strings and vendor SDK types must never leave this module. + +import type { RegionCode, SizePreset } from "./catalog.ts"; +import { REGION_TO_FLY_SLUG } from "./catalog.ts"; +import { bootstrapCommand } from "./boot-manifest.ts"; + +export type ManagedInstanceState = + | "unprovisioned" + | "provisioning" + | "bootstrapping" + | "installing_access" + | "verifying" + | "ready" + | "suspended" + | "waking" + | "reprovisioning" + | "degraded" + | "failed" + | "deleting" + | "deleted"; + +export interface ProviderInstance { + providerResourceId: string; + state: ManagedInstanceState; + region: RegionCode; + sizePreset: SizePreset; + address: string | null; +} + +export class ProviderError extends Error { + constructor( + public readonly kind: + | "not_found" + | "already_exists" + | "quota_exceeded" + | "invalid_request" + | "unavailable" + | "timeout" + | "other", + message: string, + ) { + super(message); + } +} + +export interface CreateInstanceParams { + ownerUserId: string; + region: RegionCode; + sizePreset: SizePreset; + manifestVersion: number; + idempotencyKey: string; +} + +export interface ReplaceInstanceParams { + providerResourceId: string; + region: RegionCode; + sizePreset: SizePreset; + manifestVersion: number; + idempotencyKey: string; +} + +export interface ManagedComputeProvider { + createInstance(params: CreateInstanceParams): Promise; + getInstance(providerId: string): Promise; + wakeInstance(providerId: string): Promise; + replaceInstance(params: ReplaceInstanceParams): Promise; + deleteInstance(providerId: string): Promise; +} + +function sizeToGuest(preset: SizePreset) { + switch (preset) { + case "small": + return { cpu_kind: "shared", cpus: 1, memory_mb: 2048 }; + case "medium": + return { cpu_kind: "shared", cpus: 2, memory_mb: 4096 }; + case "large": + return { cpu_kind: "shared", cpus: 4, memory_mb: 8192 }; + } +} + +function guestToSize(memoryMb: number): SizePreset { + if (memoryMb <= 2048) return "small"; + if (memoryMb <= 4096) return "medium"; + return "large"; +} + +const SLUG_TO_REGION: Record = Object.fromEntries( + Object.entries(REGION_TO_FLY_SLUG).map(([region, slug]) => [slug, region as RegionCode]), +); + +function normalizeState(vendorState: string): ManagedInstanceState { + switch (vendorState) { + case "created": + case "starting": + return "provisioning"; + case "started": + return "ready"; + case "stopping": + case "stopped": + case "suspended": + return "suspended"; + case "replacing": + return "reprovisioning"; + case "destroying": + return "deleting"; + case "destroyed": + return "deleted"; + default: + return "degraded"; + } +} + +// deno-lint-ignore no-explicit-any +function normalizeInstance(machine: any): ProviderInstance { + return { + providerResourceId: machine.id, + state: normalizeState(machine.state), + region: SLUG_TO_REGION[machine.region] ?? "us_east", + sizePreset: guestToSize(machine.config?.guest?.memory_mb ?? 2048), + address: machine.private_ip ?? null, + }; +} + +const SPRITES_BASE_IMAGE = "registry.fly.io/treq-remote-base:latest"; + +export interface SpritesConfig { + baseUrl: string; + apiToken: string; + appName: string; +} + +/// Reads Fly Sprites configuration from Edge Function secrets. Never logged; +/// never returned to a client. +export function spritesConfigFromEnv(): SpritesConfig { + const baseUrl = Deno.env.get("FLY_SPRITES_API_BASE_URL"); + const apiToken = Deno.env.get("FLY_SPRITES_API_TOKEN"); + const appName = Deno.env.get("FLY_SPRITES_APP_NAME"); + if (!baseUrl || !apiToken || !appName) { + throw new ProviderError( + "invalid_request", + "FLY_SPRITES_API_BASE_URL, FLY_SPRITES_API_TOKEN, and FLY_SPRITES_APP_NAME must be set", + ); + } + return { baseUrl, apiToken, appName }; +} + +export class SpritesProvider implements ManagedComputeProvider { + constructor(private readonly config: SpritesConfig) {} + + private machinesUrl(): string { + return `${this.config.baseUrl.replace(/\/+$/, "")}/apps/${this.config.appName}/machines`; + } + + private machineUrl(id: string): string { + return `${this.machinesUrl()}/${id}`; + } + + private headers(idempotencyKey?: string): HeadersInit { + const headers: Record = { + Authorization: `Bearer ${this.config.apiToken}`, + "Content-Type": "application/json", + }; + if (idempotencyKey) { + headers["Idempotency-Key"] = idempotencyKey; + headers["Fly-Idempotency-Key"] = idempotencyKey; + } + return headers; + } + + private async mapErrorResponse(response: Response): Promise { + const text = await response.text().catch(() => ""); + const truncated = text.length > 500 ? `${text.slice(0, 500)}…` : text; + switch (response.status) { + case 404: + return new ProviderError("not_found", "instance not found"); + case 409: + return new ProviderError("already_exists", "instance already exists"); + case 429: + return new ProviderError("quota_exceeded", "provider quota exceeded"); + case 400: + case 422: + return new ProviderError("invalid_request", truncated); + default: + if (response.status >= 500) return new ProviderError("unavailable", truncated); + return new ProviderError("other", truncated); + } + } + + async createInstance(params: CreateInstanceParams): Promise { + const body = { + name: `treq-${params.ownerUserId}`, + region: REGION_TO_FLY_SLUG[params.region], + config: { + image: SPRITES_BASE_IMAGE, + guest: sizeToGuest(params.sizePreset), + env: { TREQ_BOOT_MANIFEST_VERSION: String(params.manifestVersion) }, + init: { exec: bootstrapCommand(params.manifestVersion) }, + }, + }; + + let response: Response; + try { + response = await fetch(this.machinesUrl(), { + method: "POST", + headers: this.headers(params.idempotencyKey), + body: JSON.stringify(body), + }); + } catch (err) { + throw new ProviderError("unavailable", `could not reach Fly Machines API: ${(err as Error).message}`); + } + + if (response.status === 409) { + // A repeated create with the same idempotency key/machine name: treat + // the vendor's existing-resource response as success rather than an + // error, so create stays idempotent for the caller. + const machine = await response.json().catch(() => null); + if (machine) return normalizeInstance(machine); + throw new ProviderError("already_exists", "instance already exists"); + } + if (!response.ok) throw await this.mapErrorResponse(response); + return normalizeInstance(await response.json()); + } + + async getInstance(providerId: string): Promise { + let response: Response; + try { + response = await fetch(this.machineUrl(providerId), { headers: this.headers() }); + } catch (err) { + throw new ProviderError("unavailable", `could not reach Fly Machines API: ${(err as Error).message}`); + } + if (!response.ok) throw await this.mapErrorResponse(response); + return normalizeInstance(await response.json()); + } + + async wakeInstance(providerId: string): Promise { + let response: Response; + try { + response = await fetch(`${this.machineUrl(providerId)}/start`, { + method: "POST", + headers: this.headers(), + }); + } catch (err) { + throw new ProviderError("unavailable", `could not reach Fly Machines API: ${(err as Error).message}`); + } + if (response.ok) return; + const text = await response.text().catch(() => ""); + if (response.status === 400 && text.toLowerCase().includes("already")) return; + throw await this.mapErrorResponse(new Response(text, { status: response.status })); + } + + async replaceInstance(params: ReplaceInstanceParams): Promise { + const body = { + image: SPRITES_BASE_IMAGE, + guest: sizeToGuest(params.sizePreset), + env: { TREQ_BOOT_MANIFEST_VERSION: String(params.manifestVersion) }, + init: { exec: bootstrapCommand(params.manifestVersion) }, + }; + let response: Response; + try { + response = await fetch(`${this.machineUrl(params.providerResourceId)}/update`, { + method: "POST", + headers: this.headers(params.idempotencyKey), + body: JSON.stringify(body), + }); + } catch (err) { + throw new ProviderError("unavailable", `could not reach Fly Machines API: ${(err as Error).message}`); + } + if (!response.ok) throw await this.mapErrorResponse(response); + return normalizeInstance(await response.json()); + } + + async deleteInstance(providerId: string): Promise { + let response: Response; + try { + response = await fetch(`${this.machineUrl(providerId)}?force=true`, { + method: "DELETE", + headers: this.headers(), + }); + } catch (err) { + throw new ProviderError("unavailable", `could not reach Fly Machines API: ${(err as Error).message}`); + } + if (response.ok || response.status === 404) return; + throw await this.mapErrorResponse(response); + } +} diff --git a/supabase/functions/_shared/remote/stub-sprites-adapter.ts b/supabase/functions/_shared/remote/stub-sprites-adapter.ts new file mode 100644 index 00000000..d661a065 --- /dev/null +++ b/supabase/functions/_shared/remote/stub-sprites-adapter.ts @@ -0,0 +1,88 @@ +// In-memory Sprites adapter for local service-qa, activated when +// REMOTE_SPRITES_STUB=1 so the control plane can be exercised end to end +// without a real Fly account or vendor token, mirroring +// `stub-github-adapter.ts`'s role for the merge queue. + +import type { + CreateInstanceParams, + ManagedComputeProvider, + ManagedInstanceState, + ProviderInstance, + ReplaceInstanceParams, +} from "./sprites-adapter.ts"; +import { ProviderError } from "./sprites-adapter.ts"; + +export function isSpritesStubEnabled(): boolean { + const v = Deno.env.get("REMOTE_SPRITES_STUB") ?? ""; + return v === "1" || v.toLowerCase() === "true"; +} + +interface StubMachine { + id: string; + state: ManagedInstanceState; + region: ProviderInstance["region"]; + sizePreset: ProviderInstance["sizePreset"]; + address: string; +} + +// Module-level so repeated Edge Function invocations within the same Deno +// isolate share state, letting a stub-mode acceptance run exercise wake and +// reprovision against a "warm" instance. Each isolate restart resets it, +// which is fine for local/service-qa use. +const machines = new Map(); + +export class StubSpritesProvider implements ManagedComputeProvider { + createInstance(params: CreateInstanceParams): Promise { + const id = `stub-${params.ownerUserId}`; + const existing = machines.get(id); + if (existing) { + return Promise.resolve(toProviderInstance(existing)); + } + const machine: StubMachine = { + id, + state: "ready", + region: params.region, + sizePreset: params.sizePreset, + address: `${id}.stub.internal`, + }; + machines.set(id, machine); + return Promise.resolve(toProviderInstance(machine)); + } + + getInstance(providerId: string): Promise { + const machine = machines.get(providerId); + if (!machine) throw new ProviderError("not_found", "stub instance not found"); + return Promise.resolve(toProviderInstance(machine)); + } + + wakeInstance(providerId: string): Promise { + const machine = machines.get(providerId); + if (!machine) throw new ProviderError("not_found", "stub instance not found"); + machine.state = "ready"; + return Promise.resolve(); + } + + replaceInstance(params: ReplaceInstanceParams): Promise { + const machine = machines.get(params.providerResourceId); + if (!machine) throw new ProviderError("not_found", "stub instance not found"); + machine.region = params.region; + machine.sizePreset = params.sizePreset; + machine.state = "ready"; + return Promise.resolve(toProviderInstance(machine)); + } + + deleteInstance(providerId: string): Promise { + machines.delete(providerId); + return Promise.resolve(); + } +} + +function toProviderInstance(machine: StubMachine): ProviderInstance { + return { + providerResourceId: machine.id, + state: machine.state, + region: machine.region, + sizePreset: machine.sizePreset, + address: machine.address, + }; +} diff --git a/supabase/functions/remote-instance/index.ts b/supabase/functions/remote-instance/index.ts new file mode 100644 index 00000000..8206f2de --- /dev/null +++ b/supabase/functions/remote-instance/index.ts @@ -0,0 +1,549 @@ +// Edge function: managed compute instance lifecycle for Remote SSH Control +// (prds/remote-ssh.md, Phase 2: Sprites provisioning). +// +// POST body: { action, idempotency_key?, region?, size_preset? } +// action: +// "ensure" - provision lazily, idempotent (Goal 1 / "Provisioning trigger") +// "status" - read current instance + endpoint status +// "wake" - request a suspended instance resume +// "reprovision" - replace the instance (new size/region/manifest), increments generation +// "delete" - tear down the instance +// "list_regions" - closed set of region codes +// "list_sizes" - closed set of size presets +// +// Auth: user JWT in Authorization header. Every mutating action verifies the +// Supabase principal and that the instance (if referenced) belongs to them — +// per the PRD's "Edge Functions verify both the Supabase principal and +// resource ownership instead of relying only on client-supplied IDs." +// +// Provider credentials (Fly Sprites token) are read from Edge Function +// secrets and never returned to the client. + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { isRegionCode, isSizePreset, REGION_CODES, SIZE_PRESETS, type RegionCode, type SizePreset } from "../_shared/remote/catalog.ts"; +import { CURRENT_MANIFEST_VERSION } from "../_shared/remote/boot-manifest.ts"; +import { + ProviderError, + SpritesProvider, + spritesConfigFromEnv, + type ManagedComputeProvider, +} from "../_shared/remote/sprites-adapter.ts"; +import { isSpritesStubEnabled, StubSpritesProvider } from "../_shared/remote/stub-sprites-adapter.ts"; +import { recordAuditEvent } from "../_shared/remote/audit.ts"; +import { + beginOperation, + completeOperation, + createProvisioningInstance, + findExistingOperation, + getInstanceForOwner, + previousHostKeyFingerprint, + recordManagedEndpoint, + updateInstance, + type InstanceRow, +} from "../_shared/remote/instance-store.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +const MANAGED_SSH_PORT = 22; +const MANAGED_SSH_USERNAME = "treq"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function getProvider(): ManagedComputeProvider { + if (isSpritesStubEnabled()) return new StubSpritesProvider(); + return new SpritesProvider(spritesConfigFromEnv()); +} + +function providerErrorStatus(err: ProviderError): number { + switch (err.kind) { + case "not_found": + return 404; + case "already_exists": + return 409; + case "quota_exceeded": + return 429; + case "invalid_request": + return 400; + case "timeout": + return 504; + case "unavailable": + return 502; + default: + return 500; + } +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders, status: 204 }); + if (req.method !== "POST") return json({ error: "Method not allowed" }, 405); + + const authHeader = req.headers.get("authorization") ?? ""; + const userToken = authHeader.replace(/^Bearer\s+/i, ""); + if (!userToken) return json({ error: "Unauthorized" }, 401); + + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; + const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + + const supabaseUser = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: `Bearer ${userToken}` } }, + }); + const { + data: { user }, + error: authError, + } = await supabaseUser.auth.getUser(); + if (authError || !user) return json({ error: "Unauthorized" }, 401); + + // deno-lint-ignore no-explicit-any + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "Invalid JSON" }, 400); + } + + const action = body.action; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + try { + switch (action) { + case "list_regions": + return json({ regions: REGION_CODES }); + case "list_sizes": + return json({ presets: SIZE_PRESETS }); + case "status": + return await handleStatus(supabase, user.id); + case "ensure": + return await handleEnsure(supabase, user.id, body); + case "wake": + return await handleWake(supabase, user.id, body); + case "reprovision": + return await handleReprovision(supabase, user.id, body); + case "delete": + return await handleDelete(supabase, user.id, body); + default: + return json({ error: `Unknown action '${action}'` }, 400); + } + } catch (err) { + if (err instanceof ProviderError) { + return json({ error: err.message, provider_error: err.kind }, providerErrorStatus(err)); + } + if (err instanceof ValidationErrorWithStatus) { + return json({ error: err.message }, err.status); + } + console.error(`remote-instance action=${action} failed: ${(err as Error).message}`); + return json({ error: "Internal error" }, 500); + } +}); + +async function handleStatus(supabase: SupabaseClient, ownerUserId: string): Promise { + const instance = await getInstanceForOwner(supabase, ownerUserId); + if (!instance) return json({ instance: null, endpoint: null }); + + let endpoint = null; + if (instance.endpoint_id) { + const { data } = await supabase + .from("remote_endpoints") + .select("id, hostname, port, username, source") + .eq("id", instance.endpoint_id) + .maybeSingle(); + endpoint = data ?? null; + } + return json({ instance, endpoint }); +} + +function requireIdempotencyKey(body: Record): string { + const key = body.idempotency_key; + if (typeof key !== "string" || key.length === 0) { + throw new ValidationError("idempotency_key is required"); + } + return key; +} + +class ValidationError extends Error {} + +async function handleEnsure( + supabase: SupabaseClient, + ownerUserId: string, + // deno-lint-ignore no-explicit-any + body: Record, +): Promise { + let idempotencyKey: string; + try { + idempotencyKey = requireIdempotencyKey(body); + } catch (err) { + return json({ error: (err as Error).message }, 400); + } + + const region: RegionCode = isRegionCode(body.region) ? body.region : "us_east"; + const sizePreset: SizePreset = isSizePreset(body.size_preset) ? body.size_preset : "small"; + + const existingOp = await findExistingOperation(supabase, ownerUserId, idempotencyKey); + if (existingOp) { + // Repeated request with the same key: never create a second instance. + const instance = await getInstanceForOwner(supabase, ownerUserId); + return json({ operation_id: existingOp.id, status: existingOp.status, instance }); + } + + const existingInstance = await getInstanceForOwner(supabase, ownerUserId); + if (existingInstance && existingInstance.status !== "deleted") { + // One managed instance per user (Goal 1): ensure is a no-op once + // provisioned, regardless of idempotency key, so a second "first open of + // a managed repo" never provisions a second VM. + const op = await beginOperation(supabase, { + ownerUserId, + instanceId: existingInstance.id, + operationType: "provision", + idempotencyKey, + }); + await completeOperation(supabase, op.id, { status: "succeeded" }); + return json({ operation_id: op.id, status: "succeeded", instance: existingInstance }); + } + + const instance = await createProvisioningInstance(supabase, { + ownerUserId, + region, + sizePreset, + manifestVersion: CURRENT_MANIFEST_VERSION, + }); + + const op = await beginOperation(supabase, { + ownerUserId, + instanceId: instance.id, + operationType: "provision", + idempotencyKey, + }); + + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_create_requested", + detail: { region, size_preset: sizePreset, manifest_version: CURRENT_MANIFEST_VERSION, idempotency_key: idempotencyKey }, + }); + + try { + const provider = getProvider(); + const providerInstance = await provider.createInstance({ + ownerUserId, + region, + sizePreset, + manifestVersion: CURRENT_MANIFEST_VERSION, + idempotencyKey, + }); + + const status = mapProviderStateToInstanceStatus(providerInstance.state); + await updateInstance(supabase, instance.id, { + provider_resource_id: providerInstance.providerResourceId, + status, + ready_at: status === "ready" ? new Date().toISOString() : null, + }); + + if (providerInstance.address) { + const endpointId = await recordManagedEndpoint(supabase, { + ownerUserId, + instanceId: instance.id, + hostname: providerInstance.address, + port: MANAGED_SSH_PORT, + username: MANAGED_SSH_USERNAME, + existingEndpointId: null, + }); + await updateInstance(supabase, instance.id, { endpoint_id: endpointId }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + endpointId, + eventType: "host_key_registered", + detail: { + note: "host key fingerprint not yet available from provider create response; recorded once obtained through a trusted provisioning path", + generation: 0, + }, + }); + } else { + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "readiness_stage_failed", + detail: { stage: "endpoint_address", reason: "provider did not return an address yet" }, + }); + } + + await completeOperation(supabase, op.id, { + status: "succeeded", + providerRequestId: providerInstance.providerResourceId, + }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_create_succeeded", + detail: { provider_resource_id: providerInstance.providerResourceId, observed_state: providerInstance.state }, + }); + + const refreshed = await getInstanceForOwner(supabase, ownerUserId); + return json({ operation_id: op.id, status: "succeeded", instance: refreshed }); + } catch (err) { + await updateInstance(supabase, instance.id, { status: "failed" }); + await completeOperation(supabase, op.id, { status: "failed", errorMessage: (err as Error).message }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_create_failed", + detail: { error: (err as Error).message }, + }); + throw err; + } +} + +async function handleWake( + supabase: SupabaseClient, + ownerUserId: string, + // deno-lint-ignore no-explicit-any + body: Record, +): Promise { + let idempotencyKey: string; + try { + idempotencyKey = requireIdempotencyKey(body); + } catch (err) { + return json({ error: (err as Error).message }, 400); + } + + const instance = await requireOwnedInstance(supabase, ownerUserId, body.instance_id); + if (!instance.provider_resource_id) return json({ error: "Instance has no provider resource yet" }, 409); + + const existingOp = await findExistingOperation(supabase, ownerUserId, idempotencyKey); + if (existingOp) return json({ operation_id: existingOp.id, status: existingOp.status }); + + const op = await beginOperation(supabase, { ownerUserId, instanceId: instance.id, operationType: "wake", idempotencyKey }); + await recordAuditEvent(supabase, { ownerUserId, instanceId: instance.id, eventType: "instance_wake_requested" }); + + try { + await updateInstance(supabase, instance.id, { status: "waking" }); + const provider = getProvider(); + await provider.wakeInstance(instance.provider_resource_id); + const providerInstance = await provider.getInstance(instance.provider_resource_id); + const status = mapProviderStateToInstanceStatus(providerInstance.state); + await updateInstance(supabase, instance.id, { + status, + ready_at: status === "ready" ? new Date().toISOString() : instance.ready_at, + }); + await completeOperation(supabase, op.id, { status: "succeeded" }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_wake_succeeded", + detail: { observed_state: providerInstance.state }, + }); + return json({ operation_id: op.id, status: "succeeded" }); + } catch (err) { + await completeOperation(supabase, op.id, { status: "failed", errorMessage: (err as Error).message }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_wake_failed", + detail: { error: (err as Error).message }, + }); + throw err; + } +} + +async function handleReprovision( + supabase: SupabaseClient, + ownerUserId: string, + // deno-lint-ignore no-explicit-any + body: Record, +): Promise { + let idempotencyKey: string; + try { + idempotencyKey = requireIdempotencyKey(body); + } catch (err) { + return json({ error: (err as Error).message }, 400); + } + + const instance = await requireOwnedInstance(supabase, ownerUserId, body.instance_id); + if (!instance.provider_resource_id) return json({ error: "Instance has no provider resource yet" }, 409); + + const region: RegionCode = isRegionCode(body.region) ? body.region : instance.region; + const sizePreset: SizePreset = isSizePreset(body.size_preset) ? body.size_preset : instance.size_preset; + // Region migration is not supported (PRD non-goal): a region change is a + // brand-new resource at the vendor, not an in-place update, so surface it + // as a validation error here rather than silently reprovisioning in place. + if (region !== instance.region) { + return json( + { error: "Region migration is not supported. Delete and re-provision in the new region instead." }, + 400, + ); + } + + const existingOp = await findExistingOperation(supabase, ownerUserId, idempotencyKey); + if (existingOp) return json({ operation_id: existingOp.id, status: existingOp.status }); + + const op = await beginOperation(supabase, { + ownerUserId, + instanceId: instance.id, + operationType: "reprovision", + idempotencyKey, + }); + const nextGeneration = instance.generation + 1; + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_replace_requested", + detail: { region, size_preset: sizePreset, from_generation: instance.generation, to_generation: nextGeneration }, + }); + + try { + await updateInstance(supabase, instance.id, { status: "reprovisioning" }); + const provider = getProvider(); + const providerInstance = await provider.replaceInstance({ + providerResourceId: instance.provider_resource_id, + region, + sizePreset, + manifestVersion: CURRENT_MANIFEST_VERSION, + idempotencyKey, + }); + + const status = mapProviderStateToInstanceStatus(providerInstance.state); + // The control plane increments the instance generation on every replace + // (PRD "Reprovisioning"), regardless of whether the address changed — + // clients treat this as an explicit trust transition. + await updateInstance(supabase, instance.id, { + status, + generation: nextGeneration, + size_preset: sizePreset, + image_manifest_version: CURRENT_MANIFEST_VERSION, + ready_at: status === "ready" ? new Date().toISOString() : null, + }); + + if (providerInstance.address) { + const endpointId = await recordManagedEndpoint(supabase, { + ownerUserId, + instanceId: instance.id, + hostname: providerInstance.address, + port: MANAGED_SSH_PORT, + username: MANAGED_SSH_USERNAME, + existingEndpointId: instance.endpoint_id, + }); + if (!instance.endpoint_id) await updateInstance(supabase, instance.id, { endpoint_id: endpointId }); + + const previousFingerprint = instance.endpoint_id + ? await previousHostKeyFingerprint(supabase, instance.endpoint_id) + : null; + // Host key material is not yet returned by the vendor create/replace + // response (see remote-instance/index.ts handleEnsure comment); this + // records the rotation slot so Phase 3 verification has old/new + // fingerprint + generation to compare once a real fingerprint is + // available. + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + endpointId, + eventType: "host_key_rotated", + detail: { + previous_fingerprint: previousFingerprint, + generation: nextGeneration, + provider_resource_id: providerInstance.providerResourceId, + }, + }); + } + + await completeOperation(supabase, op.id, { status: "succeeded", providerRequestId: providerInstance.providerResourceId }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_replace_succeeded", + detail: { generation: nextGeneration, observed_state: providerInstance.state }, + }); + + const refreshed = await getInstanceForOwner(supabase, ownerUserId); + return json({ operation_id: op.id, status: "succeeded", instance: refreshed }); + } catch (err) { + await completeOperation(supabase, op.id, { status: "failed", errorMessage: (err as Error).message }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_replace_failed", + detail: { error: (err as Error).message }, + }); + throw err; + } +} + +async function handleDelete( + supabase: SupabaseClient, + ownerUserId: string, + // deno-lint-ignore no-explicit-any + body: Record, +): Promise { + let idempotencyKey: string; + try { + idempotencyKey = requireIdempotencyKey(body); + } catch (err) { + return json({ error: (err as Error).message }, 400); + } + + const instance = await requireOwnedInstance(supabase, ownerUserId, body.instance_id); + + const existingOp = await findExistingOperation(supabase, ownerUserId, idempotencyKey); + if (existingOp) return json({ operation_id: existingOp.id, status: existingOp.status }); + + const op = await beginOperation(supabase, { ownerUserId, instanceId: instance.id, operationType: "delete", idempotencyKey }); + await recordAuditEvent(supabase, { ownerUserId, instanceId: instance.id, eventType: "instance_delete_requested" }); + + try { + await updateInstance(supabase, instance.id, { status: "deleting" }); + if (instance.provider_resource_id) { + const provider = getProvider(); + await provider.deleteInstance(instance.provider_resource_id); + } + await updateInstance(supabase, instance.id, { status: "deleted", endpoint_id: null }); + await completeOperation(supabase, op.id, { status: "succeeded" }); + await recordAuditEvent(supabase, { ownerUserId, instanceId: instance.id, eventType: "instance_delete_succeeded" }); + return json({ operation_id: op.id, status: "succeeded" }); + } catch (err) { + await completeOperation(supabase, op.id, { status: "failed", errorMessage: (err as Error).message }); + await recordAuditEvent(supabase, { + ownerUserId, + instanceId: instance.id, + eventType: "instance_delete_failed", + detail: { error: (err as Error).message }, + }); + throw err; + } +} + +// Resolves the instance to act on and verifies ownership server-side, per +// the PRD's security requirement to check both principal and resource +// ownership rather than trusting a client-supplied instance id alone. A +// caller may omit instance_id (there is only ever one managed instance per +// user); if they supply one, it must match the caller's own instance. +async function requireOwnedInstance( + supabase: SupabaseClient, + ownerUserId: string, + suppliedInstanceId: unknown, +): Promise { + const instance = await getInstanceForOwner(supabase, ownerUserId); + if (!instance) throw new ValidationErrorWithStatus("No managed instance for this user", 404); + if (typeof suppliedInstanceId === "string" && suppliedInstanceId !== instance.id) { + throw new ValidationErrorWithStatus("Instance does not belong to this user", 403); + } + return instance; +} + +class ValidationErrorWithStatus extends Error { + constructor(message: string, public readonly status: number) { + super(message); + } +} + +function mapProviderStateToInstanceStatus(state: string): string { + // Provider states map 1:1 onto the domain lifecycle states already + // enumerated in the remote_instances status check constraint. + return state; +}