Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b1a5e9b
feat(server): warn when the NATS broker is unauthenticated and non-lo…
uberware Aug 21, 2026
d33bbe5
feat(config): add opt-in nats.auth broker authentication settings
uberware Aug 21, 2026
f630f28
docs: describe nats.auth behavior as shipped rather than mid-branch
uberware Aug 21, 2026
a2d7159
feat(store): add worker credential and join token persistence
uberware Aug 21, 2026
e336720
feat(brokerauth): add nkey, permission and join-token primitives
uberware Aug 21, 2026
a16024d
docs: distinguish the golangci-lint version floor from the CI pin
uberware Aug 21, 2026
dbea10e
fix(brokerauth): enforce 0600 on an existing seed file and cover key …
uberware Aug 21, 2026
c9426af
feat(bus): authorize broker connections with per-worker nkeys
uberware Aug 21, 2026
003b8ab
feat(bus)!: carry worker identity in every worker-to-server subject
uberware Aug 21, 2026
48ca042
test(bus): pin subject construction against the worker permission grants
uberware Aug 21, 2026
0860d86
feat(scheduler): reject worker messages whose subject identity does n…
uberware Aug 21, 2026
81f0594
fix(scheduler): bind a log chunk to its own task, not just its worker
uberware Aug 21, 2026
71693d2
feat(worker): load or enroll an nkey credential before connecting to …
uberware Aug 21, 2026
0df8aef
fix(worker): name the cause when the broker rejects a credential mid-run
uberware Aug 21, 2026
bcafcbd
feat(cli): add worker enrolment, revocation and keygen subcommands
uberware Aug 21, 2026
b2577af
fix(store): scope worker credential uniqueness to active rows so keys…
uberware Aug 21, 2026
0e9f428
fix(store): make credential lookup and revocation unambiguous after r…
uberware Aug 21, 2026
5dc9da9
test(store): restore credential uniqueness and rotation coverage
uberware Aug 21, 2026
3b7a389
docs(store): describe credential test invariants without process refe…
uberware Aug 21, 2026
a02faf0
feat(api): add worker enrolment and join-token endpoints behind worke…
uberware Aug 21, 2026
a0e6c40
fix(server): wire broker auth configuration into the REST enrollment …
uberware Aug 21, 2026
85aec01
feat(server): disconnect and reclaim from a worker whose credential i…
uberware Aug 21, 2026
7a2f58c
fix(server): reload broker credentials on enrollment and serialize re…
uberware Aug 21, 2026
bc0d44c
docs: document broker authentication, enrollment and the auth-off asy…
uberware Aug 21, 2026
957593c
test: cover the default-config path and both broker-auth modes end to…
uberware Aug 21, 2026
1fa2687
fix(brokerauth): confine each worker's reply inbox to its own subject…
uberware Aug 21, 2026
7fa6f50
fix(store): redeem a worker join token in one atomic claim
uberware Aug 21, 2026
54aada7
fix(cli): warn that an offline worker enrollment needs a server restart
uberware Aug 21, 2026
0330f1f
docs: state offline enrollment, synchronous revocation and the auth-o…
uberware Aug 21, 2026
178e0a6
chore: correct stale comments and drop the unused broker ServerSeed a…
uberware Aug 21, 2026
fbff686
fix(api): reject a worker id that is not a valid subject token at enr…
uberware Aug 21, 2026
5b560a2
docs(scheduler): name the subject shape that actually reaches the wor…
uberware Aug 21, 2026
ad9ac3c
fix(api): revoke a worker's broker credential when its record is deleted
uberware Aug 21, 2026
997a3d9
fix(bus): skip a worker credential whose public key is not a valid nkey
uberware Aug 21, 2026
c10634a
fix(store): touch a worker credential on registration and redeem join…
uberware Aug 21, 2026
80bec7a
fix(api): cut broker access before deleting a worker record
uberware Aug 21, 2026
2611b11
fix(brokerauth): write the worker seed file atomically
uberware Aug 21, 2026
09d1a0f
fix(worker): reject a queue id that is not a valid subject token at load
uberware Aug 21, 2026
478c130
fix(cli): note the running-server restart requirement in keygen output
uberware Aug 21, 2026
9242d3f
ci(integration): run the whole test/integration suite, not three name…
uberware Aug 21, 2026
b8e7961
fix(brokerauth): document SaveSeed's directory-permission requirement…
uberware Aug 21, 2026
5840950
docs: correct protocol channel count, transport, and worker-credentia…
uberware Aug 21, 2026
dfdb1f0
ci: give the integration suite an explicit timeout above container st…
uberware Aug 21, 2026
b576129
refactor(brokerauth): move worker join-token generation into a server…
uberware Aug 21, 2026
716a03f
perf(scheduler): cache task-attempt ownership so log ingest stops re-…
uberware Aug 21, 2026
3f68624
refactor(scheduler): extract shared discard-and-ack helpers for unexp…
uberware Aug 21, 2026
b1dd0ac
refactor(brokerauth): share the nkey signing option between the broke…
uberware Aug 21, 2026
d846d19
refactor(worker): derive the default nkey seed filename from internal…
uberware Aug 21, 2026
efd2190
fix(scheduler): evict stale attempt-cache entries on log-write failur…
uberware Aug 21, 2026
7357d3e
test(scheduler): cover attempt-cache eviction on deletion, retry, and…
uberware Aug 21, 2026
3a5f83c
docs(auth): fix credential-file, keygen, and enrollment ordering clai…
uberware Aug 21, 2026
547b7e0
docs(api): correct worker-enrollment OpenAPI responses and REST permi…
uberware Aug 21, 2026
c073a1b
docs(operations): document the sqi-server worker CLI family and its -…
uberware Aug 21, 2026
c3c27db
docs(development): note the migration step and per-aggregate fake lay…
uberware Aug 21, 2026
0ede116
fix(cmd/sqi-server): resolve db path through config and require it ex…
uberware Aug 21, 2026
157281c
fix(cmd/sqi-worker): make keygen read worker config and report new vs…
uberware Aug 21, 2026
c2fd48c
fix(scripts): create the database before minting a smoke-test join token
uberware Aug 21, 2026
00fbc7d
docs: describe the config-resolved db path and config-aware keygen
uberware Aug 21, 2026
a05594f
fix(config): expose provenance for config-decided values via LoadWith…
uberware Aug 21, 2026
92442be
fix(cmd/sqi-server): use config provenance, add schema check, and be …
uberware Aug 21, 2026
519d35c
fix(cmd/sqi-worker): use config provenance for keygen and make its te…
uberware Aug 21, 2026
255523a
docs: correct stale keygen and backup claims flagged in review
uberware Aug 21, 2026
dabe9f2
fix(scripts): exercise config-resolved db path in smoke's broker-auth…
uberware Aug 21, 2026
47aec82
chore: render the breaking-change description in the generated changelog
uberware Aug 21, 2026
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
108 changes: 104 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,13 @@ jobs:
# branch that had touched none of the reported code. Bump this
# deliberately, in its own commit, with the fallout fixed alongside it.
#
# 2.13.1 is also a FLOOR, not just a pin: .golangci.yml excludes
# There is also a FLOOR below this pin: .golangci.yml excludes
# errors.AsType from errcheck by function name, and errcheck before
# 2.13.1 cannot resolve a generic function's name, so on an older
# 2.13.0 cannot resolve a generic function's name, so on an older
# golangci-lint that exclusion silently fails to match and every
# errors.AsType call site is reported. Keep docs/development.md's
# required-version note in step with this value.
# errors.AsType call site is reported. The floor is 2.13.0 (what Homebrew
# ships, verified clean on this repo); this pin is one patch above it.
# Keep docs/development.md's required-version note in step with both.
#
# Explicit path patterns mirror the Makefile LINT_PKGS variable: they
# exclude web/node_modules/ (third-party JS packages that happen to
Expand Down Expand Up @@ -959,6 +960,105 @@ jobs:
fi
exit $missing

