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 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.
Expand Down
55 changes: 55 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions lnvps_api/src/api/apps.rs
Original file line number Diff line number Diff line change
@@ -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<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))
}

/// 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<String>,
pub icon: Option<String>,
/// 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<App> 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<String>,
/// 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<String>,
/// Subscription this deployment is billed under (renew via the subscription
/// endpoints). `None` if the subscription can't be resolved.
pub subscription_id: Option<u64>,
pub created: DateTime<Utc>,
}

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<RouterState>) -> ApiResult<Vec<ApiApp>> {
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<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<ApiApp> {
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<RouterState>,
) -> ApiResult<Vec<ApiAppDeployment>> {
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<RouterState>,
Path(id): Path<u64>,
) -> ApiResult<ApiAppDeployment> {
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)
}
2 changes: 2 additions & 0 deletions lnvps_api/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod apps;
mod contact;
mod docs;
mod ip_space;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
67 changes: 67 additions & 0 deletions lnvps_e2e/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
67 changes: 67 additions & 0 deletions lnvps_e2e/src/user_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading