Files
at-container-registry/deploy/upcloud/backup.go
T
Evan JarrettandClaude Fable 5.1 83092d9aee deploy: give the scanner its own server, and stop the tool from undoing production fixes on provision
The scanner shared the hold's 1 GB host and thrashed it twice: 11 hours on
2026-09-12 and again on the 13th (644 MiB resident plus 1.9 GB of swap, 504
on every repo page). It is memory-bound, not CPU-bound, so it now gets a
dedicated STARTER-2xCPU-4GB server: own state entry, own plan flag (pinned
name, shape match if UpCloud renames the tier again, picker last), own
cloud-init, firewall, `update scanner`, `ssh scanner`, status, backup and
teardown. Its config reaches the hold over the private network and its unit
sets MemorySwapMax=0 so an overshoot is an OOM kill and a restart, not a
wedged host. The hold's cloud-init and update paths no longer carry it.

Three defects the first provision run exposed, all fixed here:

- Frontend HTTP/2 defaulted to on and was reconciled onto the LB every run.
  Re-enabling it on the 12th stranded the appview<->hold connections for
  25 minutes. Default is now off and reconciled off, with a guard test.
- The TLS step requested Let's Encrypt bundles for every registry domain,
  re-adding the .cr ones that were removed when those moved behind Bunny.
  It now skips any domain whose DNS does not resolve to the LB.
- Each prompt built its own bufio.Scanner on stdin, so the first swallowed
  every piped answer and the second read EOF and took the default, which
  re-ran cloud-init on the production hold. One shared reader, and no answer
  now means skip.

Also: STARTER- plans take standard storage (maxiops fails with TIER_INVALID),
and the cloud-init wait polls for up to 20 minutes instead of one SSH call
capped at five, which a first boot with npm exceeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hho5da4daoCoPBJ9tCrL7s
2026-09-12 21:49:26 -05:00

126 lines
4.9 KiB
Go

package main
import (
"fmt"
"strings"
"time"
)
// backupSpec describes what a pre-deploy backup captures on one server.
// Everything the update step is about to replace is copied first, so a bad
// deploy can be reverted by copying the files back and restarting.
type backupSpec struct {
BaseDir string // parent for predeploy-* directories, e.g. /var/lib/seamark
Stamp string // UTC timestamp used in the directory name
Binaries []string // absolute paths; missing files are skipped
Configs []string // absolute paths; missing files are skipped
Units []string // systemd service names, without .service
SQLiteDB string // optional database to snapshot with sqlite3 .backup
Keep int // how many predeploy-* directories to keep, including this one
}
// Dir is the backup directory this spec writes to.
func (s backupSpec) Dir() string {
return s.BaseDir + "/predeploy-" + s.Stamp
}
// backupStamp names a backup directory the same way earlier manual backups
// did (predeploy-20260903-005611), so old and new ones sort together.
func backupStamp(now time.Time) string {
return now.UTC().Format("20060102-150405")
}
// renderBackupScript produces the bash run on the server. It never fails on a
// file that is not there (a fresh server has no previous binary), but it does
// fail if a copy or the database snapshot fails, which aborts the deploy
// before anything is replaced.
func renderBackupScript(s backupSpec) string {
var b strings.Builder
dir := s.Dir()
fmt.Fprintf(&b, "set -euo pipefail\nDIR=%q\nmkdir -p \"$DIR/bin\" \"$DIR/etc\" \"$DIR/systemd\"\n", dir)
for _, bin := range s.Binaries {
// The vcs.revision stamp Go embeds is the only record of which commit a
// binary came from; keep it next to the copy so a rollback is
// identifiable without a go toolchain on the server.
fmt.Fprintf(&b, "if [ -f %q ]; then cp -p %q \"$DIR/bin/\"; printf '%%s %%s\\n' %q \"$(grep -aoE 'vcs.revision=[0-9a-f]+' %q | head -1 || true)\" >> \"$DIR/REVISIONS\"; fi\n",
bin, bin, bin, bin)
}
for _, cfg := range s.Configs {
fmt.Fprintf(&b, "if [ -f %q ]; then cp -p %q \"$DIR/etc/\"; fi\n", cfg, cfg)
}
for _, unit := range s.Units {
path := "/etc/systemd/system/" + unit + ".service"
fmt.Fprintf(&b, "if [ -f %q ]; then cp -p %q \"$DIR/systemd/\"; fi\n", path, path)
}
if s.SQLiteDB != "" {
// sqlite3 .backup takes a consistent snapshot of a live WAL database.
// Without the CLI, fall back to copying the file set and say so: the
// copy is only consistent if the service is quiet, which it is not
// guaranteed to be.
fmt.Fprintf(&b, `if [ -f %[1]q ]; then
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 %[1]q ".backup '$DIR/$(basename %[1]q)'"
else
echo "WARN: sqlite3 not installed, copying database files raw"
cp -p %[1]q %[1]q-wal %[1]q-shm "$DIR/" 2>/dev/null || cp -p %[1]q "$DIR/"
fi
fi
`, s.SQLiteDB)
}
if s.Keep > 0 {
// Prune oldest first, never the one just written.
fmt.Fprintf(&b, "ls -1d %q/predeploy-* 2>/dev/null | sort | head -n -%d | xargs -r rm -rf\n", s.BaseDir, s.Keep)
}
b.WriteString("du -sh \"$DIR\" | cut -f1\necho \"BACKUP_OK $DIR\"\n")
return b.String()
}
// preDeployBackup runs the backup on the server and returns the directory it
// wrote. A backup that does not report BACKUP_OK is treated as a failure.
func preDeployBackup(ip string, s backupSpec) (string, error) {
out, err := runSSH(ip, renderBackupScript(s), false)
if err != nil {
return "", fmt.Errorf("%w\n%s", err, strings.TrimSpace(out))
}
if !strings.Contains(out, "BACKUP_OK") {
return "", fmt.Errorf("backup did not complete:\n%s", strings.TrimSpace(out))
}
return s.Dir(), nil
}
// backupSpecFor lists what a deploy of target replaces on its server: the
// service binary, its config and unit, plus the companion service that ships
// with it (labeler with appview). The scanner is its own target on its own
// server. The appview's SQLite database is snapshotted too because migrations
// run on the next boot.
func backupSpecFor(target string, naming Naming, state *InfraState, now time.Time, keep int) backupSpec {
s := backupSpec{
BaseDir: naming.BasePath(),
Stamp: backupStamp(now),
Keep: keep,
}
bin := naming.InstallDir() + "/bin/"
switch target {
case "appview":
s.Binaries = []string{bin + naming.Appview()}
s.Configs = []string{naming.AppviewConfigPath()}
s.Units = []string{naming.Appview()}
s.SQLiteDB = naming.BasePath() + "/ui.db"
if state.LabelerEnabled {
s.Binaries = append(s.Binaries, bin+naming.Labeler())
s.Configs = append(s.Configs, naming.LabelerConfigPath())
s.Units = append(s.Units, naming.Labeler())
}
case "hold":
s.Binaries = []string{bin + naming.Hold()}
s.Configs = []string{naming.HoldConfigPath()}
s.Units = []string{naming.Hold()}
case "scanner":
s.Binaries = []string{bin + naming.Scanner()}
s.Configs = []string{naming.ScannerConfigPath()}
s.Units = []string{naming.Scanner()}
}
return s
}