# ── Integration suite (whole package) ────────────────────────────────────────
# Runs every file under test/integration/ via `make test-integration`, rather
# than naming individual tests in a -run regex the way the LDAP, OIDC, and
# isolation jobs below do. Those three name their tests because each needs
# its own runner matrix or container prerequisites; everything else in the
# package — broker auth, EXPR parameter binding and end-to-end execution,
# failure-reason propagation (both in-process and against a real worker
# binary), retry/auto-park behavior, and product retry-override submission —
# shares no such prerequisite, so a single whole-package run covers a new
# test the moment it is added instead of only if someone also remembers to
# list it in a job. `go build`s a real sqi-worker binary along the way (see
# worker_binary_test.go); no other tooling is required beyond Go itself.
#
# LDAP, OIDC, and isolation tests live in this same package and this job
# exercises them too, but ubuntu-latest ships Docker so they are expected to
# run rather than skip; the LDAP and Keycloak images are pulled explicitly
# first so a registry hiccup fails here with a clear message instead of
# inside the test as a cold-pull timeout that would read as a skip. The
# isolation tests, in contrast, require running as root (see
# isolation-integration below) and are expected to skip on this ordinary
# runner — that is fine, and this job asserts nothing about them by name.
#
# IMPORTANT: a green `go test` here proves nothing on its own if the whole
# suite silently collected zero tests (a build-tag regression, a stray
# -run filter). This job does not trust the exit code alone: it requires a
# test named from every file in the package that has no dedicated CI job of
# its own — one exception is authfixture_test.go, which holds only helpers
# shared by ldap_test.go and oidc_test.go and declares no test function of
# its own to name. A confirmed-skip line for LDAP/OIDC/isolation does not
# fail the job; only a missing PASS among the required names, or a nonzero
# exit, does.
integration-tests:
name: Integration suite
runs-on: ubuntu-latest
needs: test

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod

