Merge pull request #235 from carlosframework/windows-release
windows: a client-only carlos, behind per-OS boundaries
13 files changed,
+270
−53
.github/workflows/ci.yml+9 −0README.md+34 −0internal/edge/purge.go+1 −31internal/edge/purge_listen_unix.go+50 −0internal/edge/purge_listen_windows.go+22 −0internal/host/flock_unix.go+15 −0internal/host/flock_windows.go+21 −0internal/host/probes.go+8 −17internal/host/restart.go+5 −5internal/host/statfs_unix.go+21 −0internal/host/statfs_windows.go+15 −0update.go+30 −0update_test.go+39 −0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml| index e65c7670b..b2d1ff026 100644 |
| --- a/.github/workflows/ci.yml |
| +++ b/.github/workflows/ci.yml |
| @@ -53,6 +53,15 @@ jobs: |
| - {goos: darwin, goarch: arm64} |
| - {goos: darwin, goarch: amd64} |
| - {goos: linux, goarch: arm64} |
| + # Windows is a CLIENT target: `carlos deploy` from a Windows |
| + # workstation against a Linux fleet. The box-side packages still |
| + # have to COMPILE there because the CLI is the same binary as the |
| + # agent, so unix-only syscalls live behind per-OS files |
| + # (internal/edge/purge_listen_*.go, internal/host/{statfs,flock}_*.go). |
| + # This job is what stops the next syscall.Foo from silently |
| + # un-shipping the Windows build. |
| + - {goos: windows, goarch: amd64} |
| + - {goos: windows, goarch: arm64} |
| steps: |
| - uses: actions/checkout@v4 |
| - uses: actions/setup-go@v5 |
diff --git a/README.md b/README.md| index 2de3a42bc..63cf13b2e 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -22,6 +22,40 @@ One static binary plays every role, selected by subcommand: `carlos edge`, |
| process per host), `carlos ship`/`promote`/`rollback`, `carlos vet`. Run |
| `carlos` with no arguments for the full command list. |
| +### Platforms |
| + |
| +Linux and macOS run every role — box and workstation alike — and install |
| +from apt (`sudo apt install carlos`) or Homebrew |
| +(`brew install carlosframework/tap/carlos`). |
| + |
| +**Windows is a client-only target.** A Windows workstation can `carlos |
| +login`, `carlos ship`, `carlos deploy`, `carlos logs` — everything that |
| +talks to a console or a bucket over the network — against a Linux fleet. |
| +It cannot BE a box: `carlos agent` and the root-side passes need unix |
| +sockets, `flock` and systemd, and the packages that provide them compile |
| +on Windows (the CLI is the same binary) but refuse at their per-OS |
| +boundary rather than pretending. See `internal/edge/purge_listen_windows.go` |
| +and `internal/host/{statfs,flock}_windows.go` for what that refusal means |
| +in each case. |
| + |
| +Two consequences worth knowing before you install it: |
| + |
| +- **`carlos update` does not self-replace on Windows.** It still checks the |
| + release feed and tells you when you are behind, then hands you the URL of |
| + the zip to unpack over your existing binary. Windows cannot rename a new |
| + binary over a running `.exe`, and a self-replace nobody here can test is |
| + worse than an honest manual step. |
| +- **Your credentials file is not permission-protected on Windows.** On Linux |
| + and macOS `~/.carlos/credentials` is written `0600`; the equivalent mode |
| + on `%USERPROFILE%\.carlos\credentials` means nothing, so any other user |
| + of that machine can read your session. Do not `carlos login` on a shared |
| + Windows machine. |
| + |
| +Install by downloading `carlos-windows-amd64.zip` (or `-arm64`) from |
| +[carlosframework/releases](https://github.com/carlosframework/releases/releases), |
| +unpacking `carlos.exe` somewhere on your `PATH`, and verifying its checksum |
| +against the release's signed `SHA256SUMS.txt`. |
| + |
| ## Accounts |
| An **account** is the tenancy primitive: a project or team that owns apps and |
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| index 471876ac0..b5ef0d77a 100644 |
| --- a/internal/edge/purge.go |
| +++ b/internal/edge/purge.go |
| @@ -2,12 +2,8 @@ package edge |
| import ( |
| "encoding/json" |
| - "errors" |
| "net" |
| "net/http" |
| - "os" |
| - "sync" |
| - "syscall" |
| ) |
| // PurgeListener is a per-instance, local-only channel for invalidating |
| @@ -27,13 +23,6 @@ type purgeRequest struct { |
| Query string `json:"query"` |
| } |
| -// listenMu guards the umask-wrapped net.Listen call in ListenPurge. Since |
| -// syscall.Umask is process-wide and not per-goroutine, concurrent ListenPurge |
| -// calls (e.g., in carlos agent spawning multiple instances) must serialize |
| -// around the umask change to avoid race windows where the socket is created |
| -// world-connectable before being chmod'd to 0600. |
| -var listenMu sync.Mutex |
| - |
| // ListenPurge binds socketPath (removing any stale file left behind by a |
| // crashed previous instance) and serves purge requests scoped to host. |
| // |
| @@ -74,30 +63,11 @@ var listenMu sync.Mutex |
| // result (see backend_unit.go); their cached routes rely on the TTL |
| // ceiling alone until that gets its own design. |
| func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { |
| - if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return nil, err |
| - } |
| - |
| - // Create the socket with umask 0177 to prevent a world-connectable race window. |
| - // syscall.Umask is process-wide, so we serialize around it with listenMu |
| - // to protect against concurrent ListenPurge calls (e.g., in carlos agent |
| - // spawning multiple instances). |
| - listenMu.Lock() |
| - prevUmask := syscall.Umask(0177) |
| - ln, err := net.Listen("unix", socketPath) |
| - syscall.Umask(prevUmask) |
| - listenMu.Unlock() |
| - |
| + ln, err := secureUnixListen(socketPath) |
| if err != nil { |
| return nil, err |
| } |
| - // Defensive chmod as backstop (no longer load-bearing, but kept for defense-in-depth). |
| - if err := os.Chmod(socketPath, 0600); err != nil { |
| - ln.Close() |
| - return nil, err |
| - } |
| - |
| mux := http.NewServeMux() |
| mux.HandleFunc("POST /purge", func(w http.ResponseWriter, r *http.Request) { |
| var req purgeRequest |
diff --git a/internal/edge/purge_listen_unix.go b/internal/edge/purge_listen_unix.go| new file mode 100644 |
| index 000000000..5f37bb1f7 |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_unix.go |
| @@ -0,0 +1,50 @@ |
| +//go:build !windows |
| + |
| +package edge |
| + |
| +import ( |
| + "errors" |
| + "net" |
| + "os" |
| + "sync" |
| + "syscall" |
| +) |
| + |
| +// listenMu guards the umask-wrapped net.Listen below. Since syscall.Umask is |
| +// process-wide and not per-goroutine, concurrent ListenPurge calls (e.g., in |
| +// carlos agent spawning multiple instances) must serialize around the umask |
| +// change to avoid race windows where the socket is created world-connectable |
| +// before being chmod'd to 0600. |
| +var listenMu sync.Mutex |
| + |
| +// secureUnixListen binds socketPath as a unix socket reachable only by its |
| +// owner, removing any stale file left behind by a crashed previous instance. |
| +// |
| +// The owner-only guarantee is the whole point of the function and the reason |
| +// it is split out per-OS rather than shimmed at the syscall: a purge socket |
| +// that anyone on the box can connect to hands out exactly the wake-storm |
| +// lever this feature exists to remove. Making that guarantee unrepresentable |
| +// on an OS without umask is safer than making it a no-op there. |
| +func secureUnixListen(socketPath string) (net.Listener, error) { |
| + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + |
| + // Create the socket with umask 0177 to prevent a world-connectable race window. |
| + listenMu.Lock() |
| + prevUmask := syscall.Umask(0177) |
| + ln, err := net.Listen("unix", socketPath) |
| + syscall.Umask(prevUmask) |
| + listenMu.Unlock() |
| + |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + // Defensive chmod as backstop (no longer load-bearing, but kept for defense-in-depth). |
| + if err := os.Chmod(socketPath, 0600); err != nil { |
| + ln.Close() |
| + return nil, err |
| + } |
| + return ln, nil |
| +} |
diff --git a/internal/edge/purge_listen_windows.go b/internal/edge/purge_listen_windows.go| new file mode 100644 |
| index 000000000..59b875fb8 |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_windows.go |
| @@ -0,0 +1,22 @@ |
| +//go:build windows |
| + |
| +package edge |
| + |
| +import ( |
| + "errors" |
| + "net" |
| +) |
| + |
| +// secureUnixListen has no Windows implementation, and that is deliberate. |
| +// |
| +// Windows is a CLIENT target for carlos: the CLI is the same binary as the |
| +// box agent, so the whole edge package must COMPILE there, but no part of it |
| +// runs. Recent Windows does support AF_UNIX, which is precisely the hazard — |
| +// a listener would bind and serve while the umask that makes it owner-only |
| +// does not exist, quietly downgrading a security property into a no-op on |
| +// the one platform nobody here can test. An error at the boundary keeps the |
| +// guarantee honest. ListenPurge's caller (internal/activator/backend_exec.go) |
| +// already treats a failure here as "no purge listener". |
| +func secureUnixListen(socketPath string) (net.Listener, error) { |
| + return nil, errors.New("purge listeners need a unix socket with owner-only permissions; not supported on windows") |
| +} |
diff --git a/internal/host/flock_unix.go b/internal/host/flock_unix.go| new file mode 100644 |
| index 000000000..c5c5af0e4 |
| --- /dev/null |
| +++ b/internal/host/flock_unix.go |
| @@ -0,0 +1,15 @@ |
| +//go:build !windows |
| + |
| +package host |
| + |
| +import ( |
| + "os" |
| + "syscall" |
| +) |
| + |
| +// lockFileEx takes an exclusive advisory lock on f, and unlockFile drops it. |
| +// syscall.Flock is available on both linux and darwin, which covers every |
| +// platform that runs a restart pass. |
| +func lockFileEx(f *os.File) error { return syscall.Flock(int(f.Fd()), syscall.LOCK_EX) } |
| + |
| +func unlockFile(f *os.File) { syscall.Flock(int(f.Fd()), syscall.LOCK_UN) } //nolint:errcheck |
diff --git a/internal/host/flock_windows.go b/internal/host/flock_windows.go| new file mode 100644 |
| index 000000000..e139a3bd1 |
| --- /dev/null |
| +++ b/internal/host/flock_windows.go |
| @@ -0,0 +1,21 @@ |
| +//go:build windows |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "os" |
| +) |
| + |
| +// lockFileEx refuses on Windows rather than pretending to lock. Restart |
| +// passes are root units on a Linux box; the Windows build exists only so the |
| +// carlos CLI compiles for workstation use. Refusing is also the SAFE failure |
| +// here: lockRestartMarks's caller dispatches nothing when the lock cannot be |
| +// taken (restart.go's pass doc), so an unlockable platform cycles no |
| +// processes — whereas a no-op lock would claim serialization it does not |
| +// provide. |
| +func lockFileEx(f *os.File) error { |
| + return errors.New("restart-mark locking is box-side (linux) only") |
| +} |
| + |
| +func unlockFile(f *os.File) {} |
diff --git a/internal/host/probes.go b/internal/host/probes.go| index 5264137f3..3b3718eaa 100644 |
| --- a/internal/host/probes.go |
| +++ b/internal/host/probes.go |
| @@ -17,7 +17,6 @@ import ( |
| "os/exec" |
| "strconv" |
| "strings" |
| - "syscall" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -154,7 +153,7 @@ func readMemFrac() *float64 { |
| } |
| // readDiskFrac reports how full the data volume is (1 - available/total), |
| -// via syscall.Statfs on dataDir() — /data by default, CARLOS_DATA_DIR when |
| +// via diskBytes on dataDir() — /data by default, CARLOS_DATA_DIR when |
| // set, matching every other on-box path in this package. There is no |
| // existing "floor" config to mirror: ops/litestream (grepped before writing |
| // this) carries no reserved-space setting, so this is the plain fraction, |
| @@ -162,16 +161,11 @@ func readMemFrac() *float64 { |
| // missing dir) -> nil; a zero-block filesystem (shouldn't happen, but would |
| // divide by zero) also -> nil rather than a crash. |
| func readDiskFrac() *float64 { |
| - var st syscall.Statfs_t |
| - if err := syscall.Statfs(dataDir(), &st); err != nil { |
| + total, avail, err := diskBytes(dataDir()) |
| + if err != nil || total == 0 { |
| return nil |
| } |
| - if st.Blocks == 0 { |
| - return nil |
| - } |
| - total := float64(st.Blocks) * float64(st.Bsize) |
| - avail := float64(st.Bavail) * float64(st.Bsize) |
| - frac := 1 - avail/total |
| + frac := 1 - float64(avail)/float64(total) |
| return &frac |
| } |
| @@ -325,17 +319,14 @@ func readLoad1() *float64 { |
| } |
| // readDiskFreeBytes reports how much free space is available on the data |
| -// volume, via syscall.Statfs on dataDir() — /data by default, CARLOS_DATA_DIR |
| +// volume, via diskBytes on dataDir() — /data by default, CARLOS_DATA_DIR |
| // when set. Statfs failure or a zero-block filesystem -> nil rather than a |
| // crash. |
| func readDiskFreeBytes() *int64 { |
| - var st syscall.Statfs_t |
| - if err := syscall.Statfs(dataDir(), &st); err != nil { |
| - return nil |
| - } |
| - if st.Blocks == 0 { |
| + total, avail, err := diskBytes(dataDir()) |
| + if err != nil || total == 0 { |
| return nil |
| } |
| - freeBytes := int64(st.Bavail) * int64(st.Bsize) |
| + freeBytes := int64(avail) |
| return &freeBytes |
| } |
diff --git a/internal/host/restart.go b/internal/host/restart.go| index 7ebe61726..cfd75554e 100644 |
| --- a/internal/host/restart.go |
| +++ b/internal/host/restart.go |
| @@ -52,7 +52,6 @@ import ( |
| "log" |
| "os" |
| "path/filepath" |
| - "syscall" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -299,19 +298,20 @@ func restartMarkKey(acct, app string) string { return acct + "|" + app } |
| // anything is cycled — instead of after, which is the difference between one |
| // logged refusal and an hourly restart of every app on the box forever. |
| // |
| -// syscall.Flock is available on both linux and darwin, which is the whole |
| -// build matrix (CI cross-compiles darwin because the CLI runs on Macs). |
| +// The lock itself is taken through lockFileEx, which is per-OS: linux and |
| +// darwin flock, Windows refuses (see flock_windows.go — refusing dispatches |
| +// nothing, which is the safe answer on a platform that never runs a pass). |
| func lockRestartMarks(path string) (unlock func(), err error) { |
| f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o644) |
| if err != nil { |
| return nil, err |
| } |
| - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { |
| + if err := lockFileEx(f); err != nil { |
| f.Close() |
| return nil, err |
| } |
| return func() { |
| - syscall.Flock(int(f.Fd()), syscall.LOCK_UN) |
| + unlockFile(f) |
| f.Close() |
| }, nil |
| } |
diff --git a/internal/host/statfs_unix.go b/internal/host/statfs_unix.go| new file mode 100644 |
| index 000000000..4c9916994 |
| --- /dev/null |
| +++ b/internal/host/statfs_unix.go |
| @@ -0,0 +1,21 @@ |
| +//go:build !windows |
| + |
| +package host |
| + |
| +import "syscall" |
| + |
| +// diskBytes reports the total and available bytes of the filesystem holding |
| +// dir. |
| +// |
| +// It returns BYTES, not the raw statfs fields, and that is the point: Bsize |
| +// is int64 on Linux and uint32 on darwin, a difference that already broke |
| +// the CI build once (see .github/workflows/ci.yml's header) because the |
| +// multiplication happened at every call site instead of once here. Callers |
| +// now get two plain uint64s and no per-OS arithmetic. |
| +func diskBytes(dir string) (total, avail uint64, err error) { |
| + var st syscall.Statfs_t |
| + if err := syscall.Statfs(dir, &st); err != nil { |
| + return 0, 0, err |
| + } |
| + return uint64(st.Blocks) * uint64(st.Bsize), uint64(st.Bavail) * uint64(st.Bsize), nil |
| +} |
diff --git a/internal/host/statfs_windows.go b/internal/host/statfs_windows.go| new file mode 100644 |
| index 000000000..00777bc67 |
| --- /dev/null |
| +++ b/internal/host/statfs_windows.go |
| @@ -0,0 +1,15 @@ |
| +//go:build windows |
| + |
| +package host |
| + |
| +import "errors" |
| + |
| +// diskBytes has no Windows implementation: this package's disk readings |
| +// describe a CARLOS box's /data volume, and boxes are Linux. Windows builds |
| +// exist so the carlos CLI compiles for workstation use, where these probes |
| +// never run. The error path is the one both callers already take when |
| +// statfs fails, so nothing new happens here — readDiskFrac and |
| +// readDiskFreeBytes report "unknown" rather than a wrong number. |
| +func diskBytes(dir string) (total, avail uint64, err error) { |
| + return 0, 0, errors.New("disk readings are box-side (linux) only") |
| +} |
diff --git a/update.go b/update.go| index 31b1d198c..ec672e7f4 100644 |
| --- a/update.go |
| +++ b/update.go |
| @@ -42,6 +42,11 @@ var ( |
| // known-vulnerable release without a signature error). |
| updateDownloadBase = "https://github.com/carlosframework/releases/releases/download" |
| + // updateGOOS is runtime.GOOS behind a var for the same reason the URLs |
| + // above are: the Windows branch is otherwise unreachable from a test on |
| + // any machine we build on. Production never changes it. |
| + updateGOOS = runtime.GOOS |
| + |
| // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the |
| // public half of the dedicated CLI-update keypair (private half at SSM |
| // /carlos/cli-update-key — NOT the release key; different blast |
| @@ -183,6 +188,14 @@ func configUpdateCheckOff() (bool, error) { |
| // `carlos update` hands package-managed installs to their manager instead |
| // of fighting it. |
| func installMethod(exe string) string { |
| + // Windows first, before any path matching: there is no apt and no brew |
| + // in the Windows story, and no self-replace either (os.Rename cannot |
| + // overwrite a running .exe), so the zip that carried this binary here |
| + // is also how it leaves. A Windows path that happens to contain |
| + // /Cellar/ is a coincidence, not a formula. |
| + if updateGOOS == "windows" { |
| + return "zip" |
| + } |
| // Both brews keep formula binaries under a Cellar (/opt/homebrew/Cellar, |
| // ~/.linuxbrew/Cellar) — matching looser substrings ("/homebrew/") would |
| // also match unrelated paths that merely contain the word. |
| @@ -235,6 +248,16 @@ func runUpdate(args []string) { |
| fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos") |
| return |
| + case "zip": |
| + // The one install method whose update is manual. Windows cannot |
| + // rename a new binary over the running .exe, so rather than ship a |
| + // self-replace nobody on this project can test, the command tells |
| + // the truth and hands over the exact URL — the staleness check |
| + // above has already done the part that needed the network. |
| + fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| + fmt.Fprintf(stdout, "Windows installs update by hand — download and unzip over %s:\n\n %s\n\n", |
| + exe, updateDownloadBase+"/"+latest+"/"+windowsAsset()) |
| + return |
| } |
| fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe) |
| // Non-TTY never self-replaces, -y or not (spec): provisioning belongs |
| @@ -255,6 +278,13 @@ func runUpdate(args []string) { |
| fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe) |
| } |
| +// windowsAsset names the zip published for this machine's architecture. |
| +// It shares the "carlos-<goos>-<goarch>" stem with the raw binaries in |
| +// SHA256SUMS.txt below, so the checksum list has one naming rule, not two. |
| +func windowsAsset() string { |
| + return fmt.Sprintf("carlos-%s-%s.zip", updateGOOS, runtime.GOARCH) |
| +} |
| + |
| // selfReplace downloads the matching artifact for this OS/arch — from the |
| // TAG-pINNED release path, so the bytes installed are the version claimed — |
| // proves it against the SIGNED checksum list, runs the replacement's own |
diff --git a/update_test.go b/update_test.go| index 0a42356aa..3c0815aaf 100644 |
| --- a/update_test.go |
| +++ b/update_test.go |
| @@ -172,6 +172,45 @@ func TestInstallMethod(t *testing.T) { |
| // asserted here — /usr/bin/carlos on a non-deb box is still "binary". |
| } |
| +// Windows has no package manager in this story and no working |
| +// self-replace — a running .exe cannot be renamed over — so every Windows |
| +// install is the zip download, whatever the path looks like. The check |
| +// comes FIRST: a path containing /Cellar/ on Windows is a coincidence, not |
| +// a brew install, and handing such a user `brew upgrade` would be a dead |
| +// end rather than a smaller inconvenience. |
| +func TestInstallMethodWindowsIsZip(t *testing.T) { |
| + old := updateGOOS |
| + updateGOOS = "windows" |
| + t.Cleanup(func() { updateGOOS = old }) |
| + for _, path := range []string{ |
| + `C:\Users\tom\bin\carlos.exe`, |
| + `C:\Program Files\carlos\carlos.exe`, |
| + "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos", |
| + } { |
| + if got := installMethod(path); got != "zip" { |
| + t.Errorf("installMethod(%q) on windows = %q, want %q", path, got, "zip") |
| + } |
| + } |
| + updateGOOS = old |
| + if got := installMethod("/home/tom/bin/carlos"); got != "binary" { |
| + t.Errorf("installMethod off windows = %q, want %q", got, "binary") |
| + } |
| +} |
| + |
| +// The Windows zip's name is a contract with the release recipe, not a |
| +// cosmetic string: `carlos update` prints this exact URL to a user who then |
| +// has to find the file on the release. Pinned here so renaming the artifact |
| +// on one side without the other fails a test instead of a download. |
| +func TestWindowsAssetName(t *testing.T) { |
| + old := updateGOOS |
| + updateGOOS = "windows" |
| + t.Cleanup(func() { updateGOOS = old }) |
| + want := "carlos-windows-" + runtime.GOARCH + ".zip" |
| + if got := windowsAsset(); got != want { |
| + t.Errorf("windowsAsset() = %q, want %q", got, want) |
| + } |
| +} |
| + |
| // releaseServer serves a fake carlosframework/releases download endpoint: |
| // a binary, its SHA256SUMS.txt, and a signature by the given key. |
| func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) { |