⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/devtools/ci/
runner.rs

1//! CI Job Runner
2//!
3//! Executes CI jobs defined in TOML configuration files. Each job runs
4//! in a namespace-isolated environment with artifact collection.
5
6use alloc::{
7    collections::BTreeMap,
8    string::{String, ToString},
9    vec::Vec,
10};
11
12/// Job status
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub(crate) enum JobStatus {
15    Pending,
16    Running,
17    Passed,
18    Failed,
19    Skipped,
20}
21
22/// CI job step
23#[derive(Debug, Clone)]
24pub(crate) struct JobStep {
25    pub(crate) name: String,
26    pub(crate) command: String,
27    pub(crate) status: JobStatus,
28    pub(crate) output: String,
29    pub(crate) exit_code: Option<i32>,
30}
31
32impl JobStep {
33    pub(crate) fn new(name: &str, command: &str) -> Self {
34        Self {
35            name: name.to_string(),
36            command: command.to_string(),
37            status: JobStatus::Pending,
38            output: String::new(),
39            exit_code: None,
40        }
41    }
42}
43
44/// CI job definition
45#[derive(Debug, Clone)]
46pub(crate) struct Job {
47    pub(crate) name: String,
48    pub(crate) steps: Vec<JobStep>,
49    pub(crate) status: JobStatus,
50    pub(crate) environment: BTreeMap<String, String>,
51    pub(crate) artifacts: Vec<String>,
52    pub(crate) dependencies: Vec<String>,
53    pub(crate) allow_failure: bool,
54}
55
56impl Job {
57    pub(crate) fn new(name: &str) -> Self {
58        Self {
59            name: name.to_string(),
60            steps: Vec::new(),
61            status: JobStatus::Pending,
62            environment: BTreeMap::new(),
63            artifacts: Vec::new(),
64            dependencies: Vec::new(),
65            allow_failure: false,
66        }
67    }
68
69    pub(crate) fn add_step(&mut self, name: &str, command: &str) {
70        self.steps.push(JobStep::new(name, command));
71    }
72
73    /// Execute all steps (simulated)
74    pub(crate) fn execute(&mut self) -> bool {
75        self.status = JobStatus::Running;
76
77        for step in &mut self.steps {
78            step.status = JobStatus::Running;
79            // In real implementation, would fork+exec the command
80            step.status = JobStatus::Passed;
81            step.exit_code = Some(0);
82            step.output = alloc::format!("$ {}\n[ok]", step.command);
83        }
84
85        let all_passed = self.steps.iter().all(|s| s.status == JobStatus::Passed);
86        self.status = if all_passed || self.allow_failure {
87            JobStatus::Passed
88        } else {
89            JobStatus::Failed
90        };
91
92        all_passed
93    }
94
95    pub(crate) fn step_count(&self) -> usize {
96        self.steps.len()
97    }
98}
99
100/// CI pipeline (collection of jobs)
101pub(crate) struct Pipeline {
102    pub(crate) name: String,
103    pub(crate) jobs: Vec<Job>,
104    pub(crate) trigger: PipelineTrigger,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub(crate) enum PipelineTrigger {
109    Push,
110    PullRequest,
111    Manual,
112    Schedule(String),
113}
114
115impl Pipeline {
116    pub(crate) fn new(name: &str) -> Self {
117        Self {
118            name: name.to_string(),
119            jobs: Vec::new(),
120            trigger: PipelineTrigger::Manual,
121        }
122    }
123
124    pub(crate) fn add_job(&mut self, job: Job) {
125        self.jobs.push(job);
126    }
127
128    /// Execute all jobs in order
129    pub(crate) fn execute(&mut self) -> bool {
130        let mut all_passed = true;
131        let mut completed: Vec<String> = Vec::new();
132
133        for job in &mut self.jobs {
134            // Check dependencies
135            let deps_met = job.dependencies.iter().all(|d| completed.contains(d));
136            if !deps_met {
137                job.status = JobStatus::Skipped;
138                continue;
139            }
140
141            if !job.execute() && !job.allow_failure {
142                all_passed = false;
143            }
144            completed.push(job.name.clone());
145        }
146
147        all_passed
148    }
149
150    pub(crate) fn job_count(&self) -> usize {
151        self.jobs.len()
152    }
153
154    pub(crate) fn passed_count(&self) -> usize {
155        self.jobs
156            .iter()
157            .filter(|j| j.status == JobStatus::Passed)
158            .count()
159    }
160
161    pub(crate) fn failed_count(&self) -> usize {
162        self.jobs
163            .iter()
164            .filter(|j| j.status == JobStatus::Failed)
165            .count()
166    }
167}
168
169/// Build artifact stored after job completion.
170#[derive(Debug, Clone)]
171pub(crate) struct Artifact {
172    pub(crate) name: String,
173    pub(crate) size: u64,
174    pub(crate) job_id: String,
175    pub(crate) pipeline_id: String,
176    pub(crate) created_tick: u64,
177    pub(crate) data_hash: [u8; 32],
178}
179
180impl Artifact {
181    pub(crate) fn new(name: &str, job_id: &str, pipeline_id: &str) -> Self {
182        Self {
183            name: name.to_string(),
184            size: 0,
185            job_id: job_id.to_string(),
186            pipeline_id: pipeline_id.to_string(),
187            created_tick: 0,
188            data_hash: [0u8; 32],
189        }
190    }
191}
192
193/// Artifact storage with retention management.
194pub(crate) struct ArtifactStore {
195    artifacts: BTreeMap<String, Artifact>,
196}
197
198impl Default for ArtifactStore {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl ArtifactStore {
205    pub(crate) fn new() -> Self {
206        Self {
207            artifacts: BTreeMap::new(),
208        }
209    }
210
211    /// Store a build artifact with metadata.
212    pub(crate) fn store_artifact(
213        &mut self,
214        name: &str,
215        data_hash: [u8; 32],
216        size: u64,
217        job_id: &str,
218        pipeline_id: &str,
219        tick: u64,
220    ) {
221        let mut artifact = Artifact::new(name, job_id, pipeline_id);
222        artifact.data_hash = data_hash;
223        artifact.size = size;
224        artifact.created_tick = tick;
225        self.artifacts.insert(name.to_string(), artifact);
226    }
227
228    /// Retrieve an artifact by name.
229    pub(crate) fn get_artifact(&self, name: &str) -> Option<&Artifact> {
230        self.artifacts.get(name)
231    }
232
233    /// List all stored artifacts.
234    pub(crate) fn list_artifacts(&self) -> Vec<&Artifact> {
235        self.artifacts.values().collect()
236    }
237
238    /// Remove artifacts older than the retention tick threshold.
239    pub(crate) fn cleanup_old(&mut self, current_tick: u64, retention_ticks: u64) -> usize {
240        let cutoff = current_tick.saturating_sub(retention_ticks);
241        let before = self.artifacts.len();
242        self.artifacts.retain(|_, a| a.created_tick >= cutoff);
243        before - self.artifacts.len()
244    }
245
246    pub(crate) fn count(&self) -> usize {
247        self.artifacts.len()
248    }
249}
250
251/// Git repository poller for triggering CI pipelines on new commits.
252#[derive(Debug, Clone)]
253pub(crate) struct GitPoller {
254    pub(crate) repo_url: String,
255    pub(crate) branch: String,
256    pub(crate) last_commit_hash: String,
257    pub(crate) poll_interval_ticks: u64,
258    last_poll_tick: u64,
259}
260
261impl GitPoller {
262    pub(crate) fn new(repo_url: &str, branch: &str, poll_interval: u64) -> Self {
263        Self {
264            repo_url: repo_url.to_string(),
265            branch: branch.to_string(),
266            last_commit_hash: String::new(),
267            poll_interval_ticks: poll_interval,
268            last_poll_tick: 0,
269        }
270    }
271
272    /// Check whether enough ticks have elapsed for a new poll.
273    pub(crate) fn should_poll(&self, current_tick: u64) -> bool {
274        current_tick.saturating_sub(self.last_poll_tick) >= self.poll_interval_ticks
275    }
276
277    /// Check for updates by comparing current HEAD with last known.
278    ///
279    /// Returns `true` if the commit hash changed (simulated: any non-empty
280    /// `current_head` that differs from last known triggers an update).
281    pub(crate) fn check_for_updates(&mut self, current_head: &str, current_tick: u64) -> bool {
282        self.last_poll_tick = current_tick;
283        if !current_head.is_empty() && current_head != self.last_commit_hash {
284            self.last_commit_hash = current_head.to_string();
285            true
286        } else {
287            false
288        }
289    }
290
291    /// Trigger a pipeline on the new commit (creates a Pipeline with Push
292    /// trigger).
293    pub(crate) fn trigger_pipeline(&self, name: &str) -> Pipeline {
294        let mut pipeline = Pipeline::new(name);
295        pipeline.trigger = PipelineTrigger::Push;
296        pipeline
297    }
298}
299
300/// Namespace isolation for CI job sandboxing.
301pub(crate) struct NamespaceIsolation {
302    pub(crate) sandbox_id: u64,
303    pub(crate) active: bool,
304}
305
306impl Default for NamespaceIsolation {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312impl NamespaceIsolation {
313    pub(crate) fn new() -> Self {
314        Self {
315            sandbox_id: 0,
316            active: false,
317        }
318    }
319
320    /// Create a conceptual sandbox for job isolation.
321    pub(crate) fn create_sandbox(&mut self, job_name: &str) -> u64 {
322        // In real implementation, this would create PID/mount namespaces
323        let mut hash: u64 = 5381;
324        for byte in job_name.bytes() {
325            hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
326        }
327        self.sandbox_id = hash;
328        self.active = true;
329        self.sandbox_id
330    }
331
332    /// Tear down the sandbox.
333    pub(crate) fn cleanup_sandbox(&mut self) {
334        self.active = false;
335        self.sandbox_id = 0;
336    }
337
338    pub(crate) fn is_active(&self) -> bool {
339        self.active
340    }
341}
342
343/// Pipeline execution report.
344#[derive(Debug, Clone)]
345pub(crate) struct PipelineReport {
346    pub(crate) pipeline_name: String,
347    pub(crate) total_jobs: usize,
348    pub(crate) passed: usize,
349    pub(crate) failed: usize,
350    pub(crate) skipped: usize,
351    pub(crate) artifacts_collected: usize,
352}
353
354impl Pipeline {
355    /// Execute the pipeline with git polling support.
356    ///
357    /// Checks the poller, runs if there are new commits, and collects
358    /// artifacts into the store.
359    pub(crate) fn run_with_polling(
360        &mut self,
361        poller: &mut GitPoller,
362        current_head: &str,
363        current_tick: u64,
364        store: &mut ArtifactStore,
365    ) -> Option<PipelineReport> {
366        if !poller.should_poll(current_tick) {
367            return None;
368        }
369        if !poller.check_for_updates(current_head, current_tick) {
370            return None;
371        }
372
373        self.execute();
374        let artifacts = self.collect_artifacts(store, current_tick);
375        Some(self.generate_report(artifacts))
376    }
377
378    /// Collect declared artifacts from completed jobs into the artifact store.
379    pub(crate) fn collect_artifacts(&self, store: &mut ArtifactStore, tick: u64) -> usize {
380        let mut count = 0;
381        for job in &self.jobs {
382            if job.status != JobStatus::Passed {
383                continue;
384            }
385            for artifact_name in &job.artifacts {
386                store.store_artifact(
387                    artifact_name,
388                    [0u8; 32], // Placeholder hash
389                    0,
390                    &job.name,
391                    &self.name,
392                    tick,
393                );
394                count += 1;
395            }
396        }
397        count
398    }
399
400    /// Generate an execution summary report.
401    pub(crate) fn generate_report(&self, artifacts_collected: usize) -> PipelineReport {
402        PipelineReport {
403            pipeline_name: self.name.clone(),
404            total_jobs: self.jobs.len(),
405            passed: self.passed_count(),
406            failed: self.failed_count(),
407            skipped: self
408                .jobs
409                .iter()
410                .filter(|j| j.status == JobStatus::Skipped)
411                .count(),
412            artifacts_collected,
413        }
414    }
415}
416
417/// Parse a minimal CI config (key=value style)
418pub(crate) fn parse_ci_config(config: &str) -> Vec<Job> {
419    let mut jobs = Vec::new();
420    let mut current_job: Option<Job> = None;
421
422    for line in config.lines() {
423        let line = line.trim();
424        if line.is_empty() || line.starts_with('#') {
425            continue;
426        }
427
428        if let Some(name) = line.strip_prefix("[job.") {
429            let name = name.trim_end_matches(']');
430            if let Some(job) = current_job.take() {
431                jobs.push(job);
432            }
433            current_job = Some(Job::new(name));
434        } else if let Some((key, value)) = line.split_once('=') {
435            let key = key.trim();
436            let value = value.trim().trim_matches('"');
437            if let Some(ref mut job) = current_job {
438                match key {
439                    "step" => {
440                        let parts: Vec<&str> = value.splitn(2, ':').collect();
441                        if parts.len() == 2 {
442                            job.add_step(parts[0].trim(), parts[1].trim());
443                        } else {
444                            job.add_step(value, value);
445                        }
446                    }
447                    "artifact" => job.artifacts.push(value.to_string()),
448                    "depends" => job.dependencies.push(value.to_string()),
449                    "allow_failure" => job.allow_failure = value == "true",
450                    _ => {}
451                }
452            }
453        }
454    }
455
456    if let Some(job) = current_job {
457        jobs.push(job);
458    }
459
460    jobs
461}
462
463// ---------------------------------------------------------------------------
464// Tests
465// ---------------------------------------------------------------------------
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_job_step_new() {
473        let step = JobStep::new("build", "cargo build");
474        assert_eq!(step.name, "build");
475        assert_eq!(step.status, JobStatus::Pending);
476    }
477
478    #[test]
479    fn test_job_execute() {
480        let mut job = Job::new("test");
481        job.add_step("build", "cargo build");
482        job.add_step("test", "cargo test");
483        assert!(job.execute());
484        assert_eq!(job.status, JobStatus::Passed);
485    }
486
487    #[test]
488    fn test_pipeline_execute() {
489        let mut pipeline = Pipeline::new("ci");
490        let mut build = Job::new("build");
491        build.add_step("compile", "cargo build");
492        pipeline.add_job(build);
493
494        let mut test = Job::new("test");
495        test.add_step("run", "cargo test");
496        test.dependencies.push("build".to_string());
497        pipeline.add_job(test);
498
499        assert!(pipeline.execute());
500        assert_eq!(pipeline.passed_count(), 2);
501    }
502
503    #[test]
504    fn test_pipeline_skipped_deps() {
505        let mut pipeline = Pipeline::new("ci");
506        let mut job = Job::new("deploy");
507        job.dependencies.push("missing".to_string());
508        pipeline.add_job(job);
509
510        pipeline.execute();
511        assert_eq!(pipeline.jobs[0].status, JobStatus::Skipped);
512    }
513
514    #[test]
515    fn test_parse_ci_config() {
516        let config = r#"
517[job.build]
518step = "compile: cargo build --release"
519step = "test: cargo test"
520artifact = "target/release/app"
521
522[job.deploy]
523depends = "build"
524step = "deploy: ./deploy.sh"
525"#;
526
527        let jobs = parse_ci_config(config);
528        assert_eq!(jobs.len(), 2);
529        assert_eq!(jobs[0].name, "build");
530        assert_eq!(jobs[0].step_count(), 2);
531        assert_eq!(jobs[0].artifacts.len(), 1);
532        assert_eq!(jobs[1].dependencies.len(), 1);
533    }
534
535    #[test]
536    fn test_job_status_eq() {
537        assert_eq!(JobStatus::Pending, JobStatus::Pending);
538        assert_ne!(JobStatus::Passed, JobStatus::Failed);
539    }
540
541    #[test]
542    fn test_pipeline_trigger() {
543        assert_eq!(PipelineTrigger::Push, PipelineTrigger::Push);
544        assert_ne!(PipelineTrigger::Push, PipelineTrigger::Manual);
545    }
546
547    #[test]
548    fn test_job_allow_failure() {
549        let mut job = Job::new("flaky");
550        job.allow_failure = true;
551        job.add_step("test", "flaky-test");
552        job.execute();
553        assert_eq!(job.status, JobStatus::Passed);
554    }
555
556    #[test]
557    fn test_empty_pipeline() {
558        let mut pipeline = Pipeline::new("empty");
559        assert!(pipeline.execute());
560        assert_eq!(pipeline.job_count(), 0);
561    }
562
563    #[test]
564    fn test_parse_empty_config() {
565        let jobs = parse_ci_config("");
566        assert!(jobs.is_empty());
567    }
568
569    #[test]
570    fn test_parse_config_comments() {
571        let config = "# comment\n[job.test]\nstep = \"run: echo ok\"\n";
572        let jobs = parse_ci_config(config);
573        assert_eq!(jobs.len(), 1);
574    }
575
576    #[test]
577    fn test_pipeline_counts() {
578        let mut pipeline = Pipeline::new("test");
579        let mut j1 = Job::new("a");
580        j1.add_step("s", "cmd");
581        let mut j2 = Job::new("b");
582        j2.add_step("s", "cmd");
583        pipeline.add_job(j1);
584        pipeline.add_job(j2);
585        pipeline.execute();
586        assert_eq!(pipeline.passed_count(), 2);
587        assert_eq!(pipeline.failed_count(), 0);
588    }
589
590    #[test]
591    fn test_artifact_store_basic() {
592        let mut store = ArtifactStore::new();
593        store.store_artifact("app.bin", [0u8; 32], 1024, "build", "ci-1", 100);
594        assert_eq!(store.count(), 1);
595        let art = store.get_artifact("app.bin").unwrap();
596        assert_eq!(art.size, 1024);
597        assert_eq!(art.job_id, "build");
598    }
599
600    #[test]
601    fn test_artifact_store_list() {
602        let mut store = ArtifactStore::new();
603        store.store_artifact("a.bin", [0u8; 32], 10, "j1", "p1", 1);
604        store.store_artifact("b.bin", [0u8; 32], 20, "j2", "p1", 2);
605        assert_eq!(store.list_artifacts().len(), 2);
606    }
607
608    #[test]
609    fn test_artifact_cleanup() {
610        let mut store = ArtifactStore::new();
611        store.store_artifact("old", [0u8; 32], 10, "j1", "p1", 10);
612        store.store_artifact("new", [0u8; 32], 20, "j2", "p1", 100);
613        let removed = store.cleanup_old(110, 50);
614        assert_eq!(removed, 1);
615        assert_eq!(store.count(), 1);
616        assert!(store.get_artifact("new").is_some());
617    }
618
619    #[test]
620    fn test_git_poller_check_updates() {
621        let mut poller = GitPoller::new("https://example.com/repo.git", "main", 100);
622        assert!(poller.check_for_updates("abc123", 100));
623        assert!(!poller.check_for_updates("abc123", 200)); // same hash
624        assert!(poller.check_for_updates("def456", 300)); // different hash
625    }
626
627    #[test]
628    fn test_git_poller_should_poll() {
629        let poller = GitPoller::new("https://example.com/repo.git", "main", 100);
630        assert!(poller.should_poll(100));
631        assert!(!poller.should_poll(50));
632    }
633
634    #[test]
635    fn test_git_poller_trigger_pipeline() {
636        let poller = GitPoller::new("https://example.com/repo.git", "main", 100);
637        let pipeline = poller.trigger_pipeline("auto-ci");
638        assert_eq!(pipeline.name, "auto-ci");
639        assert_eq!(pipeline.trigger, PipelineTrigger::Push);
640    }
641
642    #[test]
643    fn test_namespace_isolation() {
644        let mut ns = NamespaceIsolation::new();
645        assert!(!ns.is_active());
646        let id = ns.create_sandbox("build-job");
647        assert!(id > 0);
648        assert!(ns.is_active());
649        ns.cleanup_sandbox();
650        assert!(!ns.is_active());
651    }
652
653    #[test]
654    fn test_pipeline_collect_artifacts() {
655        let mut pipeline = Pipeline::new("ci");
656        let mut job = Job::new("build");
657        job.add_step("compile", "cargo build");
658        job.artifacts.push("target/app".to_string());
659        pipeline.add_job(job);
660        pipeline.execute();
661
662        let mut store = ArtifactStore::new();
663        let count = pipeline.collect_artifacts(&mut store, 500);
664        assert_eq!(count, 1);
665        assert!(store.get_artifact("target/app").is_some());
666    }
667
668    #[test]
669    fn test_pipeline_generate_report() {
670        let mut pipeline = Pipeline::new("ci");
671        let mut job = Job::new("build");
672        job.add_step("compile", "cargo build");
673        pipeline.add_job(job);
674        pipeline.execute();
675        let report = pipeline.generate_report(1);
676        assert_eq!(report.pipeline_name, "ci");
677        assert_eq!(report.total_jobs, 1);
678        assert_eq!(report.passed, 1);
679        assert_eq!(report.failed, 0);
680        assert_eq!(report.artifacts_collected, 1);
681    }
682
683    #[test]
684    fn test_run_with_polling_no_update() {
685        let mut pipeline = Pipeline::new("ci");
686        let mut job = Job::new("build");
687        job.add_step("compile", "cargo build");
688        pipeline.add_job(job);
689
690        let mut poller = GitPoller::new("repo", "main", 100);
691        let mut store = ArtifactStore::new();
692
693        // Too early to poll
694        let result = pipeline.run_with_polling(&mut poller, "abc", 50, &mut store);
695        assert!(result.is_none());
696
697        // Now poll with a commit
698        let result = pipeline.run_with_polling(&mut poller, "abc", 100, &mut store);
699        assert!(result.is_some());
700        assert_eq!(result.unwrap().passed, 1);
701    }
702}