Skip to content

Commit a264a94

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/dynamic-workflow-support
# Conflicts: # crates/hm-exec/src/local/scheduler.rs
2 parents 622fa9c + 1b08a61 commit a264a94

9 files changed

Lines changed: 355 additions & 53 deletions

File tree

crates/hm-exec/src/cloud/watch.rs

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use harmont_cloud::{
1717
HarmontClient, HarmontError,
1818
logs::{LogEvent, StreamKind},
1919
models::{build_is_terminal, job_is_terminal},
20+
types::JobState,
2021
};
2122
use hm_plugin_protocol::events::{BuildEvent, PlanSummary, StdStream};
2223
use uuid::Uuid;
@@ -56,6 +57,27 @@ fn duration_ms(start: Option<DateTime<Utc>>, end: Option<DateTime<Utc>>) -> u64
5657
}
5758
}
5859

60+
/// Whether a job has reached a state where its logs exist (running or already
61+
/// terminal), and so a log stream should be started for it.
62+
///
63+
/// Matching the typed [`JobState`] enum (rather than `to_string()`/`as_str()`
64+
/// against string literals) makes the set of states exhaustive: when the cloud
65+
/// adds a new `JobState` variant the compiler forces this decision to be
66+
/// revisited, and a misspelled state can no longer silently drop a job's logs.
67+
const fn job_logs_available(state: JobState) -> bool {
68+
match state {
69+
JobState::Running
70+
| JobState::Passed
71+
| JobState::Failed
72+
| JobState::TimedOut
73+
| JobState::Canceling
74+
| JobState::Canceled
75+
| JobState::TimingOut => true,
76+
// No logs yet (not started) or never produced (skipped).
77+
JobState::Pending | JobState::Scheduled | JobState::Assigned | JobState::Skipped => false,
78+
}
79+
}
80+
5981
/// Map a terminal build state to the process exit code the renderer and the
6082
/// `hm run` driver use. `passed` → 0, `canceled` → 130 (SIGINT-cancel, mirrors
6183
/// [`crate::BuildStatus::Canceled`]), everything else (`failed`, and any
@@ -131,18 +153,7 @@ pub async fn watch_build(
131153
// state where logs exist (running or already terminal).
132154
let jobs = client.list_jobs(org, pipeline, number).await?;
133155
for job in &jobs {
134-
let state = job.state.to_string();
135-
let logs_available = matches!(
136-
state.as_str(),
137-
"running"
138-
| "passed"
139-
| "failed"
140-
| "timed_out"
141-
| "canceling"
142-
| "canceled"
143-
| "timing_out"
144-
);
145-
if logs_available && streaming.insert(job.id) {
156+
if job_logs_available(job.state) && streaming.insert(job.id) {
146157
let name = job.name.clone().unwrap_or_else(|| "job".to_string());
147158
let idx = *chain_idx.entry(job.id).or_insert_with(|| {
148159
let i = next_idx;
@@ -400,7 +411,34 @@ async fn emit(
400411

401412
#[cfg(test)]
402413
mod tests {
403-
use super::exit_code_for_state;
414+
use super::{JobState, exit_code_for_state, job_logs_available};
415+
416+
#[test]
417+
fn logs_available_for_running_and_terminal_states() {
418+
for state in [
419+
JobState::Running,
420+
JobState::Passed,
421+
JobState::Failed,
422+
JobState::TimedOut,
423+
JobState::Canceling,
424+
JobState::Canceled,
425+
JobState::TimingOut,
426+
] {
427+
assert!(job_logs_available(state), "expected logs for {state}");
428+
}
429+
}
430+
431+
#[test]
432+
fn no_logs_before_start_or_when_skipped() {
433+
for state in [
434+
JobState::Pending,
435+
JobState::Scheduled,
436+
JobState::Assigned,
437+
JobState::Skipped,
438+
] {
439+
assert!(!job_logs_available(state), "expected no logs for {state}");
440+
}
441+
}
404442

405443
#[test]
406444
fn passed_is_zero_canceled_is_130_else_is_one() {

crates/hm-exec/src/local/backend.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! drives the [`hm_vm`] subsystem. The VM backend (Docker, etc.) is injected;
55
//! snapshot caching is owned by `hm-vm`'s [`hm_vm::ImageRegistry`].
66
7+
use std::num::NonZeroU64;
78
use std::num::NonZeroUsize;
89
use std::sync::Arc;
910

@@ -17,7 +18,7 @@ use crate::{BackendError, BackendHandle, Capabilities, ExecutionBackend, Result,
1718

1819
/// Number of cached snapshots the image registry retains before evicting
1920
/// least-recently-used entries.
20-
const REGISTRY_CAPACITY: u64 = 64;
21+
const REGISTRY_CAPACITY: NonZeroU64 = NonZeroU64::new(64).expect("64 is non-zero");
2122

2223
/// Runs the build locally via the in-process DAG scheduler, executing each
2324
/// step inside a VM provided by the injected [`hm_vm::VmBackend`].

crates/hm-exec/src/local/runner/mod.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::pin::Pin;
1111
use std::sync::Arc;
1212

1313
use anyhow::Result;
14-
use hm_plugin_protocol::{ExecutorInput, StepResult};
14+
use hm_plugin_protocol::{ExecutorInput, SnapshotRef, StepResult};
1515
use tokio_util::sync::CancellationToken;
1616

1717
use crate::local::archive::ArchiveStore;
@@ -55,6 +55,21 @@ pub trait StepRunner: Send + Sync + fmt::Debug {
5555
ctx: &StepContext,
5656
input: ExecutorInput,
5757
) -> Pin<Box<dyn Future<Output = Result<StepResult>> + Send + '_>>;
58+
59+
/// Reap transient snapshots once a run has finished.
60+
///
61+
/// Ephemeral (uncached) leaf steps commit a snapshot purely so a
62+
/// downstream `BuildsIn` child can restore from it; nothing else holds a
63+
/// reference and the cache registry never tracks them. The scheduler
64+
/// collects every such snapshot and calls this at run end (best-effort)
65+
/// so they don't leak in the backend store. The default is a no-op for
66+
/// runners that produce no reapable snapshots.
67+
fn reap_snapshots<'a>(
68+
&'a self,
69+
_snapshots: Vec<SnapshotRef>,
70+
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
71+
Box::pin(async {})
72+
}
5873
}
5974

6075
/// Maps runner names to [`StepRunner`] implementations.

crates/hm-exec/src/local/runner/vm.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ impl StepRunner for VmRunner {
4949
let vm = Arc::clone(&self.vm);
5050
Box::pin(async move { run_step_vm(&vm, &ctx, input).await })
5151
}
52+
53+
fn reap_snapshots<'a>(
54+
&'a self,
55+
snapshots: Vec<SnapshotRef>,
56+
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
57+
let vm = Arc::clone(&self.vm);
58+
Box::pin(async move {
59+
for snap in snapshots {
60+
let id = SnapshotId::new(snap.0);
61+
if let Err(e) = vm.remove_snapshot(&id).await {
62+
tracing::warn!(snapshot = %id, error = %e, "failed to reap ephemeral snapshot");
63+
}
64+
}
65+
})
66+
}
5267
}
5368

5469
#[tracing::instrument(skip(vm, ctx), fields(step_key = %input.step.key))]

crates/hm-exec/src/local/scheduler.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ struct StepOutcome {
5858
exit_code: i32,
5959
snapshot: Option<SnapshotRef>,
6060
summaries: Vec<StepResultSummary>,
61+
/// Set when this step did not complete successfully — it failed, timed
62+
/// out, was cancelled, or was itself skipped. Descendants gate on this
63+
/// (not on `exit_code`) so a skip propagates transitively: a skipped
64+
/// step reports `exit_code == 0`, so the exit code alone cannot
65+
/// distinguish "passed" from "skipped" and the cascade would break.
66+
failed_or_skipped: bool,
6167
}
6268

6369
type StepFuture = futures::future::Shared<BoxFuture<'static, StepOutcome>>;
@@ -325,8 +331,12 @@ pub(crate) async fn run(
325331
let pred_outcomes: Vec<StepOutcome> =
326332
join_all(preds.iter().map(|(_, f)| f.clone())).await;
327333

328-
// Early exit if any predecessor failed or the build was cancelled.
329-
if cancel.is_cancelled() || pred_outcomes.iter().any(|o| o.exit_code != 0) {
334+
// Early exit if any predecessor failed/was skipped, or the build
335+
// was cancelled. Gating on `failed_or_skipped` (not `exit_code`)
336+
// is what makes the skip propagate transitively: a skipped
337+
// predecessor reports `exit_code == 0`, so an exit-code-only gate
338+
// would let a skipped step's descendants run anyway.
339+
if cancel.is_cancelled() || pred_outcomes.iter().any(|o| o.failed_or_skipped) {
330340
let status = if cancel.is_cancelled() {
331341
StepStatus::Canceled
332342
} else {
@@ -342,6 +352,7 @@ pub(crate) async fn run(
342352
exit_code: None,
343353
duration_ms: 0,
344354
}],
355+
failed_or_skipped: true,
345356
};
346357
}
347358

@@ -392,6 +403,7 @@ pub(crate) async fn run(
392403
exit_code: Some(1),
393404
duration_ms: 0,
394405
}],
406+
failed_or_skipped: true,
395407
}
396408
}
397409
}
@@ -428,6 +440,22 @@ pub(crate) async fn run(
428440
let outcomes: Vec<StepOutcome> = join_all(pending).await;
429441
let any_failed = outcomes.iter().any(|o| o.exit_code != 0);
430442

443+
// Reap ephemeral leaf snapshots. Uncached steps commit an `ephemeral:*`
444+
// image for downstream container lineage; the cache registry never tracks
445+
// them, so once the run is over nothing else will. Collect every such
446+
// snapshot the steps produced and ask the default runner to remove them
447+
// (best-effort — failures are logged, not fatal).
448+
let ephemeral: Vec<SnapshotRef> = outcomes
449+
.iter()
450+
.filter_map(|o| o.snapshot.clone())
451+
.filter(|s| s.0.starts_with("ephemeral:"))
452+
.collect();
453+
if !ephemeral.is_empty()
454+
&& let Some(runner) = runner_registry.resolve(None)
455+
{
456+
runner.reap_snapshots(ephemeral).await;
457+
}
458+
431459
// Derive the overall verdict. Timeout wins (it also fired cancellation);
432460
// then cancellation; then any failed step; otherwise the build passed.
433461
let status = if timed_out {
@@ -552,6 +580,7 @@ async fn execute_step(
552580
exit_code: 0,
553581
snapshot: None,
554582
summaries: vec![summary],
583+
failed_or_skipped: true,
555584
});
556585
continue;
557586
}
@@ -612,10 +641,15 @@ async fn execute_step(
612641
} else {
613642
None
614643
};
644+
let failed_or_skipped = outcomes
645+
.iter()
646+
.flatten()
647+
.any(|outcome| outcome.failed_or_skipped);
615648
Ok(StepOutcome {
616649
exit_code: first_failure.unwrap_or(0),
617650
snapshot,
618651
summaries,
652+
failed_or_skipped,
619653
})
620654
}
621655

@@ -641,10 +675,13 @@ async fn execute_command(
641675
let step_key = step_wire.key.clone();
642676
let display_name = step_wire.label.clone().unwrap_or_else(|| {
643677
let cmd = step_wire.cmd.trim();
644-
if cmd.len() <= 40 {
678+
if cmd.chars().count() <= 40 {
645679
cmd.to_owned()
646680
} else {
647-
format!("{}…", &cmd[..39])
681+
// Truncate on a char boundary, not a byte offset: `&cmd[..39]`
682+
// panics if byte 39 falls inside a multibyte UTF-8 sequence.
683+
let truncated: String = cmd.chars().take(39).collect();
684+
format!("{truncated}…")
648685
}
649686
});
650687
let env_map = transition.env;
@@ -754,6 +791,7 @@ async fn execute_command(
754791
exit_code: Some(124),
755792
duration_ms: dur_ms,
756793
}],
794+
failed_or_skipped: true,
757795
});
758796
}
759797
}
@@ -800,6 +838,7 @@ async fn execute_command(
800838
exit_code: Some(sr.exit_code),
801839
duration_ms: dur_ms,
802840
}],
841+
failed_or_skipped: sr.exit_code != 0,
803842
})
804843
}
805844
Err(e) => {

0 commit comments

Comments
 (0)