From 8a18a49da1975971b6a6e10af1637e2816595b7a Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 24 Jul 2026 18:12:03 +0100 Subject: [PATCH] feat(api): customer app deployment ordering + lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 3b of the App Deployments epic. Customers can now deploy apps. - POST /api/v1/app-deployments: validate DNS-safe name + config (against the app's compose config schema), admit against the region's cluster capacity (AppClusterCapacityService::select_in_region), then create a Subscription + App line item + the deployment (pending). Config stored encrypted. Activated by paying the subscription via the standard flow — the renew engine already bills flat non-VPS line items, so App needs no special case. - DELETE: deactivate the subscription (stop billing) + soft-delete (operator GCs the namespace/volumes). PATCH .../stop|start toggle desired_state. All ownership-checked. - Operator: only run paid (subscription is_setup) + unexpired deployments, so an unpaid new order stays at 0 replicas until paid. - Unit tests (validate_deployment_name, resolve_config) + e2e ordering test (order, name/config/capacity rejection, stop/start, delete). Docs + changelog. Part of work/app-deployments.md. --- API_CHANGELOG.md | 1 + API_DOCUMENTATION.md | 32 ++- Cargo.lock | 1 + lnvps_api/Cargo.toml | 1 + lnvps_api/src/api/apps.rs | 308 +++++++++++++++++++++++++- lnvps_e2e/src/db.rs | 35 +++ lnvps_e2e/src/user_api.rs | 98 ++++++++ lnvps_operator/src/app_deployments.rs | 15 +- work/app-deployments.md | 13 +- 9 files changed, 489 insertions(+), 15 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 3eea6b03..fa971ae6 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 ordering & lifecycle** (part of the App Deployments epic) — customers can now deploy apps: `POST /api/v1/app-deployments` (`app_id`, DNS-safe `name`, `region_id`, `config`) validates the config against the app's compose schema, admits it against the chosen region's cluster capacity, and creates a billing subscription + `App` line item + the deployment (in `pending`). The deployment activates once its subscription is paid via the standard subscription flow (`/api/v1/subscriptions/{id}/renew`); the operator only runs paid, unexpired deployments. `PATCH /api/v1/app-deployments/{id}/stop` and `/start` toggle the workload (stop scales to 0, retaining data), and `DELETE /api/v1/app-deployments/{id}` deactivates billing and tears it down. Adds `SubscriptionType::App` to the subscription engine. - **Managed app deployments — capacity accounting** (part of the App Deployments epic) — app clusters now have static, admin-configured capacity and each catalog app carries a computed resource footprint, so deployments can be admitted against a cluster's free capacity (1:1, no overcommit). `app_cluster` gains `capacity_cpu_milli` / `capacity_memory_bytes` / `capacity_storage_bytes` (settable on the admin cluster create/update endpoints); `app` gains `cpu_milli` / `memory_bytes` / `storage_bytes`, computed from the compose (Σ service `resources` + volume sizes) on catalog create/update and returned on `AdminAppInfo`. A migration adds the columns. - **Managed app deployments — operator reconcile** (part of the App Deployments epic) — the `lnvps_operator` now reconciles `app_deployment` rows for its configured cluster into Kubernetes: one locked-down namespace per deployment (restricted Pod Security + default-deny NetworkPolicy), a Deployment + ClusterIP Service per compose service, a cert-manager-TLS Ingress for the `expose: ingress` port, PVCs for `volumes:`, and ConfigMap/Secret-backed `files:` mounted read-only. Generated `secrets:` are created once and kept stable; `${…}` env/files are resolved per deployment. PVCs use the cluster's default StorageClass. Retention is data-preserving: an **expired** deployment is scaled to **0 replicas** (pods stop, persistent volumes retained) rather than torn down — only real deletion removes the namespace/volumes. As a result the customer `AppDeployment` `status` and `hostname` fields become populated once a deployment is provisioned (previously always pending/empty). Purely operational — no request/response schema change. - **Managed app deployments — config-file mounts (`files:`)** (part of the App Deployments epic) — the app `compose` grammar now supports injecting read-only config files into a container (rendered into a ConfigMap, or a Secret when `sensitive`, and mounted via `subPath`), so file-driven apps (e.g. strfry's `strfry.conf`) are deployable. Each service's `files:` entry declares a `path` plus either inline templated `content` (with `${…}` filled from `config`/`secrets`) or `content_from` a `config` field (including a new `type: file` for customer-supplied whole files). Validated by the shared parser (and therefore the admin catalog API): absolute non-traversal paths, a single content source, ≤256 KiB inline content, `content_from` must reference a declared field, and a file may not overlap a read-write data volume. Distinct from `volumes:` (persistent read-write PVCs). diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 51131536..8344973e 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -1271,7 +1271,37 @@ 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. +Browse the catalog of predefined apps (e.g. Nostr relay, Blossom), order your own deployments, and manage their lifecycle. + +#### Order a Deployment +- **POST** `/api/v1/app-deployments` +- **Auth**: Required +- **Body**: +```typescript +interface CreateAppDeploymentRequest { + app_id: number; // catalog app to deploy + name: string; // DNS-safe label (lowercase letters/digits/hyphens, ≤40), becomes the subdomain + region_id: number; // region to deploy in; a cluster there with capacity is chosen + config?: { [field: string]: string }; // values for the app's `config` fields +} +``` +- **Response**: `AppDeployment` — created in `pending` state with a billing subscription; **pay the subscription** (`GET /api/v1/subscriptions/{subscription_id}/renew`) to activate it. The operator starts the workload once paid. +- **Errors**: `400` for an invalid `name`, missing required / unknown `config` fields, or when no cluster in the region has enough capacity; `404` if the app doesn't exist or isn't offered. + +#### Stop / Start a Deployment +- **PATCH** `/api/v1/app-deployments/{id}/stop` — scale to 0 (data retained) +- **PATCH** `/api/v1/app-deployments/{id}/start` — resume +- **Auth**: Required +- **Response**: `AppDeployment` +- **Error**: `404` if not found or not owned by you + +#### Delete a Deployment +- **DELETE** `/api/v1/app-deployments/{id}` +- **Auth**: Required +- **Response**: `true` — stops billing (deactivates the subscription) and tears the deployment down (namespace + volumes removed). +- **Error**: `404` if not found or not owned by you + +The catalog + read endpoints below are unauthenticated-safe views of what you can deploy and what you're running. #### List Catalog Apps - **GET** `/api/v1/apps` diff --git a/Cargo.lock b/Cargo.lock index c6892e49..f61d7bb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2878,6 +2878,7 @@ dependencies = [ "lightning-invoice", "lnurl-rs", "lnvps_api_common", + "lnvps_compose", "lnvps_db", "log 0.4.32", "mustache", diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index c8bbfa90..edfced57 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -57,6 +57,7 @@ stripe = ["payments-rs/method-stripe"] [dependencies] lnvps_db = { path = "../lnvps_db" } +lnvps_compose = { path = "../lnvps_compose" } lnvps_api_common = { path = "../lnvps_api_common" } try-procedure = { path = "../try-procedure" } diff --git a/lnvps_api/src/api/apps.rs b/lnvps_api/src/api/apps.rs index e795c26c..c3196e3e 100644 --- a/lnvps_api/src/api/apps.rs +++ b/lnvps_api/src/api/apps.rs @@ -5,19 +5,40 @@ use crate::api::RouterState; use axum::extract::{Path, State}; -use axum::routing::get; +use axum::routing::{get, patch, post}; 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; +use lnvps_api_common::{ + ApiData, ApiError, ApiIntervalType, ApiResult, AppCapacity, AppClusterCapacityService, + Nip98Auth, +}; +use lnvps_db::{ + App, AppDeployment, AppDeploymentDesiredState, AppDeploymentStatus, EncryptedString, LNVpsDb, + Subscription, SubscriptionLineItem, SubscriptionType, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; 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)) + .route( + "/api/v1/app-deployments", + get(v1_list_app_deployments).post(v1_create_app_deployment), + ) + .route( + "/api/v1/app-deployments/{id}", + get(v1_get_app_deployment).delete(v1_delete_app_deployment), + ) + .route( + "/api/v1/app-deployments/{id}/start", + patch(v1_start_app_deployment), + ) + .route( + "/api/v1/app-deployments/{id}/stop", + patch(v1_stop_app_deployment), + ) } /// A catalog app offered for deployment. @@ -149,3 +170,278 @@ async fn v1_get_app_deployment( } ApiData::ok(deployment_to_api(&this, deployment).await) } + +/// Order a new app deployment. +#[derive(Deserialize)] +pub struct CreateAppDeploymentRequest { + /// Catalog app to deploy. + pub app_id: u64, + /// User-chosen DNS-safe instance name (becomes the subdomain). + pub name: String, + /// Region to deploy in; a cluster there with capacity is selected. + pub region_id: u64, + /// Values for the app's `config` fields. + #[serde(default)] + pub config: BTreeMap, +} + +/// Validate `name` is a DNS-safe label usable as a subdomain. +fn validate_deployment_name(name: &str) -> Result<(), ApiError> { + let n = name.trim(); + if n.is_empty() || n.len() > 40 { + return Err(ApiError::new("name must be 1–40 characters")); + } + if !n + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + || n.starts_with('-') + || n.ends_with('-') + { + return Err(ApiError::new( + "name must be a DNS-safe label (lowercase letters, digits, hyphens)", + )); + } + Ok(()) +} + +/// Validate the submitted `config` against the app's compose `config` schema: +/// required fields must be present, unknown keys rejected; returns the resolved +/// map (submitted values ∪ declared defaults). +fn resolve_config( + compose: &lnvps_compose::Compose, + submitted: &BTreeMap, +) -> Result, ApiError> { + let declared: std::collections::HashSet<&str> = + compose.config.iter().map(|c| c.name.as_str()).collect(); + for key in submitted.keys() { + if !declared.contains(key.as_str()) { + return Err(ApiError::new(format!("unknown config field '{key}'"))); + } + } + let mut out = BTreeMap::new(); + for field in &compose.config { + match submitted.get(&field.name).or(field.default.as_ref()) { + Some(v) => { + out.insert(field.name.clone(), v.clone()); + } + None if field.required => { + return Err(ApiError::new(format!( + "config field '{}' is required", + field.name + ))); + } + None => {} + } + } + Ok(out) +} + +async fn v1_create_app_deployment( + auth: Nip98Auth, + State(this): State, + Json(req): Json, +) -> ApiResult { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + + let app = this.db.get_app(req.app_id).await?; + if !app.enabled { + return Err(ApiError::new("App is not available")); + } + validate_deployment_name(&req.name)?; + + // Validate config against the app's compose schema. + let compose = lnvps_compose::Compose::parse(&app.compose) + .map_err(|e| ApiError::new(format!("app compose is invalid: {e}")))?; + let config = resolve_config(&compose, &req.config)?; + + // Capacity admission: pick an enabled cluster in the region with room for + // the app's footprint. + let need = AppCapacity { + cpu_milli: app.cpu_milli, + memory_bytes: app.memory_bytes, + storage_bytes: app.storage_bytes, + }; + let capacity = AppClusterCapacityService::new(this.db.clone()); + let Some(cluster) = capacity.select_in_region(req.region_id, need).await? else { + return Err(ApiError::new( + "No cluster with enough capacity is available in this region", + )); + }; + let region = this.db.get_host_region(cluster.region_id).await?; + + // Create the subscription + App line item (billed via the standard + // subscription payment flow — pay the returned subscription to activate). + let subscription = Subscription { + id: 0, + user_id: uid, + company_id: region.company_id, + name: format!("{} deployment", app.display_name), + description: None, + created: Utc::now(), + expires: None, + is_active: false, + is_setup: false, + currency: app.currency.clone(), + interval_amount: app.interval_amount, + interval_type: app.interval_type, + setup_fee: app.setup_amount, + auto_renewal_enabled: true, + external_id: None, + }; + let line_item = SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::App, + name: app.display_name.clone(), + description: None, + amount: app.amount, + setup_amount: app.setup_amount, + configuration: None, + }; + let (_sub_id, line_item_ids) = this + .db + .insert_subscription_with_line_items(&subscription, vec![line_item]) + .await?; + let line_item_id = line_item_ids[0]; + + // Config is stored encrypted (may hold secret values). + let config_json = serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()); + + let mut deployment = AppDeployment { + id: 0, + user_id: uid, + app_id: app.id, + cluster_id: cluster.id, + subscription_line_item_id: line_item_id, + name: req.name.trim().to_string(), + // Temporary unique namespace; finalized to `app-{id}` below. + namespace: format!("app-pending-{line_item_id}"), + hostname: None, + config: Some(EncryptedString::new(config_json)), + desired_state: AppDeploymentDesiredState::Running, + status: AppDeploymentStatus::Pending, + status_message: None, + created: Utc::now(), + deleted: false, + }; + let id = this.db.insert_app_deployment(&deployment).await?; + // Finalize the namespace to the operator's derived form. + deployment.id = id; + deployment.namespace = format!("app-{id}"); + this.db.update_app_deployment(&deployment).await?; + + ApiData::ok(deployment_to_api(&this, deployment).await) +} + +/// Resolve and ownership-check a deployment for the authenticated user. +async fn owned_deployment( + this: &RouterState, + uid: u64, + id: u64, +) -> Result { + let d = this.db.get_app_deployment(id).await?; + if d.user_id != uid || d.deleted { + return Err(ApiError::not_found("Deployment not found")); + } + Ok(d) +} + +async fn v1_delete_app_deployment( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + let deployment = owned_deployment(&this, uid, id).await?; + + // Stop billing: deactivate the subscription, then soft-delete the + // deployment (the operator tears down the namespace + volumes on its next + // reconcile). + if let Ok(mut sub) = this + .db + .get_subscription_by_line_item_id(deployment.subscription_line_item_id) + .await + { + sub.is_active = false; + sub.auto_renewal_enabled = false; + let _ = this.db.update_subscription(&sub).await; + } + this.db.delete_app_deployment(id).await?; + ApiData::ok(true) +} + +async fn set_desired_state( + this: &RouterState, + uid: u64, + id: u64, + state: AppDeploymentDesiredState, +) -> ApiResult { + let mut deployment = owned_deployment(this, uid, id).await?; + deployment.desired_state = state; + this.db.update_app_deployment(&deployment).await?; + ApiData::ok(deployment_to_api(this, deployment).await) +} + +async fn v1_start_app_deployment( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + set_desired_state(&this, uid, id, AppDeploymentDesiredState::Running).await +} + +async fn v1_stop_app_deployment( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult { + let uid = this.db.upsert_user(&auth.pubkey()).await?; + set_desired_state(&this, uid, id, AppDeploymentDesiredState::Stopped).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_deployment_name() { + assert!(validate_deployment_name("my-relay").is_ok()); + assert!(validate_deployment_name("relay1").is_ok()); + assert!(validate_deployment_name("").is_err()); + assert!(validate_deployment_name("Relay").is_err()); + assert!(validate_deployment_name("re lay").is_err()); + assert!(validate_deployment_name("-relay").is_err()); + assert!(validate_deployment_name("relay-").is_err()); + assert!(validate_deployment_name(&"a".repeat(41)).is_err()); + } + + #[test] + fn test_resolve_config() { + let compose = lnvps_compose::Compose::parse( + "services:\n a:\n image: x\nconfig:\n - { name: relay_name, type: string, required: true }\n - { name: max_mb, type: int, default: \"100\" }\n", + ) + .unwrap(); + + // Required present + default filled. + let mut submitted = BTreeMap::new(); + submitted.insert("relay_name".to_string(), "Zap".to_string()); + let resolved = resolve_config(&compose, &submitted).ok().unwrap(); + assert_eq!(resolved.get("relay_name").unwrap(), "Zap"); + assert_eq!(resolved.get("max_mb").unwrap(), "100"); + + // Missing required -> error. + assert!(resolve_config(&compose, &BTreeMap::new()).is_err()); + + // Unknown key -> error. + let mut bad = submitted.clone(); + bad.insert("nope".to_string(), "x".to_string()); + assert!(resolve_config(&compose, &bad).is_err()); + + // Submitted overrides default. + let mut over = submitted.clone(); + over.insert("max_mb".to_string(), "500".to_string()); + let resolved = resolve_config(&compose, &over).ok().unwrap(); + assert_eq!(resolved.get("max_mb").unwrap(), "500"); + } +} diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index 11e8b116..777d611c 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -585,6 +585,41 @@ pub async fn seed_app_deployment( Ok((app_id, cluster_id, dep_id)) } +/// Seed an enabled catalog app (small footprint) + a cluster with capacity in an +/// existing region. Returns `(app_id, cluster_id, region_id)`. Used to exercise +/// the customer ordering flow. +pub async fn seed_app_and_cluster(pool: &MySqlPool) -> anyhow::Result<(u64, u64, u64)> { + let suffix = hex::encode(&rand_bytes32()[..4]); + let (region_id,): (u64,) = sqlx::query_as("SELECT MIN(id) FROM region") + .fetch_one(pool) + .await?; + + // App with a real (small) footprint and a valid single-service compose. + 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, cpu_milli, memory_bytes, \ + storage_bytes) \ + VALUES (?, 'E2E Orderable', NULL, NULL, \ + 'services:\\n web:\\n image: example/web:latest\\n ports:\\n - { name: http, container: 80, protocol: http, expose: ingress }\\nconfig:\\n - { name: title, type: string, default: \"hi\" }\\n', \ + 1000, 'USD', 1, 1, 0, 1, 250, 268435456, 0) RETURNING id", + ) + .bind(format!("orderable-{suffix}")) + .fetch_one(pool) + .await?; + + let (cluster_id,): (u64,) = sqlx::query_as( + "INSERT INTO app_cluster (name, region_id, ingress_domain, enabled, capacity_cpu_milli, \ + capacity_memory_bytes, capacity_storage_bytes) \ + VALUES (?, ?, 'apps.e2e.example.com', 1, 8000, 8589934592, 107374182400) RETURNING id", + ) + .bind(format!("ordercluster-{suffix}")) + .bind(region_id) + .fetch_one(pool) + .await?; + + Ok((app_id, cluster_id, region_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 b7d70b3c..b195b256 100644 --- a/lnvps_e2e/src/user_api.rs +++ b/lnvps_e2e/src/user_api.rs @@ -961,4 +961,102 @@ mod tests { pool.close().await; } + + #[tokio::test] + async fn test_app_deployment_ordering() { + let keys = nostr::Keys::generate(); + let client = user_client_with_keys(keys.clone()); + let pool = crate::db::connect().await.unwrap(); + let (app_id, _cluster_id, region_id) = + crate::db::seed_app_and_cluster(&pool).await.unwrap(); + + // Order a deployment. + let resp = client + .post_auth( + "/api/v1/app-deployments", + &serde_json::json!({ + "app_id": app_id, + "name": "my-app", + "region_id": region_id, + "config": { "title": "Hello" } + }), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "ordering should succeed"); + let body: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let dep_id = body["data"]["id"].as_u64().expect("deployment id"); + assert_eq!(body["data"]["name"].as_str().unwrap(), "my-app"); + // New order is pending (unpaid) and has a billing subscription. + assert_eq!(body["data"]["status"].as_str().unwrap(), "pending"); + assert!(body["data"]["subscription_id"].as_u64().is_some()); + + // Invalid name is rejected. + let resp = client + .post_auth( + "/api/v1/app-deployments", + &serde_json::json!({"app_id": app_id, "name": "Bad Name", "region_id": region_id, "config": {}}), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "bad name rejected"); + + // Unknown config field is rejected. + let resp = client + .post_auth( + "/api/v1/app-deployments", + &serde_json::json!({"app_id": app_id, "name": "ok", "region_id": region_id, "config": {"nope": "x"}}), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "unknown config rejected" + ); + + // No capacity in a bogus region. + let resp = client + .post_auth( + "/api/v1/app-deployments", + &serde_json::json!({"app_id": app_id, "name": "ok", "region_id": 99999999u64, "config": {}}), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::OK, "no capacity -> rejected"); + + // Stop / start toggles desired_state. + let resp = client + .patch_auth( + &format!("/api/v1/app-deployments/{dep_id}/stop"), + &serde_json::json!({}), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + assert_eq!(body["data"]["desired_state"].as_str().unwrap(), "stopped"); + let resp = client + .patch_auth( + &format!("/api/v1/app-deployments/{dep_id}/start"), + &serde_json::json!({}), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Delete removes it from the user's listing. + let resp = client + .delete_auth(&format!("/api/v1/app-deployments/{dep_id}")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = client + .get_auth(&format!("/api/v1/app-deployments/{dep_id}")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "deleted -> 404"); + + pool.close().await; + } } diff --git a/lnvps_operator/src/app_deployments.rs b/lnvps_operator/src/app_deployments.rs index 5d242b2c..1e368b12 100644 --- a/lnvps_operator/src/app_deployments.rs +++ b/lnvps_operator/src/app_deployments.rs @@ -731,19 +731,24 @@ async fn reconcile_one( let env = compose.resolve_env(&vars)?; let files = compose.resolve_files(&vars)?; - // Retention: an expired (unpaid) subscription scales the workload to 0 - // replicas — pods stop but the PVCs (customer data) are retained. The - // deployment is only fully torn down (namespace GC) on real deletion. - let expired = ctx + // Billing gate + retention. The workload only runs when the subscription is + // set up (paid at least once) and not expired. A freshly-ordered, unpaid + // deployment (not set up) stays at 0 replicas; an expired one scales to 0 + // but keeps its PVCs (customer data) — only real deletion tears it down. + let sub = ctx .db .get_subscription_by_line_item_id(deployment.subscription_line_item_id) .await - .ok() + .ok(); + let paid = sub.as_ref().map(|s| s.is_setup).unwrap_or(false); + let expired = sub + .as_ref() .and_then(|s| s.expires) .map(|e| e < chrono::Utc::now()) .unwrap_or(false); let running = deployment.desired_state == lnvps_db::AppDeploymentDesiredState::Running && !deployment.deleted + && paid && !expired; let replicas = if running { 1 } else { 0 }; diff --git a/work/app-deployments.md b/work/app-deployments.md index 8ca288a3..76cbb7ad 100644 --- a/work/app-deployments.md +++ b/work/app-deployments.md @@ -73,9 +73,16 @@ images (higher isolation risk — design the boundary in now). 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 3b — Customer ordering / lifecycle (billing) (DONE, PR pending) +- [x] `POST /api/v1/app-deployments`: validate name + config (vs compose `config`), capacity + admission (`select_in_region`), create Subscription + `App` line item + deployment (pending). + Pay via the standard subscription flow; the renew engine already bills flat `App` line items. +- [x] `DELETE` (deactivate subscription + soft-delete → operator GC), `PATCH .../stop|start` + (desired_state). Ownership-checked. +- [x] Operator gate: only run **paid** (`is_setup`) + unexpired deployments (unpaid orders stay at + 0 replicas). +- [x] Unit tests (name + config validation) + e2e ordering test (order, name/config/capacity + rejection, stop/start, delete). Docs + changelog. ### Increment 4a — Shared compose parser (DONE, PR #218 merged) - [x] New `lnvps_compose` crate (serde + serde_yaml, no heavy deps): typed model (`Compose`