diff --git a/Cargo.lock b/Cargo.lock index f5019f8b..d2a46e32 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" @@ -1157,6 +1175,7 @@ name = "csfx-agent" version = "0.2.2" dependencies = [ "anyhow", + "base64", "bollard", "chrono", "futures-util", @@ -1171,6 +1190,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x25519-dalek", ] [[package]] @@ -1212,6 +1232,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 +1420,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 +1467,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 +1657,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 +1884,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1862,6 +1985,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 +2616,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 +3175,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 +3440,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 +4057,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 +4577,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" @@ -6254,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 b3f8233a..792350f3 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"] } @@ -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 d87cfce3..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)] @@ -63,6 +67,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 +83,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, } @@ -81,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) @@ -95,6 +120,8 @@ impl ApiClient { client, gateway_url, cert_pem: None, + wg_public_key, + wg_endpoint, }) } @@ -214,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 7ca8262d..702da354 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -11,9 +11,15 @@ 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)] +pub struct VolumeMount { + pub volume_id: String, + pub mount_path: String, } #[derive(Debug, Clone)] @@ -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() }; @@ -160,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 9a37e110..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")?; @@ -201,7 +204,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 +327,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 +351,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/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/resource-groups.ts b/app/src/lib/api/resource-groups.ts new file mode 100644 index 00000000..9ef94e8a --- /dev/null +++ b/app/src/lib/api/resource-groups.ts @@ -0,0 +1,178 @@ +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 interface PortMapping { + container_port: number; + protocol: string | null; + node_port: number | 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}` }, + }); + if (!res.ok) throw new Error(`Failed to list resource groups: ${res.status}`); + return res.json(); +} + +export async function getResourceGroup(token: string, id: 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, +): 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}`); +} + +export async function listResourceGroupWorkloads(token: string, rgId: 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/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/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..21a6c3bf --- /dev/null +++ b/app/src/routes/(app)/resource-groups/+page.svelte @@ -0,0 +1,218 @@ + + + { createError = null; }} +> +
+
+

