Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ tests/env/certs/
# Forge runtime state and generated files
.forge/
tests/e2e/topologies/**/.forge.resolved.yaml
tests/e2e/topologies/**/.forge.resolved.*.yaml
tests/e2e/topologies/**/.forge.*/
tests/e2e/topologies/**/.grid-*.resolved.yaml

# Helm chart packaging artifacts
*.tgz
1 change: 1 addition & 0 deletions charts/praxis-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ AI image; these values may advance independently.
| `commonLabels` | object | `{}` | Labels added to all resources. |
| `podLabels` | object | `{}` | Additional pod labels. Selector labels cannot be overridden. |
| `podAnnotations` | object | `{}` | Pod annotations. |
| `terminationGracePeriodSeconds` | integer | `null` | Optional maximum termination grace period for gateway pods. Qualification topologies set this explicitly. |
| `podSecurityContext` | object | `{}` | Extra pod securityContext (`runAsUser`, `runAsGroup`, `fsGroup`, `supplementalGroups`). |
| `args` | list | `["--config", "/etc/praxis/praxis.yaml"]` | Container arguments. |
| `config.existingConfigMap` | string | **required** | Name of an existing ConfigMap with the Praxis config. |
Expand Down
3 changes: 3 additions & 0 deletions charts/praxis-gateway/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ . }}
{{- end }}
automountServiceAccountToken: false
{{- if and .Values.overlay.enabled .Values.overlay.sidecar.enabled }}
serviceAccountName: {{ include "praxis-gateway.fullname" . }}-overlay-sync
Expand Down
5 changes: 5 additions & 0 deletions charts/praxis-gateway/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"additionalProperties": false,
"required": ["config"],
"properties": {
"terminationGracePeriodSeconds": {
"type": ["integer", "null"],
"minimum": 1,
"description": "Maximum termination grace period for the gateway pod."
},
"replicaCount": {
"type": "integer",
"minimum": 1,
Expand Down
5 changes: 5 additions & 0 deletions charts/praxis-gateway/values.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# -- Number of gateway replicas.
replicaCount: 1

# -- Optional maximum time Kubernetes allows a terminating pod to finish.
# Qualification topologies set this explicitly; leaving it null preserves
# Kubernetes' default for general chart consumers.
terminationGracePeriodSeconds: null

# -- Gateway container image settings.
image:
# -- Image repository.
Expand Down
12 changes: 11 additions & 1 deletion forge/src/command/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ fn ensure_network(
}));
}
let existed_before_up = networking::network_exists(ctx.runner, binary, &net_name)?;
networking::create_network(ctx.runner, binary, &net_name, env_name)?;
networking::create_network(
ctx.runner,
binary,
&net_name,
env_name,
ctx.config
.spec
.network
.as_ref()
.and_then(|network| network.subnet.as_deref()),
)?;
let cidr = networking::inspect_network_cidr(ctx.runner, binary, &net_name)?;
set_network_active(state, &net_name, &cidr);
state.network_created_by_forge = !existed_before_up;
Expand Down
3 changes: 3 additions & 0 deletions forge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ pub struct NetworkConfig {
/// DNS zone for exported-service discovery (default `forge.test`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns_zone: Option<String>,
/// Optional IPv4 subnet for the managed cross-cluster network.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subnet: Option<String>,
}

