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
7 changes: 6 additions & 1 deletion ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3671,7 +3671,7 @@ Required Permission: `app::create`. Body:
}
```

`name` must be a DNS-safe slug and unique; `display_name`, `compose` and `currency` are required. `currency` is normalised to upper-case. The full `compose` schema is validated by the operator at deploy time.
`name` must be a DNS-safe slug and unique; `display_name`, `compose` and `currency` are required. `currency` is normalised to upper-case. The `compose` is fully parsed and validated on create/update, and the app's **resource footprint** (`cpu_milli` / `memory_bytes` / `storage_bytes` — Σ service `resources` + volume sizes) is computed from it and returned on `AdminAppInfo` (used for cluster capacity accounting).

#### Update App

Expand Down Expand Up @@ -3710,6 +3710,11 @@ Required Permissions: `app::view` / `app::create` / `app::update` / `app::delete
"ingress_domain": "apps.lnvps.tld",
// Required. Wildcard base for deployment hostnames ("{name}.{ingress_domain}").
"enabled": true,
"capacity_cpu_milli": 8000,
"capacity_memory_bytes": 8589934592,
"capacity_storage_bytes": 107374182400,
// Static total capacity available for deployments (millicores / bytes / bytes).
// A cluster with 0 capacity accepts no deployments (1:1, no overcommit).
"created": "2026-07-24T10:00:00Z"
}
```
Expand Down
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 — 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).
- **Managed app deployments — compose validation** (part of the App Deployments epic) — the admin app-catalog endpoints (`POST`/`PATCH /api/admin/v1/apps`) now fully parse and validate the app's `compose` document (via a new shared `lnvps_compose` parser, the same code the operator will use to render Kubernetes objects) instead of only checking it's non-empty. Invalid or unsafe compose is rejected at catalog-edit time: unknown `depends_on`, `expose: ingress` on a non-HTTP port, absolute/`..`-traversal volume mount paths, and malformed `backup` blocks all return a clear `400`.
Expand Down
32 changes: 32 additions & 0 deletions lnvps_api_admin/src/admin/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ fn validate_app_fields(
Ok(())
}

/// Parse the compose and compute the app's resource footprint (already
/// validated by `validate_app_fields`).
fn compose_footprint(
compose: &str,
) -> Result<lnvps_compose::Footprint, lnvps_api_common::ApiError> {
let c = lnvps_compose::Compose::parse(compose)
.map_err(|e| lnvps_api_common::ApiError::new(format!("invalid compose: {e}")))?;
c.footprint()
.map_err(|e| lnvps_api_common::ApiError::new(format!("invalid compose resources: {e}")))
}

async fn admin_list_apps(
auth: AdminAuth,
State(this): State<RouterState>,
Expand All @@ -101,6 +112,7 @@ async fn admin_create_app(
) -> ApiResult<AdminAppInfo> {
auth.require_permission(AdminResource::App, AdminAction::Create)?;
validate_app_fields(&req.name, &req.display_name, &req.compose, &req.currency)?;
let footprint = compose_footprint(&req.compose)?;

let app = App {
id: 0,
Expand All @@ -115,6 +127,9 @@ async fn admin_create_app(
interval_type: req.interval_type.into(),
setup_amount: req.setup_amount,
enabled: req.enabled,
cpu_milli: footprint.cpu_milli,
memory_bytes: footprint.memory_bytes,
storage_bytes: footprint.storage_bytes,
created: chrono::Utc::now(),
};
let id = this.db.insert_app(&app).await?;
Expand Down Expand Up @@ -165,6 +180,11 @@ async fn admin_update_app(
}

validate_app_fields(&app.name, &app.display_name, &app.compose, &app.currency)?;
// Recompute the footprint from the (possibly updated) compose.
let footprint = compose_footprint(&app.compose)?;
app.cpu_milli = footprint.cpu_milli;
app.memory_bytes = footprint.memory_bytes;
app.storage_bytes = footprint.storage_bytes;
this.db.update_app(&app).await?;
ApiData::ok(this.db.get_app(id).await?.into())
}
Expand Down Expand Up @@ -238,6 +258,9 @@ async fn admin_create_app_cluster(
region_id: req.region_id,
ingress_domain: req.ingress_domain.trim().to_string(),
enabled: req.enabled,
capacity_cpu_milli: req.capacity_cpu_milli,
capacity_memory_bytes: req.capacity_memory_bytes,
capacity_storage_bytes: req.capacity_storage_bytes,
created: chrono::Utc::now(),
};
let id = this.db.insert_app_cluster(&cluster).await?;
Expand Down Expand Up @@ -266,6 +289,15 @@ async fn admin_update_app_cluster(
if let Some(enabled) = req.enabled {
cluster.enabled = enabled;
}
if let Some(v) = req.capacity_cpu_milli {
cluster.capacity_cpu_milli = v;
}
if let Some(v) = req.capacity_memory_bytes {
cluster.capacity_memory_bytes = v;
}
if let Some(v) = req.capacity_storage_bytes {
cluster.capacity_storage_bytes = v;
}

this.db.update_app_cluster(&cluster).await?;
ApiData::ok(this.db.get_app_cluster(id).await?.into())
Expand Down
27 changes: 27 additions & 0 deletions lnvps_api_admin/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4473,6 +4473,12 @@ pub struct AdminAppInfo {
pub interval_type: ApiIntervalType,
pub setup_amount: u64,
pub enabled: bool,
/// Resource footprint computed from the compose (CPU millicores).
pub cpu_milli: u64,
/// Memory footprint in bytes.
pub memory_bytes: u64,
/// Persistent storage footprint in bytes.
pub storage_bytes: u64,
pub created: DateTime<Utc>,
}

Expand All @@ -4491,6 +4497,9 @@ impl From<lnvps_db::App> for AdminAppInfo {
interval_type: a.interval_type.into(),
setup_amount: a.setup_amount,
enabled: a.enabled,
cpu_milli: a.cpu_milli,
memory_bytes: a.memory_bytes,
storage_bytes: a.storage_bytes,
created: a.created,
}
}
Expand Down Expand Up @@ -4549,6 +4558,10 @@ pub struct AdminAppClusterInfo {
pub region_id: u64,
pub ingress_domain: String,
pub enabled: bool,
/// Static total capacity available for app deployments.
pub capacity_cpu_milli: u64,
pub capacity_memory_bytes: u64,
pub capacity_storage_bytes: u64,
pub created: DateTime<Utc>,
}

Expand All @@ -4560,6 +4573,9 @@ impl From<lnvps_db::AppCluster> for AdminAppClusterInfo {
region_id: c.region_id,
ingress_domain: c.ingress_domain,
enabled: c.enabled,
capacity_cpu_milli: c.capacity_cpu_milli,
capacity_memory_bytes: c.capacity_memory_bytes,
capacity_storage_bytes: c.capacity_storage_bytes,
created: c.created,
}
}
Expand All @@ -4573,6 +4589,14 @@ pub struct AdminCreateAppClusterRequest {
pub ingress_domain: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// Static total capacity (millicores / bytes / bytes). A cluster with 0
/// capacity accepts no deployments.
#[serde(default)]
pub capacity_cpu_milli: u64,
#[serde(default)]
pub capacity_memory_bytes: u64,
#[serde(default)]
pub capacity_storage_bytes: u64,
}

/// Update an app cluster (all fields optional; omitted = unchanged).
Expand All @@ -4582,6 +4606,9 @@ pub struct AdminUpdateAppClusterRequest {
pub region_id: Option<u64>,
pub ingress_domain: Option<String>,
pub enabled: Option<bool>,
pub capacity_cpu_milli: Option<u64>,
pub capacity_memory_bytes: Option<u64>,
pub capacity_storage_bytes: Option<u64>,
}

fn default_true() -> bool {
Expand Down
92 changes: 90 additions & 2 deletions lnvps_api_common/src/capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use chrono::Utc;
use futures::future::join_all;
use ipnetwork::{IpNetwork, NetworkSize};
use lnvps_db::{
CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, IpRange, LNVpsDb, VmCustomTemplate, VmHost,
VmHostDisk, VmIpAssignment, VmTemplate,
App, AppCluster, CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, IpRange, LNVpsDb,
VmCustomTemplate, VmHost, VmHostDisk, VmIpAssignment, VmTemplate,
};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
Expand Down Expand Up @@ -534,6 +534,94 @@ impl IPRangeCapacity {
}
}

/// Remaining or allocated app capacity on a cluster (millicores / bytes).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AppCapacity {
pub cpu_milli: u64,
pub memory_bytes: u64,
pub storage_bytes: u64,
}

/// Order-time capacity accounting for managed app deployments — the admission
/// counterpart to the operator's per-namespace ResourceQuota. Mirrors
/// [`HostCapacityService`]: a cluster's remaining capacity is its static
/// configured capacity minus the summed footprint of its live deployments
/// (1:1, no overcommit).
#[derive(Clone)]
pub struct AppClusterCapacityService {
db: Arc<dyn LNVpsDb>,
}

impl AppClusterCapacityService {
pub fn new(db: Arc<dyn LNVpsDb>) -> Self {
Self { db }
}

/// Footprint currently allocated on a cluster: the summed footprint of every
/// non-deleted deployment on it (expired deployments still hold their PVCs
/// and can be revived, so they count).
pub async fn used(&self, cluster_id: u64) -> Result<AppCapacity> {
// Index app footprints by id to avoid a lookup per deployment.
let apps: HashMap<u64, App> = self
.db
.list_apps(false)
.await?
.into_iter()
.map(|a| (a.id, a))
.collect();
let mut used = AppCapacity::default();
for d in self.db.list_all_app_deployments().await? {
if d.cluster_id != cluster_id {
continue;
}
if let Some(a) = apps.get(&d.app_id) {
used.cpu_milli += a.cpu_milli;
used.memory_bytes += a.memory_bytes;
used.storage_bytes += a.storage_bytes;
}
}
Ok(used)
}

/// Remaining capacity on a cluster = configured capacity − used (saturating).
pub async fn available(&self, cluster_id: u64) -> Result<AppCapacity> {
let cluster = self.db.get_app_cluster(cluster_id).await?;
let used = self.used(cluster_id).await?;
Ok(AppCapacity {
cpu_milli: cluster.capacity_cpu_milli.saturating_sub(used.cpu_milli),
memory_bytes: cluster
.capacity_memory_bytes
.saturating_sub(used.memory_bytes),
storage_bytes: cluster
.capacity_storage_bytes
.saturating_sub(used.storage_bytes),
})
}

/// Whether an additional `need` fits in the cluster's remaining capacity.
pub async fn fits(&self, cluster_id: u64, need: AppCapacity) -> Result<bool> {
let avail = self.available(cluster_id).await?;
Ok(need.cpu_milli <= avail.cpu_milli
&& need.memory_bytes <= avail.memory_bytes
&& need.storage_bytes <= avail.storage_bytes)
}

/// The first enabled cluster in `region_id` that can fit `need`, for
/// order-time placement. `None` when the region is full / has no cluster.
pub async fn select_in_region(
&self,
region_id: u64,
need: AppCapacity,
) -> Result<Option<AppCluster>> {
for cluster in self.db.list_app_clusters(true).await? {
if cluster.region_id == region_id && self.fits(cluster.id, need).await? {
return Ok(Some(cluster));
}
}
Ok(None)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
88 changes: 88 additions & 0 deletions lnvps_api_common/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5842,6 +5842,9 @@ mod tests {
interval_type: IntervalType::Month,
setup_amount: 0,
enabled: true,
cpu_milli: 500,
memory_bytes: 512 * 1024 * 1024,
storage_bytes: 1024 * 1024 * 1024,
created: Utc::now(),
}
}
Expand Down Expand Up @@ -5892,6 +5895,9 @@ mod tests {
region_id: 1,
ingress_domain: "apps.example.com".to_string(),
enabled: true,
capacity_cpu_milli: 100_000,
capacity_memory_bytes: 100u64 * 1024 * 1024 * 1024,
capacity_storage_bytes: 1024u64 * 1024 * 1024 * 1024,
created: Utc::now(),
})
.await
Expand Down Expand Up @@ -5921,6 +5927,9 @@ mod tests {
region_id: 1,
ingress_domain: "apps.example.com".to_string(),
enabled: true,
capacity_cpu_milli: 100_000,
capacity_memory_bytes: 100u64 * 1024 * 1024 * 1024,
capacity_storage_bytes: 1024u64 * 1024 * 1024 * 1024,
created: Utc::now(),
})
.await
Expand Down Expand Up @@ -5974,6 +5983,85 @@ mod tests {
assert_eq!(db.list_all_app_deployments().await.unwrap().len(), 2);
assert!(db.get_app_deployment(d1).await.unwrap().deleted);
}

#[tokio::test]
async fn test_app_cluster_capacity() {
use crate::{AppCapacity, AppClusterCapacityService};

let db = MockDb::default();
// Cluster with capacity for exactly 3 of mk_app's footprint
// (mk_app = 500m / 512Mi / 1Gi).
let cluster_id = db
.insert_app_cluster(&AppCluster {
id: 0,
name: "cap".to_string(),
region_id: 7,
ingress_domain: "apps.example.com".to_string(),
enabled: true,
capacity_cpu_milli: 1500,
capacity_memory_bytes: 3 * 512 * 1024 * 1024,
capacity_storage_bytes: 3 * 1024 * 1024 * 1024,
created: Utc::now(),
})
.await
.unwrap();
let app_id = db.insert_app(&mk_app("relay")).await.unwrap();

let mk_dep = |name: &str| AppDeployment {
id: 0,
user_id: 1,
app_id,
cluster_id,
subscription_line_item_id: 0,
name: name.to_string(),
namespace: format!("app-{name}"),
hostname: None,
config: None,
desired_state: AppDeploymentDesiredState::Running,
status: AppDeploymentStatus::Pending,
status_message: None,
created: Utc::now(),
deleted: false,
};
db.insert_app_deployment(&mk_dep("a")).await.unwrap();
db.insert_app_deployment(&mk_dep("b")).await.unwrap();

let dba: Arc<dyn lnvps_db::LNVpsDb> = Arc::new(db.clone());
let svc = AppClusterCapacityService::new(dba);

// Two deployments used -> 1000m / 1Gi / 2Gi.
let used = svc.used(cluster_id).await.unwrap();
assert_eq!(used.cpu_milli, 1000);
assert_eq!(used.storage_bytes, 2 * 1024 * 1024 * 1024);

// Remaining capacity = room for exactly one more.
let avail = svc.available(cluster_id).await.unwrap();
assert_eq!(avail.cpu_milli, 500);

let one_more = AppCapacity {
cpu_milli: 500,
memory_bytes: 512 * 1024 * 1024,
storage_bytes: 1024 * 1024 * 1024,
};
assert!(svc.fits(cluster_id, one_more).await.unwrap());
let two_more = AppCapacity {
cpu_milli: 1000,
..one_more
};
assert!(!svc.fits(cluster_id, two_more).await.unwrap());

// Region selection finds this cluster for a fitting need, none when full.
assert_eq!(
svc.select_in_region(7, one_more)
.await
.unwrap()
.map(|c| c.id),
Some(cluster_id)
);
assert!(svc.select_in_region(7, two_more).await.unwrap().is_none());
// Wrong region -> nothing.
assert!(svc.select_in_region(99, one_more).await.unwrap().is_none());
}
}

use crate::dns::{BasicRecord, DnsRef, DnsZone, RecordType};
Expand Down
Loading
Loading