Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e8d98d0
Start Hosted DNS, Cluster Domain and wildcard certificates
Sep 25, 2026
b063747
Cloud reserves and shows the Organization's Cluster Domain (#1019)
Sep 25, 2026
3ee0489
Release Cluster Domains with DELETE /domains/{name}, per the Hosted D…
Sep 25, 2026
d7d1246
Take generated hostnames from the Organization's Cluster Domain row
Sep 25, 2026
7f3889d
Sync the Cluster Domain's apex records and lease with sync-cluster-do…
Sep 25, 2026
92146b7
Give expanded managed routes a stable id per hostname
Sep 25, 2026
a05217b
Merge branch 'impl/1021' into feat/1015-cluster-domain
Sep 25, 2026
bf9dbbd
Publish Certificate Material for a hostname or wildcard (#1022)
Sep 25, 2026
1322fc8
Merge branch 'impl/1022' into feat/1015-cluster-domain
Sep 25, 2026
c4a21c3
Ensure and publish the Cluster Domain's wildcard certificate (#1023)
Sep 25, 2026
4086af6
Remove Hosted DNS from the daemon, SDK and CLI
Sep 25, 2026
211a35c
Merge impl/1024: remove Hosted DNS from the daemon, SDK and CLI
Sep 25, 2026
d3f30cb
Collapse IngressHostname and make CertificateRow an Acme | Published …
Sep 25, 2026
9356e63
Tidy SDK unary calls, deploy binding and CLI failure tests
Sep 25, 2026
3c8fe0a
Keep every Cluster Domain's lease alive and replace retired names
Sep 25, 2026
1b2104b
Expand managed hostnames through one helper and read the name with Su…
Sep 25, 2026
b913cd8
Move SDK tests out of sdk.rs and tighten certificate row decoding
Sep 25, 2026
eaeb4ca
Expand managed hostnames synchronously and name the records step result
Sep 25, 2026
ec51ddb
Merge origin/main into feat/1015-cluster-domain
Sep 25, 2026
a16c643
Merge origin/main into feat/1015-cluster-domain
Sep 25, 2026
c453f6e
Merge remote-tracking branch 'origin/main' into feat/1015-cluster-domain
Sep 25, 2026
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
10 changes: 5 additions & 5 deletions core/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,15 @@ The Project attributed to an Internal DNS query by matching its source Container
_Avoid_: caller identity, authenticated client, source registry

**Ingress Hostname**:
The HTTP hostname a Service publishes through ingress. Cluster Domain assignment uses the automatic `{service}-{project}` label or a chosen DNS label with no Project suffix; otherwise the hostname is an explicit validated name. An empty string is not an assignment signal.
_Avoid_: empty hostname sentinel
The HTTP hostname a Service publishes through ingress. It is always an explicit validated name: the runtime never generates one, and Cloud expands its managed hostnames against the Organization's Cluster Domain before they arrive. An empty string is not a hostname.
_Avoid_: empty hostname sentinel, Cluster Domain label

**Hostname Owner**:
The Qualified Service that wins an Ingress Hostname from one observer's Service Container observations; Serving is not required. Derived; not a persisted record, lease, or lock. Different observers may select different owners until their observations match.
_Avoid_: hostname lease, ownership table, global hostname uniqueness, Serving as the ownership gate

**Certificate Material**:
The certificate and private key held in cluster state for one Ingress Hostname. It is served as given; it is not an issuance request and not a local proxy store.
The certificate and private key held in cluster state for one Ingress Hostname or single-level wildcard `*.x`. It is served as given; it is not an issuance request and not a local proxy store. Published material comes from an operator or Cloud; ACME never orders, renews, or overwrites it, and a published wildcard serves every hostname one label under `x`.
_Avoid_: Caddy certificate, ACME certificate, cert secret

**Certificate Policy**:
Expand Down Expand Up @@ -340,8 +340,8 @@ One of exactly two names a daemon or installer may follow: `stable` (the highest
_Avoid_: latest, nightly, dev channel

**Hosted DNS**:
The Ployz-run service that grants a Cluster Domain and serves its public records. It is shared infrastructure separate from Cloud; a Cluster stores which endpoint granted its reservation and an encrypted reservation token.
_Avoid_: Uncloud DNS, Cloud DNS, generated domain as Cloud state
The Ployz-run service that grants Cluster Domains and serves their public records. Only Cloud calls it (see `dashboard/CONTEXT.md`); the daemon, SDK and CLI make no Hosted DNS calls, and a Cluster stores no reservation.
_Avoid_: Cloud DNS, cluster-held reservation, generated domain as runtime state

**Pairing Credential**:
The secret identifying the current Cloud Pairing and authenticating the CLI's enrollment callbacks to Cloud for that attempt. It never reaches the daemon and is distinct from a Machine's Management Capability.
Expand Down
22 changes: 9 additions & 13 deletions core/crates/ployz-core/src/config/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use serde_json::Value;
use super::{ConfigError, ServiceHealthcheck, ServiceSource, parse_service_config};
use crate::{
ByteQuantity, ContainerResources, CpuNanos, DeployIntent, HealthcheckSpec, HttpHealthcheck,
HttpProtocol, IngressHostname, PlanOptions, PortPublication, PreDeployCommand, PreDeployHook,
HttpProtocol, IngressHost, PlanOptions, PortPublication, PreDeployCommand, PreDeployHook,
ProjectName, PullPolicy, RawVolumeSource, RequestedServiceSpec, RestartPolicy, ServiceAttempt,
ServiceContainerSpec, ServiceDependency, ServiceMode, ServiceMount, ServiceName, ServiceVolume,
ServiceVolumeGraph, VolumeDriver,
Expand Down Expand Up @@ -176,23 +176,19 @@ pub fn lower_deployment(input: LowerDeploymentInput) -> Result<DeployIntent, Con
let mut ports = Vec::new();
for route in &config.routes {
ports.push(PortPublication::Ingress {
hostname: IngressHostname::explicit(route.hostname.clone())
.map_err(lowering_error)?,
hostname: IngressHost::parse(route.hostname.clone()).map_err(lowering_error)?,
load_balancer_port: std::num::NonZeroU16::new(443).expect("HTTPS port is nonzero"),
container_port: target_port(route.target_port, &environment, "routes")?,
http_protocol: HttpProtocol::Https,
});
}
for hostname in config.managed_hostnames {
let port = target_port(hostname.target_port, &environment, "managedHostnames")?;
ports.push(PortPublication::Ingress {
hostname: IngressHostname::ClusterDomain {
label: Some(hostname.prefix.try_into().map_err(lowering_error)?),
},
load_balancer_port: std::num::NonZeroU16::new(443).expect("HTTPS port is nonzero"),
container_port: port,
http_protocol: HttpProtocol::Https,
});
// Managed hostnames are Cloud's authored shorthand; Cloud expands them into
// explicit routes before lowering because only it knows the Cluster Domain.
if !config.managed_hostnames.is_empty() {
return Err(ConfigError::at(
"managedHostnames",
"Expand managed hostnames into explicit routes before deploying",
));
}
let command = |command: &str| vec!["/bin/sh".into(), "-c".into(), command.into()];
let pre_deploy = config
Expand Down
10 changes: 6 additions & 4 deletions core/crates/ployz-core/src/config/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde_json::Value;
use ts_rs::TS;

use super::*;
use crate::{ClusterDomainLabel, IngressHost, ServiceName};
use crate::{IngressHost, ServiceName, value::is_dns_label};

/// Validation errors identify the setting, never echo credentials or authored values.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, thiserror::Error, TS)]
Expand Down Expand Up @@ -285,9 +285,11 @@ fn managed_hostname(value: &mut ServiceManagedHostname) -> Result<(), ConfigErro

fn hostname_prefix(value: &mut String) -> Result<(), ConfigError> {
*value = value.trim().to_lowercase();
ClusterDomainLabel::parse(&*value)
.map(|_| ())
.map_err(|_| ConfigError::at("managedHostname.prefix", "Expected a lowercase DNS label"))
range(
is_dns_label(value),
"managedHostname.prefix",
"Expected a lowercase DNS label",
)
}

fn limit(value: Option<f64>, max: f64, path: &str) -> Result<(), ConfigError> {
Expand Down
33 changes: 0 additions & 33 deletions core/crates/ployz-core/src/domain/cluster_dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

use std::net::IpAddr;

use crate::IngressHost;

/// Whether resolved addresses intersect this Cluster's Machine public addresses.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClusterDnsVerdict {
Expand All @@ -30,24 +28,11 @@ pub fn cluster_dns_verdict(resolved: &[IpAddr], cluster_addresses: &[IpAddr]) ->
}
}

impl IngressHost {
/// Whether this hostname is the Cluster Domain or a name under it.
#[must_use]
pub fn under_cluster_domain(&self, cluster_domain: Option<&str>) -> bool {
let Some(domain) = cluster_domain.filter(|domain| !domain.is_empty()) else {
return false;
};
let hostname = self.as_str();
hostname == domain || hostname.ends_with(&format!(".{domain}"))
}
}

#[cfg(test)]
mod tests {
use std::net::IpAddr;

use super::{ClusterDnsVerdict, cluster_dns_verdict};
use crate::IngressHost;

#[test]
fn verdict_covers_subset_none_empty_and_mix() {
Expand All @@ -72,24 +57,6 @@ mod tests {
);
}

#[test]
fn under_cluster_domain_requires_a_label_boundary() {
let domain = Some("opaque.ployz.example");
assert!(host("web.opaque.ployz.example").under_cluster_domain(domain));
assert!(host("opaque.ployz.example").under_cluster_domain(domain));
assert!(!host("app.example.com").under_cluster_domain(domain));
assert!(!host("web.opaque.ployz.example").under_cluster_domain(None));
assert!(!host("web.opaque.ployz.example").under_cluster_domain(Some("")));
assert!(
!host("evilopaque.ployz.example").under_cluster_domain(domain),
"a suffix without a label boundary is not under the Cluster Domain"
);
}

fn host(hostname: &str) -> IngressHost {
IngressHost::parse(hostname).unwrap()
}

fn addrs<const N: usize>(values: [&str; N]) -> Vec<IpAddr> {
values
.into_iter()
Expand Down
36 changes: 6 additions & 30 deletions core/crates/ployz-core/src/domain/hostname.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,21 @@ pub fn hostname_owners<'a>(
/// Explicit Ingress Hostnames published by these ports.
pub fn explicit_ingress_hosts(ports: &[PortPublication]) -> impl Iterator<Item = &IngressHost> {
ports.iter().filter_map(|port| match port {
PortPublication::Ingress { hostname, .. } => hostname.as_explicit_host(),
PortPublication::Ingress { hostname, .. } => Some(hostname),
PortPublication::Host { .. } => None,
})
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::num::NonZeroU16;

use serde_json::json;

use super::hostname_owners;
use crate::{
ContainerId, ContainerKind, ContainerObservation, ContainerRuntimeObservation,
HttpProtocol, IngressHost, IngressHostname, MachineId, PortPublication, ProjectName,
QualifiedService, ResolvedServiceSpec, ServiceId, ServiceName,
ContainerId, ContainerKind, ContainerObservation, ContainerRuntimeObservation, IngressHost,
MachineId, ProjectName, QualifiedService, ResolvedServiceSpec, ServiceId, ServiceName,
};

#[test]
Expand Down Expand Up @@ -128,33 +126,11 @@ mod tests {
}

#[test]
fn hooks_and_unassigned_ingress_do_not_claim_a_hostname() {
fn hooks_do_not_claim_a_hostname() {
let mut hook = observation("shop", "web", '1', 'a', 1, "api.example.com");
hook.try_update(|parts| parts.kind = ContainerKind::PreDeployHook)
.unwrap();
let mut assigned = observation("blog", "web", '2', 'b', 2, "api.example.com");
assigned
.try_update(|parts| {
parts.resolved_spec.ports = vec![PortPublication::Ingress {
hostname: IngressHostname::cluster_domain(),
load_balancer_port: NonZeroU16::new(80).unwrap(),
container_port: NonZeroU16::new(80).unwrap(),
http_protocol: HttpProtocol::Http,
}]
})
.unwrap();
let mut chosen = observation("shop", "api", '3', 'c', 3, "api.example.com");
chosen
.try_update(|parts| {
parts.resolved_spec.ports = vec![PortPublication::Ingress {
hostname: IngressHostname::cluster_domain_label("api").unwrap(),
load_balancer_port: NonZeroU16::new(80).unwrap(),
container_port: NonZeroU16::new(80).unwrap(),
http_protocol: HttpProtocol::Http,
}]
})
.unwrap();
assert!(hostname_owners([&hook, &assigned, &chosen]).is_empty());
assert!(hostname_owners([&hook]).is_empty());
}

fn host(name: &str) -> IngressHost {
Expand All @@ -178,7 +154,7 @@ mod tests {
"container": { "image": "example.test/image", "pull_policy": "missing" },
"ports": [{
"mode": "ingress",
"hostname": { "kind": "explicit", "hostname": hostname },
"hostname": hostname,
"load_balancer_port": 80,
"container_port": 80,
"http_protocol": "http"
Expand Down
11 changes: 4 additions & 7 deletions core/crates/ployz-core/src/domain/runtime_watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use thiserror::Error;
use ts_rs::TS;

use crate::{
CodecError, ContainerId, ContainerObservation, DockerVolume, DockerVolumeId, IngressHost,
CertificateHost, CodecError, ContainerId, ContainerObservation, DockerVolume, DockerVolumeId,
MachineId, MachineObservation, OpaquePayload, RUNTIME_WATCH_MESSAGE_SIZE_LIMIT,
ServiceObservation, derive_services,
};
Expand All @@ -31,12 +31,12 @@ pub struct CertificateBackoff {
pub failures: u32,
}

/// Redacted certificate status keyed by Ingress Hostname.
/// Redacted certificate status keyed by certificate hostname.
///
/// Never carries Certificate Material or HTTP-01 challenge bytes.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
pub struct CertificateObservation {
pub hostname: IngressHost,
pub hostname: CertificateHost,
pub status: CertificateAvailability,
#[serde(default)]
pub last_error: Option<String>,
Expand All @@ -54,7 +54,7 @@ pub struct RuntimeWatchIncompleteIds {
#[serde(default)]
pub volumes: Vec<DockerVolumeId>,
#[serde(default)]
pub certificates: Vec<IngressHost>,
pub certificates: Vec<CertificateHost>,
}

/// One complete Runtime Watch observation.
Expand All @@ -68,9 +68,6 @@ pub struct RuntimeWatchFrame {
pub volumes: Vec<DockerVolume>,
#[serde(default)]
pub certificates: Vec<CertificateObservation>,
/// Hosted DNS hostname only; never the renewal token or endpoint.
#[serde(default)]
pub hosted_dns_hostname: Option<String>,
#[serde(default)]
pub incomplete_ids: RuntimeWatchIncompleteIds,
/// Freshness of the entry-local membership/RTT sample. Not Cluster truth.
Expand Down
59 changes: 3 additions & 56 deletions core/crates/ployz-core/src/domain/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ use super::{
ServiceVolumeGraph,
};
use crate::{
ClusterDomainLabel, ContainerHostname, ContainerLabels, ContainerPath, ExtraHost, IngressHost,
MachinePath, PidMode, RestartPolicy, ServiceId, ServiceMount, ServiceName, ServiceVolume,
ValueError,
ContainerHostname, ContainerLabels, ContainerPath, ExtraHost, IngressHost, MachinePath,
PidMode, RestartPolicy, ServiceId, ServiceMount, ServiceName, ServiceVolume, ValueError,
};

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
Expand Down Expand Up @@ -62,63 +61,11 @@ pub enum HostBind {
},
}

/// How an HTTP ingress publication obtains its hostname.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum IngressHostname {
ClusterDomain {
#[serde(default)]
label: Option<ClusterDomainLabel>,
},
Explicit {
hostname: IngressHost,
},
}

impl IngressHostname {
/// Automatic `{service}-{project}` Cluster Domain assignment.
#[must_use]
pub fn cluster_domain() -> Self {
Self::ClusterDomain { label: None }
}

/// Chosen Cluster Domain label with no Project suffix.
///
/// # Errors
///
/// Returns [`ValueError`] when `label` is not a lowercase DNS label.
pub fn cluster_domain_label(label: impl Into<String>) -> Result<Self, ValueError> {
Ok(Self::ClusterDomain {
label: Some(ClusterDomainLabel::parse(label)?),
})
}

/// Parse a non-empty validated hostname as explicit ingress intent.
///
/// # Errors
///
/// Returns [`ValueError`] when `hostname` is empty or not a lowercase DNS hostname.
pub fn explicit(hostname: impl Into<String>) -> Result<Self, ValueError> {
Ok(Self::Explicit {
hostname: IngressHost::parse(hostname)?,
})
}

/// The explicit hostname when this intent is already a concrete Ingress Hostname.
#[must_use]
pub fn as_explicit_host(&self) -> Option<&IngressHost> {
match self {
Self::Explicit { hostname } => Some(hostname),
Self::ClusterDomain { .. } => None,
}
}
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "mode")]
pub enum PortPublication {
Ingress {
hostname: IngressHostname,
hostname: IngressHost,
load_balancer_port: NonZeroU16,
container_port: NonZeroU16,
http_protocol: HttpProtocol,
Expand Down
Loading
Loading