From d52575be60db3358f95e2d01d351eec002ba9bf5 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 24 Jul 2026 15:26:23 +0100 Subject: [PATCH] feat(api): customer app catalog + deployment views (read-only) Increment 3a of the App Deployments epic. Read-only customer surface for the managed app catalog and a user's own deployments. - GET /api/v1/apps, GET /api/v1/apps/{id}: browse offered catalog apps (compose exposed so the UI can render the deploy form). - GET /api/v1/app-deployments, GET /api/v1/app-deployments/{id}: list/show the authenticated user's deployments (ownership-checked); subscription_id resolved from the line item for renewal. - New api/apps.rs module (ApiApp / ApiAppDeployment) merged into the router. - e2e customer test with a seed_app_deployment helper (app + cluster + subscription/line-item + deployment); docs + changelog. Ordering/lifecycle (billing) is increment 3b. Part of work/app-deployments.md. --- API_CHANGELOG.md | 1 + API_DOCUMENTATION.md | 55 ++++++++++++++ lnvps_api/src/api/apps.rs | 151 ++++++++++++++++++++++++++++++++++++++ lnvps_api/src/api/mod.rs | 2 + lnvps_api/src/bin/api.rs | 1 + lnvps_e2e/src/db.rs | 67 +++++++++++++++++ lnvps_e2e/src/user_api.rs | 67 +++++++++++++++++ work/app-deployments.md | 15 ++-- 8 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 lnvps_api/src/api/apps.rs diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 4b05bf3..23174e4 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Managed app deployments — customer catalog & deployment views** (part of the App Deployments epic) — new read-only customer endpoints: `GET /api/v1/apps` and `GET /api/v1/apps/{id}` browse the catalog of offered apps (each exposes its docker-compose-style `compose` so clients can render the deploy form), and `GET /api/v1/app-deployments` and `GET /api/v1/app-deployments/{id}` list/show your own deployments (name, hostname, status, desired state, billing subscription). Ordering and lifecycle control land in a later increment. - **Managed app deployments — admin catalog & clusters** (part of the App Deployments epic) — new admin endpoints to manage a catalog of predefined "apps" (e.g. Nostr relay, Blossom) deployed on shared Kubernetes infrastructure, and the clusters they run on. `GET/POST /api/admin/v1/apps` and `GET/PATCH/DELETE /api/admin/v1/apps/{id}` manage catalog apps (each defined by a docker-compose-style `compose` YAML plus subscription pricing); `GET/POST /api/admin/v1/app_clusters` and `GET/PATCH/DELETE /api/admin/v1/app_clusters/{id}` manage clusters (each bound to a `region` — which provides the billing company — and an ingress domain). Gated behind a new `app` admin permission (granted to `super_admin` by default). No customer-facing surface yet — deployment ordering and the operator reconcile land in later increments. - **User-chosen referral payout threshold** — referrers can now set a personal `payout_threshold` (in **satoshis**) on `POST`/`PATCH /api/v1/referral`, and it's returned on the `Referral` response and admin referral endpoints. This lets a referrer (on-chain ones in particular) avoid many tiny payouts by batching commission up to a larger amount. The value must be **at least the system minimum** for the payout method (the API rejects lower values); the effective threshold used at payout time is `max(system minimum, payout_threshold)`, so a referrer can raise — but never lower — the bar. `null`/omitted uses the system minimum (unchanged behaviour). A migration adds the nullable `referral.payout_threshold` column. - **Host CPU architecture on VM status** (issue #210) — the customer VM status (`GET /api/v1/vm/{id}` and `GET /api/v1/vms`) now includes an optional `cpu_arch` string (`"x86_64"`/`"arm64"`) sourced from the **host** the VM runs on. Unlike `template.cpu_arch` (an optional template *constraint* that is omitted when the template doesn't pin an architecture), this reflects the concrete host architecture and is present whenever it's known, so clients can always pass `?arch=` when listing OS images for a reinstall (see #202) and avoid offering images that would fail provisioning (see #183). Read-only, additive; omitted when the host arch is unknown. diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index b3c8078..5113153 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -1269,6 +1269,61 @@ interface ReferralState extends Referral { } ``` +### Managed Apps + +Browse the catalog of predefined apps (e.g. Nostr relay, Blossom) and view your own deployments. These endpoints are **read-only**; deployment ordering and lifecycle control are added in a later release. + +#### List Catalog Apps +- **GET** `/api/v1/apps` +- **Auth**: Required +- **Response**: `App[]` — all currently offered apps + +#### Get Catalog App +- **GET** `/api/v1/apps/{id}` +- **Auth**: Required +- **Response**: `App` +- **Error**: `404` if the app doesn't exist or isn't currently offered + +#### List Your Deployments +- **GET** `/api/v1/app-deployments` +- **Auth**: Required +- **Response**: `AppDeployment[]` — your deployments (most recent first) + +#### Get Your Deployment +- **GET** `/api/v1/app-deployments/{id}` +- **Auth**: Required +- **Response**: `AppDeployment` +- **Error**: `404` if not found or not owned by you + +**Response Types:** +```typescript +interface App { + id: number; + name: string; // URL/DNS-safe slug + display_name: string; + description?: string; + icon?: string; + compose: string; // docker-compose-style YAML; render the config form (ports/env) from this + amount: number; // recurring price in smallest currency units (cents / millisats) + currency: string; + interval_amount: number; + interval_type: "day" | "month" | "year"; + setup_amount: number; // one-off setup fee in smallest currency units (0 = none) +} + +interface AppDeployment { + id: number; + app_id: number; // catalog app being run + name: string; // your instance name + hostname?: string; // public endpoint host once assigned (null until reconciled, or for apps with no ingress port) + desired_state: "running" | "stopped"; + status: "pending" | "running" | "stopped" | "error" | "deleting"; + status_message?: string; // operator status/error detail when present + subscription_id?: number; // subscription this deployment is billed under (renew via the subscription endpoints) + created: string; // ISO 8601 datetime +} +``` + ### Monitoring and History #### Get VM Time Series Data diff --git a/lnvps_api/src/api/apps.rs b/lnvps_api/src/api/apps.rs new file mode 100644 index 0000000..e795c26 --- /dev/null +++ b/lnvps_api/src/api/apps.rs @@ -0,0 +1,151 @@ +//! Customer-facing **managed app** endpoints (read-only). +//! +//! Browse the app catalog and view your own deployments. Ordering, lifecycle +//! control and the operator reconcile land in later increments. + +use crate::api::RouterState; +use axum::extract::{Path, State}; +use axum::routing::get; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use lnvps_api_common::{ApiData, ApiError, ApiIntervalType, ApiResult, Nip98Auth}; +use lnvps_db::{App, AppDeployment, LNVpsDb}; +use serde::Serialize; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/apps", get(v1_list_apps)) + .route("/api/v1/apps/{id}", get(v1_get_app)) + .route("/api/v1/app-deployments", get(v1_list_app_deployments)) + .route("/api/v1/app-deployments/{id}", get(v1_get_app_deployment)) +} + +/// A catalog app offered for deployment. +#[derive(Serialize)] +pub struct ApiApp { + pub id: u64, + /// URL/DNS-safe slug. + pub name: String, + pub display_name: String, + pub description: Option, + pub icon: Option, + /// docker-compose-style YAML defining the app. Clients render the + /// configuration form (ports/env) from this spec. + pub compose: String, + /// Recurring price in the smallest currency unit (cents / millisats). + pub amount: u64, + pub currency: String, + pub interval_amount: u64, + pub interval_type: ApiIntervalType, + /// One-off setup fee in the smallest currency unit (0 = none). + pub setup_amount: u64, +} + +impl From for ApiApp { + fn from(a: App) -> Self { + Self { + id: a.id, + name: a.name, + display_name: a.display_name, + description: a.description, + icon: a.icon, + compose: a.compose, + amount: a.amount, + currency: a.currency, + interval_amount: a.interval_amount, + interval_type: a.interval_type.into(), + setup_amount: a.setup_amount, + } + } +} + +/// A customer's app deployment. +#[derive(Serialize)] +pub struct ApiAppDeployment { + pub id: u64, + /// Catalog app this deployment runs. + pub app_id: u64, + /// User-chosen instance name. + pub name: String, + /// Public endpoint hostname once assigned (`None` until reconciled or for + /// apps with no ingress port). + pub hostname: Option, + /// Desired run state: `running` or `stopped`. + pub desired_state: String, + /// Observed status: `pending`, `running`, `stopped`, `error`, `deleting`. + pub status: String, + /// Human-readable status/error detail from the operator, when present. + pub status_message: Option, + /// Subscription this deployment is billed under (renew via the subscription + /// endpoints). `None` if the subscription can't be resolved. + pub subscription_id: Option, + pub created: DateTime, +} + +async fn deployment_to_api(this: &RouterState, d: AppDeployment) -> ApiAppDeployment { + // Resolve the owning subscription from the line item (best-effort). + let subscription_id = this + .db + .get_subscription_by_line_item_id(d.subscription_line_item_id) + .await + .ok() + .map(|s| s.id); + ApiAppDeployment { + id: d.id, + app_id: d.app_id, + name: d.name, + hostname: d.hostname, + desired_state: d.desired_state.to_string(), + status: d.status.to_string(), + status_message: d.status_message, + subscription_id, + created: d.created, + } +} + +/// List all enabled catalog apps. +async fn v1_list_apps(_auth: Nip98Auth, State(this): State) -> ApiResult> { + let apps = this.db.list_apps(true).await?; + ApiData::ok(apps.into_iter().map(Into::into).collect()) +} + +/// Get a single enabled catalog app. +async fn v1_get_app( + _auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult { + let app = this.db.get_app(id).await?; + if !app.enabled { + return Err(ApiError::not_found("App not found")); + } + ApiData::ok(app.into()) +} + +/// List the authenticated user's app deployments. +async fn v1_list_app_deployments( + auth: Nip98Auth, + State(this): State, +) -> ApiResult> { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let deployments = this.db.list_user_app_deployments(uid).await?; + let mut out = Vec::with_capacity(deployments.len()); + for d in deployments { + out.push(deployment_to_api(&this, d).await); + } + ApiData::ok(out) +} + +/// Get one of the authenticated user's app deployments. +async fn v1_get_app_deployment( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let deployment = this.db.get_app_deployment(id).await?; + if deployment.user_id != uid || deployment.deleted { + return Err(ApiError::not_found("Deployment not found")); + } + ApiData::ok(deployment_to_api(&this, deployment).await) +} diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index e6e9dee..681b146 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -1,3 +1,4 @@ +mod apps; mod contact; mod docs; mod ip_space; @@ -14,6 +15,7 @@ mod webhook; use crate::settings::Settings; use crate::subscription::SubscriptionHandler; +pub use apps::router as apps_router; pub use contact::router as contacts_router; pub use docs::router as docs_router; pub use ip_space::router as ip_space_router; diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 64b15bb..09646ea 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -461,6 +461,7 @@ async fn main() -> Result<(), Error> { .merge(subscriptions_router()) .merge(ip_space_router()) .merge(referral_router()) + .merge(apps_router()) .merge(legal_router()) .merge(oauth_router()) .merge(webauthn_router()); diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index 203165c..11e8b11 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -518,6 +518,73 @@ pub async fn seed_referrer_with_commission( Ok((referral_id, vm_id)) } +/// Seed a full app deployment (catalog app + cluster + subscription/line-item + +/// deployment) owned by `user_id`. Returns `(app_id, cluster_id, deployment_id)`. +/// Used to exercise the read-only customer app endpoints before the ordering +/// flow exists. +pub async fn seed_app_deployment( + pool: &MySqlPool, + user_id: u64, + name: &str, +) -> anyhow::Result<(u64, u64, u64)> { + let suffix = hex::encode(&rand_bytes32()[..4]); + let slug = format!("{name}-{suffix}"); + + let (app_id,): (u64,) = sqlx::query_as( + "INSERT INTO app (name, display_name, description, icon, compose, amount, currency, \ + interval_amount, interval_type, setup_amount, enabled) \ + VALUES (?, 'E2E App', NULL, NULL, 'services: {}', 1000, 'USD', 1, 1, 0, 1) RETURNING id", + ) + .bind(&slug) + .fetch_one(pool) + .await?; + + let (cluster_id,): (u64,) = sqlx::query_as( + "INSERT INTO app_cluster (name, region_id, ingress_domain, enabled) \ + VALUES (?, (SELECT MIN(id) FROM region), 'apps.e2e.example.com', 1) RETURNING id", + ) + .bind(&slug) + .fetch_one(pool) + .await?; + + let (sub_id,): (u64,) = sqlx::query_as( + "INSERT INTO subscription (user_id, company_id, name, description, created, expires, \ + is_active, is_setup, currency, interval_amount, interval_type, setup_fee, \ + auto_renewal_enabled, external_id) \ + VALUES (?, (SELECT MIN(id) FROM company), 'e2e-app-sub', NULL, NOW(), NULL, 1, 1, \ + 'USD', 1, 1, 0, 0, NULL) RETURNING id", + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + // subscription_type = 4 (App) + let (li_id,): (u64,) = sqlx::query_as( + "INSERT INTO subscription_line_item (subscription_id, subscription_type, name, \ + description, amount, setup_amount, configuration) \ + VALUES (?, 4, 'app', NULL, 1000, 0, NULL) RETURNING id", + ) + .bind(sub_id) + .fetch_one(pool) + .await?; + + let (dep_id,): (u64,) = sqlx::query_as( + "INSERT INTO app_deployment (user_id, app_id, cluster_id, subscription_line_item_id, \ + name, namespace, hostname, config, desired_state, status, status_message) \ + VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, NULL) RETURNING id", + ) + .bind(user_id) + .bind(app_id) + .bind(cluster_id) + .bind(li_id) + .bind(name) + .bind(&slug) + .fetch_one(pool) + .await?; + + Ok((app_id, cluster_id, dep_id)) +} + fn random_mac() -> String { let b = rand_bytes32(); format!( diff --git a/lnvps_e2e/src/user_api.rs b/lnvps_e2e/src/user_api.rs index 87af718..b7d70b3 100644 --- a/lnvps_e2e/src/user_api.rs +++ b/lnvps_e2e/src/user_api.rs @@ -894,4 +894,71 @@ mod tests { resp.status() ); } + + #[tokio::test] + async fn test_app_catalog_and_deployments() { + // Dedicated user so the seeded deployment is isolated from other tests. + let keys = nostr::Keys::generate(); + let client = user_client_with_keys(keys.clone()); + let pool = crate::db::connect().await.unwrap(); + let uid = crate::db::ensure_user(&pool, &keys).await.unwrap(); + + // Seed a full deployment (app + cluster + subscription + deployment). + let (app_id, _cluster_id, dep_id) = crate::db::seed_app_deployment(&pool, uid, "my-relay") + .await + .unwrap(); + + // Catalog listing includes the seeded (enabled) app. + let resp = client.get_auth("/api/v1/apps").await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let apps = body["data"].as_array().unwrap(); + assert!(apps.iter().any(|a| a["id"].as_u64() == Some(app_id))); + // Compose is exposed so the UI can render the deploy form. + let seeded = apps + .iter() + .find(|a| a["id"].as_u64() == Some(app_id)) + .unwrap(); + assert!(seeded["compose"].as_str().is_some()); + assert_eq!(seeded["currency"].as_str().unwrap(), "USD"); + + // Get single app. + let resp = client + .get_auth(&format!("/api/v1/apps/{app_id}")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Deployment listing includes the seeded deployment. + let resp = client.get_auth("/api/v1/app-deployments").await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let dep = body["data"] + .as_array() + .unwrap() + .iter() + .find(|d| d["id"].as_u64() == Some(dep_id)) + .expect("seeded deployment present"); + assert_eq!(dep["name"].as_str().unwrap(), "my-relay"); + assert_eq!(dep["status"].as_str().unwrap(), "pending"); + assert_eq!(dep["desired_state"].as_str().unwrap(), "running"); + // subscription_id resolves from the line item. + assert!(dep["subscription_id"].as_u64().is_some()); + + // Get single deployment (ownership OK). + let resp = client + .get_auth(&format!("/api/v1/app-deployments/{dep_id}")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // A non-existent deployment id is 404 for this user. + let resp = client + .get_auth("/api/v1/app-deployments/99999999") + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + pool.close().await; + } } diff --git a/work/app-deployments.md b/work/app-deployments.md index f55d281..89856f9 100644 --- a/work/app-deployments.md +++ b/work/app-deployments.md @@ -61,11 +61,16 @@ images (higher isolation risk — design the boundary in now). - [x] Unit test (validate_app_fields) + e2e admin CRUD test (apps + clusters, auth enforcement). - [x] ADMIN_API_ENDPOINTS.md + API_CHANGELOG.md. -### Increment 3 — Customer API -- [ ] `GET /api/v1/apps` (catalog), create deployment (→ subscription + line item + invoice, - mirroring VM order), list/get/delete deployments, status + endpoint. -- [ ] Validate submitted env/config against the app's compose env schema. -- [ ] Tests + API_DOCUMENTATION.md + API_CHANGELOG.md. +### Increment 3a — Customer API (read-only) (DONE, PR pending) +- [x] `GET /api/v1/apps`, `GET /api/v1/apps/{id}` (catalog); `GET /api/v1/app-deployments`, + `GET /api/v1/app-deployments/{id}` (own deployments, ownership-checked). +- [x] ApiApp / ApiAppDeployment response models (compose exposed for the deploy form; + subscription_id resolved from the line item). +- [x] e2e customer test (seed_app_deployment helper) + API_DOCUMENTATION.md + API_CHANGELOG.md. + +### Increment 3b — Customer ordering / lifecycle (billing) — TODO +- [ ] Create deployment (validate config vs compose env schema → subscription + line item + (type App) + payment invoice, mirroring VM order); delete/stop/start; renew via subscription. ### Increment 4 — Operator reconcile - [ ] `lnvps_operator/src/app_deployments.rs`: parse compose YAML + deployment config →