carlos / platform Public

Merge pull request #235 from carlosframework/windows-release

windows: a client-only carlos, behind per-OS boundaries
Paul Campbell pushed by paul@keymail.dev 4167d5b44c401f45a4985b71888d97f4cb2f1eac parent e934cb5parent 4143d51
13 files changed, +270 −53
  • .github/workflows/ci.yml +9 −0
  • README.md +34 −0
  • internal/edge/purge.go +1 −31
  • internal/edge/purge_listen_unix.go +50 −0
  • internal/edge/purge_listen_windows.go +22 −0
  • internal/host/flock_unix.go +15 −0
  • internal/host/flock_windows.go +21 −0
  • internal/host/probes.go +8 −17
  • internal/host/restart.go +5 −5
  • internal/host/statfs_unix.go +21 −0
  • internal/host/statfs_windows.go +15 −0
  • update.go +30 −0
  • update_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) {