286c3281b5
Two assertions could not fail. `dotup --yes install | tail -8` threw the status away — no set -e, no pipefail, and $? was tail's — so a run in which 36 packages failed to install still reached "E2E PASS". And the zsh login check was a `|| echo WARN` that fell straight through. Both now fail the run.
240 lines
12 KiB
Bash
Executable File
240 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
# Phase 5 end-to-end, inside a clean container. Public tier from the live
|
|
# anonymous URL, then the private tier through the real bootstrap endpoint.
|
|
#
|
|
# Credentials arrive in the environment (BOOT_URL/BOOT_USER/BOOT_PW) and are
|
|
# never echoed. Package set is scoped to the private path and its dependency:
|
|
# the full manifest install is covered by the fake-package-manager suite, and
|
|
# what has never run end to end is bootstrap -> bws -> private repo -> secrets.
|
|
set -u
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
fail() { echo "FAIL: $*"; exit 1; }
|
|
|
|
apt-get update -qq && apt-get install -y -qq git curl zsh ca-certificates expect >/dev/null 2>&1
|
|
|
|
# ---- 1. public tier, anonymously, exactly as a stranger would ---------------
|
|
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply \
|
|
"${E2E_PUBLIC_URL:?}" >/dev/null 2>&1 \
|
|
|| fail "public tier init"
|
|
# Assert the ABSOLUTE path first, before touching PATH.
|
|
#
|
|
# A real operator has just run the installer in whatever shell they were already
|
|
# in, and on a fresh box that is bash. ~/.local/bin is added to PATH only by the
|
|
# .zshrc this tier ships -- for a zsh that dotup has not installed yet. So the
|
|
# first invocation is necessarily `~/.local/bin/dotup`, which is exactly what
|
|
# the README documents, and bare `dotup` is `command not found` on every new
|
|
# machine.
|
|
#
|
|
# This used to read `export PATH=...` and THEN `command -v dotup`, which is a
|
|
# vacuous assertion: it proved the export worked, not that the installer put
|
|
# anything anywhere. It passed happily while a real bash user hit
|
|
# `dotup: command not found`.
|
|
[ -x "$HOME/.local/bin/dotup" ] \
|
|
|| fail "public apply left no executable at ~/.local/bin/dotup"
|
|
# Only now, for the convenience of the rest of this script.
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
echo "1. public tier applied: $(chezmoi managed -p absolute | wc -l) files"
|
|
|
|
# absence assertions BEFORE the private tier exists
|
|
[ ! -e "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh present pre-bootstrap"
|
|
[ ! -e "$HOME/.config/bitwarden/bws-token" ] || fail "bws token present pre-bootstrap"
|
|
[ ! -d "$HOME/.local/share/dotfiles-private" ] || fail "private tier present pre-bootstrap"
|
|
echo "2. public-only machine is clean: no token, no secrets, no private source"
|
|
|
|
# ---- 2. select the private rows --------------------------------------------
|
|
STATE=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles
|
|
mkdir -p "$STATE"
|
|
printf 'core/unzip\nprivate/private-repo\nprivate/bws-secrets\n' > "$STATE/selected"
|
|
echo " plan says:"; dotup plan 2>&1 | sed 's/^/ /' | grep -v '^ *$' | head -8
|
|
|
|
# ---- 3. install: unzip via apt. bws must NOT appear here -------------------
|
|
# The status was thrown away here, and that is not a detail: with no `set -e`,
|
|
# no `pipefail`, and nothing reading `$?` (which was `tail`'s anyway), a run in
|
|
# which 36 packages failed to install still reached `echo "E2E PASS"`. Both
|
|
# statements were true at once and only one of them was reported. Capture the
|
|
# status, print the output, then fail on it.
|
|
set -o pipefail
|
|
dotup --yes install 2>&1 | sed 's/^/ /' | tail -8
|
|
irc=$?
|
|
set +o pipefail
|
|
[ "$irc" -eq 0 ] || fail "dotup install exited $irc — at least one package did not install"
|
|
command -v unzip >/dev/null || fail "unzip not installed"
|
|
command -v bws >/dev/null && fail "bws installed by the package phase — it must come from the private tier"
|
|
echo "3. unzip installed; bws correctly absent (it is not a manifest package)"
|
|
|
|
|
|
# ---- 4. the private tier, through the real endpoint ------------------------
|
|
# TEN answers, not three: dotup asks for the endpoint URL, username and
|
|
# password, and then the private repo's .chezmoi.toml.tmpl asks its own seven
|
|
# promptStringOnce questions.
|
|
#
|
|
# They must be answered one at a time, as each prompt appears. Two earlier
|
|
# attempts piped all ten in up front through `script` and hung forever on
|
|
# `git user.name?`. chezmoi's prompt is a full-screen TUI -- it puts the tty in
|
|
# raw mode, and a raw-mode switch done with TCSAFLUSH DISCARDS input already
|
|
# sitting in the buffer. Everything pre-fed was written to the pty before
|
|
# chezmoi started, so it was thrown away, and the pty then waited on a stdin
|
|
# that had already closed. No amount of pre-feeding can work against that
|
|
# prompt; the answers have to arrive after it draws.
|
|
#
|
|
# expect also drops `script`'s worst property: script mirrors its stdin into
|
|
# its own output, which published the endpoint password into a session
|
|
# transcript once already. Credentials reach expect through the environment
|
|
# ($env(...)), never argv, never stdin.
|
|
red() { sed -e "s|$BOOT_URL|<BOOT_URL>|g" -e "s|$BOOT_PW|<BOOT_PW>|g" \
|
|
-e 's|[0-9a-f]\{32\}|<PATH>|g'; }
|
|
export E2E_NAME='E2E Test' E2E_EMAIL='e2e@example.invalid'
|
|
timeout 600 expect -f - > /tmp/private.log 2>&1 <<'EXP'
|
|
set timeout 180
|
|
log_user 1
|
|
spawn -noecho dotup private
|
|
|
|
# A bare `expect -re {pat} { ... }` treats a timeout as "carry on", so a missed
|
|
# prompt does not fail -- it silently falls through and every later expect waits
|
|
# out its own timeout in turn, surfacing at the 600s ceiling as an unattributed
|
|
# hang. Name the prompt that was actually missed instead.
|
|
proc wait_for {pat what} {
|
|
expect {
|
|
-re $pat {}
|
|
timeout { send_user "\nTIMEOUT: never saw $what\n"; exit 3 }
|
|
eof { send_user "\nEOF before $what -- dotup exited early\n"; exit 4 }
|
|
}
|
|
}
|
|
|
|
# dotup's own three: plain `read`, no TUI, so the text is the whole signal.
|
|
#
|
|
# The FIRST password sent here is deliberately wrong. These credentials are
|
|
# asked for at the very end of a run, so a typo used to be fatal -- one wrong
|
|
# character and the whole install had to be repeated to get back to this
|
|
# prompt. Getting it wrong on purpose is the only way to prove the retry loop
|
|
# exists and that the URL and username survive the attempt.
|
|
wait_for {Bootstrap URL:} "the bootstrap URL prompt"
|
|
send -- "$env(BOOT_URL)\r"
|
|
wait_for {Username:} "the username prompt"
|
|
send -- "$env(BOOT_USER)\r"
|
|
wait_for {Password:} "the password prompt"
|
|
send -- "definitely-not-the-password\r"
|
|
|
|
# The message must name the actual fault. "endpoint refused the credentials"
|
|
# was once emitted for a 401, a 404 and an unreachable host alike.
|
|
wait_for {wrong username or password} "the wrong-password message"
|
|
# Blank keeps the URL and the username, so only the password is retyped. The
|
|
# bracketed default in the prompt is what proves they were retained.
|
|
wait_for {Bootstrap URL \[} "the retry prompt, with the URL kept"
|
|
send -- "\r"
|
|
wait_for {Username \[} "the retry username prompt, with the user kept"
|
|
send -- "\r"
|
|
wait_for {Password:} "the retry password prompt"
|
|
send -- "$env(BOOT_PW)\r"
|
|
# The private repo's seven, each answered only once its TUI is genuinely ready
|
|
# to read.
|
|
#
|
|
# Matching the prompt TEXT is not enough, and this is a race that passes most of
|
|
# the time. chezmoi writes "git user.email?" while the tty is still in cooked
|
|
# mode, and only then switches to raw mode -- with TCSAFLUSH, which DISCARDS
|
|
# whatever is already sitting in the input buffer. An answer sent on the text
|
|
# alone can therefore land in the window before the switch and be thrown away.
|
|
# The value is gone, the field sits unsubmitted, and every later expect waits
|
|
# out its own timeout: one lost keystroke costs the full 600s ceiling and
|
|
# reports as "a prompt went unanswered".
|
|
#
|
|
# Observed exactly that way -- one run answered all seven, the next lost the
|
|
# Enter after user.email. Nothing about the two runs differed but timing.
|
|
#
|
|
# `\033[?2004h` is bracketed-paste-on, which the TUI emits only AFTER raw mode
|
|
# is established. Waiting for it turns "probably ready" into "demonstrably
|
|
# ready". Each prompt emits its own, so this is per-answer, not once.
|
|
proc ask {pat val} {
|
|
wait_for $pat "chezmoi prompt $pat"
|
|
wait_for "\033\\\[\\?2004h" "raw mode after $pat (TUI never became ready)"
|
|
send -- "$val\r"
|
|
}
|
|
ask {user\.name} "$env(E2E_NAME)"
|
|
ask {user\.email} "$env(E2E_EMAIL)"
|
|
# blank accepts the template's default, which is what a real operator does for
|
|
# the signing key and the four gitea URLs
|
|
ask {signing key} ""
|
|
ask {WAN ssh} ""
|
|
ask {LAN ssh} ""
|
|
ask {WAN web} ""
|
|
ask {LAN web} ""
|
|
expect eof
|
|
catch wait result
|
|
exit [lindex $result 3]
|
|
EXP
|
|
rc=$?
|
|
case $rc in
|
|
124) fail "dotup private hit the 600s ceiling — expect itself wedged, which its own timeout handlers should have prevented" ;;
|
|
3|4) red < /tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ /'
|
|
fail "a prompt went unanswered — see the TIMEOUT/EOF line above for which one" ;;
|
|
esac
|
|
# `script` mirrors its stdin into the pty output, so the three answers -- the
|
|
# secret path and the password among them -- are sitting at the top of that log.
|
|
# Redact before anything prints it. Learned the hard way: an earlier run of this
|
|
# test published both into a session transcript and they had to be rotated.
|
|
red < /tmp/private.log | tr -d '\r' | sed 's/^/ /' | tail -14
|
|
|
|
# ---- 5. assertions ---------------------------------------------------------
|
|
echo
|
|
echo "--- verification ---"
|
|
command -v bws >/dev/null || fail "bws not installed by the private tier"
|
|
echo "bws: $(bws --version 2>&1 | head -1), installed by the private tier"
|
|
[ -r "$HOME/.config/bitwarden/bws-token" ] || fail "bws token not written"
|
|
m=$(stat -c '%a' "$HOME/.config/bitwarden/bws-token")
|
|
[ "$m" = 600 ] || fail "bws token mode $m, want 600"
|
|
echo "bws token: present, mode 600"
|
|
|
|
[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned"
|
|
sm=$(stat -c '%a' "$HOME/.local/share/dotfiles-private")
|
|
case $sm in *00) echo "private source: cloned, mode $sm (go-rwx applied)" ;;
|
|
*) fail "private source mode $sm — group/other can read it" ;; esac
|
|
|
|
[ -f "$HOME/.config/chezmoi/private.toml" ] || fail "private.toml not created"
|
|
[ -f "$HOME/.config/chezmoi/chezmoi.toml" ] || fail "public chezmoi.toml missing"
|
|
d=$(grep -c '^\s*git' "$HOME/.config/chezmoi/private.toml" 2>/dev/null || echo 0)
|
|
echo "configs: chezmoi.toml + private.toml, private has $d identity keys"
|
|
|
|
[ -r "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh not generated"
|
|
sm=$(stat -c '%a' "$HOME/.config/zsh/secrets.zsh")
|
|
[ "$sm" = 600 ] || fail "secrets.zsh mode $sm, want 600"
|
|
n=$(grep -c '^export ' "$HOME/.config/zsh/secrets.zsh")
|
|
echo "secrets.zsh: mode 600, $n exports (want 8)"
|
|
[ "$n" -eq 8 ] || fail "expected 8 exports, got $n"
|
|
|
|
# the credential must not be in any log dotup produced
|
|
if grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log; then
|
|
echo "FAIL: a credential-bearing URL appears in dotup output"; exit 1
|
|
fi
|
|
echo "log hygiene: no credential-bearing URL in dotup output"
|
|
|
|
# ...nor in the clone's own config, which is where it used to live forever.
|
|
GC="$HOME/.local/share/dotfiles-private/.git/config"
|
|
[ -f "$GC" ] || fail "no .git/config in the private source"
|
|
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \
|
|
&& fail "the git remote in .git/config still carries the token"
|
|
echo "git remote: clean — $(git -C "$HOME/.local/share/dotfiles-private" remote get-url origin)"
|
|
|
|
CRED="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials"
|
|
[ -r "$CRED" ] || fail "credential file not written to $CRED"
|
|
cm=$(stat -c '%a' "$CRED")
|
|
[ "$cm" = 600 ] || fail "credential file mode $cm, want 600"
|
|
echo "credential file: present, mode 600, $(wc -l < "$CRED") line"
|
|
|
|
# the whole point: a later update must still authenticate, with the token only
|
|
# ever coming from that file
|
|
git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \
|
|
&& echo "later fetch: authenticates from the credential file" \
|
|
|| fail "fetch failed — the credential helper did not survive the clone"
|
|
|
|
# identity arrived
|
|
git config --get user.email >/dev/null 2>&1 || fail "git identity not configured"
|
|
echo "git identity: configured"
|
|
|
|
# This was a `|| echo "WARN: …"` that fell straight through to `E2E PASS`, so
|
|
# the one assertion about the shell this repo exists to configure could not
|
|
# fail. If the login shell is broken, the run is not a pass.
|
|
zsh -ic 'exit' >/dev/null 2>&1 || fail "zsh -ic exited non-zero — the login shell this repo configures is broken"
|
|
echo "zsh: interactive login clean"
|
|
echo
|
|
echo "E2E PASS"
|