impl NetworkConfig {
Expand Down
33 changes: 33 additions & 0 deletions forge/src/config/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub fn validate(config: &ForgeConfig) -> Result<(), ForgeError> {
check_kind(config)?;
check_metadata_name(&config.metadata.name)?;
check_network_name(&config.metadata.name, &config.spec)?;
check_network_subnet(&config.spec)?;
check_cluster_names(config)?;
check_cluster_nodes(config)?;
check_service_names(config)?;
Expand All @@ -38,6 +39,15 @@ pub fn validate(config: &ForgeConfig) -> Result<(), ForgeError> {
Ok(())
}

/// Validate an explicitly configured Docker network subnet.
fn check_network_subnet(spec: &crate::config::EnvironmentSpec) -> Result<(), ForgeError> {
let Some(subnet) = spec.network.as_ref().and_then(|network| network.subnet.as_deref()) else {
return Ok(());
};
crate::networking::validate_ipv4_cidr(subnet)
.map_err(|error| ForgeError::Validation(format!("network subnet is invalid: {error}")))
}

/// `apiVersion` must match the current schema.
fn check_api_version(config: &ForgeConfig) -> Result<(), ForgeError> {
if config.api_version != API_VERSION {
Expand Down Expand Up @@ -1385,18 +1395,31 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
validate(&config).unwrap_or_else(|_e| {
std::process::abort();
});
}

#[test]
fn invalid_network_subnet_is_rejected() {
let mut config = base_config();
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: Some("not-a-cidr".to_owned()),
});
assert!(validate(&config).is_err(), "invalid subnet must be rejected");
}

#[test]
fn network_config_without_cross_cluster_passes() {
let mut config = base_config();
config.spec.network = Some(NetworkConfig {
cross_cluster: false,
dns_zone: None,
subnet: None,
});
validate(&config).unwrap_or_else(|_e| {
std::process::abort();
Expand Down Expand Up @@ -1770,6 +1793,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: Some("forge.test".to_owned()),
subnet: None,
});
assert!(validate(&config).is_ok(), "forge.test should be a valid dns zone");
}
Expand All @@ -1780,18 +1804,21 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: Some("UPPER.case".to_owned()),
subnet: None,
});
assert!(validate(&config).is_err(), "uppercase should be rejected");

config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: Some(".leading-dot".to_owned()),
subnet: None,
});
assert!(validate(&config).is_err(), "leading dot should be rejected");

