mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
feat(ops): health reports which web bundle is served, and install-refresh warns when it is not a clean build of the commit (TASK-3233) (#1583)
The binary's commit stamp says nothing about web/build: it is generated,
gitignored and embedded as it stands. A bundle built from uncommitted edits
(a negative-control build, BUG-3129) or left over from an older commit
therefore ships under a clean commit stamp, and nothing afterwards could say
which bundle a server served.
- The server digests the web FS it embeds (sha256 over each file's path and
content hash) once in SetWebUI. /api/v1/health reports it as an additive
`web: {sha256, files, source_commit?, source_dirty?}`.
- `vite build` writes build-source.json (the commit, and the count of
`git status` entries under web/) into static/, which SvelteKit copies into
the bundle. It never fails a build: with no git, both fields are null
("unknown"), never clean. The stamp is gitignored.
- install-refresh.sh step 7 logs the served bundle and WARNS when the stamp
is missing, when it was built with web/ dirty, or when web/ changed
between the stamp's commit and the expected one. A backend-only commit on
top of a bundle is correct and is not flagged. It warns after the
restart; it does not prevent it.
Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "<html></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": "<html></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": "<html></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": "<html></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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSourceStamp, type GitRunner } from './buildSourceStamp';
|
||||
|
||||
function runner(outputs: Record<string, string | Error>): 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
+23
-2
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user