From f2df85d23251190364a7ccb3071ace4afbda150c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 24 Sep 2026 07:09:03 +0200 Subject: [PATCH 1/2] feat(cli): add ftw update, backup, doctor, support, and startup A native install's version panel no longer opens the old updater. ftw update asks beta or stable, or takes --channel. The same command backs up, checks the running Core, writes a redacted support file, and prints a systemd snippet to paste. Starting Core on a port FTW already holds says that it is already running. --- .changeset/ftw-command.md | 5 + go/cmd/ftw/listen_error.go | 51 ++++ go/cmd/ftw/listen_error_test.go | 15 + go/cmd/ftw/main.go | 15 +- go/cmd/ftw/update_cmd.go | 15 + go/cmd/ftwupdate/main.go | 17 ++ go/internal/updatecli/cli.go | 199 +++++++++++++ go/internal/updatecli/commands.go | 400 +++++++++++++++++++++++++++ go/internal/updatecli/update.go | 228 +++++++++++++++ go/internal/updatecli/update_test.go | 104 +++++++ web/update-badge.js | 35 +++ web/update-progress.test.mjs | 3 + 12 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 .changeset/ftw-command.md create mode 100644 go/cmd/ftw/listen_error.go create mode 100644 go/cmd/ftw/listen_error_test.go create mode 100644 go/cmd/ftw/update_cmd.go create mode 100644 go/cmd/ftwupdate/main.go create mode 100644 go/internal/updatecli/cli.go create mode 100644 go/internal/updatecli/commands.go create mode 100644 go/internal/updatecli/update.go create mode 100644 go/internal/updatecli/update_test.go diff --git a/.changeset/ftw-command.md b/.changeset/ftw-command.md new file mode 100644 index 000000000..d37499287 --- /dev/null +++ b/.changeset/ftw-command.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +`ftw update` asks beta or stable, or takes `--channel`. The same command does backup, doctor, a redacted support file, and prints a systemd snippet for `ftw startup`. Starting Core on a port FTW already holds says that it is already running. A native install's version panel no longer opens the old updater. diff --git a/go/cmd/ftw/listen_error.go b/go/cmd/ftw/listen_error.go new file mode 100644 index 000000000..589b1c937 --- /dev/null +++ b/go/cmd/ftw/listen_error.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "syscall" + "time" +) + +func explainBindError(addr string, bindErr error) error { + if bindErr == nil { + return nil + } + 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 version, ok := probeFTW(port); ok { + return fmt.Errorf("FTW is already running on this machine at http://127.0.0.1:%s (%s). Another copy was not started. Use ftw status via ftw doctor, or ftw update", port, version) + } + return fmt.Errorf("port %s is already in use, and it did not answer as FTW. Choose another port with -port", port) +} + +func probeFTW(port string) (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/version/check", nil) + if err != nil { + return "", false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", false + } + defer resp.Body.Close() + var info struct { + Current string `json:"current"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.Current == "" { + return "", false + } + return info.Current, true +} diff --git a/go/cmd/ftw/listen_error_test.go b/go/cmd/ftw/listen_error_test.go new file mode 100644 index 000000000..dfbfbe78f --- /dev/null +++ b/go/cmd/ftw/listen_error_test.go @@ -0,0 +1,15 @@ +package main + +import ( + "net" + "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(), "did not answer as FTW") || !strings.Contains(err.Error(), "-port") { + t.Fatal(err) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 0e96943c3..ce8296f28 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -321,6 +321,9 @@ func main() { // Shift os.Args so the subcommand's flag.FlagSet sees its own flags. runNovaClaim(os.Args[2:]) return + case "update": + runUpdate(os.Args[1:]) + return } } @@ -338,6 +341,7 @@ func main() { backfillStep := flag.Duration("backfill-step", 5*time.Second, "DEV ONLY: backfill sample interval") backfillSeed := flag.Int64("backfill-seed", 0, "DEV ONLY: backfill rng seed (0 = random)") backfillForce := flag.Bool("backfill-force", false, "DEV ONLY: bypass the non-synthetic-data safety gate") + apiPort := flag.Int("port", 0, "HTTP port. 0 uses api.port from config, which defaults to 8080") flag.Parse() nativeRoot := os.Getenv("FTW_NATIVE_SLOT_ROOT") trial, err := beginNativeTrial(nativeRoot, os.Getenv("FTW_NATIVE_TRIAL_TAG"), Version) @@ -403,6 +407,13 @@ func main() { slog.Error("load config", "err", err) os.Exit(1) } + if *apiPort != 0 { + if *apiPort < 1 || *apiPort > 65535 { + slog.Error("port is outside 1-65535", "port", *apiPort) + os.Exit(1) + } + cfg.API.Port = *apiPort + } slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers)) // Repaired-but-wrong config: ERROR so it reaches the log ring and the // support report, without stopping a boot the repair made safe. @@ -464,7 +475,9 @@ func main() { ) listener, err := net.Listen("tcp", httpSrv.Addr) if err != nil { - slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", err) + bindErr := explainBindError(httpSrv.Addr, err) + fmt.Fprintln(os.Stderr, bindErr) + slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", bindErr) os.Exit(1) } go func() { diff --git a/go/cmd/ftw/update_cmd.go b/go/cmd/ftw/update_cmd.go new file mode 100644 index 000000000..c4af1ff7f --- /dev/null +++ b/go/cmd/ftw/update_cmd.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/srcfl/ftw/go/internal/updatecli" +) + +func runUpdate(args []string) { + if err := updatecli.Run(args, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "ftw:", err) + os.Exit(1) + } +} diff --git a/go/cmd/ftwupdate/main.go b/go/cmd/ftwupdate/main.go new file mode 100644 index 000000000..3987e5f93 --- /dev/null +++ b/go/cmd/ftwupdate/main.go @@ -0,0 +1,17 @@ +// Command ftwupdate is installed on PATH as `ftw`. It only runs updates. +// Starting Core is the long-running ftw binary, not this command. +package main + +import ( + "fmt" + "os" + + "github.com/srcfl/ftw/go/internal/updatecli" +) + +func main() { + if err := updatecli.Run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "ftw:", err) + os.Exit(1) + } +} diff --git a/go/internal/updatecli/cli.go b/go/internal/updatecli/cli.go new file mode 100644 index 000000000..752544a67 --- /dev/null +++ b/go/internal/updatecli/cli.go @@ -0,0 +1,199 @@ +package updatecli + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" +) + +const usageText = `ftw talks to the Core running on this machine. It does not start a Docker updater. + +Usage: + ftw update [--channel beta|stable] [--url URL] [--port PORT] + ftw backup [--output-dir DIR] [--url URL] [--port PORT] + ftw doctor [--url URL] [--port PORT] + ftw support [--output FILE] [--report] [--url URL] [--port PORT] + ftw startup [--root DIR] [--config FILE] [--user-drivers DIR] [--user USER] [--port PORT] + ftw help + +update downloads the next native release and swaps to it. +With no --channel, ftw asks beta or stable. Enter keeps the channel +the box already uses. A closed terminal also keeps that channel. + +backup makes a verified full archive while Core keeps running. +--output-dir copies it off the machine and checks the SHA-256. +Without --output-dir the archive stays on the box. + +doctor reports whether Core, history and drivers are fit to run. + +support writes Core's redacted diagnostic zip. --report writes the +shorter Markdown report instead. The file mode is 0600. + +startup prints a systemd unit and the shell lines that install it. +It does not change the machine. Paste the lines after you have read them. + +--port chooses the HTTP port. --url is the full address of a running Core. +Use one of them. +` + +// Run is the ftw command line. +func Run(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + fmt.Fprint(stdout, usageText) + return nil + } + switch args[0] { + case "update": + return runUpdate(args[1:], stdout, stderr) + case "backup": + return runBackup(args[1:], stdout) + case "doctor": + return runDoctor(args[1:], stdout) + case "support": + return runSupport(args[1:], stdout) + case "startup": + return runStartup(args[1:], stdout) + default: + fmt.Fprint(stderr, usageText) + return fmt.Errorf("unknown command %s", args[0]) + } +} + +func runUpdate(args []string, stdout, stderr io.Writer) error { + if hasHelp(args) { + fmt.Fprint(stdout, "usage: ftw update [--channel beta|stable] [--url http://127.0.0.1:8080]\n") + return nil + } + opts, _, err := commonOpts(args, map[string]bool{"--channel": true}) + if err != nil { + return err + } + channel := flagValue(args, "--channel") + if channel == "" { + info, err := peekVersion(opts) + if err != nil { + return err + } + if !info.Native { + return notNative + } + chosen, err := promptChannel(os.Stdin, stderr, info.Channel) + if err != nil { + return err + } + channel = chosen + } + if channel != "" && channel != "beta" && channel != "stable" { + return fmt.Errorf("--channel must be beta or stable, got %s", channel) + } + opts.Channel = channel + return Update(context.Background(), opts, stdout) +} + +func promptChannel(in io.Reader, out io.Writer, current string) (string, error) { + if current != "beta" && current != "stable" { + current = "beta" + } + fmt.Fprintf(out, "Release channel: beta or stable [%s]: ", current) + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && strings.TrimSpace(line) == "" { + fmt.Fprintf(out, "Keeping %s.\n", current) + return current, nil + } + line = strings.TrimSpace(line) + if line == "" { + return current, nil + } + if line != "beta" && line != "stable" { + return "", fmt.Errorf("channel must be beta or stable, got %s", line) + } + return line, nil +} + +func peekVersion(opts Options) (versionInfo, error) { + prepare(&opts) + var info versionInfo + err := getJSON(context.Background(), opts.Client, opts, "/api/version/check", &info) + return info, err +} + +func hasHelp(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + return false +} + +func flagValue(args []string, name string) string { + for i := 0; i < len(args); i++ { + if args[i] == name && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func parseCommon(args []string, opts Options, extra map[string]bool) (Options, error) { + for i := 0; i < len(args); i++ { + switch args[i] { + case "--url": + i++ + if i >= len(args) { + return opts, fmt.Errorf("--url needs an address") + } + opts.URL = stringsTrimRightSlash(args[i]) + case "--port": + if !extra["--port"] { + return opts, fmt.Errorf("unknown argument --port") + } + i++ + if i >= len(args) { + return opts, fmt.Errorf("--port needs a port") + } + case "--channel": + if !extra["--channel"] { + return opts, fmt.Errorf("unknown argument %s", args[i]) + } + i++ + if i >= len(args) { + return opts, fmt.Errorf("--channel needs beta or stable") + } + case "--output-dir", "--output": + if !extra[args[i]] { + return opts, fmt.Errorf("unknown argument %s", args[i]) + } + i++ + if i >= len(args) { + return opts, fmt.Errorf("%s needs a path", args[i-1]) + } + case "--report": + if !extra["--report"] { + return opts, fmt.Errorf("unknown argument --report") + } + case "--help", "-h": + default: + return opts, fmt.Errorf("unknown argument %s", args[i]) + } + } + return opts, nil +} + +func stringsTrimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +var notNative = errNative{} + +type errNative struct{} + +func (errNative) Error() string { + return "this Core is not a native install; ftw update does not start the Docker updater" +} diff --git a/go/internal/updatecli/commands.go b/go/internal/updatecli/commands.go new file mode 100644 index 000000000..58f950120 --- /dev/null +++ b/go/internal/updatecli/commands.go @@ -0,0 +1,400 @@ +package updatecli + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func runBackup(args []string, stdout io.Writer) error { + if hasHelp(args) { + fmt.Fprint(stdout, "usage: ftw backup [--output-dir DIR] [--url URL] [--port PORT]\n") + return nil + } + opts, outputDir, err := commonOpts(args, map[string]bool{"--output-dir": true}) + if err != nil { + return err + } + return Backup(context.Background(), opts, outputDir, stdout) +} + +func runDoctor(args []string, stdout io.Writer) error { + if hasHelp(args) { + fmt.Fprint(stdout, "usage: ftw doctor [--url URL] [--port PORT]\n") + return nil + } + opts, _, err := commonOpts(args, nil) + if err != nil { + return err + } + return Doctor(context.Background(), opts, stdout) +} + +func runSupport(args []string, stdout io.Writer) error { + if hasHelp(args) { + fmt.Fprint(stdout, "usage: ftw support [--output FILE] [--report] [--url URL] [--port PORT]\n") + return nil + } + opts, output, err := commonOpts(args, map[string]bool{"--output": true, "--report": true}) + if err != nil { + return err + } + report := false + for _, arg := range args { + if arg == "--report" { + report = true + } + } + return Support(context.Background(), opts, output, report, stdout) +} + +func runStartup(args []string, stdout io.Writer) error { + if hasHelp(args) { + fmt.Fprint(stdout, "usage: ftw startup [--root DIR] [--config FILE] [--user-drivers DIR] [--user USER] [--port PORT]\n") + return nil + } + spec := startupSpec{Root: "/opt/ftw", Config: "/var/lib/ftw/config.yaml", UserDrivers: "/var/lib/ftw/drivers", User: "ftw"} + for i := 0; i < len(args); i++ { + take := func() (string, error) { + i++ + if i >= len(args) { + return "", fmt.Errorf("%s needs a value", args[i-1]) + } + return args[i], nil + } + switch args[i] { + case "--root": + v, err := take() + if err != nil { + return err + } + spec.Root = v + case "--config": + v, err := take() + if err != nil { + return err + } + spec.Config = v + case "--user-drivers": + v, err := take() + if err != nil { + return err + } + spec.UserDrivers = v + case "--user": + v, err := take() + if err != nil { + return err + } + spec.User = v + case "--port": + v, err := take() + if err != nil { + return err + } + spec.Port = v + default: + return fmt.Errorf("unknown argument %s", args[i]) + } + } + fmt.Fprint(stdout, startupScript(spec)) + return nil +} + +type startupSpec struct { + Root, Config, UserDrivers, User, Port string +} + +func startupScript(spec startupSpec) string { + exec := fmt.Sprintf("%s/ftw-launcher -root %s -config %s -user-drivers %s", spec.Root, spec.Root, spec.Config, spec.UserDrivers) + if spec.Port != "" { + exec += " run -port " + spec.Port + } + unit := fmt.Sprintf(`[Unit] +Description=FTW local energy runtime +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%s +Group=%s +WorkingDirectory=%s +ExecStart=%s +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +`, spec.User, spec.User, filepath.Dir(spec.Config), exec) + return fmt.Sprintf(`# Review the paths, then paste this into a shell on the FTW machine. +# It installs a systemd service that keeps Core running. Updating is still: ftw update + +sudo tee /etc/systemd/system/ftw.service >/dev/null <<'EOF' +%sEOF +sudo systemctl daemon-reload +sudo systemctl enable --now ftw.service +`, unit) +} + +func commonOpts(args []string, extra map[string]bool) (Options, string, error) { + if extra == nil { + extra = map[string]bool{} + } + extra["--port"] = true + opts := Options{URL: "http://127.0.0.1:8080", MaxWait: time.Hour} + opts, err := parseCommon(args, opts, extra) + if err != nil { + return opts, "", err + } + port := flagValue(args, "--port") + url := flagValue(args, "--url") + if port != "" && url != "" { + return opts, "", errors.New("use either --port or --url") + } + if port != "" { + opts.URL = "http://127.0.0.1:" + port + } + output := flagValue(args, "--output-dir") + if output == "" { + output = flagValue(args, "--output") + } + return opts, output, nil +} + +func Backup(ctx context.Context, opts Options, outputDir string, out io.Writer) error { + prepare(&opts) + fmt.Fprintln(out, "Starting full backup. Core stays running.") + type created struct { + Warning string `json:"warning"` + Backup struct { + ID string `json:"id"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + Verified bool `json:"verified"` + } `json:"backup"` + } + done := make(chan error, 1) + var body created + go func() { + done <- postJSON(ctx, opts.Client, opts, "/api/backups", map[string]any{}, &body) + }() + var last string + start := time.Now() + if opts.Now != nil { + start = opts.Now() + } + for { + select { + case err := <-done: + if err != nil { + return err + } + if body.Warning != "" { + fmt.Fprintln(out, body.Warning) + } + if !body.Backup.Verified || !validBackupID(body.Backup.ID) || len(body.Backup.SHA256) != 64 { + return errors.New("Core did not return a verified backup") + } + fmt.Fprintf(out, "Backup on the box: %s (%d bytes, SHA-256 %s)\n", body.Backup.ID, body.Backup.SizeBytes, body.Backup.SHA256) + if outputDir == "" { + return nil + } + return downloadBackup(ctx, opts, outputDir, body.Backup.ID, body.Backup.SHA256, body.Backup.SizeBytes, out) + case <-time.After(2 * time.Second): + var list struct { + Progress struct { + Phase string `json:"phase"` + CompletedBytes int64 `json:"completed_bytes"` + TotalBytes int64 `json:"total_bytes"` + Table string `json:"table"` + RowsDone int64 `json:"rows_done"` + Error string `json:"error"` + } `json:"progress"` + } + if err := getJSON(ctx, opts.Client, opts, "/api/backups", &list); err != nil { + continue + } + now := time.Now() + if opts.Now != nil { + now = opts.Now() + } + line := backupLine(list.Progress.Phase, list.Progress.Table, list.Progress.RowsDone, list.Progress.CompletedBytes, list.Progress.TotalBytes, now.Sub(start)) + if list.Progress.Error != "" { + return errors.New(list.Progress.Error) + } + if line != last && list.Progress.Phase != "" { + fmt.Fprintln(out, line) + last = line + } + } + } +} + +func backupLine(phase, table string, rows, done, total int64, elapsed time.Duration) string { + parts := []string{fmt.Sprintf("[%s]", elapsed.Round(time.Second)), "backup:", phase} + if table != "" { + parts = append(parts, table) + } + if rows > 0 { + parts = append(parts, fmt.Sprintf("%d rows", rows)) + } + if done > 0 || total > 0 { + if total > 0 { + parts = append(parts, fmt.Sprintf("%d/%d bytes", done, total)) + } else { + parts = append(parts, fmt.Sprintf("%d bytes, total unknown", done)) + } + } + return strings.Join(parts, " ") +} + +func validBackupID(id string) bool { + return strings.HasPrefix(id, "ftw-full-backup-") && strings.HasSuffix(id, ".ftwbak") && !strings.Contains(id, "/") && !strings.Contains(id, "..") +} + +func downloadBackup(ctx context.Context, opts Options, outputDir, id, expect string, size int64, out io.Writer) error { + if err := os.MkdirAll(outputDir, 0o700); err != nil { + return err + } + target := filepath.Join(outputDir, id) + pending := target + ".part" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+"/api/backups/"+id, nil) + if err != nil { + return err + } + if opts.Token != "" { + req.Header.Set("Authorization", "Bearer "+opts.Token) + } + resp, err := opts.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("download %s", resp.Status) + } + f, err := os.OpenFile(pending, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + sum := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(f, sum), resp.Body) + closeErr := f.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(pending) + if copyErr != nil { + return copyErr + } + return closeErr + } + got := hex.EncodeToString(sum.Sum(nil)) + if got != expect || (size > 0 && n != size) { + _ = os.Remove(pending) + return errors.New("downloaded backup does not match Core's verified size and SHA-256") + } + if err := os.Rename(pending, target); err != nil { + _ = os.Remove(pending) + return err + } + fmt.Fprintf(out, "Backup saved: %s\nSHA-256 %s\n", target, expect) + return nil +} + +func Doctor(ctx context.Context, opts Options, out io.Writer) error { + prepare(&opts) + var info versionInfo + var health struct { + Status string `json:"status"` + DriversOK int `json:"drivers_ok"` + DriversOffline int `json:"drivers_offline"` + DriversFaulted int `json:"drivers_faulted"` + History struct { + Migration struct { + State string `json:"state"` + } `json:"migration"` + Writer struct { + CommitFailures int64 `json:"commit_failures"` + } `json:"writer"` + } `json:"history_storage"` + } + if err := getJSON(ctx, opts.Client, opts, "/api/version/check", &info); err != nil { + fmt.Fprintf(out, "core: not reachable (%s)\n", err) + return err + } + if err := getJSON(ctx, opts.Client, opts, "/api/health", &health); err != nil { + fmt.Fprintf(out, "health: not readable (%s)\n", err) + return err + } + native := "no" + if info.Native { + native = "yes" + } + fmt.Fprintf(out, "core: reachable\nversion: %s\nnative: %s\nchannel: %s\nhealth: %s\ndrivers: %d ok, %d offline, %d faulted\nhistory: %s\nwrite failures: %d\n", + info.Current, native, info.Channel, health.Status, health.DriversOK, health.DriversOffline, health.DriversFaulted, + health.History.Migration.State, health.History.Writer.CommitFailures) + if info.UpdateAvailable { + fmt.Fprintf(out, "update: %s is published. Run ftw update\n", info.Latest) + } else { + fmt.Fprintln(out, "update: current") + } + if !info.Native { + fmt.Fprintln(out, "note: this is not a native install") + } + if health.Status != "ok" || health.DriversFaulted > 0 { + return errors.New("doctor found a problem") + } + return nil +} + +func Support(ctx context.Context, opts Options, output string, report bool, out io.Writer) error { + prepare(&opts) + path := "/api/support/dump" + name := "ftw-support.zip" + if report { + path = "/api/support/report" + name = "ftw-support.md" + } + if output == "" { + output = name + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+path, nil) + if err != nil { + return err + } + if opts.Token != "" { + req.Header.Set("Authorization", "Bearer "+opts.Token) + } + resp, err := opts.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("support: %s", strings.TrimSpace(string(raw))) + } + f, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + n, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(output) + if copyErr != nil { + return copyErr + } + return closeErr + } + fmt.Fprintf(out, "Support file: %s (%d bytes)\nCore redacts secrets before writing this. It still describes the site.\n", output, n) + return nil +} diff --git a/go/internal/updatecli/update.go b/go/internal/updatecli/update.go new file mode 100644 index 000000000..84a66e80a --- /dev/null +++ b/go/internal/updatecli/update.go @@ -0,0 +1,228 @@ +// Package updatecli is the `ftw update` command. It asks the running native +// Core to download the next release and swap to it. It does not start a +// Docker updater. +package updatecli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Options controls one update. +type Options struct { + URL string + Channel string + Token string + MaxWait time.Duration + Now func() time.Time + Sleep func(time.Duration) + Client *http.Client +} + +type versionInfo struct { + Current string `json:"current"` + Native bool `json:"native"` + Channel string `json:"channel"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + FullBackupRequired bool `json:"full_backup_required"` + Err string `json:"err"` +} + +type updateStatus struct { + State string `json:"state"` + Message string `json:"message"` + Target string `json:"target"` + Step int `json:"step"` + TotalSteps int `json:"total_steps"` + ProgressCurrent int64 `json:"progress_current"` + ProgressTotal int64 `json:"progress_total"` + ProgressUnit string `json:"progress_unit"` +} + +// Update talks to a running Core. A non-native install is refused so this +// command cannot start the old Docker updater. +func Update(ctx context.Context, opts Options, out io.Writer) error { + if opts.URL == "" { + opts.URL = "http://127.0.0.1:8080" + } + if opts.MaxWait <= 0 { + opts.MaxWait = time.Hour + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Sleep == nil { + opts.Sleep = time.Sleep + } + client := opts.Client + if client == nil { + client = http.DefaultClient + } + if opts.Channel != "" { + if err := postJSON(ctx, client, opts, "/api/version/channel", map[string]string{"channel": opts.Channel}); err != nil { + return err + } + } + var info versionInfo + if err := getJSON(ctx, client, opts, "/api/version/check?force=1", &info); err != nil { + return err + } + if info.Err != "" { + return errors.New(info.Err) + } + if !info.Native { + return errors.New("this Core is not a native install; ftw update does not start the Docker updater") + } + if !info.UpdateAvailable { + fmt.Fprintf(out, "Already current on %s: %s\n", info.Channel, info.Current) + return nil + } + if info.FullBackupRequired { + return fmt.Errorf("updating to %s changes stored data; take a verified full backup off the machine before ftw update", info.Latest) + } + fmt.Fprintf(out, "Updating %s -> %s on %s\n", info.Current, info.Latest, info.Channel) + var started struct { + Target string `json:"target"` + } + if err := postJSON(ctx, client, opts, "/api/version/update", map[string]any{}, &started); err != nil { + return err + } + if started.Target != "" && started.Target != info.Latest { + return fmt.Errorf("Core accepted %s, not %s", started.Target, info.Latest) + } + start := opts.Now() + deadline := start.Add(opts.MaxWait) + var last string + for { + now := opts.Now() + if now.After(deadline) { + return errors.New("update did not finish; Core may still be working") + } + var st updateStatus + if err := getJSON(ctx, client, opts, "/api/version/update/status", &st); err != nil { + st.State = "restarting" + st.Message = err.Error() + } + line := formatStatus(st, now.Sub(start)) + if line != last { + fmt.Fprintln(out, line) + last = line + } + switch st.State { + case "done": + var health struct { + Status string `json:"status"` + } + var after versionInfo + if err := getJSON(ctx, client, opts, "/api/health", &health); err != nil { + return err + } + if err := getJSON(ctx, client, opts, "/api/version/check", &after); err != nil { + return err + } + if after.Current != info.Latest || health.Status != "ok" { + return fmt.Errorf("update ended as %s, health %s", after.Current, health.Status) + } + fmt.Fprintf(out, "Update complete: %s\n", after.Current) + return nil + case "failed": + if st.Message == "" { + st.Message = "update failed" + } + return errors.New(st.Message) + } + opts.Sleep(2 * time.Second) + } +} + +func formatStatus(st updateStatus, elapsed time.Duration) string { + parts := []string{fmt.Sprintf("[%s]", elapsed.Round(time.Second)), st.State} + if st.Step > 0 && st.TotalSteps > 0 { + parts = append(parts, fmt.Sprintf("step %d/%d", st.Step, st.TotalSteps)) + } + if st.ProgressUnit == "bytes" && (st.ProgressCurrent > 0 || st.ProgressTotal > 0) { + if st.ProgressTotal > 0 { + parts = append(parts, fmt.Sprintf("%d/%d bytes", st.ProgressCurrent, st.ProgressTotal)) + } else { + parts = append(parts, fmt.Sprintf("%d bytes, total unknown", st.ProgressCurrent)) + } + } + if st.Message != "" { + parts = append(parts, st.Message) + } + return strings.Join(parts, " ") +} + +func prepare(opts *Options) { + if opts.URL == "" { + opts.URL = "http://127.0.0.1:8080" + } + if opts.Client == nil { + opts.Client = http.DefaultClient + } +} + +func getJSON(ctx context.Context, client *http.Client, opts Options, path string, dest any) error { + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+path, nil) + if err != nil { + return err + } + return doJSON(client, opts, req, dest) +} + +func postJSON(ctx context.Context, client *http.Client, opts Options, path string, body any, dest ...any) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.URL+path, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + var out any + if len(dest) > 0 { + out = dest[0] + } + return doJSON(client, opts, req, out) +} + +func doJSON(client *http.Client, opts Options, req *http.Request, dest any) error { + if opts.Token != "" { + req.Header.Set("Authorization", "Bearer "+opts.Token) + } + resp, err := client.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 >= 300 { + var apiErr struct { + Error string `json:"error"` + } + _ = json.Unmarshal(payload, &apiErr) + if apiErr.Error == "" { + apiErr.Error = strings.TrimSpace(string(payload)) + } + return fmt.Errorf("%s %s: %s", req.Method, req.URL.Path, apiErr.Error) + } + if dest == nil || len(payload) == 0 { + return nil + } + return json.Unmarshal(payload, dest) +} diff --git a/go/internal/updatecli/update_test.go b/go/internal/updatecli/update_test.go new file mode 100644 index 000000000..3af19ae61 --- /dev/null +++ b/go/internal/updatecli/update_test.go @@ -0,0 +1,104 @@ +package updatecli + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestUpdateRefusesTheDockerUpdater(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/version/check" { + t.Errorf("path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"current":"v3.8.0-beta.1","native":false,"channel":"beta"}`)) + })) + defer srv.Close() + err := Update(t.Context(), Options{URL: srv.URL, Client: srv.Client(), Sleep: func(time.Duration) {}}, &strings.Builder{}) + if err == nil || !strings.Contains(err.Error(), "does not start the Docker updater") { + t.Fatal(err) + } +} + +func TestUpdatePrintsPhasesAndStopsWhenCurrent(t *testing.T) { + var posts []string + step := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/version/channel": + posts = append(posts, r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + case "/api/version/check": + if step == 0 { + _, _ = w.Write([]byte(`{"current":"v0.132.0-beta.1","native":true,"channel":"beta","latest":"v0.132.2-beta.1","update_available":true}`)) + return + } + _, _ = w.Write([]byte(`{"current":"v0.132.2-beta.1","native":true,"channel":"beta"}`)) + case "/api/version/update": + posts = append(posts, r.URL.Path) + _, _ = w.Write([]byte(`{"target":"v0.132.2-beta.1"}`)) + case "/api/version/update/status": + step++ + if step == 1 { + _, _ = w.Write([]byte(`{"state":"pulling","message":"downloading","step":2,"total_steps":4,"progress_current":10,"progress_unit":"bytes"}`)) + return + } + _, _ = w.Write([]byte(`{"state":"done","message":"ready"}`)) + case "/api/health": + _, _ = w.Write([]byte(`{"status":"ok"}`)) + default: + t.Errorf("unexpected %s", r.URL.Path) + w.WriteHeader(404) + } + })) + defer srv.Close() + var out strings.Builder + err := Update(t.Context(), Options{ + URL: srv.URL, Channel: "beta", Client: srv.Client(), + Now: func() time.Time { return time.Unix(0, 0) }, Sleep: func(time.Duration) {}, + }, &out) + if err != nil { + t.Fatal(err) + } + if strings.Join(posts, ",") != "/api/version/channel,/api/version/update" { + t.Fatalf("posts %v", posts) + } + text := out.String() + if !strings.Contains(text, "Updating v0.132.0-beta.1 -> v0.132.2-beta.1") || !strings.Contains(text, "pulling") || !strings.Contains(text, "total unknown") || !strings.Contains(text, "Update complete: v0.132.2-beta.1") { + t.Fatalf("output:\n%s", text) + } +} + +func TestHelpAndStartupAndChannelPrompt(t *testing.T) { + var out strings.Builder + if err := Run(nil, &out, &strings.Builder{}); err != nil { + t.Fatal(err) + } + for _, part := range []string{"ftw update", "ftw backup", "ftw doctor", "ftw support", "ftw startup", "--channel", "--port"} { + if !strings.Contains(out.String(), part) { + t.Fatalf("help missing %s\n%s", part, out.String()) + } + } + out.Reset() + if err := Run([]string{"startup", "--port", "9090", "--root", "/opt/ftw-native"}, &out, &strings.Builder{}); err != nil { + t.Fatal(err) + } + script := out.String() + for _, part := range []string{"sudo tee /etc/systemd/system/ftw.service", "ftw-launcher", "/opt/ftw-native", "run -port 9090", "systemctl enable --now"} { + if !strings.Contains(script, part) { + t.Fatalf("startup missing %s\n%s", part, script) + } + } + chosen, err := promptChannel(strings.NewReader("\n"), &strings.Builder{}, "beta") + if err != nil || chosen != "beta" { + t.Fatalf("empty prompt = %s %v", chosen, err) + } + chosen, err = promptChannel(strings.NewReader("stable\n"), &strings.Builder{}, "beta") + if err != nil || chosen != "stable" { + t.Fatalf("stable prompt = %s %v", chosen, err) + } +} diff --git a/web/update-badge.js b/web/update-badge.js index aafae3964..f18235780 100644 --- a/web/update-badge.js +++ b/web/update-badge.js @@ -815,6 +815,7 @@ _modalHTML() { const info = this._info || {}; if (this._phase === "updating") return this._updatingModalHTML(); + if (info.native) return this._nativeVersionHTML(info); const hasUpdate = !!info.update_available; const pending = this._pendingUpdates(); @@ -907,6 +908,40 @@ `; } + // A native install updates itself. The old Updates panel belonged to the + // Docker updater and is not the way to move this box. + _nativeVersionHTML(info) { + const current = info.current || "unknown"; + const channel = info.channel || "beta"; + 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 available = info.update_available + ? `

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

+

On the FTW machine, run:

+

ftw update

+

Core downloads that release and swaps to it. There is no separate updater.

` + : `

No newer release is published on ${escapeHTML(channel)}.

`; + return ` +
+ `; + } + // Core's channel includes the bundled Energyplan worker. _channelSectionHTML() { const info = this._info || {}; diff --git a/web/update-progress.test.mjs b/web/update-progress.test.mjs index 863a84e9b..97c405dc5 100644 --- a/web/update-progress.test.mjs +++ b/web/update-progress.test.mjs @@ -16,6 +16,9 @@ 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 \(info\.native\) return this\._nativeVersionHTML\(info\)/); + assert.match(badge, /class="mono">ftw update<\/p>/); + assert.match(badge, /There is no separate updater/); assert.match(badge, /Total:/); assert.match(badge, /Saving rollback point \(settings and config; history stays in place\)/); assert.doesNotMatch(badge, /full history backup/); From fc7d5fcad958986d275969dadffaa1d479827684 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 24 Sep 2026 08:26:35 +0200 Subject: [PATCH 2/2] feat(cli): ship ftw as the native operator command The owner runs native updates on the machine (ADR 0007 as amended in #1373). ftw-cli ships in the release package and the installer puts it on PATH as /usr/local/bin/ftw, owned by root. It talks only to the local Core API, asks nothing and never starts Core: - status: version, published release, last run, previous release, health and history, with the systemd commands to look next - update [--channel]: follows a run already in progress, waits through the restart, reports what runs and how health settled; already current exits 0; stops before a state-schema change - rollback: binary rollback to previous, followed the same way - backup [--output-dir]: verified archive, copy checked by SHA-256 - support: the redacted zip, rejected if the stream was cut Every request has a timeout and waiting is bounded by the trial deadline. doctor, startup, the channel prompt, --port and Core's -port flag are gone, and the Core binary no longer routes update. A native "restarting" status now stays in flight until the trial deadline instead of failing after five minutes. The bind error asks /api/health, so a starting Core is recognised, and names ftw status. The native web panel reports version, release, command and last run without controls; it shows a loading panel before the first check and fetches nothing for the old dialog. Setup on native names ftw update instead of offering Update now. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/ftw-command.md | 4 +- .github/workflows/core-binaries.yml | 3 +- docs/backup-and-restore.md | 12 + docs/self-update.md | 36 +- go/cmd/ftw-cli/main.go | 13 + go/cmd/ftw/listen_error.go | 34 +- go/cmd/ftw/listen_error_test.go | 29 +- go/cmd/ftw/main.go | 16 +- go/cmd/ftw/update_cmd.go | 15 - go/cmd/ftwupdate/main.go | 17 - go/internal/ftwcli/backup.go | 256 +++++++++++++ go/internal/ftwcli/cli.go | 144 +++++++ go/internal/ftwcli/cli_test.go | 436 ++++++++++++++++++++++ go/internal/ftwcli/client.go | 121 ++++++ go/internal/ftwcli/status.go | 224 +++++++++++ go/internal/ftwcli/update.go | 209 +++++++++++ go/internal/selfupdate/selfupdate.go | 7 + go/internal/selfupdate/selfupdate_test.go | 24 ++ go/internal/updatecli/cli.go | 199 ---------- go/internal/updatecli/commands.go | 400 -------------------- go/internal/updatecli/update.go | 228 ----------- go/internal/updatecli/update_test.go | 104 ------ scripts/build-core.sh | 2 +- scripts/install.sh | 13 +- scripts/package-linux.py | 5 +- scripts/test_package_linux.py | 6 +- web/components/ftw-update-check.js | 23 +- web/index.html | 4 +- web/update-badge.js | 80 +++- web/update-dialog.test.mjs | 68 +++- web/update-progress.test.mjs | 8 +- 31 files changed, 1680 insertions(+), 1060 deletions(-) create mode 100644 go/cmd/ftw-cli/main.go delete mode 100644 go/cmd/ftw/update_cmd.go delete mode 100644 go/cmd/ftwupdate/main.go create mode 100644 go/internal/ftwcli/backup.go create mode 100644 go/internal/ftwcli/cli.go create mode 100644 go/internal/ftwcli/cli_test.go create mode 100644 go/internal/ftwcli/client.go create mode 100644 go/internal/ftwcli/status.go create mode 100644 go/internal/ftwcli/update.go delete mode 100644 go/internal/updatecli/cli.go delete mode 100644 go/internal/updatecli/commands.go delete mode 100644 go/internal/updatecli/update.go delete mode 100644 go/internal/updatecli/update_test.go diff --git a/.changeset/ftw-command.md b/.changeset/ftw-command.md index d37499287..51a001c75 100644 --- a/.changeset/ftw-command.md +++ b/.changeset/ftw-command.md @@ -1,5 +1,5 @@ --- -"ftw": patch +"ftw": minor --- -`ftw update` asks beta or stable, or takes `--channel`. The same command does backup, doctor, a redacted support file, and prints a systemd snippet for `ftw startup`. Starting Core on a port FTW already holds says that it is already running. A native install's version panel no longer opens the old updater. +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 index 589b1c937..c38c97706 100644 --- a/go/cmd/ftw/listen_error.go +++ b/go/cmd/ftw/listen_error.go @@ -12,10 +12,9 @@ import ( "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 bindErr == nil { - return nil - } if !errors.Is(bindErr, syscall.EADDRINUSE) { return fmt.Errorf("HTTP port %s could not be opened: %w", addr, bindErr) } @@ -23,29 +22,32 @@ func explainBindError(addr string, bindErr error) error { if err != nil { port = strings.TrimPrefix(addr, ":") } - if version, ok := probeFTW(port); ok { - return fmt.Errorf("FTW is already running on this machine at http://127.0.0.1:%s (%s). Another copy was not started. Use ftw status via ftw doctor, or ftw update", port, version) + 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("port %s is already in use, and it did not answer as FTW. Choose another port with -port", 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) } -func probeFTW(port string) (string, bool) { +// 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/version/check", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port+"/api/health", nil) if err != nil { - return "", false + return false } resp, err := http.DefaultClient.Do(req) if err != nil { - return "", false + return false } defer resp.Body.Close() - var info struct { - Current string `json:"current"` - } - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.Current == "" { - return "", false + var health struct { + Status string `json:"status"` } - return info.Current, true + 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 index dfbfbe78f..b8b6e2395 100644 --- a/go/cmd/ftw/listen_error_test.go +++ b/go/cmd/ftw/listen_error_test.go @@ -1,7 +1,10 @@ package main import ( + "errors" "net" + "net/http" + "net/http/httptest" "strings" "syscall" "testing" @@ -9,7 +12,31 @@ import ( func TestExplainBindErrorNamesATakenPortThatIsNotFTW(t *testing.T) { err := explainBindError("127.0.0.1:1", &net.OpError{Err: syscall.EADDRINUSE}) - if err == nil || !strings.Contains(err.Error(), "did not answer as FTW") || !strings.Contains(err.Error(), "-port") { + 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 ce8296f28..285b5e148 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -321,9 +321,6 @@ func main() { // Shift os.Args so the subcommand's flag.FlagSet sees its own flags. runNovaClaim(os.Args[2:]) return - case "update": - runUpdate(os.Args[1:]) - return } } @@ -341,7 +338,6 @@ func main() { backfillStep := flag.Duration("backfill-step", 5*time.Second, "DEV ONLY: backfill sample interval") backfillSeed := flag.Int64("backfill-seed", 0, "DEV ONLY: backfill rng seed (0 = random)") backfillForce := flag.Bool("backfill-force", false, "DEV ONLY: bypass the non-synthetic-data safety gate") - apiPort := flag.Int("port", 0, "HTTP port. 0 uses api.port from config, which defaults to 8080") flag.Parse() nativeRoot := os.Getenv("FTW_NATIVE_SLOT_ROOT") trial, err := beginNativeTrial(nativeRoot, os.Getenv("FTW_NATIVE_TRIAL_TAG"), Version) @@ -407,13 +403,6 @@ func main() { slog.Error("load config", "err", err) os.Exit(1) } - if *apiPort != 0 { - if *apiPort < 1 || *apiPort > 65535 { - slog.Error("port is outside 1-65535", "port", *apiPort) - os.Exit(1) - } - cfg.API.Port = *apiPort - } slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers)) // Repaired-but-wrong config: ERROR so it reaches the log ring and the // support report, without stopping a boot the repair made safe. @@ -475,9 +464,7 @@ func main() { ) listener, err := net.Listen("tcp", httpSrv.Addr) if err != nil { - bindErr := explainBindError(httpSrv.Addr, err) - fmt.Fprintln(os.Stderr, bindErr) - slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", bindErr) + slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", explainBindError(httpSrv.Addr, err)) os.Exit(1) } go func() { @@ -2372,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/cmd/ftw/update_cmd.go b/go/cmd/ftw/update_cmd.go deleted file mode 100644 index c4af1ff7f..000000000 --- a/go/cmd/ftw/update_cmd.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/srcfl/ftw/go/internal/updatecli" -) - -func runUpdate(args []string) { - if err := updatecli.Run(args, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, "ftw:", err) - os.Exit(1) - } -} diff --git a/go/cmd/ftwupdate/main.go b/go/cmd/ftwupdate/main.go deleted file mode 100644 index 3987e5f93..000000000 --- a/go/cmd/ftwupdate/main.go +++ /dev/null @@ -1,17 +0,0 @@ -// Command ftwupdate is installed on PATH as `ftw`. It only runs updates. -// Starting Core is the long-running ftw binary, not this command. -package main - -import ( - "fmt" - "os" - - "github.com/srcfl/ftw/go/internal/updatecli" -) - -func main() { - if err := updatecli.Run(os.Args[1:], os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, "ftw:", err) - os.Exit(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/go/internal/updatecli/cli.go b/go/internal/updatecli/cli.go deleted file mode 100644 index 752544a67..000000000 --- a/go/internal/updatecli/cli.go +++ /dev/null @@ -1,199 +0,0 @@ -package updatecli - -import ( - "bufio" - "context" - "fmt" - "io" - "os" - "strings" -) - -const usageText = `ftw talks to the Core running on this machine. It does not start a Docker updater. - -Usage: - ftw update [--channel beta|stable] [--url URL] [--port PORT] - ftw backup [--output-dir DIR] [--url URL] [--port PORT] - ftw doctor [--url URL] [--port PORT] - ftw support [--output FILE] [--report] [--url URL] [--port PORT] - ftw startup [--root DIR] [--config FILE] [--user-drivers DIR] [--user USER] [--port PORT] - ftw help - -update downloads the next native release and swaps to it. -With no --channel, ftw asks beta or stable. Enter keeps the channel -the box already uses. A closed terminal also keeps that channel. - -backup makes a verified full archive while Core keeps running. ---output-dir copies it off the machine and checks the SHA-256. -Without --output-dir the archive stays on the box. - -doctor reports whether Core, history and drivers are fit to run. - -support writes Core's redacted diagnostic zip. --report writes the -shorter Markdown report instead. The file mode is 0600. - -startup prints a systemd unit and the shell lines that install it. -It does not change the machine. Paste the lines after you have read them. - ---port chooses the HTTP port. --url is the full address of a running Core. -Use one of them. -` - -// Run is the ftw command line. -func Run(args []string, stdout, stderr io.Writer) error { - if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { - fmt.Fprint(stdout, usageText) - return nil - } - switch args[0] { - case "update": - return runUpdate(args[1:], stdout, stderr) - case "backup": - return runBackup(args[1:], stdout) - case "doctor": - return runDoctor(args[1:], stdout) - case "support": - return runSupport(args[1:], stdout) - case "startup": - return runStartup(args[1:], stdout) - default: - fmt.Fprint(stderr, usageText) - return fmt.Errorf("unknown command %s", args[0]) - } -} - -func runUpdate(args []string, stdout, stderr io.Writer) error { - if hasHelp(args) { - fmt.Fprint(stdout, "usage: ftw update [--channel beta|stable] [--url http://127.0.0.1:8080]\n") - return nil - } - opts, _, err := commonOpts(args, map[string]bool{"--channel": true}) - if err != nil { - return err - } - channel := flagValue(args, "--channel") - if channel == "" { - info, err := peekVersion(opts) - if err != nil { - return err - } - if !info.Native { - return notNative - } - chosen, err := promptChannel(os.Stdin, stderr, info.Channel) - if err != nil { - return err - } - channel = chosen - } - if channel != "" && channel != "beta" && channel != "stable" { - return fmt.Errorf("--channel must be beta or stable, got %s", channel) - } - opts.Channel = channel - return Update(context.Background(), opts, stdout) -} - -func promptChannel(in io.Reader, out io.Writer, current string) (string, error) { - if current != "beta" && current != "stable" { - current = "beta" - } - fmt.Fprintf(out, "Release channel: beta or stable [%s]: ", current) - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && strings.TrimSpace(line) == "" { - fmt.Fprintf(out, "Keeping %s.\n", current) - return current, nil - } - line = strings.TrimSpace(line) - if line == "" { - return current, nil - } - if line != "beta" && line != "stable" { - return "", fmt.Errorf("channel must be beta or stable, got %s", line) - } - return line, nil -} - -func peekVersion(opts Options) (versionInfo, error) { - prepare(&opts) - var info versionInfo - err := getJSON(context.Background(), opts.Client, opts, "/api/version/check", &info) - return info, err -} - -func hasHelp(args []string) bool { - for _, arg := range args { - if arg == "--help" || arg == "-h" { - return true - } - } - return false -} - -func flagValue(args []string, name string) string { - for i := 0; i < len(args); i++ { - if args[i] == name && i+1 < len(args) { - return args[i+1] - } - } - return "" -} - -func parseCommon(args []string, opts Options, extra map[string]bool) (Options, error) { - for i := 0; i < len(args); i++ { - switch args[i] { - case "--url": - i++ - if i >= len(args) { - return opts, fmt.Errorf("--url needs an address") - } - opts.URL = stringsTrimRightSlash(args[i]) - case "--port": - if !extra["--port"] { - return opts, fmt.Errorf("unknown argument --port") - } - i++ - if i >= len(args) { - return opts, fmt.Errorf("--port needs a port") - } - case "--channel": - if !extra["--channel"] { - return opts, fmt.Errorf("unknown argument %s", args[i]) - } - i++ - if i >= len(args) { - return opts, fmt.Errorf("--channel needs beta or stable") - } - case "--output-dir", "--output": - if !extra[args[i]] { - return opts, fmt.Errorf("unknown argument %s", args[i]) - } - i++ - if i >= len(args) { - return opts, fmt.Errorf("%s needs a path", args[i-1]) - } - case "--report": - if !extra["--report"] { - return opts, fmt.Errorf("unknown argument --report") - } - case "--help", "-h": - default: - return opts, fmt.Errorf("unknown argument %s", args[i]) - } - } - return opts, nil -} - -func stringsTrimRightSlash(s string) string { - for len(s) > 0 && s[len(s)-1] == '/' { - s = s[:len(s)-1] - } - return s -} - -var notNative = errNative{} - -type errNative struct{} - -func (errNative) Error() string { - return "this Core is not a native install; ftw update does not start the Docker updater" -} diff --git a/go/internal/updatecli/commands.go b/go/internal/updatecli/commands.go deleted file mode 100644 index 58f950120..000000000 --- a/go/internal/updatecli/commands.go +++ /dev/null @@ -1,400 +0,0 @@ -package updatecli - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" -) - -func runBackup(args []string, stdout io.Writer) error { - if hasHelp(args) { - fmt.Fprint(stdout, "usage: ftw backup [--output-dir DIR] [--url URL] [--port PORT]\n") - return nil - } - opts, outputDir, err := commonOpts(args, map[string]bool{"--output-dir": true}) - if err != nil { - return err - } - return Backup(context.Background(), opts, outputDir, stdout) -} - -func runDoctor(args []string, stdout io.Writer) error { - if hasHelp(args) { - fmt.Fprint(stdout, "usage: ftw doctor [--url URL] [--port PORT]\n") - return nil - } - opts, _, err := commonOpts(args, nil) - if err != nil { - return err - } - return Doctor(context.Background(), opts, stdout) -} - -func runSupport(args []string, stdout io.Writer) error { - if hasHelp(args) { - fmt.Fprint(stdout, "usage: ftw support [--output FILE] [--report] [--url URL] [--port PORT]\n") - return nil - } - opts, output, err := commonOpts(args, map[string]bool{"--output": true, "--report": true}) - if err != nil { - return err - } - report := false - for _, arg := range args { - if arg == "--report" { - report = true - } - } - return Support(context.Background(), opts, output, report, stdout) -} - -func runStartup(args []string, stdout io.Writer) error { - if hasHelp(args) { - fmt.Fprint(stdout, "usage: ftw startup [--root DIR] [--config FILE] [--user-drivers DIR] [--user USER] [--port PORT]\n") - return nil - } - spec := startupSpec{Root: "/opt/ftw", Config: "/var/lib/ftw/config.yaml", UserDrivers: "/var/lib/ftw/drivers", User: "ftw"} - for i := 0; i < len(args); i++ { - take := func() (string, error) { - i++ - if i >= len(args) { - return "", fmt.Errorf("%s needs a value", args[i-1]) - } - return args[i], nil - } - switch args[i] { - case "--root": - v, err := take() - if err != nil { - return err - } - spec.Root = v - case "--config": - v, err := take() - if err != nil { - return err - } - spec.Config = v - case "--user-drivers": - v, err := take() - if err != nil { - return err - } - spec.UserDrivers = v - case "--user": - v, err := take() - if err != nil { - return err - } - spec.User = v - case "--port": - v, err := take() - if err != nil { - return err - } - spec.Port = v - default: - return fmt.Errorf("unknown argument %s", args[i]) - } - } - fmt.Fprint(stdout, startupScript(spec)) - return nil -} - -type startupSpec struct { - Root, Config, UserDrivers, User, Port string -} - -func startupScript(spec startupSpec) string { - exec := fmt.Sprintf("%s/ftw-launcher -root %s -config %s -user-drivers %s", spec.Root, spec.Root, spec.Config, spec.UserDrivers) - if spec.Port != "" { - exec += " run -port " + spec.Port - } - unit := fmt.Sprintf(`[Unit] -Description=FTW local energy runtime -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=%s -Group=%s -WorkingDirectory=%s -ExecStart=%s -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -`, spec.User, spec.User, filepath.Dir(spec.Config), exec) - return fmt.Sprintf(`# Review the paths, then paste this into a shell on the FTW machine. -# It installs a systemd service that keeps Core running. Updating is still: ftw update - -sudo tee /etc/systemd/system/ftw.service >/dev/null <<'EOF' -%sEOF -sudo systemctl daemon-reload -sudo systemctl enable --now ftw.service -`, unit) -} - -func commonOpts(args []string, extra map[string]bool) (Options, string, error) { - if extra == nil { - extra = map[string]bool{} - } - extra["--port"] = true - opts := Options{URL: "http://127.0.0.1:8080", MaxWait: time.Hour} - opts, err := parseCommon(args, opts, extra) - if err != nil { - return opts, "", err - } - port := flagValue(args, "--port") - url := flagValue(args, "--url") - if port != "" && url != "" { - return opts, "", errors.New("use either --port or --url") - } - if port != "" { - opts.URL = "http://127.0.0.1:" + port - } - output := flagValue(args, "--output-dir") - if output == "" { - output = flagValue(args, "--output") - } - return opts, output, nil -} - -func Backup(ctx context.Context, opts Options, outputDir string, out io.Writer) error { - prepare(&opts) - fmt.Fprintln(out, "Starting full backup. Core stays running.") - type created struct { - Warning string `json:"warning"` - Backup struct { - ID string `json:"id"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes"` - Verified bool `json:"verified"` - } `json:"backup"` - } - done := make(chan error, 1) - var body created - go func() { - done <- postJSON(ctx, opts.Client, opts, "/api/backups", map[string]any{}, &body) - }() - var last string - start := time.Now() - if opts.Now != nil { - start = opts.Now() - } - for { - select { - case err := <-done: - if err != nil { - return err - } - if body.Warning != "" { - fmt.Fprintln(out, body.Warning) - } - if !body.Backup.Verified || !validBackupID(body.Backup.ID) || len(body.Backup.SHA256) != 64 { - return errors.New("Core did not return a verified backup") - } - fmt.Fprintf(out, "Backup on the box: %s (%d bytes, SHA-256 %s)\n", body.Backup.ID, body.Backup.SizeBytes, body.Backup.SHA256) - if outputDir == "" { - return nil - } - return downloadBackup(ctx, opts, outputDir, body.Backup.ID, body.Backup.SHA256, body.Backup.SizeBytes, out) - case <-time.After(2 * time.Second): - var list struct { - Progress struct { - Phase string `json:"phase"` - CompletedBytes int64 `json:"completed_bytes"` - TotalBytes int64 `json:"total_bytes"` - Table string `json:"table"` - RowsDone int64 `json:"rows_done"` - Error string `json:"error"` - } `json:"progress"` - } - if err := getJSON(ctx, opts.Client, opts, "/api/backups", &list); err != nil { - continue - } - now := time.Now() - if opts.Now != nil { - now = opts.Now() - } - line := backupLine(list.Progress.Phase, list.Progress.Table, list.Progress.RowsDone, list.Progress.CompletedBytes, list.Progress.TotalBytes, now.Sub(start)) - if list.Progress.Error != "" { - return errors.New(list.Progress.Error) - } - if line != last && list.Progress.Phase != "" { - fmt.Fprintln(out, line) - last = line - } - } - } -} - -func backupLine(phase, table string, rows, done, total int64, elapsed time.Duration) string { - parts := []string{fmt.Sprintf("[%s]", elapsed.Round(time.Second)), "backup:", phase} - if table != "" { - parts = append(parts, table) - } - if rows > 0 { - parts = append(parts, fmt.Sprintf("%d rows", rows)) - } - if done > 0 || total > 0 { - if total > 0 { - parts = append(parts, fmt.Sprintf("%d/%d bytes", done, total)) - } else { - parts = append(parts, fmt.Sprintf("%d bytes, total unknown", done)) - } - } - return strings.Join(parts, " ") -} - -func validBackupID(id string) bool { - return strings.HasPrefix(id, "ftw-full-backup-") && strings.HasSuffix(id, ".ftwbak") && !strings.Contains(id, "/") && !strings.Contains(id, "..") -} - -func downloadBackup(ctx context.Context, opts Options, outputDir, id, expect string, size int64, out io.Writer) error { - if err := os.MkdirAll(outputDir, 0o700); err != nil { - return err - } - target := filepath.Join(outputDir, id) - pending := target + ".part" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+"/api/backups/"+id, nil) - if err != nil { - return err - } - if opts.Token != "" { - req.Header.Set("Authorization", "Bearer "+opts.Token) - } - resp, err := opts.Client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return fmt.Errorf("download %s", resp.Status) - } - f, err := os.OpenFile(pending, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - return err - } - sum := sha256.New() - n, copyErr := io.Copy(io.MultiWriter(f, sum), resp.Body) - closeErr := f.Close() - if copyErr != nil || closeErr != nil { - _ = os.Remove(pending) - if copyErr != nil { - return copyErr - } - return closeErr - } - got := hex.EncodeToString(sum.Sum(nil)) - if got != expect || (size > 0 && n != size) { - _ = os.Remove(pending) - return errors.New("downloaded backup does not match Core's verified size and SHA-256") - } - if err := os.Rename(pending, target); err != nil { - _ = os.Remove(pending) - return err - } - fmt.Fprintf(out, "Backup saved: %s\nSHA-256 %s\n", target, expect) - return nil -} - -func Doctor(ctx context.Context, opts Options, out io.Writer) error { - prepare(&opts) - var info versionInfo - var health struct { - Status string `json:"status"` - DriversOK int `json:"drivers_ok"` - DriversOffline int `json:"drivers_offline"` - DriversFaulted int `json:"drivers_faulted"` - History struct { - Migration struct { - State string `json:"state"` - } `json:"migration"` - Writer struct { - CommitFailures int64 `json:"commit_failures"` - } `json:"writer"` - } `json:"history_storage"` - } - if err := getJSON(ctx, opts.Client, opts, "/api/version/check", &info); err != nil { - fmt.Fprintf(out, "core: not reachable (%s)\n", err) - return err - } - if err := getJSON(ctx, opts.Client, opts, "/api/health", &health); err != nil { - fmt.Fprintf(out, "health: not readable (%s)\n", err) - return err - } - native := "no" - if info.Native { - native = "yes" - } - fmt.Fprintf(out, "core: reachable\nversion: %s\nnative: %s\nchannel: %s\nhealth: %s\ndrivers: %d ok, %d offline, %d faulted\nhistory: %s\nwrite failures: %d\n", - info.Current, native, info.Channel, health.Status, health.DriversOK, health.DriversOffline, health.DriversFaulted, - health.History.Migration.State, health.History.Writer.CommitFailures) - if info.UpdateAvailable { - fmt.Fprintf(out, "update: %s is published. Run ftw update\n", info.Latest) - } else { - fmt.Fprintln(out, "update: current") - } - if !info.Native { - fmt.Fprintln(out, "note: this is not a native install") - } - if health.Status != "ok" || health.DriversFaulted > 0 { - return errors.New("doctor found a problem") - } - return nil -} - -func Support(ctx context.Context, opts Options, output string, report bool, out io.Writer) error { - prepare(&opts) - path := "/api/support/dump" - name := "ftw-support.zip" - if report { - path = "/api/support/report" - name = "ftw-support.md" - } - if output == "" { - output = name - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+path, nil) - if err != nil { - return err - } - if opts.Token != "" { - req.Header.Set("Authorization", "Bearer "+opts.Token) - } - resp, err := opts.Client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("support: %s", strings.TrimSpace(string(raw))) - } - f, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err != nil { - return err - } - n, copyErr := io.Copy(f, resp.Body) - closeErr := f.Close() - if copyErr != nil || closeErr != nil { - _ = os.Remove(output) - if copyErr != nil { - return copyErr - } - return closeErr - } - fmt.Fprintf(out, "Support file: %s (%d bytes)\nCore redacts secrets before writing this. It still describes the site.\n", output, n) - return nil -} diff --git a/go/internal/updatecli/update.go b/go/internal/updatecli/update.go deleted file mode 100644 index 84a66e80a..000000000 --- a/go/internal/updatecli/update.go +++ /dev/null @@ -1,228 +0,0 @@ -// Package updatecli is the `ftw update` command. It asks the running native -// Core to download the next release and swap to it. It does not start a -// Docker updater. -package updatecli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// Options controls one update. -type Options struct { - URL string - Channel string - Token string - MaxWait time.Duration - Now func() time.Time - Sleep func(time.Duration) - Client *http.Client -} - -type versionInfo struct { - Current string `json:"current"` - Native bool `json:"native"` - Channel string `json:"channel"` - Latest string `json:"latest"` - UpdateAvailable bool `json:"update_available"` - FullBackupRequired bool `json:"full_backup_required"` - Err string `json:"err"` -} - -type updateStatus struct { - State string `json:"state"` - Message string `json:"message"` - Target string `json:"target"` - Step int `json:"step"` - TotalSteps int `json:"total_steps"` - ProgressCurrent int64 `json:"progress_current"` - ProgressTotal int64 `json:"progress_total"` - ProgressUnit string `json:"progress_unit"` -} - -// Update talks to a running Core. A non-native install is refused so this -// command cannot start the old Docker updater. -func Update(ctx context.Context, opts Options, out io.Writer) error { - if opts.URL == "" { - opts.URL = "http://127.0.0.1:8080" - } - if opts.MaxWait <= 0 { - opts.MaxWait = time.Hour - } - if opts.Now == nil { - opts.Now = time.Now - } - if opts.Sleep == nil { - opts.Sleep = time.Sleep - } - client := opts.Client - if client == nil { - client = http.DefaultClient - } - if opts.Channel != "" { - if err := postJSON(ctx, client, opts, "/api/version/channel", map[string]string{"channel": opts.Channel}); err != nil { - return err - } - } - var info versionInfo - if err := getJSON(ctx, client, opts, "/api/version/check?force=1", &info); err != nil { - return err - } - if info.Err != "" { - return errors.New(info.Err) - } - if !info.Native { - return errors.New("this Core is not a native install; ftw update does not start the Docker updater") - } - if !info.UpdateAvailable { - fmt.Fprintf(out, "Already current on %s: %s\n", info.Channel, info.Current) - return nil - } - if info.FullBackupRequired { - return fmt.Errorf("updating to %s changes stored data; take a verified full backup off the machine before ftw update", info.Latest) - } - fmt.Fprintf(out, "Updating %s -> %s on %s\n", info.Current, info.Latest, info.Channel) - var started struct { - Target string `json:"target"` - } - if err := postJSON(ctx, client, opts, "/api/version/update", map[string]any{}, &started); err != nil { - return err - } - if started.Target != "" && started.Target != info.Latest { - return fmt.Errorf("Core accepted %s, not %s", started.Target, info.Latest) - } - start := opts.Now() - deadline := start.Add(opts.MaxWait) - var last string - for { - now := opts.Now() - if now.After(deadline) { - return errors.New("update did not finish; Core may still be working") - } - var st updateStatus - if err := getJSON(ctx, client, opts, "/api/version/update/status", &st); err != nil { - st.State = "restarting" - st.Message = err.Error() - } - line := formatStatus(st, now.Sub(start)) - if line != last { - fmt.Fprintln(out, line) - last = line - } - switch st.State { - case "done": - var health struct { - Status string `json:"status"` - } - var after versionInfo - if err := getJSON(ctx, client, opts, "/api/health", &health); err != nil { - return err - } - if err := getJSON(ctx, client, opts, "/api/version/check", &after); err != nil { - return err - } - if after.Current != info.Latest || health.Status != "ok" { - return fmt.Errorf("update ended as %s, health %s", after.Current, health.Status) - } - fmt.Fprintf(out, "Update complete: %s\n", after.Current) - return nil - case "failed": - if st.Message == "" { - st.Message = "update failed" - } - return errors.New(st.Message) - } - opts.Sleep(2 * time.Second) - } -} - -func formatStatus(st updateStatus, elapsed time.Duration) string { - parts := []string{fmt.Sprintf("[%s]", elapsed.Round(time.Second)), st.State} - if st.Step > 0 && st.TotalSteps > 0 { - parts = append(parts, fmt.Sprintf("step %d/%d", st.Step, st.TotalSteps)) - } - if st.ProgressUnit == "bytes" && (st.ProgressCurrent > 0 || st.ProgressTotal > 0) { - if st.ProgressTotal > 0 { - parts = append(parts, fmt.Sprintf("%d/%d bytes", st.ProgressCurrent, st.ProgressTotal)) - } else { - parts = append(parts, fmt.Sprintf("%d bytes, total unknown", st.ProgressCurrent)) - } - } - if st.Message != "" { - parts = append(parts, st.Message) - } - return strings.Join(parts, " ") -} - -func prepare(opts *Options) { - if opts.URL == "" { - opts.URL = "http://127.0.0.1:8080" - } - if opts.Client == nil { - opts.Client = http.DefaultClient - } -} - -func getJSON(ctx context.Context, client *http.Client, opts Options, path string, dest any) error { - if client == nil { - client = http.DefaultClient - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL+path, nil) - if err != nil { - return err - } - return doJSON(client, opts, req, dest) -} - -func postJSON(ctx context.Context, client *http.Client, opts Options, path string, body any, dest ...any) error { - raw, err := json.Marshal(body) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.URL+path, bytes.NewReader(raw)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - var out any - if len(dest) > 0 { - out = dest[0] - } - return doJSON(client, opts, req, out) -} - -func doJSON(client *http.Client, opts Options, req *http.Request, dest any) error { - if opts.Token != "" { - req.Header.Set("Authorization", "Bearer "+opts.Token) - } - resp, err := client.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 >= 300 { - var apiErr struct { - Error string `json:"error"` - } - _ = json.Unmarshal(payload, &apiErr) - if apiErr.Error == "" { - apiErr.Error = strings.TrimSpace(string(payload)) - } - return fmt.Errorf("%s %s: %s", req.Method, req.URL.Path, apiErr.Error) - } - if dest == nil || len(payload) == 0 { - return nil - } - return json.Unmarshal(payload, dest) -} diff --git a/go/internal/updatecli/update_test.go b/go/internal/updatecli/update_test.go deleted file mode 100644 index 3af19ae61..000000000 --- a/go/internal/updatecli/update_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package updatecli - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestUpdateRefusesTheDockerUpdater(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/version/check" { - t.Errorf("path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"current":"v3.8.0-beta.1","native":false,"channel":"beta"}`)) - })) - defer srv.Close() - err := Update(t.Context(), Options{URL: srv.URL, Client: srv.Client(), Sleep: func(time.Duration) {}}, &strings.Builder{}) - if err == nil || !strings.Contains(err.Error(), "does not start the Docker updater") { - t.Fatal(err) - } -} - -func TestUpdatePrintsPhasesAndStopsWhenCurrent(t *testing.T) { - var posts []string - step := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/version/channel": - posts = append(posts, r.URL.Path) - _, _ = w.Write([]byte(`{}`)) - case "/api/version/check": - if step == 0 { - _, _ = w.Write([]byte(`{"current":"v0.132.0-beta.1","native":true,"channel":"beta","latest":"v0.132.2-beta.1","update_available":true}`)) - return - } - _, _ = w.Write([]byte(`{"current":"v0.132.2-beta.1","native":true,"channel":"beta"}`)) - case "/api/version/update": - posts = append(posts, r.URL.Path) - _, _ = w.Write([]byte(`{"target":"v0.132.2-beta.1"}`)) - case "/api/version/update/status": - step++ - if step == 1 { - _, _ = w.Write([]byte(`{"state":"pulling","message":"downloading","step":2,"total_steps":4,"progress_current":10,"progress_unit":"bytes"}`)) - return - } - _, _ = w.Write([]byte(`{"state":"done","message":"ready"}`)) - case "/api/health": - _, _ = w.Write([]byte(`{"status":"ok"}`)) - default: - t.Errorf("unexpected %s", r.URL.Path) - w.WriteHeader(404) - } - })) - defer srv.Close() - var out strings.Builder - err := Update(t.Context(), Options{ - URL: srv.URL, Channel: "beta", Client: srv.Client(), - Now: func() time.Time { return time.Unix(0, 0) }, Sleep: func(time.Duration) {}, - }, &out) - if err != nil { - t.Fatal(err) - } - if strings.Join(posts, ",") != "/api/version/channel,/api/version/update" { - t.Fatalf("posts %v", posts) - } - text := out.String() - if !strings.Contains(text, "Updating v0.132.0-beta.1 -> v0.132.2-beta.1") || !strings.Contains(text, "pulling") || !strings.Contains(text, "total unknown") || !strings.Contains(text, "Update complete: v0.132.2-beta.1") { - t.Fatalf("output:\n%s", text) - } -} - -func TestHelpAndStartupAndChannelPrompt(t *testing.T) { - var out strings.Builder - if err := Run(nil, &out, &strings.Builder{}); err != nil { - t.Fatal(err) - } - for _, part := range []string{"ftw update", "ftw backup", "ftw doctor", "ftw support", "ftw startup", "--channel", "--port"} { - if !strings.Contains(out.String(), part) { - t.Fatalf("help missing %s\n%s", part, out.String()) - } - } - out.Reset() - if err := Run([]string{"startup", "--port", "9090", "--root", "/opt/ftw-native"}, &out, &strings.Builder{}); err != nil { - t.Fatal(err) - } - script := out.String() - for _, part := range []string{"sudo tee /etc/systemd/system/ftw.service", "ftw-launcher", "/opt/ftw-native", "run -port 9090", "systemctl enable --now"} { - if !strings.Contains(script, part) { - t.Fatalf("startup missing %s\n%s", part, script) - } - } - chosen, err := promptChannel(strings.NewReader("\n"), &strings.Builder{}, "beta") - if err != nil || chosen != "beta" { - t.Fatalf("empty prompt = %s %v", chosen, err) - } - chosen, err = promptChannel(strings.NewReader("stable\n"), &strings.Builder{}, "beta") - if err != nil || chosen != "stable" { - t.Fatalf("stable prompt = %s %v", chosen, err) - } -} 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 f18235780..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,7 @@ _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; @@ -908,21 +914,38 @@ `; } - // A native install updates itself. The old Updates panel belonged to the - // Docker updater and is not the way to move this box. + // 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 current = info.current || "unknown"; - const channel = info.channel || "beta"; + 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 available = info.update_available - ? `

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

-

On the FTW machine, run:

-

ftw update

-

Core downloads that release and swaps to it. There is no separate updater.

` - : `

No newer release is published on ${escapeHTML(channel)}.

`; + 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 || {}; @@ -1746,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 97c405dc5..cc3567def 100644
--- a/web/update-progress.test.mjs
+++ b/web/update-progress.test.mjs
@@ -16,9 +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, /class="mono">ftw update<\/p>/);
-  assert.match(badge, /There is no separate updater/);
   assert.match(badge, /Total:/);
   assert.match(badge, /Saving rollback point \(settings and config; history stays in place\)/);
   assert.doesNotMatch(badge, /full history backup/);
@@ -37,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+\? `