Skip to content

Commit 2c41218

Browse files
authored
Merge pull request #29 from BotCoder254/feat/log-chunk-sections
Fix stale runner deployment: CI-publish the runner image, verify checkout, tolerate transient pulls
2 parents c537abe + e8aea76 commit 2c41218

3 files changed

Lines changed: 178 additions & 26 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,47 @@ jobs:
8787

8888
- name: Test
8989
run: cargo test --locked --verbose
90+
91+
# --- Runner image: build + publish to GHCR on main -------------------------
92+
# The hosted-runner provisioner pulls ghcr.io/botcoder254/overup-runner:latest
93+
# (the RUNNER_IMAGE default) — this job is what actually ships runner fixes
94+
# to deployments. ONE-TIME SETUP: after the first push, make the GHCR
95+
# package PUBLIC (github.com/botcoder254 → Packages → overup-runner →
96+
# Package settings → Change visibility), or anonymous pulls are denied and
97+
# deployments fall back to whatever image is already on their daemon.
98+
# Existing runner containers keep their old image: after a publish, pull the
99+
# new tag on the host and revoke + recreate the hosted runners.
100+
runner-image:
101+
name: runner image (publish to GHCR)
102+
runs-on: ubuntu-latest
103+
needs: rust
104+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
105+
permissions:
106+
contents: read
107+
packages: write
108+
steps:
109+
- uses: actions/checkout@v4
110+
111+
- name: Set up Docker Buildx
112+
uses: docker/setup-buildx-action@v3
113+
114+
- name: Log in to GHCR
115+
uses: docker/login-action@v3
116+
with:
117+
registry: ghcr.io
118+
username: ${{ github.actor }}
119+
password: ${{ secrets.GITHUB_TOKEN }}
120+
121+
# Build context is the repository root — runner/Dockerfile needs the
122+
# sibling protocol/ crate in the context (see the Dockerfile header).
123+
- name: Build and push
124+
uses: docker/build-push-action@v6
125+
with:
126+
context: .
127+
file: runner/Dockerfile
128+
push: true
129+
tags: |
130+
ghcr.io/botcoder254/overup-runner:latest
131+
ghcr.io/botcoder254/overup-runner:${{ github.sha }}
132+
cache-from: type=gha
133+
cache-to: type=gha,mode=max

‎runner/Dockerfile‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@
88
# docker build -f runner/Dockerfile -t overup-runner .
99
#
1010
# Running this image requires access to a Docker daemon, because jobs
11-
# execute in sibling containers. Two required mounts when using the
12-
# host's daemon (see docs/deploy-runner.md for the full setup):
11+
# execute in sibling containers. When using the host's daemon, mount the
12+
# socket and grant its group (see docs/deploy-runner.md for the full setup):
1313
#
1414
# -v /var/run/docker.sock:/var/run/docker.sock \
15-
# --group-add "$(stat -c %g /var/run/docker.sock)" \
16-
# -v /opt/overup/work:/opt/overup/work
15+
# --group-add "$(stat -c %g /var/run/docker.sock)"
1716
#
18-
# The second mount MUST map the same path on both sides: job workspaces
19-
# are created under TMPDIR (/opt/overup/work) and bind-mounted into job
20-
# containers by the HOST daemon — the path must exist identically on the
21-
# host, or jobs see an empty /workspace.
17+
# No workspace mount is needed: /workspace is a daemon-managed anonymous
18+
# volume on each job container — the source is streamed IN over the Docker
19+
# archive API after the container starts, and artifacts stream back OUT the
20+
# same way — so a containerized runner sharing the host daemon never
21+
# depends on a shared host path.
2222

2323
# ---------------------------------------------------------------- builder ---
2424
FROM rust:1-slim-bookworm AS builder
@@ -60,8 +60,8 @@ RUN apt-get update \
6060
WORKDIR /app
6161
COPY --from=builder /build/runner/target/release/runner /app/runner
6262

63-
# Job workspaces (tempfile::tempdir) land here; when the host daemon is
64-
# used, mount the SAME host path here so bind mounts resolve correctly.
63+
# Runner-local scratch space (tempfile): downloaded source tarballs and
64+
# artifact staging land here. Purely internal — no host mount required.
6565
ENV TMPDIR=/opt/overup/work
6666

