From 1171e1d7f26cd2975b70e429d00d7c60f7c1672b Mon Sep 17 00:00:00 2001 From: Dinesh Pandiyan Date: Sat, 10 Oct 2026 21:54:49 +1100 Subject: [PATCH] feat: add persistent container server setup --- .gitignore | 2 + Dockerfile | 6 +-- README.md | 33 +++++++++--- cmd/diffx-server/main.go | 41 +++++++------- cmd/diffx-server/main_test.go | 55 +++++++++++++++++-- compose.yaml | 20 +++++++ docs/architecture.md | 15 ++++-- docs/development.md | 68 +++++++++++++++++++++-- docs/release.md | 7 +-- docs/system.md | 11 +++- internal/config/remote.go | 87 ++++++++++++++++++++++++++++++ internal/config/remote_test.go | 86 +++++++++++++++++++++++++++++ internal/reviewstore/reset_test.go | 36 ++++++++++--- internal/reviewstore/store.go | 27 ++-------- mise.toml | 8 +++ 15 files changed, 425 insertions(+), 77 deletions(-) create mode 100644 compose.yaml create mode 100644 internal/config/remote.go create mode 100644 internal/config/remote_test.go diff --git a/.gitignore b/.gitignore index adfe699..0b49374 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +/data/ +/test-data/ dist/ !packages/plugin-opencode/dist/ !packages/plugin-opencode/dist/index.js diff --git a/Dockerfile b/Dockerfile index 7cce14c..8235f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,10 @@ FROM scratch COPY --from=build /diffx-server /diffx-server COPY --from=build --chown=10001:10001 /data /data USER 10001:10001 -ENV DIFFX_CONFIG_PATH=/data/config.json -ENV HOME=/data +ENV DIFFX_HOST=0.0.0.0 +ENV DIFFX_PORT=7981 +ENV DIFFX_STATE=/data/state.db VOLUME ["/data"] EXPOSE 7981 STOPSIGNAL SIGTERM ENTRYPOINT ["/diffx-server"] -CMD ["--listen", "0.0.0.0:7981", "--state", "/data/state.db"] diff --git a/README.md b/README.md index 42842f4..d7c9187 100644 --- a/README.md +++ b/README.md @@ -146,15 +146,28 @@ review context. Sessions associate with shared reviews; they do not own or claim authorship of every worktree's edits. Harness, session ID and optional session name remain searchable even when identical content reuses a review. -Build and run the Docker server (deploy behind HTTPS): +Build and run the container setup for server development: + +```sh +mise run docker:up +mise run docker:down +``` + +Compose publishes HTTP on `127.0.0.1:7981` and preserves the named SQLite volume +when stopped or recreated. Set `DIFFX_PORT=7982` to use another port. Retrieve +the initial credential with `docker compose cp server:/data/state.db.admin-token ./admin-token`. +See [server development](docs/development.md#server-development) for configuration +and persistence details. Deploy behind HTTPS when exposing the server remotely. + +Or build and run the image directly: ```sh mise run docker:build -docker run --name diffx-server -p 127.0.0.1:7981:7981 \ +docker run --name diffx-server --read-only -p 127.0.0.1:7981:7981 \ --mount source=diffx-data,target=/data diffx-server:local ``` -The volume contains observations, jobs, credentials and configuration. First +The volume contains observations, jobs and credentials. First startup writes the admin token to `/data/state.db.admin-token`; copy it with `docker cp diffx-server:/data/state.db.admin-token ./admin-token`. The image runs without Git or a repository mount, as an unprivileged user. @@ -165,11 +178,11 @@ Build/install the remote server with Go: ```sh go install github.com/flexdinesh/diffx/cmd/diffx-server@main -diffx-server --listen 0.0.0.0:7981 --state /data/state.db +DIFFX_HOST=127.0.0.1 DIFFX_PORT=7981 DIFFX_STATE=./test-data/state.db diffx-server ``` First startup creates `admin` with a generated token saved privately at -`/data/state.db.admin-token`; startup prints its path. The token persists across +`.admin-token`; startup prints its path. The token persists across restarts. API/MCP requests use bearer authentication; the browser uses HTTP Basic with the username and token as password. Deploy behind TLS. Multiple individual users share one SQLite database; credentials select and isolate each user's data. @@ -179,9 +192,12 @@ The database stores credential hashes. To add a user, stop the server, run: diffx-server user create --name alice --state /data/state.db ``` -Save the printed token, then restart the server. `--account` and `DIFFX_TOKEN` +Save the printed token, then restart the server. `--account` / `DIFFX_ACCOUNT` and `DIFFX_TOKEN` can supply initial bootstrap credentials; existing credentials are never replaced. Use `--retention-days 14` or config/environment to change server retention. +The server defaults to `127.0.0.1:7981` and `./data/state.db`, independent of +personal CLI settings. Its image defaults to `0.0.0.0:7981` and `/data/state.db`; +env vars override image defaults, and explicit flags override env vars. Collectors select that destination with config `server`, `DIFFX_SERVER_URL` or `--server`, and authenticate with config `token`, `DIFFX_TOKEN` or `--token`: @@ -256,8 +272,9 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md). Local data lives in `$XDG_STATE_HOME/diffx/state.db`, or `~/.local/state/diffx/state.db`. `--state memory` disables persistence. -Schema 9 intentionally resets older versioned databases on first open. Current -state persists across restarts; newer or unrecognized databases are refused. +Current schema 9 state persists across restarts. Older, newer or unrecognized +databases are refused without an automatic reset. To start fresh, stop the +server, back up its state directory, and explicitly choose a new database path. Legacy commands and pending producer payloads are retired. Stop older processes before upgrading. Local service lifecycle uses a private authenticated loopback control listener; its token does not authenticate the diff --git a/cmd/diffx-server/main.go b/cmd/diffx-server/main.go index b2772bf..5250e15 100644 --- a/cmd/diffx-server/main.go +++ b/cmd/diffx-server/main.go @@ -5,10 +5,8 @@ import ( "flag" "fmt" "io" - "net" "os" "os/signal" - "strconv" "strings" "syscall" @@ -22,11 +20,11 @@ func parseSettings(arguments []string, stderr io.Writer) (remoteserver.Settings, settings := remoteserver.Settings{Token: os.Getenv("DIFFX_TOKEN")} flags := flag.NewFlagSet("diffx-server", flag.ContinueOnError) flags.SetOutput(stderr) - flags.StringVar(&settings.Listen, "listen", "0.0.0.0:7981", "HTTP listen address; terminate TLS at the deployment boundary") + flags.StringVar(&settings.Listen, "listen", "127.0.0.1:7981", "HTTP listen address; terminate TLS at the deployment boundary") flags.StringVar(&settings.State, "state", "", "SQLite database path") flags.StringVar(&settings.Account, "account", "admin", "bootstrap account name; persisted credentials are never replaced") flags.IntVar(&settings.RetentionDays, "retention-days", 7, "snapshot retention in days") - path := flags.String("config-file", "", "machine JSON config path") + path := flags.String("config-file", "", "optional read-only server JSON config path") showVersion := flags.Bool("version", false, "print version and exit") if err := flags.Parse(arguments); err != nil { return settings, false, err @@ -37,38 +35,41 @@ func parseSettings(arguments []string, stderr io.Writer) (remoteserver.Settings, if flags.NArg() != 0 { return settings, false, fmt.Errorf("diffx-server takes no positional arguments") } - values, err := config.LoadFile(*path) + values, err := config.LoadRemoteFile(*path) if err != nil { return settings, false, err } - resolved, err := values.Settings() - if err != nil { - return settings, false, err - } - listenSet, stateSet, retentionSet := false, false, false + listenSet, stateSet, accountSet, retentionSet := false, false, false, false flags.Visit(func(value *flag.Flag) { listenSet = listenSet || value.Name == "listen" stateSet = stateSet || value.Name == "state" + accountSet = accountSet || value.Name == "account" retentionSet = retentionSet || value.Name == "retention-days" }) - _, hostEnv := os.LookupEnv("DIFFX_HOST") - _, portEnv := os.LookupEnv("DIFFX_PORT") - if !listenSet && (*path != "" || os.Getenv("DIFFX_CONFIG_PATH") != "" || hostEnv || portEnv) { - port := resolved.Port - if port < 0 { - port = 7981 + if !listenSet { + settings.Listen, err = values.ListenAddress() + if err != nil { + return settings, false, err } - settings.Listen = net.JoinHostPort(resolved.Host, strconv.Itoa(port)) } if !stateSet || settings.State == "" { - settings.State = resolved.State + settings.State = values.State + } + if !accountSet { + settings.Account = values.Account } if !retentionSet { settings.RetentionDays = values.RetentionDays } - if retentionSet && settings.RetentionDays <= 0 { + if settings.RetentionDays <= 0 { return settings, false, fmt.Errorf("retention days must be positive") } + if settings.State == "" { + return settings, false, fmt.Errorf("state must be a database path or memory") + } + if strings.TrimSpace(settings.Account) == "" || len(settings.Account) > 256 || strings.ContainsAny(settings.Account, "\r\n:") { + return settings, false, fmt.Errorf("invalid bootstrap account name") + } if _, err := remoteserver.Retention(settings.RetentionDays); err != nil { return settings, false, err } @@ -117,7 +118,7 @@ func createUser(arguments []string, stdout, stderr io.Writer) error { flags.SetOutput(stderr) name := flags.String("name", "", "new account name") state := flags.String("state", "", "SQLite database path; server must be stopped") - configPath := flags.String("config-file", "", "machine JSON config path") + configPath := flags.String("config-file", "", "optional read-only server JSON config path") if err := flags.Parse(arguments[1:]); err != nil { return err } diff --git a/cmd/diffx-server/main_test.go b/cmd/diffx-server/main_test.go index acefc71..7b4bfa6 100644 --- a/cmd/diffx-server/main_test.go +++ b/cmd/diffx-server/main_test.go @@ -18,7 +18,7 @@ func TestRemoteConfigMappingAndPrecedence(t *testing.T) { t.Setenv("DIFFX_CONFIG_PATH", "") t.Setenv("DIFFX_RUNTIME_DIR", t.TempDir()) settings, _, err := parseSettings(nil, io.Discard) - if err != nil || settings.Listen != "0.0.0.0:7981" { + if err != nil || settings.Listen != "127.0.0.1:7981" || settings.State != "data/state.db" { t.Fatalf("remote default changed: %#v %v", settings, err) } settings, _, err = parseSettings([]string{"--config-file", path}, io.Discard) @@ -36,6 +36,12 @@ func TestRemoteConfigMappingAndPrecedence(t *testing.T) { if err != nil || settings.Listen != "0.0.0.0:6123" || settings.State != "flags.db" { t.Fatalf("flags mapping: %#v %v", settings, err) } + t.Setenv("DIFFX_HOST", "invalid") + t.Setenv("DIFFX_PORT", "65536") + settings, _, err = parseSettings([]string{"--listen", "127.0.0.1:0"}, io.Discard) + if err != nil || settings.Listen != "127.0.0.1:0" { + t.Fatalf("overridden listener was validated: %+v %v", settings, err) + } } func TestCreateUserPersistsCredentialAndRejectsDuplicates(t *testing.T) { @@ -84,17 +90,58 @@ func TestCreateUserValidation(t *testing.T) { } } -func TestRemoteConfigAutomaticPortAndVersion(t *testing.T) { - t.Setenv("DIFFX_CONFIG_PATH", filepath.Join(t.TempDir(), "config.json")) +func TestRemoteConfigDefaultsAndVersion(t *testing.T) { + t.Setenv("DIFFX_CONFIG_PATH", "") settings, _, err := parseSettings(nil, io.Discard) if err != nil || settings.Listen != "127.0.0.1:7981" { - t.Fatalf("automatic config port: %#v %v", settings, err) + t.Fatalf("default server port: %#v %v", settings, err) } if _, showVersion, err := parseSettings([]string{"--version", "--config-file", "/missing/config.json"}, io.Discard); err != nil || !showVersion { t.Fatalf("version resolved config: %v", err) } } +func TestRemoteAccountConfigPrecedence(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.json") + if err := os.WriteFile(path, []byte(`{"account":"file-admin"}`), 0o400); err != nil { + t.Fatal(err) + } + settings, _, err := parseSettings([]string{"--config-file", path}, io.Discard) + if err != nil || settings.Account != "file-admin" { + t.Fatalf("file account: %+v %v", settings, err) + } + t.Setenv("DIFFX_ACCOUNT", "env-admin") + settings, _, err = parseSettings([]string{"--config-file", path}, io.Discard) + if err != nil || settings.Account != "env-admin" { + t.Fatalf("environment account: %+v %v", settings, err) + } + settings, _, err = parseSettings([]string{"--config-file", path, "--account", "flag-admin"}, io.Discard) + if err != nil || settings.Account != "flag-admin" { + t.Fatalf("flag account: %+v %v", settings, err) + } +} + +func TestRemoteRejectsInvalidDeploymentSettings(t *testing.T) { + for _, tc := range []struct{ key, value string }{ + {"DIFFX_HOST", "localhost"}, + {"DIFFX_PORT", "not-a-port"}, + {"DIFFX_PORT", "65536"}, + {"DIFFX_STATE", ""}, + {"DIFFX_RETENTION_DAYS", "0"}, + {"DIFFX_RETENTION_DAYS", "-1"}, + {"DIFFX_ACCOUNT", ""}, + {"DIFFX_ACCOUNT", "invalid:name"}, + } { + t.Run(tc.key+"="+tc.value, func(t *testing.T) { + t.Setenv("DIFFX_CONFIG_PATH", "") + t.Setenv(tc.key, tc.value) + if _, _, err := parseSettings(nil, io.Discard); err == nil { + t.Fatal("accepted invalid deployment settings") + } + }) + } +} + func TestRemoteRetentionConfigPrecedence(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"retentionDays":14}`), 0o600); err != nil { diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..3d59b57 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,20 @@ +services: + server: + build: . + image: diffx-server:local + environment: + DIFFX_HOST: 0.0.0.0 + DIFFX_PORT: "${DIFFX_PORT:-7981}" + DIFFX_STATE: /data/state.db + DIFFX_ACCOUNT: "${DIFFX_ACCOUNT:-admin}" + DIFFX_RETENTION_DAYS: "${DIFFX_RETENTION_DAYS:-7}" + ports: + - "127.0.0.1:${DIFFX_PORT:-7981}:${DIFFX_PORT:-7981}" + volumes: + - diffx-data:/data + read_only: true + restart: unless-stopped + stop_grace_period: 30s + +volumes: + diffx-data: diff --git a/docs/architecture.md b/docs/architecture.md index 5869bf0..4fb7e14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,8 +128,17 @@ uses `X-Diffx-State` to pin submission and polling to the destination identity; older protocols are rejected. Release artifacts use the explicit `diffx` project name, independent of the checkout directory. -Schema 9 intentionally resets older versioned databases in one transaction under -the ownership lock. Current state survives subsequent starts; future or -unrecognized schemas are refused. Upgrade server and collectors together. +Schema 9 preserves current state across starts. Older, future and unrecognized +schemas are refused; opening a database never resets it. Durable server volumes +require an explicit reset decision: stop the owner, back up its state directory, +then select a fresh database path. No automatic schema migration is implemented. +Upgrade server and collectors together. Versioned producer directories ignore old manual/hook pending payloads. Stop old binaries before upgrading. + +Remote configuration is owned by `config` and composed by `cmd/diffx-server`: +defaults, an explicitly selected read-only server JSON file, environment, then +explicit flags. Server startup does not discover, create or lock personal CLI +config. The binary defaults to loopback and a separate `./data/state.db`; the +container supplies overridable env defaults for its listener and `/data` volume. +One process owns the volume's SQLite database, credentials and durable queue. diff --git a/docs/development.md b/docs/development.md index 5755c4c..fe911ec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,6 +44,8 @@ List available tasks with `mise tasks`. Keep personal overrides in | Build the dependency-free CLI at `dist/diffx` | `mise run build` | | Build and locally install the CLI | `mise run install` | | Build agent plugin adapters | `mise run plugins:build` | +| Start the container server with persistent SQLite | `mise run docker:up` | +| Stop the container server, preserving SQLite | `mise run docker:down` | | Install Playwright Chromium | `pnpm test:browser:install` | | Run web unit and browser tests | `pnpm test:web` | | Run Go tests | `mise run test:go` | @@ -115,9 +117,10 @@ runtime directories (`DIFFX_RUNTIME_DIR`) and in-memory state for lifecycle test must not register inputs in the personal service. `DIFFX_EXIT_ON_STDIN_CLOSE` is a foreground development-process lifecycle hook. -Stop running processes before upgrading. Schema 9 resets older versioned -databases transactionally on first open. Current databases survive restart; -in-memory state does not. Server `retentionDays` defaults to seven; fresh +Stop running processes before upgrading. Current schema 9 databases survive +restart; incompatible schemas are refused without resetting data. To start +fresh, back up the state directory while stopped and explicitly choose a new +database path. In-memory state does not survive restart. Server `retentionDays` defaults to seven; fresh submissions apply the current setting, while exact retries preserve expiry. For isolated production-user tests, start `diffx-server` with a temporary @@ -126,6 +129,65 @@ startup reports its path. Stop the server before `user create --name NAME --stat DB`, save its printed token and restart. Users share one database, while credentials scope REST, MCP and events. Do not provision against the running personal service. +## Server development + +Build both binaries with `mise run build`, then run server mode directly: + +```sh +DIFFX_HOST=127.0.0.1 DIFFX_PORT=7981 DIFFX_STATE=./test-data/state.db ./dist/diffx-server +``` + +Or use the checked-in Compose setup: + +```sh +mise run docker:up +docker compose logs server +docker compose cp server:/data/state.db.admin-token ./admin-token +mise run docker:down +``` + +The same server binary handles authentication, durable admission, workers and +queries in both setups. The image uses committed embedded assets; run +`mise run web:stage` before rebuilding it after UI changes. + +| Setting | Binary default | Container default | Flag | +| ---------------------- | ------------------------------ | ------------------------------ | ----------------------- | +| `DIFFX_HOST` | `127.0.0.1` | `0.0.0.0` | `--listen HOST:PORT` | +| `DIFFX_PORT` | `7981` | `7981` | `--listen HOST:PORT` | +| `DIFFX_STATE` | `data/state.db` | `/data/state.db` | `--state PATH` | +| `DIFFX_ACCOUNT` | `admin` | `admin` | `--account NAME` | +| `DIFFX_RETENTION_DAYS` | `7` | `7` | `--retention-days DAYS` | +| `DIFFX_TOKEN` | Generated for persistent state | Generated for persistent state | None | + +`DIFFX_TOKEN` supplies initial bootstrap credentials only; existing credentials +are never replaced. Omit it to generate a private `.admin-token` file. +`memory` and `:memory:` disable persistence and require an explicit token. + +Precedence is defaults → explicit JSON file → env vars → explicit flags. Use +`--config-file FILE` or `DIFFX_CONFIG_PATH` to select a server config. The file +supports `host`, `port`, `state`, `account` and `retentionDays`. It must exist and +can be mounted read-only; startup never creates it or a config lock. Personal +CLI config, `DIFFX_RUNTIME_DIR`, producer URL settings and external web assets +do not configure server mode. Restart to apply changes. + +Compose keeps the SQLite database, sidecar files, ownership lock and generated +credential in the `diffx-data` volume. The root filesystem is read-only; the +image prepares `/data` for UID/GID 10001. If using a bind mount instead, make +the directory writable by that identity. Keep database files inside `/data`. +`mise run docker:down` preserves the volume; `docker compose down --volumes` +explicitly deletes it. Stop the server and back up the whole directory before +starting with a fresh database; a volume is not a backup. + +Set `DIFFX_PORT=7982 mise run docker:up` for a different published and internal +port. Compose also accepts `DIFFX_ACCOUNT` and `DIFFX_RETENTION_DAYS` overrides. +For other settings, use image env overrides or a Compose override file. +Only one server may own a SQLite database; competing starts fail rather than +share ownership. SIGTERM stops HTTP and workers before releasing storage; +unfinished admitted jobs recover from the durable queue after leases expire. + +This setup targets one server with SQLite. PostgreSQL, multiple replicas, +browser login and integrations remain separate future changes. + ## Build pipeline The production build generates API types, builds the web application, stages the diff --git a/docs/release.md b/docs/release.md index 80cd0c7..7d54106 100644 --- a/docs/release.md +++ b/docs/release.md @@ -38,9 +38,10 @@ arm64. Windows archives are also published. Archives include the native binary, README, and license. The embedded web application needs no installed Node.js runtime. -Stop running binaries before upgrading. Schema 9 intentionally resets older -versioned databases on first open; current state survives subsequent starts. -Future or unrecognized schemas are refused. Upgrade server and collectors +Stop running binaries before upgrading. Current schema 9 state survives +subsequent starts; older, future or unrecognized schemas are refused without +resetting data. Back up the state directory and explicitly choose a fresh +database path if discarding incompatible state. Upgrade server and collectors together for ingestion protocol 4. In-memory state lasts only for one process. The tap branch is deterministic per version, such as `diffx-v0.2.0`. diff --git a/docs/system.md b/docs/system.md index 1c61e9a..c852281 100644 --- a/docs/system.md +++ b/docs/system.md @@ -44,10 +44,16 @@ Noninteractive callers use `--replace`. There is no collector daemon. `diffx sync` collects the originating checkout and registered worktrees once, saves immutable pending payloads, and submits them over authenticated HTTP. -The Docker server owns durable queue admission, the ingestion worker, shared +The remote server binary owns durable queue admission, the ingestion worker, shared application services and persistent storage. Sync waits for committed results. Plugins schedule finite remote-only collector invocations with harness metadata. +`diffx-server` runs directly or as the same binary inside a container. It resolves +deployment config without reading or writing personal CLI settings. Container +defaults come from env vars, with SQLite and generated credentials under `/data`. +Compose mounts a named volume and publishes HTTP on host loopback; stopping or +recreating the container preserves data. Only one process may own that database. + Both modes use identical collection, validation, identity, ingestion, catalog and review operations. Their lifecycle and submission delivery differ. @@ -150,7 +156,8 @@ fresh submission; retries do not extend it. Reads enforce expiry immediately; local and remote servers prune at startup and hourly, removing all scopes, previews, comments and marks. Compact replay identities and stream heads survive deletion and expiry, preventing replay resurrection and -regression of freshness. See architecture.md for the intentional schema reset. +regression of freshness. Incompatible schemas are refused without resetting data; +see architecture.md for the explicit fresh-state procedure. ## Freshness and delivery diff --git a/internal/config/remote.go b/internal/config/remote.go new file mode 100644 index 0000000..20d1b85 --- /dev/null +++ b/internal/config/remote.go @@ -0,0 +1,87 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "strconv" +) + +// RemoteValues is deployment configuration, independent of personal CLI settings. +type RemoteValues struct { + Host string `json:"host"` + Port int `json:"port"` + State string `json:"state"` + Account string `json:"account"` + RetentionDays int `json:"retentionDays"` +} + +// LoadRemoteFile resolves defaults, an explicitly selected file, then environment. +// It never discovers personal config, creates files, or acquires a config lock. +func LoadRemoteFile(path string) (RemoteValues, error) { + values := RemoteValues{Host: "127.0.0.1", Port: 7981, State: "data/state.db", Account: "admin", RetentionDays: 7} + if path == "" { + path = os.Getenv("DIFFX_CONFIG_PATH") + } + if path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return values, fmt.Errorf("read server config: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + document := &values + if err := decoder.Decode(&document); err != nil { + return values, fmt.Errorf("invalid server config: %w", err) + } + if document == nil { + return values, errors.New("server config must be a JSON object") + } + if decoder.Decode(&struct{}{}) != io.EOF { + return values, errors.New("server config must contain one JSON object") + } + } + for _, field := range []struct { + name string + target *string + }{ + {"DIFFX_HOST", &values.Host}, + {"DIFFX_STATE", &values.State}, + {"DIFFX_ACCOUNT", &values.Account}, + } { + if value, exists := os.LookupEnv(field.name); exists { + *field.target = value + } + } + for _, field := range []struct { + name string + target *int + }{ + {"DIFFX_PORT", &values.Port}, + {"DIFFX_RETENTION_DAYS", &values.RetentionDays}, + } { + if raw, exists := os.LookupEnv(field.name); exists { + value, err := strconv.Atoi(raw) + if err != nil { + return values, fmt.Errorf("invalid %s: %w", field.name, err) + } + *field.target = value + } + } + return values, nil +} + +// ListenAddress validates the configured listener when no flag overrides it. +func (values RemoteValues) ListenAddress() (string, error) { + if net.ParseIP(values.Host) == nil { + return "", errors.New("server host must be an IP address") + } + if values.Port < 0 || values.Port > 65535 { + return "", errors.New("server port must be between 0 and 65535") + } + return net.JoinHostPort(values.Host, strconv.Itoa(values.Port)), nil +} diff --git a/internal/config/remote_test.go b/internal/config/remote_test.go new file mode 100644 index 0000000..0945250 --- /dev/null +++ b/internal/config/remote_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRemoteConfigIgnoresPersonalSettingsAndDoesNotWrite(t *testing.T) { + home := t.TempDir() + runtime := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DIFFX_RUNTIME_DIR", runtime) + t.Setenv("DIFFX_CONFIG_PATH", "") + path := filepath.Join(home, ".config", "diffx", "config.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"state":"personal.db","port":9000}`), 0o400); err != nil { + t.Fatal(err) + } + // Producer-only environment must not affect or invalidate server startup. + t.Setenv("DIFFX_SERVER_URL", "not-a-url") + t.Setenv("DIFFX_WEB_DIR", "/missing/assets") + values, err := LoadRemoteFile("") + if err != nil || values.State != "data/state.db" || values.Port != 7981 { + t.Fatalf("personal config affected server: %+v %v", values, err) + } + for _, directory := range []string{runtime, filepath.Dir(path)} { + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if directory == runtime || entry.Name() != "config.json" { + t.Fatalf("server created config artifact %s", filepath.Join(directory, entry.Name())) + } + } + } +} + +func TestRemoteConfigReadsOnlyExplicitFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.json") + t.Setenv("DIFFX_CONFIG_PATH", path) + if _, err := LoadRemoteFile(""); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing config did not fail: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("created missing config: %v", err) + } + raw := []byte(`{"host":"::1","port":4123,"state":"configured.db","retentionDays":14}`) + if err := os.WriteFile(path, raw, 0o400); err != nil { + t.Fatal(err) + } + values, err := LoadRemoteFile("") + if err != nil || values.Host != "::1" || values.Port != 4123 || values.State != "configured.db" || values.RetentionDays != 14 { + t.Fatalf("explicit config: %+v %v", values, err) + } + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil || len(entries) != 1 { + t.Fatalf("read-only config created artifacts: %v %v", entries, err) + } + other := filepath.Join(t.TempDir(), "other.json") + if err := os.WriteFile(other, []byte(`{"port":5000}`), 0o400); err != nil { + t.Fatal(err) + } + values, err = LoadRemoteFile(other) + if err != nil || values.Port != 5000 { + t.Fatalf("explicit path did not override env path: %+v %v", values, err) + } +} + +func TestRemoteConfigRejectsMalformedFiles(t *testing.T) { + for _, raw := range []string{`null`, `[]`, `{"port":"7981"}`, `{"unknown":true}`, `{"server":"https://example.com"}`, `{} {}`, `{} trailing`} { + t.Run(raw, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.json") + if err := os.WriteFile(path, []byte(raw), 0o400); err != nil { + t.Fatal(err) + } + if _, err := LoadRemoteFile(path); err == nil { + t.Fatal("accepted invalid server config") + } + }) + } +} diff --git a/internal/reviewstore/reset_test.go b/internal/reviewstore/reset_test.go index c7e99a9..bd5a1e5 100644 --- a/internal/reviewstore/reset_test.go +++ b/internal/reviewstore/reset_test.go @@ -3,27 +3,49 @@ package reviewstore import ( "database/sql" "path/filepath" + "strings" "testing" ) -func TestSchemaResetOnceAndFutureVersionRefused(t *testing.T) { +func TestOlderSchemaRefusedWithoutReset(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } - _, err = db.Exec("CREATE TABLE legacy(value TEXT); INSERT INTO legacy VALUES('discard'); PRAGMA user_version=8") + _, err = db.Exec("CREATE TABLE legacy(value TEXT); INSERT INTO legacy VALUES('retain'); PRAGMA user_version=8") if err != nil { t.Fatal(err) } db.Close() - store, err := Open(path) + for range 2 { + if store, err := Open(path); err == nil { + store.Close() + t.Fatal("accepted older schema") + } else if !strings.Contains(err.Error(), "automatic reset is disabled") { + t.Fatalf("missing recovery guidance: %v", err) + } + } + db, err = sql.Open("sqlite", path) if err != nil { t.Fatal(err) } - var name string - if err := store.db.QueryRow("SELECT name FROM sqlite_master WHERE name='legacy'").Scan(&name); err != sql.ErrNoRows { - t.Fatalf("legacy table survived: %v", err) + defer db.Close() + var value string + if err := db.QueryRow("SELECT value FROM legacy").Scan(&value); err != nil || value != "retain" { + t.Fatalf("older data changed: %q %v", value, err) + } + var version int + if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 8 { + t.Fatalf("older schema version changed: %d %v", version, err) + } +} + +func TestCurrentSchemaSurvivesRestartAndFutureVersionRefused(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + store, err := Open(path) + if err != nil { + t.Fatal(err) } user, err := store.User("owner", "owner") if err != nil { @@ -46,7 +68,7 @@ func TestSchemaResetOnceAndFutureVersionRefused(t *testing.T) { newer.Close() t.Fatal("accepted future schema") } - db, err = sql.Open("sqlite", path) + db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } diff --git a/internal/reviewstore/store.go b/internal/reviewstore/store.go index 5fb15fe..7e23843 100644 --- a/internal/reviewstore/store.go +++ b/internal/reviewstore/store.go @@ -9,7 +9,6 @@ import ( "io" "os" "path/filepath" - "strings" "time" "github.com/flexdinesh/diffx/internal/processlock" @@ -151,6 +150,9 @@ func (store *Store) initialize() error { if version > schemaVersion { return fmt.Errorf("diffx state schema %d is newer than supported schema %d", version, schemaVersion) } + if version > 0 && version < schemaVersion { + return fmt.Errorf("diffx state schema %d is older than supported schema %d; automatic reset is disabled: use a compatible binary or back up the state directory and explicitly choose a fresh database path", version, schemaVersion) + } if _, err := store.db.Exec("PRAGMA foreign_keys=OFF"); err != nil { return err } @@ -170,29 +172,6 @@ func (store *Store) initialize() error { return err } } - if version > 0 && version < schemaVersion { - rows, err := transaction.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") - if err != nil { - return err - } - var tables []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - rows.Close() - return err - } - tables = append(tables, name) - } - if err := errors.Join(rows.Err(), rows.Close()); err != nil { - return err - } - for _, name := range tables { - if _, err := transaction.Exec("DROP TABLE \"" + strings.ReplaceAll(name, "\"", "\"\"") + "\""); err != nil { - return err - } - } - } statements := []string{ `CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, os_uid TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS diffs (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, kind TEXT NOT NULL, mode TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER)`, diff --git a/mise.toml b/mise.toml index 7919a5e..308c329 100644 --- a/mise.toml +++ b/mise.toml @@ -166,3 +166,11 @@ run = "ruby -c homebrew-tap/Formula/diffx.rb" [tasks."docker:build"] description = "Build the remote server image using committed embedded assets" run = "docker build --tag diffx-server:local ." + +[tasks."docker:up"] +description = "Build and start the server with a persistent SQLite volume" +run = "docker compose up --build -d" + +[tasks."docker:down"] +description = "Stop the server, preserving its data volume" +run = "docker compose down"