Skip to main content

canic_host/release_set/current/
mod.rs

1//! Module: release_set::current
2//!
3//! Responsibility: bind the application union and infrastructure manifest into one release authority.
4//! Does not own: artifact builds, topology, Fleet identities, or installation.
5//! Boundary: finalization hashes one canonical manifest whose children were independently validated.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    durable_io::{
12        RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes,
13    },
14    release_build::{ReleaseBuildPlanError, ReleaseBuildPlanState, load_release_build_plan},
15    release_set::{
16        PersistedApplicationArtifactUnion, PersistedCanicInfrastructureArtifactManifest,
17        fixture::{
18            FixtureArtifactError, PersistedFixtureArtifactManifest, load_fixture_artifact_manifest,
19            verify_fixture_artifacts,
20        },
21    },
22};
23use canic_core::{
24    bootstrap::compiled::ComponentTopology,
25    ids::{BuildNetwork, ReleaseBuildId},
26};
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use std::{
30    io,
31    path::{Path, PathBuf},
32};
33use thiserror::Error as ThisError;
34
35pub const CURRENT_RELEASE_SET_MANIFEST_FILE: &str = "current-release-set-manifest.json";
36
37/// Canonical complete current release authority consumed by Fleet generation.
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct CurrentReleaseSetManifest {
41    pub application_artifact_union_sha256: [u8; 32],
42    pub build_network: BuildNetwork,
43    pub fixture_artifact_manifest_sha256: [u8; 32],
44    pub infrastructure_artifact_manifest_sha256: [u8; 32],
45    pub release_build_id: ReleaseBuildId,
46    pub schema_version: u16,
47}
48
49impl CurrentReleaseSetManifest {
50    pub const SCHEMA_VERSION: u16 = 1;
51
52    pub fn validate(
53        &self,
54        release_build_id: ReleaseBuildId,
55    ) -> Result<(), CurrentReleaseSetManifestError> {
56        if self.schema_version != Self::SCHEMA_VERSION {
57            return Err(CurrentReleaseSetManifestError::Invalid(format!(
58                "unsupported schema version {}",
59                self.schema_version
60            )));
61        }
62        if self.release_build_id != release_build_id {
63            return Err(CurrentReleaseSetManifestError::Invalid(
64                "path and document release-build identities differ".to_string(),
65            ));
66        }
67        Ok(())
68    }
69
70    /// Validate retained fixture bytes against the complete selected release authority.
71    pub fn verify_fixtures(
72        &self,
73        root: &Path,
74        topology: &ComponentTopology,
75    ) -> Result<PersistedFixtureArtifactManifest, FixtureArtifactError> {
76        let fixtures = load_fixture_artifact_manifest(
77            root,
78            topology,
79            self.release_build_id,
80            self.fixture_artifact_manifest_sha256,
81        )?;
82        verify_fixture_artifacts(
83            root,
84            topology,
85            self.release_build_id,
86            self.fixture_artifact_manifest_sha256,
87        )?;
88        Ok(fixtures)
89    }
90
91    pub fn canonical_bytes(&self) -> Result<Vec<u8>, CurrentReleaseSetManifestError> {
92        self.validate(self.release_build_id)?;
93        serde_json::to_vec(self).map_err(CurrentReleaseSetManifestError::Serialize)
94    }
95}
96
97/// One persisted complete current release authority.
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct PersistedCurrentReleaseSetManifest {
100    pub digest: [u8; 32],
101    pub manifest: CurrentReleaseSetManifest,
102    pub path: PathBuf,
103}
104
105/// Typed complete release-set persistence failure.
106#[derive(Debug, ThisError)]
107pub enum CurrentReleaseSetManifestError {
108    #[error("current release-set manifest already exists with different contents: {0}")]
109    Conflict(PathBuf),
110
111    #[error("invalid current release-set manifest: {0}")]
112    Invalid(String),
113
114    #[error("current release-set manifest is missing: {0}")]
115    Missing(PathBuf),
116
117    #[error("current release-set manifest is not a regular no-follow file: {0}")]
118    Unsafe(PathBuf),
119
120    #[error("failed to access current release-set manifest {path}: {source}")]
121    Io {
122        path: PathBuf,
123        #[source]
124        source: io::Error,
125    },
126
127    #[error("failed to encode current release-set manifest: {0}")]
128    Serialize(serde_json::Error),
129
130    #[error(transparent)]
131    Fixture(#[from] FixtureArtifactError),
132
133    #[error(transparent)]
134    ReleaseBuild(#[from] ReleaseBuildPlanError),
135}
136
137/// Bind the exact artifact and fixture child manifests before the release build becomes immutable.
138pub fn compile_and_persist_current_release_set_manifest(
139    root: &Path,
140    topology: &ComponentTopology,
141    release_build_id: ReleaseBuildId,
142    application: &PersistedApplicationArtifactUnion,
143    infrastructure: &PersistedCanicInfrastructureArtifactManifest,
144    fixtures: &PersistedFixtureArtifactManifest,
145) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
146    let release_build = load_release_build_plan(root, release_build_id)?;
147    if application.union.release_build_id != release_build_id
148        || infrastructure.manifest.release_build_id != release_build_id
149    {
150        return Err(CurrentReleaseSetManifestError::Invalid(
151            "child manifest release-build identities differ".to_string(),
152        ));
153    }
154    let retained =
155        load_fixture_artifact_manifest(root, topology, release_build_id, fixtures.digest)?;
156    if retained != *fixtures
157        || application.union.fleet_component_topology_digest
158            != retained.manifest.component_topology_digest
159    {
160        return Err(FixtureArtifactError::Authority.into());
161    }
162    verify_fixture_artifacts(root, topology, release_build_id, fixtures.digest)?;
163    let expected = CurrentReleaseSetManifest {
164        application_artifact_union_sha256: application.digest,
165        build_network: release_build.build_network,
166        fixture_artifact_manifest_sha256: fixtures.digest,
167        infrastructure_artifact_manifest_sha256: infrastructure.digest,
168        release_build_id,
169        schema_version: CurrentReleaseSetManifest::SCHEMA_VERSION,
170    };
171    let path = current_release_set_manifest_path(root, release_build_id);
172    let existing = load_optional(&path, release_build_id)?;
173    if matches!(release_build.state, ReleaseBuildPlanState::Finalized { .. }) {
174        return match existing {
175            Some(existing) if existing.manifest == expected => Ok(existing),
176            _ => Err(CurrentReleaseSetManifestError::Conflict(path)),
177        };
178    }
179    if let Some(existing) = existing {
180        return if existing.manifest == expected {
181            Ok(existing)
182        } else {
183            Err(CurrentReleaseSetManifestError::Conflict(path))
184        };
185    }
186    let bytes = expected.canonical_bytes()?;
187    if let Err(source) = create_new_bytes_with_parents(&path, &bytes) {
188        if let Ok(Some(existing)) = load_optional(&path, release_build_id)
189            && existing.manifest == expected
190        {
191            return Ok(existing);
192        }
193        return Err(CurrentReleaseSetManifestError::Io { path, source });
194    }
195    load_persisted_current_release_set_manifest(root, release_build_id)
196}
197
198/// Load one exact complete current release authority.
199pub fn load_persisted_current_release_set_manifest(
200    root: &Path,
201    release_build_id: ReleaseBuildId,
202) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
203    let path = current_release_set_manifest_path(root, release_build_id);
204    load_optional(&path, release_build_id)?.ok_or(CurrentReleaseSetManifestError::Missing(path))
205}
206
207fn load_optional(
208    path: &Path,
209    release_build_id: ReleaseBuildId,
210) -> Result<Option<PersistedCurrentReleaseSetManifest>, CurrentReleaseSetManifestError> {
211    let bytes = match read_optional_regular_bytes(path) {
212        Ok(value) => value,
213        Err(RegularFileReadError::NotRegular) => {
214            return Err(CurrentReleaseSetManifestError::Unsafe(path.to_path_buf()));
215        }
216        Err(RegularFileReadError::Io(source)) => {
217            return Err(CurrentReleaseSetManifestError::Io {
218                path: path.to_path_buf(),
219                source,
220            });
221        }
222        #[cfg(not(unix))]
223        Err(RegularFileReadError::UnsupportedPlatform) => {
224            return Err(CurrentReleaseSetManifestError::Io {
225                path: path.to_path_buf(),
226                source: io::Error::new(
227                    io::ErrorKind::Unsupported,
228                    "safe file reads are unavailable",
229                ),
230            });
231        }
232    };
233    let Some(bytes) = bytes else {
234        return Ok(None);
235    };
236    let manifest: CurrentReleaseSetManifest = serde_json::from_slice(&bytes)
237        .map_err(|error| CurrentReleaseSetManifestError::Invalid(error.to_string()))?;
238    manifest.validate(release_build_id)?;
239    if manifest.canonical_bytes()? != bytes {
240        return Err(CurrentReleaseSetManifestError::Invalid(
241            "document bytes are not canonical".to_string(),
242        ));
243    }
244    Ok(Some(PersistedCurrentReleaseSetManifest {
245        digest: Sha256::digest(&bytes).into(),
246        manifest,
247        path: path.to_path_buf(),
248    }))
249}
250
251fn current_release_set_manifest_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
252    root.join(".canic")
253        .join("release-builds")
254        .join(release_build_id.to_string())
255        .join(CURRENT_RELEASE_SET_MANIFEST_FILE)
256}