First cargo-mutants --in-diff pass over a61540b..aa1889b left 16 misses. Deleted: - the candidate challenge's own route-id check: rendering the Expected next holds the same binding, so it could never decide anything. - odin_can_observe in advance_warming: is_odin && !(!is_odin || observed) is is_odin && !observed; two of the misses were operators in that detour. Moved: which transactions are held for their evidence is now DeploymentTransaction::held_disagreement, so "finished or aborting work is never held" is a value with a test, not a local in resume_candidate. Pinned: the admitted-generation topology refresh (taken once, then not again), WarmingEvidence shape checks per source, the PresenceDisagrees text, the plan's readiness declaration needing the shared-infrastructure kind, TCP framed challenges (exact answer, bound, empty, non-CultNet, silence), RUDP silence, and ChallengeFailure's text and source. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1872 lines
71 KiB
Rust
1872 lines
71 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result, bail, ensure};
|
|
pub use cultnet_rs::IdunnExpectedIncarnationRecord as ExpectedIncarnation;
|
|
use cultnet_rs::{
|
|
IDUNN_EXPECTED_INCARNATION_SCHEMA, IdunnExpectedCapability, IdunnExpectedDependency,
|
|
IdunnExpectedRoute,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::control_plane::{ODIN_RENDEZVOUS_CAPABILITY, ReadinessClass, SequenceAdmittedReady};
|
|
use crate::deployment::{
|
|
CapabilityDependency, DeadlineBinding, DependencyKind, ExternalCapabilityBinding, OperatorBinding,
|
|
ServiceTransport, SourceSelectionPolicy, StartupOrder, StateDeclaration, TargetDeclaration,
|
|
capability_compatible,
|
|
};
|
|
|
|
pub const SOURCE_SELECTION_FACTS_SCHEMA: &str = "idunn.source_selection_facts.v1";
|
|
pub const COMPILED_DEPLOYMENT_PLAN_SCHEMA: &str = "idunn.compiled_deployment_plan.v3";
|
|
/// Plans sealed before phase deadlines existed. They keep their bytes, and so
|
|
/// their plan id; their deadlines are Idunn's defaults at read time.
|
|
pub const COMPILED_DEPLOYMENT_PLAN_SCHEMA_V2: &str = "idunn.compiled_deployment_plan.v2";
|
|
pub const SEALED_RELEASE_SCHEMA: &str = "idunn.sealed_release.v1";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct GitlinkTreeFact {
|
|
pub origin: String,
|
|
pub revision: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "policy", rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub enum SourceSelection {
|
|
PinnedObject,
|
|
RefHead,
|
|
SignedRelease { release_authority_id: String },
|
|
}
|
|
|
|
/// Private facts emitted by the future narrow source driver. Structural
|
|
/// validation here does not prove Git ancestry, signatures, or object custody.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SourceSelectionFacts {
|
|
pub schema: String,
|
|
pub origin: String,
|
|
pub admitted_ref: String,
|
|
pub admitted_ref_revision: String,
|
|
pub revision: String,
|
|
pub source_tree: String,
|
|
pub recipe_path: PathBuf,
|
|
pub recipe_blob_sha256: String,
|
|
pub gitlinks: BTreeMap<PathBuf, GitlinkTreeFact>,
|
|
pub selection: SourceSelection,
|
|
pub selected_at_unix_millis: u64,
|
|
}
|
|
|
|
impl SourceSelectionFacts {
|
|
pub fn validate_against(&self, binding: &OperatorBinding) -> Result<()> {
|
|
binding.validate()?;
|
|
ensure!(
|
|
self.schema == SOURCE_SELECTION_FACTS_SCHEMA,
|
|
"unsupported source-selection facts schema"
|
|
);
|
|
ensure!(
|
|
self.origin == binding.repository.origin,
|
|
"selected origin differs from binding"
|
|
);
|
|
ensure!(
|
|
self.admitted_ref == binding.repository.admitted_ref,
|
|
"selected ref differs from binding"
|
|
);
|
|
require_sha1(
|
|
&self.admitted_ref_revision,
|
|
"selected admitted-ref revision",
|
|
)?;
|
|
require_sha1(&self.revision, "selected revision")?;
|
|
require_sha1(&self.source_tree, "selected source tree")?;
|
|
ensure!(
|
|
self.recipe_path == binding.repository.recipe_path,
|
|
"selected recipe path differs from binding"
|
|
);
|
|
require_sha256(&self.recipe_blob_sha256, "selected recipe blob")?;
|
|
ensure!(
|
|
self.selected_at_unix_millis > 0,
|
|
"source-selection facts have no time"
|
|
);
|
|
|
|
match (&binding.repository.selection, &self.selection) {
|
|
(SourceSelectionPolicy::PinnedObject, SourceSelection::PinnedObject) => {
|
|
let pinned_revision = binding
|
|
.repository
|
|
.pinned_revision
|
|
.as_deref()
|
|
.context("validated pinned binding has no revision")?;
|
|
ensure!(
|
|
self.revision == pinned_revision,
|
|
"selected source differs from the pinned revision"
|
|
);
|
|
}
|
|
(SourceSelectionPolicy::RefHead, SourceSelection::RefHead) => ensure!(
|
|
self.revision == self.admitted_ref_revision,
|
|
"ref-head selection differs from the fetched admitted-ref revision"
|
|
),
|
|
(
|
|
SourceSelectionPolicy::SignedRelease,
|
|
SourceSelection::SignedRelease {
|
|
release_authority_id,
|
|
},
|
|
) => {
|
|
require_token(release_authority_id, "release authority id")?;
|
|
}
|
|
_ => bail!("source-selection facts do not match operator policy"),
|
|
}
|
|
|
|
let expected_paths: BTreeSet<_> = binding.repository.gitlinks.keys().cloned().collect();
|
|
let observed_paths: BTreeSet<_> = self.gitlinks.keys().cloned().collect();
|
|
ensure!(
|
|
expected_paths == observed_paths,
|
|
"Gitlink tree facts differ from binding"
|
|
);
|
|
for (path, receipt) in &self.gitlinks {
|
|
let expected = &binding.repository.gitlinks[path];
|
|
ensure!(
|
|
receipt.origin == expected.origin,
|
|
"Gitlink {} origin differs from binding",
|
|
path.display()
|
|
);
|
|
require_sha1(&receipt.revision, "Gitlink revision")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub enum DependencyProviderAuthority {
|
|
ManagedReady {
|
|
target: String,
|
|
incarnation_id: String,
|
|
plan_id: String,
|
|
sealed_release_id: String,
|
|
expected_projection_sha256: String,
|
|
odin_topology_correlation_sha256: String,
|
|
odin_topology_publisher_sequence: u64,
|
|
},
|
|
ExternalOperatorBinding {
|
|
binding_target: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DependencyProviderRef {
|
|
pub provider_id: String,
|
|
pub authority: DependencyProviderAuthority,
|
|
pub capability: String,
|
|
pub schema: String,
|
|
pub compatibility: String,
|
|
pub capacity: u32,
|
|
pub endpoint: Option<String>,
|
|
}
|
|
|
|
impl DependencyProviderRef {
|
|
pub fn validate(&self) -> Result<()> {
|
|
require_token(&self.provider_id, "dependency provider id")?;
|
|
require_contract(&self.capability, &self.schema, &self.compatibility)?;
|
|
ensure!(self.capacity > 0, "dependency provider capacity is zero");
|
|
if let Some(endpoint) = &self.endpoint {
|
|
require_value(endpoint, "dependency provider endpoint")?;
|
|
}
|
|
match &self.authority {
|
|
DependencyProviderAuthority::ManagedReady {
|
|
target,
|
|
incarnation_id,
|
|
plan_id,
|
|
sealed_release_id,
|
|
expected_projection_sha256,
|
|
odin_topology_correlation_sha256,
|
|
odin_topology_publisher_sequence,
|
|
} => {
|
|
require_token(target, "managed provider target")?;
|
|
require_token(incarnation_id, "managed provider incarnation")?;
|
|
require_sha256(plan_id, "managed provider plan")?;
|
|
require_sha256(sealed_release_id, "managed provider sealed release")?;
|
|
require_sha256(expected_projection_sha256, "managed expected projection")?;
|
|
require_sha256(
|
|
odin_topology_correlation_sha256,
|
|
"managed Odin topology correlation",
|
|
)?;
|
|
ensure!(
|
|
*odin_topology_publisher_sequence > 0,
|
|
"managed Odin topology sequence is zero"
|
|
);
|
|
}
|
|
DependencyProviderAuthority::ExternalOperatorBinding { binding_target } => {
|
|
require_token(binding_target, "external binding target")?;
|
|
ensure!(
|
|
self.endpoint.is_some(),
|
|
"external binding has no configured endpoint"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn compatible_with(&self, dependency: &CapabilityDependency) -> bool {
|
|
capability_compatible(
|
|
&dependency.capability,
|
|
&dependency.schema,
|
|
&dependency.compatibility,
|
|
&self.capability,
|
|
&self.schema,
|
|
&self.compatibility,
|
|
) && self.capacity >= dependency.minimum_capacity
|
|
}
|
|
|
|
fn is_external_binding(&self) -> bool {
|
|
matches!(
|
|
&self.authority,
|
|
DependencyProviderAuthority::ExternalOperatorBinding { .. }
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn external_binding_provider_refs(
|
|
binding: &OperatorBinding,
|
|
) -> Result<Vec<DependencyProviderRef>> {
|
|
binding.validate()?;
|
|
let providers = binding
|
|
.external_capabilities
|
|
.iter()
|
|
.map(|capability| expected_external_provider(&binding.target, capability))
|
|
.collect::<Vec<_>>();
|
|
for provider in &providers {
|
|
provider.validate()?;
|
|
}
|
|
Ok(providers)
|
|
}
|
|
|
|
fn expected_external_provider(
|
|
binding_target: &str,
|
|
capability: &ExternalCapabilityBinding,
|
|
) -> DependencyProviderRef {
|
|
DependencyProviderRef {
|
|
provider_id: capability.provider_id.clone(),
|
|
authority: DependencyProviderAuthority::ExternalOperatorBinding {
|
|
binding_target: binding_target.to_owned(),
|
|
},
|
|
capability: capability.capability.clone(),
|
|
schema: capability.schema.clone(),
|
|
compatibility: capability.compatibility.clone(),
|
|
capacity: capability.capacity,
|
|
endpoint: Some(capability.endpoint.clone()),
|
|
}
|
|
}
|
|
|
|
/// Projects one authenticated Ready correlation into dependency-selection
|
|
/// inputs. Authentication is not replay admission: the control-plane store
|
|
/// must atomically admit `publisher_sequence` before calling this function and
|
|
/// must re-check the selected receipt before promotion.
|
|
fn managed_ready_provider_refs(
|
|
admitted: &SequenceAdmittedReady,
|
|
) -> Result<Vec<DependencyProviderRef>> {
|
|
let expected = admitted.expected();
|
|
let topology = admitted.authenticated();
|
|
expected.validate()?;
|
|
let expected_projection_sha256 = expected.canonical_sha256()?;
|
|
let observed = topology.record();
|
|
ensure!(
|
|
observed.expected_projection_sha256 == expected_projection_sha256,
|
|
"Ready provider topology names a different Expected projection"
|
|
);
|
|
ensure!(
|
|
observed.target == expected.target && observed.runtime_id == expected.runtime_id,
|
|
"Ready provider topology names a different target or runtime"
|
|
);
|
|
ensure!(
|
|
observed.expected
|
|
&& observed.present
|
|
&& observed.ready
|
|
&& observed.observed_presence_state.as_deref() == Some("active")
|
|
&& observed.disagreements.is_empty(),
|
|
"managed provider topology is not Ready"
|
|
);
|
|
let odin_topology_correlation_sha256 = admitted.evidence_sha256().to_owned();
|
|
let endpoint = expected
|
|
.route
|
|
.as_ref()
|
|
.map(|route| route.stable_endpoint.clone());
|
|
let mut providers = Vec::new();
|
|
for capability in &observed.observed_capabilities {
|
|
providers.push(DependencyProviderRef {
|
|
provider_id: expected.target.clone(),
|
|
authority: DependencyProviderAuthority::ManagedReady {
|
|
target: expected.target.clone(),
|
|
incarnation_id: expected.incarnation_id.clone(),
|
|
plan_id: expected.plan_id.clone(),
|
|
sealed_release_id: expected.sealed_release_id.clone(),
|
|
expected_projection_sha256: expected_projection_sha256.clone(),
|
|
odin_topology_correlation_sha256: odin_topology_correlation_sha256.clone(),
|
|
odin_topology_publisher_sequence: admitted.publisher_sequence(),
|
|
},
|
|
capability: capability.capability.clone(),
|
|
schema: capability.schema.clone(),
|
|
compatibility: capability.compatibility.clone(),
|
|
capacity: capability.capacity,
|
|
endpoint: endpoint.clone(),
|
|
});
|
|
}
|
|
for provider in &providers {
|
|
provider.validate()?;
|
|
}
|
|
Ok(providers)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DependencySelection {
|
|
pub requirement: CapabilityDependency,
|
|
pub provider: Option<DependencyProviderRef>,
|
|
}
|
|
|
|
impl DependencySelection {
|
|
fn validate(&self) -> Result<()> {
|
|
require_contract(
|
|
&self.requirement.capability,
|
|
&self.requirement.schema,
|
|
&self.requirement.compatibility,
|
|
)?;
|
|
ensure!(
|
|
self.requirement.minimum_capacity > 0,
|
|
"dependency capacity is zero"
|
|
);
|
|
if let Some(provider) = &self.provider {
|
|
provider.validate()?;
|
|
ensure!(
|
|
provider.compatible_with(&self.requirement),
|
|
"selected provider is incompatible"
|
|
);
|
|
ensure!(
|
|
(self.requirement.kind == DependencyKind::ExternalOperatorBinding)
|
|
== provider.is_external_binding(),
|
|
"selected provider authority does not match dependency kind"
|
|
);
|
|
} else {
|
|
ensure!(
|
|
self.requirement.kind == DependencyKind::Optional,
|
|
"required dependency is unresolved"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expected_projection(&self) -> Result<IdunnExpectedDependency> {
|
|
self.validate()?;
|
|
let (
|
|
provider_id,
|
|
provider_authority,
|
|
provider_expected_projection_sha256,
|
|
provider_endpoint,
|
|
) = match &self.provider {
|
|
None => (None, None, None, None),
|
|
Some(provider) => match &provider.authority {
|
|
DependencyProviderAuthority::ManagedReady {
|
|
expected_projection_sha256,
|
|
..
|
|
} => (
|
|
Some(provider.provider_id.clone()),
|
|
Some("managed-incarnation".into()),
|
|
Some(expected_projection_sha256.clone()),
|
|
provider.endpoint.clone(),
|
|
),
|
|
DependencyProviderAuthority::ExternalOperatorBinding { .. } => (
|
|
Some(provider.provider_id.clone()),
|
|
Some("external-operator-binding".into()),
|
|
None,
|
|
provider.endpoint.clone(),
|
|
),
|
|
},
|
|
};
|
|
Ok(IdunnExpectedDependency {
|
|
kind: dependency_kind_name(self.requirement.kind).into(),
|
|
capability: self.requirement.capability.clone(),
|
|
schema: self.requirement.schema.clone(),
|
|
compatibility: self.requirement.compatibility.clone(),
|
|
minimum_capacity: self.requirement.minimum_capacity,
|
|
startup: startup_order_name(self.requirement.startup).into(),
|
|
provider_id,
|
|
provider_authority,
|
|
provider_expected_projection_sha256,
|
|
provider_endpoint,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn select_dependencies(
|
|
declaration: &TargetDeclaration,
|
|
providers: &[DependencyProviderRef],
|
|
) -> Result<Vec<DependencySelection>> {
|
|
declaration.validate()?;
|
|
let mut unique_contracts = BTreeSet::new();
|
|
for provider in providers {
|
|
provider.validate()?;
|
|
ensure!(
|
|
unique_contracts.insert((
|
|
provider.provider_id.as_str(),
|
|
provider.capability.as_str(),
|
|
provider.schema.as_str(),
|
|
provider.compatibility.as_str(),
|
|
)),
|
|
"dependency provider contract is duplicated"
|
|
);
|
|
}
|
|
for conflict in &declaration.conflicts {
|
|
if providers
|
|
.iter()
|
|
.any(|provider| provider.capability == conflict.capability)
|
|
{
|
|
bail!(
|
|
"capability conflict {}: {}",
|
|
conflict.capability,
|
|
conflict.reason
|
|
);
|
|
}
|
|
}
|
|
declaration
|
|
.dependencies
|
|
.iter()
|
|
.map(|dependency| {
|
|
let mut candidates = providers
|
|
.iter()
|
|
.filter(|provider| provider.compatible_with(dependency))
|
|
.filter(|provider| {
|
|
(dependency.kind == DependencyKind::ExternalOperatorBinding)
|
|
== provider.is_external_binding()
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
candidates.sort_by(|left, right| {
|
|
left.provider_id
|
|
.cmp(&right.provider_id)
|
|
.then_with(|| left.endpoint.cmp(&right.endpoint))
|
|
});
|
|
let provider = candidates.into_iter().next();
|
|
if provider.is_none() && dependency.kind != DependencyKind::Optional {
|
|
bail!(
|
|
"no expected provider satisfies {} {} {}",
|
|
dependency.capability,
|
|
dependency.schema,
|
|
dependency.compatibility
|
|
);
|
|
}
|
|
let selection = DependencySelection {
|
|
requirement: dependency.clone(),
|
|
provider,
|
|
};
|
|
selection.validate()?;
|
|
Ok(selection)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Idunn's default duration for each post-fencing phase, in seconds. A binding
|
|
/// overrides any of them; the resolved values are frozen into the plan.
|
|
pub const DEFAULT_FENCING_DEADLINE_SECONDS: u32 = 120;
|
|
pub const DEFAULT_LEASING_DEADLINE_SECONDS: u32 = 120;
|
|
// Unsafe for streampixels-service (startup = "create-or-open-after-write-lease"):
|
|
// its Postgres migrations run inside AwaitingReady. B5 requires an explicit
|
|
// binding value for stateful targets instead of this default.
|
|
pub const DEFAULT_AWAITING_READY_DEADLINE_SECONDS: u32 = 300;
|
|
pub const DEFAULT_ROUTING_DEADLINE_SECONDS: u32 = 120;
|
|
pub const DEFAULT_COMMITTING_DEADLINE_SECONDS: u32 = 120;
|
|
|
|
/// The resolved per-phase deadline durations a plan carries. Nothing reads
|
|
/// them at the cut that introduced them.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct PhaseDeadlines {
|
|
pub fencing_seconds: u32,
|
|
pub leasing_seconds: u32,
|
|
pub awaiting_ready_seconds: u32,
|
|
pub routing_seconds: u32,
|
|
pub committing_seconds: u32,
|
|
}
|
|
|
|
impl PhaseDeadlines {
|
|
pub const IDUNN_DEFAULTS: Self = Self {
|
|
fencing_seconds: DEFAULT_FENCING_DEADLINE_SECONDS,
|
|
leasing_seconds: DEFAULT_LEASING_DEADLINE_SECONDS,
|
|
awaiting_ready_seconds: DEFAULT_AWAITING_READY_DEADLINE_SECONDS,
|
|
routing_seconds: DEFAULT_ROUTING_DEADLINE_SECONDS,
|
|
committing_seconds: DEFAULT_COMMITTING_DEADLINE_SECONDS,
|
|
};
|
|
|
|
/// Binding overrides over Idunn's defaults.
|
|
pub fn resolve(binding: Option<&DeadlineBinding>) -> Self {
|
|
let defaults = Self::IDUNN_DEFAULTS;
|
|
let Some(binding) = binding else {
|
|
return defaults;
|
|
};
|
|
Self {
|
|
fencing_seconds: binding.fencing_seconds.unwrap_or(defaults.fencing_seconds),
|
|
leasing_seconds: binding.leasing_seconds.unwrap_or(defaults.leasing_seconds),
|
|
awaiting_ready_seconds: binding
|
|
.awaiting_ready_seconds
|
|
.unwrap_or(defaults.awaiting_ready_seconds),
|
|
routing_seconds: binding.routing_seconds.unwrap_or(defaults.routing_seconds),
|
|
committing_seconds: binding
|
|
.committing_seconds
|
|
.unwrap_or(defaults.committing_seconds),
|
|
}
|
|
}
|
|
|
|
fn validate_against(&self, binding: &OperatorBinding) -> Result<()> {
|
|
let declared = binding.deadlines.clone().unwrap_or_default();
|
|
for (name, frozen, declared) in [
|
|
("fencing", self.fencing_seconds, declared.fencing_seconds),
|
|
("leasing", self.leasing_seconds, declared.leasing_seconds),
|
|
(
|
|
"awaiting-ready",
|
|
self.awaiting_ready_seconds,
|
|
declared.awaiting_ready_seconds,
|
|
),
|
|
("routing", self.routing_seconds, declared.routing_seconds),
|
|
(
|
|
"committing",
|
|
self.committing_seconds,
|
|
declared.committing_seconds,
|
|
),
|
|
] {
|
|
ensure!(
|
|
(1..=crate::deployment::MAXIMUM_DEADLINE_SECONDS).contains(&frozen),
|
|
"frozen {name} deadline is out of range"
|
|
);
|
|
if let Some(declared) = declared {
|
|
ensure!(
|
|
frozen == declared,
|
|
"frozen {name} deadline differs from the binding"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Private Idunn control-plane state. This contains host binding details and is
|
|
/// never an Odin/CultMesh projection. The shared CultNet Expected incarnation
|
|
/// is the sanitized topology projection derived only after a release validates
|
|
/// against this plan.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CompiledDeploymentPlan {
|
|
pub schema: String,
|
|
pub plan_id: String,
|
|
pub incarnation_id: String,
|
|
pub created_at_unix_millis: u64,
|
|
pub source: SourceSelectionFacts,
|
|
pub recipe_blob: Vec<u8>,
|
|
pub binding_blob: Vec<u8>,
|
|
pub dependencies: Vec<DependencySelection>,
|
|
pub candidate_port: Option<u16>,
|
|
/// Present exactly on v3 plans. Skipped when absent so a v2 plan re-encodes
|
|
/// to its original bytes and keeps its plan id.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub deadlines: Option<PhaseDeadlines>,
|
|
}
|
|
|
|
impl CompiledDeploymentPlan {
|
|
pub fn validate(&self) -> Result<()> {
|
|
ensure!(
|
|
self.schema == COMPILED_DEPLOYMENT_PLAN_SCHEMA
|
|
|| self.schema == COMPILED_DEPLOYMENT_PLAN_SCHEMA_V2,
|
|
"unsupported deployment-plan schema"
|
|
);
|
|
require_sha256(&self.plan_id, "plan id")?;
|
|
require_token(&self.incarnation_id, "plan incarnation")?;
|
|
ensure!(
|
|
self.created_at_unix_millis >= self.source.selected_at_unix_millis,
|
|
"deployment plan predates source selection"
|
|
);
|
|
let (declaration, binding) = self.parsed_inputs()?;
|
|
self.source.validate_against(&binding)?;
|
|
match (&self.schema[..], &self.deadlines) {
|
|
(COMPILED_DEPLOYMENT_PLAN_SCHEMA, Some(deadlines)) => deadlines.validate_against(&binding)?,
|
|
(COMPILED_DEPLOYMENT_PLAN_SCHEMA_V2, None) => {}
|
|
_ => bail!("deployment plan deadlines differ from its schema"),
|
|
}
|
|
ensure!(
|
|
sha256_id(&self.recipe_blob) == self.source.recipe_blob_sha256,
|
|
"selected recipe blob differs from the exact plan recipe bytes"
|
|
);
|
|
let declared_requirements: Vec<_> = declaration.dependencies.iter().collect();
|
|
let selected_requirements: Vec<_> = self
|
|
.dependencies
|
|
.iter()
|
|
.map(|selection| &selection.requirement)
|
|
.collect();
|
|
ensure!(
|
|
declared_requirements == selected_requirements,
|
|
"selected graph differs from recipe dependencies"
|
|
);
|
|
for selection in &self.dependencies {
|
|
selection.validate()?;
|
|
}
|
|
match (&binding.route, self.candidate_port) {
|
|
(Some(bound), Some(port)) => {
|
|
ensure!(
|
|
(bound.private_port_start..=bound.private_port_end).contains(&port),
|
|
"candidate port is outside the operator range"
|
|
);
|
|
}
|
|
(None, None) => {}
|
|
(Some(_), None) => bail!("routed deployment has no candidate port"),
|
|
(None, Some(_)) => bail!("private deployment selected a routed candidate port"),
|
|
}
|
|
ensure!(
|
|
self.plan_id == self.recomputed_plan_id()?,
|
|
"deployment plan digest is not canonical"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The deadline durations this plan runs under: frozen for v3, Idunn's
|
|
/// defaults for a v2 plan that predates them.
|
|
pub fn phase_deadlines(&self) -> PhaseDeadlines {
|
|
self.deadlines.unwrap_or(PhaseDeadlines::IDUNN_DEFAULTS)
|
|
}
|
|
|
|
pub(crate) fn parsed_inputs(&self) -> Result<(TargetDeclaration, OperatorBinding)> {
|
|
let recipe_text = std::str::from_utf8(&self.recipe_blob)
|
|
.context("stored deployment recipe is not UTF-8")?;
|
|
let binding_text = std::str::from_utf8(&self.binding_blob)
|
|
.context("stored operator binding is not UTF-8")?;
|
|
let declaration = TargetDeclaration::parse(recipe_text)?;
|
|
let binding = OperatorBinding::parse(binding_text)?;
|
|
binding.admit(&declaration)?;
|
|
Ok((declaration, binding))
|
|
}
|
|
|
|
/// How this plan's target proves readiness, from its recipe's declarations
|
|
/// and its binding's route, or the typed refusal that it declares none.
|
|
/// Admission asks this before anything is sealed or installed.
|
|
pub(crate) fn readiness_class(&self) -> Result<ReadinessClass> {
|
|
let (declaration, binding) = self.parsed_inputs()?;
|
|
Ok(ReadinessClass::declared(
|
|
&declaration.target,
|
|
declaration
|
|
.provides
|
|
.iter()
|
|
.any(|provided| provided.capability == ODIN_RENDEZVOUS_CAPABILITY),
|
|
declaration.dependencies.iter().any(|dependency| {
|
|
dependency.kind == DependencyKind::SharedInfrastructure
|
|
&& dependency.capability == ODIN_RENDEZVOUS_CAPABILITY
|
|
}),
|
|
binding.route.is_some(),
|
|
)?)
|
|
}
|
|
|
|
fn recomputed_plan_id(&self) -> Result<String> {
|
|
let mut unsigned = self.clone();
|
|
unsigned.plan_id.clear();
|
|
Ok(sha256_id(
|
|
&rmp_serde::to_vec(&unsigned).context("encoding deployment plan identity")?,
|
|
))
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn compile_deployment_plan(
|
|
recipe_bytes: &[u8],
|
|
binding_bytes: &[u8],
|
|
source: SourceSelectionFacts,
|
|
incarnation_id: impl Into<String>,
|
|
candidate_port: Option<u16>,
|
|
created_at_unix_millis: u64,
|
|
managed_ready_providers: &[SequenceAdmittedReady],
|
|
) -> Result<CompiledDeploymentPlan> {
|
|
let recipe_text = std::str::from_utf8(recipe_bytes).context("recipe is not UTF-8")?;
|
|
let binding_text = std::str::from_utf8(binding_bytes).context("binding is not UTF-8")?;
|
|
let declaration = TargetDeclaration::parse(recipe_text)?;
|
|
let binding = OperatorBinding::parse(binding_text)?;
|
|
binding.admit(&declaration)?;
|
|
source.validate_against(&binding)?;
|
|
ensure!(
|
|
sha256_id(recipe_bytes) == source.recipe_blob_sha256,
|
|
"selected recipe blob differs from compiled recipe bytes"
|
|
);
|
|
let incarnation_id = incarnation_id.into();
|
|
require_token(&incarnation_id, "deployment incarnation")?;
|
|
ensure!(
|
|
created_at_unix_millis >= source.selected_at_unix_millis,
|
|
"deployment plan predates source selection"
|
|
);
|
|
let mut providers = Vec::new();
|
|
for provider in managed_ready_providers {
|
|
providers.extend(managed_ready_provider_refs(provider)?);
|
|
}
|
|
providers.extend(external_binding_provider_refs(&binding)?);
|
|
let dependencies = select_dependencies(&declaration, &providers)?;
|
|
let mut plan = CompiledDeploymentPlan {
|
|
schema: COMPILED_DEPLOYMENT_PLAN_SCHEMA.into(),
|
|
plan_id: String::new(),
|
|
incarnation_id,
|
|
created_at_unix_millis,
|
|
source,
|
|
recipe_blob: recipe_bytes.to_vec(),
|
|
binding_blob: binding_bytes.to_vec(),
|
|
dependencies,
|
|
candidate_port,
|
|
deadlines: Some(PhaseDeadlines::resolve(binding.deadlines.as_ref())),
|
|
};
|
|
plan.plan_id = plan.recomputed_plan_id()?;
|
|
plan.validate()?;
|
|
Ok(plan)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ArtifactReceipt {
|
|
pub artifact_id: String,
|
|
pub destination: PathBuf,
|
|
pub sha256: String,
|
|
pub size_bytes: u64,
|
|
pub executable: bool,
|
|
}
|
|
|
|
impl ArtifactReceipt {
|
|
fn validate(&self, declaration: &TargetDeclaration) -> Result<()> {
|
|
require_token(&self.artifact_id, "artifact receipt id")?;
|
|
require_sha256(&self.sha256, "artifact digest")?;
|
|
ensure!(self.size_bytes > 0, "sealed artifact is empty");
|
|
let declared = declaration
|
|
.artifacts
|
|
.iter()
|
|
.find(|artifact| artifact.id == self.artifact_id)
|
|
.with_context(|| format!("plan declares no artifact {}", self.artifact_id))?;
|
|
ensure!(
|
|
self.destination == declared.destination && self.executable == declared.executable,
|
|
"artifact receipt differs from declared output"
|
|
);
|
|
if let Some(expected_sha256) = &declared.expected_sha256 {
|
|
ensure!(
|
|
self.sha256 == prefixed_sha256(expected_sha256),
|
|
"artifact receipt differs from the recipe-pinned digest"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExternalInputMaterializationReceipt {
|
|
pub input_id: String,
|
|
pub url: String,
|
|
pub sha256: String,
|
|
pub runner: String,
|
|
pub destination: PathBuf,
|
|
pub size_bytes: u64,
|
|
}
|
|
|
|
impl ExternalInputMaterializationReceipt {
|
|
fn validate(&self, declaration: &TargetDeclaration) -> Result<()> {
|
|
require_token(&self.input_id, "external input receipt id")?;
|
|
require_sha256(&self.sha256, "external input materialized digest")?;
|
|
ensure!(self.size_bytes > 0, "materialized external input is empty");
|
|
let declared = declaration
|
|
.external_inputs
|
|
.iter()
|
|
.find(|input| input.id == self.input_id)
|
|
.with_context(|| format!("plan declares no external input {}", self.input_id))?;
|
|
ensure!(
|
|
self.url == declared.url
|
|
&& self.sha256 == prefixed_sha256(&declared.sha256)
|
|
&& self.runner == declared.runner
|
|
&& self.destination == declared.destination,
|
|
"external input receipt differs from its pinned declaration"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Private Idunn release state. The content address covers the exact plan ID,
|
|
/// sorted full artifact receipts, and sorted full external-input
|
|
/// materialization receipts. The plan remains a separate private record and is
|
|
/// supplied explicitly for validation.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SealedRelease {
|
|
pub schema: String,
|
|
pub sealed_release_id: String,
|
|
pub plan_id: String,
|
|
pub artifacts: Vec<ArtifactReceipt>,
|
|
pub external_inputs: Vec<ExternalInputMaterializationReceipt>,
|
|
pub sealed_at_unix_millis: u64,
|
|
}
|
|
|
|
impl SealedRelease {
|
|
pub fn new(
|
|
plan: &CompiledDeploymentPlan,
|
|
mut artifacts: Vec<ArtifactReceipt>,
|
|
mut external_inputs: Vec<ExternalInputMaterializationReceipt>,
|
|
sealed_at_unix_millis: u64,
|
|
) -> Result<Self> {
|
|
plan.validate()?;
|
|
artifacts.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
|
|
external_inputs.sort_by(|left, right| left.input_id.cmp(&right.input_id));
|
|
let mut release = Self {
|
|
schema: SEALED_RELEASE_SCHEMA.into(),
|
|
sealed_release_id: String::new(),
|
|
plan_id: plan.plan_id.clone(),
|
|
artifacts,
|
|
external_inputs,
|
|
sealed_at_unix_millis,
|
|
};
|
|
release.validate_contents(plan)?;
|
|
release.sealed_release_id = release.recomputed_release_id()?;
|
|
release.validate_against(plan)?;
|
|
Ok(release)
|
|
}
|
|
|
|
pub fn validate_against(&self, plan: &CompiledDeploymentPlan) -> Result<()> {
|
|
require_sha256(&self.sealed_release_id, "sealed release id")?;
|
|
self.validate_contents(plan)?;
|
|
ensure!(
|
|
self.sealed_release_id == self.recomputed_release_id()?,
|
|
"sealed release content address is wrong"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn expected_projection(
|
|
&self,
|
|
plan: &CompiledDeploymentPlan,
|
|
) -> Result<ExpectedIncarnation> {
|
|
self.validate_against(plan)?;
|
|
let (declaration, binding) = plan.parsed_inputs()?;
|
|
let route = match (&binding.route, plan.candidate_port) {
|
|
(Some(bound), Some(port)) => Some(IdunnExpectedRoute {
|
|
route_id: bound.route_id.clone(),
|
|
transport: endpoint_scheme(declaration.service.transport)?.into(),
|
|
stable_endpoint: bound.stable_endpoint.clone(),
|
|
candidate_endpoint: format!(
|
|
"{}://{}:{port}",
|
|
endpoint_scheme(declaration.service.transport)?,
|
|
bound.private_host
|
|
),
|
|
}),
|
|
(None, None) => None,
|
|
_ => bail!("plan route and candidate port disagree"),
|
|
};
|
|
let artifact_sha256 = self
|
|
.artifacts
|
|
.iter()
|
|
.find(|artifact| artifact.artifact_id == declaration.service.executable_artifact)
|
|
.context("sealed release has no executable service artifact")?
|
|
.sha256
|
|
.clone();
|
|
let mut capabilities = declaration
|
|
.provides
|
|
.iter()
|
|
.map(|capability| IdunnExpectedCapability {
|
|
capability: capability.capability.clone(),
|
|
schema: capability.schema.clone(),
|
|
compatibility: capability.compatibility.clone(),
|
|
minimum_capacity: capability.capacity,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
capabilities.sort_by(|left, right| {
|
|
(&left.capability, &left.schema, &left.compatibility).cmp(&(
|
|
&right.capability,
|
|
&right.schema,
|
|
&right.compatibility,
|
|
))
|
|
});
|
|
let mut dependencies = plan
|
|
.dependencies
|
|
.iter()
|
|
.map(DependencySelection::expected_projection)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
dependencies.sort_by(|left, right| {
|
|
(&left.capability, &left.schema, &left.compatibility).cmp(&(
|
|
&right.capability,
|
|
&right.schema,
|
|
&right.compatibility,
|
|
))
|
|
});
|
|
let expected = ExpectedIncarnation {
|
|
schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(),
|
|
target: declaration.target.clone(),
|
|
plan_id: plan.plan_id.clone(),
|
|
incarnation_id: plan.incarnation_id.clone(),
|
|
sealed_release_id: self.sealed_release_id.clone(),
|
|
source_repository: canonical_repository_identity(&plan.source.origin)?,
|
|
source_revision: plan.source.revision.clone(),
|
|
recipe_sha256: plan.source.recipe_blob_sha256.clone(),
|
|
runtime_id: binding.runtime_identity.runtime_id.clone(),
|
|
expected_signer_identity_id: binding
|
|
.runtime_identity
|
|
.expected_signer_identity_id
|
|
.clone(),
|
|
health_contract: declaration.service.health.contract.clone(),
|
|
artifact_sha256,
|
|
state_schema_generation: declaration
|
|
.state
|
|
.as_ref()
|
|
.map(|state| state.schema_generation.clone()),
|
|
state_contract_sha256: declaration
|
|
.state
|
|
.as_ref()
|
|
.map(canonical_state_contract_sha256)
|
|
.transpose()?,
|
|
write_lease_required: declaration.write_lease_required(),
|
|
route,
|
|
capabilities,
|
|
dependencies,
|
|
};
|
|
expected.validate()?;
|
|
Ok(expected)
|
|
}
|
|
|
|
fn validate_contents(&self, plan: &CompiledDeploymentPlan) -> Result<()> {
|
|
ensure!(
|
|
self.schema == SEALED_RELEASE_SCHEMA,
|
|
"unsupported sealed-release schema"
|
|
);
|
|
plan.validate()?;
|
|
let (declaration, _) = plan.parsed_inputs()?;
|
|
require_sha256(&self.plan_id, "sealed plan id")?;
|
|
ensure!(
|
|
self.plan_id == plan.plan_id,
|
|
"sealed release refers to a different plan"
|
|
);
|
|
ensure!(
|
|
self.sealed_at_unix_millis >= plan.created_at_unix_millis,
|
|
"release was sealed before it was planned"
|
|
);
|
|
ensure!(
|
|
self.artifacts
|
|
.windows(2)
|
|
.all(|pair| pair[0].artifact_id < pair[1].artifact_id),
|
|
"artifact receipts are not in canonical order"
|
|
);
|
|
ensure!(
|
|
self.external_inputs
|
|
.windows(2)
|
|
.all(|pair| pair[0].input_id < pair[1].input_id),
|
|
"external input receipts are not in canonical order"
|
|
);
|
|
let declared_ids = declaration
|
|
.artifacts
|
|
.iter()
|
|
.map(|artifact| artifact.id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let observed_ids = self
|
|
.artifacts
|
|
.iter()
|
|
.map(|artifact| artifact.artifact_id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
ensure!(
|
|
declared_ids == observed_ids && observed_ids.len() == self.artifacts.len(),
|
|
"artifact receipts differ from declared outputs"
|
|
);
|
|
for artifact in &self.artifacts {
|
|
artifact.validate(&declaration)?;
|
|
}
|
|
let declared_input_ids = declaration
|
|
.external_inputs
|
|
.iter()
|
|
.map(|input| input.id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let observed_input_ids = self
|
|
.external_inputs
|
|
.iter()
|
|
.map(|input| input.input_id.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
ensure!(
|
|
declared_input_ids == observed_input_ids
|
|
&& observed_input_ids.len() == self.external_inputs.len(),
|
|
"external input receipts differ from pinned declarations"
|
|
);
|
|
for input in &self.external_inputs {
|
|
input.validate(&declaration)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn recomputed_release_id(&self) -> Result<String> {
|
|
Ok(sha256_id(
|
|
&rmp_serde::to_vec(&(
|
|
self.schema.as_str(),
|
|
self.plan_id.as_str(),
|
|
&self.artifacts,
|
|
&self.external_inputs,
|
|
))
|
|
.context("encoding sealed release identity")?,
|
|
))
|
|
}
|
|
}
|
|
|
|
fn canonical_state_contract_sha256(state: &StateDeclaration) -> Result<String> {
|
|
Ok(sha256_id(
|
|
&rmp_serde::to_vec(state).context("encoding canonical state contract")?,
|
|
))
|
|
}
|
|
|
|
fn endpoint_scheme(transport: ServiceTransport) -> Result<&'static str> {
|
|
match transport {
|
|
ServiceTransport::Http => Ok("http"),
|
|
ServiceTransport::Tcp => Ok("tcp"),
|
|
ServiceTransport::Rudp => Ok("rudp"),
|
|
// Private transport is the only one with no route: `route_required` is
|
|
// false for it, so a private target never reaches this function. RUDP
|
|
// does route -- `admit` accepts (route_required, Rudp, nginx-stream-udp)
|
|
// against an `rudp://` stable endpoint, and the nginx driver already
|
|
// writes `listen ... udp reuseport` for it.
|
|
ServiceTransport::Private => {
|
|
bail!("service transport has no stable route scheme")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dependency_kind_name(kind: DependencyKind) -> &'static str {
|
|
match kind {
|
|
DependencyKind::Bootstrap => "bootstrap",
|
|
DependencyKind::Required => "required",
|
|
DependencyKind::Optional => "optional",
|
|
DependencyKind::SharedInfrastructure => "shared-infrastructure",
|
|
DependencyKind::Private => "private",
|
|
DependencyKind::ExternalOperatorBinding => "external-operator-binding",
|
|
}
|
|
}
|
|
|
|
fn startup_order_name(startup: StartupOrder) -> &'static str {
|
|
match startup {
|
|
StartupOrder::BeforePromotion => "before-promotion",
|
|
StartupOrder::BeforeStart => "before-start",
|
|
}
|
|
}
|
|
|
|
fn canonical_repository_identity(origin: &str) -> Result<String> {
|
|
let identity = origin
|
|
.strip_prefix("https://")
|
|
.and_then(|origin| origin.strip_suffix(".git"))
|
|
.context("validated Git origin is not a canonical HTTPS repository")?;
|
|
Ok(identity.to_owned())
|
|
}
|
|
|
|
fn prefixed_sha256(raw_sha256: &str) -> String {
|
|
format!("sha256-{raw_sha256}")
|
|
}
|
|
|
|
fn require_token(value: &str, label: &str) -> Result<()> {
|
|
ensure!(
|
|
!value.is_empty() && value.len() <= 128,
|
|
"{label} is empty or too long"
|
|
);
|
|
ensure!(
|
|
value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric()
|
|
|| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')),
|
|
"{label} contains a forbidden character"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_contract(capability: &str, schema: &str, compatibility: &str) -> Result<()> {
|
|
require_token(capability, "capability")?;
|
|
require_token(schema, "capability schema")?;
|
|
require_token(compatibility, "capability compatibility")
|
|
}
|
|
|
|
fn require_value(value: &str, label: &str) -> Result<()> {
|
|
ensure!(
|
|
!value.trim().is_empty() && value == value.trim(),
|
|
"{label} is empty or noncanonical"
|
|
);
|
|
ensure!(
|
|
value.len() <= 4096 && !value.contains('\0'),
|
|
"{label} is oversized or contains NUL"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_sha1(value: &str, label: &str) -> Result<()> {
|
|
require_lower_hex(value, 40, label)
|
|
}
|
|
|
|
fn require_sha256(value: &str, label: &str) -> Result<()> {
|
|
let digest = value
|
|
.strip_prefix("sha256-")
|
|
.with_context(|| format!("{label} has no sha256- prefix"))?;
|
|
require_lower_hex(digest, 64, label)
|
|
}
|
|
|
|
fn require_lower_hex(value: &str, length: usize, label: &str) -> Result<()> {
|
|
ensure!(value.len() == length, "{label} has the wrong length");
|
|
ensure!(
|
|
value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
|
|
"{label} is not lowercase hexadecimal"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn sha256_id(bytes: &[u8]) -> String {
|
|
let digest = Sha256::digest(bytes);
|
|
let mut encoded = String::with_capacity(71);
|
|
encoded.push_str("sha256-");
|
|
for byte in digest {
|
|
use std::fmt::Write as _;
|
|
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
|
}
|
|
encoded
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
use cultnet_rs::{
|
|
GameCultProviderHealthIdentity, GameCultRuntimeCapability, IdunnRuntimeActivationLaunch,
|
|
IdunnServiceIdentity, ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA,
|
|
OdinRuntimeTopologyCorrelationPurpose, OdinRuntimeTopologyCorrelationRecord,
|
|
OdinTopologyAuthenticationContext, OdinTopologyIdentity,
|
|
authenticate_odin_runtime_topology_correlation, enroll_service_identity_at,
|
|
verify_runtime_authority,
|
|
};
|
|
|
|
pub(crate) const RECIPE: &str = r#"
|
|
schema = "gamecult.idunn.target_declaration.v1"
|
|
target = "service"
|
|
source_stamp_environment = "SERVICE_BUILD_COMMIT"
|
|
|
|
[[steps]]
|
|
id = "build"
|
|
phase = "build"
|
|
runner = "rust"
|
|
argv = ["cargo", "build", "--locked"]
|
|
|
|
[[external_inputs]]
|
|
id = "toolchain-index"
|
|
url = "https://example.invalid/toolchain-index"
|
|
sha256 = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
|
runner = "rust"
|
|
destination = "inputs/toolchain-index"
|
|
|
|
[[artifacts]]
|
|
id = "daemon"
|
|
source_kind = "runner-output"
|
|
runner = "rust"
|
|
source = "target/release/service"
|
|
destination = "service"
|
|
expected_sha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
|
executable = true
|
|
|
|
[service]
|
|
executable_artifact = "daemon"
|
|
arguments = [
|
|
{ kind = "binding", name = "state_root" },
|
|
]
|
|
transport = "http"
|
|
route_required = true
|
|
required_environment = ["GAMECULT_IDUNN_CANDIDATE_BIND", "GAMECULT_IDUNN_RUNTIME_BUNDLE"]
|
|
|
|
[service.health]
|
|
contract = "service.health"
|
|
|
|
[state]
|
|
schema_generation = "v1"
|
|
|
|
[[state.slots]]
|
|
id = "runtime-cache"
|
|
relative_path = "runtime-cache.cc"
|
|
kind = "cultcache-file"
|
|
schema = "service.runtime-cache.v1"
|
|
writer = "none"
|
|
recovery = "rebuildable"
|
|
startup = "open-at-start"
|
|
|
|
[[provides]]
|
|
capability = "service.runtime"
|
|
schema = "service.runtime.v1"
|
|
compatibility = "v1"
|
|
|
|
[[dependencies]]
|
|
kind = "shared-infrastructure"
|
|
capability = "odin.verse-rendezvous"
|
|
schema = "odin.verse-topology.v1"
|
|
compatibility = "v1"
|
|
"#;
|
|
|
|
pub(crate) const BINDING: &str = r#"
|
|
schema = "gamecult.idunn.operator_binding.v2"
|
|
target = "service"
|
|
|
|
[repository]
|
|
origin = "https://github.com/GameCult/Service.git"
|
|
admitted_ref = "refs/heads/main"
|
|
minimum_revision = "1111111111111111111111111111111111111111"
|
|
selection = "ref-head"
|
|
checkout = "/srv/build/Service"
|
|
recipe_path = "deployment/idunn/recipe.toml"
|
|
|
|
[runners.rust]
|
|
driver = "docker"
|
|
image = "rust@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
user = "1000:1000"
|
|
affordances = ["source-read", "artifact-write"]
|
|
allowed_programs = ["cargo"]
|
|
network_profile = "build-dependency-egress"
|
|
memory_mebibytes = 2048
|
|
cpu_quota_percent = 200
|
|
pids_limit = 512
|
|
tmpfs_mebibytes = 512
|
|
|
|
[workload]
|
|
driver = "systemd-transient"
|
|
state_group = "service"
|
|
unit_prefix = "idunn-service"
|
|
release_root = "/srv/service/releases"
|
|
state_root = "/var/lib/gamecult/service"
|
|
runtime_root = "/run/gamecult/idunn/service"
|
|
network = "host-private"
|
|
hardening = "strict"
|
|
memory_mebibytes = 1024
|
|
cpu_quota_percent = 100
|
|
|
|
[workload.argument_bindings]
|
|
state_root = "/var/lib/gamecult/service"
|
|
|
|
[workload.secret_files]
|
|
GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-identity.cc"
|
|
|
|
[runtime_identity]
|
|
runtime_id = "service-yggdrasil"
|
|
expected_signer_identity_id = "service-runtime-signer"
|
|
trust_anchor_store = "/etc/gamecult/trust/service.cc"
|
|
|
|
[route]
|
|
driver = "nginx-stream-tcp"
|
|
route_id = "service"
|
|
stable_endpoint = "http://127.0.0.1:17999"
|
|
private_host = "127.0.0.1"
|
|
private_port_start = 18000
|
|
private_port_end = 18009
|
|
config_path = "/etc/nginx/idunn-stream-routes/service.conf"
|
|
reload_unit = "nginx.service"
|
|
|
|
[brakes]
|
|
deployment_store = "/var/lib/gamecult/idunn/service-deployment-brake.cc"
|
|
lifecycle_store = "/var/lib/gamecult/idunn/service-lifecycle-brake.cc"
|
|
|
|
[rollout]
|
|
strategy = "candidate-then-promote"
|
|
drain_seconds = 30
|
|
retain_releases = 2
|
|
|
|
[placement]
|
|
desired_replicas = 1
|
|
nodes = ["yggdrasil"]
|
|
"#;
|
|
|
|
fn digest(byte: u8) -> String {
|
|
format!("sha256-{}", char::from(byte).to_string().repeat(64))
|
|
}
|
|
|
|
pub(crate) fn source(recipe: &str) -> SourceSelectionFacts {
|
|
SourceSelectionFacts {
|
|
schema: SOURCE_SELECTION_FACTS_SCHEMA.into(),
|
|
origin: "https://github.com/GameCult/Service.git".into(),
|
|
admitted_ref: "refs/heads/main".into(),
|
|
admitted_ref_revision: "2222222222222222222222222222222222222222".into(),
|
|
revision: "2222222222222222222222222222222222222222".into(),
|
|
source_tree: "3333333333333333333333333333333333333333".into(),
|
|
recipe_path: "deployment/idunn/recipe.toml".into(),
|
|
recipe_blob_sha256: sha256_id(recipe.as_bytes()),
|
|
gitlinks: BTreeMap::new(),
|
|
selection: SourceSelection::RefHead,
|
|
selected_at_unix_millis: 100,
|
|
}
|
|
}
|
|
|
|
fn odin() -> ExpectedIncarnation {
|
|
ExpectedIncarnation {
|
|
schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(),
|
|
target: "odin".into(),
|
|
plan_id: digest(b'a'),
|
|
incarnation_id: "odin-incarnation-1".into(),
|
|
sealed_release_id: digest(b'e'),
|
|
source_repository: "github.com/GameCult/Odin".into(),
|
|
source_revision: "2222222222222222222222222222222222222222".into(),
|
|
recipe_sha256: digest(b'b'),
|
|
runtime_id: "odin-yggdrasil".into(),
|
|
expected_signer_identity_id: "odin-runtime-signer".into(),
|
|
health_contract: "odin.cultnet-service-health".into(),
|
|
artifact_sha256: digest(b'f'),
|
|
state_schema_generation: None,
|
|
state_contract_sha256: None,
|
|
write_lease_required: false,
|
|
route: Some(IdunnExpectedRoute {
|
|
route_id: "odin-private".into(),
|
|
transport: "tcp".into(),
|
|
stable_endpoint: "tcp://10.77.0.1:17871".into(),
|
|
candidate_endpoint: "tcp://127.0.0.1:17871".into(),
|
|
}),
|
|
capabilities: vec![IdunnExpectedCapability {
|
|
capability: "odin.verse-rendezvous".into(),
|
|
schema: "odin.verse-topology.v1".into(),
|
|
compatibility: "v1".into(),
|
|
minimum_capacity: 1,
|
|
}],
|
|
dependencies: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn ready_odin_provider(target: &str, runtime_id: &str, capacity: u32) -> SequenceAdmittedReady {
|
|
(|| -> Result<SequenceAdmittedReady> {
|
|
let root = tempfile::tempdir()?;
|
|
let provider = enroll_service_identity_at::<GameCultProviderHealthIdentity>(
|
|
&root.path().join("provider.cc"),
|
|
)?;
|
|
let idunn =
|
|
enroll_service_identity_at::<IdunnServiceIdentity>(&root.path().join("idunn.cc"))?;
|
|
let odin_signer =
|
|
enroll_service_identity_at::<OdinTopologyIdentity>(&root.path().join("odin.cc"))?;
|
|
let mut expected = odin();
|
|
expected.target = target.into();
|
|
expected.runtime_id = runtime_id.into();
|
|
expected.expected_signer_identity_id = provider.entry().identity_id.clone();
|
|
let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest(b'8'), 100, &idunn)?;
|
|
let activation = launch.activation().clone();
|
|
launch.write_credential(std::io::sink())?;
|
|
let authority = verify_runtime_authority(
|
|
&expected,
|
|
&activation,
|
|
&idunn.trust_anchor()?,
|
|
&provider.entry().public_key,
|
|
)?;
|
|
let mut topology = OdinRuntimeTopologyCorrelationRecord {
|
|
schema_version: ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA.into(),
|
|
target: expected.target.clone(),
|
|
expected_projection_sha256: expected.canonical_sha256()?,
|
|
expected: true,
|
|
current_activation_sha256: Some(activation.canonical_sha256()?),
|
|
signed_presence_sha256: Some(digest(b'9')),
|
|
observed_presence_state: Some("active".into()),
|
|
observed_presence_publisher_sequence: Some(1),
|
|
observed_write_lease_sha256: None,
|
|
observed_capabilities: vec![GameCultRuntimeCapability {
|
|
capability: "odin.verse-rendezvous".into(),
|
|
schema: "odin.verse-topology.v1".into(),
|
|
compatibility: "v1".into(),
|
|
capacity,
|
|
}],
|
|
runtime_id: expected.runtime_id.clone(),
|
|
runtime_instance_id: Some(activation.runtime_instance_id.clone()),
|
|
present: true,
|
|
ready: true,
|
|
dependencies: Vec::new(),
|
|
disagreements: Vec::new(),
|
|
signer_identity_id: odin_signer.entry().identity_id.clone(),
|
|
publisher_sequence: 7,
|
|
observed_at_unix_millis: 110,
|
|
signature_algorithm: "ed25519".into(),
|
|
signature: Vec::new(),
|
|
};
|
|
topology.signature = odin_signer
|
|
.sign::<OdinRuntimeTopologyCorrelationPurpose>(
|
|
&topology.unsigned_signature_payload()?,
|
|
)
|
|
.signature;
|
|
let bytes = topology.canonical_bytes()?;
|
|
let authenticated = authenticate_odin_runtime_topology_correlation(
|
|
&bytes,
|
|
&authority,
|
|
None,
|
|
&odin_signer.entry().public_key,
|
|
OdinTopologyAuthenticationContext {
|
|
trusted_received_at_unix_millis: 120,
|
|
maximum_age_millis: 30,
|
|
maximum_future_skew_millis: 5,
|
|
},
|
|
)?;
|
|
SequenceAdmittedReady::for_test(&expected, authenticated, 120)
|
|
})()
|
|
.unwrap()
|
|
}
|
|
|
|
fn plan() -> CompiledDeploymentPlan {
|
|
let providers = [ready_odin_provider("odin", "odin-yggdrasil", 1)];
|
|
plan_with(&providers)
|
|
}
|
|
|
|
fn plan_with(providers: &[SequenceAdmittedReady]) -> CompiledDeploymentPlan {
|
|
compile_deployment_plan(
|
|
RECIPE.as_bytes(),
|
|
BINDING.as_bytes(),
|
|
source(RECIPE),
|
|
"service-incarnation-1",
|
|
Some(18001),
|
|
110,
|
|
providers,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
pub(crate) fn artifact_receipt() -> ArtifactReceipt {
|
|
ArtifactReceipt {
|
|
artifact_id: "daemon".into(),
|
|
destination: "service".into(),
|
|
sha256: digest(b'c'),
|
|
size_bytes: 42,
|
|
executable: true,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn external_input_receipt() -> ExternalInputMaterializationReceipt {
|
|
ExternalInputMaterializationReceipt {
|
|
input_id: "toolchain-index".into(),
|
|
url: "https://example.invalid/toolchain-index".into(),
|
|
sha256: digest(b'd'),
|
|
runner: "rust".into(),
|
|
destination: "inputs/toolchain-index".into(),
|
|
size_bytes: 17,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compiler_parses_the_exact_bytes_it_receipts() {
|
|
let providers = [ready_odin_provider("odin", "odin-yggdrasil", 1)];
|
|
let first = plan_with(&providers);
|
|
let second = plan_with(&providers);
|
|
assert_eq!(first, second);
|
|
assert_eq!(first.recipe_blob.as_slice(), RECIPE.as_bytes());
|
|
assert_eq!(first.binding_blob.as_slice(), BINDING.as_bytes());
|
|
assert_eq!(
|
|
sha256_id(&first.recipe_blob),
|
|
first.source.recipe_blob_sha256
|
|
);
|
|
let changed = RECIPE.replace(
|
|
"contract = \"service.health\"",
|
|
"contract = \"service.health.v2\"",
|
|
);
|
|
let providers = [ready_odin_provider("odin", "odin-yggdrasil", 1)];
|
|
assert!(
|
|
compile_deployment_plan(
|
|
changed.as_bytes(),
|
|
BINDING.as_bytes(),
|
|
source(RECIPE),
|
|
"service-incarnation-2",
|
|
Some(18002),
|
|
111,
|
|
&providers,
|
|
)
|
|
.is_err()
|
|
);
|
|
let mut corrupted = first;
|
|
corrupted
|
|
.binding_blob
|
|
.extend_from_slice(b"\nunknown = true\n");
|
|
assert!(corrupted.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn pinned_policy_requires_the_exact_operator_pin() {
|
|
let pinned_binding = BINDING.replace(
|
|
"selection = \"ref-head\"",
|
|
"selection = \"pinned-object\"\npinned_revision = \"2222222222222222222222222222222222222222\"",
|
|
);
|
|
let binding = OperatorBinding::parse(&pinned_binding).unwrap();
|
|
assert!(source(RECIPE).validate_against(&binding).is_err());
|
|
let mut facts = source(RECIPE);
|
|
facts.selection = SourceSelection::PinnedObject;
|
|
facts.validate_against(&binding).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn gitlink_fact_must_match_the_selected_superproject_tree() {
|
|
let recipe = RECIPE.replace(
|
|
"source_stamp_environment = \"SERVICE_BUILD_COMMIT\"",
|
|
"source_stamp_environment = \"SERVICE_BUILD_COMMIT\"\nrequired_gitlinks = [\"vendor/lib\"]",
|
|
);
|
|
let binding_text = BINDING.replace(
|
|
"recipe_path = \"deployment/idunn/recipe.toml\"",
|
|
"recipe_path = \"deployment/idunn/recipe.toml\"\ngitlinks = { \"vendor/lib\" = { origin = \"https://github.com/GameCult/Lib.git\" } }",
|
|
);
|
|
let binding = OperatorBinding::parse(&binding_text).unwrap();
|
|
binding
|
|
.admit(&TargetDeclaration::parse(&recipe).unwrap())
|
|
.unwrap();
|
|
let mut facts = source(&recipe);
|
|
facts.gitlinks.insert(
|
|
"vendor/lib".into(),
|
|
GitlinkTreeFact {
|
|
origin: "https://github.com/GameCult/Lib.git".into(),
|
|
revision: "4444444444444444444444444444444444444444".into(),
|
|
},
|
|
);
|
|
facts.validate_against(&binding).unwrap();
|
|
facts
|
|
.gitlinks
|
|
.get_mut(&PathBuf::from("vendor/lib"))
|
|
.unwrap()
|
|
.origin = "https://github.com/GameCult/Wrong.git".into();
|
|
assert!(facts.validate_against(&binding).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_selection_uses_observed_ready_capacity_and_stable_identity_order() {
|
|
let declaration = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let admitted = [
|
|
ready_odin_provider("odin-z", "odin-runtime-z", 4),
|
|
ready_odin_provider("odin-yggdrasil", "odin-runtime-yggdrasil", 2),
|
|
];
|
|
let providers = admitted
|
|
.iter()
|
|
.flat_map(|provider| managed_ready_provider_refs(provider).unwrap())
|
|
.collect::<Vec<_>>();
|
|
let selected = select_dependencies(&declaration, &providers).unwrap();
|
|
assert_eq!(
|
|
selected[0].provider.as_ref().unwrap().provider_id,
|
|
"odin-yggdrasil"
|
|
);
|
|
assert_eq!(selected[0].provider.as_ref().unwrap().capacity, 2);
|
|
assert_eq!(
|
|
selected[0].provider.as_ref().unwrap().endpoint.as_deref(),
|
|
Some("tcp://10.77.0.1:17871")
|
|
);
|
|
assert!(matches!(
|
|
&selected[0].provider.as_ref().unwrap().authority,
|
|
DependencyProviderAuthority::ManagedReady {
|
|
odin_topology_publisher_sequence: 7,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn external_binding_produces_expected_configuration_only() {
|
|
let input = BINDING.replace(
|
|
"[placement]",
|
|
"[[external_capabilities]]\nprovider_id = \"operator-archive\"\ncapability = \"archive.store\"\nschema = \"archive.store.v1\"\ncompatibility = \"v1\"\ncapacity = 2\nendpoint = \"s3://operator-bound\"\n\n[placement]",
|
|
);
|
|
let binding = OperatorBinding::parse(&input).unwrap();
|
|
let providers = external_binding_provider_refs(&binding).unwrap();
|
|
let declaration = TargetDeclaration::parse(&RECIPE.replace(
|
|
"kind = \"shared-infrastructure\"\ncapability = \"odin.verse-rendezvous\"\nschema = \"odin.verse-topology.v1\"",
|
|
"kind = \"external-operator-binding\"\ncapability = \"archive.store\"\nschema = \"archive.store.v1\"",
|
|
))
|
|
.unwrap();
|
|
binding.admit(&declaration).unwrap();
|
|
let selected = select_dependencies(&declaration, &providers).unwrap();
|
|
assert!(matches!(
|
|
&providers[0].authority,
|
|
DependencyProviderAuthority::ExternalOperatorBinding { .. }
|
|
));
|
|
assert_eq!(providers[0].capacity, 2);
|
|
assert_eq!(
|
|
selected[0].provider.as_ref().unwrap().provider_id,
|
|
"operator-archive"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_plan_names_how_its_target_proves_readiness_and_agrees_with_its_expected() {
|
|
use crate::control_plane::UndeclaredReadiness;
|
|
let unrouted_binding = {
|
|
let (head, tail) = BINDING.split_once("[route]").unwrap();
|
|
format!("{head}[brakes]{}", tail.split_once("[brakes]").unwrap().1)
|
|
};
|
|
let unrouted = |recipe: &str| {
|
|
recipe
|
|
.replace("route_required = true", "route_required = false")
|
|
.replace(
|
|
r#"["GAMECULT_IDUNN_CANDIDATE_BIND", "GAMECULT_IDUNN_RUNTIME_BUNDLE"]"#,
|
|
r#"["GAMECULT_IDUNN_RUNTIME_BUNDLE"]"#,
|
|
)
|
|
};
|
|
let without_odin = RECIPE.split("[[dependencies]]").next().unwrap().to_owned();
|
|
let provides_odin = |recipe: &str| {
|
|
recipe.replace("capability = \"service.runtime\"", "capability = \"odin.verse-rendezvous\"")
|
|
};
|
|
let cases: [(&str, String, &str, Option<u16>, Result<ReadinessClass, ()>); 6] = [
|
|
("depends on Odin, routed", RECIPE.to_owned(), BINDING, Some(18001), Ok(ReadinessClass::OdinCorrelated)),
|
|
("routed, no Odin", without_odin.clone(), BINDING, Some(18001), Ok(ReadinessClass::RouteProof)),
|
|
("unrouted, no Odin", unrouted(&without_odin), &unrouted_binding, None, Err(())),
|
|
("provides Odin, unrouted", provides_odin(&unrouted(&without_odin)), &unrouted_binding, None, Ok(ReadinessClass::OdinSelf)),
|
|
("depends on Odin, unrouted", unrouted(RECIPE), &unrouted_binding, None, Ok(ReadinessClass::OdinCorrelated)),
|
|
// Only the shared-infrastructure declaration is the declaration.
|
|
("requires the Odin capability, routed", RECIPE.replace("kind = \"shared-infrastructure\"", "kind = \"required\""), BINDING, Some(18001), Ok(ReadinessClass::RouteProof)),
|
|
];
|
|
for (name, recipe, binding, port, outcome) in cases {
|
|
let providers = [ready_odin_provider("odin", "odin-yggdrasil", 1)];
|
|
let plan = compile_deployment_plan(
|
|
recipe.as_bytes(),
|
|
binding.as_bytes(),
|
|
source(&recipe),
|
|
"service-incarnation-1",
|
|
port,
|
|
110,
|
|
if recipe.contains("[[dependencies]]") { &providers } else { &[] },
|
|
)
|
|
.unwrap_or_else(|error| panic!("{name}: {error:#}"));
|
|
let release = SealedRelease::new(
|
|
&plan,
|
|
vec![artifact_receipt()],
|
|
vec![external_input_receipt()],
|
|
120,
|
|
)
|
|
.unwrap();
|
|
let expected = release.expected_projection(&plan).unwrap();
|
|
match outcome {
|
|
Ok(class) => {
|
|
assert_eq!(plan.readiness_class().unwrap(), class, "{name}");
|
|
assert_eq!(ReadinessClass::of(&expected), Ok(class), "{name}");
|
|
}
|
|
Err(()) => {
|
|
let error = plan.readiness_class().expect_err(name);
|
|
let undeclared = UndeclaredReadiness {
|
|
target: "service".into(),
|
|
};
|
|
assert_eq!(error.downcast_ref::<UndeclaredReadiness>(), Some(&undeclared), "{name}");
|
|
assert_eq!(ReadinessClass::of(&expected), Err(undeclared), "{name}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expected_projection_is_sanitized_and_names_the_selected_graph() {
|
|
let plan = plan();
|
|
let release = SealedRelease::new(
|
|
&plan,
|
|
vec![artifact_receipt()],
|
|
vec![external_input_receipt()],
|
|
120,
|
|
)
|
|
.unwrap();
|
|
let expected = release.expected_projection(&plan).unwrap();
|
|
assert_eq!(expected.plan_id, plan.plan_id);
|
|
assert_eq!(expected.sealed_release_id, release.sealed_release_id);
|
|
assert_eq!(expected.incarnation_id, "service-incarnation-1");
|
|
assert_eq!(expected.runtime_id, "service-yggdrasil");
|
|
assert_eq!(
|
|
expected.expected_signer_identity_id,
|
|
"service-runtime-signer"
|
|
);
|
|
assert_eq!(expected.health_contract, "service.health");
|
|
assert_eq!(expected.artifact_sha256, digest(b'c'));
|
|
assert_eq!(expected.source_repository, "github.com/GameCult/Service");
|
|
assert_eq!(expected.source_revision, plan.source.revision);
|
|
assert_eq!(expected.recipe_sha256, plan.source.recipe_blob_sha256);
|
|
assert_eq!(expected.state_schema_generation.as_deref(), Some("v1"));
|
|
assert!(expected.state_contract_sha256.is_some());
|
|
assert!(!expected.write_lease_required);
|
|
assert_eq!(
|
|
expected.dependencies[0].provider_id.as_deref(),
|
|
Some("odin")
|
|
);
|
|
assert_eq!(
|
|
expected.dependencies[0].provider_authority.as_deref(),
|
|
Some("managed-incarnation")
|
|
);
|
|
assert_eq!(
|
|
expected.route.as_ref().unwrap().candidate_endpoint,
|
|
"http://127.0.0.1:18001"
|
|
);
|
|
assert_eq!(expected.route.as_ref().unwrap().transport, "http");
|
|
assert!(expected.canonical_sha256().unwrap().starts_with("sha256-"));
|
|
let mut invalid_release = release;
|
|
invalid_release.artifacts[0].size_bytes += 1;
|
|
assert!(invalid_release.expected_projection(&plan).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn routed_rudp_target_projects_an_rudp_route() {
|
|
// Odin is the only routed RUDP target, and it is the bootstrap target:
|
|
// if this projection cannot be built, nothing else can ever warm.
|
|
let recipe = RECIPE.replace("transport = \"http\"", "transport = \"rudp\"");
|
|
let binding = BINDING
|
|
.replace("nginx-stream-tcp", "nginx-stream-udp")
|
|
.replace("http://127.0.0.1:17999", "rudp://127.0.0.1:17999");
|
|
let plan = compile_deployment_plan(
|
|
recipe.as_bytes(),
|
|
binding.as_bytes(),
|
|
source(&recipe),
|
|
"service-incarnation-rudp",
|
|
Some(18003),
|
|
111,
|
|
&[ready_odin_provider("odin", "odin-yggdrasil", 1)],
|
|
)
|
|
.unwrap();
|
|
let release = SealedRelease::new(
|
|
&plan,
|
|
vec![artifact_receipt()],
|
|
vec![external_input_receipt()],
|
|
120,
|
|
)
|
|
.unwrap();
|
|
let route = release.expected_projection(&plan).unwrap().route.unwrap();
|
|
assert_eq!(route.transport, "rudp");
|
|
assert_eq!(route.stable_endpoint, "rudp://127.0.0.1:17999");
|
|
assert_eq!(route.candidate_endpoint, "rudp://127.0.0.1:18003");
|
|
}
|
|
|
|
#[test]
|
|
fn expected_projection_sorts_shared_capability_and_dependency_claims() {
|
|
let recipe = RECIPE
|
|
.replace(
|
|
"[[provides]]\ncapability = \"service.runtime\"",
|
|
"[[provides]]\ncapability = \"zeta.runtime\"\nschema = \"zeta.runtime.v1\"\ncompatibility = \"v1\"\n\n[[provides]]\ncapability = \"service.runtime\"",
|
|
)
|
|
.replace(
|
|
"[[dependencies]]\nkind = \"shared-infrastructure\"",
|
|
"[[dependencies]]\nkind = \"optional\"\ncapability = \"zeta.optional\"\nschema = \"zeta.optional.v1\"\ncompatibility = \"v1\"\n\n[[dependencies]]\nkind = \"shared-infrastructure\"",
|
|
);
|
|
let plan = compile_deployment_plan(
|
|
recipe.as_bytes(),
|
|
BINDING.as_bytes(),
|
|
source(&recipe),
|
|
"service-incarnation-sorted",
|
|
Some(18002),
|
|
111,
|
|
&[ready_odin_provider("odin", "odin-yggdrasil", 1)],
|
|
)
|
|
.unwrap();
|
|
let release = SealedRelease::new(
|
|
&plan,
|
|
vec![artifact_receipt()],
|
|
vec![external_input_receipt()],
|
|
120,
|
|
)
|
|
.unwrap();
|
|
let expected = release.expected_projection(&plan).unwrap();
|
|
assert_eq!(
|
|
expected
|
|
.capabilities
|
|
.iter()
|
|
.map(|capability| capability.capability.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["service.runtime", "zeta.runtime"]
|
|
);
|
|
assert_eq!(
|
|
expected
|
|
.dependencies
|
|
.iter()
|
|
.map(|dependency| dependency.capability.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["odin.verse-rendezvous", "zeta.optional"]
|
|
);
|
|
expected.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn sealed_release_address_covers_artifacts_and_external_inputs() {
|
|
let receipt = artifact_receipt();
|
|
let input = external_input_receipt();
|
|
let plan = plan();
|
|
let mut wrong_artifact = receipt.clone();
|
|
wrong_artifact.sha256 = digest(b'f');
|
|
assert!(SealedRelease::new(&plan, vec![wrong_artifact], vec![input.clone()], 120).is_err());
|
|
assert!(SealedRelease::new(&plan, vec![receipt.clone()], vec![], 120).is_err());
|
|
let release =
|
|
SealedRelease::new(&plan, vec![receipt.clone()], vec![input.clone()], 120).unwrap();
|
|
let later_receipt_time =
|
|
SealedRelease::new(&plan, vec![receipt], vec![input], 130).unwrap();
|
|
release.validate_against(&plan).unwrap();
|
|
assert_eq!(
|
|
release.sealed_release_id,
|
|
later_receipt_time.sealed_release_id
|
|
);
|
|
let mut changed = release.clone();
|
|
changed.artifacts[0].size_bytes = 43;
|
|
assert!(changed.validate_against(&plan).is_err());
|
|
let mut changed = release;
|
|
changed.external_inputs[0].size_bytes = 18;
|
|
assert!(changed.validate_against(&plan).is_err());
|
|
}
|
|
|
|
fn binding_with_deadlines(table: &str) -> String {
|
|
BINDING.replace("[placement]", &format!("{table}\n[placement]"))
|
|
}
|
|
|
|
fn plan_under(binding: &str) -> Result<CompiledDeploymentPlan> {
|
|
let providers = [ready_odin_provider("odin", "odin-yggdrasil", 1)];
|
|
compile_deployment_plan(
|
|
RECIPE.as_bytes(),
|
|
binding.as_bytes(),
|
|
source(RECIPE),
|
|
"service-incarnation-1",
|
|
Some(18001),
|
|
110,
|
|
&providers,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_compiled_plan_freezes_idunn_default_deadlines() {
|
|
let plan = plan();
|
|
assert_eq!(plan.schema, COMPILED_DEPLOYMENT_PLAN_SCHEMA);
|
|
assert_eq!(plan.deadlines, Some(PhaseDeadlines::IDUNN_DEFAULTS));
|
|
assert_eq!(plan.phase_deadlines(), PhaseDeadlines::IDUNN_DEFAULTS);
|
|
plan.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_binding_overrides_only_the_deadlines_it_names() {
|
|
let binding = binding_with_deadlines("[deadlines]\nawaiting_ready_seconds = 45\n");
|
|
let plan = plan_under(&binding).unwrap();
|
|
let frozen = plan.phase_deadlines();
|
|
assert_eq!(frozen.awaiting_ready_seconds, 45);
|
|
assert_eq!(
|
|
PhaseDeadlines {
|
|
awaiting_ready_seconds: DEFAULT_AWAITING_READY_DEADLINE_SECONDS,
|
|
..frozen
|
|
},
|
|
PhaseDeadlines::IDUNN_DEFAULTS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deadline_bounds_and_unknown_phases_are_refused() {
|
|
for table in [
|
|
"[deadlines]\nrouting_seconds = 0\n",
|
|
"[deadlines]\nrouting_seconds = 86401\n",
|
|
"[deadlines]\nwarming_seconds = 5\n",
|
|
] {
|
|
assert!(
|
|
OperatorBinding::parse(&binding_with_deadlines(table)).is_err(),
|
|
"{table}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_deadlines_must_agree_with_the_binding_and_the_plan_schema() {
|
|
let binding = binding_with_deadlines("[deadlines]\nrouting_seconds = 45\n");
|
|
let sealed = |mut plan: CompiledDeploymentPlan| {
|
|
plan.plan_id = plan.recomputed_plan_id().unwrap();
|
|
plan
|
|
};
|
|
let honest = plan_under(&binding).unwrap();
|
|
|
|
let mut drifted = honest.clone();
|
|
drifted.deadlines.as_mut().unwrap().routing_seconds = 46;
|
|
assert!(sealed(drifted).validate().is_err());
|
|
|
|
let mut absent = honest.clone();
|
|
absent.deadlines = None;
|
|
assert!(sealed(absent).validate().is_err());
|
|
|
|
let mut v2_with_deadlines = honest.clone();
|
|
v2_with_deadlines.schema = COMPILED_DEPLOYMENT_PLAN_SCHEMA_V2.into();
|
|
assert!(sealed(v2_with_deadlines).validate().is_err());
|
|
|
|
// A plan sealed before deadlines existed keeps its nine-field encoding,
|
|
// so its plan id still holds, and it runs on Idunn's defaults.
|
|
let mut v2 = plan();
|
|
v2.schema = COMPILED_DEPLOYMENT_PLAN_SCHEMA_V2.into();
|
|
v2.deadlines = None;
|
|
let v2 = sealed(v2);
|
|
v2.validate().unwrap();
|
|
assert_eq!(v2.phase_deadlines(), PhaseDeadlines::IDUNN_DEFAULTS);
|
|
assert_eq!(rmp_serde::to_vec(&v2).unwrap()[0], 0x99);
|
|
assert_eq!(rmp_serde::to_vec(&honest).unwrap()[0], 0x9a);
|
|
}
|
|
}
|