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    },
18};
19use canic_core::ids::ReleaseBuildId;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use std::{
23    io,
24    path::{Path, PathBuf},
25};
26use thiserror::Error as ThisError;
27
28pub const CURRENT_RELEASE_SET_MANIFEST_FILE: &str = "current-release-set-manifest.json";
29
30/// Canonical complete current release authority consumed by Fleet generation.
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct CurrentReleaseSetManifest {
34    pub application_artifact_union_sha256: [u8; 32],
35    pub infrastructure_artifact_manifest_sha256: [u8; 32],
36    pub release_build_id: ReleaseBuildId,
37    pub schema_version: u16,
38}
39
40impl CurrentReleaseSetManifest {
41    pub const SCHEMA_VERSION: u16 = 1;
42
43    pub fn validate(
44        &self,
45        release_build_id: ReleaseBuildId,
46    ) -> Result<(), CurrentReleaseSetManifestError> {
47        if self.schema_version != Self::SCHEMA_VERSION {
48            return Err(CurrentReleaseSetManifestError::Invalid(format!(
49                "unsupported schema version {}",
50                self.schema_version
51            )));
52        }
53        if self.release_build_id != release_build_id {
54            return Err(CurrentReleaseSetManifestError::Invalid(
55                "path and document release-build identities differ".to_string(),
56            ));
57        }
58        Ok(())
59    }
60
61    pub fn canonical_bytes(&self) -> Result<Vec<u8>, CurrentReleaseSetManifestError> {
62        self.validate(self.release_build_id)?;
63        serde_json::to_vec(self).map_err(CurrentReleaseSetManifestError::Serialize)
64    }
65}
66
67/// One persisted complete current release authority.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct PersistedCurrentReleaseSetManifest {
70    pub digest: [u8; 32],
71    pub manifest: CurrentReleaseSetManifest,
72    pub path: PathBuf,
73}
74
75/// Typed complete release-set persistence failure.
76#[derive(Debug, ThisError)]
77pub enum CurrentReleaseSetManifestError {
78    #[error("current release-set manifest already exists with different contents: {0}")]
79    Conflict(PathBuf),
80
81    #[error("invalid current release-set manifest: {0}")]
82    Invalid(String),
83
84    #[error("current release-set manifest is missing: {0}")]
85    Missing(PathBuf),
86
87    #[error("current release-set manifest is not a regular no-follow file: {0}")]
88    Unsafe(PathBuf),
89
90    #[error("failed to access current release-set manifest {path}: {source}")]
91    Io {
92        path: PathBuf,
93        #[source]
94        source: io::Error,
95    },
96
97    #[error("failed to encode current release-set manifest: {0}")]
98    Serialize(serde_json::Error),
99
100    #[error(transparent)]
101    ReleaseBuild(#[from] ReleaseBuildPlanError),
102}
103
104/// Bind two exact child manifests before the release build becomes immutable.
105pub fn compile_and_persist_current_release_set_manifest(
106    root: &Path,
107    release_build_id: ReleaseBuildId,
108    application: &PersistedApplicationArtifactUnion,
109    infrastructure: &PersistedCanicInfrastructureArtifactManifest,
110) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
111    let release_build = load_release_build_plan(root, release_build_id)?;
112    if application.union.release_build_id != release_build_id
113        || infrastructure.manifest.release_build_id != release_build_id
114    {
115        return Err(CurrentReleaseSetManifestError::Invalid(
116            "child manifest release-build identities differ".to_string(),
117        ));
118    }
119    let expected = CurrentReleaseSetManifest {
120        application_artifact_union_sha256: application.digest,
121        infrastructure_artifact_manifest_sha256: infrastructure.digest,
122        release_build_id,
123        schema_version: CurrentReleaseSetManifest::SCHEMA_VERSION,
124    };
125    let path = current_release_set_manifest_path(root, release_build_id);
126    let existing = load_optional(&path, release_build_id)?;
127    if matches!(release_build.state, ReleaseBuildPlanState::Finalized { .. }) {
128        return match existing {
129            Some(existing) if existing.manifest == expected => Ok(existing),
130            _ => Err(CurrentReleaseSetManifestError::Conflict(path)),
131        };
132    }
133    if let Some(existing) = existing {
134        return if existing.manifest == expected {
135            Ok(existing)
136        } else {
137            Err(CurrentReleaseSetManifestError::Conflict(path))
138        };
139    }
140    let bytes = expected.canonical_bytes()?;
141    if let Err(source) = create_new_bytes_with_parents(&path, &bytes) {
142        if let Ok(Some(existing)) = load_optional(&path, release_build_id)
143            && existing.manifest == expected
144        {
145            return Ok(existing);
146        }
147        return Err(CurrentReleaseSetManifestError::Io { path, source });
148    }
149    load_persisted_current_release_set_manifest(root, release_build_id)
150}
151
152/// Load one exact complete current release authority.
153pub fn load_persisted_current_release_set_manifest(
154    root: &Path,
155    release_build_id: ReleaseBuildId,
156) -> Result<PersistedCurrentReleaseSetManifest, CurrentReleaseSetManifestError> {
157    let path = current_release_set_manifest_path(root, release_build_id);
158    load_optional(&path, release_build_id)?.ok_or(CurrentReleaseSetManifestError::Missing(path))
159}
160
161fn load_optional(
162    path: &Path,
163    release_build_id: ReleaseBuildId,
164) -> Result<Option<PersistedCurrentReleaseSetManifest>, CurrentReleaseSetManifestError> {
165    let bytes = match read_optional_regular_bytes(path) {
166        Ok(value) => value,
167        Err(RegularFileReadError::NotRegular) => {
168            return Err(CurrentReleaseSetManifestError::Unsafe(path.to_path_buf()));
169        }
170        Err(RegularFileReadError::Io(source)) => {
171            return Err(CurrentReleaseSetManifestError::Io {
172                path: path.to_path_buf(),
173                source,
174            });
175        }
176        #[cfg(not(unix))]
177        Err(RegularFileReadError::UnsupportedPlatform) => {
178            return Err(CurrentReleaseSetManifestError::Io {
179                path: path.to_path_buf(),
180                source: io::Error::new(
181                    io::ErrorKind::Unsupported,
182                    "safe file reads are unavailable",
183                ),
184            });
185        }
186    };
187    let Some(bytes) = bytes else {
188        return Ok(None);
189    };
190    let manifest: CurrentReleaseSetManifest = serde_json::from_slice(&bytes)
191        .map_err(|error| CurrentReleaseSetManifestError::Invalid(error.to_string()))?;
192    manifest.validate(release_build_id)?;
193    if manifest.canonical_bytes()? != bytes {
194        return Err(CurrentReleaseSetManifestError::Invalid(
195            "document bytes are not canonical".to_string(),
196        ));
197    }
198    Ok(Some(PersistedCurrentReleaseSetManifest {
199        digest: Sha256::digest(&bytes).into(),
200        manifest,
201        path: path.to_path_buf(),
202    }))
203}
204
205fn current_release_set_manifest_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
206    root.join(".canic")
207        .join("release-builds")
208        .join(release_build_id.to_string())
209        .join(CURRENT_RELEASE_SET_MANIFEST_FILE)
210}