smoke: workspace export scenario + smoke-fresh target + fix the export bug it caught

The export round-trip (`vm create` → `workspace prepare` → guest edit →
`workspace export`) exposed a reproducible failure on Debian bookworm
guests: `git read-tree HEAD --index-output=/tmp/...` returns exit 128
"unable to write new index file" when the target lives on tmpfs while
`.git` is on the workspace overlay. Move the temp index into
`$(git rev-parse --git-dir)` so it shares a filesystem with `.git/index`
and the lockfile + rename + hardlink dance git does internally works.

Alongside:
- new workspace-export smoke scenario that would have caught this at
  the boundary between daemon and guest git
- `make smoke-fresh` = `smoke-clean && smoke` for release-time runs
  that want first-install paths (migrations, image pull) stamped into
  the coverage report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 11:34:55 -03:00
parent 672d7151e9
commit e94e7c4dcc
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 55 additions and 2 deletions

View file

@ -33,7 +33,7 @@ GO_LDFLAGS := -X banger/internal/buildinfo.Version=$(VERSION) -X banger/internal
.DEFAULT_GOAL := help
.PHONY: help build banger bangerd test fmt tidy clean install uninstall lint lint-go lint-shell coverage coverage-html coverage-total smoke smoke-build smoke-coverage-html smoke-clean
.PHONY: help build banger bangerd test fmt tidy clean install uninstall lint lint-go lint-shell coverage coverage-html coverage-total smoke smoke-build smoke-coverage-html smoke-clean smoke-fresh
help:
@printf '%s\n' \
@ -50,6 +50,7 @@ help:
' make tidy Run go mod tidy' \
' make clean Remove built Go binaries and coverage artefacts' \
' make smoke Build instrumented binaries, run scripts/smoke.sh, report coverage (needs KVM + sudo)' \
' make smoke-fresh smoke-clean + smoke — forces first-install paths (migrations, image pull) into the coverage stamp' \
' make smoke-coverage-html HTML coverage report from the last smoke run' \
' make smoke-clean Remove the smoke build tree'
@ -147,6 +148,15 @@ smoke-coverage-html: smoke
smoke-clean:
rm -rf "$(SMOKE_DIR)"
# smoke-fresh wipes everything under $(SMOKE_DIR) (instrumented
# binaries, coverage pods, persisted XDG state) and runs a full
# smoke from scratch. Useful before a release tag: the regular
# `make smoke` reuses the XDG state across runs to skip the ~290MB
# image pull, which is fast but leaves migrations and image-upsert
# paths cold on every run after the first. smoke-fresh pays the
# time cost to stamp those paths into the coverage report too.
smoke-fresh: smoke-clean smoke
install: build
mkdir -p "$(DESTDIR)$(BINDIR)"
mkdir -p "$(DESTDIR)$(LIBDIR)/banger"

View file

@ -116,11 +116,21 @@ func (s *WorkspaceService) ExportVMWorkspace(ctx context.Context, params api.Wor
// Mechanics: seed a temp index from diffRef's tree via git read-tree,
// restage the working tree into that temp index with GIT_INDEX_FILE,
// then emit the diff. The temp index is rm'd on exit via trap.
//
// The temp index must live on the same filesystem as the repo's
// real .git directory. `git read-tree --index-output=PATH` uses a
// lockfile + rename + hardlink sequence that fails with "unable to
// write new index file" when PATH is on a different filesystem —
// reliably reproducible on Debian bookworm guests where /tmp is
// tmpfs and the workspace overlay is on a separate FS. mktemp'ing
// inside `$(git rev-parse --git-dir)` keeps the temp index on the
// same FS as .git/index without polluting the working tree.
func exportScript(guestPath, diffRef, diffFlag string) string {
return fmt.Sprintf(
"set -euo pipefail\n"+
"cd %s\n"+
"tmp_idx=\"$(mktemp \"${TMPDIR:-/tmp}/banger-export.XXXXXX\")\"\n"+
"git_dir=\"$(git rev-parse --git-dir)\"\n"+
"tmp_idx=\"$(mktemp \"$git_dir/banger-export-idx.XXXXXX\")\"\n"+
"trap 'rm -f \"$tmp_idx\"' EXIT\n"+
"git read-tree %s --index-output=\"$tmp_idx\"\n"+
"GIT_INDEX_FILE=\"$tmp_idx\" git add -A\n"+

View file

@ -141,6 +141,39 @@ grep -q 'untracked-marker' <<<"$inc_out" || die "--include-untracked didn't ship
# Restore repo to tracked-only state for any later scenarios.
rm -f "$repodir/smoke-untracked.txt"
# --- workspace export round-trip --------------------------------------
# Exercises ExportVMWorkspace: create a VM, prepare the workspace,
# write a new file inside the guest, then export and assert the
# emitted patch sees the guest-side change. If the export pipeline
# (temp-index, git add -A, diff --binary) ever stops capturing
# guest-side changes, this scenario catches it.
log 'workspace export: create + prepare + guest edit + export + assert marker'
export_vm='smoke-export'
cleanup_export_vm() {
"$BANGER" vm delete "$export_vm" >/dev/null 2>&1 || true
}
# Chain the VM cleanup with the existing runtime_dir trap so a mid-
# scenario failure still tears the VM down before the script exits.
# shellcheck disable=SC2064
trap "cleanup_export_vm; rm -rf '$runtime_dir'" EXIT
"$BANGER" vm create --name "$export_vm" --image debian-bookworm >/dev/null \
|| die "export: vm create exit $?"
"$BANGER" vm workspace prepare "$export_vm" "$repodir" >/dev/null \
|| die "export: workspace prepare exit $?"
"$BANGER" vm ssh "$export_vm" -- sh -c 'echo guest-edit > /root/repo/new-guest-file.txt' \
|| die "export: guest-side file write exit $?"
export_patch="$runtime_dir/smoke-export.diff"
"$BANGER" vm workspace export "$export_vm" --output "$export_patch" \
|| die "export: workspace export exit $?"
[[ -s "$export_patch" ]] || die "export: patch file empty at $export_patch"
grep -q 'new-guest-file.txt' "$export_patch" \
|| die "export: patch missing new-guest-file.txt marker (head: $(head -c 400 "$export_patch"))"
cleanup_export_vm
# shellcheck disable=SC2064
trap "rm -rf '$runtime_dir'" EXIT
# --- concurrent vm runs -----------------------------------------------
# Stresses per-VM lock scoping, the tap pool warm-up path, and
# createVMMu's narrow reservation window. Two `vm run --rm` invocations