diff --git a/.changeset/ftw-command.md b/.changeset/ftw-command.md new file mode 100644 index 000000000..51a001c75 --- /dev/null +++ b/.changeset/ftw-command.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +A native install now has the `ftw` command on the machine: `ftw status`, `ftw update`, `ftw rollback`, `ftw backup` and `ftw support`. Updates on a native install are run there, by hand or from the owner's own automation; `ftw update` asks no questions, waits through the restart and exits 0 when the box is current. The web version panel on a native install shows the running version, a published release and the command, without update, rollback, channel or backup controls, and setup no longer offers an update. A native update that is slow to start is no longer reported as failed after five minutes. Starting a second Core on a port FTW already holds says that FTW is already running. diff --git a/.github/workflows/core-binaries.yml b/.github/workflows/core-binaries.yml index 5c1eda2ef..39e7d43bd 100644 --- a/.github/workflows/core-binaries.yml +++ b/.github/workflows/core-binaries.yml @@ -73,7 +73,7 @@ jobs: - name: Verify pure Go build and absence of DuckDB run: | set -euo pipefail - for binary in bin/linux/ftw bin/linux/ftw-backup bin/linux/ftw-launcher; do + for binary in bin/linux/ftw bin/linux/ftw-backup bin/linux/ftw-cli bin/linux/ftw-launcher; do go version -m "$binary" | tee "$binary.buildinfo" if grep -F 'github.com/duckdb/' "$binary.buildinfo"; then exit 1; fi grep -F 'CGO_ENABLED=0' "$binary.buildinfo" @@ -90,6 +90,7 @@ jobs: sh -ec ' /binaries/ftw -h /binaries/ftw-launcher -h + /binaries/ftw-cli help | grep -F "ftw update" set +e output=$(/binaries/ftw-backup 2>&1) status=$? diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index aef5238c0..c85f75909 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -40,6 +40,18 @@ Choose **Download**, save the `.ftwbak` file on another computer or USB disk, and keep at least one older known-good copy. **Verify** rechecks the server copy; it does not prove that a download exists elsewhere. +A native install has no backup controls in the web UI. On the machine, run: + +```bash +ftw backup --output-dir /media/usb/ftw-backups +``` + +It waits for Core's verified archive, copies it to the directory and compares +size and SHA-256 before the copy gets its final name. Without `--output-dir` +the archive stays only in Core's backup directory on the same disk. Keeping a +copy somewhere else is the owner's step: point `--output-dir` at another +disk, or copy the file off the machine. + From another computer, [`scripts/ftwctl.py`](../scripts/ftwctl.py) can do all three steps in one command: create the archive, wait for Core's verification, then download it and compare SHA-256 before naming the local file. Use an SSH diff --git a/docs/self-update.md b/docs/self-update.md index 30f356217..d5920413c 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -92,25 +92,31 @@ commit. Beta and stable contain different embedded version strings, so each package has its own hash and receipt. A tag, green CI run or published package alone is not field validation. -On a native site, Update Center can download and verify a 0.x package, create -a mandatory local settings/config rollback point, stage the new slot and -restart through the launcher. A trial only becomes current after readiness; -a failed trial falls back to the previous Core. A local rollback point does -not include history and stays on the same disk. A history-format change needs -a full backup made before the update. See -[full backup and restore](backup-and-restore.md). - -The same operator CLI can show the native path from a terminal: +On a native site the owner runs updates on the machine, by hand or from their +own timer or agent. The installer puts the `ftw` command on `PATH`: ```bash -python3 scripts/ftwctl.py --url http://127.0.0.1:18080 update --channel beta +ftw status # version, published release, last update, health +ftw update # install the next release on the saved channel +ftw update --channel stable # change the channel first +ftw rollback # return to the previous release ``` -It asks Core to update through its normal API and follows the local rollback -point, download, restart and health result. If Core says a full backup is -required, pass `--backup-dir ~/FTW-backups` so the CLI first creates, verifies -and downloads one to this computer. The `update` command refuses old 1.x, -2.x and 3.x installs before changing their channel. +`ftw update` asks Core to save a local settings/config rollback point, +download and verify the 0.x package, stage the new slot and restart through +the launcher. It prints each phase, waits through the restart and reports the +version and health that result. A trial only becomes current after readiness; +a failed trial falls back to the previous Core, and `ftw update` names the +Core that runs. Already current exits 0, so a script can run the step +unattended; a failed step exits 1. The same steps are Core API calls. A +release that changes stored data (the state schema) cannot be installed +natively yet; `ftw update` stops before it changes anything. + +`ftw rollback` returns to the previous release when it reads the same data. +The web UI on a native install shows the running version, a published +release and the command; it has no update controls. See +[ADR 0007](adr/0007-self-updating-binary.md) and +[full backup and restore](backup-and-restore.md). Core and the compiled Energyplan worker ship in one package. Core validates plans and keeps its Go fallback. Signed Lua drivers follow their own beta and diff --git a/go/cmd/ftw-cli/main.go b/go/cmd/ftw-cli/main.go new file mode 100644 index 000000000..2bc935293 --- /dev/null +++ b/go/cmd/ftw-cli/main.go @@ -0,0 +1,13 @@ +// Command ftw-cli is installed on PATH as `ftw`, the operator command for a +// native install. Core itself is releases//ftw, started by the launcher. +package main + +import ( + "os" + + "github.com/srcfl/ftw/go/internal/ftwcli" +) + +func main() { + os.Exit(ftwcli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/go/cmd/ftw/listen_error.go b/go/cmd/ftw/listen_error.go new file mode 100644 index 000000000..c38c97706 --- /dev/null +++ b/go/cmd/ftw/listen_error.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "syscall" + "time" +) + +// explainBindError names the usual reason the HTTP port is taken: FTW is +// already running on this machine, most often as the service. +func explainBindError(addr string, bindErr error) error { + if !errors.Is(bindErr, syscall.EADDRINUSE) { + return fmt.Errorf("HTTP port %s could not be opened: %w", addr, bindErr) + } + _, port, err := net.SplitHostPort(addr) + if err != nil { + port = strings.TrimPrefix(addr, ":") + } + if !answersAsFTW(port) { + return fmt.Errorf("port %s is in use by a program that does not answer as FTW; stop it or give FTW another api.port", port) + } + check := "ftw status" + if port != "8080" { + check += " --url http://127.0.0.1:" + port + } + return fmt.Errorf("FTW is already running on this machine at http://127.0.0.1:%s, so this copy did not start. Check it with: %s", port, check) +} + +// answersAsFTW asks /api/health, which a Core still opening its state also +// answers. +func answersAsFTW(port string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port+"/api/health", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + var health struct { + Status string `json:"status"` + } + return json.NewDecoder(resp.Body).Decode(&health) == nil && health.Status != "" +} diff --git a/go/cmd/ftw/listen_error_test.go b/go/cmd/ftw/listen_error_test.go new file mode 100644 index 000000000..b8b6e2395 --- /dev/null +++ b/go/cmd/ftw/listen_error_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "syscall" + "testing" +) + +func TestExplainBindErrorNamesATakenPortThatIsNotFTW(t *testing.T) { + err := explainBindError("127.0.0.1:1", &net.OpError{Err: syscall.EADDRINUSE}) + if err == nil || !strings.Contains(err.Error(), "does not answer as FTW") || !strings.Contains(err.Error(), "api.port") { + t.Fatal(err) + } +} + +func TestExplainBindErrorRecognisesAStartingFTW(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health" { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte(`{"phase":"initializing state","status":"starting"}`)) + })) + defer srv.Close() + _, port, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://")) + err := explainBindError(":"+port, &net.OpError{Err: syscall.EADDRINUSE}) + if err == nil || !strings.Contains(err.Error(), "FTW is already running") || + !strings.Contains(err.Error(), "ftw status --url http://127.0.0.1:"+port) { + t.Fatal(err) + } +} + +func TestExplainBindErrorKeepsOtherFailures(t *testing.T) { + cause := &net.OpError{Err: syscall.EACCES} + if err := explainBindError(":80", cause); !errors.Is(err, syscall.EACCES) { + t.Fatal(err) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 0e96943c3..285b5e148 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -464,7 +464,7 @@ func main() { ) listener, err := net.Listen("tcp", httpSrv.Addr) if err != nil { - slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", err) + slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", explainBindError(httpSrv.Addr, err)) os.Exit(1) } go func() { @@ -2359,6 +2359,7 @@ func main() { SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), StatusPath: statusPath, NativeRoot: nativeRoot, + NativeTrialTimeout: nativeTrialTimeout, NativeRestart: func() error { restartOnce.Do(func() { reexecAfterShutdown, exitCode = false, 1 diff --git a/go/internal/ftwcli/backup.go b/go/internal/ftwcli/backup.go new file mode 100644 index 000000000..9870314cb --- /dev/null +++ b/go/internal/ftwcli/backup.go @@ -0,0 +1,256 @@ +package ftwcli + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type backupEntry struct { + ID string `json:"id"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + Verified bool `json:"verified"` +} + +type backupProgress struct { + Phase string `json:"phase"` + CompletedBytes int64 `json:"completed_bytes"` + TotalBytes int64 `json:"total_bytes"` + Table string `json:"table"` + RowsDone int64 `json:"rows_done"` +} + +func (p backupProgress) line() string { + parts := []string{strings.ReplaceAll(p.Phase, "_", " ")} + if p.Table != "" { + parts = append(parts, p.Table) + } + if p.RowsDone > 0 { + parts = append(parts, fmt.Sprintf("%d rows", p.RowsDone)) + } + if p.TotalBytes > 0 { + parts = append(parts, formatBytes(p.CompletedBytes)+" of "+formatBytes(p.TotalBytes)) + } else if p.CompletedBytes > 0 { + parts = append(parts, formatBytes(p.CompletedBytes)+", total unknown") + } + return strings.Join(parts, " ") +} + +func runBackup(args []string, out io.Writer, e env) error { + var outputDir string + base, err := parse(args, out, "ftw backup [--output-dir DIR] [--url URL]", func(fs *flag.FlagSet) { + fs.StringVar(&outputDir, "output-dir", "", "") + }) + if err != nil { + return err + } + c := newClient(base, e) + ctx := context.Background() + fmt.Fprintln(out, "Making a full backup. Core keeps running; Ctrl-C stops the backup.") + start := e.now() + + type result struct { + body struct { + Warning string `json:"warning"` + Backup backupEntry `json:"backup"` + } + err error + } + done := make(chan result, 1) + go func() { + var r result + // No per-call timeout: Core answers only when the archive is verified. + r.err = c.call(ctx, http.MethodPost, "/api/backups", map[string]any{}, &r.body, 0) + done <- r + }() + var dir, last string + lastPrinted := start + for { + select { + case r := <-done: + if r.err != nil { + return fmt.Errorf("backup failed: %w", r.err) + } + if r.body.Warning != "" { + fmt.Fprintln(out, "Warning:", r.body.Warning) + } + b := r.body.Backup + if !b.Verified || !validBackupID(b.ID) || !validDigest(b.SHA256) { + return errors.New("Core did not return a verified backup") + } + if dir == "" { + var list struct { + Dir string `json:"dir"` + } + _ = c.get(ctx, "/api/backups", &list) + dir = list.Dir + } + where := b.ID + if dir != "" { + where = filepath.Join(dir, b.ID) + } + fmt.Fprintf(out, "Backup on the box: %s (%s, SHA-256 %s) after %s\n", + where, formatBytes(b.SizeBytes), b.SHA256, formatElapsed(e.now().Sub(start))) + if outputDir == "" { + return nil + } + return c.copyBackup(ctx, out, outputDir, b) + case <-time.After(e.pollInterval): + } + var list struct { + Dir string `json:"dir"` + Progress backupProgress `json:"progress"` + } + if c.get(ctx, "/api/backups", &list) != nil || list.Progress.Phase == "" { + continue + } + dir = list.Dir + now := e.now() + if line := list.Progress.line(); line != last || now.Sub(lastPrinted) >= e.heartbeat { + fmt.Fprintf(out, "[%s] %s\n", formatElapsed(now.Sub(start)), line) + last, lastPrinted = line, now + } + } +} + +// copyBackup copies a verified archive to dir and checks size and SHA-256 +// before the file gets its final name. +func (c *client) copyBackup(ctx context.Context, out io.Writer, dir string, b backupEntry) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + target := filepath.Join(dir, b.ID) + if sum, err := fileSHA256(target); err == nil { + if sum != b.SHA256 { + return fmt.Errorf("%s already exists and differs from Core's backup", target) + } + fmt.Fprintf(out, "Already saved: %s\n", target) + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + pending := target + ".part" + // A copy interrupted earlier leaves this name behind; it is ours. + if err := os.Remove(pending); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + resp, err := c.open(ctx, "/api/backups/"+b.ID) + if err != nil { + return err + } + defer resp.Body.Close() + f, err := os.OpenFile(pending, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + sum := sha256.New() + n, err := io.Copy(io.MultiWriter(f, sum), resp.Body) + if err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil && (hex.EncodeToString(sum.Sum(nil)) != b.SHA256 || (b.SizeBytes > 0 && n != b.SizeBytes)) { + err = errors.New("the copy does not match Core's verified size and SHA-256") + } + if err == nil { + err = os.Rename(pending, target) + } + if err != nil { + _ = os.Remove(pending) + return err + } + syncDir(dir) + fmt.Fprintf(out, "Copied and checked: %s\n", target) + return nil +} + +func runSupport(args []string, out io.Writer, e env) error { + var output string + base, err := parse(args, out, "ftw support [--output FILE] [--url URL]", func(fs *flag.FlagSet) { + fs.StringVar(&output, "output", "", "") + }) + if err != nil { + return err + } + if output == "" { + output = "ftw-support-" + e.now().Format("20060102-150405") + ".zip" + } + c := newClient(base, e) + ctx, cancel := context.WithTimeout(context.Background(), 2*e.requestTimeout) + defer cancel() + resp, err := c.open(ctx, "/api/support/dump") + if err != nil { + return err + } + defer resp.Body.Close() + f, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + n, err := io.Copy(f, resp.Body) + if err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + // Core streams the zip; a cut stream leaves an unreadable file. + var r *zip.ReadCloser + if r, err = zip.OpenReader(output); err == nil { + _ = r.Close() + } + } + if err != nil { + _ = os.Remove(output) + return fmt.Errorf("support file not written: %w", err) + } + fmt.Fprintf(out, "Support file: %s (%s)\nCore removes secrets from it; it still describes this site.\n", output, formatBytes(n)) + return nil +} + +func validBackupID(id string) bool { + return strings.HasPrefix(id, "ftw-full-backup-") && strings.HasSuffix(id, ".ftwbak") && + !strings.ContainsAny(id, `/\`) && !strings.Contains(id, "..") +} + +func validDigest(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil && strings.ToLower(s) == s +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + sum := sha256.New() + if _, err := io.Copy(sum, f); err != nil { + return "", err + } + return hex.EncodeToString(sum.Sum(nil)), nil +} + +func syncDir(path string) { + if dir, err := os.Open(path); err == nil { + _ = dir.Sync() + _ = dir.Close() + } +} diff --git a/go/internal/ftwcli/cli.go b/go/internal/ftwcli/cli.go new file mode 100644 index 000000000..8f710b612 --- /dev/null +++ b/go/internal/ftwcli/cli.go @@ -0,0 +1,144 @@ +// Package ftwcli is the ftw operator command for a native install. It runs +// the owner's steps against the Core on this machine through Core's HTTP API +// and never starts Core itself. Starting, stopping and logs stay with +// systemd. +package ftwcli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + "time" +) + +const defaultURL = "http://127.0.0.1:8080" + +const usageText = `ftw runs FTW's operator steps against the Core on this machine. +It uses Core's local API and never starts Core itself. + +Usage: + ftw status version, releases, last update and health + ftw update [--channel beta|stable] install the next release; already current exits 0 + ftw rollback return to the previous release + ftw backup [--output-dir DIR] make a verified full backup; DIR gets a checked copy + ftw support [--output FILE] write the redacted support file + ftw help + +Every command takes --url URL (default http://127.0.0.1:8080). +Exit status: 0 done, 1 the step failed, 2 the command line was wrong. + +Starting, stopping and logs belong to systemd: + sudo systemctl restart ftw + journalctl -u ftw -n 100 +` + +const ( + exitOK = 0 + exitFailed = 1 + exitUsage = 2 +) + +type usageError string + +func (e usageError) Error() string { return string(e) } + +// env holds the clock and the limits, so tests can shorten them. +type env struct { + now func() time.Time + sleep func(time.Duration) + // requestTimeout bounds each API call except making and copying a backup. + requestTimeout time.Duration + // pollInterval is the gap between progress reads. + pollInterval time.Duration + // followLimit bounds waiting for an update or rollback. A native trial + // may take six hours to become ready. + followLimit time.Duration + // heartbeat repeats an unchanged progress line so a person or a log + // can see the command is still alive. + heartbeat time.Duration + // healthSettle is how long a new Core gets to read its devices before + // its health is reported. + healthSettle time.Duration +} + +func defaultEnv() env { + return env{ + now: time.Now, sleep: time.Sleep, + requestTimeout: 15 * time.Second, pollInterval: 2 * time.Second, + followLimit: 6*time.Hour + 15*time.Minute, heartbeat: 30 * time.Second, + healthSettle: 30 * time.Second, + } +} + +// Run executes one ftw command and returns its exit status. +func Run(args []string, stdout, stderr io.Writer) int { + return run(args, stdout, stderr, defaultEnv()) +} + +func run(args []string, stdout, stderr io.Writer, e env) int { + if len(args) == 0 { + fmt.Fprint(stdout, usageText) + return exitOK + } + var err error + switch args[0] { + case "help", "-h", "--help": + fmt.Fprint(stdout, usageText) + return exitOK + case "status": + err = runStatus(args[1:], stdout, e) + case "update": + err = runUpdate(args[1:], stdout, e) + case "rollback": + err = runRollback(args[1:], stdout, e) + case "backup": + err = runBackup(args[1:], stdout, e) + case "support": + err = runSupport(args[1:], stdout, e) + default: + err = usageError("unknown command " + args[0]) + } + return report(err, stderr) +} + +func report(err error, stderr io.Writer) int { + var usage usageError + switch { + case err == nil, errors.Is(err, flag.ErrHelp): + return exitOK + case errors.As(err, &usage): + fmt.Fprintf(stderr, "ftw: %s\nRun ftw help for the commands.\n", usage) + return exitUsage + default: + fmt.Fprintf(stderr, "ftw: %s\n", err) + return exitFailed + } +} + +// parse reads one command's flags; --name value and --name=value both work. +// It returns the Core URL. +func parse(args []string, stdout io.Writer, usage string, define func(*flag.FlagSet)) (string, error) { + fs := flag.NewFlagSet("ftw", flag.ContinueOnError) + fs.SetOutput(io.Discard) + url := fs.String("url", defaultURL, "") + if define != nil { + define(fs) + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + fmt.Fprintf(stdout, "usage: %s\n", usage) + return "", err + } + return "", usageError(err.Error() + "; usage: " + usage) + } + if fs.NArg() > 0 { + return "", usageError("unexpected argument " + fs.Arg(0) + "; usage: " + usage) + } + base := strings.TrimRight(*url, "/") + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + return "", usageError("--url must start with http:// or https://") + } + return base, nil +} diff --git a/go/internal/ftwcli/cli_test.go b/go/internal/ftwcli/cli_test.go new file mode 100644 index 000000000..444ec2d25 --- /dev/null +++ b/go/internal/ftwcli/cli_test.go @@ -0,0 +1,436 @@ +package ftwcli + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func testEnv() env { + return env{now: time.Now, sleep: time.Sleep, requestTimeout: time.Second, + pollInterval: time.Millisecond, followLimit: 5 * time.Second, heartbeat: time.Hour, + healthSettle: 20 * time.Millisecond} +} + +func runCLI(t *testing.T, e env, args ...string) (int, string, string) { + t.Helper() + var out, errOut strings.Builder + code := run(args, &out, &errOut, e) + return code, out.String(), errOut.String() +} + +// fakeCore answers the routes the CLI uses and records every POST. +type fakeCore struct { + mu sync.Mutex + routes map[string]http.HandlerFunc + posts []string + bodies []string +} + +func newFakeCore(t *testing.T) (*fakeCore, *httptest.Server) { + f := &fakeCore{routes: map[string]http.HandlerFunc{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + f.mu.Lock() + if r.Method == http.MethodPost { + body, _ := io.ReadAll(r.Body) + f.posts = append(f.posts, r.URL.Path) + f.bodies = append(f.bodies, string(body)) + } + handler := f.routes[key] + f.mu.Unlock() + if handler == nil { + t.Errorf("unexpected %s", key) + w.WriteHeader(http.StatusNotFound) + return + } + handler(w, r) + })) + t.Cleanup(srv.Close) + return f, srv +} + +func (f *fakeCore) on(method, path string, h http.HandlerFunc) { + f.mu.Lock() + defer f.mu.Unlock() + f.routes[method+" "+path] = h +} + +func (f *fakeCore) postedPaths() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.posts...) +} + +func reply(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + } +} + +// sequence answers each call with the next body and repeats the last one. +func sequence(bodies ...string) http.HandlerFunc { + var mu sync.Mutex + next := 0 + return func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + body := bodies[min(next, len(bodies)-1)] + next++ + mu.Unlock() + status := http.StatusOK + if strings.HasPrefix(body, "503 ") { + status, body = http.StatusServiceUnavailable, strings.TrimPrefix(body, "503 ") + } + reply(status, body)(w, r) + } +} + +const ( + oldCore = `{"current":"v0.132.1-beta.1","native":true,"channel":"beta","previous":"v0.132.0-beta.1"}` + offer = `{"current":"v0.132.1-beta.1","native":true,"channel":"beta","latest":"v0.132.2-beta.1","update_available":true}` + newCore = `{"current":"v0.132.2-beta.1","native":true,"channel":"beta","previous":"v0.132.1-beta.1"}` +) + +func TestHelpAndCommandLineErrors(t *testing.T) { + e := testEnv() + for _, args := range [][]string{nil, {"help"}, {"--help"}} { + if code, out, _ := runCLI(t, e, args...); code != exitOK || !strings.Contains(out, "ftw update [--channel beta|stable]") { + t.Fatalf("%v: %d\n%s", args, code, out) + } + } + if code, out, _ := runCLI(t, e, "update", "--help"); code != exitOK || !strings.Contains(out, "usage: ftw update") { + t.Fatalf("update --help: %d %s", code, out) + } + for _, args := range [][]string{ + {"doctor"}, {"update", "--channel", "nightly"}, {"update", "--channel"}, + {"update", "now"}, {"status", "--url", "127.0.0.1:8080"}, {"backup", "--port", "9090"}, + } { + if code, _, errOut := runCLI(t, e, args...); code != exitUsage || !strings.Contains(errOut, "ftw help") { + t.Fatalf("%v: exit %d, stderr %q", args, code, errOut) + } + } +} + +func TestUpdateRollsThroughTheRestart(t *testing.T) { + f, srv := newFakeCore(t) + var mu sync.Mutex + finished := false + f.on("GET", "/api/version/check", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch { + case finished: + reply(200, newCore)(w, r) + case r.URL.Query().Get("force") == "1": + reply(200, offer)(w, r) + default: + reply(200, oldCore)(w, r) + } + }) + f.on("GET", "/api/version/update/status", sequence( + `{"state":"done","action":"update","target":"v0.132.1-beta.1"}`, + `{"state":"starting","action":"update","target":"v0.132.2-beta.1","step":1,"total_steps":4,"message":"starting update"}`, + `{"state":"pulling","action":"update","target":"v0.132.2-beta.1","step":2,"total_steps":4,"message":"Downloading verified Core release","progress_current":10000000,"progress_total":21600000,"progress_unit":"bytes"}`, + `503 {"error":"starting"}`, + `503 {"error":"starting"}`, + `{"state":"done","action":"update","target":"v0.132.2-beta.1","step":4,"total_steps":4,"message":"Core is ready on v0.132.2-beta.1"}`, + )) + f.on("POST", "/api/version/update", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + finished = true + mu.Unlock() + reply(202, `{"status":"started","target":"v0.132.2-beta.1"}`)(w, r) + }) + f.on("GET", "/api/health", sequence(`{"status":"degraded","drivers_ok":2,"drivers_offline":1}`, `{"status":"ok","drivers_ok":3}`)) + + code, out, errOut := runCLI(t, testEnv(), "update", "--url", srv.URL) + if code != exitOK { + t.Fatalf("exit %d\n%s\n%s", code, out, errOut) + } + for _, want := range []string{ + "Updating v0.132.1-beta.1 -> v0.132.2-beta.1 on beta", + "2/4 Downloading verified Core release 10.0 MB of 21.6 MB", + "Core is restarting and not answering yet", + "Now running v0.132.2-beta.1 (was v0.132.1-beta.1)", + "Health: ok; drivers 3 ok, 0 degraded, 0 offline, 0 faulted.", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + if got := strings.Join(f.postedPaths(), ","); got != "/api/version/update" { + t.Fatalf("posts %s", got) + } +} + +func TestUpdateChangesTheChannelOnlyWhenAsked(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", reply(200, oldCore)) + f.on("GET", "/api/version/update/status", reply(200, `{"state":"idle"}`)) + f.on("POST", "/api/version/channel", reply(200, `{}`)) + + code, out, errOut := runCLI(t, testEnv(), "update", "--channel", "beta", "--url", srv.URL) + if code != exitOK || !strings.Contains(out, "Already current on beta: v0.132.1-beta.1") || len(f.postedPaths()) != 0 { + t.Fatalf("same channel: %d %v\n%s%s", code, f.postedPaths(), out, errOut) + } + code, out, errOut = runCLI(t, testEnv(), "update", "--channel=stable", "--url", srv.URL) + if code != exitOK || !strings.Contains(out, "Channel: beta -> stable") { + t.Fatalf("new channel: %d\n%s%s", code, out, errOut) + } + if f.postedPaths()[0] != "/api/version/channel" || f.bodies[0] != `{"channel":"stable"}` { + t.Fatalf("posted %v %v", f.posts, f.bodies) + } +} + +func TestUpdateRefusesACoreThatIsNotNative(t *testing.T) { + for _, check := range []http.HandlerFunc{ + reply(200, `{"current":"v2.3.2","native":false,"channel":"stable","update_available":true,"latest":"v2.3.3"}`), + reply(503, `{"error":"self-update disabled"}`), + } { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", check) + code, _, errOut := runCLI(t, testEnv(), "update", "--channel", "beta", "--url", srv.URL) + if code != exitFailed || !strings.Contains(errOut, "not a native install") || len(f.postedPaths()) != 0 { + t.Fatalf("exit %d posts %v stderr %q", code, f.postedPaths(), errOut) + } + } +} + +func TestUpdateStopsBeforeAStateSchemaChange(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("force") == "1" { + reply(200, `{"current":"v0.132.1-beta.1","native":true,"channel":"beta","latest":"v0.133.0-beta.1","update_available":true, + "full_backup_required":true,"current_state_schema":7,"target_state_schema":8}`)(w, r) + return + } + reply(200, oldCore)(w, r) + }) + f.on("GET", "/api/version/update/status", reply(200, `{"state":"idle"}`)) + code, _, errOut := runCLI(t, testEnv(), "update", "--url", srv.URL) + if code != exitFailed || !strings.Contains(errOut, "state schema 7 -> 8") || len(f.postedPaths()) != 0 { + t.Fatalf("exit %d posts %v stderr %q", code, f.postedPaths(), errOut) + } +} + +func TestUpdateFollowsARunThatIsAlreadyGoing(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", sequence(oldCore, newCore)) + f.on("GET", "/api/version/update/status", sequence( + `{"state":"pulling","action":"update","target":"v0.132.2-beta.1","message":"Downloading verified Core release"}`, + `{"state":"done","action":"update","target":"v0.132.2-beta.1","message":"Core is ready on v0.132.2-beta.1"}`, + )) + f.on("GET", "/api/health", reply(200, `{"status":"ok"}`)) + code, out, errOut := runCLI(t, testEnv(), "update", "--url", srv.URL) + if code != exitOK || !strings.Contains(out, "already running; following it") || len(f.postedPaths()) != 0 { + t.Fatalf("exit %d posts %v\n%s%s", code, f.postedPaths(), out, errOut) + } +} + +func TestUpdateReportsAFailedTrialAndWhatRuns(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("force") == "1" { + reply(200, offer)(w, r) + return + } + reply(200, oldCore)(w, r) + }) + f.on("GET", "/api/version/update/status", sequence( + `{"state":"idle"}`, + `{"state":"restarting","action":"update","target":"v0.132.2-beta.1"}`, + `{"state":"failed","action":"update","target":"v0.132.2-beta.1","message":"New Core did not reach readiness; previous Core is running"}`, + )) + f.on("POST", "/api/version/update", reply(202, `{"target":"v0.132.2-beta.1"}`)) + code, _, errOut := runCLI(t, testEnv(), "update", "--url", srv.URL) + if code != exitFailed || !strings.Contains(errOut, "did not reach readiness") || !strings.Contains(errOut, "Core v0.132.1-beta.1 is running") { + t.Fatalf("exit %d stderr %q", code, errOut) + } +} + +func TestUpdateGivesUpOnACoreThatNeverAnswers(t *testing.T) { + release := make(chan struct{}) + f, srv := newFakeCore(t) + t.Cleanup(func() { close(release) }) + hang := func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + } + f.on("GET", "/api/version/check", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("force") == "1" { + reply(200, offer)(w, r) + return + } + reply(200, oldCore)(w, r) + }) + f.on("POST", "/api/version/update", reply(202, `{"target":"v0.132.2-beta.1"}`)) + f.on("GET", "/api/version/update/status", hang) + f.on("GET", "/api/health", hang) + e := testEnv() + e.requestTimeout, e.followLimit = 50*time.Millisecond, 300*time.Millisecond + start := time.Now() + code, out, errOut := runCLI(t, e, "update", "--url", srv.URL) + if code != exitFailed || !strings.Contains(errOut, "no result after") || time.Since(start) > 3*time.Second { + t.Fatalf("exit %d after %s\n%s%s", code, time.Since(start), out, errOut) + } +} + +func TestRollbackReturnsToThePreviousRelease(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/version/check", sequence(newCore, `{"current":"v0.132.1-beta.1","native":true,"channel":"beta"}`)) + f.on("GET", "/api/version/update/status", sequence( + `{"state":"done","action":"update","target":"v0.132.2-beta.1"}`, + `{"state":"restarting","action":"rollback","target":"v0.132.1-beta.1","message":"Starting the previous Core once"}`, + `{"state":"done","action":"rollback","target":"v0.132.1-beta.1","message":"Core is ready on v0.132.1-beta.1"}`, + )) + f.on("POST", "/api/version/binary-rollback", reply(202, `{"status":"started","action":"rollback","target":"v0.132.1-beta.1"}`)) + f.on("GET", "/api/health", reply(200, `{"status":"degraded","drivers_offline":1}`)) + code, out, errOut := runCLI(t, testEnv(), "rollback", "--url", srv.URL) + if code != exitOK || !strings.Contains(out, "Returning v0.132.2-beta.1 -> v0.132.1-beta.1") || !strings.Contains(out, "Now running v0.132.1-beta.1") || + !strings.Contains(out, "Health is degraded: drivers 0 ok, 0 degraded, 1 offline") { + t.Fatalf("exit %d\n%s%s", code, out, errOut) + } + + f, srv = newFakeCore(t) + f.on("GET", "/api/version/check", reply(200, `{"current":"v0.132.1-beta.1","native":true,"channel":"beta"}`)) + if code, _, errOut := runCLI(t, testEnv(), "rollback", "--url", srv.URL); code != exitFailed || !strings.Contains(errOut, "no previous release") { + t.Fatalf("without previous: %d %q", code, errOut) + } +} + +func TestStatusShowsReleaseLastRunAndHealth(t *testing.T) { + f, srv := newFakeCore(t) + f.on("GET", "/api/health", reply(200, `{"status":"ok","drivers_ok":1,"history_storage":{"migration":{"state":"complete"},"writer":{"commit_failures":0}}}`)) + f.on("GET", "/api/version/check", reply(200, `{"current":"v0.132.1-beta.1","native":true,"channel":"beta","latest":"v0.132.2-beta.1","update_available":true,"previous":"v0.132.0-beta.1"}`)) + f.on("GET", "/api/version/update/status", reply(200, `{"state":"failed","action":"update","target":"v0.132.1-beta.1","message":"New Core did not reach readiness; previous Core is running"}`)) + code, out, _ := runCLI(t, testEnv(), "status", "--url", srv.URL) + for _, want := range []string{ + "Core: v0.132.1-beta.1, native, beta channel", + "Release: v0.132.2-beta.1 is published. Install it with: ftw update", + "Update: last update FAILED: New Core did not reach readiness", + "Previous: v0.132.0-beta.1 (ftw rollback returns to it)", + "Health: ok; drivers 1 ok", + "History: complete; 0 write failures", + "journalctl -u ftw", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + if code != exitOK { + t.Fatalf("exit %d", code) + } + + f.on("GET", "/api/health", reply(200, `{"status":"degraded","drivers_offline":1}`)) + if code, _, errOut := runCLI(t, testEnv(), "status", "--url", srv.URL); code != exitFailed || !strings.Contains(errOut, "health is degraded") { + t.Fatalf("degraded: %d %q", code, errOut) + } + f.on("GET", "/api/health", reply(200, `{"status":"starting","phase":"initializing state"}`)) + if code, out, _ := runCLI(t, testEnv(), "status", "--url", srv.URL); code != exitFailed || !strings.Contains(out, "starting: initializing state") { + t.Fatalf("starting: %d %s", code, out) + } + f.on("GET", "/api/health", reply(200, `{"status":"ok"}`)) + f.on("GET", "/api/version/check", reply(503, `{"error":"self-update disabled"}`)) + f.on("GET", "/api/status", reply(200, `{"version":"v2.3.2"}`)) + if code, out, _ := runCLI(t, testEnv(), "status", "--url", srv.URL); code != exitOK || !strings.Contains(out, "v2.3.2, updates are managed outside FTW") { + t.Fatalf("self-update off: %d %s", code, out) + } + + srv.Close() + if code, out, _ := runCLI(t, testEnv(), "status", "--url", srv.URL); code != exitFailed || !strings.Contains(out, "not answering") || !strings.Contains(out, "journalctl -u ftw") { + t.Fatalf("down: %d %s", code, out) + } +} + +func TestBackupCopiesOnlyAVerifiedMatch(t *testing.T) { + archive := []byte("verified archive bytes") + sum := sha256.Sum256(archive) + digest := hex.EncodeToString(sum[:]) + id := "ftw-full-backup-20260924T052200.199Z.ftwbak" + f, srv := newFakeCore(t) + created, _ := json.Marshal(map[string]any{"backup": map[string]any{"id": id, "sha256": digest, "size_bytes": len(archive), "verified": true}}) + f.on("POST", "/api/backups", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(20 * time.Millisecond) + reply(201, string(created))(w, r) + }) + f.on("GET", "/api/backups", reply(200, `{"dir":"/var/lib/ftw/backups","progress":{"phase":"packing_archive","completed_bytes":5,"total_bytes":22}}`)) + served := archive + f.on("GET", "/api/backups/"+id, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(served) }) + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, id+".part"), []byte("interrupted"), 0o600); err != nil { + t.Fatal(err) + } + code, out, errOut := runCLI(t, testEnv(), "backup", "--output-dir", dir, "--url", srv.URL) + if code != exitOK || !strings.Contains(out, "Backup on the box: /var/lib/ftw/backups/"+id) || !strings.Contains(out, "Copied and checked") { + t.Fatalf("exit %d\n%s%s", code, out, errOut) + } + got, err := os.ReadFile(filepath.Join(dir, id)) + if err != nil || !bytes.Equal(got, archive) { + t.Fatalf("copy %q %v", got, err) + } + if info, _ := os.Stat(filepath.Join(dir, id)); info.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", info.Mode()) + } + if _, err := os.Stat(filepath.Join(dir, id+".part")); !os.IsNotExist(err) { + t.Fatalf("leftover part: %v", err) + } + if code, out, _ := runCLI(t, testEnv(), "backup", "--output-dir", dir, "--url", srv.URL); code != exitOK || !strings.Contains(out, "Already saved") { + t.Fatalf("second copy: %d %s", code, out) + } + + served = []byte("a different archive!!!") + other := t.TempDir() + if code, _, errOut := runCLI(t, testEnv(), "backup", "--output-dir", other, "--url", srv.URL); code != exitFailed || !strings.Contains(errOut, "does not match") { + t.Fatalf("mismatch: %d %q", code, errOut) + } + if entries, _ := os.ReadDir(other); len(entries) != 0 { + t.Fatalf("a mismatched copy left %v", entries) + } +} + +func TestSupportWritesAPrivateReadableZip(t *testing.T) { + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + w, _ := zw.Create("00-help-report.md") + _, _ = w.Write([]byte("# FTW help report\n")) + _ = zw.Close() + f, srv := newFakeCore(t) + served := archive.Bytes() + f.on("GET", "/api/support/dump", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(served) }) + + t.Chdir(t.TempDir()) + e := testEnv() + e.now = func() time.Time { return time.Date(2026, 9, 24, 7, 30, 0, 0, time.Local) } + code, out, errOut := runCLI(t, e, "support", "--url", srv.URL) + name := "ftw-support-20260924-073000.zip" + if code != exitOK || !strings.Contains(out, name) { + t.Fatalf("exit %d\n%s%s", code, out, errOut) + } + if info, err := os.Stat(name); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("support file %v %v", info, err) + } + + served = served[:len(served)/2] + if code, _, errOut := runCLI(t, testEnv(), "support", "--output", "cut.zip", "--url", srv.URL); code != exitFailed || !strings.Contains(errOut, "not written") { + t.Fatalf("cut zip: %d %q", code, errOut) + } + if _, err := os.Stat("cut.zip"); !os.IsNotExist(err) { + t.Fatalf("cut zip kept: %v", err) + } +} diff --git a/go/internal/ftwcli/client.go b/go/internal/ftwcli/client.go new file mode 100644 index 000000000..becee7b75 --- /dev/null +++ b/go/internal/ftwcli/client.go @@ -0,0 +1,121 @@ +package ftwcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type client struct { + base string + env env + http *http.Client +} + +func newClient(base string, e env) *client { + // No client-wide timeout: each call sets its own, and copying a large + // backup must not be cut off by one. + return &client{base: base, env: e, http: &http.Client{}} +} + +// apiError is a non-2xx answer from Core. +type apiError struct { + method, path string + status int + message string + body []byte +} + +func (e *apiError) Error() string { + return fmt.Sprintf("%s %s: HTTP %d: %s", e.method, e.path, e.status, e.message) +} + +func (c *client) get(ctx context.Context, path string, dest any) error { + return c.call(ctx, http.MethodGet, path, nil, dest, c.env.requestTimeout) +} + +func (c *client) post(ctx context.Context, path string, dest any) error { + return c.call(ctx, http.MethodPost, path, map[string]any{}, dest, c.env.requestTimeout) +} + +// call sends one request. A zero timeout leaves only ctx to bound it. +func (c *client) call(ctx context.Context, method, path string, body, dest any, timeout time.Duration) error { + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return &apiError{method: method, path: path, status: resp.StatusCode, message: errorMessage(payload), body: payload} + } + if dest == nil || len(payload) == 0 { + return nil + } + return json.Unmarshal(payload, dest) +} + +func errorMessage(payload []byte) string { + var body struct { + Error string `json:"error"` + Err string `json:"err"` + } + if json.Unmarshal(payload, &body) == nil { + if body.Error != "" { + return body.Error + } + if body.Err != "" { + return body.Err + } + } + if text := strings.TrimSpace(string(payload)); text != "" { + return text + } + return "no reason given" +} + +// open starts a download of a large body; only ctx bounds it. +func (c *client) open(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + defer resp.Body.Close() + payload, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, &apiError{method: http.MethodGet, path: path, status: resp.StatusCode, message: errorMessage(payload)} + } + return resp, nil +} diff --git a/go/internal/ftwcli/status.go b/go/internal/ftwcli/status.go new file mode 100644 index 000000000..ffb71dbfc --- /dev/null +++ b/go/internal/ftwcli/status.go @@ -0,0 +1,224 @@ +package ftwcli + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type versionInfo struct { + Current string `json:"current"` + Native bool `json:"native"` + Previous string `json:"previous"` + Channel string `json:"channel"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + FullBackupRequired bool `json:"full_backup_required"` + CurrentStateSchema int `json:"current_state_schema"` + TargetStateSchema int `json:"target_state_schema"` + CheckedAt time.Time `json:"checked_at"` + Err string `json:"err"` +} + +type updateStatus struct { + State string `json:"state"` + Action string `json:"action"` + Target string `json:"target"` + Message string `json:"message"` + Step int `json:"step"` + TotalSteps int `json:"total_steps"` + ProgressCurrent int64 `json:"progress_current"` + ProgressTotal int64 `json:"progress_total"` + ProgressUnit string `json:"progress_unit"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (st updateStatus) inFlight() bool { + switch st.State { + case "starting", "snapshotting", "pulling", "restarting", "checking", "restoring": + return true + } + return false +} + +type health struct { + Status string `json:"status"` + Phase string `json:"phase"` + DriversOK int `json:"drivers_ok"` + DriversDegraded int `json:"drivers_degraded"` + DriversOffline int `json:"drivers_offline"` + DriversFaulted int `json:"drivers_faulted"` + History *struct { + Migration struct { + State string `json:"state"` + } `json:"migration"` + Writer struct { + CommitFailures uint64 `json:"commit_failures"` + } `json:"writer"` + } `json:"history_storage"` +} + +func (h health) drivers() string { + return fmt.Sprintf("drivers %d ok, %d degraded, %d offline, %d faulted", + h.DriversOK, h.DriversDegraded, h.DriversOffline, h.DriversFaulted) +} + +// selfUpdateOff reports Core's answer when it does not manage its own +// updates: a container, the Home Assistant add-on or a plain binary. +func selfUpdateOff(err error) bool { + var api *apiError + return errors.As(err, &api) && api.status == http.StatusServiceUnavailable +} + +func runStatus(args []string, out io.Writer, e env) error { + base, err := parse(args, out, "ftw status [--url URL]", nil) + if err != nil { + return err + } + c := newClient(base, e) + ctx := context.Background() + var h health + if err := c.get(ctx, "/api/health", &h); err != nil { + fmt.Fprintf(out, "Core: not answering at %s (%s)\n", base, err) + printNextSteps(out) + return errors.New("Core is not answering") + } + if h.Status == "starting" { + fmt.Fprintf(out, "Core: starting: %s\n", h.Phase) + printNextSteps(out) + return errors.New("Core is still starting") + } + + var info versionInfo + infoErr := c.get(ctx, "/api/version/check", &info) + switch { + case infoErr == nil && info.Native: + fmt.Fprintf(out, "Core: %s, native, %s channel\n", info.Current, info.Channel) + fmt.Fprintf(out, "Release: %s\n", releaseLine(info)) + var st updateStatus + if err := c.get(ctx, "/api/version/update/status", &st); err == nil { + if line := lastRunLine(st); line != "" { + fmt.Fprintf(out, "Update: %s\n", line) + } + } + if info.Previous != "" { + fmt.Fprintf(out, "Previous: %s (ftw rollback returns to it)\n", info.Previous) + } + case infoErr == nil: + fmt.Fprintf(out, "Core: %s, not a native install; ftw does not update it\n", info.Current) + case selfUpdateOff(infoErr): + var site struct { + Version string `json:"version"` + } + _ = c.get(ctx, "/api/status", &site) + fmt.Fprintf(out, "Core: %s, updates are managed outside FTW\n", orUnknown(site.Version)) + default: + fmt.Fprintf(out, "Core: version not readable (%s)\n", infoErr) + } + + fmt.Fprintf(out, "Health: %s; %s\n", h.Status, h.drivers()) + if h.History != nil { + fmt.Fprintf(out, "History: %s; %d write failures\n", orUnknown(h.History.Migration.State), h.History.Writer.CommitFailures) + } + printNextSteps(out) + if h.Status != "ok" { + return fmt.Errorf("health is %s", h.Status) + } + return nil +} + +func printNextSteps(out io.Writer) { + fmt.Fprintln(out, "Logs: journalctl -u ftw -n 100") + fmt.Fprintln(out, "Restart: sudo systemctl restart ftw") +} + +func releaseLine(info versionInfo) string { + checked := "" + if !info.CheckedAt.IsZero() { + checked = " (checked " + info.CheckedAt.Local().Format("Jan 2 15:04") + ")" + } + switch { + case info.Err != "": + return "check failed: " + info.Err + checked + case info.UpdateAvailable: + return info.Latest + " is published. Install it with: ftw update" + checked + default: + return "nothing newer on " + info.Channel + checked + } +} + +func lastRunLine(st updateStatus) string { + action := st.Action + if action == "" { + action = "update" + } + when := "" + if !st.UpdatedAt.IsZero() { + when = " (" + st.UpdatedAt.Local().Format("Jan 2 15:04") + ")" + } + switch { + case st.inFlight(): + return action + " running: " + progressLine(st) + case st.State == "done": + return "last " + action + " done: " + orUnknown(st.Message) + when + case st.State == "failed": + return "last " + action + " FAILED: " + orUnknown(st.Message) + when + } + return "" +} + +// progressLine describes one status without elapsed time, so an unchanged +// phase can be recognised. +func progressLine(st updateStatus) string { + parts := []string{} + if st.Step > 0 && st.TotalSteps > 0 { + parts = append(parts, fmt.Sprintf("%d/%d", st.Step, st.TotalSteps)) + } + if st.Message != "" { + parts = append(parts, st.Message) + } else { + parts = append(parts, st.State) + } + if st.ProgressUnit == "bytes" && st.ProgressCurrent > 0 { + if st.ProgressTotal > 0 { + parts = append(parts, formatBytes(st.ProgressCurrent)+" of "+formatBytes(st.ProgressTotal)) + } else { + parts = append(parts, formatBytes(st.ProgressCurrent)+", total unknown") + } + } + return strings.Join(parts, " ") +} + +func formatBytes(n int64) string { + switch { + case n >= 1_000_000_000: + return fmt.Sprintf("%.1f GB", float64(n)/1e9) + case n >= 1_000_000: + return fmt.Sprintf("%.1f MB", float64(n)/1e6) + case n >= 1_000: + return fmt.Sprintf("%.1f kB", float64(n)/1e3) + } + return fmt.Sprintf("%d B", n) +} + +func formatElapsed(d time.Duration) string { + d = d.Round(time.Second) + switch { + case d >= time.Hour: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + case d >= time.Minute: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%ds", int(d.Seconds())) +} + +func orUnknown(s string) string { + if s == "" { + return "unknown" + } + return s +} diff --git a/go/internal/ftwcli/update.go b/go/internal/ftwcli/update.go new file mode 100644 index 000000000..0294b0400 --- /dev/null +++ b/go/internal/ftwcli/update.go @@ -0,0 +1,209 @@ +package ftwcli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "time" +) + +const notNative = "this Core is not a native install; ftw only updates native FTW" + +func runUpdate(args []string, out io.Writer, e env) error { + var channel string + base, err := parse(args, out, "ftw update [--channel beta|stable] [--url URL]", func(fs *flag.FlagSet) { + fs.StringVar(&channel, "channel", "", "") + }) + if err != nil { + return err + } + if channel != "" && channel != "beta" && channel != "stable" { + return usageError("--channel must be beta or stable, not " + channel) + } + c := newClient(base, e) + ctx := context.Background() + + info, err := c.nativeInfo(ctx) + if err != nil { + return err + } + if st, err := c.status(ctx); err == nil && st.inFlight() { + fmt.Fprintf(out, "An %s to %s is already running; following it.\n", orUnknown(st.Action), orUnknown(st.Target)) + return c.finish(ctx, out, st.Action, st.Target, info.Current) + } + if channel != "" && channel != info.Channel { + body := map[string]string{"channel": channel} + if err := c.call(ctx, http.MethodPost, "/api/version/channel", body, nil, e.requestTimeout); err != nil { + return err + } + fmt.Fprintf(out, "Channel: %s -> %s\n", info.Channel, channel) + } + return c.update(ctx, out) +} + +func (c *client) update(ctx context.Context, out io.Writer) error { + var info versionInfo + if err := c.get(ctx, "/api/version/check?force=1", &info); err != nil { + return fmt.Errorf("release check failed: %w", err) + } + if info.Err != "" { + return errors.New("release check failed: " + info.Err) + } + if !info.UpdateAvailable { + fmt.Fprintf(out, "Already current on %s: %s\n", info.Channel, info.Current) + return nil + } + if info.FullBackupRequired { + return fmt.Errorf("%s changes stored data (state schema %d -> %d), which a native update cannot take yet; %s stays installed", + info.Latest, info.CurrentStateSchema, info.TargetStateSchema, info.Current) + } + fmt.Fprintf(out, "Updating %s -> %s on %s\n", info.Current, info.Latest, info.Channel) + var started struct { + Target string `json:"target"` + } + if err := c.post(ctx, "/api/version/update", &started); err != nil { + return err + } + if started.Target != info.Latest { + return fmt.Errorf("Core started an update to %s, not %s; check it with ftw status", orUnknown(started.Target), info.Latest) + } + return c.finish(ctx, out, "update", info.Latest, info.Current) +} + +func runRollback(args []string, out io.Writer, e env) error { + base, err := parse(args, out, "ftw rollback [--url URL]", nil) + if err != nil { + return err + } + c := newClient(base, e) + ctx := context.Background() + info, err := c.nativeInfo(ctx) + if err != nil { + return err + } + if info.Previous == "" { + return errors.New("there is no previous release that can read the current data") + } + if st, err := c.status(ctx); err == nil && st.inFlight() { + return fmt.Errorf("an %s to %s is running; wait for it, then run ftw status", orUnknown(st.Action), orUnknown(st.Target)) + } + fmt.Fprintf(out, "Returning %s -> %s\n", info.Current, info.Previous) + var started struct { + Target string `json:"target"` + } + if err := c.post(ctx, "/api/version/binary-rollback", &started); err != nil { + return err + } + if started.Target == "" { + started.Target = info.Previous + } + return c.finish(ctx, out, "rollback", started.Target, info.Current) +} + +// nativeInfo reads the cached release state and refuses a Core that does not +// update itself natively. +func (c *client) nativeInfo(ctx context.Context) (versionInfo, error) { + var info versionInfo + if err := c.get(ctx, "/api/version/check", &info); err != nil { + if selfUpdateOff(err) { + return info, errors.New(notNative) + } + return info, fmt.Errorf("Core is not answering at %s: %w", c.base, err) + } + if !info.Native { + return info, errors.New(notNative) + } + return info, nil +} + +func (c *client) status(ctx context.Context) (updateStatus, error) { + var st updateStatus + err := c.get(ctx, "/api/version/update/status", &st) + return st, err +} + +// finish waits for an update or rollback to end and checks what runs. +func (c *client) finish(ctx context.Context, out io.Writer, action, target, from string) error { + start := c.env.now() + st, err := c.follow(ctx, out, action, target, start) + if err != nil { + return err + } + if st.State == "failed" { + running := "unknown" + var info versionInfo + if c.get(ctx, "/api/version/check", &info) == nil { + running = info.Current + } + return fmt.Errorf("%s to %s failed: %s. Core %s is running; see ftw status", action, target, orUnknown(st.Message), running) + } + var info versionInfo + if err := c.get(ctx, "/api/version/check", &info); err != nil { + return fmt.Errorf("%s finished, but the running version is not readable: %w", action, err) + } + if info.Current != target { + return fmt.Errorf("%s finished, but Core reports %s instead of %s; see ftw status", action, info.Current, target) + } + fmt.Fprintf(out, "Now running %s (was %s) after %s.\n", info.Current, orUnknown(from), formatElapsed(c.env.now().Sub(start))) + c.reportHealth(ctx, out) + return nil +} + +// reportHealth gives a fresh Core a short while to read its devices again; +// readings from before the restart count as stale for a few seconds. +// Health is information here: the run itself has already succeeded. +func (c *client) reportHealth(ctx context.Context, out io.Writer) { + start := c.env.now() + for { + var h health + err := c.get(ctx, "/api/health", &h) + if err == nil && h.Status == "ok" { + fmt.Fprintf(out, "Health: ok; %s.\n", h.drivers()) + return + } + if c.env.now().Sub(start) >= c.env.healthSettle { + if err != nil { + fmt.Fprintf(out, "Health is not readable (%s). See ftw status.\n", err) + } else { + fmt.Fprintf(out, "Health is %s: %s. See ftw status.\n", h.Status, h.drivers()) + } + return + } + c.env.sleep(c.env.pollInterval) + } +} + +// follow polls Core until the run ends. While the next Core starts, Core +// does not answer or answers only /api/health; that is part of the run. +func (c *client) follow(ctx context.Context, out io.Writer, action, target string, start time.Time) (updateStatus, error) { + last, lastPrinted := "", time.Time{} + for { + now := c.env.now() + if now.Sub(start) > c.env.followLimit { + return updateStatus{}, fmt.Errorf("no result after %s; Core may still be working. Check it with ftw status", formatElapsed(now.Sub(start))) + } + st, err := c.status(ctx) + var line string + switch { + case err == nil && st.Target == target && st.Action == action && (st.State == "done" || st.State == "failed"): + return st, nil + case err == nil: + line = progressLine(st) + default: + var h health + if c.get(ctx, "/api/health", &h) == nil && h.Status == "starting" { + line = "the new Core is starting: " + orUnknown(h.Phase) + } else { + line = "Core is restarting and not answering yet" + } + } + if line != last || now.Sub(lastPrinted) >= c.env.heartbeat { + fmt.Fprintf(out, "[%s] %s\n", formatElapsed(now.Sub(start)), line) + last, lastPrinted = line, now + } + c.env.sleep(c.env.pollInterval) + } +} diff --git a/go/internal/selfupdate/selfupdate.go b/go/internal/selfupdate/selfupdate.go index 1e9ed3bff..7c8d036c2 100644 --- a/go/internal/selfupdate/selfupdate.go +++ b/go/internal/selfupdate/selfupdate.go @@ -135,6 +135,10 @@ type Config struct { NativeRoot string NativeRestart func() error NativeReleaseURL string // test override; empty uses the public GitHub release URL + // NativeTrialTimeout is how long a new native Core may take to become + // ready. The next Core writes no status before it is ready, so a native + // "restarting" status is not stale until this much time has passed. + NativeTrialTimeout time.Duration // Bus receives an events.UpdateAvailable event whenever Check // discovers a new, non-skipped release tag. Nil disables emission. Bus *events.Bus @@ -1105,6 +1109,9 @@ func (c *Checker) Status() UpdateStatus { } if isInFlightState(st.State) && !st.UpdatedAt.IsZero() { threshold := updateStatusStaleThreshold(st) + if st.State == "restarting" && c.cfg.NativeRoot != "" && c.cfg.NativeTrialTimeout > threshold { + threshold = c.cfg.NativeTrialTimeout + } if c.cfg.Now().Sub(st.UpdatedAt) > threshold { st.State = "failed" if st.Message == "" { diff --git a/go/internal/selfupdate/selfupdate_test.go b/go/internal/selfupdate/selfupdate_test.go index 678fe7dbb..260a79e51 100644 --- a/go/internal/selfupdate/selfupdate_test.go +++ b/go/internal/selfupdate/selfupdate_test.go @@ -766,6 +766,30 @@ func TestStatus_ReadsAndDetectsStale(t *testing.T) { } } +func TestStatus_NativeRestartLastsUntilTheTrialDeadline(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + now := time.Now() + c := New(Config{StatusPath: path, NativeRoot: t.TempDir(), NativeTrialTimeout: 6 * time.Hour, + Now: func() time.Time { return now }}, nil) + restarting := UpdateStatus{State: "restarting", Action: "update", Target: "v0.133.0", + PhaseStartedAt: now.Add(-20 * time.Minute), UpdatedAt: now.Add(-20 * time.Minute)} + + writeJSON(t, path, restarting) + if got := c.Status(); got.State != "restarting" { + t.Fatalf("20-minute native start = %q, want restarting", got.State) + } + restarting.UpdatedAt = now.Add(-6*time.Hour - time.Second) + writeJSON(t, path, restarting) + if got := c.Status(); got.State != "failed" { + t.Fatalf("native start past the trial deadline = %q, want failed", got.State) + } + restarting.State, restarting.UpdatedAt = "pulling", now.Add(-20*time.Minute) + writeJSON(t, path, restarting) + if got := c.Status(); got.State != "failed" { + t.Fatalf("silent native download = %q, want failed", got.State) + } +} + func TestStatus_AllowsSilentLegacyPullUntilItsDockerTimeout(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") now := time.Now() diff --git a/scripts/build-core.sh b/scripts/build-core.sh index 9fa8da71e..f1f3ca0c9 100644 --- a/scripts/build-core.sh +++ b/scripts/build-core.sh @@ -11,7 +11,7 @@ cd "$root/go" if [[ "${FTW_BUILD_ALL:-0}" == 1 ]]; then set -- ./... else - set -- ./cmd/ftw ./cmd/ftw-backup ./cmd/ftw-launcher + set -- ./cmd/ftw ./cmd/ftw-backup ./cmd/ftw-cli ./cmd/ftw-launcher fi go build -trimpath -tags=netgo,osusergo \ -ldflags "-s -w -X main.Version=${VERSION:-dev} -X main.CandidateTag=${CANDIDATE_TAG:-}" \ diff --git a/scripts/install.sh b/scripts/install.sh index 47ca4cd90..c57fab0f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -58,7 +58,7 @@ legacy_paths=( "$HOME/forty-two-watts/docker-compose.yml" ) if [[ "$mode" == --fresh-host ]]; then - existing_paths=(/opt/ftw /var/lib/ftw /etc/systemd/system/ftw.service + existing_paths=(/opt/ftw /var/lib/ftw /etc/systemd/system/ftw.service /usr/local/bin/ftw /etc/systemd/system/forty-two-watts.service "$pending" "${legacy_paths[@]}") else existing_paths=("${legacy_paths[@]}") @@ -199,6 +199,13 @@ as_root install -m 0755 "${work}/ftw-launcher" /opt/ftw/ftw-launcher as_root chown -R ftw:ftw /opt/ftw as_root /opt/ftw/ftw-launcher -root /opt/ftw init "$tag" as_root /opt/ftw/ftw-launcher -root /opt/ftw status >/dev/null +# The operator command. Root owns this copy, so the service account cannot +# replace a program that people may run with sudo. +operator_command=no +if as_root test -f "/opt/ftw/releases/${tag}/ftw-cli"; then + as_root install -m 0755 -o root -g root "/opt/ftw/releases/${tag}/ftw-cli" /usr/local/bin/ftw + operator_command=yes +fi as_root install -m 0644 \ "${stage}/releases/${tag}/deploy/ftw-native.service" \ /etc/systemd/system/ftw.service @@ -220,3 +227,7 @@ as_root rm "$pending" echo "Native FTW $tag is running. Open http://:8080/setup to finish setup." echo "Check the reported version, storage health and live device readings before use." +if [[ "$operator_command" == yes ]]; then + echo "On this machine, ftw status shows its state, ftw update installs a newer release" + echo "and ftw backup makes a verified backup. Run ftw help for the rest." +fi diff --git a/scripts/package-linux.py b/scripts/package-linux.py index 19a05a8cf..b094cdc61 100755 --- a/scripts/package-linux.py +++ b/scripts/package-linux.py @@ -20,6 +20,7 @@ "LICENSE", "NOTICE", "LICENSING.md", "THIRD-PARTY-NOTICES.txt", ) +BINARIES = ("ftw", "ftw-backup", "ftw-cli", "ftw-launcher") MACHINES = {"amd64": 62, "arm64": 183} ENERGYPLAN_DIR = "optimizer/native/bundle" # Use the reviewed verifier beside this helper, including when --root selects @@ -32,7 +33,7 @@ def package(root, binaries, output, arch): # Catch an accidentally reused host build before it reaches the release. - for name in ("ftw", "ftw-backup", "ftw-launcher"): + for name in BINARIES: with (binaries / name).open("rb") as source: header = source.read(20) if (len(header) != 20 or header[:6] != b"\x7fELF\x02\x01" @@ -73,7 +74,7 @@ def normalized(info): pending = Path(raw.name) with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: - for name in ("ftw", "ftw-backup", "ftw-launcher"): + for name in BINARIES: info = normalized(tar.gettarinfo(str(binaries / name), name)) info.mode = 0o755 with (binaries / name).open("rb") as binary: diff --git a/scripts/test_package_linux.py b/scripts/test_package_linux.py index 93da6dd2c..94606d93f 100644 --- a/scripts/test_package_linux.py +++ b/scripts/test_package_linux.py @@ -34,7 +34,7 @@ def setUp(self): (self.root / "state-schema.json").write_text('{"version": 7}') shutil.copytree(Path(__file__).resolve().parents[1] / packager.ENERGYPLAN_DIR, self.root / packager.ENERGYPLAN_DIR) - for name in ("ftw", "ftw-backup", "ftw-launcher"): + for name in packager.BINARIES: (self.binaries / name).write_bytes(b"\x7fELF\x02\x01" + bytes(12) + (62).to_bytes(2, "little")) def build(self, arch="amd64"): @@ -46,7 +46,7 @@ def test_only_target_solver_is_shipped_with_valid_metadata_and_notices(self): original_manifest = json.loads(original) for arch in ("amd64", "arm64"): with self.subTest(arch=arch): - for name in ("ftw", "ftw-backup", "ftw-launcher"): + for name in packager.BINARIES: (self.binaries / name).write_bytes( b"\x7fELF\x02\x01" + bytes(12) + packager.MACHINES[arch].to_bytes(2, "little")) archive = self.build(arch) @@ -69,7 +69,7 @@ def test_only_target_solver_is_shipped_with_valid_metadata_and_notices(self): def test_archive_has_runtime_backup_service_and_checksums(self): archive = self.build() with tarfile.open(archive) as tar: - for name in ("ftw", "ftw-backup", "ftw-launcher", "release-version.json", + for name in ("ftw", "ftw-backup", "ftw-cli", "ftw-launcher", "release-version.json", "web/index.html", "drivers/fixture.lua", "optimizer/native/bundle/manifest.json", "deploy/ftw.service", "LICENSE"): self.assertTrue(tar.getmember(name).isfile(), name) diff --git a/web/components/ftw-update-check.js b/web/components/ftw-update-check.js index 2f4a3f3d8..f8518dd35 100644 --- a/web/components/ftw-update-check.js +++ b/web/components/ftw-update-check.js @@ -13,6 +13,8 @@ // update_available && !skipped && sidecar_ready. sidecar_ready // is true when Docker's updater socket is reachable or a native // release slot is ready. Dev runs keep the banner hidden. +// On a native install the banner only names `ftw update`; the owner +// runs it on the machine (ADR 0007, decision 12). // 4. Update-now posts /api/version/update, opens an -based // progress overlay, polls /api/version/update/status, and // cache-busts reloads on `done`. Long phases keep polling while the @@ -70,6 +72,8 @@ class FtwUpdateCheck extends FtwElement { font-family: var(--mono, ui-monospace, monospace); color: var(--fg); } + .banner-hint { font-size: 0.8rem; color: var(--fg-dim); } + .banner-hint code { font-family: var(--mono, ui-monospace, monospace); color: var(--fg); } .banner-notes { font-size: 0.78rem; color: var(--accent-e); @@ -376,11 +380,12 @@ class FtwUpdateCheck extends FtwElement { // Banner is only useful when the full pull+restart flow is actionable. // sidecar_ready also means a native release slot is ready. Both paths // must be actionable before we offer the update button. + // A native banner only informs: the owner runs ftw update on the box. const showBanner = !!info && info.update_available && !info.skipped && - info.sidecar_ready === true && + (info.native === true || info.sidecar_ready === true) && this._phase === "idle"; // Toggle :host visibility so the element collapses when it has @@ -437,15 +442,21 @@ class FtwUpdateCheck extends FtwElement { ? `` : ""; + const actions = info.native + ? ` + ` + : ``; return ` `; } diff --git a/web/index.html b/web/index.html index f4c1484cd..969e6ed44 100644 --- a/web/index.html +++ b/web/index.html @@ -824,7 +824,7 @@

