test: container lab for the two-tier apply

A disposable ubuntu container, a fake private tier and a fake bootstrap
endpoint, so the whole documented path — chezmoi init --apply, dotup pick,
dotup private, cmp apply, dotsecrets — can run end to end without touching a
real machine or a real credential. The fake tier mirrors the real one's
structure (seven secrets and one alias) because dotsecrets is copied verbatim
and the "8 exports, not 7" assertion depends on that cardinality; its ids are
sequential and obviously synthetic.

check-verbatim.sh keeps the fake tier's copies of shipped files honest, and
snapshot.sh records file modes so a 644 where a 600 belongs is a diff.
This commit is contained in:
bcherb2
2026-08-21 22:34:53 -04:00
parent d1e3f8bce2
commit a001406a33
22 changed files with 2809 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Local stand-in for the whole remote side of a bootstrap.
One process serves three things a real machine reaches out to:
GET /<route>/bootstrap.env basic auth, returns the two-line blob
GET /git/dotfiles-public.git anonymous, smart git-http
GET /git/dotfiles-private.git basic auth, smart git-http
anything else connection closed with no response
The last one is not laziness. Caddy's catch-all is `handle { abort }`, which
closes the connection without a status line, so a retired route reports curl
exit 52/000 rather than 404. A mock that answered 404 there would let a wrong
assertion pass.
Serving git over HTTP rather than `git daemon` is deliberate too: the private
clone URL carries an inline token, and the code under test splits that token
out into a credential file. Over git:// there is no credential to split and
that entire path goes untested.
No credential is ever logged. Bodies are streamed, so packfiles of any size
work without buffering.
"""
import base64
import os
import socket
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROUTE = os.environ.get("LAB_ROUTE", "bootstrap")
USER = os.environ.get("LAB_USER", "ben")
PASS = os.environ.get("LAB_PASS", "lab-password")
GIT_ROOT = os.environ["LAB_GIT_ROOT"]
# The blob names the git URL, which names the port -- but the port is not
# known until bind() returns. Rather than start the server twice, let it
# substitute its own port: LAB_BLOB may contain {PORT}.
BLOB = os.environ["LAB_BLOB"]
GIT_TOKEN = os.environ.get("LAB_GIT_TOKEN", "lab-git-token")
# 0 means "any free port". Several scenarios run in parallel, so a fixed port
# turns a second run into "Address already in use" -- which reads like a lab
# fault rather than a scheduling one. The chosen port is printed for the
# caller to read back.
PORT = int(os.environ.get("LAB_PORT", "0"))
# Default to loopback. The driver overrides this with the docker0 address so
# containers can reach it; that interface is not routable off the box, which
# 0.0.0.0 would have been. A repo that is private by construction should not
# be reachable from the LAN because a test was running.
BIND = os.environ.get("LAB_BIND", "127.0.0.1")
BACKEND = "/usr/lib/git-core/git-http-backend"
def _check(header, user, pw):
if not header or not header.startswith("Basic "):
return False
try:
raw = base64.b64decode(header[6:]).decode("utf-8", "replace")
except Exception:
return False
got_u, _, got_p = raw.partition(":")
return got_u == user and got_p == pw
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "lab/1.0"
# The default logger writes the request line to stderr. Request lines here
# contain no credential (basic auth rides in a header), but the endpoint
# route is meant to be unguessable, so it stays out of the log too.
def log_message(self, fmt, *a):
pass
def _abort(self):
"""Close with no response at all -- what `handle { abort }` does.
BaseHTTPRequestHandler flushes wfile after handle_one_request returns,
so simply closing it raises out of the server's own plumbing and prints
a traceback that looks like a lab fault rather than the behaviour under
test. Point wfile at /dev/null first: the socket is genuinely gone, and
the inherited flush lands somewhere harmless.
"""
self.close_connection = True
try:
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.connection.close()
finally:
self.wfile = open(os.devnull, "wb")
def _unauth(self, realm):
body = b"unauthorized\n"
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="%s"' % realm)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._route("GET")
def do_POST(self):
self._route("POST")
def _route(self, method):
path = self.path.split("?", 1)[0]
auth = self.headers.get("Authorization")
if path == "/%s/bootstrap.env" % ROUTE:
if not _check(auth, USER, PASS):
return self._unauth("bootstrap")
body = BLOB.encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
return self.wfile.write(body)
if path.startswith("/git/"):
# The private repo demands the token; the public one is open,
# because a stranger really can clone the public tier.
if "dotfiles-private" in path and not _check(auth, "git", GIT_TOKEN):
return self._unauth("git")
return self._git(method, path)
self._abort()
def _git(self, method, path):
env = {
"GIT_PROJECT_ROOT": GIT_ROOT,
"GIT_HTTP_EXPORT_ALL": "1",
"PATH_INFO": path[len("/git"):],
"REQUEST_METHOD": method,
"QUERY_STRING": self.path.split("?", 1)[1] if "?" in self.path else "",
"REMOTE_ADDR": self.client_address[0],
"REMOTE_USER": "lab",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
for h, e in (("Content-Type", "CONTENT_TYPE"),
("Content-Encoding", "HTTP_CONTENT_ENCODING"),
("Accept-Encoding", "HTTP_ACCEPT_ENCODING"),
("Git-Protocol", "HTTP_GIT_PROTOCOL")):
v = self.headers.get(h)
if v:
env[e] = v
n = int(self.headers.get("Content-Length") or 0)
if n:
env["CONTENT_LENGTH"] = str(n)
p = subprocess.Popen([BACKEND], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
if n:
p.stdin.write(self.rfile.read(n))
p.stdin.close()
# CGI response: headers, blank line, body. Status comes back as a
# `Status:` header when it is not 200.
head, status, sent = [], 200, []
while True:
line = p.stdout.readline()
if not line or line in (b"\r\n", b"\n"):
break
k, _, v = line.decode("latin-1").rstrip("\r\n").partition(":")
if k.lower() == "status":
status = int(v.strip().split()[0])
else:
head.append((k, v.strip()))
self.send_response(status)
for k, v in head:
self.send_header(k, v)
# Length is unknown up front for a packfile, so stream it chunked.
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
while True:
chunk = p.stdout.read(65536)
if not chunk:
break
self.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
self.wfile.write(b"0\r\n\r\n")
p.stdout.close()
p.wait()
def _main():
global BLOB
if not os.path.exists(BACKEND):
sys.exit("git-http-backend not found at %s" % BACKEND)
srv = ThreadingHTTPServer((BIND, PORT), Handler)
srv.daemon_threads = True
BLOB = BLOB.replace("{PORT}", str(srv.server_address[1]))
print("lab: listening on %s:%d, route /%s, git root %s"
% (BIND, srv.server_address[1], ROUTE, GIT_ROOT), flush=True)
srv.serve_forever()
if __name__ == "__main__":
_main()