New Resource Group

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ {#if createError} +

{createError}

+ {/if} +
+ + +
+
+
+ +
+ + / + Resource Groups +
+ +
+
+
+

Resource Groups

+

+ Isolated namespaces with dedicated internal networks +

+
+ +
+ + {#if error} +

{error}

+ {/if} + +
+ + + + + + + + + + + + + + {#if loading} + + + + {:else if groups.length === 0} + + + + {:else} + {#each groups as group (group.id)} + goto(`/resource-groups/${group.id}`)} + > + + + + + + + + + {/each} + {/if} + +
IDNameCIDRDescriptionStatusCreated
Loading...
+ No resource groups. Create one to get started. +
{group.id.slice(0, 8)}{group.name}{group.internal_cidr}{group.description ?? "-"} + {group.status} + {group.created_at.slice(0, 10)} + +
+
+
diff --git a/app/src/routes/(app)/resource-groups/[id]/+page.svelte b/app/src/routes/(app)/resource-groups/[id]/+page.svelte new file mode 100644 index 00000000..51da30d1 --- /dev/null +++ b/app/src/routes/(app)/resource-groups/[id]/+page.svelte @@ -0,0 +1,604 @@ + + + resetDeployForm()} +> +
+
+

Deploy Container

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {#if volumes.length > 0} +
+ + +

Available: {volumes.map(v => v.name).join(", ")}

+
+ {/if} +
+ + +
+ {#if deployError} +

{deployError}

+ {/if} +
+ + +
+
+
+ + { volumeError = null; }} +> +
+
+

Create Volume

+ +
+
+
+ + +
+
+ + +
+
+ {#if volumeError} +

{volumeError}

+ {/if} +
+ + +
+
+
+ +
+ + / + + / + {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 error} +

{error}

+ {/if} + +
+
+
+ {#each [["all", `All ${allResources.length}`], ["container", `Container ${workloads.length}`], ["volume", `Volume ${volumes.length}`]] as [tab, label]} + + {/each} +
+
+ + +
+
+ + + + + + + + + + + + + + {#if filteredResources.length === 0} + + + + {:else} + {#each filteredResources as item (item.kind + item.data.id)} + {#if item.kind === "container"} + {@const w = item.data} + + + + + + + + + {:else} + {@const v = item.data} + + + + + + + + + {/if} + {/each} + {/if} + +
ResourceKindHost / SizeLoadStatus
+ {allResources.length === 0 ? "No resources yet. Deploy a container or create a volume." : "No resources match filter."} +
+
+
+ +
+
+

{w.name}

+

{w.image}

+
+
+
+ Container + + {w.assigned_agent_id ? w.assigned_agent_id.slice(0, 8) : "-"} + +
+ {w.cpu_millicores}m +
+
+
+
+
+ + {w.status} + + + +
+
+
+ +
+
+

{v.name}

+

{v.size_gb} GB · {v.pool}

+
+
+
+ Volume + + {v.size_gb} GB + +
+ {v.size_gb}G +
+
+
+
+
+ + {v.status} + + + +
+
+ {/if} +
diff --git a/app/src/routes/layout.css b/app/src/routes/layout.css index cf53eeb0..704e1533 100644 --- a/app/src/routes/layout.css +++ b/app/src/routes/layout.css @@ -136,4 +136,7 @@ html { @apply font-sans; } + dialog { + @apply bg-background text-foreground; + } } 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/agents.rs b/control-plane/api-gateway/src/routes/agents.rs index fbfa1474..c5f1c4d1 100644 --- a/control-plane/api-gateway/src/routes/agents.rs +++ b/control-plane/api-gateway/src/routes/agents.rs @@ -166,6 +166,8 @@ pub async fn register_agent( tags: ActiveValue::Set(registration.tags), capabilities: ActiveValue::Set(None), public_key_pem: ActiveValue::Set(None), + wg_public_key: ActiveValue::Set(None), + wg_endpoint: ActiveValue::Set(None), }; new_agent.insert(&state.db_conn).await.map_err(|e| { 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..559cf8d1 --- /dev/null +++ b/control-plane/api-gateway/src/routes/resource_groups.rs @@ -0,0 +1,491 @@ +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::{IntoResponse, Json, Response}, + routing::get, + Router, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::Utc; +use entity::{ + entities::{agents, networks, resource_groups, volumes, workloads}, + Agents, Networks, ResourceGroups, Volumes, Workloads, +}; +use ring::rand::{SecureRandom, SystemRandom}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, +}; +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 existing = ResourceGroups::find() + .filter(resource_groups::Column::InternalCidr.eq(&req.internal_cidr)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to check cidr uniqueness"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + if existing.is_some() { + return Err(( + StatusCode::CONFLICT, + Json(json!({ "error": format!("CIDR {} is already in use by another resource group", req.internal_cidr) })), + )); + } + + 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 async fn get_vpn_config( + 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" })), + ) + })?; + + let gateway_agent = Agents::find() + .filter(agents::Column::Status.eq("Online")) + .filter(agents::Column::WgPublicKey.is_not_null()) + .filter(agents::Column::WgEndpoint.is_not_null()) + .order_by_asc(agents::Column::RegisteredAt) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find gateway agent"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "no WireGuard-enabled agent online" })), + ) + })?; + + let server_pubkey = gateway_agent.wg_public_key.as_deref().unwrap_or(""); + let endpoint = gateway_agent.wg_endpoint.as_deref().unwrap_or(""); + + let client_private_key = generate_wg_key().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to generate keypair" })), + ) + })?; + + let dns = first_host_ip(&group.internal_cidr).unwrap_or_else(|| "1.1.1.1".to_string()); + + let config = format!( + "[Interface]\nPrivateKey = {client_private_key}\nAddress = {dns}/32\nDNS = {dns}\n\n[Peer]\nPublicKey = {server_pubkey}\nEndpoint = {endpoint}\nAllowedIPs = {cidr}\nPersistentKeepalive = 25\n", + client_private_key = client_private_key, + dns = dns, + server_pubkey = server_pubkey, + endpoint = endpoint, + cidr = group.internal_cidr, + ); + + let filename = format!("csfx-{}.conf", group.name.to_lowercase().replace(' ', "-")); + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/plain; charset=utf-8"), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{}\"", filename), + ), + ], + config, + ) + .into_response()) +} + +fn generate_wg_key() -> Result { + let rng = SystemRandom::new(); + let mut key_bytes = [0u8; 32]; + rng.fill(&mut key_bytes)?; + key_bytes[0] &= 248; + key_bytes[31] &= 127; + key_bytes[31] |= 64; + Ok(B64.encode(key_bytes)) +} + +fn first_host_ip(cidr: &str) -> Option { + let parts: Vec<&str> = cidr.split('/').collect(); + if parts.len() != 2 { + return None; + } + let octets: Vec = parts[0] + .split('.') + .filter_map(|o| o.parse().ok()) + .collect(); + if octets.len() != 4 { + return None; + } + let n = u32::from_be_bytes([octets[0], octets[1], octets[2], octets[3]]); + let host = n + 1; + let [a, b, c, d] = host.to_be_bytes(); + Some(format!("{}.{}.{}.{}", a, b, c, d)) +} + +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), + ) + .route("/resource-groups/{id}/vpn-config", get(get_vpn_config)) +} diff --git a/control-plane/api-gateway/src/self_monitor.rs b/control-plane/api-gateway/src/self_monitor.rs index 9c9604d7..c0d7ace0 100644 --- a/control-plane/api-gateway/src/self_monitor.rs +++ b/control-plane/api-gateway/src/self_monitor.rs @@ -90,6 +90,8 @@ impl SelfMonitor { "self-monitor".to_string(), )]))), public_key_pem: ActiveValue::Set(None), + wg_public_key: ActiveValue::Set(None), + wg_endpoint: ActiveValue::Set(None), }; let agent = new_agent.insert(db_conn.as_ref()).await?; diff --git a/control-plane/registry/src/db/agents.rs b/control-plane/registry/src/db/agents.rs index c23ce15b..263848a4 100644 --- a/control-plane/registry/src/db/agents.rs +++ b/control-plane/registry/src/db/agents.rs @@ -78,6 +78,8 @@ pub async fn create( tags: Set(tags), capabilities: Set(capabilities), public_key_pem: Set(public_key_pem), + wg_public_key: Set(None), + wg_endpoint: Set(None), }; Ok(model.insert(db).await?) @@ -95,6 +97,8 @@ pub async fn update_heartbeat( db: &DatabaseConnection, agent_id: Uuid, status: String, + wg_public_key: Option, + wg_endpoint: Option, ) -> Result<()> { let mut agent: agents::ActiveModel = agents::Entity::find_by_id(agent_id) .one(db) @@ -105,6 +109,12 @@ pub async fn update_heartbeat( agent.last_heartbeat = Set(Some(chrono::Utc::now().naive_utc())); agent.status = Set(status); agent.updated_at = Set(Some(chrono::Utc::now().naive_utc())); + if wg_public_key.is_some() { + agent.wg_public_key = Set(wg_public_key); + } + if wg_endpoint.is_some() { + agent.wg_endpoint = Set(wg_endpoint); + } agent.update(db).await?; Ok(()) diff --git a/control-plane/registry/src/handlers/agent.rs b/control-plane/registry/src/handlers/agent.rs index c6992bd7..eb8f8a9a 100644 --- a/control-plane/registry/src/handlers/agent.rs +++ b/control-plane/registry/src/handlers/agent.rs @@ -208,7 +208,11 @@ pub async fn heartbeat( } } - match state.agent_registry.update_heartbeat(agent_id).await { + match state + .agent_registry + .update_heartbeat(agent_id, request.wg_public_key.clone(), request.wg_endpoint.clone()) + .await + { Ok(_) => { forward_metrics(&state, agent_id, &request).await; diff --git a/control-plane/registry/src/models/agent.rs b/control-plane/registry/src/models/agent.rs index e38537be..ac65f3b5 100644 --- a/control-plane/registry/src/models/agent.rs +++ b/control-plane/registry/src/models/agent.rs @@ -128,6 +128,8 @@ pub struct HeartbeatRequest { pub network_rx_bytes: Option, pub network_tx_bytes: Option, pub uptime_seconds: Option, + pub wg_public_key: Option, + pub wg_endpoint: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/control-plane/registry/src/services/registry.rs b/control-plane/registry/src/services/registry.rs index 3df0ec69..73fa39a8 100644 --- a/control-plane/registry/src/services/registry.rs +++ b/control-plane/registry/src/services/registry.rs @@ -209,10 +209,21 @@ impl AgentRegistry { Ok((agent, false)) } - pub async fn update_heartbeat(&self, agent_id: Uuid) -> Result<(), String> { - crate::db::agents::update_heartbeat(&self.db, agent_id, "Online".to_string()) - .await - .map_err(|e| format!("Failed to update heartbeat: {}", e))?; + pub async fn update_heartbeat( + &self, + agent_id: Uuid, + wg_public_key: Option, + wg_endpoint: Option, + ) -> Result<(), String> { + crate::db::agents::update_heartbeat( + &self.db, + agent_id, + "Online".to_string(), + wg_public_key, + wg_endpoint, + ) + .await + .map_err(|e| format!("Failed to update heartbeat: {}", e))?; crate::log_debug!( "agent_registry", diff --git a/control-plane/scheduler/src/db/agents.rs b/control-plane/scheduler/src/db/agents.rs index fba9b0f7..f29eb4a4 100644 --- a/control-plane/scheduler/src/db/agents.rs +++ b/control-plane/scheduler/src/db/agents.rs @@ -1,4 +1,4 @@ -use entity::entities::{agent_metrics, agents}; +use entity::entities::{agent_metrics, agents, volumes}; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; use uuid::Uuid; @@ -69,3 +69,11 @@ pub async fn get_assigned_workload_resources( Ok((cpu, mem, disk)) } + +pub async fn get_volume_agent( + db: &DatabaseConnection, + volume_id: Uuid, +) -> Result, sea_orm::DbErr> { + let vol = volumes::Entity::find_by_id(volume_id).one(db).await?; + Ok(vol.and_then(|v| v.attached_to_agent)) +} diff --git a/control-plane/scheduler/src/db/workloads.rs b/control-plane/scheduler/src/db/workloads.rs index 9b8c6218..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,11 +31,13 @@ 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), 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), }; @@ -93,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, @@ -103,6 +114,8 @@ 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 6ab22942..676d178a 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,13 +50,15 @@ pub struct CreateWorkloadRequest { pub disk_bytes: i64, pub env_vars: Option>, pub ports: Option>, + pub volume_mounts: Option>, + pub resource_group_id: Option, } #[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, Serialize, Deserialize)] @@ -72,6 +80,8 @@ 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/scheduler/src/services/scheduler.rs b/control-plane/scheduler/src/services/scheduler.rs index 1835c5fe..8ae77ebc 100644 --- a/control-plane/scheduler/src/services/scheduler.rs +++ b/control-plane/scheduler/src/services/scheduler.rs @@ -43,6 +43,12 @@ impl SchedulerService { agent.free_disk_bytes -= reserved_disk; } + let volume_pinned_agent = self.resolve_volume_affinity(&req).await?; + + if let Some(required_agent) = volume_pinned_agent { + agents.retain(|a| a.agent_id == required_agent); + } + match self.first_fit(&req, &agents) { Some(agent_id) => { crate::db::workloads::assign(&self.db, workload.id, agent_id) @@ -92,6 +98,40 @@ impl SchedulerService { } } + async fn resolve_volume_affinity( + &self, + req: &CreateWorkloadRequest, + ) -> Result, String> { + let mounts = match &req.volume_mounts { + Some(m) if !m.is_empty() => m, + _ => return Ok(None), + }; + + let mut pinned: Option = None; + + for mount in mounts { + let agent_id = + crate::db::agents::get_volume_agent(&self.db, mount.volume_id) + .await + .map_err(|e| format!("Failed to check volume affinity: {}", e))?; + + if let Some(aid) = agent_id { + match pinned { + None => pinned = Some(aid), + Some(existing) if existing != aid => { + return Err(format!( + "Volume mounts require conflicting agents: {} vs {}", + existing, aid + )); + } + _ => {} + } + } + } + + Ok(pinned) + } + fn first_fit(&self, req: &CreateWorkloadRequest, agents: &[AgentResources]) -> Option { let mut sorted: Vec<&AgentResources> = agents.iter().collect(); sorted.sort_by(|a, b| { 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/agents.rs b/control-plane/shared/entity/src/entities/agents.rs index fe64aeb9..b26103df 100644 --- a/control-plane/shared/entity/src/entities/agents.rs +++ b/control-plane/shared/entity/src/entities/agents.rs @@ -21,6 +21,8 @@ pub struct Model { pub tags: Option, pub capabilities: Option, pub public_key_pem: Option, + pub wg_public_key: Option, + pub wg_endpoint: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 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..b4c662d9 100644 --- a/control-plane/shared/entity/src/entities/workloads.rs +++ b/control-plane/shared/entity/src/entities/workloads.rs @@ -13,11 +13,13 @@ 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, pub created_by: Option, pub organization_id: Option, + pub resource_group_id: Option, pub created_at: DateTime, pub updated_at: Option, } @@ -32,6 +34,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 +50,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..4365d679 100644 --- a/control-plane/shared/migration/src/lib.rs +++ b/control-plane/shared/migration/src/lib.rs @@ -18,6 +18,9 @@ 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; +mod m20260628_000000_add_volume_mounts; +mod m20260628_100000_rg_cidr_unique_agent_wg; pub struct Migrator; @@ -43,6 +46,9 @@ 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), + Box::new(m20260628_000000_add_volume_mounts::Migration), + Box::new(m20260628_100000_rg_cidr_unique_agent_wg::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/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 + } +} diff --git a/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs b/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs new file mode 100644 index 00000000..e1f0eb75 --- /dev/null +++ b/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs @@ -0,0 +1,63 @@ +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_index( + Index::create() + .name("idx_resource_groups_cidr_unique") + .table(Alias::new("resource_groups")) + .col(Alias::new("internal_cidr")) + .unique() + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("wg_public_key")) + .text() + .null(), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("wg_endpoint")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_resource_groups_cidr_unique") + .table(Alias::new("resource_groups")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .drop_column(Alias::new("wg_public_key")) + .drop_column(Alias::new("wg_endpoint")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} 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>, } 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: