diff --git a/Cargo.lock b/Cargo.lock
index 2bb17b2..25648a0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -320,6 +320,45 @@ dependencies = [
"regex-syntax",
]
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "async-channel"
version = "1.9.0"
@@ -1257,6 +1296,20 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom 7.1.3",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1812,16 +1865,23 @@ dependencies = [
"grass-node-protocol",
"grass-validator",
"hex",
+ "hyper-util",
"mime_guess",
+ "rcgen",
"reqwest 0.12.28",
+ "rustls",
+ "rustls-pemfile",
"serde",
"serde_json",
"sha2",
"subtle",
"sysinfo",
"tar",
+ "tempfile",
"thiserror",
+ "time",
"tokio",
+ "tokio-rustls",
"tokio-tungstenite 0.26.2",
"tokio-util",
"toml 0.8.23",
@@ -1830,6 +1890,7 @@ dependencies = [
"url",
"uuid",
"walkdir",
+ "x509-parser",
]
[[package]]
@@ -2512,7 +2573,7 @@ dependencies = [
"httpdate",
"idna",
"mime",
- "nom",
+ "nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
@@ -2737,6 +2798,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2813,6 +2880,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "nom"
version = "8.0.0"
@@ -2972,6 +3049,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3500,6 +3586,20 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "rcgen"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
[[package]]
name = "redis"
version = "0.31.0"
@@ -3807,6 +3907,15 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom 7.1.3",
+]
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3848,6 +3957,15 @@ dependencies = [
"security-framework",
]
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -4769,6 +4887,19 @@ dependencies = [
"xattr",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -6037,6 +6168,24 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom 7.1.3",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror",
+ "time",
+]
+
[[package]]
name = "xattr"
version = "1.6.1"
@@ -6053,6 +6202,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+[[package]]
+name = "yasna"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
+dependencies = [
+ "time",
+]
+
[[package]]
name = "yoke"
version = "0.8.2"
diff --git a/Cargo.toml b/Cargo.toml
index 4e54e23..91ff43c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ bytes = "1"
clap = { version = "4.5.50", features = ["derive", "env"] }
config = { version = "0.15.19", default-features = false, features = ["toml"] }
hex = "0.4"
+hyper-util = { version = "0.1", features = ["server-auto", "service", "tokio"] }
image = { version = "0.25", default-features = false, features = ["png"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
jsonwebtoken = "9"
@@ -42,6 +43,9 @@ rand = "0.8"
redis = { version = "0.31.0", default-features = false, features = ["tokio-comp", "aio", "script"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
ring = "0.17"
+rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
+rustls-pemfile = "2"
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tokio-tungstenite = { version = "0.26", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
rust-embed = "8"
sea-orm = { version = "2.0.0-rc.38", default-features = false, features = ["macros", "sqlx-postgres", "runtime-tokio-rustls", "with-json", "with-time", "with-uuid"] }
@@ -62,6 +66,7 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt", "jso
url = "2.5.4"
uuid = { version = "1.18.1", features = ["serde", "v7"] }
walkdir = "2"
+x509-parser = "0.18"
webp = "0.3"
zip = { version = "2", default-features = false, features = ["deflate"] }
bollard = "0.19"
diff --git a/apps/console/src/features/admin/admin-route.test.tsx b/apps/console/src/features/admin/admin-route.test.tsx
index 8603cc6..bc378d1 100644
--- a/apps/console/src/features/admin/admin-route.test.tsx
+++ b/apps/console/src/features/admin/admin-route.test.tsx
@@ -77,6 +77,7 @@ it("lists nodes with health state for administrators", async () => {
{
id: "node-1",
name: "build-node-1",
+ region: "default",
status: "active",
healthy: true,
build_enabled: true,
diff --git a/apps/console/src/features/admin/admin.api.ts b/apps/console/src/features/admin/admin.api.ts
index 0994b35..2f3ee44 100644
--- a/apps/console/src/features/admin/admin.api.ts
+++ b/apps/console/src/features/admin/admin.api.ts
@@ -18,6 +18,7 @@ export type NodeStatus = "pending" | "active" | "draining" | "offline" | "disabl
export interface AdminNode {
id: string;
name: string;
+ region: string;
status: NodeStatus;
healthy: boolean;
build_enabled: boolean;
@@ -85,6 +86,7 @@ export interface AdminNodeConfigurationSync {
export interface NodeConfiguration {
node: {
id: string;
+ region: string;
control_api: string;
work_root: string;
capabilities: { build: boolean; serve: boolean };
@@ -716,7 +718,7 @@ export const adminApi = {
listNodes: () =>
request<{ nodes: AdminNode[]; local_process: AdminLocalProcessInfo }>("/api/v1/admin/nodes"),
- createNode: (input: { name: string; start_local?: boolean }) =>
+ createNode: (input: { name: string; region?: string; start_local?: boolean }) =>
request<{
node: AdminNode;
token: string;
diff --git a/apps/console/src/features/admin/components/nodes-panel.test.tsx b/apps/console/src/features/admin/components/nodes-panel.test.tsx
index 07648b9..4b60d17 100644
--- a/apps/console/src/features/admin/components/nodes-panel.test.tsx
+++ b/apps/console/src/features/admin/components/nodes-panel.test.tsx
@@ -15,6 +15,7 @@ function jsonResponse(data: unknown): Response {
const configuration = {
node: {
id: "serve-node-1",
+ region: "default",
control_api: "http://127.0.0.1:7817",
work_root: "/data/node",
capabilities: { build: true, serve: true },
@@ -54,6 +55,7 @@ const configuration = {
const nodeFixture = {
id: "node-1",
name: "serve-node-1",
+ region: "default",
status: "active",
healthy: true,
build_enabled: false,
diff --git a/apps/console/src/features/admin/components/nodes-panel.tsx b/apps/console/src/features/admin/components/nodes-panel.tsx
index d8a662e..b7c4016 100644
--- a/apps/console/src/features/admin/components/nodes-panel.tsx
+++ b/apps/console/src/features/admin/components/nodes-panel.tsx
@@ -336,6 +336,7 @@ export function NodesPanel() {
Name
+ Region
Health
Capabilities
Serve load
@@ -374,6 +375,9 @@ export function NodesPanel() {
)}
+
@@ -770,6 +774,16 @@ function EditConfigurationDialog({ node }: { node: AdminNode }) {
)
}
/>
+
+ setConfiguration((current) =>
+ current ? { ...current, node: { ...current.node, region } } : current,
+ )
+ }
+ />
adminApi.createNode({ name, start_local: startLocal }),
+ mutationFn: () => adminApi.createNode({ name, region, start_local: startLocal }),
onSuccess: ({ token, warnings }) => {
setOpen(false);
setName("");
+ setRegion("default");
onCreated(token, warnings ?? []);
},
});
@@ -1479,6 +1495,16 @@ function CreateNodeDialog({
required
/>
+
+ Region
+ setRegion(event.target.value)}
+ required
+ />
+
- {deployment.serve_node?.name ?? "Unassigned"}
+ {deployment.serve_node?.name ?? "Unassigned"}
+ · {deployment.region}
{deployment.serve_resources.cpu_millicores}m ·{" "}
diff --git a/apps/console/src/features/deployments/deployments.api.ts b/apps/console/src/features/deployments/deployments.api.ts
index bc97577..66d52cd 100644
--- a/apps/console/src/features/deployments/deployments.api.ts
+++ b/apps/console/src/features/deployments/deployments.api.ts
@@ -36,6 +36,7 @@ export interface NodeUsage extends ServeResources {
export interface ServeNodeTarget {
id: string;
name: string;
+ region: string;
healthy: boolean;
capacity: NodeResources;
usage: NodeUsage;
@@ -50,6 +51,7 @@ export interface Deployment {
id: string;
project_id: string;
team_id: string;
+ region: string;
build_node: NodeRef | null;
serve_node: NodeRef | null;
environment: DeploymentEnvironment;
@@ -142,15 +144,24 @@ export const deploymentsApi = {
create: (
projectId: string,
- input: { environment: DeploymentEnvironment; branch?: string; serve_node_id?: string },
+ input: {
+ environment: DeploymentEnvironment;
+ branch?: string;
+ serve_node_id?: string;
+ region?: string;
+ },
) =>
request<{ deployment: Deployment }>(`/api/v1/projects/${projectId}/deployments`, {
method: "POST",
body: JSON.stringify(input),
}),
- serveNodes: (projectId: string) =>
- request<{ serve_nodes: ServeNodeTarget[] }>(`/api/v1/projects/${projectId}/serve-nodes`),
+ serveNodes: (projectId: string, region?: string) => {
+ const query = region ? `?region=${encodeURIComponent(region)}` : "";
+ return request<{ serve_nodes: ServeNodeTarget[] }>(
+ `/api/v1/projects/${projectId}/serve-nodes${query}`,
+ );
+ },
detail: (projectId: string, deploymentId: string) =>
request(`/api/v1/projects/${projectId}/deployments/${deploymentId}`),
diff --git a/apps/control-api/src/domain/delivery.rs b/apps/control-api/src/domain/delivery.rs
index 0145c83..cebf780 100644
--- a/apps/control-api/src/domain/delivery.rs
+++ b/apps/control-api/src/domain/delivery.rs
@@ -386,7 +386,15 @@ pub async fn request_release(
let placement = if target.serve_node_id.is_none()
|| matches!(target.serve_status, DeploymentServeStatus::Retired)
{
- Some(scheduler::place_deployment(tx, serve_resources(&target)?, None).await?)
+ Some(
+ scheduler::place_deployment_in_region(
+ tx,
+ serve_resources(&target)?,
+ None,
+ Some(&target.region),
+ )
+ .await?,
+ )
} else {
None
};
@@ -591,6 +599,7 @@ mod tests {
},
Placement {
node_id,
+ region: "default".to_owned(),
overcommitted: false,
mode: PlacementMode::Automatic,
},
@@ -672,6 +681,7 @@ mod tests {
let node = node::ActiveModel {
id: Set(Uuid::now_v7()),
name: Set("serve-1".to_owned()),
+ region: Set("default".to_owned()),
token_hash: Set("test-token-hash".to_owned()),
status: Set(NodeStatus::Active),
build_enabled: Set(true),
diff --git a/apps/control-api/src/domain/deployments.rs b/apps/control-api/src/domain/deployments.rs
index 1d33504..27c818d 100644
--- a/apps/control-api/src/domain/deployments.rs
+++ b/apps/control-api/src/domain/deployments.rs
@@ -231,6 +231,7 @@ pub async fn create_deployment(
id: Set(Uuid::now_v7()),
project_id: Set(project.id),
team_id: Set(project.team_id),
+ region: Set(placement.region.clone()),
build_node_id: Set(None),
serve_node_id: Set(Some(placement.node_id)),
environment: Set(params.environment),
diff --git a/apps/control-api/src/domain/node_deletions.rs b/apps/control-api/src/domain/node_deletions.rs
index d4c4077..7467ff1 100644
--- a/apps/control-api/src/domain/node_deletions.rs
+++ b/apps/control-api/src/domain/node_deletions.rs
@@ -839,6 +839,7 @@ mod tests {
node::ActiveModel {
id: Set(Uuid::now_v7()),
name: Set(name.to_owned()),
+ region: Set("default".to_owned()),
token_hash: Set(format!("token-{name}")),
status: Set(NodeStatus::Active),
build_enabled: Set(true),
@@ -891,6 +892,7 @@ mod tests {
},
Placement {
node_id: source_node_id,
+ region: "default".to_owned(),
overcommitted: false,
mode: PlacementMode::Automatic,
},
@@ -1339,6 +1341,7 @@ mod tests {
},
Placement {
node_id: fixture.source.id,
+ region: "default".to_owned(),
overcommitted: false,
mode: PlacementMode::Automatic,
},
diff --git a/apps/control-api/src/domain/nodes.rs b/apps/control-api/src/domain/nodes.rs
index b4898cb..30432a0 100644
--- a/apps/control-api/src/domain/nodes.rs
+++ b/apps/control-api/src/domain/nodes.rs
@@ -17,6 +17,7 @@ pub fn gateway_token(secret: &str) -> String {
pub struct CreateNodeParams {
pub name: String,
+ pub region: String,
pub token_hash: String,
pub storage_root: Option,
}
@@ -37,6 +38,7 @@ pub async fn create_node(
node::ActiveModel {
id: Set(Uuid::now_v7()),
name: Set(params.name.clone()),
+ region: Set(params.region),
token_hash: Set(params.token_hash),
status: Set(NodeStatus::Pending),
build_enabled: Set(true),
@@ -145,6 +147,7 @@ pub async fn list(db: &C) -> anyhow::Result
pub struct RegisterNodeParams {
pub name: String,
+ pub region: String,
pub version: String,
pub build_enabled: bool,
pub serve_enabled: bool,
@@ -282,6 +285,7 @@ pub async fn apply_registration(
let initialize_deployments = first_resource_report;
let mut active: node::ActiveModel = node.into();
active.name = Set(params.name);
+ active.region = Set(params.region);
active.build_enabled = Set(params.build_enabled);
active.serve_enabled = Set(params.serve_enabled);
active.build_concurrency = Set(if params.build_enabled {
@@ -498,6 +502,7 @@ mod tests {
node::Model {
id: Uuid::nil(),
name: "test".to_owned(),
+ region: "default".to_owned(),
token_hash: String::new(),
status,
build_enabled: true,
diff --git a/apps/control-api/src/domain/retention.rs b/apps/control-api/src/domain/retention.rs
index 7026b5e..159c5b9 100644
--- a/apps/control-api/src/domain/retention.rs
+++ b/apps/control-api/src/domain/retention.rs
@@ -270,6 +270,7 @@ mod tests {
id: Uuid::now_v7(),
project_id: Uuid::now_v7(),
team_id: Uuid::now_v7(),
+ region: "default".to_owned(),
build_node_id: None,
serve_node_id: None,
environment,
diff --git a/apps/control-api/src/domain/scheduler.rs b/apps/control-api/src/domain/scheduler.rs
index f024f55..0c54278 100644
--- a/apps/control-api/src/domain/scheduler.rs
+++ b/apps/control-api/src/domain/scheduler.rs
@@ -27,6 +27,7 @@ GROUP BY d.serve_node_id
const ELIGIBLE_CANDIDATES_SQL: &str = r#"
SELECT
n.id AS node_id,
+ n.region,
n.capacity_cpu_millicores,
n.capacity_memory_mb,
n.capacity_disk_mb,
@@ -83,6 +84,7 @@ pub struct NodeUsage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub node_id: Uuid,
+ pub region: String,
pub capacity: NodeResources,
pub usage: NodeUsage,
}
@@ -93,9 +95,10 @@ pub enum PlacementMode {
Manual,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placement {
pub node_id: Uuid,
+ pub region: String,
pub overcommitted: bool,
pub mode: PlacementMode,
}
@@ -104,6 +107,8 @@ pub struct Placement {
pub enum ScheduleError {
#[error("no serve node has enough capacity")]
NoCapacity,
+ #[error("no healthy serve node is available in region '{0}'")]
+ NoCapacityInRegion(String),
#[error("selected serve node is unavailable")]
SelectedNodeUnavailable,
#[error("selected serve node has no remaining capacity")]
@@ -194,6 +199,29 @@ pub fn choose_candidate(
)
}
+pub fn choose_candidate_in_region(
+ candidates: &[Candidate],
+ requested: ServeResources,
+ region: Option<&str>,
+ selected_node_id: Option,
+) -> Result {
+ let filtered = region
+ .map(|region| {
+ candidates
+ .iter()
+ .filter(|candidate| candidate.region == region)
+ .cloned()
+ .collect::>()
+ })
+ .unwrap_or_else(|| candidates.to_vec());
+ if let Some(region) = region
+ && filtered.is_empty()
+ {
+ return Err(ScheduleError::NoCapacityInRegion(region.to_owned()));
+ }
+ choose_candidate(&filtered, requested, selected_node_id)
+}
+
pub fn choose_candidate_with_rng(
candidates: &[Candidate],
requested: ServeResources,
@@ -212,6 +240,7 @@ pub fn choose_candidate_with_rng(
}
return Ok(Placement {
node_id: candidate.node_id,
+ region: candidate.region.clone(),
overcommitted: !candidate.projected(requested).all_at_most_one(),
mode: PlacementMode::Manual,
});
@@ -229,6 +258,7 @@ pub fn choose_candidate_with_rng(
) {
return Ok(Placement {
node_id: candidate.node_id,
+ region: candidate.region.clone(),
overcommitted: false,
mode: PlacementMode::Automatic,
});
@@ -241,6 +271,7 @@ pub fn choose_candidate_with_rng(
)
.map(|candidate| Placement {
node_id: candidate.node_id,
+ region: candidate.region.clone(),
overcommitted: true,
mode: PlacementMode::Automatic,
})
@@ -273,14 +304,15 @@ where
tied.choose(rng).copied()
}
-pub async fn place_deployment(
+pub async fn place_deployment_in_region(
transaction: &DatabaseTransaction,
requested: ServeResources,
selected_node_id: Option,
+ region: Option<&str>,
) -> Result {
lock_placement(transaction).await?;
let candidates = eligible_candidates(transaction).await?;
- choose_candidate(&candidates, requested, selected_node_id)
+ choose_candidate_in_region(&candidates, requested, region, selected_node_id)
}
/// Serializes operations that can change whether a Serve Node has room for
@@ -328,6 +360,7 @@ pub async fn eligible_candidates(
#[derive(Debug, FromQueryResult)]
struct CandidateRow {
node_id: Uuid,
+ region: String,
capacity_cpu_millicores: i64,
capacity_memory_mb: i64,
capacity_disk_mb: i64,
@@ -378,6 +411,7 @@ impl TryFrom for Candidate {
fn try_from(row: CandidateRow) -> Result {
Ok(Self {
node_id: row.node_id,
+ region: row.region,
capacity: NodeResources {
cpu_millicores: row
.capacity_cpu_millicores
@@ -448,6 +482,7 @@ mod tests {
) -> Candidate {
Candidate {
node_id: Uuid::from_u128(id),
+ region: "default".to_owned(),
capacity: NodeResources {
cpu_millicores: capacity.0,
memory_mb: capacity.1,
@@ -483,6 +518,25 @@ mod tests {
assert_eq!(placement.mode, PlacementMode::Automatic);
}
+ #[test]
+ fn regional_candidates_are_limited_to_the_requested_region() {
+ let candidates = vec![
+ candidate_in_region(1, "us-east"),
+ candidate_in_region(2, "eu-west"),
+ ];
+
+ let placement =
+ choose_candidate_in_region(&candidates, request(), Some("eu-west"), None).unwrap();
+
+ assert_eq!(placement.node_id, Uuid::from_u128(2));
+ }
+
+ fn candidate_in_region(id: u128, region: &str) -> Candidate {
+ let mut candidate = candidate(id, (2_000, 2_000, 10_000, 10), (0, 0, 0, 0));
+ candidate.region = region.to_owned();
+ candidate
+ }
+
#[test]
fn selection_uses_projected_dominant_resource() {
let lower_current = candidate(1, (1_000, 1_000, 10_000, 10), (0, 0, 0, 0));
diff --git a/apps/control-api/src/domain/storage_migrations.rs b/apps/control-api/src/domain/storage_migrations.rs
index 5acc142..002323d 100644
--- a/apps/control-api/src/domain/storage_migrations.rs
+++ b/apps/control-api/src/domain/storage_migrations.rs
@@ -477,6 +477,7 @@ mod tests {
std::env::temp_dir().join(format!("grass-migration-node-{}.toml", Uuid::now_v7()));
let node_config = GenerateParams {
node_name: "local-node",
+ region: "default",
node_token: "node-token",
control_api_url: "http://127.0.0.1:7817".to_owned(),
storage_root: source_root.to_str().unwrap(),
diff --git a/apps/control-api/src/features/api/v1/admin/nodes.rs b/apps/control-api/src/features/api/v1/admin/nodes.rs
index 34ab006..c6bac79 100644
--- a/apps/control-api/src/features/api/v1/admin/nodes.rs
+++ b/apps/control-api/src/features/api/v1/admin/nodes.rs
@@ -76,6 +76,7 @@ fn node_view(
"build_enabled": node.build_enabled,
"serve_enabled": node.serve_enabled,
"build_concurrency": node.build_concurrency,
+ "region": node.region,
"base_url": node.base_url,
"work_root": node.work_root,
"version": node.metadata.get("version"),
@@ -328,6 +329,9 @@ fn validate_node_configuration(
if identity.id.trim().is_empty() || identity.id.chars().count() > 120 {
return Err("node id must contain between 1 and 120 characters".to_owned());
}
+ if grass_validator::normalize_region(&identity.region).is_err() {
+ return Err("node region is invalid".to_owned());
+ }
if !validate_http_url(identity.control_api.trim()) {
return Err("control API must be an absolute HTTP(S) URL without credentials".to_owned());
}
@@ -351,6 +355,9 @@ fn validate_node_configuration(
if serve.port == 0 {
return Err("serve port must be greater than zero".to_owned());
}
+ if serve.tls.enabled && (serve.tls.port == 0 || serve.tls.port == serve.port) {
+ return Err("serve TLS port must be positive and different from the HTTP port".to_owned());
+ }
if !validate_http_url(serve.public_base_url.trim()) {
return Err("serve public base URL must be an absolute HTTP(S) URL".to_owned());
}
@@ -699,6 +706,8 @@ pub async fn health(
#[derive(Deserialize)]
pub struct CreateNodeRequest {
pub name: String,
+ #[serde(default)]
+ pub region: Option,
/// Generate the local node config and start the managed process.
#[serde(default)]
pub start_local: bool,
@@ -722,11 +731,19 @@ pub async fn create(
});
}
+ let region = body.region.as_deref().unwrap_or("default");
+ let region =
+ grass_validator::normalize_region(region).map_err(|error| AppError::Validation {
+ op: OP,
+ message: format!("region: {error}"),
+ })?;
+
let token = grass_token::generate_token();
let node = nodes::create_node(
db,
CreateNodeParams {
name: body.name.trim().to_owned(),
+ region: region.clone(),
token_hash: grass_token::hash_token(&token),
storage_root: None,
},
@@ -771,6 +788,7 @@ pub async fn create(
&config_path,
&config_file::GenerateParams {
node_name: &node.name,
+ region: ®ion,
node_token: &token,
control_api_url,
storage_root: &storage_root,
@@ -1002,6 +1020,7 @@ mod tests {
node::Model {
id: Uuid::nil(),
name: "serve-node-1".to_owned(),
+ region: "default".to_owned(),
token_hash: String::new(),
status: NodeStatus::Active,
build_enabled: false,
@@ -1072,6 +1091,20 @@ mod tests {
let mut configuration = configurable_node();
assert!(validate_node_configuration(&configuration).is_ok());
+ configuration.serve.tls.enabled = true;
+ assert!(validate_node_configuration(&configuration).is_ok());
+ configuration.serve.tls.port = 0;
+ assert_eq!(
+ validate_node_configuration(&configuration).unwrap_err(),
+ "serve TLS port must be positive and different from the HTTP port"
+ );
+ configuration.serve.tls.port = configuration.serve.port;
+ assert_eq!(
+ validate_node_configuration(&configuration).unwrap_err(),
+ "serve TLS port must be positive and different from the HTTP port"
+ );
+ configuration = configurable_node();
+
configuration.node.capabilities.build = false;
configuration.node.capabilities.serve = false;
assert_eq!(
diff --git a/apps/control-api/src/features/api/v1/internal/deployments.rs b/apps/control-api/src/features/api/v1/internal/deployments.rs
index 0df7888..5a51f3d 100644
--- a/apps/control-api/src/features/api/v1/internal/deployments.rs
+++ b/apps/control-api/src/features/api/v1/internal/deployments.rs
@@ -1479,6 +1479,7 @@ mod tests {
id: Uuid::now_v7(),
project_id,
team_id,
+ region: "default".to_owned(),
build_node_id: None,
serve_node_id: None,
environment: DeploymentEnvironment::Preview,
@@ -1580,6 +1581,7 @@ mod tests {
let authenticated_node = node::Model {
id: node_id,
name: "build-1".to_owned(),
+ region: "default".to_owned(),
token_hash: "unused".to_owned(),
status: NodeStatus::Active,
build_enabled: true,
diff --git a/apps/control-api/src/features/api/v1/internal/nodes.rs b/apps/control-api/src/features/api/v1/internal/nodes.rs
index ee8e3d2..9a11312 100644
--- a/apps/control-api/src/features/api/v1/internal/nodes.rs
+++ b/apps/control-api/src/features/api/v1/internal/nodes.rs
@@ -44,6 +44,12 @@ pub async fn register(
node,
RegisterNodeParams {
name,
+ region: grass_validator::normalize_region(&body.region).map_err(|error| {
+ AppError::Validation {
+ op: OP,
+ message: format!("region: {error}"),
+ }
+ })?,
version: body.version,
build_enabled: body.capabilities.build,
serve_enabled: body.capabilities.serve,
@@ -88,6 +94,9 @@ pub async fn register(
}
fn validate_registration(body: &RegisterRequest) -> Result<(), &'static str> {
+ if grass_validator::normalize_region(&body.region).is_err() {
+ return Err("region is invalid");
+ }
if !body.capabilities.build && !body.capabilities.serve {
return Err("node must enable build or serve");
}
@@ -159,6 +168,7 @@ mod tests {
name: "node-a".to_owned(),
version: "0.1.0".to_owned(),
capabilities: NodeCapabilities { build, serve },
+ region: "default".to_owned(),
build_concurrency: u16::from(build),
serve_base_url: serve.then(|| "http://node-a:8080".to_owned()),
resources: serve.then_some(grass_node_protocol::NodeResources {
diff --git a/apps/control-api/src/features/api/v1/internal/serve.rs b/apps/control-api/src/features/api/v1/internal/serve.rs
index 175baf0..63fbb67 100644
--- a/apps/control-api/src/features/api/v1/internal/serve.rs
+++ b/apps/control-api/src/features/api/v1/internal/serve.rs
@@ -70,12 +70,14 @@ fn route_revision(routes: &[ServeRoute]) -> String {
canonical.sort_by(|left, right| {
(
&left.host,
+ &left.region,
left.deployment_id,
left.target_node_id,
&left.target_base_url,
)
.cmp(&(
&right.host,
+ &right.region,
right.deployment_id,
right.target_node_id,
&right.target_base_url,
@@ -622,6 +624,7 @@ pub async fn routes(
.into_iter()
.map(|(host, access)| ServeRoute {
host,
+ region: deployment.region.clone(),
deployment_id: deployment.id,
target_node_id: node_id,
target_base_url: target_base_url.clone(),
@@ -867,6 +870,7 @@ mod tests {
};
let first = ServeRoute {
host: "a.example.com".to_owned(),
+ region: "default".to_owned(),
deployment_id: Uuid::now_v7(),
target_node_id: Uuid::now_v7(),
target_base_url: "http://node-a:8080".to_owned(),
@@ -875,6 +879,7 @@ mod tests {
};
let second = ServeRoute {
host: "b.example.com".to_owned(),
+ region: "default".to_owned(),
deployment_id: Uuid::now_v7(),
target_node_id: Uuid::now_v7(),
target_base_url: "http://node-b:8080".to_owned(),
diff --git a/apps/control-api/src/features/api/v1/projects/deployments.rs b/apps/control-api/src/features/api/v1/projects/deployments.rs
index edca756..e8a37eb 100644
--- a/apps/control-api/src/features/api/v1/projects/deployments.rs
+++ b/apps/control-api/src/features/api/v1/projects/deployments.rs
@@ -194,6 +194,7 @@ pub(crate) fn deployment_view(
"id": deployment.id,
"project_id": deployment.project_id,
"team_id": deployment.team_id,
+ "region": deployment.region,
"build_node": node_view(deployment.build_node_id),
"serve_node": node_view(deployment.serve_node_id),
"environment": deployments::environment_value(&deployment.environment),
@@ -382,6 +383,14 @@ pub struct CreateDeploymentRequest {
pub commit_message: Option,
#[serde(default)]
pub serve_node_id: Option,
+ #[serde(default)]
+ pub region: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+pub struct ServeNodesQuery {
+ #[serde(default)]
+ pub region: Option,
}
fn default_environment() -> String {
@@ -430,6 +439,7 @@ async fn create_placed_deployment(
db: &sea_orm::DatabaseConnection,
params: CreateDeploymentParams,
selected_node_id: Option,
+ region: Option<&str>,
op: &'static str,
) -> Result {
let requested = deployments::runtime_serve_resources(¶ms.project.runtime);
@@ -440,15 +450,22 @@ async fn create_placed_deployment(
op,
source: source.into(),
})?;
- let placement =
- match scheduler::place_deployment(&transaction, requested, selected_node_id).await {
- Ok(placement) => placement,
- Err(error) => {
- let error = map_schedule_error(error, op);
- let _ = transaction.rollback().await;
- return Err(error);
- }
- };
+ let placement = match scheduler::place_deployment_in_region(
+ &transaction,
+ requested,
+ selected_node_id,
+ region,
+ )
+ .await
+ {
+ Ok(placement) => placement,
+ Err(error) => {
+ let error = map_schedule_error(error, op);
+ let _ = transaction.rollback().await;
+ return Err(error);
+ }
+ };
+ let placement_for_event = placement.clone();
let deployment = match deployments::create_deployment(&transaction, params, placement).await {
Ok(deployment) => deployment,
Err(source) => {
@@ -456,7 +473,7 @@ async fn create_placed_deployment(
return Err(AppError::Infrastructure { op, source });
}
};
- let mode = match placement.mode {
+ let mode = match placement_for_event.mode {
PlacementMode::Automatic => "automatic",
PlacementMode::Manual => "manual",
};
@@ -467,9 +484,9 @@ async fn create_placed_deployment(
"deployment assigned to serve node",
json!({
"mode": mode,
- "serve_node_id": placement.node_id,
+ "serve_node_id": placement_for_event.node_id,
"resources": requested,
- "overcommitted": placement.overcommitted,
+ "overcommitted": placement_for_event.overcommitted,
}),
)
.await
@@ -518,6 +535,15 @@ pub async fn create(
});
}
let environment = parse_environment(&body.environment, OP)?;
+ let requested_region = body
+ .region
+ .as_deref()
+ .map(grass_validator::normalize_region)
+ .transpose()
+ .map_err(|error| AppError::Validation {
+ op: OP,
+ message: format!("region: {error}"),
+ })?;
let quota = QuotaService::new(db, cache);
let reservation = quota
@@ -575,6 +601,7 @@ pub async fn create(
source_credential_version_id,
},
body.serve_node_id,
+ requested_region.as_deref(),
OP,
)
.await
@@ -790,11 +817,21 @@ pub async fn serve_nodes(
State(state): State,
session: Session,
Path(project_id): Path,
+ Query(query): Query,
) -> Result {
const OP: &str = "deployments.serve_nodes";
let access = super::project_access(&state, &session, project_id, false, OP).await?;
let db = super::database(&state, OP)?;
let requested = deployments::runtime_serve_resources(&access.project.runtime);
+ let requested_region = query
+ .region
+ .as_deref()
+ .map(grass_validator::normalize_region)
+ .transpose()
+ .map_err(|error| AppError::Validation {
+ op: OP,
+ message: format!("region: {error}"),
+ })?;
let candidates = scheduler::eligible_candidates(db)
.await
.map_err(|error| map_schedule_error(error, OP))?;
@@ -821,6 +858,12 @@ pub async fn serve_nodes(
let views = candidates
.iter()
.filter_map(|candidate| {
+ if requested_region
+ .as_deref()
+ .is_some_and(|region| candidate.region != region)
+ {
+ return None;
+ }
let node = nodes.get(&candidate.node_id)?;
let placement = scheduler::choose_candidate(
std::slice::from_ref(candidate),
@@ -833,10 +876,11 @@ pub async fn serve_nodes(
Some(json!({
"id": node.id,
"name": node.name,
+ "region": candidate.region,
"healthy": true,
"capacity": candidate.capacity,
"usage": candidate.usage,
- "normal_available": placement.is_some_and(|placement| !placement.overcommitted),
+ "normal_available": placement.as_ref().is_some_and(|placement| !placement.overcommitted),
"schedulable": placement.is_some(),
"overflow_only": placement.is_some_and(|placement| placement.overcommitted),
"disk_available_mb": candidate.capacity.disk_mb.saturating_sub(candidate.usage.disk_mb),
@@ -1332,6 +1376,7 @@ pub async fn retry(
source_credential_version_id: source_deployment.source_credential_version_id,
},
None,
+ Some(&source_deployment.region),
OP,
)
.await
diff --git a/apps/control-api/src/features/api/v1/setup/node.rs b/apps/control-api/src/features/api/v1/setup/node.rs
index 7a3070a..1ab3b9c 100644
--- a/apps/control-api/src/features/api/v1/setup/node.rs
+++ b/apps/control-api/src/features/api/v1/setup/node.rs
@@ -64,6 +64,7 @@ pub async fn handler(
db,
CreateNodeParams {
name: name.to_owned(),
+ region: "default".to_owned(),
token_hash,
storage_root: storage_root.clone(),
},
@@ -103,6 +104,7 @@ pub async fn handler(
&config_path,
&crate::infra::node_manager::config_file::GenerateParams {
node_name: &node.name,
+ region: "default",
node_token: &token,
control_api_url,
storage_root: &storage_root,
diff --git a/apps/control-api/src/infra/database/entity/deployment.rs b/apps/control-api/src/infra/database/entity/deployment.rs
index 4d435fa..c8250e2 100644
--- a/apps/control-api/src/infra/database/entity/deployment.rs
+++ b/apps/control-api/src/infra/database/entity/deployment.rs
@@ -13,6 +13,7 @@ pub struct Model {
pub id: Uuid,
pub project_id: Uuid,
pub team_id: Uuid,
+ pub region: String,
pub build_node_id: Option,
pub serve_node_id: Option,
pub environment: DeploymentEnvironment,
diff --git a/apps/control-api/src/infra/database/entity/node.rs b/apps/control-api/src/infra/database/entity/node.rs
index 127a28b..04ec5b9 100644
--- a/apps/control-api/src/infra/database/entity/node.rs
+++ b/apps/control-api/src/infra/database/entity/node.rs
@@ -9,6 +9,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
+ pub region: String,
pub token_hash: String,
pub status: NodeStatus,
pub build_enabled: bool,
diff --git a/apps/control-api/src/infra/database/migrate.rs b/apps/control-api/src/infra/database/migrate.rs
index 1fb0028..9dea338 100644
--- a/apps/control-api/src/infra/database/migrate.rs
+++ b/apps/control-api/src/infra/database/migrate.rs
@@ -40,6 +40,7 @@ impl MigratorTrait for Migrator {
Box::new(migration::m20260807_000026_avatars::Migration),
Box::new(migration::m20260807_000027_deployment_screenshots::Migration),
Box::new(migration::m20260808_000028_object_storage::Migration),
+ Box::new(migration::m20260908_000029_regional_routing::Migration),
]
}
}
@@ -150,7 +151,7 @@ mod tests {
fn registers_audit_foundation_migration() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(11).expect("twelfth migration").name(),
"m20260729_000012_audit_foundation"
@@ -179,7 +180,7 @@ mod tests {
fn registers_team_group_review_policy_migration() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(12).expect("thirteenth migration").name(),
"m20260729_000013_team_group_review_policy"
@@ -190,7 +191,7 @@ mod tests {
fn registers_node_config_sync_migration() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(13).expect("fourteenth migration").name(),
"m20260729_000014_node_config_sync"
@@ -201,7 +202,7 @@ mod tests {
fn registers_node_deletion_queue_migration() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(14).expect("fifteenth migration").name(),
"m20260729_000015_node_deletion_queue"
@@ -212,7 +213,7 @@ mod tests {
fn registers_domain_review_policy_after_node_deletion_queue() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(14).expect("fifteenth migration").name(),
"m20260729_000015_node_deletion_queue"
@@ -227,7 +228,7 @@ mod tests {
fn registers_project_notifications_after_domain_review_policy() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(15).expect("sixteenth migration").name(),
"m20260730_000016_domain_review_policy"
@@ -250,7 +251,7 @@ mod tests {
fn registers_scoped_codes_after_authentication_migrations() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(23).expect("twenty-fourth migration").name(),
"m20260806_000024_scoped_codes"
@@ -261,7 +262,7 @@ mod tests {
fn registers_registration_allowlist_after_scoped_codes() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(24).expect("twenty-fifth migration").name(),
"m20260806_000025_registration_allowlist"
@@ -272,7 +273,7 @@ mod tests {
fn registers_avatar_versions_after_registration_allowlist() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(25).expect("twenty-sixth migration").name(),
"m20260807_000026_avatars"
@@ -283,15 +284,19 @@ mod tests {
fn registers_object_storage_after_deployment_screenshots() {
let migrations = Migrator::migrations();
- assert_eq!(migrations.len(), 28);
+ assert_eq!(migrations.len(), 29);
assert_eq!(
migrations.get(26).expect("twenty-seventh migration").name(),
"m20260807_000027_deployment_screenshots"
);
assert_eq!(
- migrations.last().expect("last migration").name(),
+ migrations.get(27).expect("twenty-eighth migration").name(),
"m20260808_000028_object_storage"
);
+ assert_eq!(
+ migrations.last().expect("last migration").name(),
+ "m20260908_000029_regional_routing"
+ );
}
#[tokio::test]
@@ -304,7 +309,7 @@ mod tests {
let verification = async {
Migrator::up(&test_db.db, None).await?;
- assert_migration_tracking(&test_db.db, 28, 0).await?;
+ assert_migration_tracking(&test_db.db, 29, 0).await?;
assert_avatar_schema(&test_db.db).await?;
assert_screenshot_schema(&test_db.db).await?;
assert_object_storage_schema(&test_db.db).await?;
@@ -325,7 +330,7 @@ mod tests {
assert_avatar_schema_absent(&test_db.db).await?;
Migrator::up(&test_db.db, Some(3)).await?;
- assert_migration_tracking(&test_db.db, 28, 0).await?;
+ assert_migration_tracking(&test_db.db, 29, 0).await?;
assert_avatar_schema(&test_db.db).await?;
assert_screenshot_schema(&test_db.db).await?;
assert_object_storage_schema(&test_db.db).await
diff --git a/apps/control-api/src/infra/database/migration/m20260908_000029_regional_routing.rs b/apps/control-api/src/infra/database/migration/m20260908_000029_regional_routing.rs
new file mode 100644
index 0000000..18670a7
--- /dev/null
+++ b/apps/control-api/src/infra/database/migration/m20260908_000029_regional_routing.rs
@@ -0,0 +1,58 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+pub(crate) const UP_SQL: &str = r#"
+ALTER TABLE nodes
+ ADD COLUMN region TEXT NOT NULL DEFAULT 'default',
+ ADD CONSTRAINT ck_nodes_region_nonempty CHECK (char_length(btrim(region)) > 0);
+
+ALTER TABLE deployments
+ ADD COLUMN region TEXT NOT NULL DEFAULT 'default',
+ ADD CONSTRAINT ck_deployments_region_nonempty CHECK (char_length(btrim(region)) > 0);
+
+CREATE INDEX ix_nodes_region_health
+ ON nodes (region, status, last_heartbeat_at)
+ WHERE deleted_at IS NULL AND serve_enabled = TRUE;
+
+CREATE INDEX ix_deployments_region_status
+ ON deployments (region, serve_status)
+ WHERE deleted_at IS NULL;
+"#;
+
+pub(crate) const DOWN_SQL: &str = r#"
+DROP INDEX IF EXISTS ix_deployments_region_status;
+DROP INDEX IF EXISTS ix_nodes_region_health;
+ALTER TABLE deployments DROP CONSTRAINT ck_deployments_region_nonempty, DROP COLUMN region;
+ALTER TABLE nodes DROP CONSTRAINT ck_nodes_region_nonempty, DROP COLUMN region;
+"#;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager.get_connection().execute_unprepared(UP_SQL).await?;
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .get_connection()
+ .execute_unprepared(DOWN_SQL)
+ .await?;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn migration_adds_defaulted_regions_and_indexes() {
+ assert!(UP_SQL.contains("ADD COLUMN region TEXT NOT NULL DEFAULT 'default'"));
+ assert!(UP_SQL.contains("ix_nodes_region_health"));
+ assert!(UP_SQL.contains("ix_deployments_region_status"));
+ assert!(DOWN_SQL.contains("DROP COLUMN region"));
+ }
+}
diff --git a/apps/control-api/src/infra/database/migration/mod.rs b/apps/control-api/src/infra/database/migration/mod.rs
index 92afd63..7d17368 100644
--- a/apps/control-api/src/infra/database/migration/mod.rs
+++ b/apps/control-api/src/infra/database/migration/mod.rs
@@ -26,3 +26,4 @@ pub mod m20260806_000025_registration_allowlist;
pub mod m20260807_000026_avatars;
pub mod m20260807_000027_deployment_screenshots;
pub mod m20260808_000028_object_storage;
+pub mod m20260908_000029_regional_routing;
diff --git a/apps/control-api/src/infra/http/middlewares/audit.rs b/apps/control-api/src/infra/http/middlewares/audit.rs
index d1db0c6..a6ee6f9 100644
--- a/apps/control-api/src/infra/http/middlewares/audit.rs
+++ b/apps/control-api/src/infra/http/middlewares/audit.rs
@@ -529,6 +529,7 @@ mod tests {
id: deployment_id,
project_id,
team_id,
+ region: "default".to_owned(),
build_node_id: None,
serve_node_id: None,
environment: DeploymentEnvironment::Production,
diff --git a/apps/control-api/src/infra/http/middlewares/node_auth.rs b/apps/control-api/src/infra/http/middlewares/node_auth.rs
index 870ed3e..b4943b1 100644
--- a/apps/control-api/src/infra/http/middlewares/node_auth.rs
+++ b/apps/control-api/src/infra/http/middlewares/node_auth.rs
@@ -154,6 +154,7 @@ mod tests {
let node = crate::infra::database::entity::node::Model {
id: uuid::Uuid::now_v7(),
name: "disabled-node".to_owned(),
+ region: "default".to_owned(),
token_hash: grass_token::hash_token(token),
status: NodeStatus::Disabled,
build_enabled: true,
diff --git a/apps/control-api/src/infra/node_manager/config_file.rs b/apps/control-api/src/infra/node_manager/config_file.rs
index 73da322..ccfd02e 100644
--- a/apps/control-api/src/infra/node_manager/config_file.rs
+++ b/apps/control-api/src/infra/node_manager/config_file.rs
@@ -14,6 +14,7 @@ const GENERATED_BY: &str = "grass-control-api";
pub struct GenerateParams<'a> {
pub node_name: &'a str,
+ pub region: &'a str,
pub node_token: &'a str,
pub control_api_url: String,
pub storage_root: &'a str,
@@ -30,6 +31,7 @@ struct GeneratedConfig {
#[derive(Serialize)]
struct NodeSection {
id: String,
+ region: String,
control_api: String,
node_token: String,
work_root: String,
@@ -85,6 +87,7 @@ pub fn generate(path: &str, params: &GenerateParams<'_>) -> anyhow::Result Result {
+ let response = self
+ .http
+ .get(self.url("/serve/certificates"))
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .context("serve.certificates: request failed")
+ .map_err(RouteSnapshotError::Infrastructure)?;
+ if matches!(
+ response.status(),
+ reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
+ ) {
+ return Err(RouteSnapshotError::AuthorizationRevoked);
+ }
+ Self::unwrap_envelope(response, "serve.certificates")
+ .await
+ .map_err(RouteSnapshotError::Infrastructure)
+ }
+
+ pub async fn report_ingress_status(
+ &self,
+ request: &ReportIngressStatusRequest,
+ ) -> anyhow::Result<()> {
+ let _: serde_json::Value = self
+ .post_json("/serve/ingress-status", request, "serve.ingress_status")
+ .await?;
+ Ok(())
+ }
+
#[allow(dead_code)] // Wired by the serve resolver in Milestone 10.
pub async fn resolve_host(&self, host: &str) -> anyhow::Result