6767
USER runner

‎runner/src/executor.rs‎

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -287,16 +287,28 @@ async fn execute(
287287
let mut layer_progress: std::collections::HashMap<String, (i64, i64)> =
288288
std::collections::HashMap::new();
289289
let mut last_progress_log = Instant::now();
290+
let mut pull_error: Option<String> = None;
290291
while let Some(progress) = pull.next().await {
291292
if *cancel.borrow() {
292293
return (JobConclusion::Cancelled, None, None);
293294
}
294295
match progress {
295296
Err(error) => {
296-
log.system(&format!("image pull failed: {error}")).await;
297-
return fail("image_pull_failed");
297+
pull_error = Some(error.to_string());
298+
break;
298299
}
299300
Ok(info) => {
301+
// bollard maps `errorDetail` frames with a message to stream
302+
// errors; a message-less one would otherwise slip through.
303+
if let Some(detail) = &info.error_detail {
304+
pull_error = Some(
305+
detail
306+
.message
307+
.clone()
308+
.unwrap_or_else(|| "unknown pull error".to_string()),
309+
);
310+
break;
311+
}
300312
if let (Some(id), Some(detail)) = (&info.id, &info.progress_detail)
301313
&& let (Some(current), Some(total)) = (detail.current, detail.total)
302314
&& total > 0
@@ -323,6 +335,20 @@ async fn execute(
323335
}
324336
}
325337
}
338+
if let Some(error) = pull_error {
339+
// Pulls fail transiently (registry hiccups, daemon layer-extraction
340+
// errors) even when a usable copy of the image is already on the
341+
// daemon — fall back to it rather than failing the job.
342+
if docker.inspect_image(&payload.image).await.is_ok() {
343+
log.system(&format!(
344+
"image pull failed ({error}); using locally cached image"
345+
))
346+
.await;
347+
} else {
348+
log.system(&format!("image pull failed: {error}")).await;
349+
return fail("image_pull_failed");
350+
}
351+
}
326352
metrics.image_pull_ms = Some(pull_started.elapsed().as_millis() as u64);
327353
log.system(&format!(
328354
"image ready in {:.1}s",
@@ -442,14 +468,15 @@ async fn execute(
442468
// --- checkout (upload source into the container) ------------------------
443469
// Now that /workspace exists inside the container, stream the repackaged
444470
// source into it over the archive API.
445-
if let Some(tar) = source_tar {
471+
if let Some(source) = source_tar {
446472
log.section(Some("checkout"), None);
447-
if let Err(error) = upload_source(&docker, &container, tar).await {
473+
let files = source.files;
474+
if let Err(error) = upload_source(&docker, &container, source.tar).await {
448475
log.system(&format!("checkout failed: {error:#}")).await;
449476
cleanup(&docker, Some(&container), network.as_deref()).await;
450477
return fail("checkout_failed");
451478
}
452-
log.system("checkout complete").await;
479+
log.system(&format!("checkout complete ({files} files)")).await;
453480
}
454481

455482
// Resource telemetry for the Performance tab; failures only cost the
@@ -766,16 +793,25 @@ fn format_mib(bytes: i64) -> String {
766793
format!("{:.0} MiB", bytes.max(0) as f64 / (1024.0 * 1024.0))
767794
}
768795

796+
/// A repackaged source archive: the uncompressed tar bytes plus how many
797+
/// regular files survived filtering. Zero files means the tarball had an
798+
/// unexpected layout — uploading it would leave /workspace empty while the
799+
/// log claims a successful checkout, so callers treat it as a failure.
800+
struct RepackagedSource {
801+
tar: Vec<u8>,
802+
files: u64,
803+
}
804+
769805
/// Download the repository tarball (size-capped) and repackage it into a
770806
/// plain tar with the GitHub top-level directory stripped, ready to stream
771807
/// into the container's /workspace. Only plain relative path segments survive
772808
/// — entries with `..`, absolute paths, or prefixes are dropped, as are
773809
/// symlink/hardlink entries (directory-traversal defense; the path check
774-
/// can't validate a link TARGET). Returns the uncompressed tar bytes.
810+
/// can't validate a link TARGET). Fails if no files survive.
775811
async fn fetch_and_repackage(
776812
http: &reqwest::Client,
777813
checkout: &protocol::Checkout,
778-
) -> anyhow::Result<Vec<u8>> {
814+
) -> anyhow::Result<RepackagedSource> {
779815
let response = http
780816
.get(&checkout.tarball_url)
781817
.bearer_auth(&checkout.token)
@@ -805,20 +841,25 @@ async fn fetch_and_repackage(
805841
drop(file);
806842

807843
let tar_path = tarball.path().to_path_buf();
808-
tokio::task::spawn_blocking(move || repackage_stripped(&tar_path))
844+
let source = tokio::task::spawn_blocking(move || repackage_stripped(&tar_path))
809845
.await
810-
.context("repackage task panicked")?
846+
.context("repackage task panicked")??;
847+
if source.files == 0 {
848+
anyhow::bail!("repository archive contained no usable files (unexpected tarball layout)");
849+
}
850+
Ok(source)
811851
}
812852

813853
/// Read the downloaded gzip tarball and re-emit an uncompressed tar with the
814854
/// top-level `{owner}-{repo}-{sha}/` component stripped and unsafe entries
815855
/// dropped. Entry paths are rebuilt with `/` separators so the archive is
816856
/// valid for a Linux container regardless of the runner's own platform.
817-
fn repackage_stripped(tar_path: &Path) -> anyhow::Result<Vec<u8>> {
857+
fn repackage_stripped(tar_path: &Path) -> anyhow::Result<RepackagedSource> {
818858
let file = std::fs::File::open(tar_path)?;
819859
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
820860
let mut builder = tar::Builder::new(Vec::new());
821861
let mut skipped_links: u64 = 0;
862+
let mut files: u64 = 0;
822863
for entry in archive.entries()? {
823864
let mut entry = entry?;
824865

@@ -837,7 +878,12 @@ fn repackage_stripped(tar_path: &Path) -> anyhow::Result<Vec<u8>> {
837878
let path = entry.path()?.into_owned();
838879
// Strip GitHub's top-level wrapper, keep only plain segments, and
839880
// rebuild the path with `/` so it is portable into the container.
840-
let mut components = path.components();
881+
// A leading `./` (some tar producers emit it) is consumed first so
882+
// the strip removes the wrapper dir, not the no-op dot.
883+
let mut components = path.components().peekable();
884+
if matches!(components.peek(), Some(Component::CurDir)) {
885+
components.next();
886+
}
841887
components.next();
842888
let mut rel = String::new();
843889
let mut safe = true;
@@ -862,12 +908,16 @@ fn repackage_stripped(tar_path: &Path) -> anyhow::Result<Vec<u8>> {
862908
// The cloned header carries the correct size/mode/mtime; append_data
863909
// sets the (stripped) path and copies exactly `size` bytes.
864910
let mut header = entry.header().clone();
911+
if header.entry_type().is_file() {
912+
files += 1;
913+
}
865914
builder.append_data(&mut header, &rel, &mut entry)?;
866915
}
867916
if skipped_links > 0 {
868917
tracing::warn!(count = skipped_links, "skipped link entries in repository tarball");
869918
}
870-
builder.into_inner().context("finalizing source tar failed")
919+
let tar = builder.into_inner().context("finalizing source tar failed")?;
920+
Ok(RepackagedSource { tar, files })
871921
}
872922

873923
/// Stream a repackaged source tar into the container's /workspace over the
@@ -977,9 +1027,9 @@ mod tests {
9771027
#[test]
9781028
fn repackage_strips_top_level_and_drops_links() {
9791029
let src = sample_source_tarball();
980-
let bytes = repackage_stripped(src.path()).unwrap();
1030+
let source = repackage_stripped(src.path()).unwrap();
9811031

982-
let mut archive = tar::Archive::new(&bytes[..]);
1032+
let mut archive = tar::Archive::new(&source.tar[..]);
9831033
let mut paths: Vec<String> = archive
9841034
.entries()
9851035
.unwrap()
@@ -989,15 +1039,16 @@ mod tests {
9891039

9901040
// Top-level `repo-sha/` stripped; the symlink is gone.
9911041
assert_eq!(paths, vec!["package.json".to_string(), "src/main.rs".to_string()]);
1042+
assert_eq!(source.files, 2);
9921043
}
9931044

9941045
#[test]
9951046
fn repackaged_source_unpacks_to_workspace_root() {
9961047
let src = sample_source_tarball();
997-
let bytes = repackage_stripped(src.path()).unwrap();
1048+
let source = repackage_stripped(src.path()).unwrap();
9981049

9991050
let dest = tempfile::tempdir().unwrap();
1000-
tar::Archive::new(&bytes[..]).unpack(dest.path()).unwrap();
1051+
tar::Archive::new(&source.tar[..]).unpack(dest.path()).unwrap();
10011052

10021053
// Exactly where a step running in /workspace expects them.
10031054
assert_eq!(
@@ -1008,4 +1059,61 @@ mod tests {
10081059
assert!(!dest.path().join("evil").exists());
10091060
}
10101061

1062+
/// `./`-prefixed entries (`./repo-sha/…`) must strip the wrapper dir, not
1063+
/// the no-op dot — otherwise files land at /workspace/repo-sha/… and
1064+
/// steps see an empty workspace root.
1065+
#[test]
1066+
fn repackage_handles_dot_prefixed_entries() {
1067+
let tmp = tempfile::NamedTempFile::new().unwrap();
1068+
let gz = flate2::write::GzEncoder::new(
1069+
std::fs::File::create(tmp.path()).unwrap(),
1070+
flate2::Compression::default(),
1071+
);
1072+
let mut builder = tar::Builder::new(gz);
1073+
for (name, contents) in [
1074+
("./repo-sha/package.json", &b"{}"[..]),
1075+
("./repo-sha/src/main.rs", b"fn main() {}"),
1076+
] {
1077+
let mut header = tar::Header::new_gnu();
1078+
header.set_size(contents.len() as u64);
1079+
header.set_mode(0o644);
1080+
header.set_entry_type(tar::EntryType::Regular);
1081+
header.set_cksum();
1082+
builder.append_data(&mut header, name, contents).unwrap();
1083+
}
1084+
builder.into_inner().unwrap().finish().unwrap();
1085+
1086+
let source = repackage_stripped(tmp.path()).unwrap();
1087+
assert_eq!(source.files, 2);
1088+
1089+
let dest = tempfile::tempdir().unwrap();
1090+
tar::Archive::new(&source.tar[..]).unpack(dest.path()).unwrap();
1091+
assert!(dest.path().join("package.json").is_file());
1092+
assert!(dest.path().join("src/main.rs").is_file());
1093+
assert!(!dest.path().join("repo-sha").exists());
1094+
}
1095+
1096+
/// A tarball whose entries all get filtered out (here: links only) must
1097+
/// report zero files so the caller fails the checkout instead of
1098+
/// uploading an empty archive and logging success.
1099+
#[test]
1100+
fn repackage_reports_zero_files_when_everything_is_filtered() {
1101+
let tmp = tempfile::NamedTempFile::new().unwrap();
1102+
let gz = flate2::write::GzEncoder::new(
1103+
std::fs::File::create(tmp.path()).unwrap(),
1104+
flate2::Compression::default(),
1105+
);
1106+
let mut builder = tar::Builder::new(gz);
1107+
let mut link = tar::Header::new_gnu();
1108+
link.set_entry_type(tar::EntryType::Symlink);
1109+
link.set_size(0);
1110+
link.set_cksum();
1111+
builder
1112+
.append_link(&mut link, "repo-sha/evil", "/etc/passwd")
1113+
.unwrap();
1114+
builder.into_inner().unwrap().finish().unwrap();
1115+
1116+
let source = repackage_stripped(tmp.path()).unwrap();
1117+
assert_eq!(source.files, 0);
1118+
}
10111119
}

0 commit comments

Comments
 (0)