From 5decf9bbd747d66c597073992130b78e9d6b2a5c Mon Sep 17 00:00:00 2001
From: CodeMaster4711
Date: Thu, 25 Jun 2026 20:43:17 +0200
Subject: [PATCH 1/5] fix: for local dev env
---
Cargo.toml | 2 +-
app/src/lib/api/nodes.ts | 2 +-
app/src/lib/api/system.ts | 2 +-
app/src/lib/auth/api.ts | 2 +-
docker-compose.yml | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index b3f8233a..2fdf864c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,7 +48,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] }
# Security
-jsonwebtoken = "10.0.0"
+jsonwebtoken = { version = "10.0.0", features = ["rust_crypto"] }
bcrypt = "0.19"
rsa = { version = "0.9", features = ["sha2"] }
totp-rs = { version = "5.6", features = ["qr", "otpauth"] }
diff --git a/app/src/lib/api/nodes.ts b/app/src/lib/api/nodes.ts
index f48afaaa..d8073408 100644
--- a/app/src/lib/api/nodes.ts
+++ b/app/src/lib/api/nodes.ts
@@ -1,4 +1,4 @@
-const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api';
+const API_BASE = '/api';
export interface Node {
id: string;
diff --git a/app/src/lib/api/system.ts b/app/src/lib/api/system.ts
index ddc2818a..216676cd 100644
--- a/app/src/lib/api/system.ts
+++ b/app/src/lib/api/system.ts
@@ -1,4 +1,4 @@
-const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api';
+const API_BASE = '/api';
export interface UpdateStatus {
current_version: string;
diff --git a/app/src/lib/auth/api.ts b/app/src/lib/auth/api.ts
index 36bdff22..6554eb23 100644
--- a/app/src/lib/auth/api.ts
+++ b/app/src/lib/auth/api.ts
@@ -1,4 +1,4 @@
-const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api';
+const API_BASE = '/api';
export interface AuthResponse {
token: string;
diff --git a/docker-compose.yml b/docker-compose.yml
index da71700e..d3707687 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -293,7 +293,7 @@ services:
dockerfile: Dockerfile
container_name: csfx-app-dev
environment:
- VITE_API_URL: https://api-gateway:8000
+ VITE_API_URL: http://api-gateway:8000
ports:
- "5173:5173"
volumes:
From e818f2f9f29fe2b58b2561c7855e8e046668a721 Mon Sep 17 00:00:00 2001
From: CodeMaster4711
Date: Thu, 25 Jun 2026 21:26:13 +0200
Subject: [PATCH 2/5] feat: added ressource groups
---
Cargo.lock | 196 ++++++++++
control-plane/api-gateway/src/auth/rbac.rs | 18 +-
control-plane/api-gateway/src/init.rs | 19 +-
control-plane/api-gateway/src/main.rs | 13 +-
control-plane/api-gateway/src/routes/mod.rs | 2 +
.../api-gateway/src/routes/resource_groups.rs | 357 ++++++++++++++++++
control-plane/scheduler/src/db/workloads.rs | 2 +
.../scheduler/src/models/workload.rs | 2 +
.../sdn-controller/src/db/networks.rs | 1 +
control-plane/sdn-controller/src/models.rs | 1 +
.../shared/entity/src/entities/mod.rs | 2 +
.../shared/entity/src/entities/networks.rs | 15 +
.../entity/src/entities/resource_groups.rs | 59 +++
.../shared/entity/src/entities/volumes.rs | 15 +
.../shared/entity/src/entities/workloads.rs | 15 +
control-plane/shared/migration/src/lib.rs | 2 +
.../m20260625_000000_add_resource_groups.rs | 190 ++++++++++
.../volume-manager/src/db/volumes.rs | 2 +
.../volume-manager/src/models/volume.rs | 2 +
19 files changed, 898 insertions(+), 15 deletions(-)
create mode 100644 control-plane/api-gateway/src/routes/resource_groups.rs
create mode 100644 control-plane/shared/entity/src/entities/resource_groups.rs
create mode 100644 control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs
diff --git a/Cargo.lock b/Cargo.lock
index f5019f8b..3677127e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -603,6 +603,12 @@ dependencies = [
"tower-service",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
[[package]]
name = "base32"
version = "0.5.1"
@@ -1132,6 +1138,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1212,6 +1230,33 @@ dependencies = [
"cipher 0.4.4",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
@@ -1373,6 +1418,44 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2 0.10.9",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "either"
version = "1.16.0"
@@ -1382,6 +1465,27 @@ dependencies = [
"serde",
]
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "entity"
version = "0.2.2"
@@ -1551,6 +1655,22 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1762,6 +1882,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -1862,6 +1983,17 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.4.15"
@@ -2482,11 +2614,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"base64",
+ "ed25519-dalek",
"getrandom 0.2.17",
+ "hmac",
"js-sys",
+ "p256",
+ "p384",
"pem",
+ "rand 0.8.6",
+ "rsa",
"serde",
"serde_json",
+ "sha2 0.10.9",
"signature",
"simple_asn1",
"zeroize",
@@ -3034,6 +3173,30 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3275,6 +3438,15 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -3883,6 +4055,16 @@ dependencies = [
"webpki-roots 1.0.8",
]
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -4393,6 +4575,20 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
diff --git a/control-plane/api-gateway/src/auth/rbac.rs b/control-plane/api-gateway/src/auth/rbac.rs
index 69e42bbc..9aa5acdf 100644
--- a/control-plane/api-gateway/src/auth/rbac.rs
+++ b/control-plane/api-gateway/src/auth/rbac.rs
@@ -8,7 +8,7 @@ use axum::{
http::{request::Parts, StatusCode},
};
use chrono::Utc;
-use entity::{organization, InvalidJwt, Organization};
+use entity::{InvalidJwt};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
@@ -26,6 +26,8 @@ pub struct CanManageVolumes(pub Claims);
pub struct CanViewNetworks(pub Claims);
pub struct CanManageNetworks(pub Claims);
pub struct CanManageSystem(pub Claims);
+pub struct CanViewResourceGroups(pub Claims);
+pub struct CanManageResourceGroups(pub Claims);
async fn extract_claims(parts: &mut Parts, state: &AppState) -> Result {
let token = parts
@@ -70,13 +72,9 @@ async fn extract_claims(parts: &mut Parts, state: &AppState) -> Result Result {
- Organization::find()
- .filter(organization::Column::Name.eq("Default Organization"))
- .one(&state.db_conn)
- .await
- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
- .map(|o| o.id)
+fn get_default_org(state: &AppState) -> Result {
+ state
+ .default_org_id
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)
}
@@ -87,7 +85,7 @@ async fn check(
action: &str,
) -> Result {
let claims = extract_claims(parts, state).await?;
- let org_id = get_default_org(state).await?;
+ let org_id = get_default_org(state)?;
let rbac = RbacService::new(state.db_conn.clone());
let allowed = rbac
.has_permission(claims.user_id, org_id, resource, action)
@@ -124,3 +122,5 @@ impl_extractor!(CanManageVolumes, "volumes", "manage");
impl_extractor!(CanViewNetworks, "networks", "view");
impl_extractor!(CanManageNetworks, "networks", "manage");
impl_extractor!(CanManageSystem, "system", "manage");
+impl_extractor!(CanViewResourceGroups, "resource_groups", "view");
+impl_extractor!(CanManageResourceGroups, "resource_groups", "manage");
diff --git a/control-plane/api-gateway/src/init.rs b/control-plane/api-gateway/src/init.rs
index 9d3e785e..5f851917 100644
--- a/control-plane/api-gateway/src/init.rs
+++ b/control-plane/api-gateway/src/init.rs
@@ -9,7 +9,7 @@ use crate::auth::crypto::{generate_salt, hash_password, RsaKeyPair};
pub async fn initialize_database(
db: &DatabaseConnection,
-) -> Result<(), Box> {
+) -> Result> {
tracing::info!("Initializing database with default data...");
// 1. Create RSA key pair if not exists
@@ -148,6 +148,18 @@ pub async fn initialize_database(
"manage",
"Trigger control plane updates",
),
+ (
+ "resource_groups.view",
+ "resource_groups",
+ "view",
+ "View resource groups and their resources",
+ ),
+ (
+ "resource_groups.manage",
+ "resource_groups",
+ "manage",
+ "Create, update, and delete resource groups",
+ ),
];
let mut permission_map = std::collections::HashMap::new();
@@ -248,6 +260,8 @@ pub async fn initialize_database(
"networks.view",
"networks.manage",
"members.view",
+ "resource_groups.view",
+ "resource_groups.manage",
];
for perm_name in operator_perms {
if let Some(perm_id) = permission_map.get(perm_name) {
@@ -291,6 +305,7 @@ pub async fn initialize_database(
"networks.view",
"organization.view",
"members.view",
+ "resource_groups.view",
];
for perm_name in viewer_perms {
if let Some(perm_id) = permission_map.get(perm_name) {
@@ -360,5 +375,5 @@ pub async fn initialize_database(
}
tracing::info!("Database initialization completed");
- Ok(())
+ Ok(default_org_id)
}
diff --git a/control-plane/api-gateway/src/main.rs b/control-plane/api-gateway/src/main.rs
index 585ecd72..64a169bb 100644
--- a/control-plane/api-gateway/src/main.rs
+++ b/control-plane/api-gateway/src/main.rs
@@ -125,6 +125,7 @@ impl utoipa::Modify for SecurityAddon {
pub struct AppState {
pub db_conn: DbConn,
pub service_client: service_client::ServiceClient,
+ pub default_org_id: Option,
}
#[tokio::main]
@@ -149,14 +150,18 @@ async fn main() {
}
};
- if let Err(e) = init::initialize_database(&db_conn).await {
- tracing::error!(error = %e, "failed to initialize database");
- std::process::exit(1);
- }
+ let default_org_id = match init::initialize_database(&db_conn).await {
+ Ok(id) => id,
+ Err(e) => {
+ tracing::error!(error = %e, "failed to initialize database");
+ std::process::exit(1);
+ }
+ };
let state = AppState {
db_conn: db_conn.clone(),
service_client: service_client::ServiceClient::new(),
+ default_org_id: Some(default_org_id),
};
tracing::info!("starting self-monitoring service");
diff --git a/control-plane/api-gateway/src/routes/mod.rs b/control-plane/api-gateway/src/routes/mod.rs
index fe2338a9..9e7ab1a1 100644
--- a/control-plane/api-gateway/src/routes/mod.rs
+++ b/control-plane/api-gateway/src/routes/mod.rs
@@ -20,6 +20,7 @@ pub mod networks;
pub mod organizations;
pub mod registry;
pub mod releases;
+pub mod resource_groups;
pub mod ssh_keys;
pub mod system;
pub mod update;
@@ -93,6 +94,7 @@ pub fn create_router() -> Router {
.merge(volumes::volumes_routes())
.merge(workloads::workloads_routes())
.merge(events::events_routes())
+ .merge(resource_groups::resource_groups_routes())
.layer(GovernorLayer::new(governor_config));
let api_router = Router::new()
diff --git a/control-plane/api-gateway/src/routes/resource_groups.rs b/control-plane/api-gateway/src/routes/resource_groups.rs
new file mode 100644
index 00000000..ac93567d
--- /dev/null
+++ b/control-plane/api-gateway/src/routes/resource_groups.rs
@@ -0,0 +1,357 @@
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::{IntoResponse, Json},
+ routing::get,
+ Router,
+};
+use chrono::Utc;
+use entity::{
+ entities::{networks, resource_groups, volumes, workloads},
+ Networks, ResourceGroups, Volumes, Workloads,
+};
+use sea_orm::{
+ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use uuid::Uuid;
+
+use crate::{
+ auth::rbac::{CanManageResourceGroups, CanViewResourceGroups},
+ AppState,
+};
+
+#[derive(Debug, Deserialize)]
+pub struct CreateResourceGroupRequest {
+ pub name: String,
+ pub description: Option,
+ pub internal_cidr: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ResourceGroupResponse {
+ pub id: Uuid,
+ pub organization_id: Uuid,
+ pub name: String,
+ pub description: Option,
+ pub internal_cidr: String,
+ pub status: String,
+ pub created_at: chrono::NaiveDateTime,
+ pub updated_at: Option,
+}
+
+impl From for ResourceGroupResponse {
+ fn from(m: resource_groups::Model) -> Self {
+ Self {
+ id: m.id,
+ organization_id: m.organization_id,
+ name: m.name,
+ description: m.description,
+ internal_cidr: m.internal_cidr,
+ status: m.status,
+ created_at: m.created_at,
+ updated_at: m.updated_at,
+ }
+ }
+}
+
+fn get_org_id(state: &AppState) -> Uuid {
+ state
+ .default_org_id
+ .expect("default organization not initialized")
+}
+
+pub async fn list_resource_groups(
+ CanViewResourceGroups(_claims): CanViewResourceGroups,
+ State(state): State,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ let groups = ResourceGroups::find()
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .all(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to list resource groups");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ let resp: Vec = groups.into_iter().map(Into::into).collect();
+ Ok((StatusCode::OK, Json(json!(resp))))
+}
+
+pub async fn create_resource_group(
+ CanManageResourceGroups(_claims): CanManageResourceGroups,
+ State(state): State,
+ Json(req): Json,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+ let now = Utc::now().naive_utc();
+
+ let model = resource_groups::ActiveModel {
+ id: Set(Uuid::new_v4()),
+ organization_id: Set(org_id),
+ name: Set(req.name),
+ description: Set(req.description),
+ internal_cidr: Set(req.internal_cidr),
+ status: Set("active".to_string()),
+ created_at: Set(now),
+ updated_at: Set(None),
+ };
+
+ let inserted = model.insert(&state.db_conn).await.map_err(|e| {
+ tracing::error!(error = %e, "failed to create resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ Ok((StatusCode::CREATED, Json(json!(ResourceGroupResponse::from(inserted)))))
+}
+
+pub async fn get_resource_group(
+ CanViewResourceGroups(_claims): CanViewResourceGroups,
+ State(state): State,
+ Path(id): Path,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ let group = ResourceGroups::find_by_id(id)
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .one(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to get resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "resource group not found" })),
+ )
+ })?;
+
+ Ok((StatusCode::OK, Json(json!(ResourceGroupResponse::from(group)))))
+}
+
+pub async fn delete_resource_group(
+ CanManageResourceGroups(_claims): CanManageResourceGroups,
+ State(state): State,
+ Path(id): Path,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ let group = ResourceGroups::find_by_id(id)
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .one(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to find resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "resource group not found" })),
+ )
+ })?;
+
+ let active_workloads = Workloads::find()
+ .filter(workloads::Column::ResourceGroupId.eq(id))
+ .filter(workloads::Column::Status.is_in(["pending", "scheduled", "running"]))
+ .count(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to count workloads");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ if active_workloads > 0 {
+ return Err((
+ StatusCode::CONFLICT,
+ Json(json!({ "error": "resource group has active workloads" })),
+ ));
+ }
+
+ let mut active: resource_groups::ActiveModel = group.into();
+ active.status = Set("deleting".to_string());
+ active.updated_at = Set(Some(Utc::now().naive_utc()));
+ active.update(&state.db_conn).await.map_err(|e| {
+ tracing::error!(error = %e, "failed to update resource group status");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ ResourceGroups::delete_by_id(id)
+ .exec(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to delete resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ Ok(StatusCode::NO_CONTENT.into_response())
+}
+
+pub async fn list_resource_group_workloads(
+ CanViewResourceGroups(_claims): CanViewResourceGroups,
+ State(state): State,
+ Path(id): Path,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ ResourceGroups::find_by_id(id)
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .one(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to find resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "resource group not found" })),
+ )
+ })?;
+
+ let workloads = Workloads::find()
+ .filter(workloads::Column::ResourceGroupId.eq(id))
+ .all(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to list workloads");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ Ok((StatusCode::OK, Json(json!(workloads))))
+}
+
+pub async fn list_resource_group_volumes(
+ CanViewResourceGroups(_claims): CanViewResourceGroups,
+ State(state): State,
+ Path(id): Path,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ ResourceGroups::find_by_id(id)
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .one(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to find resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "resource group not found" })),
+ )
+ })?;
+
+ let vols = Volumes::find()
+ .filter(volumes::Column::ResourceGroupId.eq(id))
+ .all(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to list volumes");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ Ok((StatusCode::OK, Json(json!(vols))))
+}
+
+pub async fn list_resource_group_networks(
+ CanViewResourceGroups(_claims): CanViewResourceGroups,
+ State(state): State,
+ Path(id): Path,
+) -> Result)> {
+ let org_id = get_org_id(&state);
+
+ ResourceGroups::find_by_id(id)
+ .filter(resource_groups::Column::OrganizationId.eq(org_id))
+ .one(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to find resource group");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "resource group not found" })),
+ )
+ })?;
+
+ let nets = Networks::find()
+ .filter(networks::Column::ResourceGroupId.eq(id))
+ .all(&state.db_conn)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "failed to list networks");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": "database error" })),
+ )
+ })?;
+
+ Ok((StatusCode::OK, Json(json!(nets))))
+}
+
+pub fn resource_groups_routes() -> Router {
+ Router::new()
+ .route(
+ "/resource-groups",
+ get(list_resource_groups).post(create_resource_group),
+ )
+ .route(
+ "/resource-groups/{id}",
+ get(get_resource_group).delete(delete_resource_group),
+ )
+ .route(
+ "/resource-groups/{id}/workloads",
+ get(list_resource_group_workloads),
+ )
+ .route(
+ "/resource-groups/{id}/volumes",
+ get(list_resource_group_volumes),
+ )
+ .route(
+ "/resource-groups/{id}/networks",
+ get(list_resource_group_networks),
+ )
+}
diff --git a/control-plane/scheduler/src/db/workloads.rs b/control-plane/scheduler/src/db/workloads.rs
index 9b8c6218..ff497e05 100644
--- a/control-plane/scheduler/src/db/workloads.rs
+++ b/control-plane/scheduler/src/db/workloads.rs
@@ -32,6 +32,7 @@ pub async fn create(
container_id: Set(None),
created_by: Set(None),
organization_id: Set(None),
+ resource_group_id: Set(req.resource_group_id),
created_at: Set(Utc::now().naive_utc()),
updated_at: Set(None),
};
@@ -103,6 +104,7 @@ fn into_response(m: workloads::Model) -> WorkloadResponse {
status: WorkloadStatus::from_str(&m.status),
assigned_agent_id: m.assigned_agent_id,
container_id: m.container_id,
+ resource_group_id: m.resource_group_id,
created_at: m.created_at.and_utc(),
updated_at: m.updated_at.map(|dt| dt.and_utc()),
}
diff --git a/control-plane/scheduler/src/models/workload.rs b/control-plane/scheduler/src/models/workload.rs
index 6ab22942..47ecf109 100644
--- a/control-plane/scheduler/src/models/workload.rs
+++ b/control-plane/scheduler/src/models/workload.rs
@@ -44,6 +44,7 @@ pub struct CreateWorkloadRequest {
pub disk_bytes: i64,
pub env_vars: Option>,
pub ports: Option>,
+ pub resource_group_id: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -72,6 +73,7 @@ pub struct WorkloadResponse {
pub status: WorkloadStatus,
pub assigned_agent_id: Option,
pub container_id: Option,
+ pub resource_group_id: Option,
pub created_at: DateTime,
pub updated_at: Option>,
}
diff --git a/control-plane/sdn-controller/src/db/networks.rs b/control-plane/sdn-controller/src/db/networks.rs
index 78cee66e..55ab1a88 100644
--- a/control-plane/sdn-controller/src/db/networks.rs
+++ b/control-plane/sdn-controller/src/db/networks.rs
@@ -19,6 +19,7 @@ pub async fn create_network(
overlay_type: Set(req.overlay_type),
status: Set("active".to_string()),
organization_id: Set(None),
+ resource_group_id: Set(req.resource_group_id),
created_at: Set(Utc::now().naive_utc()),
updated_at: Set(None),
};
diff --git a/control-plane/sdn-controller/src/models.rs b/control-plane/sdn-controller/src/models.rs
index 2526c0fe..22b1f231 100644
--- a/control-plane/sdn-controller/src/models.rs
+++ b/control-plane/sdn-controller/src/models.rs
@@ -5,6 +5,7 @@ pub struct CreateNetworkRequest {
pub name: String,
pub cidr: String,
pub overlay_type: String,
+ pub resource_group_id: Option,
}
#[derive(Debug, Serialize, Deserialize)]
diff --git a/control-plane/shared/entity/src/entities/mod.rs b/control-plane/shared/entity/src/entities/mod.rs
index d3ea77fd..6c54b09a 100644
--- a/control-plane/shared/entity/src/entities/mod.rs
+++ b/control-plane/shared/entity/src/entities/mod.rs
@@ -14,6 +14,7 @@ pub mod networks;
pub mod organization;
pub mod permission;
pub mod registry_tokens;
+pub mod resource_groups;
pub mod role;
pub mod role_permission;
pub mod user;
@@ -39,6 +40,7 @@ pub use networks::Entity as Networks;
pub use organization::Entity as Organization;
pub use permission::Entity as Permission;
pub use registry_tokens::Entity as RegistryTokens;
+pub use resource_groups::Entity as ResourceGroups;
pub use role::Entity as Role;
pub use role_permission::Entity as RolePermission;
pub use user::Entity as User;
diff --git a/control-plane/shared/entity/src/entities/networks.rs b/control-plane/shared/entity/src/entities/networks.rs
index 32c38c7d..afd6e57d 100644
--- a/control-plane/shared/entity/src/entities/networks.rs
+++ b/control-plane/shared/entity/src/entities/networks.rs
@@ -11,6 +11,7 @@ pub struct Model {
pub overlay_type: String,
pub status: String,
pub organization_id: Option,
+ pub resource_group_id: Option,
pub created_at: chrono::NaiveDateTime,
pub updated_at: Option,
}
@@ -21,6 +22,14 @@ pub enum Relation {
Policies,
#[sea_orm(has_many = "super::network_members::Entity")]
Members,
+ #[sea_orm(
+ belongs_to = "super::resource_groups::Entity",
+ from = "Column::ResourceGroupId",
+ to = "super::resource_groups::Column::Id",
+ on_update = "NoAction",
+ on_delete = "SetNull"
+ )]
+ ResourceGroup,
}
impl Related for Entity {
@@ -35,4 +44,10 @@ impl Related for Entity {
}
}
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::ResourceGroup.def()
+ }
+}
+
impl ActiveModelBehavior for ActiveModel {}
diff --git a/control-plane/shared/entity/src/entities/resource_groups.rs b/control-plane/shared/entity/src/entities/resource_groups.rs
new file mode 100644
index 00000000..cc63a440
--- /dev/null
+++ b/control-plane/shared/entity/src/entities/resource_groups.rs
@@ -0,0 +1,59 @@
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
+#[sea_orm(table_name = "resource_groups")]
+pub struct Model {
+ #[sea_orm(primary_key, auto_increment = false)]
+ pub id: Uuid,
+ pub organization_id: Uuid,
+ pub name: String,
+ pub description: Option,
+ pub internal_cidr: String,
+ pub status: String,
+ pub created_at: chrono::NaiveDateTime,
+ pub updated_at: Option,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::organization::Entity",
+ from = "Column::OrganizationId",
+ to = "super::organization::Column::Id",
+ on_delete = "Cascade"
+ )]
+ Organization,
+ #[sea_orm(has_many = "super::workloads::Entity")]
+ Workloads,
+ #[sea_orm(has_many = "super::volumes::Entity")]
+ Volumes,
+ #[sea_orm(has_many = "super::networks::Entity")]
+ Networks,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Organization.def()
+ }
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Workloads.def()
+ }
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Volumes.def()
+ }
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Networks.def()
+ }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/control-plane/shared/entity/src/entities/volumes.rs b/control-plane/shared/entity/src/entities/volumes.rs
index c29f7498..2eb6a6d6 100644
--- a/control-plane/shared/entity/src/entities/volumes.rs
+++ b/control-plane/shared/entity/src/entities/volumes.rs
@@ -15,6 +15,7 @@ pub struct Model {
pub attached_to_workload: Option,
pub mapped_device: Option,
pub organization_id: Option,
+ pub resource_group_id: Option,
pub created_at: chrono::NaiveDateTime,
pub updated_at: Option,
}
@@ -29,6 +30,14 @@ pub enum Relation {
Agent,
#[sea_orm(has_many = "super::volume_snapshots::Entity")]
Snapshots,
+ #[sea_orm(
+ belongs_to = "super::resource_groups::Entity",
+ from = "Column::ResourceGroupId",
+ to = "super::resource_groups::Column::Id",
+ on_update = "NoAction",
+ on_delete = "SetNull"
+ )]
+ ResourceGroup,
}
impl Related for Entity {
@@ -43,4 +52,10 @@ impl Related for Entity {
}
}
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::ResourceGroup.def()
+ }
+}
+
impl ActiveModelBehavior for ActiveModel {}
diff --git a/control-plane/shared/entity/src/entities/workloads.rs b/control-plane/shared/entity/src/entities/workloads.rs
index d86d7d34..27c97d5c 100644
--- a/control-plane/shared/entity/src/entities/workloads.rs
+++ b/control-plane/shared/entity/src/entities/workloads.rs
@@ -18,6 +18,7 @@ pub struct Model {
pub container_id: Option,
pub created_by: Option,
pub organization_id: Option,
+ pub resource_group_id: Option,
pub created_at: DateTime,
pub updated_at: Option,
}
@@ -32,6 +33,14 @@ pub enum Relation {
on_delete = "SetNull"
)]
Agent,
+ #[sea_orm(
+ belongs_to = "super::resource_groups::Entity",
+ from = "Column::ResourceGroupId",
+ to = "super::resource_groups::Column::Id",
+ on_update = "NoAction",
+ on_delete = "SetNull"
+ )]
+ ResourceGroup,
}
impl Related for Entity {
@@ -40,4 +49,10 @@ impl Related for Entity {
}
}
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::ResourceGroup.def()
+ }
+}
+
impl ActiveModelBehavior for ActiveModel {}
diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs
index 5bf75d02..7764dad3 100644
--- a/control-plane/shared/migration/src/lib.rs
+++ b/control-plane/shared/migration/src/lib.rs
@@ -18,6 +18,7 @@ mod m20260307_000000_add_networks;
mod m20260308_000000_add_org_scoping;
mod m20260309_000000_add_bootstrap_tokens;
mod m20260523_000000_add_ssh_keys;
+mod m20260625_000000_add_resource_groups;
pub struct Migrator;
@@ -43,6 +44,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260308_000000_add_org_scoping::Migration),
Box::new(m20260309_000000_add_bootstrap_tokens::Migration),
Box::new(m20260523_000000_add_ssh_keys::Migration),
+ Box::new(m20260625_000000_add_resource_groups::Migration),
]
}
}
diff --git a/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs b/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs
new file mode 100644
index 00000000..a862ff88
--- /dev/null
+++ b/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs
@@ -0,0 +1,190 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .create_table(
+ Table::create()
+ .table(ResourceGroups::Table)
+ .if_not_exists()
+ .col(ColumnDef::new(ResourceGroups::Id).uuid().not_null().primary_key())
+ .col(ColumnDef::new(ResourceGroups::OrganizationId).uuid().not_null())
+ .col(ColumnDef::new(ResourceGroups::Name).string().not_null())
+ .col(ColumnDef::new(ResourceGroups::Description).string().null())
+ .col(ColumnDef::new(ResourceGroups::InternalCidr).string().not_null())
+ .col(ColumnDef::new(ResourceGroups::Status).string().not_null().default("active"))
+ .col(ColumnDef::new(ResourceGroups::CreatedAt).date_time().not_null())
+ .col(ColumnDef::new(ResourceGroups::UpdatedAt).date_time().null())
+ .foreign_key(
+ ForeignKey::create()
+ .from(ResourceGroups::Table, ResourceGroups::OrganizationId)
+ .to(Organization::Table, Organization::Id)
+ .on_delete(ForeignKeyAction::Cascade),
+ )
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Workloads::Table)
+ .add_column(ColumnDef::new(Workloads::ResourceGroupId).uuid().null())
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Volumes::Table)
+ .add_column(ColumnDef::new(Volumes::ResourceGroupId).uuid().null())
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Networks::Table)
+ .add_column(ColumnDef::new(Networks::ResourceGroupId).uuid().null())
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .table(ResourceGroups::Table)
+ .col(ResourceGroups::OrganizationId)
+ .name("idx_resource_groups_organization_id")
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .table(Workloads::Table)
+ .col(Workloads::ResourceGroupId)
+ .name("idx_workloads_resource_group_id")
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .table(Volumes::Table)
+ .col(Volumes::ResourceGroupId)
+ .name("idx_volumes_resource_group_id")
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .table(Networks::Table)
+ .col(Networks::ResourceGroupId)
+ .name("idx_networks_resource_group_id")
+ .to_owned(),
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .drop_index(Index::drop().name("idx_networks_resource_group_id").to_owned())
+ .await?;
+ manager
+ .drop_index(Index::drop().name("idx_volumes_resource_group_id").to_owned())
+ .await?;
+ manager
+ .drop_index(Index::drop().name("idx_workloads_resource_group_id").to_owned())
+ .await?;
+ manager
+ .drop_index(
+ Index::drop()
+ .name("idx_resource_groups_organization_id")
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Networks::Table)
+ .drop_column(Networks::ResourceGroupId)
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Volumes::Table)
+ .drop_column(Volumes::ResourceGroupId)
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Workloads::Table)
+ .drop_column(Workloads::ResourceGroupId)
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .drop_table(Table::drop().table(ResourceGroups::Table).to_owned())
+ .await?;
+
+ Ok(())
+ }
+}
+
+#[derive(DeriveIden)]
+enum ResourceGroups {
+ Table,
+ Id,
+ OrganizationId,
+ Name,
+ Description,
+ InternalCidr,
+ Status,
+ CreatedAt,
+ UpdatedAt,
+}
+
+#[derive(DeriveIden)]
+enum Organization {
+ Table,
+ Id,
+}
+
+#[derive(DeriveIden)]
+enum Workloads {
+ Table,
+ ResourceGroupId,
+}
+
+#[derive(DeriveIden)]
+enum Volumes {
+ Table,
+ ResourceGroupId,
+}
+
+#[derive(DeriveIden)]
+enum Networks {
+ Table,
+ ResourceGroupId,
+}
diff --git a/control-plane/volume-manager/src/db/volumes.rs b/control-plane/volume-manager/src/db/volumes.rs
index 7e6921a3..52733ae1 100644
--- a/control-plane/volume-manager/src/db/volumes.rs
+++ b/control-plane/volume-manager/src/db/volumes.rs
@@ -31,6 +31,7 @@ pub async fn create(
attached_to_workload: Set(None),
mapped_device: Set(None),
organization_id: Set(None),
+ resource_group_id: Set(req.resource_group_id),
created_at: Set(Utc::now().naive_utc()),
updated_at: Set(None),
};
@@ -140,6 +141,7 @@ pub fn into_response(m: volumes::Model) -> VolumeResponse {
attached_to_agent: m.attached_to_agent,
attached_to_workload: m.attached_to_workload,
mapped_device: m.mapped_device,
+ resource_group_id: m.resource_group_id,
created_at: m.created_at.and_utc(),
updated_at: m.updated_at.map(|dt| dt.and_utc()),
}
diff --git a/control-plane/volume-manager/src/models/volume.rs b/control-plane/volume-manager/src/models/volume.rs
index 9e3d8434..2192cd2b 100644
--- a/control-plane/volume-manager/src/models/volume.rs
+++ b/control-plane/volume-manager/src/models/volume.rs
@@ -62,6 +62,7 @@ pub struct CreateVolumeRequest {
pub name: String,
pub size_gb: i32,
pub pool: Option,
+ pub resource_group_id: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -86,6 +87,7 @@ pub struct VolumeResponse {
pub attached_to_agent: Option,
pub attached_to_workload: Option,
pub mapped_device: Option,
+ pub resource_group_id: Option,
pub created_at: DateTime,
pub updated_at: Option>,
}
From b1cb885b283d785190683537bc40100ade67d0b9 Mon Sep 17 00:00:00 2001
From: CodeMaster4711
Date: Thu, 25 Jun 2026 21:35:31 +0200
Subject: [PATCH 3/5] fix: build errors and added frontend
---
app/src/lib/api/resource-groups.ts | 50 +++++
.../lib/components/sidebar/app-sidebar.svelte | 2 +-
.../routes/(app)/resource-groups/+page.svelte | 203 ++++++++++++++++++
3 files changed, 254 insertions(+), 1 deletion(-)
create mode 100644 app/src/lib/api/resource-groups.ts
create mode 100644 app/src/routes/(app)/resource-groups/+page.svelte
diff --git a/app/src/lib/api/resource-groups.ts b/app/src/lib/api/resource-groups.ts
new file mode 100644
index 00000000..11b340e9
--- /dev/null
+++ b/app/src/lib/api/resource-groups.ts
@@ -0,0 +1,50 @@
+const API_BASE = '/api';
+
+export interface ResourceGroup {
+ id: string;
+ organization_id: string;
+ name: string;
+ description: string | null;
+ internal_cidr: string;
+ status: string;
+ created_at: string;
+ updated_at: string | null;
+}
+
+export interface CreateResourceGroupRequest {
+ name: string;
+ description?: string;
+ internal_cidr: string;
+}
+
+export async function listResourceGroups(token: string): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to list resource groups: ${res.status}`);
+ return res.json();
+}
+
+export async function createResourceGroup(
+ token: string,
+ req: CreateResourceGroupRequest,
+): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to create resource group: ${res.status}`);
+ return res.json();
+}
+
+export async function deleteResourceGroup(token: string, id: string): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to delete resource group: ${res.status}`);
+}
diff --git a/app/src/lib/components/sidebar/app-sidebar.svelte b/app/src/lib/components/sidebar/app-sidebar.svelte
index f46ba238..3376f022 100644
--- a/app/src/lib/components/sidebar/app-sidebar.svelte
+++ b/app/src/lib/components/sidebar/app-sidebar.svelte
@@ -43,7 +43,7 @@
},
{
title: "Resource groups",
- url: "#",
+ url: "/resource-groups",
icon: Layers,
},
],
diff --git a/app/src/routes/(app)/resource-groups/+page.svelte b/app/src/routes/(app)/resource-groups/+page.svelte
new file mode 100644
index 00000000..3131ba42
--- /dev/null
+++ b/app/src/routes/(app)/resource-groups/+page.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+ /
+ Resource Groups
+
+
+
+
+
+
Resource Groups
+
+ Isolated namespaces with dedicated internal networks
+
+
+
+
+
+ {#if showCreate}
+
+
Create Resource Group
+
+ {#if createError}
+
{createError}
+ {/if}
+
+
+
+
+
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
+ | ID |
+ Name |
+ CIDR |
+ Description |
+ Status |
+ Created |
+ |
+
+
+
+ {#if loading}
+
+ | Loading... |
+
+ {:else if groups.length === 0}
+
+ |
+ No resource groups. Create one to get started.
+ |
+
+ {:else}
+ {#each groups as group (group.id)}
+
+ | {group.id.slice(0, 8)} |
+ {group.name} |
+ {group.internal_cidr} |
+ {group.description ?? "-"} |
+
+ {group.status}
+ |
+ {group.created_at.slice(0, 10)} |
+
+
+ |
+
+ {/each}
+ {/if}
+
+
+
+
From 7b32db8ee1dacf56062f96dee4f1649048f13464 Mon Sep 17 00:00:00 2001
From: CodeMaster4711
Date: Sun, 28 Jun 2026 20:52:31 +0200
Subject: [PATCH 4/5] feat(resource-groups): add container deploy, volume
management, and detail view
---
agent/src/client.rs | 7 +
agent/src/docker.rs | 21 +
agent/src/main.rs | 22 +-
app/src/lib/api/resource-groups.ts | 128 +++++
.../routes/(app)/resource-groups/+page.svelte | 8 +-
.../(app)/resource-groups/[id]/+page.svelte | 531 ++++++++++++++++++
control-plane/scheduler/src/db/workloads.rs | 11 +
.../scheduler/src/models/workload.rs | 8 +
.../shared/entity/src/entities/workloads.rs | 1 +
control-plane/shared/migration/src/lib.rs | 2 +
.../src/m20260628_000000_add_volume_mounts.rs | 33 ++
11 files changed, 769 insertions(+), 3 deletions(-)
create mode 100644 app/src/routes/(app)/resource-groups/[id]/+page.svelte
create mode 100644 control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs
diff --git a/agent/src/client.rs b/agent/src/client.rs
index d87cfce3..184532eb 100644
--- a/agent/src/client.rs
+++ b/agent/src/client.rs
@@ -63,6 +63,12 @@ pub struct HeartbeatResponse {
pub post_update_heartbeats: Option,
}
+#[derive(Debug, Deserialize, Clone)]
+pub struct VolumeMount {
+ pub volume_id: String,
+ pub mount_path: String,
+}
+
#[derive(Debug, Deserialize)]
pub struct AssignedWorkload {
pub id: String,
@@ -73,6 +79,7 @@ pub struct AssignedWorkload {
pub disk_bytes: i64,
pub env_vars: Option>,
pub ports: Option>,
+ pub volume_mounts: Option>,
pub status: String,
pub container_id: Option,
}
diff --git a/agent/src/docker.rs b/agent/src/docker.rs
index 7ca8262d..fbcda9a9 100644
--- a/agent/src/docker.rs
+++ b/agent/src/docker.rs
@@ -16,6 +16,12 @@ pub struct PortMapping {
pub protocol: Option,
}
+#[derive(Debug, Clone)]
+pub struct VolumeMount {
+ pub volume_id: String,
+ pub mount_path: String,
+}
+
#[derive(Debug, Clone)]
pub struct WorkloadSpec {
pub workload_id: String,
@@ -23,6 +29,7 @@ pub struct WorkloadSpec {
pub image: String,
pub env_vars: Option>,
pub ports: Option>,
+ pub volume_mounts: Option>,
}
pub struct DockerManager {
@@ -79,12 +86,26 @@ impl DockerManager {
let (port_bindings, exposed_ports) = build_port_config(spec.ports.as_deref());
+ let binds = spec.volume_mounts.as_deref().map(|mounts| {
+ mounts
+ .iter()
+ .map(|m| {
+ format!(
+ "{}:{}",
+ crate::rbd::mount_point_for(&m.volume_id),
+ m.mount_path
+ )
+ })
+ .collect::>()
+ });
+
let host_config = HostConfig {
port_bindings: if port_bindings.is_empty() {
None
} else {
Some(port_bindings)
},
+ binds,
..Default::default()
};
diff --git a/agent/src/main.rs b/agent/src/main.rs
index 9a37e110..60454f18 100644
--- a/agent/src/main.rs
+++ b/agent/src/main.rs
@@ -201,7 +201,7 @@ async fn run_heartbeat_loop(
process_volumes(client, agent_id, api_key, &mounted_volumes).await;
if let Some(ref dm) = docker {
- process_workloads(client, api_key, dm, &running_containers).await;
+ process_workloads(client, api_key, dm, &running_containers, &mounted_volumes).await;
}
let statuses = build_container_statuses(&running_containers).await;
@@ -324,6 +324,7 @@ async fn process_workloads(
api_key: &str,
docker: &docker::DockerManager,
running_containers: &Arc>>,
+ mounted_volumes: &Arc>>,
) {
let workloads = match client.fetch_assigned_workloads(api_key).await {
Ok(w) => w,
@@ -347,12 +348,31 @@ async fn process_workloads(
continue;
}
+ if let Some(ref mounts) = workload.volume_mounts {
+ let locked = mounted_volumes.lock().await;
+ let all_ready = mounts.iter().all(|m| locked.contains_key(&m.volume_id));
+ drop(locked);
+ if !all_ready {
+ info!(workload_id = %workload.id, "Waiting for volumes to be mounted, deferring workload");
+ continue;
+ }
+ }
+
let spec = docker::WorkloadSpec {
workload_id: workload.id.clone(),
name: workload.name.clone(),
image: workload.image.clone(),
env_vars: workload.env_vars,
ports: workload.ports,
+ volume_mounts: workload.volume_mounts.map(|mounts| {
+ mounts
+ .into_iter()
+ .map(|m| docker::VolumeMount {
+ volume_id: m.volume_id,
+ mount_path: m.mount_path,
+ })
+ .collect()
+ }),
};
match docker.start_container(&spec).await {
diff --git a/app/src/lib/api/resource-groups.ts b/app/src/lib/api/resource-groups.ts
index 11b340e9..2bd82fec 100644
--- a/app/src/lib/api/resource-groups.ts
+++ b/app/src/lib/api/resource-groups.ts
@@ -17,6 +17,62 @@ export interface CreateResourceGroupRequest {
internal_cidr: string;
}
+export interface PortMapping {
+ host_port: number;
+ container_port: number;
+ protocol: string | null;
+}
+
+export interface VolumeMount {
+ volume_id: string;
+ mount_path: string;
+}
+
+export interface Volume {
+ id: string;
+ name: string;
+ size_gb: number;
+ pool: string;
+ status: string;
+ attached_to_workload: string | null;
+ resource_group_id: string | null;
+ created_at: string;
+}
+
+export interface Workload {
+ id: string;
+ name: string;
+ image: string;
+ cpu_millicores: number;
+ memory_bytes: number;
+ disk_bytes: number;
+ status: string;
+ assigned_agent_id: string | null;
+ container_id: string | null;
+ volume_mounts: VolumeMount[] | null;
+ resource_group_id: string | null;
+ created_at: string;
+ updated_at: string | null;
+}
+
+export interface CreateWorkloadRequest {
+ name: string;
+ image: string;
+ cpu_millicores: number;
+ memory_bytes: number;
+ disk_bytes: number;
+ env_vars: Record | null;
+ ports: PortMapping[] | null;
+ volume_mounts: VolumeMount[] | null;
+ resource_group_id: string;
+}
+
+export interface CreateVolumeRequest {
+ name: string;
+ size_gb: number;
+ resource_group_id: string;
+}
+
export async function listResourceGroups(token: string): Promise {
const res = await fetch(`${API_BASE}/resource-groups`, {
headers: { Authorization: `Bearer ${token}` },
@@ -25,6 +81,14 @@ export async function listResourceGroups(token: string): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups/${id}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to get resource group: ${res.status}`);
+ return res.json();
+}
+
export async function createResourceGroup(
token: string,
req: CreateResourceGroupRequest,
@@ -48,3 +112,67 @@ export async function deleteResourceGroup(token: string, id: string): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups/${rgId}/workloads`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to list workloads: ${res.status}`);
+ return res.json();
+}
+
+export async function createWorkload(token: string, req: CreateWorkloadRequest): Promise<{ workload_id: string; status: string; assigned_agent_id: string | null; message: string }> {
+ const res = await fetch(`${API_BASE}/workloads`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.status }));
+ throw new Error(err.error ?? `Failed to create workload: ${res.status}`);
+ }
+ return res.json();
+}
+
+export async function deleteWorkload(token: string, id: string): Promise {
+ const res = await fetch(`${API_BASE}/workloads/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to delete workload: ${res.status}`);
+}
+
+export async function listResourceGroupVolumes(token: string, rgId: string): Promise {
+ const res = await fetch(`${API_BASE}/resource-groups/${rgId}/volumes`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to list volumes: ${res.status}`);
+ return res.json();
+}
+
+export async function createVolume(token: string, req: CreateVolumeRequest): Promise {
+ const res = await fetch(`${API_BASE}/volumes`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.status }));
+ throw new Error(err.error ?? `Failed to create volume: ${res.status}`);
+ }
+ return res.json();
+}
+
+export async function deleteVolume(token: string, id: string): Promise {
+ const res = await fetch(`${API_BASE}/volumes/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error(`Failed to delete volume: ${res.status}`);
+}
diff --git a/app/src/routes/(app)/resource-groups/+page.svelte b/app/src/routes/(app)/resource-groups/+page.svelte
index 3131ba42..d6f1ddbb 100644
--- a/app/src/routes/(app)/resource-groups/+page.svelte
+++ b/app/src/routes/(app)/resource-groups/+page.svelte
@@ -1,5 +1,6 @@
+
+
+
+ /
+
+ /
+ {group?.name ?? rgId.slice(0, 8)}
+
+
+
+ {#if loading}
+
Loading...
+ {:else if error && !group}
+
{error}
+ {:else if group}
+
+
+ {initials(group.name)}
+
+
+
+
{group.name}
+ {group.status}
+
+ {#if group.description}
+
{group.description}
+ {/if}
+
{group.internal_cidr}
+
+
+
+
+
+
+
+
+
+
Containers
+
{workloads.length}
+
{workloads.filter(w => w.status === 'running').length} running
+
+
+
Volumes
+
{volumes.length}
+
{totalDisk} GB total
+
+
+
CPU Requested
+
{(totalCpu / 1000).toFixed(1)}
+
vCPU
+
+
+
Memory Requested
+
{fmtBytes(totalMem)}
+
across containers
+
+
+
+ {#if showDeployContainer}
+
+
Deploy Container
+
+ {#if volumes.length > 0}
+
+
+
+
Available: {volumes.map(v => v.name).join(", ")}
+
+ {/if}
+
+
+
+
+ {#if deployError}
+
{deployError}
+ {/if}
+
+
+
+
+
+ {/if}
+
+ {#if showCreateVolume}
+
+
Create Volume
+
+ {#if volumeError}
+
{volumeError}
+ {/if}
+
+
+
+
+
+ {/if}
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+ {#each [["all", `All ${allResources.length}`], ["container", `Container ${workloads.length}`], ["volume", `Volume ${volumes.length}`]] as [tab, label]}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+ | Resource |
+ Kind |
+ Host / Size |
+ Load |
+ Status |
+ |
+
+
+
+ {#if filteredResources.length === 0}
+
+ |
+ {allResources.length === 0 ? "No resources yet. Deploy a container or create a volume." : "No resources match filter."}
+ |
+
+ {:else}
+ {#each filteredResources as item (item.kind + item.data.id)}
+ {#if item.kind === "container"}
+ {@const w = item.data}
+
+ |
+
+ |
+
+ Container
+ |
+
+ {w.assigned_agent_id ? w.assigned_agent_id.slice(0, 8) : "-"}
+ |
+
+
+ {w.cpu_millicores}m
+
+
+ |
+
+
+ {w.status}
+
+ |
+
+
+ |
+
+ {:else}
+ {@const v = item.data}
+
+
+
+
+
+ {v.name}
+ {v.size_gb} GB · {v.pool}
+
+
+ |
+
+ Volume
+ |
+
+ {v.size_gb} GB
+ |
+
+
+ |
+
+
+ {v.status}
+
+ |
+
+
+ |
+
+ {/if}
+ {/each}
+ {/if}
+
+
+
+ {/if}
+
diff --git a/control-plane/scheduler/src/db/workloads.rs b/control-plane/scheduler/src/db/workloads.rs
index ff497e05..8ea7d69d 100644
--- a/control-plane/scheduler/src/db/workloads.rs
+++ b/control-plane/scheduler/src/db/workloads.rs
@@ -17,6 +17,10 @@ pub async fn create(
.ports
.as_ref()
.and_then(|p| serde_json::to_value(p).ok());
+ let volume_mounts = req
+ .volume_mounts
+ .as_ref()
+ .and_then(|v| serde_json::to_value(v).ok());
let model = workloads::ActiveModel {
id: Set(Uuid::new_v4()),
@@ -27,6 +31,7 @@ pub async fn create(
disk_bytes: Set(req.disk_bytes),
env_vars: Set(env_vars),
ports: Set(ports),
+ volume_mounts: Set(volume_mounts),
status: Set(WorkloadStatus::Pending.as_str().to_string()),
assigned_agent_id: Set(None),
container_id: Set(None),
@@ -94,6 +99,11 @@ pub async fn delete(db: &DatabaseConnection, workload_id: Uuid) -> Result<(), se
}
fn into_response(m: workloads::Model) -> WorkloadResponse {
+ let volume_mounts = m
+ .volume_mounts
+ .as_ref()
+ .and_then(|v| serde_json::from_value(v.clone()).ok());
+
WorkloadResponse {
id: m.id,
name: m.name,
@@ -104,6 +114,7 @@ fn into_response(m: workloads::Model) -> WorkloadResponse {
status: WorkloadStatus::from_str(&m.status),
assigned_agent_id: m.assigned_agent_id,
container_id: m.container_id,
+ volume_mounts,
resource_group_id: m.resource_group_id,
created_at: m.created_at.and_utc(),
updated_at: m.updated_at.map(|dt| dt.and_utc()),
diff --git a/control-plane/scheduler/src/models/workload.rs b/control-plane/scheduler/src/models/workload.rs
index 47ecf109..4b1f9fbb 100644
--- a/control-plane/scheduler/src/models/workload.rs
+++ b/control-plane/scheduler/src/models/workload.rs
@@ -35,6 +35,12 @@ impl WorkloadStatus {
}
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VolumeMount {
+ pub volume_id: Uuid,
+ pub mount_path: String,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkloadRequest {
pub name: String,
@@ -44,6 +50,7 @@ pub struct CreateWorkloadRequest {
pub disk_bytes: i64,
pub env_vars: Option>,
pub ports: Option>,
+ pub volume_mounts: Option>,
pub resource_group_id: Option,
}
@@ -73,6 +80,7 @@ pub struct WorkloadResponse {
pub status: WorkloadStatus,
pub assigned_agent_id: Option,
pub container_id: Option,
+ pub volume_mounts: Option>,
pub resource_group_id: Option,
pub created_at: DateTime,
pub updated_at: Option>,
diff --git a/control-plane/shared/entity/src/entities/workloads.rs b/control-plane/shared/entity/src/entities/workloads.rs
index 27c97d5c..b4c662d9 100644
--- a/control-plane/shared/entity/src/entities/workloads.rs
+++ b/control-plane/shared/entity/src/entities/workloads.rs
@@ -13,6 +13,7 @@ pub struct Model {
pub disk_bytes: i64,
pub env_vars: Option,
pub ports: Option,
+ pub volume_mounts: Option,
pub status: String,
pub assigned_agent_id: Option,
pub container_id: Option,
diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs
index 7764dad3..c05f5298 100644
--- a/control-plane/shared/migration/src/lib.rs
+++ b/control-plane/shared/migration/src/lib.rs
@@ -19,6 +19,7 @@ mod m20260308_000000_add_org_scoping;
mod m20260309_000000_add_bootstrap_tokens;
mod m20260523_000000_add_ssh_keys;
mod m20260625_000000_add_resource_groups;
+mod m20260628_000000_add_volume_mounts;
pub struct Migrator;
@@ -45,6 +46,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260309_000000_add_bootstrap_tokens::Migration),
Box::new(m20260523_000000_add_ssh_keys::Migration),
Box::new(m20260625_000000_add_resource_groups::Migration),
+ Box::new(m20260628_000000_add_volume_mounts::Migration),
]
}
}
diff --git a/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs b/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs
new file mode 100644
index 00000000..450fb41e
--- /dev/null
+++ b/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs
@@ -0,0 +1,33 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Alias::new("workloads"))
+ .add_column_if_not_exists(
+ ColumnDef::new(Alias::new("volume_mounts"))
+ .json()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Alias::new("workloads"))
+ .drop_column(Alias::new("volume_mounts"))
+ .to_owned(),
+ )
+ .await
+ }
+}
From d8c098b71064d1e3c1ec32e66d21821ab90e63a8 Mon Sep 17 00:00:00 2001
From: CodeMaster4711
Date: Sun, 28 Jun 2026 21:36:27 +0200
Subject: [PATCH 5/5] feat(ui,agent,registry): add WireGuard P2S VPN, modal
forms, and NodePort expose model
---
Cargo.lock | 14 +
Cargo.toml | 1 +
agent/Cargo.toml | 2 +
agent/src/client.rs | 24 +-
agent/src/docker.rs | 23 +-
agent/src/main.rs | 7 +-
app/src/lib/api/resource-groups.ts | 2 +-
.../routes/(app)/resource-groups/+page.svelte | 109 +++----
.../(app)/resource-groups/[id]/+page.svelte | 271 +++++++++++-------
app/src/routes/layout.css | 3 +
.../api-gateway/src/routes/agents.rs | 2 +
.../api-gateway/src/routes/resource_groups.rs | 144 +++++++++-
control-plane/api-gateway/src/self_monitor.rs | 2 +
control-plane/registry/src/db/agents.rs | 10 +
control-plane/registry/src/handlers/agent.rs | 6 +-
control-plane/registry/src/models/agent.rs | 2 +
.../registry/src/services/registry.rs | 19 +-
control-plane/scheduler/src/db/agents.rs | 10 +-
.../scheduler/src/models/workload.rs | 2 +-
.../scheduler/src/services/scheduler.rs | 40 +++
.../shared/entity/src/entities/agents.rs | 2 +
control-plane/shared/migration/src/lib.rs | 2 +
...20260628_100000_rg_cidr_unique_agent_wg.rs | 63 ++++
23 files changed, 585 insertions(+), 175 deletions(-)
create mode 100644 control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs
diff --git a/Cargo.lock b/Cargo.lock
index 3677127e..d2a46e32 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1175,6 +1175,7 @@ name = "csfx-agent"
version = "0.2.2"
dependencies = [
"anyhow",
+ "base64",
"bollard",
"chrono",
"futures-util",
@@ -1189,6 +1190,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
+ "x25519-dalek",
]
[[package]]
@@ -6450,6 +6452,18 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.6.4",
+ "serde",
+ "zeroize",
+]
+
[[package]]
name = "x509-parser"
version = "0.18.1"
diff --git a/Cargo.toml b/Cargo.toml
index 2fdf864c..792350f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -88,6 +88,7 @@ tracing-opentelemetry = { version = "0.33" }
# Other
rand = "0.10"
+x25519-dalek = { version = "2", features = ["static_secrets"] }
aes-gcm = "0.10"
base64 = "0.22"
sha1 = "0.11"
diff --git a/agent/Cargo.toml b/agent/Cargo.toml
index fe7acd4c..0bc5d926 100644
--- a/agent/Cargo.toml
+++ b/agent/Cargo.toml
@@ -24,3 +24,5 @@ sysinfo = { workspace = true }
rcgen = { workspace = true }
bollard = { workspace = true }
futures-util = { workspace = true }
+x25519-dalek = { workspace = true }
+base64 = { workspace = true }
diff --git a/agent/src/client.rs b/agent/src/client.rs
index 184532eb..d9b79271 100644
--- a/agent/src/client.rs
+++ b/agent/src/client.rs
@@ -1,5 +1,7 @@
use anyhow::{Context, Result};
+use base64::{engine::general_purpose::STANDARD as B64, Engine};
use reqwest::Client;
+use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
@@ -48,6 +50,8 @@ struct HeartbeatRequest {
network_rx_bytes: Option,
network_tx_bytes: Option,
uptime_seconds: Option,
+ wg_public_key: Option,
+ wg_endpoint: Option,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -88,10 +92,24 @@ pub struct ApiClient {
client: Client,
gateway_url: String,
cert_pem: Option,
+ wg_public_key: String,
+ wg_endpoint: Option,
+}
+
+pub fn generate_wg_keypair() -> (String, String) {
+ let rng = SystemRandom::new();
+ let mut private_bytes = [0u8; 32];
+ rng.fill(&mut private_bytes).expect("failed to generate WireGuard key");
+ private_bytes[0] &= 248;
+ private_bytes[31] &= 127;
+ private_bytes[31] |= 64;
+ let secret = x25519_dalek::StaticSecret::from(private_bytes);
+ let public = x25519_dalek::PublicKey::from(&secret);
+ (B64.encode(private_bytes), B64.encode(public.to_bytes()))
}
impl ApiClient {
- pub fn new(gateway_url: String) -> Result {
+ pub fn new(gateway_url: String, wg_public_key: String, wg_endpoint: Option) -> Result {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
@@ -102,6 +120,8 @@ impl ApiClient {
client,
gateway_url,
cert_pem: None,
+ wg_public_key,
+ wg_endpoint,
})
}
@@ -221,6 +241,8 @@ impl ApiClient {
network_rx_bytes,
network_tx_bytes,
uptime_seconds,
+ wg_public_key: Some(self.wg_public_key.clone()),
+ wg_endpoint: self.wg_endpoint.clone(),
});
if let Some(ref cert_pem) = self.cert_pem {
diff --git a/agent/src/docker.rs b/agent/src/docker.rs
index fbcda9a9..702da354 100644
--- a/agent/src/docker.rs
+++ b/agent/src/docker.rs
@@ -11,9 +11,9 @@ use tracing::{info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortMapping {
- pub host_port: u16,
pub container_port: u16,
pub protocol: Option,
+ pub node_port: Option,
}
#[derive(Debug, Clone)]
@@ -181,16 +181,17 @@ fn build_port_config(
for p in ports {
let proto = p.protocol.as_deref().unwrap_or("tcp");
let container_key = format!("{}/{}", p.container_port, proto);
-
- port_bindings.insert(
- container_key.clone(),
- Some(vec![bollard::models::PortBinding {
- host_ip: Some("0.0.0.0".to_string()),
- host_port: Some(p.host_port.to_string()),
- }]),
- );
-
- exposed_ports.push(container_key);
+ exposed_ports.push(container_key.clone());
+
+ if let Some(node_port) = p.node_port {
+ port_bindings.insert(
+ container_key,
+ Some(vec![bollard::models::PortBinding {
+ host_ip: Some("0.0.0.0".to_string()),
+ host_port: Some(node_port.to_string()),
+ }]),
+ );
+ }
}
}
diff --git a/agent/src/main.rs b/agent/src/main.rs
index 60454f18..a7d6cac5 100644
--- a/agent/src/main.rs
+++ b/agent/src/main.rs
@@ -51,8 +51,11 @@ async fn main() -> Result<()> {
.and_then(|v| v.parse().ok())
.unwrap_or(60);
- let api_client =
- client::ApiClient::new(gateway_url.clone()).context("Failed to initialize API client")?;
+ let wg_endpoint = std::env::var("CSFX_WG_ENDPOINT").ok();
+ let (_wg_private_key, wg_public_key) = client::generate_wg_keypair();
+
+ let api_client = client::ApiClient::new(gateway_url.clone(), wg_public_key, wg_endpoint)
+ .context("Failed to initialize API client")?;
let agent_pki = pki::AgentPki::load_or_generate().context("Failed to initialize PKI")?;
diff --git a/app/src/lib/api/resource-groups.ts b/app/src/lib/api/resource-groups.ts
index 2bd82fec..9ef94e8a 100644
--- a/app/src/lib/api/resource-groups.ts
+++ b/app/src/lib/api/resource-groups.ts
@@ -18,9 +18,9 @@ export interface CreateResourceGroupRequest {
}
export interface PortMapping {
- host_port: number;
container_port: number;
protocol: string | null;
+ node_port: number | null;
}
export interface VolumeMount {
diff --git a/app/src/routes/(app)/resource-groups/+page.svelte b/app/src/routes/(app)/resource-groups/+page.svelte
index d6f1ddbb..21a6c3bf 100644
--- a/app/src/routes/(app)/resource-groups/+page.svelte
+++ b/app/src/routes/(app)/resource-groups/+page.svelte
@@ -15,7 +15,7 @@
let loading = $state(true);
let error = $state(null);
let creating = $state(false);
- let showCreate = $state(false);
+ let createDialog = $state(null);
let newName = $state("");
let newCidr = $state("10.100.0.0/24");
@@ -44,7 +44,7 @@
internal_cidr: newCidr,
});
groups = [...groups, created];
- showCreate = false;
+ createDialog?.close();
newName = "";
newCidr = "10.100.0.0/24";
newDescription = "";
@@ -77,6 +77,63 @@
onMount(load);
+
+
/
@@ -91,7 +148,7 @@
Isolated namespaces with dedicated internal networks
-