Plan decisions

More

Setup, settings and system

-

Configure FTW, check for updates or open advanced operational details.

+

Configure FTW, see its version or open advanced operational details.

diff --git a/web/update-badge.js b/web/update-badge.js index aafae3964..86370c53d 100644 --- a/web/update-badge.js +++ b/web/update-badge.js @@ -47,6 +47,7 @@ this._dialogRoot = null; this._storageOpen = false; this._info = null; // last /api/version/check payload + this._lastRun = null; // last finished update or rollback this._phase = "idle"; // idle | dialog | updating this._sidecarState = null; // last /api/version/update/status this._updateStartedAt = 0; @@ -97,6 +98,7 @@ .then((r) => (r.ok ? r.json() : null)) .then((st) => { if (!this.isConnected || generation !== this._resumeGeneration) return; + if (st && (st.state === "done" || st.state === "failed")) this._lastRun = st; if (!st || !isUpdateInFlight(st.state) || this._phase === "updating") return; const started = st.started_at ? Date.parse(st.started_at) : 0; this._phase = "updating"; @@ -187,9 +189,11 @@ this._render(); }); this._refresh(false); // surface the freshest info when opened + this._refreshComponents(false); + // A native box shows only its version; the rest feeds the old dialog. + if (this._info && this._info.native) return; this._refreshSnapshots(); // pull the list for the Snapshots accordion this._refreshBackups(); - this._refreshComponents(false); this._refreshComponentHistory(); this._refreshDriverCatalog(); } @@ -711,7 +715,8 @@ _pendingUpdates() { const info = this._info || {}; const core = !!(info.update_available && !info.skipped); - const drivers = this._driverEntries().filter((entry) => entry.pending_update).length; + // Native driver updates live in Settings › Devices, not in this panel. + const drivers = info.native ? 0 : this._driverEntries().filter((entry) => entry.pending_update).length; return { core, drivers, total: (core ? 1 : 0) + drivers }; } @@ -815,6 +820,8 @@ _modalHTML() { const info = this._info || {}; if (this._phase === "updating") return this._updatingModalHTML(); + if (!this._info) return this._versionLoadingHTML(); + if (info.native) return this._nativeVersionHTML(info); const hasUpdate = !!info.update_available; const pending = this._pendingUpdates(); @@ -907,6 +914,75 @@ `; } + // On a native install the owner runs updates on the machine, by hand or + // from their own automation (ADR 0007, decision 12). This panel reports; + // it has no update, rollback, channel or backup controls. + _nativeVersionHTML(info) { + const channel = info.channel ? ` on the ${escapeHTML(info.channel)} channel` : ""; + const checked = info.checked_at ? Date.parse(info.checked_at) : 0; + const checkedLine = checked > 0 + ? `Checked ${new Date(checked).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + : "Not checked yet."; + const notesHref = safeHref(info.release_notes_url); + const notes = notesHref + ? ` What's new ↗` + : ""; + let release; + if (info.update_available && info.full_backup_required) { + release = `

