From 3f2b28d0ec115eec4e2f308928eb3bce66a99e5d Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Tue, 4 Aug 2026 16:34:09 +0900 Subject: [PATCH] fix(bootstrap): ship deploy/paper in the embedded game-stack tar --- bootstrap_asset.go | 11 ++++--- bootstrap_asset_test.go | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/bootstrap_asset.go b/bootstrap_asset.go index a66e135..ff36377 100644 --- a/bootstrap_asset.go +++ b/bootstrap_asset.go @@ -13,8 +13,8 @@ var bootstrapScript string //go:embed deploy/crd/*.yaml var bootstrapAssets embed.FS -// gameStackAssets carries everything deploy/bootstrap.sh needs to build the two -// always-on game images (login limbo + lobby) and the Velocity plugin, for the TUI +// gameStackAssets carries everything deploy/bootstrap.sh needs to build the three +// game images (login limbo, lobby, plain Paper) and the Velocity plugin, for the TUI // install path — which pipes the embedded bootstrap.sh into bash and therefore has // NO source checkout on disk to build from. // @@ -26,6 +26,7 @@ var bootstrapAssets embed.FS // //go:embed deploy/limbo/Dockerfile deploy/limbo/entrypoint.sh //go:embed deploy/lobby/Dockerfile deploy/lobby/entrypoint.sh +//go:embed deploy/paper/Dockerfile deploy/paper/entrypoint.sh //go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src //go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src //go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src @@ -46,9 +47,9 @@ func GameStackTar(w io.Writer) error { if err != nil { return err } - // Mode 0644 for everything: entrypoint.sh is invoked as `sh ` by both - // Dockerfiles precisely because the +x bit does not survive a Windows checkout, - // so nothing here needs to be executable. + // Mode 0644 for everything: entrypoint.sh is invoked as `sh ` by all + // three Dockerfiles precisely because the +x bit does not survive a Windows + // checkout, so nothing here needs to be executable. if err := tw.WriteHeader(&tar.Header{ Name: path, Mode: 0o644, diff --git a/bootstrap_asset_test.go b/bootstrap_asset_test.go index 78914f8..b352454 100644 --- a/bootstrap_asset_test.go +++ b/bootstrap_asset_test.go @@ -1,6 +1,8 @@ package felis import ( + "io/fs" + "regexp" "strings" "testing" ) @@ -104,6 +106,74 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) { } } +// The embed list and the images bootstrap.sh builds are two lists nobody reconciles. +// deploy/paper shipped an image build without ever being added to gameStackAssets, and +// nothing said so: a checkout on disk satisfies the build either way, and the tar is +// only the build context on the path that has no checkout — `curl | bash`, where the +// third `docker build -f` then names a file that was never unpacked. So derive the +// inputs from the script and from each Dockerfile's own COPY lines instead of restating +// them here; a fourth image inherits the check for free. +func TestGameStackTarCarriesEveryBuildInput(t *testing.T) { + // Matches the path only when GAME_STACK_DIR is followed by one, which skips the + // build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and + // the glob for gradle's output, none of which are inputs this tar has to carry. + found := regexp.MustCompile(`\$\{GAME_STACK_DIR\}/(\S+?)"`).FindAllStringSubmatch(BootstrapScript(), -1) + var paths []string + seen := map[string]bool{} + for _, m := range found { + if !seen[m[1]] { + seen[m[1]] = true + paths = append(paths, m[1]) + } + } + // Guards the regex itself: a rewrite of how bootstrap.sh spells the build context + // would otherwise turn this test into an unconditional pass. It has to come before + // the loop — a missing file in there is fatal, and a floor placed after it would + // never be reached to say that the regex, not the tar, is what went wrong. + if len(paths) < 3 { + t.Fatalf("only %d game-stack path(s) resolved out of bootstrap.sh; the limbo, "+ + "lobby and paper Dockerfiles are all built from ${GAME_STACK_DIR}", len(paths)) + } + for _, path := range paths { + requireEmbedded(t, path) + // A Dockerfile that arrives without the files it COPYs fails just as late and + // just as far from here; the deploy/paper gap was missing its entrypoint too. + for _, src := range copySources(t, path) { + requireEmbedded(t, src) + } + } +} + +// copySources lists the build-context paths a Dockerfile COPYs in, skipping the +// --from= copies, whose sources are produced by an earlier stage rather than +// unpacked from the tar. +func copySources(t *testing.T, dockerfile string) []string { + t.Helper() + var out []string + // Continuations are joined first: a COPY split across lines would otherwise be two + // fragments, neither of them starting with COPY followed by a source, and its + // source would slip past unchecked. + body := strings.ReplaceAll(readGameStackFile(t, dockerfile), "\\\n", " ") + for line := range strings.SplitSeq(body, "\n") { + f := strings.Fields(line) + if len(f) < 2 || f[0] != "COPY" || strings.HasPrefix(f[1], "--") { + continue + } + out = append(out, strings.TrimSuffix(f[1], "/")) + } + return out +} + +// fs.Stat rather than ReadFile: half of these are directories (`COPY plugins/shared/`), +// and embed.FS answers for those too. +func requireEmbedded(t *testing.T, path string) { + t.Helper() + if _, err := fs.Stat(gameStackAssets, path); err != nil { + t.Errorf("%s is a game-stack build input but is not in gameStackAssets; an "+ + "install with no source checkout dies on it: %v", path, err) + } +} + func readGameStackFile(t *testing.T, name string) string { t.Helper() b, err := gameStackAssets.ReadFile(name)