ai-code-battle/cmd/acb-index-builder/sitebuild.go
jedarden 88bd70640a fix(types): add missing ReplayPlayer import and type annotation for transcript feature
- Add ReplayPlayer to type imports in replay-viewer.ts
- Add explicit type annotation for entry parameter in replay.ts transcript map
- Fixes TypeScript compilation errors for §15.3 screen reader transcript feature
2026-04-22 18:20:56 -04:00

219 lines
6.6 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
var (
craneConfigDir = "/tmp/crane-config"
siteBuildDigestFile = ".site-build-digest"
siteBuildExtractDir = "/tmp/acb-site-build"
bakedInWebDist = "/app/web/dist"
)
// initCraneAuth writes a Docker config.json for crane to authenticate with
// the container registry. No-op if registry auth is not configured.
func initCraneAuth(cfg *Config) error {
if cfg.RegistryUsername == "" || cfg.SiteBuildImage == "" {
return nil
}
if err := os.MkdirAll(craneConfigDir, 0700); err != nil {
return fmt.Errorf("create crane config dir: %w", err)
}
registry := extractRegistry(cfg.SiteBuildImage)
auth := base64.StdEncoding.EncodeToString([]byte(cfg.RegistryUsername + ":" + cfg.RegistryPassword))
config := map[string]interface{}{
"auths": map[string]interface{}{
registry: map[string]string{"auth": auth},
},
}
data, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("marshal docker config: %w", err)
}
return os.WriteFile(filepath.Join(craneConfigDir, "config.json"), data, 0600)
}
// craneEnviron returns the process environment with DOCKER_CONFIG set if auth
// was configured.
func craneEnviron() []string {
env := os.Environ()
if _, err := os.Stat(filepath.Join(craneConfigDir, "config.json")); err == nil {
env = append(env, "DOCKER_CONFIG="+craneConfigDir)
}
return env
}
// syncSiteBuild checks for a newer site build image in the container registry
// and extracts it if available. Returns the path to the web assets directory
// and whether the site build changed (new image extracted or baked-in used
// for the first time). Falls back to baked-in assets when the registry is
// unreachable or crane is not installed.
func syncSiteBuild(ctx context.Context, cfg *Config) (string, bool) {
if cfg.SiteBuildImage == "" {
return bakedInWebDist, false
}
if _, err := exec.LookPath("crane"); err != nil {
slog.Warn("crane not found in PATH, using baked-in web assets")
return bakedInWebDist, false
}
remoteDigest, err := craneDigest(ctx, cfg)
if err != nil {
slog.Warn("Failed to query remote site build digest, using cached or baked-in assets", "error", err)
return fallbackWebDir(cfg), false
}
cachedDigest := readCachedDigest(cfg.OutputDir)
if cachedDigest == remoteDigest {
slog.Debug("Site build image unchanged", "digest", remoteDigest)
return extractedDistPath(cfg), false
}
slog.Info("New site build image detected",
"image", cfg.SiteBuildImage,
"old_digest", cachedDigest,
"new_digest", remoteDigest,
)
if err := craneExport(ctx, cfg); err != nil {
slog.Error("Failed to extract site build image", "error", err)
return fallbackWebDir(cfg), false
}
writeCachedDigest(cfg.OutputDir, remoteDigest)
return extractedDistPath(cfg), true
}
// extractedDistPath returns the path to the dist directory within the
// extraction staging area.
func extractedDistPath(cfg *Config) string {
return filepath.Join(siteBuildExtractDir, cfg.SiteBuildPath)
}
// craneDigest uses crane to get the digest of the configured site build image.
func craneDigest(ctx context.Context, cfg *Config) (string, error) {
cmd := exec.CommandContext(ctx, "crane", "digest", cfg.SiteBuildImage)
cmd.Env = craneEnviron()
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("crane digest %s: %w", cfg.SiteBuildImage, err)
}
return strings.TrimSpace(string(out)), nil
}
// craneExport uses crane to export the image filesystem and extracts it into
// the staging directory.
func craneExport(ctx context.Context, cfg *Config) error {
os.RemoveAll(siteBuildExtractDir)
if err := os.MkdirAll(siteBuildExtractDir, 0755); err != nil {
return fmt.Errorf("create extract dir: %w", err)
}
craneCmd := exec.CommandContext(ctx, "crane", "export", cfg.SiteBuildImage, "-")
craneCmd.Env = craneEnviron()
tarCmd := exec.CommandContext(ctx, "tar", "-xf", "-", "-C", siteBuildExtractDir)
pipe, err := craneCmd.StdoutPipe()
if err != nil {
return fmt.Errorf("crane pipe: %w", err)
}
tarCmd.Stdin = pipe
if err := craneCmd.Start(); err != nil {
return fmt.Errorf("start crane: %w", err)
}
if err := tarCmd.Run(); err != nil {
return fmt.Errorf("extract tar: %w", err)
}
if err := craneCmd.Wait(); err != nil {
return fmt.Errorf("crane export: %w", err)
}
slog.Info("Extracted site build image", "path", siteBuildExtractDir)
return nil
}
// fallbackWebDir returns the best available web asset directory when the
// registry is unreachable.
func fallbackWebDir(cfg *Config) string {
p := extractedDistPath(cfg)
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
slog.Info("Using previously extracted site build")
return p
}
if _, err := os.Stat(bakedInWebDist); err == nil {
slog.Info("Using baked-in web assets")
return bakedInWebDist
}
slog.Warn("No web assets available")
return bakedInWebDist
}
func readCachedDigest(outputDir string) string {
data, err := os.ReadFile(filepath.Join(outputDir, siteBuildDigestFile))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func writeCachedDigest(outputDir, digest string) {
if err := os.WriteFile(filepath.Join(outputDir, siteBuildDigestFile), []byte(digest+"\n"), 0644); err != nil {
slog.Warn("Failed to cache site build digest", "error", err)
}
}
// cleanStaleWebAssets removes old SPA files from the output directory when a
// new site build is detected. It preserves the generated data/ directory and
// the internal .site-build-digest tracking file. Without this cleanup, hashed
// JS/CSS files from previous Vite builds accumulate toward Pages' 20K file
// limit.
func cleanStaleWebAssets(cfg *Config) error {
entries, err := os.ReadDir(cfg.OutputDir)
if err != nil {
return fmt.Errorf("read output dir: %w", err)
}
for _, entry := range entries {
name := entry.Name()
// Preserve generated data files
if name == "data" {
continue
}
// Preserve internal tracking files
if name == siteBuildDigestFile {
continue
}
path := filepath.Join(cfg.OutputDir, name)
if err := os.RemoveAll(path); err != nil {
slog.Warn("Failed to remove stale asset", "path", path, "error", err)
} else {
slog.Debug("Removed stale web asset", "path", path)
}
}
slog.Info("Cleaned stale web assets from output directory")
return nil
}
// extractRegistry parses the registry host from an image reference.
// "forgejo.example.com/ns/image:tag" → "forgejo.example.com"
func extractRegistry(imageRef string) string {
parts := strings.SplitN(imageRef, "/", 2)
if len(parts) == 2 && strings.Contains(parts[0], ".") {
return parts[0]
}
return "https://index.docker.io/v1/"
}