diff --git a/internal/server/handlers_workspaces.go b/internal/server/handlers_workspaces.go index 4cba157fb..68053e7fd 100644 --- a/internal/server/handlers_workspaces.go +++ b/internal/server/handlers_workspaces.go @@ -97,6 +97,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp["build_time"] = s.buildTime } resp["cloud_mode"] = s.cloudMode + if s.webIdentity != nil { + resp["web"] = s.webIdentity + } writeJSON(w, http.StatusOK, resp) } diff --git a/internal/server/server.go b/internal/server/server.go index 4c22bf321..f3baaf1c6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,6 +78,7 @@ type Server struct { streamGaugeFor *metrics.Metrics // the metrics instance pad_stream_connections_active is registered on (BUG-2726) httpServer *http.Server // underlying HTTP server (set during ListenAndServe) webFS fs.FS // embedded web UI static files (optional) + webIdentity *webBuildIdentity // what webFS is, computed once in SetWebUI (TASK-3233) events events.EventBus // real-time event bus (optional) publishFailures publishFailureLog // rate-bounds publishActivityEvent's failure log (BUG-2732) watchEvents watchevents.Bus // watch/nudge notification bus (optional, TASK-2533) @@ -2173,6 +2174,7 @@ func (s *Server) setupRouter() { // SetWebUI sets the embedded web UI filesystem for serving the SPA. func (s *Server) SetWebUI(fsys fs.FS) { s.webFS = fsys + s.webIdentity = identifyWebBuild(fsys) s.ensureRouter() s.router.Handle("/*", s.spaHandler()) } diff --git a/internal/server/web_build_identity.go b/internal/server/web_build_identity.go new file mode 100644 index 000000000..7d0124c36 --- /dev/null +++ b/internal/server/web_build_identity.go @@ -0,0 +1,86 @@ +package server + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "log/slog" +) + +// webBuildSourceFile is the stamp `vite build` writes into the bundle +// (web/vite.config.ts, buildSourceStamp): the commit the web source was at and +// how many entries `git status` listed under web/ when it ran. +const webBuildSourceFile = "build-source.json" + +// webBuildIdentity says which web bundle this binary serves (TASK-3233). +// +// The commit a binary is stamped with says nothing about its web/build: that +// directory is generated, gitignored, and embedded as it stands, so a bundle +// left over from another commit, or built while web/ held uncommitted edits (a +// negative-control build, BUG-3129), ships under the same commit stamp. +// SHA256 is the bundle's own identity, computed from the bytes served, so it +// needs no trust in the build machine. The Source* fields come from the stamp +// and say where the bundle came from; they are absent when it has none. +type webBuildIdentity struct { + SHA256 string `json:"sha256"` + Files int `json:"files"` + SourceCommit *string `json:"source_commit,omitempty"` + SourceDirty *int `json:"source_dirty,omitempty"` +} + +type webBuildSource struct { + Commit *string `json:"commit"` + Dirty *int `json:"dirty"` +} + +// identifyWebBuild digests every regular file in fsys: sha256 over, in +// WalkDir's lexical order, each file's path, a NUL, its content's sha256, and +// a newline. Paths are part of it, so a rename changes the digest too. A +// failure is logged and leaves the identity unknown (nil); it never stops the +// server, since serving works without it. +func identifyWebBuild(fsys fs.FS) *webBuildIdentity { + if fsys == nil { + return nil + } + h := sha256.New() + files := 0 + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + f, err := fsys.Open(path) + if err != nil { + return err + } + defer f.Close() + fh := sha256.New() + if _, err := io.Copy(fh, f); err != nil { + return err + } + io.WriteString(h, path) + h.Write([]byte{0}) + io.WriteString(h, hex.EncodeToString(fh.Sum(nil))) + h.Write([]byte{'\n'}) + files++ + return nil + }) + if err != nil { + slog.Warn("web build identity: could not digest the embedded web UI", "error", err) + return nil + } + id := &webBuildIdentity{SHA256: hex.EncodeToString(h.Sum(nil)), Files: files} + if raw, err := fs.ReadFile(fsys, webBuildSourceFile); err == nil { + var src webBuildSource + if err := json.Unmarshal(raw, &src); err != nil { + slog.Warn("web build identity: unreadable "+webBuildSourceFile, "error", err) + } else { + id.SourceCommit, id.SourceDirty = src.Commit, src.Dirty + } + } + return id +} diff --git a/internal/server/web_build_identity_test.go b/internal/server/web_build_identity_test.go new file mode 100644 index 000000000..bb2b48ef8 --- /dev/null +++ b/internal/server/web_build_identity_test.go @@ -0,0 +1,105 @@ +package server + +import ( + "net/http" + "testing" + "testing/fstest" +) + +func webFS(files map[string]string) fstest.MapFS { + m := fstest.MapFS{} + for p, c := range files { + m[p] = &fstest.MapFile{Data: []byte(c)} + } + return m +} + +func TestIdentifyWebBuild_DigestTracksContentAndPaths(t *testing.T) { + base := map[string]string{ + "index.html": "", + "_app/immutable/start.js": "console.log(1)", + } + a := identifyWebBuild(webFS(base)) + if a == nil || a.SHA256 == "" || a.Files != 2 { + t.Fatalf("identity: %+v", a) + } + if b := identifyWebBuild(webFS(base)); b.SHA256 != a.SHA256 { + t.Errorf("digest is not deterministic: %s vs %s", a.SHA256, b.SHA256) + } + + changed := map[string]string{"index.html": "", "_app/immutable/start.js": "console.log(2)"} + if c := identifyWebBuild(webFS(changed)); c.SHA256 == a.SHA256 { + t.Error("a content change did not change the digest") + } + renamed := map[string]string{"index.html": "", "_app/immutable/start2.js": "console.log(1)"} + if r := identifyWebBuild(webFS(renamed)); r.SHA256 == a.SHA256 { + t.Error("a path change did not change the digest") + } + if a.SourceCommit != nil || a.SourceDirty != nil { + t.Errorf("no stamp, but source fields set: %+v", a) + } +} + +func TestIdentifyWebBuild_ReadsStamp(t *testing.T) { + id := identifyWebBuild(webFS(map[string]string{ + "index.html": "x", + webBuildSourceFile: `{"commit":"abc123","dirty":2}`, + })) + if id.SourceCommit == nil || *id.SourceCommit != "abc123" { + t.Errorf("source_commit: %v", id.SourceCommit) + } + if id.SourceDirty == nil || *id.SourceDirty != 2 { + t.Errorf("source_dirty: %v", id.SourceDirty) + } + + // A stamp written where git was unavailable carries nulls: the fields + // stay absent rather than claiming a clean tree at no commit. + unknown := identifyWebBuild(webFS(map[string]string{ + "index.html": "x", + webBuildSourceFile: `{"commit":null,"dirty":null}`, + })) + if unknown.SourceCommit != nil || unknown.SourceDirty != nil { + t.Errorf("null stamp should leave source fields absent: %+v", unknown) + } +} + +func TestHealth_ReportsServedWebBundle(t *testing.T) { + srv := testServer(t) + srv.SetWebUI(webFS(map[string]string{ + "index.html": "", + webBuildSourceFile: `{"commit":"abc123","dirty":0}`, + })) + + rr := doRequest(srv, http.MethodGet, "/api/v1/health", nil) + if rr.Code != http.StatusOK { + t.Fatalf("health: %d", rr.Code) + } + var resp struct { + Web *struct { + SHA256 string `json:"sha256"` + Files int `json:"files"` + SourceCommit *string `json:"source_commit"` + SourceDirty *int `json:"source_dirty"` + } `json:"web"` + } + parseJSON(t, rr, &resp) + if resp.Web == nil { + t.Fatalf("health has no web member: %s", rr.Body.String()) + } + if resp.Web.SHA256 != srv.webIdentity.SHA256 || resp.Web.Files != 2 { + t.Errorf("web: %+v", resp.Web) + } + if resp.Web.SourceCommit == nil || *resp.Web.SourceCommit != "abc123" || resp.Web.SourceDirty == nil || *resp.Web.SourceDirty != 0 { + t.Errorf("web source: %+v", resp.Web) + } +} + +func TestHealth_NoWebUI_NoWebMember(t *testing.T) { + srv := testServer(t) + rr := doRequest(srv, http.MethodGet, "/api/v1/health", nil) + var resp map[string]interface{} + parseJSON(t, rr, &resp) + if _, ok := resp["web"]; ok { + t.Errorf("health reported a web bundle with no web UI set: %v", resp["web"]) + } +} diff --git a/scripts/install-refresh.sh b/scripts/install-refresh.sh index 51e7712c3..f9b70b0e7 100755 --- a/scripts/install-refresh.sh +++ b/scripts/install-refresh.sh @@ -893,3 +893,38 @@ note "server restarted and answering on: ${probe_hosts[*]} (port $PORT)" if [ -n "${unattributed:-}" ]; then note "listener ownership NOT verified: neither ss nor lsof can see listeners here, so the process answering was not confirmed to be the restart (pid $RESTART_PID)" fi + +# --- 7. Report WHICH web bundle is served, and warn when it is not a clean +# build of this commit (TASK-3233) --------------------------------------- +# The binary's commit stamp says nothing about web/build: it is generated, +# gitignored and embedded as it stands, so a bundle built from uncommitted +# edits (a negative-control build, BUG-3129) or left over from an older +# commit ships under a clean commit stamp. The server digests the bundle it +# embeds and reports it in /api/v1/health, with the source stamp `vite build` +# writes (commit, and entries `git status` listed under web/). This step only +# WARNS: the restart has already happened, and the fix is to rebuild web and +# refresh again. A server older than TASK-3233 reports no `web` member. +warn() { printf 'install-refresh: WARNING: %s\n' "$*" >&2; } +health_json="$(curl -fsS -m 2 "$(probe_url "${probe_hosts[0]}" "$PORT")" 2>/dev/null || true)" +web_json="$(printf '%s' "$health_json" | sed -n 's/.*"web":{\([^}]*\)}.*/\1/p')" +if [ -z "$web_json" ]; then + note "served web bundle: not reported (the server predates TASK-3233, or has no web UI)" +else + web_sha="$(printf '%s' "$web_json" | sed -n 's/.*"sha256":"\([0-9a-f]*\)".*/\1/p')" + web_commit="$(printf '%s' "$web_json" | sed -n 's/.*"source_commit":"\([0-9a-f]*\)".*/\1/p')" + web_dirty="$(printf '%s' "$web_json" | sed -n 's/.*"source_dirty":\([0-9]*\).*/\1/p')" + note "serving web bundle sha256 ${web_sha:-?} (built from ${web_commit:-unknown commit}, web/ dirty ${web_dirty:-unknown})" + if [ -z "$web_commit" ]; then + warn "the web bundle carries no source stamp, so where it was built from is unknown (built without git, or before TASK-3233)." + elif [ -n "$EXPECT_COMMIT" ] && ! commit_matches "$EXPECT_COMMIT" "$web_commit"; then + # A bundle stamped with an older commit is correct when web/ did not + # change in between (a backend-only commit on top). Warn only when it + # did, or when that cannot be established. + if ! git diff --quiet "$web_commit" "$EXPECT_COMMIT" -- web/ 2>/dev/null; then + warn "the web bundle was built at ${web_commit}, and web/ differs between that commit and ${EXPECT_COMMIT} (or that cannot be checked here): web/build is stale. Rebuild web (vite build) and refresh again." + fi + fi + if [ -n "$web_dirty" ] && [ "$web_dirty" != "0" ]; then + warn "the web bundle was built while web/ had ${web_dirty} uncommitted change(s): it is not a build of any commit. Rebuild web from a clean tree and refresh again." + fi +fi diff --git a/web/.gitignore b/web/.gitignore index 746718de7..18d3422b1 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -26,3 +26,6 @@ vite.config.ts.timestamp-* /playwright-report /test-results /e2e/.auth + +# Build source stamp written by vite build (TASK-3233); copied into build/. +/static/build-source.json diff --git a/web/src/lib/build/buildSourceStamp.test.ts b/web/src/lib/build/buildSourceStamp.test.ts new file mode 100644 index 000000000..755264bd8 --- /dev/null +++ b/web/src/lib/build/buildSourceStamp.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { buildSourceStamp, type GitRunner } from './buildSourceStamp'; + +function runner(outputs: Record): GitRunner { + return (args) => { + const out = outputs[args[0]]; + if (out instanceof Error) throw out; + if (out === undefined) throw new Error(`unexpected git ${args.join(' ')}`); + return out; + }; +} + +describe('buildSourceStamp (TASK-3233)', () => { + it('a clean tree stamps the commit and zero', () => { + expect(buildSourceStamp(runner({ 'rev-parse': 'abc123\n', status: '' }))).toEqual({ commit: 'abc123', dirty: 0 }); + }); + + it('counts each porcelain entry under web/', () => { + const status = ' M src/lib/components/items/CopyItemDialog.svelte\n?? src/new.ts\n'; + expect(buildSourceStamp(runner({ 'rev-parse': 'abc123', status }))).toEqual({ commit: 'abc123', dirty: 2 }); + }); + + it('no git: both fields null (unknown, never clean)', () => { + expect(buildSourceStamp(runner({ 'rev-parse': new Error('not a git repository') }))).toEqual({ + commit: null, + dirty: null + }); + }); + + it('a failing status leaves dirty unknown, not zero', () => { + expect(buildSourceStamp(runner({ 'rev-parse': 'abc123', status: new Error('boom') }))).toEqual({ + commit: 'abc123', + dirty: null + }); + }); +}); diff --git a/web/src/lib/build/buildSourceStamp.ts b/web/src/lib/build/buildSourceStamp.ts new file mode 100644 index 000000000..0eb8e9d88 --- /dev/null +++ b/web/src/lib/build/buildSourceStamp.ts @@ -0,0 +1,41 @@ +// The web bundle's source stamp (TASK-3233): which commit `vite build` ran at, +// and how many entries `git status` listed under web/ at that moment. The +// server reads it out of the embedded bundle and reports it in +// /api/v1/health next to the served bundle's own digest, so a bundle built +// from uncommitted edits (a negative-control build, BUG-3129) or left over +// from another commit is visible after it ships under a clean commit stamp. +// +// It is written into static/ at buildStart, which SvelteKit copies into the +// build output. It never fails a build: with no git (a Nix sandbox, a +// tarball) both fields are null, which reads as "unknown", never as clean. + +export const BUILD_SOURCE_FILE = 'build-source.json'; + +export interface BuildSource { + commit: string | null; + dirty: number | null; +} + +/** Runs git with the given args in the web/ directory and returns stdout. */ +export type GitRunner = (args: string[]) => string; + +export function buildSourceStamp(git: GitRunner): BuildSource { + let commit: string | null = null; + let dirty: number | null = null; + try { + commit = git(['rev-parse', 'HEAD']).trim() || null; + } catch { + commit = null; + } + if (commit !== null) { + try { + // `-- .` scopes to web/. Ignored paths (node_modules, build, + // .svelte-kit, this stamp) do not count. + const out = git(['status', '--porcelain', '--', '.']); + dirty = out.split('\n').filter((l) => l.trim() !== '').length; + } catch { + dirty = null; + } + } + return { commit, dirty }; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index a98c1038e..e45180b1b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,29 @@ import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { BUILD_SOURCE_FILE, buildSourceStamp } from './src/lib/build/buildSourceStamp'; + +const WEB_DIR = fileURLToPath(new URL('.', import.meta.url)); + +// Stamp the bundle with where it was built from (TASK-3233); see +// buildSourceStamp.ts. Build only: the dev server never needs it. +function buildSource(): Plugin { + return { + name: 'pad-build-source', + apply: 'build', + buildStart() { + const stamp = buildSourceStamp((args) => + execFileSync('git', args, { cwd: WEB_DIR, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + ); + writeFileSync(new URL(`./static/${BUILD_SOURCE_FILE}`, import.meta.url), JSON.stringify(stamp) + '\n'); + } + }; +} export default defineConfig({ - plugins: [sveltekit()], + plugins: [buildSource(), sveltekit()], server: { proxy: { '/api': {