mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-22 18:24:21 +00:00
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
494 lines
16 KiB
Go
494 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var updateCmd = &cobra.Command{
|
|
Use: "update [target]",
|
|
Short: "Deploy updates to servers",
|
|
Args: cobra.MaximumNArgs(1),
|
|
ValidArgs: []string{"all", "appview", "hold", "scanner"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
target := "all"
|
|
if len(args) > 0 {
|
|
target = args[0]
|
|
}
|
|
withScanner, _ := cmd.Flags().GetBool("with-scanner")
|
|
withLabeler, _ := cmd.Flags().GetBool("with-labeler")
|
|
withBilling, _ := cmd.Flags().GetBool("with-billing")
|
|
skipBackup, _ := cmd.Flags().GetBool("skip-backup")
|
|
keep, _ := cmd.Flags().GetInt("backup-keep")
|
|
return cmdUpdate(target, withScanner, withLabeler, withBilling, updateOptions{SkipBackup: skipBackup, BackupKeep: keep})
|
|
},
|
|
}
|
|
|
|
var sshCmd = &cobra.Command{
|
|
Use: "ssh <target>",
|
|
Short: "SSH into a server",
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgs: []string{"appview", "hold", "scanner"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cmdSSH(args[0])
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
updateCmd.Flags().Bool("with-billing", false, "Compile Stripe billing into the appview (pkg/billing, `billing` build tag). Off by default: a deployment that runs without billing must not gain it because someone redeployed.")
|
|
updateCmd.Flags().Bool("with-scanner", false, "Enable the vulnerability scanner (its server is created by `provision --with-scanner`)")
|
|
updateCmd.Flags().Bool("with-labeler", false, "Enable and deploy content moderation labeler alongside appview")
|
|
updateCmd.Flags().Bool("skip-backup", false, "Do not snapshot the server's binaries, configs, units and appview database into <data dir>/predeploy-<stamp>/ before replacing them")
|
|
updateCmd.Flags().Int("backup-keep", 5, "How many predeploy-* backups to keep per server; older ones are pruned after a successful backup")
|
|
rootCmd.AddCommand(updateCmd)
|
|
rootCmd.AddCommand(sshCmd)
|
|
}
|
|
|
|
// updateOptions are the deploy knobs that are not about what gets built.
|
|
type updateOptions struct {
|
|
SkipBackup bool
|
|
BackupKeep int
|
|
}
|
|
|
|
func cmdUpdate(target string, withScanner, withLabeler, withBilling bool, opts updateOptions) error {
|
|
state, err := loadState()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
naming := state.Naming()
|
|
rootDir := projectRoot()
|
|
|
|
// Enable scanner retroactively via --with-scanner on update. The secret
|
|
// lands in the hold's config on this run; the scanner server itself is
|
|
// created by provision, which is the only command that creates servers.
|
|
if withScanner && !state.ScannerEnabled {
|
|
state.ScannerEnabled = true
|
|
if state.ScannerSecret == "" {
|
|
secret, err := generateScannerSecret()
|
|
if err != nil {
|
|
return fmt.Errorf("generate scanner secret: %w", err)
|
|
}
|
|
state.ScannerSecret = secret
|
|
fmt.Printf("Generated scanner shared secret\n")
|
|
}
|
|
_ = saveState(state)
|
|
if state.Scanner.UUID == "" {
|
|
fmt.Println("Scanner enabled. Run `provision --with-scanner` to create its server, then `update scanner` deploys to it.")
|
|
}
|
|
}
|
|
|
|
// Enable labeler retroactively via --with-labeler on update
|
|
if withLabeler && !state.LabelerEnabled {
|
|
state.LabelerEnabled = true
|
|
_ = saveState(state)
|
|
}
|
|
|
|
vals := configValsFromState(state)
|
|
|
|
// renderUnit is per target because the scanner unit carries memory limits
|
|
// the generic serviceUnitParams does not know about.
|
|
standardUnit := func(tmpl, binaryName, configPath, serviceName string) func() (string, error) {
|
|
return func() (string, error) {
|
|
return renderServiceUnit(tmpl, serviceUnitParams{
|
|
DisplayName: naming.DisplayName(),
|
|
User: naming.SystemUser(),
|
|
BinaryPath: naming.InstallDir() + "/bin/" + binaryName,
|
|
ConfigPath: configPath,
|
|
DataDir: naming.BasePath(),
|
|
ServiceName: serviceName,
|
|
})
|
|
}
|
|
}
|
|
|
|
targets := map[string]struct {
|
|
ip string
|
|
binaryName string
|
|
localBinary string
|
|
serviceName string
|
|
healthURL string
|
|
configTmpl string
|
|
configPath string
|
|
renderUnit func() (string, error)
|
|
setup string // extra remote shell run before restart; empty for none
|
|
}{
|
|
"appview": {
|
|
ip: state.Appview.PublicIP,
|
|
binaryName: naming.Appview(),
|
|
localBinary: "atcr-appview",
|
|
serviceName: naming.Appview(),
|
|
healthURL: "http://localhost:5000/health",
|
|
configTmpl: appviewConfigTmpl,
|
|
configPath: naming.AppviewConfigPath(),
|
|
renderUnit: standardUnit(appviewServiceTmpl, naming.Appview(), naming.AppviewConfigPath(), naming.Appview()),
|
|
},
|
|
"hold": {
|
|
ip: state.Hold.PublicIP,
|
|
binaryName: naming.Hold(),
|
|
localBinary: "atcr-hold",
|
|
serviceName: naming.Hold(),
|
|
healthURL: "http://localhost:8080/xrpc/_health",
|
|
configTmpl: holdConfigTmpl,
|
|
configPath: naming.HoldConfigPath(),
|
|
renderUnit: standardUnit(holdServiceTmpl, naming.Hold(), naming.HoldConfigPath(), naming.Hold()),
|
|
},
|
|
"scanner": {
|
|
ip: state.Scanner.PublicIP,
|
|
binaryName: naming.Scanner(),
|
|
localBinary: "atcr-scanner",
|
|
serviceName: naming.Scanner(),
|
|
healthURL: "http://localhost:9090/healthz",
|
|
configTmpl: scannerConfigTmpl,
|
|
configPath: naming.ScannerConfigPath(),
|
|
renderUnit: func() (string, error) {
|
|
return renderScannerServiceUnit(scannerUnitParams(naming))
|
|
},
|
|
setup: scannerDirsScript(naming),
|
|
},
|
|
}
|
|
|
|
var toUpdate []string
|
|
switch target {
|
|
case "all":
|
|
toUpdate = []string{"appview", "hold"}
|
|
if state.Scanner.UUID != "" {
|
|
toUpdate = append(toUpdate, "scanner")
|
|
}
|
|
case "appview", "hold":
|
|
toUpdate = []string{target}
|
|
case "scanner":
|
|
if state.Scanner.UUID == "" {
|
|
return fmt.Errorf("no scanner server in state.json; run `provision --with-scanner` to create one")
|
|
}
|
|
toUpdate = []string{target}
|
|
default:
|
|
return fmt.Errorf("unknown target: %s (use: all, appview, hold, scanner)", target)
|
|
}
|
|
|
|
// Build all binaries via `make build-trixie` so output links against
|
|
// glibc 2.41 (the deploy target's glibc) regardless of the host's glibc.
|
|
// build-trixie depends on the `generate` make target, which runs
|
|
// `go generate ./...` on the host before invoking the trixie container.
|
|
if err := runMakeBuildTrixie(rootDir, withBilling); err != nil {
|
|
return fmt.Errorf("build: %w", err)
|
|
}
|
|
|
|
// Check what was actually produced, not what was asked for. The build tag
|
|
// is invisible in the binary's name and in its version stamp, so a wrong
|
|
// one is only discovered in production — as happened here, where a deploy
|
|
// silently compiled billing into a deployment running without it.
|
|
if slices.Contains(toUpdate, "appview") {
|
|
if err := verifyAppviewBilling(filepath.Join(rootDir, "bin", "atcr-appview"), withBilling); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Deploy each target
|
|
for _, name := range toUpdate {
|
|
t := targets[name]
|
|
fmt.Printf("\nDeploying %s (%s)...\n", name, t.ip)
|
|
|
|
// Snapshot what is about to be replaced. The upload below deletes the
|
|
// remote binary before copying the new one, so without this there is
|
|
// nothing to roll back to if the deploy fails half way.
|
|
if opts.SkipBackup {
|
|
fmt.Printf(" backup: skipped (--skip-backup)\n")
|
|
} else {
|
|
spec := backupSpecFor(name, naming, state, time.Now(), opts.BackupKeep)
|
|
dir, err := preDeployBackup(t.ip, spec)
|
|
if err != nil {
|
|
return fmt.Errorf("%s pre-deploy backup: %w", name, err)
|
|
}
|
|
fmt.Printf(" backup: %s (restore by copying bin/, etc/, systemd/ back and restarting)\n", dir)
|
|
}
|
|
|
|
// Sync config keys (adds missing keys from template, never overwrites)
|
|
configYAML, err := renderConfig(t.configTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render %s config: %w", name, err)
|
|
}
|
|
if err := syncConfigKeys(name, t.ip, t.configPath, configYAML, false); err != nil {
|
|
return fmt.Errorf("%s config sync: %w", name, err)
|
|
}
|
|
|
|
// Sync systemd service unit
|
|
renderedUnit, err := t.renderUnit()
|
|
if err != nil {
|
|
return fmt.Errorf("render %s service unit: %w", name, err)
|
|
}
|
|
unitChanged, err := syncServiceUnit(name, t.ip, t.serviceName, renderedUnit)
|
|
if err != nil {
|
|
return fmt.Errorf("%s service unit sync: %w", name, err)
|
|
}
|
|
|
|
// Upload binary
|
|
localPath := filepath.Join(rootDir, "bin", t.localBinary)
|
|
remotePath := naming.InstallDir() + "/bin/" + t.binaryName
|
|
if err := scpFile(localPath, t.ip, remotePath); err != nil {
|
|
return fmt.Errorf("upload %s: %w", name, err)
|
|
}
|
|
|
|
daemonReload := ""
|
|
if unitChanged {
|
|
daemonReload = "systemctl daemon-reload"
|
|
}
|
|
|
|
if t.setup != "" {
|
|
if _, err := runSSH(t.ip, t.setup, false); err != nil {
|
|
return fmt.Errorf("%s setup: %w", name, err)
|
|
}
|
|
}
|
|
|
|
// Labeler additions for appview server
|
|
labelerRestart := ""
|
|
labelerHealthCheck := ""
|
|
if name == "appview" && state.LabelerEnabled {
|
|
// Sync labeler config keys
|
|
labelerConfigYAML, err := renderConfig(labelerConfigTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render labeler config: %w", err)
|
|
}
|
|
if err := syncConfigKeys("labeler", t.ip, naming.LabelerConfigPath(), labelerConfigYAML, false); err != nil {
|
|
return fmt.Errorf("labeler config sync: %w", err)
|
|
}
|
|
|
|
// Sync labeler service unit
|
|
labelerUnit, err := renderLabelerServiceUnit(labelerServiceUnitParams{
|
|
DisplayName: naming.DisplayName(),
|
|
User: naming.SystemUser(),
|
|
BinaryPath: naming.InstallDir() + "/bin/" + naming.Labeler(),
|
|
ConfigPath: naming.LabelerConfigPath(),
|
|
DataDir: naming.BasePath(),
|
|
ServiceName: naming.Labeler(),
|
|
AppviewServiceName: naming.Appview(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("render labeler service unit: %w", err)
|
|
}
|
|
labelerUnitChanged, err := syncServiceUnit("labeler", t.ip, naming.Labeler(), labelerUnit)
|
|
if err != nil {
|
|
return fmt.Errorf("labeler service unit sync: %w", err)
|
|
}
|
|
if labelerUnitChanged {
|
|
daemonReload = "systemctl daemon-reload"
|
|
}
|
|
|
|
// Upload labeler binary
|
|
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
|
|
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
|
|
if err := scpFile(labelerLocal, t.ip, labelerRemote); err != nil {
|
|
return fmt.Errorf("upload labeler: %w", err)
|
|
}
|
|
|
|
// Ensure labeler data dirs exist
|
|
labelerSetup := fmt.Sprintf(`mkdir -p %s
|
|
chown -R %s:%s %s`,
|
|
naming.LabelerDataDir(),
|
|
naming.SystemUser(), naming.SystemUser(), naming.LabelerDataDir())
|
|
if _, err := runSSH(t.ip, labelerSetup, false); err != nil {
|
|
return fmt.Errorf("labeler dir setup: %w", err)
|
|
}
|
|
|
|
labelerRestart = fmt.Sprintf("\nsystemctl restart %s", naming.Labeler())
|
|
labelerHealthCheck = renderHealthProbe("labeler", "http://localhost:5002/.well-known/did.json", healthProbeTries)
|
|
}
|
|
|
|
// Restart services, then poll each one's health URL for up to 30s.
|
|
// Every restarted service is probed, the labeler included: a service
|
|
// that crash-loops on its config is invisible to systemctl's exit
|
|
// status and only shows up as a health URL that never answers.
|
|
restartScript := fmt.Sprintf(`set -euo pipefail
|
|
%s
|
|
systemctl restart %s%s
|
|
%s%s`, daemonReload, t.serviceName, labelerRestart,
|
|
renderHealthProbe(name, t.healthURL, healthProbeTries), labelerHealthCheck)
|
|
|
|
output, err := runSSH(t.ip, restartScript, true)
|
|
if err != nil {
|
|
fmt.Printf(" ERROR: %v\n", err)
|
|
fmt.Printf(" Output: %s\n", output)
|
|
return fmt.Errorf("restart %s failed", name)
|
|
}
|
|
if !probeMarkersPresent(output) {
|
|
fmt.Printf(" %s: restarted, but the health probes never ran\n", name)
|
|
}
|
|
|
|
results := parseProbes(output)
|
|
reportProbe(results, name, t.ip, t.serviceName)
|
|
if name == "appview" && state.LabelerEnabled {
|
|
reportProbe(results, "labeler", t.ip, naming.Labeler())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// configValsFromState builds ConfigValues from persisted state.
|
|
// S3SecretKey is intentionally left empty — syncConfigKeys only adds missing
|
|
// keys and never overwrites, so the server's existing secret is preserved.
|
|
func configValsFromState(state *InfraState) *ConfigValues {
|
|
naming := state.Naming()
|
|
_, baseDomain, _, _ := extractFromAppviewTemplate()
|
|
holdDomain := state.Zone + ".cove." + baseDomain
|
|
labelerDomain := "labeler." + baseDomain
|
|
|
|
return &ConfigValues{
|
|
S3Endpoint: state.ObjectStorage.Endpoint,
|
|
S3Region: state.ObjectStorage.Region,
|
|
S3Bucket: state.ObjectStorage.Bucket,
|
|
S3AccessKey: state.ObjectStorage.AccessKeyID,
|
|
S3SecretKey: "", // not persisted in state; existing value on server is preserved
|
|
Zone: state.Zone,
|
|
HoldDomain: holdDomain,
|
|
HoldDid: "did:web:" + holdDomain,
|
|
LabelerDomain: labelerDomain,
|
|
BasePath: naming.BasePath(),
|
|
ScannerSecret: state.ScannerSecret,
|
|
ScannerHoldURL: scannerHoldURL(state.Hold.PrivateIP),
|
|
}
|
|
}
|
|
|
|
// runMakeBuildTrixie shells out to `make build-trixie`, which builds all
|
|
// production binaries (appview, hold, credential-helper, labeler, scanner)
|
|
// inside a Debian 13 container so they link against glibc 2.41. Centralizing
|
|
// the build recipe in the Makefile keeps deploy and local builds in sync.
|
|
func runMakeBuildTrixie(rootDir string, withBilling bool) error {
|
|
fmt.Printf("Running `make build-trixie` (linux/amd64, glibc 2.41, billing: %s)...\n", billingLabel(withBilling))
|
|
args := []string{"build-trixie"}
|
|
if withBilling {
|
|
args = append(args, "BILLING=1")
|
|
}
|
|
cmd := exec.Command("make", args...)
|
|
cmd.Dir = rootDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// scpFile uploads a local file to a remote server via SCP.
|
|
// Removes the remote file first to avoid ETXTBSY when overwriting a running binary.
|
|
func scpFile(localPath, ip, remotePath string) error {
|
|
fmt.Printf(" uploading %s → %s:%s\n", filepath.Base(localPath), ip, remotePath)
|
|
_, _ = runSSH(ip, fmt.Sprintf("rm -f %s", remotePath), false)
|
|
cmd := exec.Command("scp",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
localPath,
|
|
"root@"+ip+":"+remotePath,
|
|
)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func cmdSSH(target string) error {
|
|
state, err := loadState()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var ip string
|
|
switch target {
|
|
case "appview":
|
|
ip = state.Appview.PublicIP
|
|
case "hold":
|
|
ip = state.Hold.PublicIP
|
|
case "scanner":
|
|
ip = state.Scanner.PublicIP
|
|
if ip == "" {
|
|
return fmt.Errorf("no scanner server in state.json; run `provision --with-scanner` to create one")
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown target: %s (use: appview, hold, scanner)", target)
|
|
}
|
|
|
|
fmt.Printf("Connecting to %s (%s)...\n", target, ip)
|
|
cmd := exec.Command("ssh",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"root@"+ip,
|
|
)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
func runSSH(ip, script string, stream bool) (string, error) {
|
|
cmd := exec.Command("ssh",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
"root@"+ip,
|
|
"bash -s",
|
|
)
|
|
cmd.Stdin = strings.NewReader(script)
|
|
|
|
var buf bytes.Buffer
|
|
if stream {
|
|
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
|
cmd.Stderr = io.MultiWriter(os.Stderr, &buf)
|
|
} else {
|
|
cmd.Stdout = &buf
|
|
cmd.Stderr = &buf
|
|
}
|
|
|
|
// Give deploys up to 5 minutes (SCP + restart, much faster than remote builds)
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Run() }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
return buf.String(), err
|
|
case <-time.After(5 * time.Minute):
|
|
_ = cmd.Process.Kill()
|
|
return buf.String(), fmt.Errorf("SSH command timed out after 5 minutes")
|
|
}
|
|
}
|
|
|
|
func billingLabel(on bool) string {
|
|
if on {
|
|
return "ENABLED"
|
|
}
|
|
return "disabled"
|
|
}
|
|
|
|
// verifyAppviewBilling refuses to ship an appview whose billing state is not
|
|
// the one asked for.
|
|
//
|
|
// It reads the built binary rather than trusting the flag, because the two can
|
|
// disagree for reasons the flag cannot see: a stale bin/atcr-appview left by an
|
|
// earlier build, a Makefile that hardcodes the tag, a builder image that
|
|
// ignored it. The Stripe SDK is only linked when the `billing` tag is set, so
|
|
// its symbols are a direct observation of which build this is.
|
|
//
|
|
// Getting this wrong is not cosmetic. Compiling billing into a deployment that
|
|
// runs without it exposes a live Stripe webhook endpoint and a paid tier ladder
|
|
// on a service whose operator chose neither.
|
|
func verifyAppviewBilling(binPath string, want bool) error {
|
|
data, err := os.ReadFile(binPath)
|
|
if err != nil {
|
|
return fmt.Errorf("verify appview billing: %w", err)
|
|
}
|
|
got := bytes.Contains(data, []byte("stripe-go"))
|
|
if got != want {
|
|
return fmt.Errorf(
|
|
"appview billing mismatch: asked for %s, built binary has billing %s.\n"+
|
|
" %s carries %s Stripe symbols. Rebuild before deploying:\n"+
|
|
" make build-trixie%s",
|
|
billingLabel(want), billingLabel(got),
|
|
binPath, map[bool]string{true: "", false: "no"}[got],
|
|
map[bool]string{true: " BILLING=1", false: ""}[want],
|
|
)
|
|
}
|
|
fmt.Printf(" appview billing verified: %s\n", billingLabel(got))
|
|
return nil
|
|
}
|