The secret-files campaign rests on one admit line: a binding may bind only names its recipe declares, so a recipe that declares a secret only as its *_FILE name leaves a plaintext value no place in a binding. Its only test, extra_operator_environment_is_rejected, asserted is_err(), which any other refusal satisfies. The replacement tests assert this refusal exactly, each against a declared twin that admits: - an undeclared [workload.environment] name; - an undeclared [workload.secret_files] name; - the plaintext twin of a declared *_FILE name, the shape of the Heimdall leak; - `idunn validate` returns the refusal, as main prints it, without the bound value (a canary), pinning the admit layer of P6. Tests only. The deployment test fixtures become pub(crate) so control_plane's validate test can reuse them. Idunn secret-files cut, Cut 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2498 lines
89 KiB
Rust
2498 lines
89 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::net::IpAddr;
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, bail, ensure};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const TARGET_DECLARATION_SCHEMA: &str = "gamecult.idunn.target_declaration.v1";
|
|
pub const OPERATOR_BINDING_SCHEMA: &str = "gamecult.idunn.operator_binding.v2";
|
|
pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE";
|
|
pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND";
|
|
pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE";
|
|
pub const RUNTIME_PRESENCE_IDENTITY_BINDING: &str = "GAMECULT_RUNTIME_PRESENCE_IDENTITY";
|
|
/// A host actuator hands the workload its activation credential as a file
|
|
/// named by this variable; on the Idunn host it arrives as a parent-only
|
|
/// descriptor instead and this name is never set.
|
|
pub const IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT: &str = "GAMECULT_IDUNN_ACTIVATION_CREDENTIAL";
|
|
pub const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct TargetDeclaration {
|
|
pub schema: String,
|
|
pub target: String,
|
|
pub source_stamp_environment: String,
|
|
#[serde(default)]
|
|
pub required_gitlinks: BTreeSet<PathBuf>,
|
|
#[serde(default)]
|
|
pub steps: Vec<RecipeStep>,
|
|
#[serde(default)]
|
|
pub external_inputs: Vec<ExternalInput>,
|
|
pub artifacts: Vec<ArtifactOutput>,
|
|
pub service: ServiceDeclaration,
|
|
#[serde(default)]
|
|
pub state: Option<StateDeclaration>,
|
|
#[serde(default)]
|
|
pub provides: Vec<ProvidedCapability>,
|
|
#[serde(default)]
|
|
pub dependencies: Vec<CapabilityDependency>,
|
|
#[serde(default)]
|
|
pub conflicts: Vec<CapabilityConflict>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum RunnerAffordance {
|
|
SourceRead,
|
|
ArtifactWrite,
|
|
BuildCache,
|
|
SecretRead,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RecipeStep {
|
|
pub id: String,
|
|
pub phase: RecipePhase,
|
|
pub runner: String,
|
|
pub argv: Vec<String>,
|
|
#[serde(default = "default_working_directory")]
|
|
pub working_directory: PathBuf,
|
|
#[serde(default)]
|
|
pub required_environment: BTreeSet<String>,
|
|
}
|
|
|
|
fn default_working_directory() -> PathBuf {
|
|
PathBuf::from(".")
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum RecipePhase {
|
|
Prepare,
|
|
Test,
|
|
Build,
|
|
Acceptance,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExternalInput {
|
|
pub id: String,
|
|
pub url: String,
|
|
pub sha256: String,
|
|
pub runner: String,
|
|
pub destination: PathBuf,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ArtifactOutput {
|
|
pub id: String,
|
|
pub source_kind: ArtifactSource,
|
|
pub runner: Option<String>,
|
|
pub source: PathBuf,
|
|
pub destination: PathBuf,
|
|
#[serde(default)]
|
|
pub expected_sha256: Option<String>,
|
|
#[serde(default)]
|
|
pub executable: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum ArtifactSource {
|
|
RunnerOutput,
|
|
WorktreeTree,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ServiceDeclaration {
|
|
pub executable_artifact: String,
|
|
#[serde(default)]
|
|
pub required_adjacent_artifacts: BTreeSet<String>,
|
|
#[serde(default)]
|
|
pub arguments: Vec<LaunchArgument>,
|
|
pub transport: ServiceTransport,
|
|
#[serde(default)]
|
|
pub route_required: bool,
|
|
pub health: HealthDeclaration,
|
|
#[serde(default)]
|
|
pub required_environment: BTreeSet<String>,
|
|
#[serde(default)]
|
|
pub optional_environment: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HealthDeclaration {
|
|
pub contract: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum ServiceTransport {
|
|
Http,
|
|
Tcp,
|
|
Rudp,
|
|
Private,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub enum LaunchArgument {
|
|
Literal { value: String },
|
|
Binding { name: String },
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct StateDeclaration {
|
|
pub schema_generation: String,
|
|
pub slots: Vec<StateSlot>,
|
|
pub migration: Option<MigrationDeclaration>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct StateSlot {
|
|
pub id: String,
|
|
pub relative_path: PathBuf,
|
|
pub kind: StateKind,
|
|
pub schema: String,
|
|
pub writer: StateWriter,
|
|
pub recovery: StateRecovery,
|
|
pub startup: StateStartup,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct MigrationDeclaration {
|
|
pub executable_artifact: String,
|
|
pub from_generations: BTreeSet<String>,
|
|
pub to_generation: String,
|
|
#[serde(default)]
|
|
pub arguments: Vec<LaunchArgument>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum StateKind {
|
|
CultcacheFile,
|
|
/// Process-owned state imposed by an external runtime contract. Idunn
|
|
/// preserves and fences the file but never interprets its payload.
|
|
ExternalFile,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum StateWriter {
|
|
ProcessBoundSingleWriter,
|
|
None,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum StateRecovery {
|
|
Preserve,
|
|
Rebuildable,
|
|
ExternalAuthority,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum StateStartup {
|
|
CreateOrOpenAfterWriteLease,
|
|
OpenAtStart,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProvidedCapability {
|
|
pub capability: String,
|
|
pub schema: String,
|
|
pub compatibility: String,
|
|
#[serde(default = "one")]
|
|
pub capacity: u32,
|
|
}
|
|
|
|
fn one() -> u32 {
|
|
1
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CapabilityDependency {
|
|
pub kind: DependencyKind,
|
|
pub capability: String,
|
|
pub schema: String,
|
|
pub compatibility: String,
|
|
#[serde(default = "one")]
|
|
pub minimum_capacity: u32,
|
|
#[serde(default)]
|
|
pub startup: StartupOrder,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum DependencyKind {
|
|
Bootstrap,
|
|
Required,
|
|
Optional,
|
|
SharedInfrastructure,
|
|
Private,
|
|
ExternalOperatorBinding,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum StartupOrder {
|
|
#[default]
|
|
BeforePromotion,
|
|
BeforeStart,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CapabilityConflict {
|
|
pub capability: String,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OperatorBinding {
|
|
pub schema: String,
|
|
pub target: String,
|
|
pub repository: RepositoryBinding,
|
|
pub runners: BTreeMap<String, RunnerBinding>,
|
|
pub workload: WorkloadBinding,
|
|
pub runtime_identity: RuntimeIdentityBinding,
|
|
pub route: Option<RouteBinding>,
|
|
#[serde(default)]
|
|
pub process_write_lease: Option<ProcessWriteLeaseBinding>,
|
|
pub brakes: BrakeBinding,
|
|
pub rollout: RolloutBinding,
|
|
/// Optional per-phase deadline overrides; absent fields take Idunn's defaults
|
|
/// when the plan is compiled.
|
|
#[serde(default)]
|
|
pub deadlines: Option<DeadlineBinding>,
|
|
pub placement: PlacementBinding,
|
|
#[serde(default)]
|
|
pub external_capabilities: Vec<ExternalCapabilityBinding>,
|
|
#[serde(default)]
|
|
pub profiles: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RepositoryBinding {
|
|
pub origin: String,
|
|
pub admitted_ref: String,
|
|
pub minimum_revision: String,
|
|
pub selection: SourceSelectionPolicy,
|
|
pub pinned_revision: Option<String>,
|
|
pub release_authority_store: Option<PathBuf>,
|
|
pub checkout: PathBuf,
|
|
pub recipe_path: PathBuf,
|
|
#[serde(default)]
|
|
pub gitlinks: BTreeMap<PathBuf, GitlinkBinding>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum SourceSelectionPolicy {
|
|
PinnedObject,
|
|
RefHead,
|
|
SignedRelease,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct GitlinkBinding {
|
|
pub origin: String,
|
|
}
|
|
|
|
/// The driver is the tag of the binding. A docker runner builds on the Idunn
|
|
/// host inside a pinned image; a host-native runner builds on a managed host
|
|
/// through that host's actuator, with the same allowed-program and environment
|
|
/// contract but no container.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "driver", rename_all = "kebab-case")]
|
|
pub enum RunnerBinding {
|
|
Docker(DockerRunnerBinding),
|
|
HostNative(HostNativeRunnerBinding),
|
|
}
|
|
|
|
impl RunnerBinding {
|
|
pub fn docker(&self) -> Result<&DockerRunnerBinding> {
|
|
match self {
|
|
Self::Docker(binding) => Ok(binding),
|
|
Self::HostNative(_) => bail!("runner is host-native, not docker"),
|
|
}
|
|
}
|
|
|
|
pub fn host_native(&self) -> Result<&HostNativeRunnerBinding> {
|
|
match self {
|
|
Self::HostNative(binding) => Ok(binding),
|
|
Self::Docker(_) => bail!("runner is docker, not host-native"),
|
|
}
|
|
}
|
|
|
|
pub fn affordances(&self) -> &BTreeSet<RunnerAffordance> {
|
|
match self {
|
|
Self::Docker(binding) => &binding.affordances,
|
|
Self::HostNative(binding) => &binding.affordances,
|
|
}
|
|
}
|
|
|
|
pub fn allowed_programs(&self) -> &BTreeSet<String> {
|
|
match self {
|
|
Self::Docker(binding) => &binding.allowed_programs,
|
|
Self::HostNative(binding) => &binding.allowed_programs,
|
|
}
|
|
}
|
|
|
|
pub fn environment(&self) -> &BTreeMap<String, String> {
|
|
match self {
|
|
Self::Docker(binding) => &binding.environment,
|
|
Self::HostNative(binding) => &binding.environment,
|
|
}
|
|
}
|
|
|
|
pub fn secret_environment_names(&self) -> Vec<&String> {
|
|
match self {
|
|
Self::Docker(binding) => binding.secret_files.keys().collect(),
|
|
Self::HostNative(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn network_profile(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Docker(binding) => binding.network_profile.as_deref(),
|
|
Self::HostNative(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs each recipe step as a process on the actuator's host. Paths are the
|
|
/// host's, validated by the actuator; Idunn on yggdrasil treats them as opaque.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HostNativeRunnerBinding {
|
|
#[serde(default)]
|
|
pub affordances: BTreeSet<RunnerAffordance>,
|
|
pub cache_root: Option<String>,
|
|
#[serde(default)]
|
|
pub allowed_programs: BTreeSet<String>,
|
|
#[serde(default)]
|
|
pub environment: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl HostNativeRunnerBinding {
|
|
fn validate(&self, id: &str) -> Result<()> {
|
|
ensure!(
|
|
!self.allowed_programs.is_empty(),
|
|
"runner {id} has no allowed programs"
|
|
);
|
|
for program in &self.allowed_programs {
|
|
require_program(program, "allowed runner program")?;
|
|
}
|
|
if let Some(cache_root) = &self.cache_root {
|
|
require_value(cache_root, "runner cache root")?;
|
|
}
|
|
ensure!(
|
|
self.affordances.contains(&RunnerAffordance::BuildCache) == self.cache_root.is_some(),
|
|
"runner {id} cache root and build-cache affordance disagree"
|
|
);
|
|
ensure!(
|
|
!self.affordances.contains(&RunnerAffordance::SecretRead),
|
|
"host-native runner {id} cannot read secrets"
|
|
);
|
|
for name in self.environment.keys() {
|
|
require_environment_name(name)?;
|
|
}
|
|
for value in self.environment.values() {
|
|
require_value(value, "runner environment value")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DockerRunnerBinding {
|
|
pub image: String,
|
|
pub user: String,
|
|
#[serde(default)]
|
|
pub affordances: BTreeSet<RunnerAffordance>,
|
|
pub cache_root: Option<PathBuf>,
|
|
#[serde(default)]
|
|
pub allowed_programs: BTreeSet<String>,
|
|
#[serde(default)]
|
|
pub environment: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub secret_files: BTreeMap<String, PathBuf>,
|
|
#[serde(default)]
|
|
pub network_profile: Option<String>,
|
|
pub memory_mebibytes: u32,
|
|
pub cpu_quota_percent: u32,
|
|
pub pids_limit: u32,
|
|
pub tmpfs_mebibytes: u32,
|
|
}
|
|
|
|
/// The driver is the tag of the binding. A systemd-transient workload runs on
|
|
/// the Idunn host; a host-actuator workload runs on a managed host through the
|
|
/// actuator that dials in under that host's name.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "driver", rename_all = "kebab-case")]
|
|
pub enum WorkloadBinding {
|
|
SystemdTransient(SystemdWorkloadBinding),
|
|
HostActuator(HostWorkloadBinding),
|
|
}
|
|
|
|
impl WorkloadBinding {
|
|
pub fn systemd(&self) -> Result<&SystemdWorkloadBinding> {
|
|
match self {
|
|
Self::SystemdTransient(binding) => Ok(binding),
|
|
Self::HostActuator(_) => bail!("workload is host-actuator, not systemd-transient"),
|
|
}
|
|
}
|
|
|
|
pub fn host(&self) -> Result<&HostWorkloadBinding> {
|
|
match self {
|
|
Self::HostActuator(binding) => Ok(binding),
|
|
Self::SystemdTransient(_) => bail!("workload is systemd-transient, not host-actuator"),
|
|
}
|
|
}
|
|
|
|
pub fn declares_state_root(&self) -> bool {
|
|
match self {
|
|
Self::SystemdTransient(binding) => binding.state_root.is_some(),
|
|
Self::HostActuator(binding) => binding.state_root.is_some(),
|
|
}
|
|
}
|
|
|
|
pub fn environment(&self) -> &BTreeMap<String, String> {
|
|
match self {
|
|
Self::SystemdTransient(binding) => &binding.environment,
|
|
Self::HostActuator(binding) => &binding.environment,
|
|
}
|
|
}
|
|
|
|
pub fn secret_environment_names(&self) -> Vec<&String> {
|
|
match self {
|
|
Self::SystemdTransient(binding) => binding.secret_files.keys().collect(),
|
|
Self::HostActuator(binding) => binding.secret_files.keys().collect(),
|
|
}
|
|
}
|
|
|
|
pub fn argument_bindings(&self) -> &BTreeMap<String, String> {
|
|
match self {
|
|
Self::SystemdTransient(binding) => &binding.argument_bindings,
|
|
Self::HostActuator(binding) => &binding.argument_bindings,
|
|
}
|
|
}
|
|
|
|
fn validate_launch_environment(&self) -> Result<()> {
|
|
for name in self
|
|
.environment()
|
|
.keys()
|
|
.chain(self.secret_environment_names())
|
|
{
|
|
require_environment_name(name)?;
|
|
ensure!(
|
|
name != IDUNN_RUNTIME_BUNDLE_ENVIRONMENT
|
|
&& name != IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT,
|
|
"operator binding attempts to replace Idunn-owned workload environment"
|
|
);
|
|
}
|
|
for value in self.environment().values() {
|
|
require_value(value, "workload environment value")?;
|
|
}
|
|
for name in self.argument_bindings().keys() {
|
|
require_id(name, "argument binding")?;
|
|
}
|
|
for value in self.argument_bindings().values() {
|
|
require_value(value, "argument binding value")?;
|
|
}
|
|
ensure!(
|
|
!self
|
|
.environment()
|
|
.contains_key(RUNTIME_PRESENCE_IDENTITY_BINDING),
|
|
"runtime presence identity must be a secret source, not environment"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// A workload on a managed host. Every path is the host's and is validated by
|
|
/// the actuator that owns the host; here they are opaque non-empty values.
|
|
/// The host's actuator is authenticated against `host_trust_anchor_store`,
|
|
/// which is an Idunn-host path.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct HostWorkloadBinding {
|
|
pub host: String,
|
|
pub host_trust_anchor_store: PathBuf,
|
|
pub release_root: String,
|
|
pub runtime_root: String,
|
|
#[serde(default)]
|
|
pub state_root: Option<String>,
|
|
#[serde(default)]
|
|
pub environment: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub secret_files: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub argument_bindings: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl HostWorkloadBinding {
|
|
fn validate(&self) -> Result<()> {
|
|
require_id(&self.host, "workload host")?;
|
|
require_absolute_path(&self.host_trust_anchor_store, "host trust anchor store")?;
|
|
require_value(&self.release_root, "release root")?;
|
|
require_value(&self.runtime_root, "runtime root")?;
|
|
ensure!(
|
|
self.release_root != self.runtime_root,
|
|
"host release root and runtime root are identical"
|
|
);
|
|
if let Some(state_root) = &self.state_root {
|
|
require_value(state_root, "state root")?;
|
|
}
|
|
for path in self.secret_files.values() {
|
|
require_value(path, "secret file")?;
|
|
}
|
|
let presence_identity = self
|
|
.secret_files
|
|
.get(RUNTIME_PRESENCE_IDENTITY_BINDING)
|
|
.context("workload has no runtime presence identity source")?;
|
|
ensure!(
|
|
self.secret_files
|
|
.values()
|
|
.filter(|path| *path == presence_identity)
|
|
.count()
|
|
== 1,
|
|
"runtime presence identity source is duplicated under another secret binding"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SystemdWorkloadBinding {
|
|
#[serde(default)]
|
|
pub state_group: Option<String>,
|
|
pub unit_prefix: String,
|
|
pub release_root: PathBuf,
|
|
#[serde(default)]
|
|
pub state_root: Option<PathBuf>,
|
|
pub runtime_root: PathBuf,
|
|
pub network: WorkloadNetwork,
|
|
pub hardening: WorkloadHardening,
|
|
#[serde(default)]
|
|
pub read_only_paths: BTreeSet<PathBuf>,
|
|
#[serde(default)]
|
|
pub read_write_paths: BTreeSet<PathBuf>,
|
|
#[serde(default)]
|
|
pub devices: BTreeSet<PathBuf>,
|
|
pub memory_mebibytes: u32,
|
|
pub cpu_quota_percent: u32,
|
|
#[serde(default)]
|
|
pub environment: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub secret_files: BTreeMap<String, PathBuf>,
|
|
#[serde(default)]
|
|
pub argument_bindings: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum WorkloadNetwork {
|
|
HostPrivate,
|
|
None,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum WorkloadHardening {
|
|
Strict,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RuntimeIdentityBinding {
|
|
pub runtime_id: String,
|
|
pub expected_signer_identity_id: String,
|
|
pub trust_anchor_store: PathBuf,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProcessWriteLeaseBinding {
|
|
pub record_path: PathBuf,
|
|
}
|
|
|
|
impl ProcessWriteLeaseBinding {
|
|
pub fn lock_path(&self) -> PathBuf {
|
|
let mut lock_path = self.record_path.as_os_str().to_os_string();
|
|
lock_path.push(".lock");
|
|
PathBuf::from(lock_path)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BrakeBinding {
|
|
pub deployment_store: PathBuf,
|
|
pub lifecycle_store: PathBuf,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RouteBinding {
|
|
pub driver: RouteDriver,
|
|
pub route_id: String,
|
|
pub stable_endpoint: String,
|
|
pub private_host: String,
|
|
pub private_port_start: u16,
|
|
pub private_port_end: u16,
|
|
pub config_path: PathBuf,
|
|
pub reload_unit: String,
|
|
}
|
|
|
|
impl RouteBinding {
|
|
pub(crate) fn stable_socket(&self) -> Result<(IpAddr, u16)> {
|
|
let authority = match self.driver {
|
|
RouteDriver::NginxStreamTcp => self
|
|
.stable_endpoint
|
|
.strip_prefix("http://")
|
|
.or_else(|| self.stable_endpoint.strip_prefix("tcp://")),
|
|
RouteDriver::NginxStreamUdp => self.stable_endpoint.strip_prefix("rudp://"),
|
|
}
|
|
.context("stable endpoint transport does not match its route driver")?;
|
|
ensure!(
|
|
!authority
|
|
.chars()
|
|
.any(|character| matches!(character, '/' | '?' | '#' | '@' | '[' | ']')),
|
|
"stable endpoint is not a plain IP address and port"
|
|
);
|
|
let (host, port) = authority
|
|
.rsplit_once(':')
|
|
.context("stable endpoint has no port")?;
|
|
let host = host
|
|
.parse::<IpAddr>()
|
|
.context("stable endpoint host is not an IP address")?;
|
|
ensure!(
|
|
matches!(host, IpAddr::V4(_)),
|
|
"route binding v2 stable endpoints use explicit IPv4 addresses"
|
|
);
|
|
let port = port
|
|
.parse::<u16>()
|
|
.context("stable endpoint port is not a u16")?;
|
|
ensure!(port > 0, "stable endpoint port is zero");
|
|
Ok((host, port))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum RouteDriver {
|
|
NginxStreamTcp,
|
|
NginxStreamUdp,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RolloutBinding {
|
|
pub strategy: RolloutStrategy,
|
|
pub drain_seconds: u32,
|
|
pub retain_releases: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeadlineBinding {
|
|
#[serde(default)]
|
|
pub fencing_seconds: Option<u32>,
|
|
#[serde(default)]
|
|
pub leasing_seconds: Option<u32>,
|
|
#[serde(default)]
|
|
pub awaiting_ready_seconds: Option<u32>,
|
|
#[serde(default)]
|
|
pub routing_seconds: Option<u32>,
|
|
#[serde(default)]
|
|
pub committing_seconds: Option<u32>,
|
|
}
|
|
|
|
/// Longest deadline a binding may declare: one day.
|
|
pub const MAXIMUM_DEADLINE_SECONDS: u32 = 86_400;
|
|
|
|
impl DeadlineBinding {
|
|
fn validate(&self) -> Result<()> {
|
|
for (name, seconds) in [
|
|
("fencing", self.fencing_seconds),
|
|
("leasing", self.leasing_seconds),
|
|
("awaiting-ready", self.awaiting_ready_seconds),
|
|
("routing", self.routing_seconds),
|
|
("committing", self.committing_seconds),
|
|
] {
|
|
if let Some(seconds) = seconds {
|
|
ensure!(
|
|
(1..=MAXIMUM_DEADLINE_SECONDS).contains(&seconds),
|
|
"{name} deadline must be between 1 and {MAXIMUM_DEADLINE_SECONDS} seconds"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum RolloutStrategy {
|
|
CandidateThenPromote,
|
|
/// The admitted incumbent is stopped before the candidate starts. For a
|
|
/// workload that owns fixed host resources no route can multiplex (one
|
|
/// desktop, one capture device, fixed advertised ports), two incarnations
|
|
/// cannot overlap; the honest strategy says so instead of failing at
|
|
/// bind time. Only a route-less host-actuator workload may declare it.
|
|
StopThenStart,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct PlacementBinding {
|
|
pub desired_replicas: u32,
|
|
pub nodes: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExternalCapabilityBinding {
|
|
pub provider_id: String,
|
|
pub capability: String,
|
|
pub schema: String,
|
|
pub compatibility: String,
|
|
#[serde(default = "one")]
|
|
pub capacity: u32,
|
|
pub endpoint: String,
|
|
}
|
|
|
|
impl TargetDeclaration {
|
|
pub fn parse(input: &str) -> Result<Self> {
|
|
let declaration: Self =
|
|
toml::from_str(input).context("decoding strict Idunn target declaration")?;
|
|
declaration.validate()?;
|
|
Ok(declaration)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<()> {
|
|
ensure!(
|
|
self.schema == TARGET_DECLARATION_SCHEMA,
|
|
"unsupported target declaration schema"
|
|
);
|
|
require_id(&self.target, "target")?;
|
|
require_environment_name(&self.source_stamp_environment)?;
|
|
for path in &self.required_gitlinks {
|
|
require_relative_path(path, "required gitlink", false)?;
|
|
}
|
|
ensure!(!self.steps.is_empty(), "no recipe steps declared");
|
|
ensure!(!self.artifacts.is_empty(), "no artifact outputs declared");
|
|
|
|
let mut runner_ids = BTreeSet::new();
|
|
unique_ids(
|
|
self.steps.iter().map(|step| step.id.as_str()),
|
|
"recipe step",
|
|
)?;
|
|
for step in &self.steps {
|
|
require_id(&step.runner, "runner class")?;
|
|
runner_ids.insert(step.runner.as_str());
|
|
ensure!(!step.argv.is_empty(), "step {} has empty argv", step.id);
|
|
for argument in &step.argv {
|
|
require_value(argument, "step argv")?;
|
|
}
|
|
require_program(&step.argv[0], "step program")?;
|
|
require_relative_path(&step.working_directory, "step working directory", true)?;
|
|
for name in &step.required_environment {
|
|
require_environment_name(name)?;
|
|
}
|
|
}
|
|
|
|
let mut artifact_ids = BTreeSet::new();
|
|
let mut destinations = BTreeSet::new();
|
|
for artifact in &self.artifacts {
|
|
require_id(&artifact.id, "artifact")?;
|
|
ensure!(
|
|
artifact_ids.insert(artifact.id.as_str()),
|
|
"artifact id {} is declared twice",
|
|
artifact.id
|
|
);
|
|
require_relative_path(&artifact.source, "artifact source", false)?;
|
|
require_leaf_path(&artifact.destination, "artifact destination")?;
|
|
if let Some(expected_sha256) = &artifact.expected_sha256 {
|
|
require_sha256(expected_sha256, "expected artifact sha256")?;
|
|
}
|
|
ensure!(
|
|
destinations.insert(artifact.destination.as_path()),
|
|
"artifact destination {} is declared twice",
|
|
artifact.destination.display()
|
|
);
|
|
match (artifact.source_kind, artifact.runner.as_deref()) {
|
|
(ArtifactSource::RunnerOutput, Some(runner)) => {
|
|
require_id(runner, "artifact runner class")?;
|
|
runner_ids.insert(runner);
|
|
}
|
|
(ArtifactSource::WorktreeTree, None) => {}
|
|
(ArtifactSource::RunnerOutput, None) => {
|
|
bail!("runner-output artifact {} has no runner class", artifact.id)
|
|
}
|
|
(ArtifactSource::WorktreeTree, Some(_)) => bail!(
|
|
"worktree-tree artifact {} cannot name a runner class",
|
|
artifact.id
|
|
),
|
|
}
|
|
}
|
|
let mut external_input_ids = BTreeSet::new();
|
|
let mut external_input_destinations = BTreeSet::new();
|
|
for input in &self.external_inputs {
|
|
require_id(&input.id, "external input")?;
|
|
ensure!(
|
|
external_input_ids.insert(input.id.as_str()),
|
|
"external input id {} is declared twice",
|
|
input.id
|
|
);
|
|
ensure!(
|
|
!artifact_ids.contains(input.id.as_str()),
|
|
"external input id {} collides with an artifact id",
|
|
input.id
|
|
);
|
|
require_value(&input.url, "external input URL")?;
|
|
https_host(&input.url)?;
|
|
require_sha256(&input.sha256, "external input sha256")?;
|
|
require_id(&input.runner, "external input runner class")?;
|
|
runner_ids.insert(input.runner.as_str());
|
|
require_relative_path(&input.destination, "external input destination", false)?;
|
|
ensure!(
|
|
external_input_destinations
|
|
.insert((input.runner.as_str(), input.destination.as_path())),
|
|
"external input destination {} is declared twice for runner {}",
|
|
input.destination.display(),
|
|
input.runner
|
|
);
|
|
}
|
|
ensure!(
|
|
!runner_ids.is_empty(),
|
|
"target declaration uses no runner classes"
|
|
);
|
|
|
|
ensure!(
|
|
artifact_ids.contains(self.service.executable_artifact.as_str()),
|
|
"service executable artifact is not declared"
|
|
);
|
|
for artifact in &self.service.required_adjacent_artifacts {
|
|
ensure!(
|
|
artifact_ids.contains(artifact.as_str()),
|
|
"adjacent service artifact {artifact} is not declared"
|
|
);
|
|
}
|
|
for argument in &self.service.arguments {
|
|
validate_launch_argument(argument)?;
|
|
}
|
|
require_capability(&self.service.health.contract, "health contract")?;
|
|
for name in &self.service.required_environment {
|
|
require_environment_name(name)?;
|
|
}
|
|
ensure!(
|
|
self.service
|
|
.required_environment
|
|
.contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT),
|
|
"service does not require the standard Idunn runtime bundle"
|
|
);
|
|
ensure!(
|
|
self.service
|
|
.required_environment
|
|
.contains(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT)
|
|
== self.service.route_required,
|
|
"routed services must require exactly one Idunn-owned candidate bind"
|
|
);
|
|
ensure!(
|
|
self.service
|
|
.required_environment
|
|
.contains(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT)
|
|
== self.write_lease_required(),
|
|
"state-writing services must require exactly one Idunn-owned process write lease"
|
|
);
|
|
for name in &self.service.optional_environment {
|
|
require_environment_name(name)?;
|
|
ensure!(
|
|
!self.service.required_environment.contains(name),
|
|
"service environment {name} is both required and optional"
|
|
);
|
|
}
|
|
|
|
if let Some(state) = &self.state {
|
|
require_capability(&state.schema_generation, "state schema generation")?;
|
|
ensure!(!state.slots.is_empty(), "state contract has no slots");
|
|
let slot_ids = unique_ids(
|
|
state.slots.iter().map(|slot| slot.id.as_str()),
|
|
"state slot",
|
|
)?;
|
|
let mut slot_paths = BTreeSet::<PathBuf>::new();
|
|
for slot in &state.slots {
|
|
require_relative_path(&slot.relative_path, "state slot path", false)?;
|
|
ensure!(
|
|
slot_paths
|
|
.iter()
|
|
.all(|other| !paths_overlap(other, &slot.relative_path)),
|
|
"state slot path {} overlaps another declared slot",
|
|
slot.relative_path.display()
|
|
);
|
|
ensure!(
|
|
slot_paths.insert(slot.relative_path.clone()),
|
|
"state slot path {} is declared twice",
|
|
slot.relative_path.display()
|
|
);
|
|
require_capability(&slot.schema, "state slot schema")?;
|
|
match slot.writer {
|
|
StateWriter::ProcessBoundSingleWriter => ensure!(
|
|
slot.startup == StateStartup::CreateOrOpenAfterWriteLease,
|
|
"writable state slot {} must open after its write lease",
|
|
slot.id
|
|
),
|
|
StateWriter::None => ensure!(
|
|
slot.startup == StateStartup::OpenAtStart,
|
|
"non-writable state slot {} cannot wait for a write lease",
|
|
slot.id
|
|
),
|
|
}
|
|
if slot.recovery == StateRecovery::ExternalAuthority {
|
|
ensure!(
|
|
slot.writer == StateWriter::None,
|
|
"externally authoritative state slot {} cannot name the service as writer",
|
|
slot.id
|
|
);
|
|
}
|
|
}
|
|
if let Some(migration) = state.migration.as_ref() {
|
|
ensure!(
|
|
artifact_ids.contains(migration.executable_artifact.as_str()),
|
|
"migration executable artifact is not declared"
|
|
);
|
|
ensure!(
|
|
!migration.from_generations.is_empty(),
|
|
"migration has no source generations"
|
|
);
|
|
for generation in &migration.from_generations {
|
|
require_capability(generation, "migration source generation")?;
|
|
}
|
|
ensure!(
|
|
migration.to_generation == state.schema_generation,
|
|
"migration destination differs from state generation"
|
|
);
|
|
for argument in &migration.arguments {
|
|
validate_launch_argument(argument)?;
|
|
}
|
|
}
|
|
ensure!(slot_ids.len() == state.slots.len(), "duplicate state slot");
|
|
}
|
|
|
|
let mut provided = BTreeSet::new();
|
|
for capability in &self.provides {
|
|
require_capability_contract(
|
|
&capability.capability,
|
|
&capability.schema,
|
|
&capability.compatibility,
|
|
)?;
|
|
ensure!(
|
|
capability.capacity > 0,
|
|
"provided capability capacity must be positive"
|
|
);
|
|
ensure!(
|
|
provided.insert(capability.capability.as_str()),
|
|
"capability {} is provided twice",
|
|
capability.capability
|
|
);
|
|
}
|
|
let mut required = BTreeSet::new();
|
|
for dependency in &self.dependencies {
|
|
require_capability_contract(
|
|
&dependency.capability,
|
|
&dependency.schema,
|
|
&dependency.compatibility,
|
|
)?;
|
|
ensure!(
|
|
dependency.minimum_capacity > 0,
|
|
"dependency capacity must be positive"
|
|
);
|
|
if dependency.kind == DependencyKind::Bootstrap {
|
|
ensure!(
|
|
dependency.startup == StartupOrder::BeforeStart,
|
|
"bootstrap dependency {} must be available before start",
|
|
dependency.capability
|
|
);
|
|
}
|
|
ensure!(
|
|
required.insert((dependency.kind, dependency.capability.as_str())),
|
|
"dependency {} is declared twice for the same kind",
|
|
dependency.capability
|
|
);
|
|
}
|
|
for conflict in &self.conflicts {
|
|
require_capability(&conflict.capability, "conflicting capability")?;
|
|
require_value(&conflict.reason, "conflict reason")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_lease_required(&self) -> bool {
|
|
self.state.as_ref().is_some_and(|state| {
|
|
state
|
|
.slots
|
|
.iter()
|
|
.any(|slot| slot.writer == StateWriter::ProcessBoundSingleWriter)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl OperatorBinding {
|
|
pub fn parse(input: &str) -> Result<Self> {
|
|
let binding: Self =
|
|
toml::from_str(input).context("decoding strict Idunn operator binding")?;
|
|
binding.validate()?;
|
|
Ok(binding)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<()> {
|
|
ensure!(
|
|
self.schema == OPERATOR_BINDING_SCHEMA,
|
|
"unsupported operator binding schema"
|
|
);
|
|
require_id(&self.target, "target")?;
|
|
ensure!(
|
|
self.repository.origin.starts_with("https://"),
|
|
"repository origin must use HTTPS"
|
|
);
|
|
ensure!(
|
|
self.repository.origin.ends_with(".git"),
|
|
"repository origin must name a Git repository"
|
|
);
|
|
ensure!(
|
|
self.repository.admitted_ref.starts_with("refs/heads/"),
|
|
"admitted ref must be a full branch ref"
|
|
);
|
|
require_sha1(&self.repository.minimum_revision, "minimum revision")?;
|
|
match self.repository.selection {
|
|
SourceSelectionPolicy::PinnedObject => {
|
|
let revision = self.repository.pinned_revision.as_deref().ok_or_else(|| {
|
|
anyhow::anyhow!("pinned-object selection has no pinned revision")
|
|
})?;
|
|
require_sha1(revision, "pinned revision")?;
|
|
ensure!(
|
|
self.repository.release_authority_store.is_none(),
|
|
"pinned-object selection cannot name a release-authority store"
|
|
);
|
|
}
|
|
SourceSelectionPolicy::RefHead => {
|
|
ensure!(
|
|
self.repository.pinned_revision.is_none(),
|
|
"ref-head selection cannot name a pinned revision"
|
|
);
|
|
ensure!(
|
|
self.repository.release_authority_store.is_none(),
|
|
"ref-head selection cannot name a release-authority store"
|
|
);
|
|
}
|
|
SourceSelectionPolicy::SignedRelease => {
|
|
ensure!(
|
|
self.repository.pinned_revision.is_none(),
|
|
"signed-release selection cannot name a pinned revision"
|
|
);
|
|
let store = self
|
|
.repository
|
|
.release_authority_store
|
|
.as_ref()
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("signed-release selection has no authority store")
|
|
})?;
|
|
require_absolute_path(store, "release authority store")?;
|
|
}
|
|
}
|
|
require_absolute_path(&self.repository.checkout, "repository checkout")?;
|
|
require_relative_path(&self.repository.recipe_path, "recipe path", false)?;
|
|
for (path, gitlink) in &self.repository.gitlinks {
|
|
require_relative_path(path, "gitlink binding", false)?;
|
|
require_git_origin(&gitlink.origin, "gitlink origin")?;
|
|
}
|
|
ensure!(!self.runners.is_empty(), "operator binding has no runners");
|
|
let host_workload = matches!(self.workload, WorkloadBinding::HostActuator(_));
|
|
for (id, runner) in &self.runners {
|
|
require_id(id, "runner binding")?;
|
|
let runner = match runner {
|
|
RunnerBinding::HostNative(runner) => {
|
|
ensure!(
|
|
host_workload,
|
|
"host-native runner {id} needs a host-actuator workload"
|
|
);
|
|
runner.validate(id)?;
|
|
continue;
|
|
}
|
|
RunnerBinding::Docker(runner) => {
|
|
ensure!(
|
|
!host_workload,
|
|
"docker runner {id} cannot build for a host-actuator workload"
|
|
);
|
|
runner
|
|
}
|
|
};
|
|
require_pinned_image(&runner.image)?;
|
|
require_container_identity(&runner.user)?;
|
|
ensure!(
|
|
runner.memory_mebibytes > 0,
|
|
"runner memory must be positive"
|
|
);
|
|
ensure!(
|
|
(1..=100_000).contains(&runner.cpu_quota_percent),
|
|
"runner CPU quota is outside 1..=100000"
|
|
);
|
|
ensure!(
|
|
runner.pids_limit > 0 && runner.tmpfs_mebibytes > 0,
|
|
"runner process or temporary-filesystem limit is zero"
|
|
);
|
|
if let Some(cache_root) = runner.cache_root.as_ref() {
|
|
require_absolute_path(cache_root, "runner cache root")?;
|
|
}
|
|
for program in &runner.allowed_programs {
|
|
require_program(program, "allowed runner program")?;
|
|
}
|
|
ensure!(
|
|
!runner.allowed_programs.is_empty(),
|
|
"runner {id} has no allowed programs"
|
|
);
|
|
if let Some(profile) = &runner.network_profile {
|
|
require_capability(profile, "runner network profile")?;
|
|
}
|
|
for name in runner.environment.keys().chain(runner.secret_files.keys()) {
|
|
require_environment_name(name)?;
|
|
}
|
|
for value in runner.environment.values() {
|
|
require_value(value, "runner environment value")?;
|
|
}
|
|
for path in runner.secret_files.values() {
|
|
require_absolute_path(path, "runner secret file")?;
|
|
}
|
|
if runner.affordances.contains(&RunnerAffordance::BuildCache) {
|
|
ensure!(
|
|
runner.cache_root.is_some(),
|
|
"build-cache runner {id} has no cache root"
|
|
);
|
|
}
|
|
ensure!(
|
|
runner.affordances.contains(&RunnerAffordance::BuildCache)
|
|
== runner.cache_root.is_some(),
|
|
"runner {id} cache root and build-cache affordance disagree"
|
|
);
|
|
ensure!(
|
|
runner.affordances.contains(&RunnerAffordance::SecretRead)
|
|
== !runner.secret_files.is_empty(),
|
|
"runner {id} secret files and secret-read affordance disagree"
|
|
);
|
|
}
|
|
require_id(&self.runtime_identity.runtime_id, "runtime id")?;
|
|
require_id(
|
|
&self.runtime_identity.expected_signer_identity_id,
|
|
"expected signer identity id",
|
|
)?;
|
|
require_absolute_path(
|
|
&self.runtime_identity.trust_anchor_store,
|
|
"trust anchor store",
|
|
)?;
|
|
for (label, path) in [
|
|
("deployment brake", &self.brakes.deployment_store),
|
|
("lifecycle brake", &self.brakes.lifecycle_store),
|
|
] {
|
|
require_absolute_path(path, label)?;
|
|
}
|
|
if let Some(write_lease) = &self.process_write_lease {
|
|
require_absolute_path(&write_lease.record_path, "process write-lease record")?;
|
|
require_absolute_path(&write_lease.lock_path(), "process write-lease lock")?;
|
|
}
|
|
ensure!(
|
|
self.brakes.deployment_store != self.brakes.lifecycle_store,
|
|
"deployment and lifecycle brakes are identical"
|
|
);
|
|
self.workload.validate_launch_environment()?;
|
|
let workload = match &self.workload {
|
|
WorkloadBinding::HostActuator(workload) => {
|
|
workload.validate()?;
|
|
ensure!(
|
|
self.process_write_lease.is_none(),
|
|
"a host-actuator workload cannot hold an Idunn-host process write lease"
|
|
);
|
|
ensure!(
|
|
self.route.is_none(),
|
|
"a host-actuator workload cannot be routed by the Idunn host"
|
|
);
|
|
ensure!(
|
|
self.placement.nodes.contains(&workload.host),
|
|
"placement does not name the workload host"
|
|
);
|
|
return self.validate_shared_tail();
|
|
}
|
|
WorkloadBinding::SystemdTransient(workload) => {
|
|
ensure!(
|
|
self.rollout.strategy == RolloutStrategy::CandidateThenPromote,
|
|
"a systemd-transient workload rolls out candidate-then-promote"
|
|
);
|
|
workload
|
|
}
|
|
};
|
|
let needs_state_group =
|
|
workload.state_root.is_some() || !workload.read_write_paths.is_empty();
|
|
ensure!(
|
|
workload.state_group.is_some() == needs_state_group,
|
|
"a fixed state group must exist exactly when the dynamic workload has writable paths"
|
|
);
|
|
if let Some(group) = &workload.state_group {
|
|
require_identity(group, "workload state group")?;
|
|
ensure!(
|
|
!matches!(group.as_str(), "root" | "0"),
|
|
"ordinary Idunn workload state cannot use the root group"
|
|
);
|
|
}
|
|
require_id(&workload.unit_prefix, "workload unit prefix")?;
|
|
for (label, path) in [
|
|
("release root", &workload.release_root),
|
|
("runtime root", &workload.runtime_root),
|
|
] {
|
|
require_absolute_path(path, label)?;
|
|
}
|
|
if let Some(state_root) = &workload.state_root {
|
|
require_absolute_path(state_root, "state root")?;
|
|
}
|
|
ensure!(
|
|
workload.memory_mebibytes > 0,
|
|
"workload memory must be positive"
|
|
);
|
|
ensure!(
|
|
(1..=100_000).contains(&workload.cpu_quota_percent),
|
|
"workload CPU quota is outside 1..=100000"
|
|
);
|
|
for path in workload
|
|
.read_only_paths
|
|
.iter()
|
|
.chain(workload.read_write_paths.iter())
|
|
.chain(workload.devices.iter())
|
|
{
|
|
require_absolute_path(path, "workload path")?;
|
|
}
|
|
for path in &workload.read_write_paths {
|
|
if let Some(state_root) = &workload.state_root {
|
|
ensure!(
|
|
!paths_overlap(path, state_root),
|
|
"workload read-write path {} overlaps the write-lease-controlled state root",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
let mut authority_paths = vec![
|
|
self.brakes.deployment_store.clone(),
|
|
self.brakes.lifecycle_store.clone(),
|
|
];
|
|
if let Some(write_lease) = &self.process_write_lease {
|
|
authority_paths.push(write_lease.record_path.clone());
|
|
authority_paths.push(write_lease.lock_path());
|
|
}
|
|
ensure!(
|
|
authority_paths.iter().enumerate().all(|(index, path)| {
|
|
authority_paths[index + 1..]
|
|
.iter()
|
|
.all(|other| !paths_overlap(path, other))
|
|
}),
|
|
"Idunn authority paths overlap"
|
|
);
|
|
let mut protected_paths = authority_paths;
|
|
protected_paths.extend([
|
|
workload.release_root.clone(),
|
|
workload.runtime_root.clone(),
|
|
self.runtime_identity.trust_anchor_store.clone(),
|
|
]);
|
|
if let Some(route) = &self.route {
|
|
protected_paths.push(route.config_path.clone());
|
|
}
|
|
if let Some(release_authority) = &self.repository.release_authority_store {
|
|
protected_paths.push(release_authority.clone());
|
|
}
|
|
for authority_path in protected_paths {
|
|
for writable_path in workload
|
|
.read_write_paths
|
|
.iter()
|
|
.chain(workload.state_root.iter())
|
|
{
|
|
ensure!(
|
|
!paths_overlap(&authority_path, writable_path),
|
|
"workload writable path {} overlaps Idunn authority {}",
|
|
writable_path.display(),
|
|
authority_path.display()
|
|
);
|
|
}
|
|
}
|
|
for path in workload.secret_files.values() {
|
|
require_absolute_path(path, "secret file")?;
|
|
}
|
|
let presence_identity = workload
|
|
.secret_files
|
|
.get(RUNTIME_PRESENCE_IDENTITY_BINDING)
|
|
.context("workload has no parent-only runtime presence identity source")?;
|
|
ensure!(
|
|
workload
|
|
.secret_files
|
|
.iter()
|
|
.filter(|(_, path)| *path == presence_identity)
|
|
.count()
|
|
== 1,
|
|
"runtime presence identity source is duplicated under another secret binding"
|
|
);
|
|
if let Some(route) = self.route.as_ref() {
|
|
require_id(&route.route_id, "route id")?;
|
|
require_value(&route.stable_endpoint, "stable endpoint")?;
|
|
require_host(&route.private_host)?;
|
|
let private_host: IpAddr = route
|
|
.private_host
|
|
.parse()
|
|
.context("route private host is not an IP address")?;
|
|
ensure!(
|
|
private_host.is_loopback(),
|
|
"systemd-transient candidate routes must use a loopback private host"
|
|
);
|
|
ensure!(
|
|
route.private_port_start > 0,
|
|
"private port range starts at zero"
|
|
);
|
|
ensure!(
|
|
route.private_port_start < route.private_port_end,
|
|
"candidate rollout requires at least two private ports"
|
|
);
|
|
route.stable_socket()?;
|
|
require_absolute_path(&route.config_path, "route config")?;
|
|
require_unit(&route.reload_unit, "route reload unit")?;
|
|
}
|
|
self.validate_shared_tail()
|
|
}
|
|
|
|
/// The part of validation that does not depend on where the workload runs.
|
|
fn validate_shared_tail(&self) -> Result<()> {
|
|
if let Some(deadlines) = &self.deadlines {
|
|
deadlines.validate()?;
|
|
}
|
|
ensure!(
|
|
self.rollout.retain_releases >= 2,
|
|
"candidate rollout must retain at least current and prior releases"
|
|
);
|
|
ensure!(
|
|
self.placement.desired_replicas == 1,
|
|
"operator binding v2 admits exactly one replica"
|
|
);
|
|
ensure!(
|
|
self.placement.nodes.len() == 1,
|
|
"operator binding v2 admits exactly one node"
|
|
);
|
|
for node in &self.placement.nodes {
|
|
require_id(node, "placement node")?;
|
|
}
|
|
let mut external_contracts = BTreeSet::new();
|
|
for capability in &self.external_capabilities {
|
|
require_id(&capability.provider_id, "external provider id")?;
|
|
require_capability_contract(
|
|
&capability.capability,
|
|
&capability.schema,
|
|
&capability.compatibility,
|
|
)?;
|
|
ensure!(
|
|
capability.capacity > 0,
|
|
"external capability capacity must be positive"
|
|
);
|
|
require_value(&capability.endpoint, "external capability endpoint")?;
|
|
ensure!(
|
|
external_contracts.insert((
|
|
capability.provider_id.as_str(),
|
|
capability.capability.as_str(),
|
|
capability.schema.as_str(),
|
|
capability.compatibility.as_str(),
|
|
)),
|
|
"external provider capability is declared twice"
|
|
);
|
|
}
|
|
for profile in &self.profiles {
|
|
require_capability(profile, "profile")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn admit(&self, declaration: &TargetDeclaration) -> Result<()> {
|
|
ensure!(
|
|
self.target == declaration.target,
|
|
"binding target does not match declaration"
|
|
);
|
|
let bound_gitlinks: BTreeSet<_> = self.repository.gitlinks.keys().cloned().collect();
|
|
ensure!(
|
|
bound_gitlinks == declaration.required_gitlinks,
|
|
"operator gitlink bindings do not exactly match required gitlinks"
|
|
);
|
|
let declared_runners: BTreeSet<_> = declaration
|
|
.steps
|
|
.iter()
|
|
.map(|step| step.runner.as_str())
|
|
.chain(
|
|
declaration
|
|
.external_inputs
|
|
.iter()
|
|
.map(|input| input.runner.as_str()),
|
|
)
|
|
.chain(
|
|
declaration
|
|
.artifacts
|
|
.iter()
|
|
.filter_map(|artifact| artifact.runner.as_deref()),
|
|
)
|
|
.collect();
|
|
let bound_runners: BTreeSet<_> = self.runners.keys().map(String::as_str).collect();
|
|
ensure!(
|
|
declared_runners == bound_runners,
|
|
"operator runner classes do not exactly match the target recipe"
|
|
);
|
|
ensure!(
|
|
self.process_write_lease.is_some() == declaration.write_lease_required(),
|
|
"process write-lease binding must exist exactly when writable state requires it"
|
|
);
|
|
ensure!(
|
|
self.workload.declares_state_root() == declaration.state.is_some(),
|
|
"state root binding must exist exactly when the recipe declares state"
|
|
);
|
|
for step in &declaration.steps {
|
|
let binding = &self.runners[&step.runner];
|
|
ensure!(
|
|
!binding
|
|
.environment()
|
|
.contains_key(&declaration.source_stamp_environment)
|
|
&& !binding
|
|
.secret_environment_names()
|
|
.contains(&&declaration.source_stamp_environment),
|
|
"runner cannot replace the Idunn source stamp"
|
|
);
|
|
ensure!(
|
|
binding
|
|
.affordances()
|
|
.contains(&RunnerAffordance::SourceRead),
|
|
"runner {} cannot read the frozen source",
|
|
step.runner
|
|
);
|
|
ensure!(
|
|
binding.allowed_programs().contains(&step.argv[0]),
|
|
"runner {} does not admit program {}",
|
|
step.runner,
|
|
step.argv[0]
|
|
);
|
|
let available: BTreeSet<_> = binding
|
|
.environment()
|
|
.keys()
|
|
.chain(binding.secret_environment_names())
|
|
.cloned()
|
|
.collect();
|
|
ensure!(
|
|
step.required_environment.is_subset(&available),
|
|
"step {} lacks required runner environment",
|
|
step.id
|
|
);
|
|
}
|
|
for artifact in declaration
|
|
.artifacts
|
|
.iter()
|
|
.filter(|artifact| artifact.source_kind == ArtifactSource::RunnerOutput)
|
|
{
|
|
let runner_id = artifact
|
|
.runner
|
|
.as_deref()
|
|
.expect("validated runner-output artifact");
|
|
let binding = &self.runners[runner_id];
|
|
ensure!(
|
|
binding
|
|
.affordances()
|
|
.contains(&RunnerAffordance::ArtifactWrite),
|
|
"runner {runner_id} cannot publish artifact {}",
|
|
artifact.id
|
|
);
|
|
}
|
|
for input in &declaration.external_inputs {
|
|
let binding = &self.runners[&input.runner];
|
|
ensure!(
|
|
binding
|
|
.affordances()
|
|
.contains(&RunnerAffordance::SourceRead),
|
|
"external input {} runner cannot read the frozen source",
|
|
input.id
|
|
);
|
|
ensure!(
|
|
binding
|
|
.affordances()
|
|
.contains(&RunnerAffordance::ArtifactWrite),
|
|
"external input {} runner cannot materialize inputs",
|
|
input.id
|
|
);
|
|
ensure!(
|
|
binding.network_profile().is_some(),
|
|
"external input {} runner has no operator-bound network profile",
|
|
input.id
|
|
);
|
|
}
|
|
let workload_names: BTreeSet<&String> = self
|
|
.workload
|
|
.environment()
|
|
.keys()
|
|
.chain(self.workload.secret_environment_names())
|
|
.collect();
|
|
for owned in [
|
|
IDUNN_RUNTIME_BUNDLE_ENVIRONMENT,
|
|
IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT,
|
|
IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT,
|
|
IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT,
|
|
] {
|
|
ensure!(
|
|
!workload_names.contains(&owned.to_string()),
|
|
"operator binding cannot replace Idunn-owned launch environment {owned}"
|
|
);
|
|
}
|
|
// On the Idunn host the presence identity is a parent-only descriptor
|
|
// and the activation credential another; neither is environment. A
|
|
// host actuator has no descriptor to hand over and passes both as
|
|
// files named by environment.
|
|
let host_workload = matches!(self.workload, WorkloadBinding::HostActuator(_));
|
|
let mut available_environment: BTreeSet<_> = workload_names
|
|
.into_iter()
|
|
.filter(|name| host_workload || name.as_str() != RUNTIME_PRESENCE_IDENTITY_BINDING)
|
|
.cloned()
|
|
.collect();
|
|
available_environment.insert(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT.into());
|
|
if host_workload {
|
|
available_environment.insert(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT.into());
|
|
}
|
|
if declaration.service.route_required {
|
|
available_environment.insert(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT.into());
|
|
}
|
|
if declaration.write_lease_required() {
|
|
available_environment.insert(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT.into());
|
|
}
|
|
ensure!(
|
|
declaration
|
|
.service
|
|
.required_environment
|
|
.is_subset(&available_environment),
|
|
"operator service environment omits a required launch input"
|
|
);
|
|
let declared_environment: BTreeSet<_> = declaration
|
|
.service
|
|
.required_environment
|
|
.union(&declaration.service.optional_environment)
|
|
.cloned()
|
|
.collect();
|
|
// On the Idunn host the presence identity reaches the workload as a
|
|
// parent-only descriptor, so a recipe that names it as environment
|
|
// is asking for something that will never be set. A host actuator
|
|
// has no descriptor to pass and hands it over as a file named by
|
|
// exactly this variable, so there the recipe must declare it.
|
|
ensure!(
|
|
host_workload == declared_environment.contains(RUNTIME_PRESENCE_IDENTITY_BINDING),
|
|
if host_workload {
|
|
"a host-actuator workload must declare the runtime presence identity as required environment"
|
|
} else {
|
|
"runtime presence identity is a named parent-only descriptor, not service environment"
|
|
}
|
|
);
|
|
ensure!(
|
|
available_environment.is_subset(&declared_environment),
|
|
"operator service environment contains an undeclared launch input"
|
|
);
|
|
let required_arguments: BTreeSet<_> = declaration
|
|
.service
|
|
.arguments
|
|
.iter()
|
|
.filter_map(|argument| match argument {
|
|
LaunchArgument::Binding { name } => Some(name.clone()),
|
|
LaunchArgument::Literal { .. } => None,
|
|
})
|
|
.collect();
|
|
let bound_arguments: BTreeSet<_> =
|
|
self.workload.argument_bindings().keys().cloned().collect();
|
|
let state_root: Option<String> = match &self.workload {
|
|
WorkloadBinding::SystemdTransient(workload) => workload
|
|
.state_root
|
|
.as_ref()
|
|
.map(|path| path.display().to_string()),
|
|
WorkloadBinding::HostActuator(workload) => workload.state_root.clone(),
|
|
};
|
|
match (
|
|
state_root.as_deref(),
|
|
self.workload.argument_bindings().get("state_root"),
|
|
) {
|
|
(Some(state_root), Some(argument)) => ensure!(
|
|
argument == state_root,
|
|
"state_root argument binding differs from the admitted workload state root"
|
|
),
|
|
(Some(_), None) => bail!("stateful workload has no state_root argument binding"),
|
|
(None, Some(_)) => bail!("stateless workload carries a state_root argument binding"),
|
|
(None, None) => {}
|
|
}
|
|
ensure!(
|
|
required_arguments.contains("state_root") == state_root.is_some(),
|
|
"recipe state_root argument must exist exactly when the workload has a state root"
|
|
);
|
|
ensure!(
|
|
required_arguments == bound_arguments,
|
|
"operator argument bindings do not exactly match the launch contract"
|
|
);
|
|
match (
|
|
declaration.service.route_required,
|
|
declaration.service.transport,
|
|
self.route.as_ref(),
|
|
) {
|
|
(true, ServiceTransport::Http, Some(route))
|
|
if route.driver == RouteDriver::NginxStreamTcp =>
|
|
{
|
|
ensure!(
|
|
route.stable_endpoint.starts_with("http://"),
|
|
"HTTP stable endpoint is not an HTTP URI"
|
|
);
|
|
}
|
|
(true, ServiceTransport::Tcp, Some(route))
|
|
if route.driver == RouteDriver::NginxStreamTcp =>
|
|
{
|
|
ensure!(
|
|
route.stable_endpoint.starts_with("tcp://"),
|
|
"TCP stable endpoint is not a TCP URI"
|
|
);
|
|
}
|
|
(true, ServiceTransport::Rudp, Some(route))
|
|
if route.driver == RouteDriver::NginxStreamUdp =>
|
|
{
|
|
ensure!(
|
|
route.stable_endpoint.starts_with("rudp://"),
|
|
"RUDP stable endpoint is not an RUDP URI"
|
|
);
|
|
}
|
|
(false, _, None) => {}
|
|
_ => bail!("route binding does not match the launch contract"),
|
|
}
|
|
for dependency in declaration
|
|
.dependencies
|
|
.iter()
|
|
.filter(|dependency| dependency.kind == DependencyKind::ExternalOperatorBinding)
|
|
{
|
|
ensure!(
|
|
self.external_capabilities.iter().any(|binding| {
|
|
capability_compatible(
|
|
&dependency.capability,
|
|
&dependency.schema,
|
|
&dependency.compatibility,
|
|
&binding.capability,
|
|
&binding.schema,
|
|
&binding.compatibility,
|
|
) && binding.capacity >= dependency.minimum_capacity
|
|
}),
|
|
"external capability {} has no compatible operator binding",
|
|
dependency.capability
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn capability_compatible(
|
|
required_capability: &str,
|
|
required_schema: &str,
|
|
required_compatibility: &str,
|
|
provided_capability: &str,
|
|
provided_schema: &str,
|
|
provided_compatibility: &str,
|
|
) -> bool {
|
|
required_capability == provided_capability
|
|
&& required_schema == provided_schema
|
|
&& required_compatibility == provided_compatibility
|
|
}
|
|
|
|
fn unique_ids<'a>(values: impl Iterator<Item = &'a str>, label: &str) -> Result<BTreeSet<&'a str>> {
|
|
let mut seen = BTreeSet::new();
|
|
for value in values {
|
|
require_id(value, label)?;
|
|
ensure!(seen.insert(value), "duplicate {label} id {value}");
|
|
}
|
|
Ok(seen)
|
|
}
|
|
|
|
fn require_id(value: &str, label: &str) -> Result<()> {
|
|
ensure!(!value.is_empty(), "{label} is empty");
|
|
ensure!(value.len() <= 96, "{label} is too long");
|
|
ensure!(
|
|
value.bytes().all(|byte| byte.is_ascii_lowercase()
|
|
|| byte.is_ascii_digit()
|
|
|| matches!(byte, b'-' | b'_' | b'.' | b':')),
|
|
"{label} contains an invalid character"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_capability(value: &str, label: &str) -> Result<()> {
|
|
require_id(value, label)
|
|
}
|
|
|
|
fn require_capability_contract(capability: &str, schema: &str, compatibility: &str) -> Result<()> {
|
|
require_capability(capability, "capability")?;
|
|
require_capability(schema, "capability schema")?;
|
|
require_capability(compatibility, "capability compatibility")
|
|
}
|
|
|
|
fn require_value(value: &str, label: &str) -> Result<()> {
|
|
ensure!(!value.trim().is_empty(), "{label} is empty");
|
|
ensure!(
|
|
!value.chars().any(char::is_control),
|
|
"{label} contains a control character"
|
|
);
|
|
ensure!(value.len() <= 4_096, "{label} is too long");
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_launch_argument(argument: &LaunchArgument) -> Result<()> {
|
|
match argument {
|
|
LaunchArgument::Literal { value } => require_value(value, "literal launch argument"),
|
|
LaunchArgument::Binding { name } => require_id(name, "launch argument binding"),
|
|
}
|
|
}
|
|
|
|
fn require_program(value: &str, label: &str) -> Result<()> {
|
|
require_value(value, label)?;
|
|
ensure!(
|
|
!value.contains('/') && !value.contains('\\') && !value.chars().any(char::is_whitespace),
|
|
"{label} must be one executable name"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_git_origin(value: &str, label: &str) -> Result<()> {
|
|
ensure!(value.starts_with("https://"), "{label} must use HTTPS");
|
|
ensure!(
|
|
value.ends_with(".git"),
|
|
"{label} must name a Git repository"
|
|
);
|
|
require_value(value, label)
|
|
}
|
|
|
|
fn https_host(value: &str) -> Result<&str> {
|
|
let remainder = value
|
|
.strip_prefix("https://")
|
|
.ok_or_else(|| anyhow::anyhow!("artifact URL must use HTTPS"))?;
|
|
let authority = remainder.split('/').next().unwrap_or_default();
|
|
ensure!(!authority.is_empty(), "artifact URL has no host");
|
|
ensure!(
|
|
!authority.contains('@')
|
|
&& !authority.contains(':')
|
|
&& !authority.contains('?')
|
|
&& !authority.contains('#'),
|
|
"artifact URL authority is not a bare host"
|
|
);
|
|
require_host(authority)?;
|
|
Ok(authority)
|
|
}
|
|
|
|
fn require_environment_name(value: &str) -> Result<()> {
|
|
ensure!(
|
|
!value.is_empty() && value.len() <= 128,
|
|
"environment name is empty or too long"
|
|
);
|
|
ensure!(
|
|
value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'),
|
|
"environment name contains an invalid character"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_relative_path(path: &Path, label: &str, allow_current: bool) -> Result<()> {
|
|
ensure!(!path.as_os_str().is_empty(), "{label} is empty");
|
|
ensure!(!path.is_absolute(), "{label} must be relative");
|
|
let mut saw_normal = false;
|
|
for component in path.components() {
|
|
match component {
|
|
Component::Normal(_) => saw_normal = true,
|
|
Component::CurDir if allow_current => {}
|
|
_ => bail!("{label} contains a forbidden path component"),
|
|
}
|
|
}
|
|
ensure!(allow_current || saw_normal, "{label} must name a path");
|
|
Ok(())
|
|
}
|
|
|
|
fn require_leaf_path(path: &Path, label: &str) -> Result<()> {
|
|
require_relative_path(path, label, false)?;
|
|
ensure!(
|
|
path.components().count() == 1,
|
|
"{label} must be a single filename"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Paths in a binding are the Idunn host's, and the Idunn host is Linux. A
|
|
/// host actuator re-parses the same binding on Windows to reach its own
|
|
/// section, so "absolute" is judged by the path's own shape, not by the
|
|
/// platform doing the judging.
|
|
fn require_absolute_path(path: &Path, label: &str) -> Result<()> {
|
|
ensure!(
|
|
path.is_absolute() || path.to_string_lossy().starts_with('/'),
|
|
"{label} must be absolute"
|
|
);
|
|
ensure!(
|
|
path.components()
|
|
.all(|component| !matches!(component, Component::ParentDir)),
|
|
"{label} contains a parent traversal"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn paths_overlap(left: &Path, right: &Path) -> bool {
|
|
left.starts_with(right) || right.starts_with(left)
|
|
}
|
|
|
|
fn require_sha1(value: &str, label: &str) -> Result<()> {
|
|
require_lower_hex(value, 40, label)
|
|
}
|
|
|
|
fn require_sha256(value: &str, label: &str) -> Result<()> {
|
|
require_lower_hex(value, 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} must be lowercase hexadecimal"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_pinned_image(value: &str) -> Result<()> {
|
|
let Some((name, digest)) = value.rsplit_once("@sha256:") else {
|
|
bail!("runner image is not pinned by sha256 digest");
|
|
};
|
|
require_value(name, "runner image name")?;
|
|
require_sha256(digest, "runner image digest")
|
|
}
|
|
|
|
fn require_container_identity(value: &str) -> Result<()> {
|
|
let (uid, gid) = value
|
|
.split_once(':')
|
|
.context("runner user must be an explicit numeric UID:GID")?;
|
|
let uid: u32 = uid.parse().context("runner UID is not a u32")?;
|
|
let gid: u32 = gid.parse().context("runner GID is not a u32")?;
|
|
ensure!(uid > 0 && gid > 0, "runner identity must be unprivileged");
|
|
Ok(())
|
|
}
|
|
|
|
fn require_identity(value: &str, label: &str) -> Result<()> {
|
|
require_id(value, label)
|
|
}
|
|
|
|
fn require_unit(value: &str, label: &str) -> Result<()> {
|
|
require_value(value, label)?;
|
|
ensure!(
|
|
value.ends_with(".service"),
|
|
"{label} must be a systemd service unit"
|
|
);
|
|
ensure!(
|
|
!value.contains('/') && !value.contains('\\'),
|
|
"{label} must not contain a path"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn require_host(value: &str) -> Result<()> {
|
|
require_value(value, "host")?;
|
|
ensure!(
|
|
!value.contains('/') && !value.contains('@'),
|
|
"host contains an invalid delimiter"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
|
|
// `idunn validate`'s tests in control_plane reuse these two fixtures.
|
|
pub(crate) const RECIPE: &str = r#"
|
|
schema = "gamecult.idunn.target_declaration.v1"
|
|
target = "test-service"
|
|
source_stamp_environment = "TEST_SERVICE_BUILD_COMMIT"
|
|
|
|
[[steps]]
|
|
id = "test"
|
|
phase = "test"
|
|
runner = "rust"
|
|
argv = ["cargo", "test", "--locked", "-p", "test-service"]
|
|
|
|
[[steps]]
|
|
id = "build"
|
|
phase = "build"
|
|
runner = "rust"
|
|
argv = ["cargo", "build", "--locked", "--release", "-p", "test-service"]
|
|
|
|
[[external_inputs]]
|
|
id = "tool"
|
|
url = "https://registry.npmjs.org/tool.tgz"
|
|
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
runner = "rust"
|
|
destination = "inputs/tool.tgz"
|
|
|
|
[[artifacts]]
|
|
id = "daemon"
|
|
source_kind = "runner-output"
|
|
runner = "rust"
|
|
source = "target/release/test-service"
|
|
destination = "test-service"
|
|
expected_sha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
|
executable = true
|
|
|
|
[service]
|
|
executable_artifact = "daemon"
|
|
required_adjacent_artifacts = []
|
|
arguments = [
|
|
{ kind = "literal", value = "--state-root" },
|
|
{ kind = "binding", name = "state_root" },
|
|
]
|
|
transport = "http"
|
|
route_required = true
|
|
required_environment = ["GAMECULT_IDUNN_CANDIDATE_BIND", "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", "GAMECULT_IDUNN_RUNTIME_BUNDLE"]
|
|
|
|
[service.health]
|
|
contract = "test-service.cultnet-service-health"
|
|
|
|
[state]
|
|
schema_generation = "v1"
|
|
|
|
[[state.slots]]
|
|
id = "world"
|
|
relative_path = "world.cc"
|
|
kind = "cultcache-file"
|
|
schema = "test-service.state.v1"
|
|
writer = "process-bound-single-writer"
|
|
recovery = "preserve"
|
|
startup = "create-or-open-after-write-lease"
|
|
|
|
[[provides]]
|
|
capability = "test-service.runtime"
|
|
schema = "test-service.state.v1"
|
|
compatibility = "v1"
|
|
|
|
[[dependencies]]
|
|
kind = "shared-infrastructure"
|
|
capability = "odin.verse-rendezvous"
|
|
schema = "odin.verse-topology.v1"
|
|
compatibility = "v1"
|
|
startup = "before-promotion"
|
|
"#;
|
|
|
|
pub(crate) const BINDING: &str = r#"
|
|
schema = "gamecult.idunn.operator_binding.v2"
|
|
target = "test-service"
|
|
profiles = ["aetheria", "full-gamecult"]
|
|
|
|
[repository]
|
|
origin = "https://github.com/GameCult/TestService.git"
|
|
admitted_ref = "refs/heads/main"
|
|
minimum_revision = "13d5136b69bcd3a52c400d66a940f94a47122e48"
|
|
selection = "ref-head"
|
|
checkout = "/srv/build/TestService"
|
|
recipe_path = "deployment/idunn/recipe.toml"
|
|
|
|
[runners.rust]
|
|
driver = "docker"
|
|
image = "rust@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb"
|
|
user = "1000:1000"
|
|
affordances = ["source-read", "artifact-write", "build-cache"]
|
|
cache_root = "/srv/ghostlight/build-cache"
|
|
allowed_programs = ["cargo"]
|
|
network_profile = "build-dependency-egress"
|
|
memory_mebibytes = 8192
|
|
cpu_quota_percent = 400
|
|
pids_limit = 512
|
|
tmpfs_mebibytes = 1024
|
|
|
|
[workload]
|
|
driver = "systemd-transient"
|
|
state_group = "test-service"
|
|
unit_prefix = "idunn-test-service"
|
|
release_root = "/srv/test-service/releases"
|
|
state_root = "/var/lib/gamecult/test-service"
|
|
runtime_root = "/etc/gamecult/test-service/runtime"
|
|
network = "host-private"
|
|
hardening = "strict"
|
|
memory_mebibytes = 2048
|
|
cpu_quota_percent = 200
|
|
|
|
[workload.argument_bindings]
|
|
state_root = "/var/lib/gamecult/test-service"
|
|
|
|
[workload.secret_files]
|
|
GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/test-service/runtime-presence-identity.cc"
|
|
|
|
[runtime_identity]
|
|
runtime_id = "test-service-yggdrasil"
|
|
expected_signer_identity_id = "test-service-runtime-signer"
|
|
trust_anchor_store = "/etc/gamecult/trust/test-service.cc"
|
|
|
|
[route]
|
|
driver = "nginx-stream-tcp"
|
|
route_id = "test-service-public"
|
|
stable_endpoint = "http://127.0.0.1:18830"
|
|
private_host = "127.0.0.1"
|
|
private_port_start = 18831
|
|
private_port_end = 18839
|
|
config_path = "/etc/nginx/idunn-stream-routes/test-service.conf"
|
|
reload_unit = "nginx.service"
|
|
|
|
[process_write_lease]
|
|
record_path = "/etc/gamecult/test-service/runtime/process-write-lease.cc"
|
|
|
|
[brakes]
|
|
deployment_store = "/var/lib/gamecult/idunn-authority/test-service-deployment-brake.cc"
|
|
lifecycle_store = "/var/lib/gamecult/idunn-authority/test-service-lifecycle-brake.cc"
|
|
|
|
[rollout]
|
|
strategy = "candidate-then-promote"
|
|
drain_seconds = 30
|
|
retain_releases = 2
|
|
|
|
[placement]
|
|
desired_replicas = 1
|
|
nodes = ["yggdrasil"]
|
|
"#;
|
|
|
|
#[test]
|
|
fn strict_recipe_and_binding_admit_without_host_privilege_in_recipe() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let binding = OperatorBinding::parse(BINDING).unwrap();
|
|
binding.admit(&recipe).unwrap();
|
|
assert_eq!(recipe.target, "test-service");
|
|
assert_eq!(binding.route.as_ref().unwrap().private_port_start, 18831);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_recipe_field_is_rejected() {
|
|
let input = RECIPE.replace(
|
|
"target = \"test-service\"",
|
|
"target = \"test-service\"\nroot = true",
|
|
);
|
|
assert!(TargetDeclaration::parse(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn operator_cannot_omit_an_affordance_derived_from_the_recipe() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let input = BINDING.replace(
|
|
"affordances = [\"source-read\", \"artifact-write\", \"build-cache\"]",
|
|
"affordances = [\"source-read\", \"build-cache\"]",
|
|
);
|
|
let binding = OperatorBinding::parse(&input).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn runner_mounts_require_their_exact_operator_affordance() {
|
|
let cache_without_affordance = BINDING.replace(
|
|
"affordances = [\"source-read\", \"artifact-write\", \"build-cache\"]",
|
|
"affordances = [\"source-read\", \"artifact-write\"]",
|
|
);
|
|
assert!(OperatorBinding::parse(&cache_without_affordance).is_err());
|
|
|
|
let affordance_without_secret = BINDING.replace(
|
|
"affordances = [\"source-read\", \"artifact-write\", \"build-cache\"]",
|
|
"affordances = [\"source-read\", \"artifact-write\", \"build-cache\", \"secret-read\"]",
|
|
);
|
|
assert!(OperatorBinding::parse(&affordance_without_secret).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn runner_cannot_replace_the_source_stamp() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let forged = BINDING.replace(
|
|
"[workload]",
|
|
"[runners.rust.environment]\nTEST_SERVICE_BUILD_COMMIT = \"forged\"\n\n[workload]",
|
|
);
|
|
let binding = OperatorBinding::parse(&forged).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn route_transport_mismatch_is_rejected() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
assert!(
|
|
OperatorBinding::parse(&BINDING.replace("nginx-stream-tcp", "nginx-stream-udp"))
|
|
.is_err()
|
|
);
|
|
|
|
assert!(
|
|
OperatorBinding::parse(
|
|
&BINDING.replace("http://127.0.0.1:18830", "tcp://yggdrasil.gamecult.org:443",),
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(recipe.service.route_required);
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_route_uses_the_udp_stream_driver() {
|
|
let recipe = TargetDeclaration::parse(
|
|
&RECIPE.replace("transport = \"http\"", "transport = \"rudp\""),
|
|
)
|
|
.unwrap();
|
|
let binding = OperatorBinding::parse(
|
|
&BINDING
|
|
.replace("nginx-stream-tcp", "nginx-stream-udp")
|
|
.replace("http://127.0.0.1:18830", "rudp://127.0.0.1:17872"),
|
|
)
|
|
.unwrap();
|
|
assert!(binding.admit(&recipe).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn raw_unit_template_is_rejected() {
|
|
let input = RECIPE.replace(
|
|
"executable_artifact = \"daemon\"",
|
|
"executable_artifact = \"daemon\"\nunit_template = \"deployment/root.service\"",
|
|
);
|
|
assert!(TargetDeclaration::parse(&input).is_err());
|
|
}
|
|
|
|
/// The refusal of a binding that sets a name its recipe does not declare.
|
|
/// A recipe declares a secret only as a `*_FILE` name, so this one rule is
|
|
/// what leaves a plaintext secret no place in a binding.
|
|
pub(crate) const UNDECLARED_INPUT_REFUSAL: &str =
|
|
"operator service environment contains an undeclared launch input";
|
|
|
|
/// RECIPE plus one declared plain input and one declared secret-file input.
|
|
pub(crate) fn recipe_declaring_inputs() -> String {
|
|
RECIPE.replace(
|
|
"required_environment = [",
|
|
"optional_environment = [\"DECLARED_INPUT\", \"DECLARED_SECRET_FILE\"]\nrequired_environment = [",
|
|
)
|
|
}
|
|
|
|
/// BINDING plus one `[workload.environment]` entry.
|
|
pub(crate) fn binding_with_environment(name: &str, value: &str) -> String {
|
|
BINDING.replace(
|
|
"[workload.argument_bindings]",
|
|
&format!("[workload.environment]\n{name} = \"{value}\"\n\n[workload.argument_bindings]"),
|
|
)
|
|
}
|
|
|
|
/// BINDING plus one `[workload.secret_files]` entry.
|
|
fn binding_with_secret_file(name: &str) -> String {
|
|
BINDING.replace(
|
|
"[workload.secret_files]\n",
|
|
&format!(
|
|
"[workload.secret_files]\n{name} = \"/etc/gamecult/test-service/credentials/{name}\"\n"
|
|
),
|
|
)
|
|
}
|
|
|
|
fn admit_refusal(recipe: &TargetDeclaration, binding: &str) -> String {
|
|
let binding = OperatorBinding::parse(binding).unwrap();
|
|
format!("{:#}", binding.admit(recipe).unwrap_err())
|
|
}
|
|
|
|
#[test]
|
|
fn binding_environment_sets_only_declared_names() {
|
|
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
|
|
OperatorBinding::parse(&binding_with_environment("DECLARED_INPUT", "plain"))
|
|
.unwrap()
|
|
.admit(&recipe)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
admit_refusal(&recipe, &binding_with_environment("UNDECLARED_INPUT", "plain")),
|
|
UNDECLARED_INPUT_REFUSAL
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn binding_secret_files_set_only_declared_names() {
|
|
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
|
|
OperatorBinding::parse(&binding_with_secret_file("DECLARED_SECRET_FILE"))
|
|
.unwrap()
|
|
.admit(&recipe)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
admit_refusal(&recipe, &binding_with_secret_file("UNDECLARED_SECRET_FILE")),
|
|
UNDECLARED_INPUT_REFUSAL
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn declaring_a_secret_file_does_not_admit_its_plaintext_twin() {
|
|
// The shape of the Heimdall leak: the recipe declares only
|
|
// DECLARED_SECRET_FILE, and a binding puts the value itself under
|
|
// DECLARED_SECRET in plain environment.
|
|
let recipe = TargetDeclaration::parse(&recipe_declaring_inputs()).unwrap();
|
|
assert_eq!(
|
|
admit_refusal(&recipe, &binding_with_environment("DECLARED_SECRET", "plain")),
|
|
UNDECLARED_INPUT_REFUSAL
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_presence_identity_is_exactly_one_parent_only_secret_source() {
|
|
let secret = "[workload.secret_files]\nGAMECULT_RUNTIME_PRESENCE_IDENTITY = \"/etc/gamecult/test-service/runtime-presence-identity.cc\"\n\n";
|
|
assert!(OperatorBinding::parse(&BINDING.replace(secret, "")).is_err());
|
|
assert!(OperatorBinding::parse(&BINDING.replace(
|
|
secret,
|
|
"[workload.environment]\nGAMECULT_RUNTIME_PRESENCE_IDENTITY = \"/etc/gamecult/test-service/runtime-presence-identity.cc\"\n\n",
|
|
))
|
|
.is_err());
|
|
assert!(OperatorBinding::parse(&BINDING.replace(
|
|
secret,
|
|
"[workload.secret_files]\nGAMECULT_RUNTIME_PRESENCE_IDENTITY = \"/etc/gamecult/test-service/runtime-presence-identity.cc\"\nDUPLICATE_IDENTITY = \"/etc/gamecult/test-service/runtime-presence-identity.cc\"\n\n",
|
|
))
|
|
.is_err());
|
|
|
|
let recipe = TargetDeclaration::parse(&RECIPE.replace(
|
|
"required_environment = [",
|
|
"required_environment = [\"GAMECULT_RUNTIME_PRESENCE_IDENTITY\", ",
|
|
))
|
|
.unwrap();
|
|
let binding = OperatorBinding::parse(BINDING).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn operator_cannot_replace_idunn_owned_launch_inputs() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
for name in [
|
|
IDUNN_RUNTIME_BUNDLE_ENVIRONMENT,
|
|
IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT,
|
|
IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT,
|
|
] {
|
|
let input = BINDING.replace(
|
|
"[workload.argument_bindings]",
|
|
&format!(
|
|
"[workload.environment]\n{name} = \"forged\"\n\n[workload.argument_bindings]"
|
|
),
|
|
);
|
|
let rejected = OperatorBinding::parse(&input)
|
|
.and_then(|binding| binding.admit(&recipe).map(|_| ()));
|
|
assert!(rejected.is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn state_slot_path_cannot_escape_the_operator_owned_root() {
|
|
let input = RECIPE.replace(
|
|
"relative_path = \"world.cc\"",
|
|
"relative_path = \"../world.cc\"",
|
|
);
|
|
assert!(TargetDeclaration::parse(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mismatched_state_root_argument_binding_is_rejected() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let mismatched = BINDING.replace(
|
|
"[workload.argument_bindings]\nstate_root = \"/var/lib/gamecult/test-service\"",
|
|
"[workload.argument_bindings]\nstate_root = \"/var/lib/gamecult/another-service\"",
|
|
);
|
|
let binding = OperatorBinding::parse(&mismatched).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn external_file_state_keeps_process_bound_writer_and_lease_semantics() {
|
|
let input = RECIPE.replace("kind = \"cultcache-file\"", "kind = \"external-file\"");
|
|
let declaration = TargetDeclaration::parse(&input).unwrap();
|
|
let slot = &declaration.state.as_ref().unwrap().slots[0];
|
|
assert_eq!(slot.kind, StateKind::ExternalFile);
|
|
assert_eq!(slot.writer, StateWriter::ProcessBoundSingleWriter);
|
|
assert_eq!(slot.startup, StateStartup::CreateOrOpenAfterWriteLease);
|
|
assert!(declaration.write_lease_required());
|
|
}
|
|
|
|
#[test]
|
|
fn state_slots_cannot_claim_overlapping_paths() {
|
|
let input = RECIPE.replace(
|
|
"startup = \"create-or-open-after-write-lease\"",
|
|
"startup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"nested\"\nrelative_path = \"world.cc/index.cc\"\nkind = \"cultcache-file\"\nschema = \"test-service.index.v1\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"",
|
|
);
|
|
assert!(TargetDeclaration::parse(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn workload_mount_cannot_bypass_write_lease_controlled_state() {
|
|
let input = BINDING.replace(
|
|
"cpu_quota_percent = 200\n\n[workload.argument_bindings]",
|
|
"cpu_quota_percent = 200\nread_write_paths = [\"/var/lib/gamecult/test-service/world\"]\n\n[workload.argument_bindings]",
|
|
);
|
|
assert!(OperatorBinding::parse(&input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn v2_binding_is_explicitly_singleton() {
|
|
assert!(
|
|
OperatorBinding::parse(
|
|
&BINDING.replace("desired_replicas = 1", "desired_replicas = 2")
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
OperatorBinding::parse(&BINDING.replace(
|
|
"nodes = [\"yggdrasil\"]",
|
|
"nodes = [\"yggdrasil\", \"raven\"]"
|
|
))
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_static_workload_identity_has_no_authority_in_v2() {
|
|
assert!(
|
|
OperatorBinding::parse(&BINDING.replace(
|
|
"schema = \"gamecult.idunn.operator_binding.v2\"",
|
|
"schema = \"gamecult.idunn.operator_binding.v1\"",
|
|
),)
|
|
.is_err()
|
|
);
|
|
let with_static_identity = BINDING.replace(
|
|
"driver = \"systemd-transient\"",
|
|
"driver = \"systemd-transient\"\nuser = \"test-service\"\ngroup = \"test-service\"",
|
|
);
|
|
assert!(OperatorBinding::parse(&with_static_identity).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn writable_state_requires_one_non_root_state_group() {
|
|
assert!(
|
|
OperatorBinding::parse(&BINDING.replace("state_group = \"test-service\"\n", ""),)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
OperatorBinding::parse(
|
|
&BINDING.replace("state_group = \"test-service\"", "state_group = \"root\""),
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_input_uses_an_operator_bound_network_profile() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let binding = OperatorBinding::parse(BINDING).unwrap();
|
|
binding.admit(&recipe).unwrap();
|
|
let isolated = BINDING.replace("network_profile = \"build-dependency-egress\"\n", "");
|
|
let binding = OperatorBinding::parse(&isolated).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn process_write_lease_exists_exactly_for_process_writable_state() {
|
|
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
|
let without_lease = BINDING.replace(
|
|
"[process_write_lease]\nrecord_path = \"/etc/gamecult/test-service/runtime/process-write-lease.cc\"\n\n",
|
|
"",
|
|
);
|
|
let binding = OperatorBinding::parse(&without_lease).unwrap();
|
|
assert!(binding.admit(&recipe).is_err());
|
|
|
|
let read_only_recipe = TargetDeclaration::parse(
|
|
&RECIPE
|
|
.replace(
|
|
"writer = \"process-bound-single-writer\"",
|
|
"writer = \"none\"",
|
|
)
|
|
.replace(
|
|
"startup = \"create-or-open-after-write-lease\"",
|
|
"startup = \"open-at-start\"",
|
|
)
|
|
.replace("\"GAMECULT_IDUNN_PROCESS_WRITE_LEASE\", ", ""),
|
|
)
|
|
.unwrap();
|
|
binding.admit(&read_only_recipe).unwrap();
|
|
|
|
let binding = OperatorBinding::parse(BINDING).unwrap();
|
|
assert_eq!(
|
|
binding.process_write_lease.as_ref().unwrap().lock_path(),
|
|
PathBuf::from("/etc/gamecult/test-service/runtime/process-write-lease.cc.lock")
|
|
);
|
|
assert!(binding.admit(&read_only_recipe).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn stateless_target_has_no_state_binding_or_generation() {
|
|
let mut recipe_text = RECIPE.replace(
|
|
"arguments = [\n { kind = \"literal\", value = \"--state-root\" },\n { kind = \"binding\", name = \"state_root\" },\n]",
|
|
"arguments = []",
|
|
);
|
|
recipe_text = recipe_text.replace("\"GAMECULT_IDUNN_PROCESS_WRITE_LEASE\", ", "");
|
|
let state_start = recipe_text.find("\n[state]\n").unwrap();
|
|
let state_end = recipe_text.find("\n[[provides]]\n").unwrap();
|
|
recipe_text.replace_range(state_start..state_end, "");
|
|
let recipe = TargetDeclaration::parse(&recipe_text).unwrap();
|
|
assert!(recipe.state.is_none());
|
|
|
|
let binding_text = BINDING
|
|
.replace("state_group = \"test-service\"\n", "")
|
|
.replace("state_root = \"/var/lib/gamecult/test-service\"\n", "")
|
|
.replace(
|
|
"[workload.argument_bindings]\nstate_root = \"/var/lib/gamecult/test-service\"\n\n",
|
|
"",
|
|
)
|
|
.replace(
|
|
"[process_write_lease]\nrecord_path = \"/etc/gamecult/test-service/runtime/process-write-lease.cc\"\n\n",
|
|
"",
|
|
);
|
|
let binding = OperatorBinding::parse(&binding_text).unwrap();
|
|
binding.admit(&recipe).unwrap();
|
|
assert!(!binding.workload.declares_state_root());
|
|
}
|
|
|
|
#[test]
|
|
fn bootstrap_dependency_must_be_available_before_start() {
|
|
let invalid = RECIPE.replace("kind = \"shared-infrastructure\"", "kind = \"bootstrap\"");
|
|
assert!(TargetDeclaration::parse(&invalid).is_err());
|
|
let valid = invalid.replace(
|
|
"startup = \"before-promotion\"",
|
|
"startup = \"before-start\"",
|
|
);
|
|
TargetDeclaration::parse(&valid).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_compatibility_requires_exact_typed_contract() {
|
|
assert!(capability_compatible(
|
|
"odin.verse-rendezvous",
|
|
"odin.verse-topology.v1",
|
|
"v1",
|
|
"odin.verse-rendezvous",
|
|
"odin.verse-topology.v1",
|
|
"v1",
|
|
));
|
|
assert!(!capability_compatible(
|
|
"odin.verse-rendezvous",
|
|
"odin.verse-topology.v1",
|
|
"v1",
|
|
"odin.verse-rendezvous",
|
|
"odin.verse-topology.v2",
|
|
"v2",
|
|
));
|
|
}
|
|
}
|