Files
pad/internal/server/web_build_identity.go
T
xarmian bf0631d22f 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
2026-09-26 16:54:20 -04:00

87 lines
2.7 KiB
Go

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
}