Skip to main content

kcode_k1_web_coding/
lib.rs

1use kcode_k1_transaction_id::TxId;
2use kcode_k1_web_cache::{Digest, ResolutionManifest, UserWebCache};
3use kcode_k1_web_check_plan::{CheckRevisions, RuntimeIdentity, check_identity, check_input};
4use kcode_k1_web_checker_protocol::{Outcome, Report};
5use kcode_k1_web_package::{SourcePackage, WebFamily, WebId};
6use kcode_k1_web_podman::{
7    CheckOutput, CommandDiagnostics, WebPodman, WebPodmanConfig, WebPodmanError,
8};
9use kcode_k1_web_projection::{K1WebProjection, PublishError, PublishOutcome};
10use kcode_k1_web_release_gate::{ReleaseGateError, admit_source_preserved_release};
11use kcode_k1_web_selection::{SelectionError, select};
12use std::fmt::{Debug, Display, Formatter};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18type CodingResult<T> = Result<T, WebCodingError>;
19
20pub struct WebCodingRevisions {
21    pub boot: String,
22    pub schema: String,
23    pub route: String,
24    pub harness: String,
25    pub check_policy: String,
26}
27
28pub struct WebCodingConfig {
29    revisions: WebCodingRevisions,
30    projection_root: PathBuf,
31    podman: Option<WebPodmanConfig>,
32}
33
34impl WebCodingConfig {
35    pub fn new(
36        revisions: WebCodingRevisions,
37        projection_root: PathBuf,
38        podman: WebPodmanConfig,
39    ) -> CodingResult<Self> {
40        let values = [
41            &revisions.boot,
42            &revisions.schema,
43            &revisions.route,
44            &revisions.harness,
45            &revisions.check_policy,
46        ];
47        if values.iter().any(|value| value.is_empty()) {
48            return Err(WebCodingError::State(
49                "Web coding revisions must be nonempty".into(),
50            ));
51        }
52        if !exact_directory(&projection_root) {
53            return Err(WebCodingError::State(
54                "projection root must be a canonical ordinary directory".into(),
55            ));
56        }
57        Ok(Self {
58            revisions,
59            projection_root,
60            podman: Some(podman),
61        })
62    }
63}
64
65#[derive(Debug)]
66pub struct CheckExecution {
67    pub diagnostics: CommandDiagnostics,
68    pub report: Report,
69}
70
71#[derive(Debug)]
72pub enum CheckOutcome {
73    Reused,
74    Checked(Box<CheckExecution>),
75}
76
77#[derive(Debug)]
78pub struct PublishResult {
79    pub source: Arc<SourcePackage>,
80    pub check: CheckOutcome,
81    pub outcome: PublishOutcome,
82}
83
84#[derive(Debug)]
85pub enum WebCodingError {
86    State(String),
87    Cache(String),
88    Authorization(String),
89    WorkspaceDenied,
90    CandidateUnavailable,
91    DependencyUnavailable,
92    Podman(Box<WebPodmanError>),
93    CheckFailed(Box<CheckExecution>),
94    SourcePreservation(String),
95    PublicReleaseDenied,
96    Projection(String),
97    ProjectionAfterSubmit { submitted: String, message: String },
98}
99
100impl Display for WebCodingError {
101    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
102        Debug::fmt(self, formatter)
103    }
104}
105
106impl std::error::Error for WebCodingError {}
107
108struct OpenTimer(Instant);
109
110impl Drop for OpenTimer {
111    fn drop(&mut self) {
112        if self.0.elapsed() > Duration::from_millis(100) {
113            eprintln!("{{\"level\":\"warning\",\"event\":\"k1_web_coding_open_slow\"}}");
114        }
115    }
116}
117
118pub struct K1WebCoding {
119    config: WebCodingConfig,
120    projection: Arc<K1WebProjection>,
121    cache: UserWebCache,
122    podman: WebPodman,
123    runtime: RuntimeIdentity,
124}
125
126impl K1WebCoding {
127    pub fn open(
128        cache_root: impl AsRef<Path>,
129        user: TxId,
130        mut config: WebCodingConfig,
131        projection: Arc<K1WebProjection>,
132    ) -> CodingResult<Self> {
133        let _timer = OpenTimer(Instant::now());
134        let podman = WebPodman::new(config.podman.take().expect("unopened configuration"))
135            .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
136        let runtime = RuntimeIdentity::new(
137            podman.checker_digest(),
138            podman.image_digest(),
139            podman.chromium_version(),
140            podman.frozen_command_policy_identity(),
141        )
142        .map_err(|error| WebCodingError::State(error.to_string()))?;
143        let revisions = &config.revisions;
144        let cache = UserWebCache::open(
145            cache_root,
146            user,
147            &revisions.boot,
148            &revisions.schema,
149            &revisions.check_policy,
150        )
151        .map_err(|cause| WebCodingError::Cache(format!("open user cache: {cause}")))?;
152        Ok(Self {
153            config,
154            projection,
155            cache,
156            podman,
157            runtime,
158        })
159    }
160
161    pub fn cache_epoch(&self) -> u64 {
162        self.cache.epoch()
163    }
164
165    pub fn view(
166        &self,
167        id: &WebId,
168        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
169    ) -> CodingResult<Option<Arc<SourcePackage>>> {
170        let Some(candidate) = self.retained_candidate(id)? else {
171            return self.projection.load(id).map_err(|cause| {
172                WebCodingError::State(format!("load public projection: {cause}"))
173            });
174        };
175        match authorize_workspace(id.family()) {
176            Ok(true) => Ok(Some(candidate)),
177            Ok(false) => self
178                .projection
179                .load(id)
180                .map_err(|cause| WebCodingError::State(format!("load public projection: {cause}"))),
181            Err(cause) => Err(WebCodingError::Authorization(cause)),
182        }
183    }
184
185    pub fn write(
186        &mut self,
187        candidate: &SourcePackage,
188        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
189    ) -> CodingResult<Arc<SourcePackage>> {
190        authorize(candidate.id().family(), authorize_workspace)?;
191        cache_result(self.cache.materialize(candidate), "materialize candidate")?;
192        self.retained_candidate(candidate.id())?
193            .ok_or(WebCodingError::CandidateUnavailable)
194    }
195
196    pub fn check(
197        &mut self,
198        id: &WebId,
199        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
200    ) -> CodingResult<CheckOutcome> {
201        authorize(id.family(), authorize_workspace)?;
202        let candidate = self
203            .retained_candidate(id)?
204            .ok_or(WebCodingError::CandidateUnavailable)?;
205        self.check_retained(&candidate, true)
206            .map(|(outcome, _)| outcome)
207    }
208
209    pub fn check_fresh(
210        &mut self,
211        id: &WebId,
212        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
213    ) -> CodingResult<CheckOutcome> {
214        authorize(id.family(), authorize_workspace)?;
215        let candidate = self
216            .retained_candidate(id)?
217            .ok_or(WebCodingError::CandidateUnavailable)?;
218        self.check_retained(&candidate, false)
219            .map(|(outcome, _)| outcome)
220    }
221
222    pub fn publish(
223        &mut self,
224        id: &WebId,
225        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
226        authorize_public_release: &dyn Fn(&SourcePackage, Digest) -> Result<bool, String>,
227    ) -> CodingResult<PublishResult> {
228        self.publish_with_source_preservation(
229            id,
230            authorize_workspace,
231            &|_, _| Ok(()),
232            authorize_public_release,
233        )
234    }
235
236    pub fn publish_with_source_preservation(
237        &mut self,
238        id: &WebId,
239        authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
240        preserve_source: &dyn Fn(&SourcePackage, Digest) -> Result<(), String>,
241        authorize_public_release: &dyn Fn(&SourcePackage, Digest) -> Result<bool, String>,
242    ) -> CodingResult<PublishResult> {
243        authorize(id.family(), authorize_workspace)?;
244        let candidate = self
245            .retained_candidate(id)?
246            .ok_or(WebCodingError::CandidateUnavailable)?;
247        let (check, digest) = self.check_retained(&candidate, true)?;
248        admit_source_preserved_release(
249            &candidate,
250            digest,
251            preserve_source,
252            authorize_public_release,
253        )
254        .map_err(release_gate_error)?;
255        let outcome = self
256            .projection
257            .publish(&candidate)
258            .map_err(publication_error)?;
259        Ok(PublishResult {
260            source: candidate,
261            check,
262            outcome,
263        })
264    }
265
266    pub fn reset(&mut self) -> CodingResult<()> {
267        let revisions = &self.config.revisions;
268        cache_result(
269            self.cache
270                .reset(&revisions.boot, &revisions.schema, &revisions.check_policy),
271            "reset user cache",
272        )
273    }
274
275    fn retained_candidate(&self, id: &WebId) -> CodingResult<Option<Arc<SourcePackage>>> {
276        cache_result(self.cache.candidate(id), "load retained candidate")
277            .map(|candidate| candidate.map(Arc::new))
278    }
279
280    fn check_retained(
281        &mut self,
282        candidate: &SourcePackage,
283        reuse_receipt: bool,
284    ) -> CodingResult<(CheckOutcome, Digest)> {
285        let source = cache_result(
286            self.cache.source_identity(),
287            "read retained source identity",
288        )?
289        .ok_or(WebCodingError::CandidateUnavailable)?;
290        let snapshot = self.projection.snapshot().map_err(|cause| {
291            WebCodingError::State(format!("capture projection snapshot: {cause}"))
292        })?;
293        let selected = select(&snapshot, candidate).map_err(selection_error)?;
294        let manifest = ResolutionManifest::from_snapshot(candidate, source, selected)
295            .map_err(|cause| WebCodingError::State(format!("build frozen resolution: {cause}")))?;
296        let resolution = cache_result(
297            self.cache.record_resolution(&manifest),
298            "record frozen resolution",
299        )?;
300        let revisions = CheckRevisions::new(
301            self.config.revisions.route.as_str(),
302            self.config.revisions.harness.as_str(),
303            self.config.revisions.check_policy.as_str(),
304        );
305        let identity = check_identity(
306            self.cache.epoch(),
307            source,
308            resolution,
309            manifest.view_digest(),
310            &self.runtime,
311            &revisions,
312        );
313        if reuse_receipt && cache_result(self.cache.has_check(&identity), "read check receipt")? {
314            return Ok((CheckOutcome::Reused, source.digest));
315        }
316        let input = check_input(
317            candidate,
318            self.cache.source_root(),
319            self.cache.resolution_root(),
320            manifest.entries(),
321        );
322        let CheckOutput {
323            diagnostics,
324            report,
325        } = self
326            .podman
327            .check_frozen(input, &self.config.projection_root)
328            .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
329        let execution = Box::new(CheckExecution {
330            diagnostics,
331            report,
332        });
333        if !matches!(&execution.report.outcome, Outcome::Success) {
334            return Err(WebCodingError::CheckFailed(execution));
335        }
336        cache_result(self.cache.record_check(&identity), "record check receipt")?;
337        Ok((CheckOutcome::Checked(execution), source.digest))
338    }
339}
340
341fn cache_result<T>(result: Result<T, impl Display>, action: &str) -> CodingResult<T> {
342    result.map_err(|cause| WebCodingError::Cache(format!("{action}: {cause}")))
343}
344
345fn authorize(
346    family: &WebFamily,
347    authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
348) -> CodingResult<()> {
349    match authorize_workspace(family) {
350        Ok(true) => Ok(()),
351        Ok(false) => Err(WebCodingError::WorkspaceDenied),
352        Err(cause) => Err(WebCodingError::Authorization(cause)),
353    }
354}
355
356fn release_gate_error(error: ReleaseGateError) -> WebCodingError {
357    match error {
358        ReleaseGateError::SourcePreservation(message) => {
359            WebCodingError::SourcePreservation(message)
360        }
361        ReleaseGateError::Authorization(message) => WebCodingError::Authorization(message),
362        ReleaseGateError::PublicReleaseDenied => WebCodingError::PublicReleaseDenied,
363    }
364}
365
366fn selection_error(error: SelectionError) -> WebCodingError {
367    match error {
368        SelectionError::DependencyUnavailable => WebCodingError::DependencyUnavailable,
369        SelectionError::InvalidDependency(message) => {
370            WebCodingError::State(format!("select projection dependency: {message}"))
371        }
372    }
373}
374
375fn publication_error(error: PublishError) -> WebCodingError {
376    match error.submitted {
377        Some(submitted) => WebCodingError::ProjectionAfterSubmit {
378            submitted: submitted.to_string(),
379            message: format!("publish projection: {}", error.message),
380        },
381        None => WebCodingError::Projection(format!("publish projection: {}", error.message)),
382    }
383}
384
385fn exact_directory(path: &Path) -> bool {
386    fs::symlink_metadata(path).is_ok_and(|value| value.is_dir() && !value.file_type().is_symlink())
387        && fs::canonicalize(path).is_ok_and(|value| value == path)
388}
389
390#[cfg(test)]
391mod tests;