1#![allow(clippy::result_large_err)]
4
5use std::{
6 collections::HashSet,
7 path::{Path, PathBuf},
8};
9
10use tempfile::{tempdir_in, TempDir};
11
12use crate::{
13 action::Context,
14 cloud_init::{CloudInitError, LocalDataStore, LocalDataStoreBuilder},
15 config::Config,
16 git::{git_head, git_is_clean, is_git, GitError},
17 plan::{construct_all_plans, PlanError, RunnablePlan},
18 project::{Project, ProjectError, Projects, State},
19 qemu::{QemuError, QemuRunner},
20 runlog::{RunLog, RunLogError, RunLogSource},
21 util::{mkdir, mkdir_child, recreate_dir, UtilError},
22 vdrive::{create_tar, create_tar_with_size, VirtualDrive, VirtualDriveError},
23};
24
25#[allow(clippy::too_many_arguments)]
27pub fn cmd_run(
28 config: &Config,
29 runlog: &mut RunLog,
30 projects: &Projects,
31 chosen_projects: Option<&[String]>,
32 dry_run: bool,
33 force: bool,
34 uefi: bool,
35) -> Result<(), RunError> {
36 let executor = config.executor().ok_or(RunError::NoRunCi)?;
37 eprintln!("executor from config: {}", executor.display());
38
39 let abs_executor = find_on_path(executor)?;
40 eprintln!("executor from PATH: {}", abs_executor.display());
41
42 let statedir = config.state();
43 if !statedir.exists() {
44 mkdir(statedir).map_err(|e| RunError::MkdirState(statedir.into(), e))?;
45 }
46
47 for (name, project) in chosen(projects, chosen_projects) {
48 let (do_run, mut state) = should_run(dry_run, force, statedir, name, project)?;
49
50 if do_run {
51 state.remove_run_log()?;
52 let log = state.create_run_log()?;
53 runlog.to_named_file(&log).map_err(RunError::RunLogCreate)?;
54
55 state.remove_raw_log()?;
56 let raw_log = state.create_raw_log()?;
57 let console_log = state.create_console_log()?;
58
59 runlog.run_ci(RunLogSource::Prelude, name);
60 let (pre_plan, mut plan, post_plan) =
61 construct_all_plans(config, name, project, &state)?;
62
63 let mut pre_plan_context = Context::new(runlog);
64 pre_plan.execute(RunLogSource::PrePlan, &mut pre_plan_context)?;
65 plan.carry_over_from_context(&pre_plan_context);
66
67 let tmp = tempdir_in(config.tmpdir()).map_err(RunError::TempDir)?;
68
69 let source_drive = create_source_vdrive(&tmp, project.source())?;
70 let deps_drive = create_dependencies_vdrive(&tmp, &state)?;
71 let executor_drive = create_executor_vdrive(&tmp, &plan, &abs_executor)?;
72
73 let artifactsdir = state.artifactsdir();
74 let artifact_drive = create_artifacts_vdrive(&tmp, config, project, &artifactsdir)?;
75
76 let cachedir = state.cachedir();
77 let cache_drive = create_cache_vdrive(&tmp, config, project, &cachedir)?;
78
79 let ds = create_cloud_init_iso(false)?;
80
81 let exit = QemuRunner::default()
82 .config(config)
83 .base_image(project.image())
84 .executor(&executor_drive)
85 .source(&source_drive)
86 .cache(&cache_drive)
87 .dependencies(&deps_drive)
88 .artifacts(&artifact_drive)
89 .raw_log(&raw_log)
90 .console_log(&console_log)
91 .cloud_init(&ds)
92 .uefi(uefi)
93 .run(RunLogSource::Plan, runlog)?;
94
95 if exit == 0 {
96 artifact_drive.extract_to(&artifactsdir)?;
97
98 if cachedir.exists() {
99 recreate_dir(&cachedir)?;
100 }
101
102 cache_drive
103 .extract_to(&cachedir)
104 .map_err(|e| QemuError::ExtractCache(cachedir.clone(), Box::new(e)))?;
105
106 post_plan.execute(RunLogSource::PostPlan, &mut Context::new(runlog))?;
107 }
108
109 if is_git(project.source()) {
110 let head = git_head(project.source())?;
111 state.set_latest_commot(Some(&head));
112 } else {
113 state.set_latest_commot(None);
114 };
115 state.write_to_file()?;
116
117 if exit != 0 {
118 return Err(RunError::RunFailed);
119 }
120 } else {
121 runlog.skip_ci(RunLogSource::Prelude, name);
122 }
123 }
124
125 Ok(())
126}
127
128fn find_on_path(bin: &Path) -> Result<PathBuf, RunError> {
129 let path = std::env::var_os("PATH").ok_or(RunError::NoPath)?;
130 for dir in std::env::split_paths(&path) {
131 let full = dir.join(bin);
132 if full.exists() {
133 return Ok(full);
134 }
135 }
136 Err(RunError::NotOnPath(bin.to_path_buf()))
137}
138
139pub fn create_cloud_init_iso(network: bool) -> Result<LocalDataStore, RunError> {
141 const BOOTSTRAP: &str = r#"
142(set -xeu
143env
144dir="$(mktemp -d)"
145cd "$dir"
146tar -xvf /dev/vdb
147find -ls || true
148ldd ./run-ci || true
149echo ================================ BEGIN ================================
150export RUST_BACKTRACE=1
151if ./run-ci; then
152 echo "EXIT CODE: 0"
153else
154 echo "EXIT CODE: $?"
155fi) > /dev/ttyS1 2>&1
156"#;
157 LocalDataStoreBuilder::default()
158 .with_hostname("ambient")
159 .with_runcmd("echo xyzzy > /dev/ttyS1")
160 .with_runcmd(BOOTSTRAP)
161 .with_runcmd("poweroff")
162 .with_network(network)
163 .build()
164 .map_err(RunError::CloudInit)
165}
166
167pub fn create_source_vdrive(tmp: &TempDir, source_dir: &Path) -> Result<VirtualDrive, RunError> {
169 Ok(create_tar(tmp.path().join("src.tar"), source_dir)?)
170}
171
172fn create_artifacts_vdrive(
173 tmp: &TempDir,
174 config: &Config,
175 project: &Project,
176 artifactsdir: &Path,
177) -> Result<VirtualDrive, RunError> {
178 recreate_dir(artifactsdir)?;
179 Ok(create_tar_with_size(
180 tmp.path().join("artifacts.tar"),
181 artifactsdir,
182 project
183 .artifact_max_size()
184 .unwrap_or(config.artifacts_max_size()),
185 )?)
186}
187
188fn create_dependencies_vdrive(tmp: &TempDir, state: &State) -> Result<VirtualDrive, RunError> {
189 let dependencies = state.dependenciesdir();
190 if !dependencies.exists() {
191 mkdir(&dependencies)
192 .map_err(|e| RunError::MkdirProjectSubState(dependencies.clone(), e))?;
193 }
194 Ok(create_tar(tmp.path().join("deps.tar"), &dependencies)?)
195}
196
197fn create_cache_vdrive(
198 tmp: &TempDir,
199 config: &Config,
200 project: &Project,
201 cachedir: &Path,
202) -> Result<VirtualDrive, RunError> {
203 if !cachedir.exists() {
204 mkdir(cachedir).map_err(|e| RunError::MkdirProjectSubState(cachedir.into(), e))?;
205 }
206 Ok(create_tar_with_size(
207 tmp.path().join("cache.tar"),
208 cachedir,
209 project.cache_max_size().unwrap_or(config.cache_max_size()),
210 )?)
211}
212
213pub fn create_executor_vdrive(
215 tmp: &TempDir,
216 plan: &RunnablePlan,
217 executor: &Path,
218) -> Result<VirtualDrive, RunError> {
219 assert!(executor.exists());
220
221 let dirname =
222 mkdir_child(tmp.path(), "ambient-execute-plan").map_err(RunError::MkdirProjectRunCi)?;
223 let bin2 = dirname.join("run-ci");
224
225 std::fs::copy(executor, &bin2).map_err(|e| RunError::Copy(executor.into(), bin2.clone(), e))?;
226
227 let plan_filename = dirname.join("plan.yaml");
228 plan.to_file(&plan_filename)?;
229
230 Ok(create_tar(tmp.path().join("executor.tar"), &dirname)?)
231}
232
233fn should_run(
234 dry_run: bool,
235 force: bool,
236 statedir: &Path,
237 name: &str,
238 project: &Project,
239) -> Result<(bool, State), RunError> {
240 let mut decision = Decision::default();
241
242 let state = State::from_file(statedir, name)?;
243 if let Some(latest_commit) = state.latest_commit() {
244 decision.latest_commit(latest_commit);
245 } else {
246 }
248
249 let is_git = is_git(project.source());
250 if is_git {
251 decision.is_git();
252 } else {
253 decision.is_not_git();
254 }
255
256 if git_is_clean(project.source()) {
257 decision.is_clean();
258 } else {
259 decision.is_dirty();
260 }
261
262 if is_git {
263 let head = git_head(project.source())?;
264 decision.current_commit(&head);
265 } else {
266 }
268
269 if dry_run {
270 decision.dry_run();
271 } else {
272 decision.no_dry_run();
273 }
274
275 if force {
276 decision.force();
277 } else {
278 decision.no_force();
279 }
280
281 let do_run = decision.should_run() == ShouldRun::Run;
282
283 Ok((do_run, state))
284}
285
286fn chosen<'a>(projects: &'a Projects, chosen: Option<&[String]>) -> Vec<(&'a str, &'a Project)> {
287 let set: HashSet<&str> = match chosen {
288 Some(v) if !v.is_empty() => v.iter().map(|s| s.as_str()).collect(),
289 _ => projects.iter().map(|(k, _)| k).collect(),
290 };
291 let mut projects: Vec<(&'a str, &'a Project)> =
292 projects.iter().filter(|(k, _)| set.contains(k)).collect();
293 projects.sort_by(|(a_name, _), (b_name, _)| a_name.cmp(b_name));
294 projects
295}
296
297#[derive(Debug, thiserror::Error)]
299pub enum RunError {
300 #[error(transparent)]
302 Project(#[from] ProjectError),
303
304 #[error(transparent)]
306 Util(#[from] UtilError),
307
308 #[error("failed to create a context for executing actions")]
310 Context(#[source] crate::action::ActionError),
311
312 #[error("failed to create temporary directory for running CI build")]
314 TempDir(#[source] std::io::Error),
315
316 #[error("failed to create a general state directory")]
318 MkdirState(PathBuf, #[source] UtilError),
319
320 #[error("failed to create a project state sub-directory")]
322 MkdirProjectSubState(PathBuf, #[source] UtilError),
323
324 #[error("failed to create a temporary directory for action runner")]
326 MkdirProjectRunCi(#[source] UtilError),
327
328 #[error("failed to create a cloud-init ISO file")]
330 CloudInit(#[source] CloudInitError),
331
332 #[error("virtual drive is too big: {0} > {1}")]
334 DriveTooBig(u64, u64),
335
336 #[error(transparent)]
338 Qemu(#[from] QemuError),
339
340 #[error("CI run failed inside QEMU")]
342 RunFailed,
343
344 #[error("failed to copy {0} to {1}")]
346 Copy(PathBuf, PathBuf, #[source] std::io::Error),
347
348 #[error(transparent)]
350 Plan(#[from] PlanError),
351
352 #[error(transparent)]
354 Git(#[from] GitError),
355
356 #[error(transparent)]
358 VDrive(#[from] VirtualDriveError),
359
360 #[error("you must set path to ambient-execute-plan program with option or in configuration")]
362 NoRunCi,
363
364 #[error("failed to open run log")]
366 RunLogCreate(#[source] RunLogError),
367
368 #[error("failed to find PATH in the environment")]
370 NoPath,
371
372 #[error("failed to find {0} on PATH")]
374 NotOnPath(PathBuf),
375}
376
377#[derive(Debug, Default)]
378struct Decision {
379 dry_run: Option<bool>,
380 force_run: Option<bool>,
381 is_git: Option<bool>,
382 latest_commit: Option<String>,
383 current_commit: Option<String>,
384 source_is_dirty: Option<bool>,
385}
386
387impl Decision {
388 fn dry_run(&mut self) {
389 self.dry_run = Some(true);
390 }
391
392 fn no_dry_run(&mut self) {
393 self.dry_run = Some(false);
394 }
395
396 fn force(&mut self) {
397 self.force_run = Some(true);
398 }
399
400 fn no_force(&mut self) {
401 self.force_run = Some(false);
402 }
403
404 fn is_git(&mut self) {
405 self.is_git = Some(true);
406 }
407
408 fn is_not_git(&mut self) {
409 self.is_git = Some(false);
410 }
411
412 fn latest_commit(&mut self, commit: &str) {
413 self.latest_commit = Some(commit.into());
414 }
415
416 fn current_commit(&mut self, commit: &str) {
417 self.current_commit = Some(commit.into());
418 }
419
420 fn is_clean(&mut self) {
421 self.source_is_dirty = Some(false);
422 }
423
424 fn is_dirty(&mut self) {
425 self.source_is_dirty = Some(true);
426 }
427
428 fn should_run(&self) -> ShouldRun {
429 let dry_run = self.dry_run == Some(true);
430 let force = self.force_run == Some(true);
431 let is_git = self.is_git == Some(true);
432 let dirty = self.source_is_dirty == Some(true);
433
434 if dry_run {
435 Self::log("dry run");
436 ShouldRun::DontRun
437 } else if force {
438 Self::log("force");
439 ShouldRun::Run
440 } else if !is_git {
441 Self::log("not git");
442 ShouldRun::Run
443 } else if dirty {
444 Self::log("dirty");
445 ShouldRun::Run
446 } else if self.current_commit == self.latest_commit {
447 Self::log("commits are equal");
448 ShouldRun::DontRun
449 } else {
450 Self::log("nothing prevents run");
451 ShouldRun::Run
452 }
453 }
454
455 #[allow(unused_variables)]
456 fn log(msg: &str) {
457 #[cfg(test)]
458 println!("{msg}");
459 }
460}
461
462#[derive(Debug, Eq, PartialEq, Copy, Clone)]
465enum ShouldRun {
466 Run,
467 DontRun,
468}
469
470#[cfg(test)]
471mod test_run_decision {
472 use super::{Decision, ShouldRun};
473
474 #[test]
475 fn is_not_git() {
476 let mut d = Decision::default();
477 d.no_dry_run();
478 d.no_force();
479 d.is_not_git();
480 d.is_clean();
481 assert_eq!(d.should_run(), ShouldRun::Run);
482 }
483
484 #[test]
485 fn unchanged() {
486 let mut d = Decision::default();
487 d.no_dry_run();
488 d.no_force();
489 d.is_git();
490 d.is_clean();
491 d.latest_commit("abcd");
492 d.current_commit("abcd");
493 assert_eq!(d.should_run(), ShouldRun::DontRun);
494 }
495
496 #[test]
497 fn unchanged_with_force() {
498 let mut d = Decision::default();
499 d.no_dry_run();
500 d.force();
501 d.is_git();
502 d.is_clean();
503 d.latest_commit("abcd");
504 d.current_commit("abcd");
505 assert_eq!(d.should_run(), ShouldRun::Run);
506 }
507
508 #[test]
509 fn unchanged_commit_but_dirty() {
510 let mut d = Decision::default();
511 d.no_dry_run();
512 d.no_force();
513 d.is_git();
514 d.is_dirty();
515 d.latest_commit("abcd");
516 d.current_commit("abcd");
517 assert_eq!(d.should_run(), ShouldRun::Run);
518 }
519
520 #[test]
521 fn commit_changed() {
522 let mut d = Decision::default();
523 d.no_dry_run();
524 d.no_force();
525 d.is_git();
526 d.is_clean();
527 d.latest_commit("abcd");
528 d.current_commit("efgh");
529 assert_eq!(d.should_run(), ShouldRun::Run);
530 }
531
532 #[test]
533 fn dry_run_for_unchanged() {
534 let mut d = Decision::default();
535 d.dry_run();
536 d.no_force();
537 d.is_git();
538 d.is_clean();
539 d.latest_commit("abcd");
540 d.current_commit("abcd");
541 assert_eq!(d.should_run(), ShouldRun::DontRun);
542 }
543
544 #[test]
545 fn dry_run_for_unchanged_but_dirty() {
546 let mut d = Decision::default();
547 d.dry_run();
548 d.no_force();
549 d.is_git();
550 d.is_dirty();
551 d.latest_commit("abcd");
552 d.current_commit("efgh");
553 assert_eq!(d.should_run(), ShouldRun::DontRun);
554 }
555
556 #[test]
557 fn dry_run_for_commit_changed() {
558 let mut d = Decision::default();
559 d.dry_run();
560 d.no_force();
561 d.is_git();
562 d.is_clean();
563 d.latest_commit("abcd");
564 d.current_commit("efgh");
565 assert_eq!(d.should_run(), ShouldRun::DontRun);
566 }
567
568 #[test]
569 fn dry_run_for_unchanged_with_force() {
570 let mut d = Decision::default();
571 d.dry_run();
572 d.no_force();
573 d.is_git();
574 d.is_clean();
575 d.latest_commit("abcd");
576 d.current_commit("abcd");
577 assert_eq!(d.should_run(), ShouldRun::DontRun);
578 }
579
580 #[test]
581 fn dry_run_for_unchanged_but_dirty_with_force() {
582 let mut d = Decision::default();
583 d.dry_run();
584 d.no_force();
585 d.is_git();
586 d.is_dirty();
587 d.latest_commit("abcd");
588 d.current_commit("efgh");
589 assert_eq!(d.should_run(), ShouldRun::DontRun);
590 }
591
592 #[test]
593 fn dry_run_for_commit_changed_with_force() {
594 let mut d = Decision::default();
595 d.dry_run();
596 d.no_force();
597 d.is_git();
598 d.is_clean();
599 d.latest_commit("abcd");
600 d.current_commit("efgh");
601 assert_eq!(d.should_run(), ShouldRun::DontRun);
602 }
603}