From 2fdad9dfc6bb1c1c4f8b283f03a050ab213bf2ec Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 24 Jul 2026 17:52:42 +0100 Subject: [PATCH] feat: app cluster capacity accounting (footprint + admission) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capacity increment (part 2): order-time admission counterpart to the operator's per-namespace ResourceQuota. - DB: app gains cpu_milli/memory_bytes/storage_bytes (its footprint); app_cluster gains static admin-set capacity_* columns (1:1, no overcommit). Migration + model + mysql + mock. - Admin: the app footprint is computed from the compose (lnvps_compose::footprint = sum service resources + volume sizes) and stored on create/update; capacity_* settable on cluster create/update; both surfaced on AdminAppInfo / AdminAppClusterInfo. - AppClusterCapacityService (lnvps_api_common, mirrors HostCapacityService): used / available / fits / select_in_region, where available = capacity − Σ footprint of non-deleted deployments. Unit-tested via MockDb. - e2e admin test asserts computed footprint + capacity echo. Consumed at order time by the customer ordering flow (3b, next). Part of work/app-deployments.md. --- ADMIN_API_ENDPOINTS.md | 7 +- API_CHANGELOG.md | 1 + lnvps_api_admin/src/admin/apps.rs | 32 +++++++ lnvps_api_admin/src/admin/model.rs | 27 ++++++ lnvps_api_common/src/capacity.rs | 92 ++++++++++++++++++- lnvps_api_common/src/mock.rs | 88 ++++++++++++++++++ .../20260724174305_app_capacity.sql | 14 +++ lnvps_db/src/model.rs | 19 ++++ lnvps_db/src/mysql.rs | 27 ++++-- lnvps_e2e/src/admin_api.rs | 5 +- work/app-deployments.md | 14 ++- 11 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 lnvps_db/migrations/20260724174305_app_capacity.sql diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 04cf800..928a231 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -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 @@ -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" } ``` diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 008de75..3eea6b0 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 — 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`. diff --git a/lnvps_api_admin/src/admin/apps.rs b/lnvps_api_admin/src/admin/apps.rs index bfab72c..69bdb98 100644 --- a/lnvps_api_admin/src/admin/apps.rs +++ b/lnvps_api_admin/src/admin/apps.rs @@ -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 { + 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, @@ -101,6 +112,7 @@ async fn admin_create_app( ) -> ApiResult { 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, @@ -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?; @@ -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()) } @@ -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?; @@ -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()) diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 8ddaf0f..5d61e1c 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -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, } @@ -4491,6 +4497,9 @@ impl From 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, } } @@ -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, } @@ -4560,6 +4573,9 @@ impl From 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, } } @@ -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). @@ -4582,6 +4606,9 @@ pub struct AdminUpdateAppClusterRequest { pub region_id: Option, pub ingress_domain: Option, pub enabled: Option, + pub capacity_cpu_milli: Option, + pub capacity_memory_bytes: Option, + pub capacity_storage_bytes: Option, } fn default_true() -> bool { diff --git a/lnvps_api_common/src/capacity.rs b/lnvps_api_common/src/capacity.rs index 1444d30..f543ceb 100644 --- a/lnvps_api_common/src/capacity.rs +++ b/lnvps_api_common/src/capacity.rs @@ -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; @@ -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, +} + +impl AppClusterCapacityService { + pub fn new(db: Arc) -> 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 { + // Index app footprints by id to avoid a lookup per deployment. + let apps: HashMap = 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 { + 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 { + 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> { + 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::*; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index b1c1c5b..ff94dce 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -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(), } } @@ -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 @@ -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 @@ -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 = 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}; diff --git a/lnvps_db/migrations/20260724174305_app_capacity.sql b/lnvps_db/migrations/20260724174305_app_capacity.sql new file mode 100644 index 0000000..b01bcdd --- /dev/null +++ b/lnvps_db/migrations/20260724174305_app_capacity.sql @@ -0,0 +1,14 @@ +-- Resource footprint of a catalog app (computed from its compose: sum of +-- service CPU/memory requests + volume sizes), stored denormalized so cluster +-- capacity accounting is a cheap sum over deployments. +ALTER TABLE app + ADD COLUMN cpu_milli BIGINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN memory_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN storage_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0; + +-- Static per-cluster capacity (admin-configured; 1:1, no overcommit). A cluster +-- with 0 capacity accepts no deployments until an admin sets real values. +ALTER TABLE app_cluster + ADD COLUMN capacity_cpu_milli BIGINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN capacity_memory_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN capacity_storage_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 96960f3..8e153f3 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -3100,6 +3100,16 @@ pub struct App { pub setup_amount: u64, /// Whether the app is offered in the catalog. pub enabled: bool, + /// Resource footprint computed from the compose (Σ service CPU requests, in + /// millicores). Denormalized for cheap cluster-capacity accounting. + #[sqlx(default)] + pub cpu_milli: u64, + /// Memory footprint in bytes. + #[sqlx(default)] + pub memory_bytes: u64, + /// Persistent storage footprint in bytes. + #[sqlx(default)] + pub storage_bytes: u64, pub created: DateTime, } @@ -3119,6 +3129,15 @@ pub struct AppCluster { /// deployment's host is `"{deployment.name}.{ingress_domain}"`. pub ingress_domain: String, pub enabled: bool, + /// Static total CPU capacity (millicores) available for app deployments. + #[sqlx(default)] + pub capacity_cpu_milli: u64, + /// Static total memory capacity (bytes). + #[sqlx(default)] + pub capacity_memory_bytes: u64, + /// Static total persistent-storage capacity (bytes). + #[sqlx(default)] + pub capacity_storage_bytes: u64, pub created: DateTime, } diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 86d6235..e55d4e5 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -3634,8 +3634,9 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_app(&self, app: &App) -> DbResult { let res = sqlx::query( "INSERT INTO app (name, display_name, description, icon, compose, amount, currency, \ - interval_amount, interval_type, setup_amount, enabled) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id", + interval_amount, interval_type, setup_amount, enabled, cpu_milli, memory_bytes, \ + storage_bytes) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning id", ) .bind(&app.name) .bind(&app.display_name) @@ -3648,6 +3649,9 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(app.interval_type) .bind(app.setup_amount) .bind(app.enabled) + .bind(app.cpu_milli) + .bind(app.memory_bytes) + .bind(app.storage_bytes) .fetch_one(&self.db) .await?; Ok(res.try_get(0)?) @@ -3657,7 +3661,7 @@ impl LNVpsDbBase for LNVpsDbMysql { sqlx::query( "UPDATE app SET name = ?, display_name = ?, description = ?, icon = ?, compose = ?, \ amount = ?, currency = ?, interval_amount = ?, interval_type = ?, setup_amount = ?, \ - enabled = ? WHERE id = ?", + enabled = ?, cpu_milli = ?, memory_bytes = ?, storage_bytes = ? WHERE id = ?", ) .bind(&app.name) .bind(&app.display_name) @@ -3670,6 +3674,9 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(app.interval_type) .bind(app.setup_amount) .bind(app.enabled) + .bind(app.cpu_milli) + .bind(app.memory_bytes) + .bind(app.storage_bytes) .bind(app.id) .execute(&self.db) .await?; @@ -3704,13 +3711,17 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_app_cluster(&self, cluster: &AppCluster) -> DbResult { let res = sqlx::query( - "INSERT INTO app_cluster (name, region_id, ingress_domain, enabled) \ - VALUES (?, ?, ?, ?) returning id", + "INSERT INTO app_cluster (name, region_id, ingress_domain, enabled, \ + capacity_cpu_milli, capacity_memory_bytes, capacity_storage_bytes) \ + VALUES (?, ?, ?, ?, ?, ?, ?) returning id", ) .bind(&cluster.name) .bind(cluster.region_id) .bind(&cluster.ingress_domain) .bind(cluster.enabled) + .bind(cluster.capacity_cpu_milli) + .bind(cluster.capacity_memory_bytes) + .bind(cluster.capacity_storage_bytes) .fetch_one(&self.db) .await?; Ok(res.try_get(0)?) @@ -3718,13 +3729,17 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn update_app_cluster(&self, cluster: &AppCluster) -> DbResult<()> { sqlx::query( - "UPDATE app_cluster SET name = ?, region_id = ?, ingress_domain = ?, enabled = ? \ + "UPDATE app_cluster SET name = ?, region_id = ?, ingress_domain = ?, enabled = ?, \ + capacity_cpu_milli = ?, capacity_memory_bytes = ?, capacity_storage_bytes = ? \ WHERE id = ?", ) .bind(&cluster.name) .bind(cluster.region_id) .bind(&cluster.ingress_domain) .bind(cluster.enabled) + .bind(cluster.capacity_cpu_milli) + .bind(cluster.capacity_memory_bytes) + .bind(cluster.capacity_storage_bytes) .bind(cluster.id) .execute(&self.db) .await?; diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 11666ca..abbdfa8 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -569,6 +569,8 @@ mod tests { let app_id = body["data"]["id"].as_u64().expect("app id"); // currency is normalised to upper-case. assert_eq!(body["data"]["currency"].as_str().unwrap(), "USD"); + // Footprint is computed from the compose (one service, default 250m). + assert_eq!(body["data"]["cpu_milli"].as_u64(), Some(250)); // Duplicate name is rejected. let resp = client @@ -655,7 +657,7 @@ mod tests { let resp = client .post_auth( "/api/admin/v1/app_clusters", - &serde_json::json!({"name": "e2e-cluster", "region_id": region_id, "ingress_domain": "apps.e2e.example.com"}), + &serde_json::json!({"name": "e2e-cluster", "region_id": region_id, "ingress_domain": "apps.e2e.example.com", "capacity_cpu_milli": 8000, "capacity_memory_bytes": 8589934592u64, "capacity_storage_bytes": 107374182400u64}), ) .await .unwrap(); @@ -666,6 +668,7 @@ mod tests { ); let body: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); let cluster_id = body["data"]["id"].as_u64().expect("cluster id"); + assert_eq!(body["data"]["capacity_cpu_milli"].as_u64(), Some(8000)); // Read / list / update. let resp = client diff --git a/work/app-deployments.md b/work/app-deployments.md index cab04df..8ca288a 100644 --- a/work/app-deployments.md +++ b/work/app-deployments.md @@ -116,11 +116,15 @@ images (higher isolation risk — design the boundary in now). - [x] Operator: container requests==limits from `resources:` (Guaranteed QoS, 1:1); ResourceQuota sized from `compose.footprint()` now applied (caps the namespace at what was provisioned). -### Increment 4c-ii — Capacity admission (NEXT) -- [ ] DB: `app` footprint columns + `app_cluster` static `capacity_*` columns (admin-set), 1:1. -- [ ] `AppClusterCapacityService` (mirrors `HostCapacityService`): available = capacity − Σ active - footprints; used at order time (3b) for admission + cluster selection in a region. -- [ ] Admin: compute+store footprint on app save; capacity fields on cluster create/update. +### Increment 4c-ii — Capacity admission (DONE, PR pending) +- [x] DB: `app` footprint columns (`cpu_milli`/`memory_bytes`/`storage_bytes`) + `app_cluster` + static `capacity_*` columns (admin-set), 1:1. +- [x] `AppClusterCapacityService` (lnvps_api_common, mirrors `HostCapacityService`): `used` / + `available` / `fits` / `select_in_region` (available = capacity − Σ non-deleted deployment + footprints). Unit-tested via MockDb. +- [x] Admin: footprint computed from compose (`lnvps_compose::footprint`) + stored on app + create/update; `capacity_*` on cluster create/update; both exposed on the info responses. + e2e admin test asserts footprint + capacity echo. ### Increment 5 — Seed launch apps - [ ] Seed **strfry**, **haven relay**, **route96 (+ MariaDB)**, **generic Blossom** (compose