# Pulled explicitly for the same reason ldap-integration and
# oidc-integration below pull them: a registry problem fails here with a
# clear message rather than surfacing inside the test as a cold-pull
# timeout, which the test's own Docker-unavailable handling would read
# as an ordinary skip.
- name: Pull OpenLDAP image
run: docker pull osixia/openldap:1.5.0

- name: Pull Keycloak image
run: docker pull quay.io/keycloak/keycloak:26.0.7

- name: Run the integration suite
run: |
set +e
# go test's own default (10m per package) leaves little headroom
# once the LDAP and Keycloak containers are in the run: up to ~90s
# for LDAP readiness plus up to ~180s for Keycloak, on top of the
# suite's own runtime, which is dominated by that container
# readiness rather than by the tests themselves. 15m is set
# explicitly here, through INTEGRATION_TEST_FLAGS, so a slow runner
# gets a clear failure instead of a default timeout landing close
# enough to the estimate to be a coin flip; local runs are
# unaffected since the Makefile leaves this empty by default.
make test-integration INTEGRATION_TEST_FLAGS="-timeout 15m" 2>&1 | tee integration-output.log
status=${PIPESTATUS[0]}
set -e

# One name from each file in test/integration/ that has no dedicated
# CI job of its own, so a build-tag or filter regression that let the
# suite silently collect zero tests cannot pass this job.
expected=(
TestDefaultConfig_NoBrokerAuth
TestRevocation_DisconnectsAndReclaims
TestEnrollment_ConnectsToRunningBrokerWithoutRestart
TestEXPRParamTypes_BindAndCarry
TestEXPRJobEndToEnd
TestTaskFailureReason_VisibleEndToEnd
TestWorkerBinaryStagingFailureReason
TestProductSubmit_RetryOverrides
TestAutoRetry_RetryThenSucceed
)
missing=0
for name in "${expected[@]}"; do
if ! grep -q -- "--- PASS: $name" integration-output.log; then
echo "::error::$name did not pass"
missing=1
fi
done
if [ "$status" -ne 0 ] || [ "$missing" -ne 0 ]; then
echo "::error::the integration suite did not all pass (exit=$status)."
exit 1
fi
echo "Confirmed all ${#expected[@]} named integration tests passed, and the whole suite exited 0."

# Runs the official OpenJD conformance suite (a pinned submodule under
# third_party/) against internal/openjd. Tagged `conformance`, so it does not
# run in the default `make test`.
Expand Down
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ linters:
# and reports it. Exclude the function rather than turning check-blank
# off, so a genuine `_ = f()` is still caught.
#
# Requires golangci-lint >= 2.13.1: earlier errcheck cannot resolve a
# Requires golangci-lint >= 2.13.0: earlier errcheck cannot resolve a
# generic function's name (it reports "Error return value is not
# checked" with no name), so this entry never matches and every
# errors.AsType call site is flagged.
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,12 @@ test-cover-html: test-cover ## Open HTML coverage report in the browser
go tool cover -html=$(COVERAGE_OUT)

.PHONY: test-integration
# INTEGRATION_TEST_FLAGS is empty by default so a local run gets go test's
# ordinary defaults; CI sets it to add a timeout sized for the suite's own
# LDAP/Keycloak containers without changing what a local run does.
INTEGRATION_TEST_FLAGS ?=
test-integration: ## Run integration tests (tagged 'integration')
go test $(TEST_FLAGS) -tags integration ./test/...
go test $(TEST_FLAGS) -tags integration -v $(INTEGRATION_TEST_FLAGS) ./test/...

