Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
32 changes: 31 additions & 1 deletion API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lnvps_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
308 changes: 302 additions & 6 deletions lnvps_api/src/api/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterState> {
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.
Expand Down Expand Up @@ -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<String, String>,
}

/// 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<String, String>,
) -> Result<BTreeMap<String, String>, 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<RouterState>,
Json(req): Json<CreateAppDeploymentRequest>,
) -> ApiResult<ApiAppDeployment> {
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<AppDeployment, ApiError> {
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<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<bool> {
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<ApiAppDeployment> {
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<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<ApiAppDeployment> {
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<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<ApiAppDeployment> {
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");
}
}
Loading
Loading