Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ftw-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

A native install now has the `ftw` command on the machine: `ftw status`, `ftw update`, `ftw rollback`, `ftw backup` and `ftw support`. Updates on a native install are run there, by hand or from the owner's own automation; `ftw update` asks no questions, waits through the restart and exits 0 when the box is current. The web version panel on a native install shows the running version, a published release and the command, without update, rollback, channel or backup controls, and setup no longer offers an update. A native update that is slow to start is no longer reported as failed after five minutes. Starting a second Core on a port FTW already holds says that FTW is already running.
3 changes: 2 additions & 1 deletion .github/workflows/core-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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=$?
Expand Down
12 changes: 12 additions & 0 deletions docs/backup-and-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 21 additions & 15 deletions docs/self-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions go/cmd/ftw-cli/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Command ftw-cli is installed on PATH as `ftw`, the operator command for a
// native install. Core itself is releases/<tag>/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))
}
53 changes: 53 additions & 0 deletions go/cmd/ftw/listen_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"syscall"
"time"
)

// explainBindError names the usual reason the HTTP port is taken: FTW is
// already running on this machine, most often as the service.
func explainBindError(addr string, bindErr error) error {
if !errors.Is(bindErr, syscall.EADDRINUSE) {
return fmt.Errorf("HTTP port %s could not be opened: %w", addr, bindErr)
}
_, port, err := net.SplitHostPort(addr)
if err != nil {
port = strings.TrimPrefix(addr, ":")
}
if !answersAsFTW(port) {
return fmt.Errorf("port %s is in use by a program that does not answer as FTW; stop it or give FTW another api.port", port)
}
check := "ftw status"
if port != "8080" {
check += " --url http://127.0.0.1:" + port
}
return fmt.Errorf("FTW is already running on this machine at http://127.0.0.1:%s, so this copy did not start. Check it with: %s", port, check)
}

// answersAsFTW asks /api/health, which a Core still opening its state also
// answers.
func answersAsFTW(port string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port+"/api/health", nil)
if err != nil {
return false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
var health struct {
Status string `json:"status"`
}
return json.NewDecoder(resp.Body).Decode(&health) == nil && health.Status != ""
}
42 changes: 42 additions & 0 deletions go/cmd/ftw/listen_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"syscall"
"testing"
)

func TestExplainBindErrorNamesATakenPortThatIsNotFTW(t *testing.T) {
err := explainBindError("127.0.0.1:1", &net.OpError{Err: syscall.EADDRINUSE})
if err == nil || !strings.Contains(err.Error(), "does not answer as FTW") || !strings.Contains(err.Error(), "api.port") {
t.Fatal(err)
}
}

func TestExplainBindErrorRecognisesAStartingFTW(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/health" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"phase":"initializing state","status":"starting"}`))
}))
defer srv.Close()
_, port, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
err := explainBindError(":"+port, &net.OpError{Err: syscall.EADDRINUSE})
if err == nil || !strings.Contains(err.Error(), "FTW is already running") ||
!strings.Contains(err.Error(), "ftw status --url http://127.0.0.1:"+port) {
t.Fatal(err)
}
}

func TestExplainBindErrorKeepsOtherFailures(t *testing.T) {
cause := &net.OpError{Err: syscall.EACCES}
if err := explainBindError(":80", cause); !errors.Is(err, syscall.EACCES) {
t.Fatal(err)
}
}
3 changes: 2 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ func main() {
)
listener, err := net.Listen("tcp", httpSrv.Addr)
if err != nil {
slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", err)
slog.Error("http listener could not bind", "addr", httpSrv.Addr, "err", explainBindError(httpSrv.Addr, err))
os.Exit(1)
}
go func() {
Expand Down Expand Up @@ -2359,6 +2359,7 @@ func main() {
SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"),
StatusPath: statusPath,
NativeRoot: nativeRoot,
NativeTrialTimeout: nativeTrialTimeout,
NativeRestart: func() error {
restartOnce.Do(func() {
reexecAfterShutdown, exitCode = false, 1
Expand Down
Loading
Loading