.PHONY: test-conformance
test-conformance: ## Run the official OpenJD conformance suite (needs the pinned submodule)
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Configuration cascades: farm defaults → queue overrides, with retry policy (ma

**Scheduling considers:** job priority, task dependencies, queue and farm policy (concurrency limits, scheduling mode), compute location affinity, worker capability tags (OS, GPU, installed software), and usage pool availability.
- *Design:* ready tasks remain `ready` until a worker sends a core-NATS
request to `work.lease.<queue>`. The server computes free cores
request to `work.lease.<worker>.<queue>`. The server computes free cores
(`CPUCount − Σ committed`), selects a priority-ordered batch that fits,
atomically transitions the batch `ready → assigned` (stamping `assigned_at`
only now), and replies. The `SQI_WORK` JetStream stream, `work.assign.<queue>`
Expand Down Expand Up @@ -212,7 +212,7 @@ NATS JetStream handles:
- Heartbeats and worker registration

Work leases use **core NATS** request/reply (not JetStream): the worker requests
work on `work.lease.<queue>` and the server replies with a batch it is
work on `work.lease.<worker>.<queue>` and the server replies with a batch it is
authorized to run (pull-based). Real-time UI updates reach web clients over
WebSocket, fanned out by the server after it ingests the JetStream messages.

Expand Down
4 changes: 3 additions & 1 deletion cliff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ body = """
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.breaking %} ⚠️ BREAKING{% endif %} \
{% if commit.breaking %} ⚠️ BREAKING\
{% if commit.breaking_description and commit.breaking_description != commit.message %} — {{ commit.breaking_description }}{% endif %}\
{% endif %} \
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/uberware/sqi/commit/{{ commit.id }}))
{%- endfor %}
{% endfor %}\n
Expand Down
35 changes: 26 additions & 9 deletions cmd/sqi-server/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ The backup is produced using SQLite's VACUUM INTO statement, which snapshots
the live database without taking an exclusive lock. The server may be running
or stopped — either works. The destination file must not already exist.

The source database path defaults to store.sqlite_path from the resolved
configuration (the root -c/--config file and SQI_STORE_SQLITE_PATH), falling
back to the legacy SQI_SQLITE_PATH environment variable and then to "sqi.db".
Pass --db to override it explicitly. The source database must already exist —
this command never creates one.

Example:
sqi-server backup --db sqi.db --out sqi-backup-$(date +%Y%m%d).db`,
RunE: runBackup,
Expand All @@ -39,8 +45,8 @@ Example:
func init() {
backupCmd.Flags().StringVar(
&backupFlags.DBPath,
"db", envOr("SQI_SQLITE_PATH", "sqi.db"),
"path to source SQLite database file",
"db", "sqi.db",
"path to source SQLite database file (defaults to store.sqlite_path from config, then the deprecated SQI_SQLITE_PATH, then \"sqi.db\")",
)
backupCmd.Flags().StringVar(
&backupFlags.OutPath,
Expand All @@ -52,20 +58,31 @@ func init() {
}
}

func runBackup(_ *cobra.Command, _ []string) error {
if backupFlags.DBPath == "" {
return errors.New("source database path is empty; use --db or set SQI_SQLITE_PATH")
}
func runBackup(cmd *cobra.Command, _ []string) error {
if backupFlags.OutPath == "" {
return errors.New("destination path is empty; use --out")
}

dbPath, err := resolveDBPath(backupFlags.DBPath, cmd != nil && cmd.Flags().Changed("db"))
if err != nil {
return err
}
if dbPath == "" {
return errors.New("source database path is empty; use --db, set store.sqlite_path, or set SQI_STORE_SQLITE_PATH")
}
if err := requireExistingDB(dbPath); err != nil {
return err
}
if err := requireMigratedDB(dbPath); err != nil {
return err
}

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))

ctx := context.Background()

logger.InfoContext(ctx, "backup: opening source database", slog.String("path", backupFlags.DBPath))
st, err := sqlite.Open(ctx, backupFlags.DBPath, sqlite.Options{AutoMigrate: false})
logger.InfoContext(ctx, "backup: opening source database", slog.String("path", dbPath))
st, err := sqlite.Open(ctx, dbPath, sqlite.Options{AutoMigrate: false})
if err != nil {
return fmt.Errorf("open source database: %w", err)
}
Expand All @@ -74,7 +91,7 @@ func runBackup(_ *cobra.Command, _ []string) error {
start := time.Now()
logger.InfoContext(
ctx, "backup: starting",
slog.String("src", backupFlags.DBPath),
slog.String("src", dbPath),
slog.String("dst", backupFlags.OutPath),
)

Expand Down
122 changes: 122 additions & 0 deletions cmd/sqi-server/dbpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"context"
"database/sql"
"errors"
"fmt"
"os"

_ "modernc.org/sqlite" // register "sqlite" driver

"github.com/uberware/sqi/internal/config"
)

// resolveDBPath applies the database-path precedence shared by backup,
// migrate, and worker, highest priority first:
//
// 1. explicit is used as-is when explicitChanged is true (the operator
// passed --db on the command line).
// 2. The config layer: the root -c/--config file and SQI_STORE_SQLITE_PATH,
// i.e. whatever [config.LoadWithSources] resolves store.sqlite_path to,
// when [config.Sources.StoreSQLitePath] reports the file or env var
// actually set it — NOT when the resolved value merely differs from the
// built-in default, which a config file that restates the default
// value (as config/sqi-server.example.yaml does) would defeat.
// 3. The legacy SQI_SQLITE_PATH environment variable, kept working as an
// alias. A deprecation notice naming SQI_STORE_SQLITE_PATH — the
// variable sqi-server itself reads — is printed to stderr when this is
// the layer that decided the path.
// 4. The built-in default ("sqi.db").
//
// The config layer is always loaded, even when an explicit --db makes its
// result moot, so a malformed --config file is reported as an error rather
// than silently ignored when the operator asked for a specific one (root
// -c/--config was passed explicitly). Without an explicit -c, a config-load
// failure — an auto-discovered but broken /etc/sqi/sqi-server.yaml, or an
// unrelated malformed SQI_* env var with nothing to do with the database
// path — is NOT a hard failure: backup, migrate, and worker are the tools
// reached for when something is already broken, so a warning goes to
// stderr and resolution falls through to the legacy env var and default as
// if the config layer had decided nothing.
func resolveDBPath(explicit string, explicitChanged bool) (string, error) {
cfg, src, err := config.LoadWithSources(persistentFlags.ConfigFile, config.FlagOverrides{})
if err != nil {
if persistentFlags.ConfigFile != "" {
return "", fmt.Errorf("load config: %w", err)
}
fmt.Fprintf(
os.Stderr,
"warning: could not load configuration (%v); falling back to SQI_SQLITE_PATH or the built-in default\n",
err,
)
cfg = config.DefaultConfig()
src = config.Sources{}
}

if explicitChanged {
return explicit, nil
}

if src.StoreSQLitePath {
return cfg.Store.SQLitePath, nil
}

if legacy := envOr("SQI_SQLITE_PATH", ""); legacy != "" {
fmt.Fprintln(os.Stderr,
"warning: SQI_SQLITE_PATH is deprecated; set SQI_STORE_SQLITE_PATH instead, "+
"which is the variable sqi-server itself reads")
return legacy, nil
}

return cfg.Store.SQLitePath, nil
}

// requireExistingDB stats path and returns an actionable error naming it and
// how to point elsewhere when no file exists there. Used by backup and
// worker, which must never create a database — unlike migrate, whose job is
// to create one.
func requireExistingDB(path string) error {
if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf(
"no database at %s; point --db, store.sqlite_path, or SQI_STORE_SQLITE_PATH at the right file, "+
"or run \"sqi-server migrate up\" to create one there",
path,
)
}
return fmt.Errorf("stat %s: %w", path, err)
}
return nil
}

// requireMigratedDB catches the case requireExistingDB's stat check cannot
// see: a file that exists at path but was never migrated (empty, or created
// by something other than "migrate up" or the server's own AutoMigrate).
// sqlite.Open with AutoMigrate: false succeeds against such a file — SQLite
// opens an empty database happily — so the first real query would otherwise
// fail with a raw driver error ("no such table: ..."). Checked directly
// against sqlite_master rather than through the store, so it runs before
// any store method that would surface that error unremediated.
func requireMigratedDB(path string) error {
db, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer db.Close()

var count int
err = db.QueryRowContext(
context.Background(),
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
).Scan(&count)
if err != nil {
return fmt.Errorf("check schema at %s: %w", path, err)
}
if count == 0 {
return fmt.Errorf("the database at %s has no schema; run \"sqi-server migrate up\" first", path)
}
return nil
}
Loading