config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: Some("nodot".to_owned()),
subnet: None,
});
assert!(validate(&config).is_err(), "no dot should be rejected");
}
Expand Down Expand Up @@ -1822,6 +1849,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
config.spec.stacks.insert(
"net".to_owned(),
Expand Down Expand Up @@ -1858,6 +1886,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
config.spec.stacks.insert(
"net".to_owned(),
Expand Down Expand Up @@ -1887,6 +1916,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
config.spec.stacks.insert(
"net".to_owned(),
Expand Down Expand Up @@ -1914,6 +1944,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
let Err(err) = validate(&config) else {
std::process::abort();
Expand All @@ -1932,6 +1963,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
validate(&config).unwrap_or_else(|_e| {
std::process::abort();
Expand All @@ -1944,6 +1976,7 @@ spec:
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
validate(&config).unwrap_or_else(|_e| {
std::process::abort();
Expand Down
54 changes: 37 additions & 17 deletions forge/src/networking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ pub fn create_network(
binary: &str,
net_name: &str,
env_name: &str,
subnet: Option<&str>,
) -> Result<(), ForgeError> {
if network_exists(runner, binary, net_name)? {
return verify_ownership(runner, binary, net_name, env_name);
}
let spec = create_spec(binary, net_name, env_name);
let spec = create_spec(binary, net_name, env_name, subnet);
let output = runner.run(&spec)?;
check_success(&output, "network create")
}
Expand Down Expand Up @@ -173,18 +174,23 @@ fn missing_label(net_name: &str, key: &str) -> ForgeError {
// ---------------------------------------------------------------

/// Build a `<binary> network create` command spec with labels.
fn create_spec(binary: &str, net_name: &str, env_name: &str) -> CommandSpec {
fn create_spec(binary: &str, net_name: &str, env_name: &str, subnet: Option<&str>) -> CommandSpec {
let mut args = vec![
"network".into(),
"create".into(),
"--label".into(),
"forge.managed=true".into(),
"--label".into(),
format!("forge.environment={env_name}").into(),
];
if let Some(subnet) = subnet {
args.push("--subnet".into());
args.push(subnet.into());
}
args.push(net_name.into());
CommandSpec {
program: binary.into(),
args: vec![
"network".into(),
"create".into(),
"--label".into(),
"forge.managed=true".into(),
"--label".into(),
format!("forge.environment={env_name}").into(),
net_name.into(),
],
args,
env: BTreeMap::default(),
stdin: None,
redact: Vec::new(),
Expand Down Expand Up @@ -274,7 +280,7 @@ fn parse_ipam_config(stdout: &str) -> Result<String, ForgeError> {
}

/// Validate an IPv4 CIDR without accepting host-only or IPv6 forms.
fn validate_ipv4_cidr(cidr: &str) -> Result<(), ForgeError> {
pub(crate) fn validate_ipv4_cidr(cidr: &str) -> Result<(), ForgeError> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: this only rejects a prefix over 32, so host bits (10.240.5.7/24) and /31 or /32 slip through. could tighten it for user-supplied subnets.

let (address, prefix) = cidr
.split_once('/')
.ok_or_else(|| ForgeError::State(format!("network subnet is not CIDR: {cidr:?}")))?;
Expand Down Expand Up @@ -365,12 +371,26 @@ mod tests {
runner.respond("docker network inspect test-net", not_found());
runner.respond("docker", ok());

create_network(&runner, "docker", "test-net", "test").unwrap_or_else(|_| std::process::abort());
create_network(&runner, "docker", "test-net", "test", None).unwrap_or_else(|_| std::process::abort());
assert!(runner.was_called("network create"), "should call network create");
assert!(runner.was_called("forge.managed=true"), "should include managed label");
assert!(runner.was_called("forge.environment=test"), "should include env label");
}

#[test]
fn create_uses_explicit_subnet() {
let mut runner = MockRunner::new();
runner.respond("docker network inspect test-net", not_found());
runner.respond("docker", ok());

create_network(&runner, "docker", "test-net", "test", Some("10.251.0.0/16"))
.unwrap_or_else(|_| std::process::abort());
assert!(
runner.was_called("--subnet 10.251.0.0/16"),
"should pass configured subnet"
);
}

#[test]
fn create_skips_when_exists_with_correct_owner() {
let mut runner = MockRunner::new();
Expand All @@ -380,7 +400,7 @@ mod tests {
owned_labels("test"),
);

create_network(&runner, "docker", "test-net", "test").unwrap_or_else(|_| std::process::abort());
create_network(&runner, "docker", "test-net", "test", None).unwrap_or_else(|_| std::process::abort());
assert!(
!runner.was_called("network create"),
"should not create existing network"
Expand All @@ -396,7 +416,7 @@ mod tests {
owned_labels("other-env"),
);

let result = create_network(&runner, "docker", "test-net", "test");
let result = create_network(&runner, "docker", "test-net", "test", None);
let Err(err) = result else {
std::process::abort();
};
Expand All @@ -416,7 +436,7 @@ mod tests {
foreign_labels(),
);

let result = create_network(&runner, "docker", "test-net", "test");
let result = create_network(&runner, "docker", "test-net", "test", None);
assert!(result.is_err(), "should reject unmanaged network");
}

Expand Down Expand Up @@ -589,7 +609,7 @@ mod tests {
runner.respond("podman network inspect test-net", not_found());
runner.respond("podman", ok());

create_network(&runner, "podman", "test-net", "test").unwrap_or_else(|_| std::process::abort());
create_network(&runner, "podman", "test-net", "test", None).unwrap_or_else(|_| std::process::abort());
assert!(runner.was_called("podman"), "should use podman binary");
}
}
1 change: 1 addition & 0 deletions forge/src/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ mod tests {
config.spec.network = Some(NetworkConfig {
cross_cluster: true,
dns_zone: None,
subnet: None,
});
let runner = crate::command::runner::MockRunner::new();
let ctx = ForgeContext {
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/topologies/grid-token-rate-limit/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.forge/
.forge.*/
.forge.resolved.*.yaml
Loading
Loading