${escapeHTML(info.latest || "A newer release")} is published.${notes}

+

It changes stored data, which a native update cannot take yet. ${escapeHTML(info.current || "This release")} stays installed.

`; + } else if (info.update_available) { + release = `

${escapeHTML(info.latest || "A newer release")} is published.${notes}

+

On the machine that runs FTW, install it with:

+
ftw update
`; + } else { + release = `

Nothing newer is published${channel}.

`; + } + const run = this._lastRun || {}; + const action = run.action === "rollback" ? "rollback" : "update"; + let lastRun = ""; + if (run.state === "done") { + lastRun = `

Last ${action}: ${escapeHTML(run.message || "done")}

`; + } else if (run.state === "failed") { + lastRun = `

Last ${action} failed: ${escapeHTML(run.message || "no reason given")}

`; + } + return ` +
+ `; + } + + // Shown until the first version check answers, so a native box never + // flashes the old Updates dialog. + _versionLoadingHTML() { + return ` +
+ `; + } + // Core's channel includes the bundled Energyplan worker. _channelSectionHTML() { const info = this._info || {}; @@ -1711,6 +1787,15 @@ color: var(--red-e, #f87171); font-size: 0.85rem; } .dim { color: var(--fg-dim, #a0a0a0); font-size: 0.8rem; } + .cmd, code { font-family: var(--mono, ui-monospace, monospace); } + .cmd { + margin: 0.25rem 0 0.75rem; + padding: 0.5rem 0.7rem; + border: 1px solid var(--line, #2a2a2a); + border-radius: var(--radius-xs, 4px); + color: var(--fg, #e5e5e5); + user-select: all; + } .modal footer { display: flex; gap: 0.5rem; justify-content: flex-end; padding: 0.75rem 1rem; diff --git a/web/update-dialog.test.mjs b/web/update-dialog.test.mjs index cb916de7d..d11dcafc1 100644 --- a/web/update-dialog.test.mjs +++ b/web/update-dialog.test.mjs @@ -38,7 +38,7 @@ function fixture({ get } = {}) { document: { body, createElement: () => new Element() }, customElements: { define: (_, cls) => { Badge = cls; } }, CustomEvent: class {}, - window: { alert: message => alerts.push(message), confirm: () => true }, + window: { alert: message => alerts.push(message), confirm: () => true, location: { href: "http://127.0.0.1:8080/" } }, fetch: (url, options) => { requests.push({ url, options }); if (options?.method === "POST") return new Promise(resolve => { finish = resolve; }); @@ -51,6 +51,7 @@ function fixture({ get } = {}) { vm.runInNewContext(source, sandbox); const badge = new Badge(); badge._phase = "dialog"; + badge._info = {}; // a Core that is not native keeps the old dialog badge._backups = { enabled: true, backups: [], on_device: true }; badge._snapshots = { enabled: true, snapshots: [] }; badge._render(); @@ -75,26 +76,61 @@ test("the dialog and update progress render outside the header badge", () => { assert.equal(rig.body.children.length, 0, "closing removes the overlay"); }); -test("native beta shows the stable gap and offers binary rollback without online data restore", () => { +test("a native install reports its version and names ftw update, with no controls", () => { const rig = fixture(); rig.badge._info = { - native: true, current: "v0.131.0-beta.1", previous: "v0.130.4", - channel: "stable", update_available: false, + native: true, current: "v0.131.0-beta.1", previous: "v0.130.4", channel: "beta", + update_available: true, latest: "v0.131.0-beta.2", + release_notes_url: "https://github.com/srcfl/ftw/releases/tag/v0.131.0-beta.2", }; - rig.badge._components = { core: { version: "v0.131.0-beta.1" } }; - rig.badge._snapshots.snapshots = [{ id: "pre-update", restorable: true }]; + rig.badge._lastRun = { state: "failed", action: "update", message: "New Core did not reach readiness; previous Core is running" }; rig.badge._render(); - assert.match(rig.root().innerHTML, /No newer 0\.x stable package is ready yet/); - assert.match(rig.root().innerHTML, /beta installed; stable package not ready/); - assert.match(rig.root().innerHTML, /Return to v0\.130\.4/); - assert.match(rig.root().innerHTML, /Offline restore/); - assert.doesNotMatch(rig.root().innerHTML, /data-action="rollback-snapshot"/); + const html = rig.root().innerHTML.split("").pop(); + assert.match(html, /v0\.131\.0-beta\.1<\/strong> is running on the beta channel/); + assert.match(html, /v0\.131\.0-beta\.2<\/strong> is published/); + assert.match(html, /
ftw update<\/pre>/);
+  assert.match(html, /Last update failed: New Core did not reach readiness/);
+  assert.match(html, /What's new/);
+  assert.deepEqual([...new Set(rig.root().actions.map(action => action.dataset.action))].sort(), ["check", "close"]);
+  assert.doesNotMatch(html, /Return to v0\.130\.4|Offline restore|Snapshots|Full backup|Channel/);
+});
+
+test("a native release that changes stored data is named without a command", () => {
+  const rig = fixture();
+  rig.badge._info = {
+    native: true, current: "v0.132.2", channel: "stable", update_available: true,
+    latest: "v0.133.0", full_backup_required: true,
+  };
+  rig.badge._render();
+  assert.match(rig.root().innerHTML, /changes stored data, which a native update cannot take yet/);
+  assert.doesNotMatch(rig.root().innerHTML, /class="cmd"/);
+});
+
+test("the dialog waits for the version check instead of showing the old dialog", () => {
+  const rig = fixture();
+  rig.badge._info = null;
+  rig.badge._render();
+  assert.match(rig.root().innerHTML, /Reading the running version/);
+  assert.doesNotMatch(rig.root().innerHTML, /Updates<\/h3>|data-action="restart"/);
+});
+
+test("opening on a native install reads only the version, the last run and the components", async () => {
+  const rig = fixture({ get: () => ({ ok: true, json: async () => ({ state: "idle" }) }) });
+  rig.badge._info = { native: true, current: "v0.131.0-beta.1", channel: "beta" };
+  rig.requests.length = 0;
+  rig.badge.open();
+  await settled();
+  assert.deepEqual(rig.requests.map(request => request.url).sort(),
+    ["/api/components", "/api/version/check", "/api/version/update/status"]);
+});
 
-  const rollback = rig.root().actions.find(action => action.dataset.action === "rollback-binary");
-  rollback.click({ currentTarget: rollback });
-  assert.equal(rig.requests.find(request => request.options?.method === "POST")?.url,
-    "/api/version/binary-rollback");
-  assert.equal(rig.badge._expectedRun.target, "v0.130.4");
+test("a native header mark counts only Core; drivers update in Settings", () => {
+  const rig = fixture();
+  rig.badge._driverCatalog = { entries: [{ pending_update: true }] };
+  rig.badge._info = { native: true, update_available: false };
+  assert.equal(rig.badge._pendingUpdates().total, 0);
+  rig.badge._info = { update_available: false };
+  assert.equal(rig.badge._pendingUpdates().total, 1);
 });
 
 test("full backup displays phase and row progress while the request is pending", () => {
diff --git a/web/update-progress.test.mjs b/web/update-progress.test.mjs
index 863a84e9b..cc3567def 100644
--- a/web/update-progress.test.mjs
+++ b/web/update-progress.test.mjs
@@ -16,6 +16,8 @@ test("update UI resumes work and shows each server phase", () => {
   assert.match(badge, /written, total unknown/);
   assert.match(badge, /No new measured progress for/);
   assert.doesNotMatch(badge, /Large history databases can take several minutes/);
+  assert.match(badge, /if \(!this\._info\) return this\._versionLoadingHTML\(\)/);
+  assert.match(badge, /if \(info\.native\) return this\._nativeVersionHTML\(info\)/);
   assert.match(badge, /Total:/);
   assert.match(badge, /Saving rollback point \(settings and config; history stays in place\)/);
   assert.doesNotMatch(badge, /full history backup/);
@@ -34,6 +36,11 @@ test("setup keeps polling when a safe update takes longer", () => {
   assert.match(setup, /case "checking":\s+return "Checking service health"/);
 });
 
+test("setup on a native install names ftw update and offers no update button", () => {
+  assert.match(setup, /info\.native === true \|\| info\.sidecar_ready === true/);
+  assert.match(setup, /const actions = info\.native\s+\? `