diff --git a/.githooks/install b/.githooks/install index 6ff078f..569cee4 100755 --- a/.githooks/install +++ b/.githooks/install @@ -23,7 +23,7 @@ source_hook="$repo_root/.githooks/pre-commit" installed_hook="$hooks_dir/pre-commit" mkdir -p "$hooks_dir" -mkdir -p "$hooks_dir/pre-commit.d/backend" "$hooks_dir/pre-commit.d/frontend" +mkdir -p "$hooks_dir/pre-commit.d" if [ -e "$installed_hook" ] || [ -L "$installed_hook" ]; then if [ -L "$installed_hook" ] && [ "$(readlink "$installed_hook")" = "$source_hook" ]; then echo "Pre-commit hook is already installed." diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6703039..c37fc1b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -64,15 +64,6 @@ run_extra_dir() { done } -check_go_format() { - local files - files=$(gofmt -l cmd internal) || return 1 - if [ -n "$files" ]; then - printf '%s\n' "$files" >&2 - return 1 - fi -} - echo "Running pre-commit checks ($mode_label)... ${REPO_ROOT}" >&2 go_changed=0 frontend_changed=0 @@ -80,7 +71,7 @@ frontend_changed=0 while IFS= read -r path; do [ -n "$path" ] || continue case "$path" in - cmd/*.go | internal/*.go | go.mod | go.sum | go.work | go.work.sum) + cmd/*.go | internal/*.go | go.mod | go.sum | go.work | go.work.sum | .golangci.yml | .githooks/pre-commit) go_changed=1 ;; .editorconfig | mise.toml) @@ -99,21 +90,21 @@ EOF if [ "$frontend_changed" -eq 1 ]; then echo "Preparing frontend..." >&2 - NODE_ENV=development bun --cwd="$PWD/frontend/" run postinstall --envName=development + NODE_ENV=development mise exec -- bun --cwd="$PWD/frontend/" run nuxt prepare --envName=development echo "Frontend preparation passed." >&2 fi if [ "$go_changed" -eq 1 ]; then - run_step "Go: Formatter" check_go_format - run_step "Go: Tests" go test -race ./cmd/... ./internal/... - run_step "Go: Vet" go vet ./cmd/... ./internal/... + run_step "Go: Formatter" mise exec -- golangci-lint fmt --diff ./cmd/... ./internal/... + run_step "Go: Tests" mise exec -- go test -race ./cmd/... ./internal/... + run_step "Go: Linter" mise exec -- golangci-lint run ./cmd/... ./internal/... fi if [ "$frontend_changed" -eq 1 ]; then - run_step "Frontend: Formatter" bun --cwd="$PWD/frontend/" format:check - run_step "Frontend: Linter" bun --cwd="$PWD/frontend/" lint - run_step "Frontend: Tests" bun --cwd="$PWD/frontend/" run test - run_step "Frontend: TSC" bun --cwd="$PWD/frontend/" lint:tsc + run_step "Frontend: Formatter" mise exec -- bun --cwd="$PWD/frontend/" run format:check + run_step "Frontend: Linter" mise exec -- bun --cwd="$PWD/frontend/" run lint + run_step "Frontend: Tests" mise exec -- bun --cwd="$PWD/frontend/" test + run_step "Frontend: TSC" mise exec -- bun --cwd="$PWD/frontend/" run lint:tsc fi extra_dir=$(git rev-parse --git-path hooks)/pre-commit.d diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 8e2d284..8a89e93 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -13,11 +13,6 @@ on: - '**.md' - '.github/ISSUE_TEMPLATE/**' -env: - BUN_VERSION: '1.4.4' - NODE_VERSION: 22 - GO_VERSION: '1.27.3' - jobs: test: name: Run Tests & Build Validation @@ -27,11 +22,26 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up Go - uses: actions/setup-go@v6 + - name: Set up tools + uses: jdx/mise-action@v5 + with: + install_args: go bun node golangci-lint + + - name: Locate Go caches + id: go-cache + run: | + echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + + - name: Cache Go dependencies and builds + uses: actions/cache@v6 with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: go.sum + path: | + ${{ steps.go-cache.outputs.modules }} + ${{ steps.go-cache.outputs.build }} + key: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}-${{ hashFiles('go.sum') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}- - name: Cache Bun dependencies uses: actions/cache@v6 @@ -41,16 +51,6 @@ jobs: restore-keys: | ${{ runner.os }}-bun- - - name: Install Node.js - uses: actions/setup-node@v7 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - name: Install frontend dependencies env: NODE_ENV: development diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b81a5b6..d7d23ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,9 +27,6 @@ env: BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }} DOCKERHUB_SLUG: arabcoders/dcompose GHCR_SLUG: ghcr.io/arabcoders/dcompose - BUN_VERSION: '1.4.4' - NODE_VERSION: '22' - GO_VERSION: '1.27.3' jobs: validate-build-config: @@ -53,10 +50,25 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v6 + - uses: jdx/mise-action@v5 with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: go.sum + install_args: go bun node golangci-lint + + - name: Locate Go caches + id: go-cache + run: | + echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + + - name: Cache Go dependencies and builds + uses: actions/cache@v6 + with: + path: | + ${{ steps.go-cache.outputs.modules }} + ${{ steps.go-cache.outputs.build }} + key: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}-${{ hashFiles('go.sum') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}- - name: Cache Bun dependencies uses: actions/cache@v6 @@ -65,14 +77,6 @@ jobs: key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock') }} restore-keys: ${{ runner.os }}-bun- - - uses: actions/setup-node@v7 - with: - node-version: ${{ env.NODE_VERSION }} - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - name: Install frontend dependencies run: bun --cwd=$PWD/frontend/ install --frozen-lockfile @@ -124,10 +128,25 @@ jobs: name: frontend-build path: frontend/exported/ - - uses: actions/setup-go@v6 + - uses: jdx/mise-action@v5 + with: + install_args: go + + - name: Locate Go caches + id: go-cache + run: | + echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + + - name: Cache Go dependencies and builds + uses: actions/cache@v6 with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: go.sum + path: | + ${{ steps.go-cache.outputs.modules }} + ${{ steps.go-cache.outputs.build }} + key: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}-${{ hashFiles('go.sum') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-go-${{ steps.go-cache.outputs.version }}-${{ github.job }}- - name: Build and package native binaries run: | diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b9e9fa9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +version: '2' + +formatters: + enable: + - gofmt + - golines + settings: + golines: + max-len: 140 + +linters: + default: none + enable: + - govet + - staticcheck + - ineffassign + - unused + - errcheck + - modernize + settings: + staticcheck: + checks: + - SA* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e95633..6c37222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,9 @@ review. ## Before you start -Report vulnerabilities privately through [SECURITY.md](SECURITY.md). For a bug fix or small feature, open a GitHub issue. -Use a discussion for larger changes or general questions. Explain what you want to change, why, your proposed approach -and which existing behavior or use cases it affects. +Report vulnerabilities privately through [SECURITY.md](SECURITY.md). For bugs, proposed changes or general questions, +open a GitHub issue. Explain what you want to change, why, your proposed approach and which existing behavior or use +cases it affects. Bug fixes, functional improvements and measurable performance work take priority. An interface change needs more than personal preference. I will decline a proposal or ask for a different scope or approach. @@ -22,7 +22,17 @@ documentation will be rejected. Write tests that check behavior, not just increa ## Development setup -You'll need Go 1.27.3+ and Bun 1.4+. Run commands from the repository root. Install frontend dependencies with: +Branch from `dev`: + +```sh +git switch dev +git pull origin dev +git switch -c feature/descriptive-name +``` + +Install [mise](https://mise.jdx.dev/getting-started.html), then run `mise install` to install the development tools from +`mise.toml`. The pre-commit checks use `mise exec` so local checks and CI use the same tool versions. +Run commands from the repository root. Install frontend dependencies with: ```sh bun --cwd=$PWD/frontend/ install --frozen-lockfile @@ -40,8 +50,9 @@ Install the local pre-commit hook once per clone: .githooks/pre-commit --all # all tracked files ``` -The installer preserves an existing pre-commit hook unless you pass `--force`. To keep local checks, place executable -files in `.git/hooks/pre-commit.d/` (always run), `backend/` (Go changes), or `frontend/` (frontend changes). +The installer preserves an existing pre-commit hook unless you pass `--force`. Put executable local checks in +`.git/hooks/pre-commit.d/` to run them for all changes. Create `backend/` or `frontend/` subdirectories there for +Go-only or frontend-only checks. ## Check changes @@ -57,10 +68,13 @@ bun --cwd=$PWD/frontend/ format:check For Go changes: ```sh -go test -race ./cmd/... ./internal/... -go vet ./cmd/... ./internal/... +mise exec -- golangci-lint fmt ./cmd/... ./internal/... +mise exec -- go test -race ./cmd/... ./internal/... +mise exec -- golangci-lint run ./cmd/... ./internal/... ``` +Go formatting uses gofmt and golines with a 140-column target. Pre-commit and CI check formatting without rewriting files. + ## Local hot reload Install the tools from `mise.toml` and start the backend watcher: @@ -113,6 +127,12 @@ go build -o dist/dagent ./cmd/dagent The deployed server includes the web interface and doesn't need Go or Bun. +## Pull requests + +Every pull request must target `dev`, never `master`. Reference the approved issue, explain what changed +and why, and include any breaking changes or setup steps. Run the checks that apply to your changes and update the +documentation where needed. Pull requests targeting `master` will be closed without review. + ## API versions `GET /api/version` returns the server's application version, API version and supported node API version. A node's @@ -127,5 +147,5 @@ The image includes both binaries, Docker Compose and the Podman remote client. I the command with `dagent` for a node. To build it: ```sh -docker build -t arabcoders/dcompose . +docker build -t ghcr.io/arabcoders/dcompose . ``` diff --git a/README.md b/README.md index d2b9f73..e74102f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # DCompose ![Build Status](https://github.com/arabcoders/dcompose/actions/workflows/main.yml/badge.svg) +![MIT License](https://img.shields.io/github/license/arabcoders/dcompose.svg) +![Docker Pulls](https://img.shields.io/docker/pulls/arabcoders/dcompose.svg) +![GHCR Pulls](https://ghcr-badge.elias.eu.org/shield/arabcoders/dcompose/dcompose) DCompose manages your compose stacks on local and remote hosts from one web interface. The project is inspired by [Dockge](https://github.com/louislam/dockge). @@ -22,10 +25,12 @@ Only `dagent` needs access to Docker or Podman. On Linux, run the installer and choose server, node, or both: ```sh -curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/master/install.sh -o install.sh +curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/HEAD/install.sh -o install.sh bash install.sh ``` +The installer previews commands by default. Review them, then run `bash install.sh --execute` to install. + See [Installation](installation.md) for requirements. For manual setup, use the Docker instructions below or the [native and Podman instructions](packaging/README.md). @@ -106,6 +111,6 @@ is not good fit for you. Build your own using technologies you approve of. ## Other projects -- [YTPTube](https://github.com/arabcoders/ytptube): Download manager and automation interface for yt-dlp. -- [WatchState](https://github.com/arabcoders/watchstate): Sync play states between media servers without third-party services. -- [Millwright](https://github.com/arabcoders/millwright): automation interface for FFmpeg. +* [WatchState](https://github.com/arabcoders/watchstate): Sync your media servers without relying on 3rd parties. +* [YTPTube](https://github.com/arabcoders/ytptube): Download manager and automation interface for yt-dlp. +* [Millwright](https://github.com/arabcoders/millwright): Automation interface for FFmpeg. diff --git a/cmd/dagent/main.go b/cmd/dagent/main.go index a81173f..3980e06 100644 --- a/cmd/dagent/main.go +++ b/cmd/dagent/main.go @@ -38,7 +38,13 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() if err := agent.RunOperation(ctx, *operationFile); nil != err { - slog.Error(fmt.Sprintf("Detached stack operation from request %q failed: %v", *operationFile, err), "request", *operationFile, "error", err) + slog.Error( + fmt.Sprintf("Detached stack operation from request %q failed: %v", *operationFile, err), + "request", + *operationFile, + "error", + err, + ) os.Exit(1) } return @@ -51,10 +57,21 @@ func main() { if "" != *certDir { fingerprint, err := agent.GenerateCert(*certDir) if nil != err { - slog.Error(fmt.Sprintf("Could not create the node TLS certificate and private key in %q: %v", *certDir, err), "directory", *certDir, "error", err) + slog.Error( + fmt.Sprintf("Could not create the node TLS certificate and private key in %q: %v", *certDir, err), + "directory", + *certDir, + "error", + err, + ) os.Exit(1) } - fmt.Fprintf(os.Stderr, "Created the node TLS certificate at %s and private key at %s.\n", filepath.Join(*certDir, "node.crt"), filepath.Join(*certDir, "node.key")) + fmt.Fprintf( + os.Stderr, + "Created the node TLS certificate at %s and private key at %s.\n", + filepath.Join(*certDir, "node.crt"), + filepath.Join(*certDir, "node.key"), + ) fmt.Printf("TLS fingerprint: %s\n", fingerprint) } if "" != *tokenPath { diff --git a/cmd/dagent/main_test.go b/cmd/dagent/main_test.go index dadc620..8238bc5 100644 --- a/cmd/dagent/main_test.go +++ b/cmd/dagent/main_test.go @@ -111,7 +111,8 @@ func TestCLI(t *testing.T) { } cmd := exec.Command(binary, args...) output, err := cmd.CombinedOutput() - if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "error=") || !strings.Contains(string(output), "config="+path) { + if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "error=") || + !strings.Contains(string(output), "config="+path) { t.Fatalf("startup error: %v: %s", err, output) } if test.warn != strings.Contains(string(output), " WARN ") { @@ -131,7 +132,8 @@ func TestCLI(t *testing.T) { requestFile := filepath.Join(root, "missing", "request.json") cmd = exec.Command(binary, "--config", missing, "--run-operation", requestFile) output, err := cmd.CombinedOutput() - if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "request="+requestFile) || strings.Contains(string(output), "config=") { + if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "request="+requestFile) || + strings.Contains(string(output), "config=") { t.Fatalf("operation helper loaded node configuration: %v: %s", err, output) } message := cliMessage(t, output) @@ -147,7 +149,8 @@ func TestCLI(t *testing.T) { certs := filepath.Join(root, "certs") cmd = exec.Command(binary, "--config", missing, "--log-level", "error", "--generate-token", token, "--generate-cert", certs) output, err = cmd.CombinedOutput() - if nil != err || !strings.Contains(string(output), token) || !strings.Contains(string(output), filepath.Join(certs, "node.crt")) || !strings.Contains(string(output), filepath.Join(certs, "node.key")) { + if nil != err || !strings.Contains(string(output), token) || !strings.Contains(string(output), filepath.Join(certs, "node.crt")) || + !strings.Contains(string(output), filepath.Join(certs, "node.key")) { t.Fatalf("credential generation failed or filtered its output: %v: %s", err, output) } for _, path := range []string{token, filepath.Join(certs, "node.key"), filepath.Join(certs, "node.crt")} { diff --git a/cmd/dcompose/main.go b/cmd/dcompose/main.go index 806ec9a..6b5c3a7 100644 --- a/cmd/dcompose/main.go +++ b/cmd/dcompose/main.go @@ -94,7 +94,17 @@ func main() { defer stop() app := server.New(config, frontend.Assets(), version) if err := app.StartLogHistory(); nil != err { - slog.Error(fmt.Sprintf("Could not open operational log history for configuration %q: %v. Make the configuration logs directory writable by the runtime user.", path, err), "config", path, "error", err) + slog.Error( + fmt.Sprintf( + "Could not open operational log history for configuration %q: %v. Make the configuration logs directory writable by the runtime user.", + path, + err, + ), + "config", + path, + "error", + err, + ) os.Exit(1) } app.Start(ctx) @@ -110,13 +120,33 @@ func main() { shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := httpServer.Shutdown(shutdown); nil != err { - slog.Warn(fmt.Sprintf("Could not finish shutting down DCompose at %s: %v. Closing active connections.", config.Global.Listen, err), "address", config.Global.Listen, "error", err) + slog.Warn( + fmt.Sprintf("Could not finish shutting down DCompose at %s: %v. Closing active connections.", config.Global.Listen, err), + "address", + config.Global.Listen, + "error", + err, + ) _ = httpServer.Close() } }() - slog.Info(fmt.Sprintf("Starting DCompose %s at http://%s with configuration %q.", version, config.Global.Listen, path), "version", version, "address", "http://"+config.Global.Listen, "config", path) + slog.Info( + fmt.Sprintf("Starting DCompose %s at http://%s with configuration %q.", version, config.Global.Listen, path), + "version", + version, + "address", + "http://"+config.Global.Listen, + "config", + path, + ) if err := httpServer.ListenAndServe(); nil != err && http.ErrServerClosed != err { - slog.Error(fmt.Sprintf("DCompose could not serve HTTP requests at %s: %v", config.Global.Listen, err), "address", config.Global.Listen, "error", err) + slog.Error( + fmt.Sprintf("DCompose could not serve HTTP requests at %s: %v", config.Global.Listen, err), + "address", + config.Global.Listen, + "error", + err, + ) app.Close() os.Exit(1) } diff --git a/cmd/dcompose/main_test.go b/cmd/dcompose/main_test.go index 37edd18..1ede2ca 100644 --- a/cmd/dcompose/main_test.go +++ b/cmd/dcompose/main_test.go @@ -30,7 +30,10 @@ func TestLogLevel(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Setenv("DCOMPOSE_LOG_LEVEL", test.environment) path := filepath.Join(t.TempDir(), "dcompose.toml") - data := fmt.Sprintf("[global]\nunknown = true\nlog_level = %q\n[auth]\nusername = 'admin'\npassword = 'invalid'\n", test.configured) + data := fmt.Sprintf( + "[global]\nunknown = true\nlog_level = %q\n[auth]\nusername = 'admin'\npassword = 'invalid'\n", + test.configured, + ) if err := os.WriteFile(path, []byte(data), 0600); nil != err { t.Fatal(err) } @@ -39,13 +42,14 @@ func TestLogLevel(t *testing.T) { args = append(args, "--log-level", test.override) } output, err := exec.Command(binary, args...).CombinedOutput() - if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "error=") || !strings.Contains(string(output), "config="+path) { + if nil == err || !strings.Contains(string(output), " ERROR ") || !strings.Contains(string(output), "error=") || + !strings.Contains(string(output), "config="+path) { t.Fatalf("startup error: %v: %s", err, output) } if test.warn != strings.Contains(string(output), " WARN ") { t.Fatalf("warning filter: %s", output) } - for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") { parts := strings.SplitN(line, " ", 3) if 3 != len(parts) { t.Fatal("missing log message", line) @@ -94,7 +98,7 @@ func TestPasswordConfig(t *testing.T) { t.Fatal(err) } _ = write.Close() - defer read.Close() + defer func() { _ = read.Close() }() oldArgs, oldFlags, oldStdin := os.Args, flag.CommandLine, os.Stdin oldLogger := slog.Default() t.Cleanup(func() { diff --git a/cmd/dcompose/password.go b/cmd/dcompose/password.go index c924c83..a420abf 100644 --- a/cmd/dcompose/password.go +++ b/cmd/dcompose/password.go @@ -16,9 +16,9 @@ func readPassword(input *os.File, output io.Writer, confirm bool) (string, error var data []byte var err error if interactive { - fmt.Fprint(output, "Password: ") + _, _ = fmt.Fprint(output, "Password: ") data, err = term.ReadPassword(fd) - fmt.Fprintln(output) + _, _ = fmt.Fprintln(output) } else { data, err = io.ReadAll(io.LimitReader(input, 4096)) } @@ -35,9 +35,9 @@ func readPassword(input *os.File, output io.Writer, confirm bool) (string, error } if interactive && confirm { - fmt.Fprint(output, "Confirm password: ") + _, _ = fmt.Fprint(output, "Confirm password: ") repeated, err := term.ReadPassword(fd) - fmt.Fprintln(output) + _, _ = fmt.Fprintln(output) if nil != err { return "", err } diff --git a/cmd/dcompose/password_test.go b/cmd/dcompose/password_test.go index 8026e4f..f713158 100644 --- a/cmd/dcompose/password_test.go +++ b/cmd/dcompose/password_test.go @@ -28,7 +28,7 @@ func TestPasswordInput(t *testing.T) { if nil != err { t.Fatal(err) } - defer input.Close() + defer func() { _ = input.Close() }() if _, err := output.WriteString(tc.input); nil != err { t.Fatal(err) } @@ -65,14 +65,14 @@ func TestPasswordPrompt(t *testing.T) { if nil != err { t.Fatal(err) } - defer master.Close() - defer slave.Close() + defer func() { _ = master.Close() }() + defer func() { _ = slave.Close() }() prompts, output, err := os.Pipe() if nil != err { t.Fatal(err) } - defer prompts.Close() - defer output.Close() + defer func() { _ = prompts.Close() }() + defer func() { _ = output.Close() }() driven := make(chan error, 1) go func() { diff --git a/compose.yaml b/compose.yaml index 6c605f7..f49539c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ name: dcompose services: dcompose: - image: arabcoders/dcompose:master + image: ghcr.io/arabcoders/dcompose:latest user: '${DCOMPOSE_UID:-1000}:${DCOMPOSE_GID:-1000}' environment: # Optional override. If unset, use global.log_level from TOML. @@ -18,7 +18,7 @@ services: - ./config/dcompose:/config dagent: - image: arabcoders/dcompose:master + image: ghcr.io/arabcoders/dcompose:latest hostname: dagent command: ['dagent'] labels: diff --git a/go.mod b/go.mod index 157898d..5e5ea1a 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/coder/websocket v1.8.15 github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 - github.com/go-jose/go-jose/v4 v4.1.3 + github.com/go-jose/go-jose/v4 v4.1.6 github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 golang.org/x/crypto v0.38.0 golang.org/x/oauth2 v0.32.0 diff --git a/go.sum b/go.sum index 6d052c8..dda1ee8 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/ github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= diff --git a/install.sh b/install.sh index bebafe9..c617ab1 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,7 @@ NODE_PORT=9077 HOST= SERVER_SOURCE=any TEMP_DIR= +DRY_RUN=true fail() { printf 'Error: %s\n' "$*" >&2 @@ -23,11 +24,37 @@ need() { command -v "$1" >/dev/null || fail "Install $1 and run the installer again." } +run() { + if [[ true == $DRY_RUN ]]; then + printf '%q ' "$@" + printf '\n' + else + "$@" + fi +} + +write_file() { + local path=$1 mode=$2 owner=$3 group=$4 + shift 4 + if [[ true == $DRY_RUN ]]; then + printf '\n# Save this content to %s:\n' "$path" + "$@" + printf '\n' + as_root chmod "$mode" "$path" + as_root chown "$owner:$group" "$path" + printf '\n' + else + local temporary=$TEMP_DIR/$(basename "$path") + "$@" >"$temporary" + as_root install -m "$mode" -o "$owner" -g "$group" "$temporary" "$path" + fi +} + as_root() { if [[ $EUID == 0 ]]; then - "$@" + run "$@" else - sudo "$@" + run sudo "$@" fi } @@ -35,7 +62,7 @@ as_user() { local user=$1 shift if [[ $(id -un) == "$user" ]]; then - "$@" + run "$@" else as_root runuser -u "$user" -- "$@" fi @@ -47,10 +74,10 @@ docker_cli() { if [[ $PODMAN_MODE == rootful ]]; then as_root "${connection[@]}" docker "$@" else - "${connection[@]}" docker "$@" + run "${connection[@]}" docker "$@" fi else - docker "$@" + run docker "$@" fi } @@ -58,14 +85,16 @@ prepare_podman_socket() { need systemctl if [[ $PODMAN_MODE == rootless ]]; then as_root loginctl enable-linger "$(id -un)" - systemctl --user enable --now podman.socket - systemctl --user enable podman-restart.service + run systemctl --user enable --now podman.socket + run systemctl --user enable podman-restart.service else as_root systemctl enable --now podman.socket as_root systemctl enable podman-restart.service fi - as_root test -S "$PODMAN_SOCKET" || fail "No Podman socket at $PODMAN_SOCKET. Start that socket before installing." - docker_cli info >/dev/null + if [[ false == $DRY_RUN ]]; then + as_root test -S "$PODMAN_SOCKET" || fail "No Podman socket at $PODMAN_SOCKET. Start that socket before installing." + docker_cli info >/dev/null + fi } ask() { @@ -89,6 +118,10 @@ choose() { } set_admin_password() { + if [[ true == $DRY_RUN ]]; then + "$@" + return + fi local retry until "$@" "$TEMP_DIR/agent.toml" - as_root install -m 0600 -o "$NODE_USER" -g "$NODE_GROUP" "$TEMP_DIR/agent.toml" "$NODE_CONFIG" + write_file "$NODE_CONFIG" 0600 "$NODE_USER" "$NODE_GROUP" write_agent_config fi if [[ $ROLE != node ]]; then if [[ $ROLE == both ]]; then - FINGERPRINT=$(fingerprint) + if [[ true == $DRY_RUN ]]; then + printf '\n# Paste the output below into the fingerprint field in %s:\n' "$SERVER_CONFIG" + fingerprint + FINGERPRINT=REPLACE_WITH_NODE_TLS_FINGERPRINT + else + FINGERPRINT=$(fingerprint) + fi as_root install -m 0600 -o "$SERVER_USER" -g "$SERVER_GROUP" "$NODE_DIR/secrets/token" "$SERVER_DIR/secrets/local-node.token" fi - write_server_config >"$TEMP_DIR/dcompose.toml" - as_root install -m 0600 -o "$SERVER_USER" -g "$SERVER_GROUP" "$TEMP_DIR/dcompose.toml" "$SERVER_CONFIG" + write_file "$SERVER_CONFIG" 0600 "$SERVER_USER" "$SERVER_GROUP" write_server_config fi } compose_install() { - if as_root test -d "$INSTALL_DIR" && [[ -n $(as_root find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]]; then - fail "$INSTALL_DIR isn't empty. Choose a new installation directory." + if [[ false == $DRY_RUN ]]; then + if as_root test -d "$INSTALL_DIR" && [[ -n $(as_root find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]]; then + fail "$INSTALL_DIR isn't empty. Choose a new installation directory." + fi fi if [[ $ENGINE == podman ]]; then prepare_podman_socket fi - if [[ -n $(docker_cli ps --all --filter label=com.docker.compose.project=dcompose --format '{{.ID}}') ]]; then - fail 'A dcompose Compose project already exists. Use its current installation directory to manage it.' - fi - if [[ $ROLE != server && -n $(docker_cli ps --all --filter label=DCOMPOSE_AGENT=true --format '{{.ID}}') ]]; then - fail 'A DCompose node container already exists on this host.' + if [[ false == $DRY_RUN ]]; then + if [[ -n $(docker_cli ps --all --filter label=com.docker.compose.project=dcompose --format '{{.ID}}') ]]; then + fail 'A dcompose Compose project already exists. Use its current installation directory to manage it.' + fi + if [[ server != $ROLE && -n $(docker_cli ps --all --filter label=DCOMPOSE_AGENT=true --format '{{.ID}}') ]]; then + fail 'A DCompose node container already exists on this host.' + fi fi - docker_cli pull "arabcoders/dcompose:$VERSION" + docker_cli pull "ghcr.io/arabcoders/dcompose:$VERSION" NODE_USER=root NODE_GROUP=root - if [[ $ENGINE == podman && $PODMAN_MODE == rootless ]]; then + if [[ docker == $ENGINE ]]; then + NODE_USER=$(id -un "$INSTALL_UID") + NODE_GROUP=$INSTALL_GID + elif [[ rootless == $PODMAN_MODE ]]; then NODE_USER=$(id -un) NODE_GROUP=$(id -gn) fi @@ -321,7 +382,7 @@ compose_install() { if [[ $ROLE != server ]]; then prepare_stacks fi - as_root install -d -m 0750 -o "$INSTALL_UID" -g "$INSTALL_GID" "$INSTALL_DIR" + as_root install -d -m 0750 -o "$INSTALL_UID" -g "$INSTALL_GID" "$INSTALL_DIR" "$INSTALL_DIR/config" local compose=(docker_cli compose --project-directory "$INSTALL_DIR" -f "$INSTALL_DIR/compose.yaml") NODE_DIR=$INSTALL_DIR/config/dagent SERVER_DIR=$INSTALL_DIR/config/dcompose @@ -330,8 +391,7 @@ compose_install() { AGENT_LISTEN=0.0.0.0:9077 SERVER_LISTEN=0.0.0.0:8080 NODE_URL=https://dagent:9077 - write_compose >"$TEMP_DIR/compose.yaml" - as_root install -m 0644 -o "$INSTALL_UID" -g "$INSTALL_GID" "$TEMP_DIR/compose.yaml" "$INSTALL_DIR/compose.yaml" + write_file "$INSTALL_DIR/compose.yaml" 0644 "$INSTALL_UID" "$INSTALL_GID" write_compose if [[ $ROLE != node ]]; then as_root install -d -m 0700 -o "$SERVER_USER" -g "$SERVER_GROUP" "$SERVER_DIR" "$SERVER_DIR/secrets" fi @@ -346,6 +406,9 @@ compose_install() { set_admin_password "${compose[@]}" run --rm --no-deps dcompose dcompose --set-password --username "$ADMIN_USER" fi "${compose[@]}" up -d + if [[ true == $DRY_RUN ]]; then + return + fi printf '\nCompose file: %s/compose.yaml\n' "$INSTALL_DIR" if [[ $ENGINE == podman ]]; then if [[ $PODMAN_MODE == rootful ]]; then @@ -364,9 +427,9 @@ native_install() { need unzip need sha256sum need runuser - if [[ $ROLE != server && $ENGINE == docker ]]; then + if [[ false == $DRY_RUN && $ROLE != server && $ENGINE == docker ]]; then systemctl cat docker.service >/dev/null || fail 'The native Docker node needs docker.service.' - elif [[ $ROLE != server && $PODMAN_MODE == rootful ]]; then + elif [[ false == $DRY_RUN && $ROLE != server && $PODMAN_MODE == rootful ]]; then as_root podman --remote=false info >/dev/null fi local server_unit=/etc/systemd/system/dcompose.service node_unit=/etc/systemd/system/dagent.service @@ -377,7 +440,9 @@ native_install() { NODE_HOME=/var/lib/dagent if [[ $ENGINE == podman && $PODMAN_MODE == rootless && $ROLE != server ]]; then [[ $EUID != 0 ]] || fail 'Run the installer without sudo for a rootless Podman node.' - systemctl --user show-environment >/dev/null || fail 'Run this from a login session with a systemd user service manager.' + if [[ false == $DRY_RUN ]]; then + systemctl --user show-environment >/dev/null || fail 'Run this from a login session with a systemd user service manager.' + fi NODE_USER=$(id -un) NODE_HOME=$HOME local user_config=$HOME/.config @@ -399,7 +464,7 @@ native_install() { fi if [[ -z $VERSION ]]; then local latest - latest=$(curl -fsSL -o /dev/null -w '%{url_effective}' "$RELEASES/latest") || fail 'No latest native release is available. Use --version with a published release, or choose Compose.' + latest=$(curl -fsSL -o /dev/null -w '%{url_effective}' "$RELEASES/latest") || fail "Couldn't resolve the latest native release. Use --version with a published release." VERSION=${latest##*/} fi [[ $VERSION =~ ^v[0-9][A-Za-z0-9._-]*$ ]] || fail 'Native installations need a published v* release tag.' @@ -421,12 +486,17 @@ native_install() { if [[ $NODE_USER == dagent ]]; then create_account dagent "$NODE_HOME" fi - NODE_GROUP=$(id -gn "$NODE_USER") + NODE_GROUP=$NODE_USER + if id "$NODE_USER" >/dev/null 2>&1; then + NODE_GROUP=$(id -gn "$NODE_USER") + fi prepare_stacks fi if [[ $ROLE != node ]]; then create_account dcompose "$SERVER_DIR" - SERVER_GROUP=$(id -gn dcompose) + if id dcompose >/dev/null 2>&1; then + SERVER_GROUP=$(id -gn dcompose) + fi as_root install -d -m 0700 -o dcompose -g "$SERVER_GROUP" "$SERVER_DIR" "$SERVER_DIR/secrets" as_root install -d -m 0755 -o root -g root /usr/local/bin as_root install -m 0755 -o root -g root "$TEMP_DIR/dcompose-linux-$ARCH-$VERSION/dcompose" /usr/local/bin/dcompose @@ -435,7 +505,7 @@ native_install() { if [[ $ROLE != server ]]; then as_root install -d -m 0755 -o root -g root /opt/dagent if [[ $ENGINE == podman && $PODMAN_MODE == rootless ]]; then - mkdir -p "$NODE_DIR" + run mkdir -p "$NODE_DIR" fi as_root install -d -m 0700 -o "$NODE_USER" -g "$NODE_GROUP" "$NODE_DIR" "$NODE_DIR/secrets" as_root install -m 0755 -o root -g root "$TEMP_DIR/dagent-linux-$ARCH-$VERSION/dagent" /opt/dagent/dagent @@ -443,13 +513,10 @@ native_install() { if [[ $ENGINE == docker ]]; then as_root install -m 0644 -o root -g root "$TEMP_DIR/dagent-linux-$ARCH-$VERSION/packaging/systemd/dagent.service" "$node_unit" else - write_podman_unit >"$TEMP_DIR/dagent.service" if [[ $PODMAN_MODE == rootless ]]; then - mkdir -p "$(dirname "$node_unit")" - install -m 0644 "$TEMP_DIR/dagent.service" "$node_unit" - else - as_root install -m 0644 -o root -g root "$TEMP_DIR/dagent.service" "$node_unit" + run mkdir -p "$(dirname "$node_unit")" fi + write_file "$node_unit" 0644 "$NODE_USER" "$NODE_GROUP" write_podman_unit fi fi save_configs @@ -460,9 +527,9 @@ native_install() { if [[ $ROLE != server ]]; then if [[ $ENGINE == podman && $PODMAN_MODE == rootless ]]; then as_root loginctl enable-linger "$NODE_USER" - systemctl --user daemon-reload - systemctl --user enable --now podman.socket dagent.service - systemctl --user enable podman-restart.service + run systemctl --user daemon-reload + run systemctl --user enable --now podman.socket dagent.service + run systemctl --user enable podman-restart.service else if [[ $ENGINE == podman ]]; then as_root systemctl enable --now podman.socket @@ -474,6 +541,9 @@ native_install() { if [[ $ROLE != node ]]; then as_root systemctl enable --now dcompose.service fi + if [[ true == $DRY_RUN ]]; then + return + fi printf '\n' if [[ $ROLE != node ]]; then printf 'Server config: %s\nLogs: sudo journalctl -u dcompose -f\n' "$SERVER_CONFIG" @@ -492,7 +562,9 @@ main() { while [[ $# != 0 ]]; do case $1 in --version) [[ $# -ge 2 ]] || fail '--version needs a tag.'; VERSION=$2; shift 2 ;; - -h|--help) printf 'Usage: bash install.sh [--version TAG]\n\nInstall the server, a node, or both using Docker Compose or native systemd services.\nRequires Linux, Bash, curl, sudo/root and an installed container runtime for nodes.\nExisting installations are not overwritten.\n'; return ;; + --dry-run) DRY_RUN=true; shift ;; + --execute) DRY_RUN=false; shift ;; + -h|--help) printf 'Usage: bash install.sh [--dry-run | --execute] [--version TAG]\n\nPreview installation commands by default. Use --execute to install.\nInstall the server, a node, or both using Docker Compose or native systemd services.\nRequires Linux, Bash, curl, sudo/root and an installed container runtime for nodes.\nExisting installations are not overwritten.\n'; return ;; *) fail "Unknown option: $1" ;; esac done @@ -505,13 +577,16 @@ main() { [[ -z $VERSION || $VERSION =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || fail 'Use a release tag or image tag without slashes.' need curl need realpath - if [[ $EUID != 0 ]]; then + if [[ false == $DRY_RUN && $EUID != 0 ]]; then need sudo fi [[ -r /dev/tty ]] || fail 'Run the installer in an interactive terminal.' INSTALL_UID=${SUDO_UID:-$(id -u)} INSTALL_GID=${SUDO_GID:-$(id -g)} - printf 'DCompose installation\nRuntime packages must already be installed. Existing installations are left untouched.\n\n' + printf 'DCompose installation\nRuntime packages must already be installed. Existing installations are left untouched.\n\n' >&2 + if [[ true == $DRY_RUN ]]; then + printf 'DRY RUN: no changes will be made. Use --execute to install.\nCommands and generated files will be printed to stdout.\n\n' >&2 + fi choose ROLE 'Install (both/server/node)' both both server node choose METHOD 'Deployment (compose/native)' compose compose native if [[ $METHOD == compose || $ROLE != server ]]; then @@ -541,7 +616,7 @@ main() { ask SERVER_SOURCE 'Server IP, CIDR or hostname allowed to reach this node (any allows all)' any fi fi - if [[ $ROLE == both ]]; then + if [[ false == $DRY_RUN && $ROLE == both ]]; then need openssl fi if [[ $METHOD == compose || $ROLE != server ]]; then @@ -553,12 +628,16 @@ main() { need podman if [[ $PODMAN_MODE == rootless ]]; then [[ $EUID != 0 ]] || fail 'Run the installer without sudo for rootless Podman.' - [[ $(podman --remote=false info --format '{{.Host.Security.Rootless}}') == true ]] || fail 'Select the user who owns the rootless Podman containers.' + if [[ false == $DRY_RUN ]]; then + [[ $(podman --remote=false info --format '{{.Host.Security.Rootless}}') == true ]] || fail 'Select the user who owns the rootless Podman containers.' + fi fi fi if [[ $METHOD == compose ]]; then if [[ $ENGINE == docker ]]; then - docker info >/dev/null + if [[ false == $DRY_RUN ]]; then + docker info >/dev/null + fi local endpoint if [[ -n ${DOCKER_CONTEXT:-} ]]; then endpoint=$(docker context inspect "$DOCKER_CONTEXT" --format '{{.Endpoints.docker.Host}}') @@ -575,28 +654,32 @@ main() { ask PODMAN_SOCKET 'Host Podman Unix socket' "$socket" PODMAN_SOCKET=$(absolute_path "$PODMAN_SOCKET") fi - VERSION=${VERSION:-master} + VERSION=${VERSION:-latest} local directory=/opt/dcompose if [[ $ROLE != server ]]; then directory=$STACKS_DIR/dcompose fi ask INSTALL_DIR 'Compose installation directory' "$directory" INSTALL_DIR=$(absolute_path "$INSTALL_DIR") - elif [[ $ROLE != server && $ENGINE == docker ]]; then + elif [[ false == $DRY_RUN && $ROLE != server && $ENGINE == docker ]]; then as_root docker --host unix:///var/run/docker.sock info >/dev/null getent group docker >/dev/null || fail 'The native Docker node needs the docker group.' fi - printf '\nInstall: %s, %s' "$ROLE" "$METHOD" + printf '\nInstall: %s, %s' "$ROLE" "$METHOD" >&2 if [[ $ROLE != server ]]; then - printf ', %s node; stacks: %s' "$ENGINE" "$STACKS_DIR" + printf ', %s node; stacks: %s' "$ENGINE" "$STACKS_DIR" >&2 + fi + printf '\n' >&2 + if [[ true == $DRY_RUN ]]; then + TEMP_DIR=. + else + local confirm + choose confirm 'Continue (yes/no)' no yes no + [[ $confirm == yes ]] || return 0 + TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dcompose-install.XXXXXX") + trap 'rm -rf -- "$TEMP_DIR"' EXIT + umask 077 fi - printf '\n' - local confirm - choose confirm 'Continue (yes/no)' no yes no - [[ $confirm == yes ]] || return 0 - TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dcompose-install.XXXXXX") - trap 'rm -rf -- "$TEMP_DIR"' EXIT - umask 077 PAIRED=false [[ $ROLE != both ]] || PAIRED=true if [[ $METHOD == compose ]]; then @@ -604,6 +687,10 @@ main() { else native_install fi + if [[ true == $DRY_RUN ]]; then + printf '\nDry run complete. Review the commands above or run bash install.sh --execute to install.\n' >&2 + return + fi local address=$HOST if [[ $address == *:* && $address != \[*\] ]]; then address=[$address] diff --git a/installation.md b/installation.md index 3270fe6..1d986ac 100644 --- a/installation.md +++ b/installation.md @@ -21,7 +21,13 @@ curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/master/install.sh bash install.sh ``` -Use `bash install.sh --help` to see its options. +Running without flags previews the installation. To install: + +```sh +bash install.sh --execute +``` + +Use `bash install.sh --help` to see all options. ## Choose the deployment @@ -30,16 +36,16 @@ Use `bash install.sh --help` to see its options. - **Node only:** manage this host from a DCompose server you already have. Choose **Compose** or **native systemd**. Both Docker and Podman are supported, including rootless and rootful Podman. -Choose where to store your stacks and, for Compose, where to install DCompose. The user running the node needs write access to your stacks. +Choose your stack and installation directories when prompted. -Compose uses the `master` image by default. Native installs require a tagged -[GitHub Release](https://github.com/arabcoders/dcompose/releases) and use the latest release unless you choose a tag: +Compose uses `ghcr.io/arabcoders/dcompose:latest` by default. Native installs use the latest +[GitHub Release](https://github.com/arabcoders/dcompose/releases). To choose a specific version: ```sh -bash install.sh --version TAG +bash install.sh --execute --version TAG ``` -Replace `TAG` with a published release tag. If no native release is available, use Compose. +Replace `TAG` with a published release tag. For Compose, use `--version dev` to install the development image. If you're installing the server, the wizard asks you to create an account before starting it. ## After installation @@ -67,7 +73,7 @@ pre-verifying the fingerprint. Back up configuration, secrets and application data first. The installer doesn't upgrade existing installations. For Compose, change the image tags in `compose.yaml` if you want a specific release, then run `docker compose pull` -and `docker compose up -d` from its directory. The `master` tag follows ongoing development. +and `docker compose up -d` from its directory. The `latest` tag tracks releases; `dev` tracks ongoing development. For native services, download the release archive for your architecture and verify its binary with `sha256sum --check SHA256SUMS` from the extracted directory. For the server: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 09bf0a2..47ea563 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -75,8 +75,8 @@ func newAgent(ctx context.Context, c config) *agent { return a } -// interface{} is limited to JSON encoding of the concrete response structs below. -func respond(w http.ResponseWriter, code int, data interface{}) { +// any (the interface{} alias) is limited to JSON encoding of the concrete response structs below. +func respond(w http.ResponseWriter, code int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(data) @@ -85,12 +85,10 @@ func respond(w http.ResponseWriter, code int, data interface{}) { func respondError(w http.ResponseWriter, r *http.Request, err error) { code := 500 message := "Could not complete the request. Try again." - var apiErr *apiError - var engineErr *engine.Error - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*apiError](err); ok { code = apiErr.code message = apiErr.message - } else if errors.As(err, &engineErr) { + } else if engineErr, ok := errors.AsType[*engine.Error](err); ok { code = engineErr.Code message = engineErr.Message } @@ -102,19 +100,43 @@ func respondError(w http.ResponseWriter, r *http.Request, err error) { if context.Canceled == r.Context().Err() { level = slog.LevelDebug } - slog.Log(r.Context(), level, fmt.Sprintf("%s %s from peer %s failed (HTTP %d): %v", r.Method, r.URL.Path, r.RemoteAddr, code, err), "peer", r.RemoteAddr, "method", r.Method, "resource", r.URL.Path, "status", code, "error", err) + slog.Log( + r.Context(), + level, + fmt.Sprintf("%s %s from peer %s failed (HTTP %d): %v", r.Method, r.URL.Path, r.RemoteAddr, code, err), + "peer", + r.RemoteAddr, + "method", + r.Method, + "resource", + r.URL.Path, + "status", + code, + "error", + err, + ) } respond(w, code, struct { Error string `json:"error"` }{message}) } -func decodeBody(r *http.Request, target interface{}) error { - // interface{} is the standard JSON decoder boundary; callers use typed request structs. +func decodeBody(r *http.Request, target any) error { + // any (the interface{} alias) is the standard JSON decoder boundary; callers use typed request structs. decoder := json.NewDecoder(io.LimitReader(r.Body, (2<<20)+1)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); nil != err { - slog.Warn(fmt.Sprintf("Could not decode %s %s from peer %s: %v", r.Method, r.URL.Path, r.RemoteAddr, err), "peer", r.RemoteAddr, "method", r.Method, "resource", r.URL.Path, "error", err) + slog.Warn( + fmt.Sprintf("Could not decode %s %s from peer %s: %v", r.Method, r.URL.Path, r.RemoteAddr, err), + "peer", + r.RemoteAddr, + "method", + r.Method, + "resource", + r.URL.Path, + "error", + err, + ) return fault(400, "Invalid JSON request.") } var extra json.RawMessage @@ -178,7 +200,10 @@ func (a *agent) ServeHTTP(w http.ResponseWriter, r *http.Request) { respond(w, 200, a.health()) return } - if supplied := r.Header.Values(protocol.Header); 0 != len(supplied) && (1 != len(supplied) || strconv.Itoa(protocol.AgentAPI) != supplied[0]) { + if supplied := r.Header.Values( + protocol.Header, + ); 0 != len(supplied) && + (1 != len(supplied) || strconv.Itoa(protocol.AgentAPI) != supplied[0]) { respondError(w, r, fault(http.StatusUpgradeRequired, "Node version is incompatible. Update the node and server.")) return } @@ -282,15 +307,13 @@ func (a *agent) listStacks(ctx context.Context, w http.ResponseWriter) error { close(jobs) var workers sync.WaitGroup for i := 0; 4 > i && len(names) > i; i++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { for index := range jobs { if state, err := a.runtimeStatus(ctx, names[index]); nil == err { stacks[index].Status = state.Status } } - }() + }) } workers.Wait() respond(w, 200, struct { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b519415..64940be 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -164,7 +164,9 @@ func TestHealth(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &health); nil != err { t.Fatal(err) } - if 200 != w.Code || "ok" != health.Status || version != health.Version || runtime.GOOS != health.OS || runtime.GOARCH != health.Arch || !a.startedAt.Equal(health.StartedAt) || a.config.Global.ImageCheckInterval != health.ImageCheckInterval { + if 200 != w.Code || "ok" != health.Status || version != health.Version || runtime.GOOS != health.OS || runtime.GOARCH != health.Arch || + !a.startedAt.Equal(health.StartedAt) || + a.config.Global.ImageCheckInterval != health.ImageCheckInterval { t.Fatalf("health: %s", w.Body) } if protocol.AgentAPI != health.APIVersion || strconv.Itoa(protocol.AgentAPI) != w.Header().Get(protocol.Header) { @@ -217,7 +219,8 @@ func TestRouteAuth(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); nil != err { t.Fatal(err) } - if "" == body.Error || strings.Contains(body.Error, "192.0.2.1") || strings.Contains(body.Error, "127.0.0.1") || strings.Contains(body.Error, "test-secret") { + if "" == body.Error || strings.Contains(body.Error, "192.0.2.1") || strings.Contains(body.Error, "127.0.0.1") || + strings.Contains(body.Error, "test-secret") { t.Fatalf("forbidden response exposed access rules: %s", w.Body) } } @@ -362,7 +365,8 @@ func TestSave(t *testing.T) { } } argv, _ := os.ReadFile(filepath.Join(fake, "argv")) - if !strings.Contains(string(argv), "<--project-directory><"+dir+">") || !strings.Contains(string(argv), "<--quiet><--no-env-resolution><--services>") { + if !strings.Contains(string(argv), "<--project-directory><"+dir+">") || + !strings.Contains(string(argv), "<--quiet><--no-env-resolution><--services>") { t.Fatalf("wrong validation argv: %s", argv) } cwd, _ := os.ReadFile(filepath.Join(fake, "cwd")) @@ -373,7 +377,11 @@ func TestSave(t *testing.T) { var response struct { Error string `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 400 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), "invalid compose") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 400 != w.Code || "" == response.Error || + strings.Contains(w.Body.String(), "invalid compose") { t.Fatalf("invalid save: %d %s", w.Code, w.Body) } data, _ = os.ReadFile(filepath.Join(dir, "docker-compose.yml")) @@ -508,7 +516,8 @@ func TestUpdateImages(t *testing.T) { } op := completed(t, a, request(a, "POST", "/stacks/demo/actions", `{"action":"update"}`)) argv, _ := os.ReadFile(filepath.Join(fake, "argv")) - if test.failed != ("failed" == op.Status) || !strings.Contains(string(argv), "") || test.start != strings.Contains(string(argv), "<-d>") { + if test.failed != ("failed" == op.Status) || !strings.Contains(string(argv), "") || + test.start != strings.Contains(string(argv), "<-d>") { t.Fatalf("update: %s %+v", argv, op) } if !test.failed && ("succeeded" != op.Status || nil == op.ExitCode || 0 != *op.ExitCode) { diff --git a/internal/agent/auth.go b/internal/agent/auth.go index 6be9485..0f1982f 100644 --- a/internal/agent/auth.go +++ b/internal/agent/auth.go @@ -50,7 +50,7 @@ func sourceHostname(host string) bool { if 0 == len(host) || 253 < len(host) { return false } - for _, label := range strings.Split(host, ".") { + for label := range strings.SplitSeq(host, ".") { if 0 == len(label) || 63 < len(label) || '-' == label[0] || '-' == label[len(label)-1] { return false } @@ -75,7 +75,15 @@ func (c *hostCache) addresses(ctx context.Context, host string) []netip.Addr { addresses, err := c.lookup(lookupCtx, "ip", host) if nil != err { addresses = nil - slog.Warn(fmt.Sprintf("Could not resolve authentication hostname %q: %v. Retrying in %s.", host, err, sourceDNSCacheTTL.String()), "host", host, "error", err, "retry_in", sourceDNSCacheTTL) + slog.Warn( + fmt.Sprintf("Could not resolve authentication hostname %q: %v. Retrying in %s.", host, err, sourceDNSCacheTTL.String()), + "host", + host, + "error", + err, + "retry_in", + sourceDNSCacheTTL, + ) } c.entries[host] = hostAddresses{addresses: addresses, expires: time.Now().Add(sourceDNSCacheTTL)} return addresses @@ -128,12 +136,32 @@ func (a *agent) sourceIP(r *http.Request) (netip.Addr, error) { func (a *agent) authorized(w http.ResponseWriter, r *http.Request) bool { ip, err := a.sourceIP(r) if nil != err { - slog.Warn(fmt.Sprintf("Rejected %s %s from peer %s: cannot determine the connection source: %v", r.Method, r.URL.Path, r.RemoteAddr, err), "peer", r.RemoteAddr, "error", err) + slog.Warn( + fmt.Sprintf("Rejected %s %s from peer %s: cannot determine the connection source: %v", r.Method, r.URL.Path, r.RemoteAddr, err), + "peer", + r.RemoteAddr, + "error", + err, + ) respondError(w, r, fault(403, "Cannot determine the connection source. Check trusted proxy and forwarding settings.")) return false } if !a.matchesSource(ip, a.config.allowed, a.config.allowedHosts) { - slog.Warn(fmt.Sprintf("Rejected %s %s from source %s via peer %s: connection source is not allowed by auth.allowed_ips.", r.Method, r.URL.Path, ip, r.RemoteAddr), "peer", r.RemoteAddr, "source", ip, "allowed_ips", a.config.Auth.AllowedIPs) + slog.Warn( + fmt.Sprintf( + "Rejected %s %s from source %s via peer %s: connection source is not allowed by auth.allowed_ips.", + r.Method, + r.URL.Path, + ip, + r.RemoteAddr, + ), + "peer", + r.RemoteAddr, + "source", + ip, + "allowed_ips", + a.config.Auth.AllowedIPs, + ) respondError(w, r, fault(403, "Connection source is not allowed by auth.allowed_ips.")) return false } @@ -146,7 +174,19 @@ func (a *agent) authorized(w http.ResponseWriter, r *http.Request) bool { hash := sha256.Sum256([]byte(token)) if 1 != subtle.ConstantTimeCompare(hash[:], a.config.token[:]) || "" == token { w.Header().Set("WWW-Authenticate", "Bearer") - slog.Warn(fmt.Sprintf("Rejected %s %s from source %s via peer %s: missing or invalid bearer token.", r.Method, r.URL.Path, ip, r.RemoteAddr), "peer", r.RemoteAddr, "source", ip) + slog.Warn( + fmt.Sprintf( + "Rejected %s %s from source %s via peer %s: missing or invalid bearer token.", + r.Method, + r.URL.Path, + ip, + r.RemoteAddr, + ), + "peer", + r.RemoteAddr, + "source", + ip, + ) respondError(w, r, fault(401, "invalid bearer token")) return false } diff --git a/internal/agent/auth_test.go b/internal/agent/auth_test.go index 4cfeee4..0ca262e 100644 --- a/internal/agent/auth_test.go +++ b/internal/agent/auth_test.go @@ -27,16 +27,23 @@ func TestHostAuth(t *testing.T) { if nil != err { t.Fatal(err) } - key := struct{}{} + type contextKey struct{} + key := contextKey{} a := newAgent(context.WithValue(context.Background(), key, "agent"), c) calls := 0 a.dns.lookup = func(ctx context.Context, network, host string) ([]netip.Addr, error) { calls++ deadline, bounded := ctx.Deadline() - if "ip" != network || "dash_service.invalid" != host || "agent" != ctx.Value(key) || nil != ctx.Err() || !bounded || 0 >= time.Until(deadline) || 5*time.Second < time.Until(deadline) { + if "ip" != network || "dash_service.invalid" != host || "agent" != ctx.Value(key) || nil != ctx.Err() || !bounded || + 0 >= time.Until(deadline) || + 5*time.Second < time.Until(deadline) { t.Fatalf("lookup did not use bounded agent context: %s %s %v", network, host, ctx.Err()) } - return []netip.Addr{netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("2001:db8::10"), netip.MustParseAddr("::ffff:192.0.2.11")}, nil + return []netip.Addr{ + netip.MustParseAddr("192.0.2.10"), + netip.MustParseAddr("2001:db8::10"), + netip.MustParseAddr("::ffff:192.0.2.11"), + }, nil } ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -153,7 +160,9 @@ func TestHostNegativeCache(t *testing.T) { } } entry := a.dns.entries["failed.invalid"] - if 1 != calls["failed.invalid"] || 1 != calls["other.invalid"] || 0 != len(entry.addresses) || entry.expires.Before(before.Add(time.Minute)) || entry.expires.After(time.Now().Add(time.Minute)) { + if 1 != calls["failed.invalid"] || 1 != calls["other.invalid"] || 0 != len(entry.addresses) || + entry.expires.Before(before.Add(time.Minute)) || + entry.expires.After(time.Now().Add(time.Minute)) { t.Fatalf("negative cache: calls=%v entry=%+v", calls, entry) } recovered = true @@ -168,7 +177,9 @@ func TestHostNegativeCache(t *testing.T) { } func TestTrustedProxy(t *testing.T) { - c, err := loadConfig(configFixture(t, "", "allowed_ips = ['192.0.2.10', '2001:db8::10']\ntrusted_proxies = ['198.51.100.0/24', '2001:db8:1::/64']")) + c, err := loadConfig( + configFixture(t, "", "allowed_ips = ['192.0.2.10', '2001:db8::10']\ntrusted_proxies = ['198.51.100.0/24', '2001:db8:1::/64']"), + ) if nil != err { t.Fatal(err) } diff --git a/internal/agent/auto_update_logging_test.go b/internal/agent/auto_update_logging_test.go index afe294a..37c9acd 100644 --- a/internal/agent/auto_update_logging_test.go +++ b/internal/agent/auto_update_logging_test.go @@ -58,7 +58,7 @@ func captureAutomaticLogs(t *testing.T) *automaticLogBuffer { func (b *automaticLogBuffer) records(t *testing.T) []automaticLogRecord { t.Helper() records := []automaticLogRecord{} - for _, line := range strings.Split(strings.TrimSpace(b.text()), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(b.text()), "\n") { if "" == line { continue } @@ -109,7 +109,10 @@ func TestAutoUpdateLogs(t *testing.T) { if failed { level, status, code = "ERROR", "failed", 7 } - if "INFO" != records[0].Level || "running" != records[0].Status || nil != records[0].ExitCode || level != records[1].Level || status != records[1].Status || nil == records[1].ExitCode || code != *records[1].ExitCode { + if "INFO" != records[0].Level || "running" != records[0].Status || nil != records[0].ExitCode || level != records[1].Level || + status != records[1].Status || + nil == records[1].ExitCode || + code != *records[1].ExitCode { t.Fatalf("lifecycle status: %+v", records) } if failed && !strings.Contains(records[1].Message, fmt.Sprint(code)) { @@ -159,7 +162,10 @@ func TestAutoUpdateStartLogs(t *testing.T) { t.Fatalf("startup records: %+v", records) } record := records[0] - if test.level != record.Level || test.code != record.HTTPCode || "rejected" != record.Status || "demo" != record.Stack || target != record.Service || "" == record.Reason || test.allocated != ("" != record.Operation) { + if test.level != record.Level || test.code != record.HTTPCode || "rejected" != record.Status || "demo" != record.Stack || + target != record.Service || + "" == record.Reason || + test.allocated != ("" != record.Operation) { t.Fatalf("startup fields: %+v", record) } for _, detail := range []string{record.Stack, record.Service, record.Reason, fmt.Sprint(test.code)} { @@ -218,7 +224,9 @@ func TestAutoDetachedLogs(t *testing.T) { if launchFailed { status, level, code = "failed", "ERROR", 1 } - if 2 != len(records) || "running" != records[0].Status || status != records[1].Status || level != records[1].Level || nil == records[1].ExitCode || code != *records[1].ExitCode { + if 2 != len(records) || "running" != records[0].Status || status != records[1].Status || level != records[1].Level || + nil == records[1].ExitCode || + code != *records[1].ExitCode { t.Fatalf("detached lifecycle: %+v", records) } for _, record := range records { @@ -243,7 +251,14 @@ func TestAutoDetachedRestoreLogs(t *testing.T) { t.Run(fmt.Sprintf("%t/%d", ready, code), func(t *testing.T) { logs := captureAutomaticLogs(t) a, driver, _ := detachedFixture(t) - op := &operation{operationSnapshot: operationSnapshot{ID: "0123456789abcdef", Stack: "self", Action: "update", Status: "running"}, automatic: true, service: "web"} + op := &operation{ + ID: "0123456789abcdef", + Stack: "self", + Action: "update", + Status: "running", + automatic: true, + service: "web", + } if err := a.prepareDetached(op, engine.Stack{Name: "self", Dir: "/stacks/self"}, "web"); nil != err { t.Fatal(err) } @@ -270,7 +285,12 @@ func TestAutoDetachedRestoreLogs(t *testing.T) { if 0 != code { status, level = "failed", "ERROR" } - if 1 != len(records) || status != records[0].Status || level != records[0].Level || "self" != records[0].Stack || "web" != records[0].Service || op.ID != records[0].Operation || nil == records[0].ExitCode || code != *records[0].ExitCode || status != restored.snapshot().Status { + if 1 != len(records) || status != records[0].Status || level != records[0].Level || "self" != records[0].Stack || + "web" != records[0].Service || + op.ID != records[0].Operation || + nil == records[0].ExitCode || + code != *records[0].ExitCode || + status != restored.snapshot().Status { t.Fatalf("restored lifecycle: %+v", records) } // Collected history is not a fresh automatic completion on restart. diff --git a/internal/agent/backup_test.go b/internal/agent/backup_test.go index 1538940..78d27d1 100644 --- a/internal/agent/backup_test.go +++ b/internal/agent/backup_test.go @@ -58,7 +58,11 @@ func TestBackupReadFailure(t *testing.T) { } w := request(a, "GET", "/backup", "") var backup protocol.NodeBackup - if err := json.Unmarshal(w.Body.Bytes(), &backup); nil != err || 400 != w.Code || "" == backup.Error || 0 != len(backup.Files) || strings.Contains(w.Body.String(), dir) { + if err := json.Unmarshal( + w.Body.Bytes(), + &backup, + ); nil != err || 400 != w.Code || "" == backup.Error || 0 != len(backup.Files) || + strings.Contains(w.Body.String(), dir) { t.Fatal(w.Code, w.Body.String()) } } diff --git a/internal/agent/cert.go b/internal/agent/cert.go index 5f20681..d4a433b 100644 --- a/internal/agent/cert.go +++ b/internal/agent/cert.go @@ -55,7 +55,11 @@ func generateCert(dir string) (string, error) { if err := writeNewFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); nil != err { return "", err } - if err := writeNewFile(filepath.Join(dir, "node.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); nil != err { + if err := writeNewFile( + filepath.Join(dir, "node.crt"), + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + 0644, + ); nil != err { _ = os.Remove(keyPath) return "", err } diff --git a/internal/agent/channel.go b/internal/agent/channel.go index 2958bb1..122379c 100644 --- a/internal/agent/channel.go +++ b/internal/agent/channel.go @@ -27,9 +27,21 @@ func (a *agent) channel(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if nil != err { if context.Canceled == r.Context().Err() { - slog.Debug(fmt.Sprintf("DCompose connection setup from %s was canceled: %v", r.RemoteAddr, err), "peer", r.RemoteAddr, "error", err) + slog.Debug( + fmt.Sprintf("DCompose connection setup from %s was canceled: %v", r.RemoteAddr, err), + "peer", + r.RemoteAddr, + "error", + err, + ) } else { - slog.Error(fmt.Sprintf("Could not establish an authenticated DCompose connection from %s: %v", r.RemoteAddr, err), "peer", r.RemoteAddr, "error", err) + slog.Error( + fmt.Sprintf("Could not establish an authenticated DCompose connection from %s: %v", r.RemoteAddr, err), + "peer", + r.RemoteAddr, + "error", + err, + ) } return } @@ -43,7 +55,13 @@ func (a *agent) channel(w http.ResponseWriter, r *http.Request) { _ = conn.CloseNow() workers.Wait() }() - slog.Debug(fmt.Sprintf("DCompose peer %s connected and authenticated to node %q.", r.RemoteAddr, a.config.Global.NodeID), "peer", r.RemoteAddr, "node", a.config.Global.NodeID) + slog.Debug( + fmt.Sprintf("DCompose peer %s connected and authenticated to node %q.", r.RemoteAddr, a.config.Global.NodeID), + "peer", + r.RemoteAddr, + "node", + a.config.Global.NodeID, + ) outgoing := make(chan protocol.ChannelMessage, 64) commands := make(chan protocol.ChannelMessage, 64) @@ -64,7 +82,13 @@ func (a *agent) channel(w http.ResponseWriter, r *http.Request) { case <-ctx.Done(): return default: - slog.Warn(fmt.Sprintf("Closing DCompose connection from %s because its %d-command buffer is full.", r.RemoteAddr, cap(commands)), "peer", r.RemoteAddr, "capacity", cap(commands)) + slog.Warn( + fmt.Sprintf("Closing DCompose connection from %s because its %d-command buffer is full.", r.RemoteAddr, cap(commands)), + "peer", + r.RemoteAddr, + "capacity", + cap(commands), + ) return } } @@ -98,7 +122,17 @@ func (a *agent) channel(w http.ResponseWriter, r *http.Request) { case <-ctx.Done(): return case <-subscriber.done: - slog.Warn(fmt.Sprintf("Closing DCompose connection from %s because its %d-status-update buffer is full.", r.RemoteAddr, cap(subscriber.events)), "peer", r.RemoteAddr, "capacity", cap(subscriber.events)) + slog.Warn( + fmt.Sprintf( + "Closing DCompose connection from %s because its %d-status-update buffer is full.", + r.RemoteAddr, + cap(subscriber.events), + ), + "peer", + r.RemoteAddr, + "capacity", + cap(subscriber.events), + ) return case e := <-subscriber.events: if !write(protocol.ChannelMessage{Type: "event", Event: e.name, Payload: e.data}) { @@ -133,21 +167,53 @@ func (a *agent) channelError(ctx context.Context, peer, operation string, err er } status := websocket.CloseStatus(err) if websocket.StatusNormalClosure == status || websocket.StatusGoingAway == status { - slog.Debug(fmt.Sprintf("DCompose peer %s disconnected from node %q: WebSocket closed with code %d (%s).", peer, a.config.Global.NodeID, status, status.String()), "peer", peer, "node", a.config.Global.NodeID) + slog.Debug( + fmt.Sprintf( + "DCompose peer %s disconnected from node %q: WebSocket closed with code %d (%s).", + peer, + a.config.Global.NodeID, + status, + status.String(), + ), + "peer", + peer, + "node", + a.config.Global.NodeID, + ) return } if errors.Is(err, io.EOF) { - slog.Info(fmt.Sprintf("DCompose peer %s disconnected from node %q without a WebSocket close handshake.", peer, a.config.Global.NodeID), "peer", peer, "node", a.config.Global.NodeID) + slog.Info( + fmt.Sprintf("DCompose peer %s disconnected from node %q without a WebSocket close handshake.", peer, a.config.Global.NodeID), + "peer", + peer, + "node", + a.config.Global.NodeID, + ) return } detail := err.Error() if errors.Is(err, io.ErrUnexpectedEOF) { detail = "connection ended during an incomplete WebSocket message" } - slog.Warn(fmt.Sprintf("DCompose connection from %s to node %q failed while %s: %s", peer, a.config.Global.NodeID, operation, detail), "peer", peer, "node", a.config.Global.NodeID, "error", err) + slog.Warn( + fmt.Sprintf("DCompose connection from %s to node %q failed while %s: %s", peer, a.config.Global.NodeID, operation, detail), + "peer", + peer, + "node", + a.config.Global.NodeID, + "error", + err, + ) } -func (a *agent) channelCommands(ctx context.Context, peer string, commands <-chan protocol.ChannelMessage, outgoing chan<- protocol.ChannelMessage, cancel context.CancelFunc) { +func (a *agent) channelCommands( + ctx context.Context, + peer string, + commands <-chan protocol.ChannelMessage, + outgoing chan<- protocol.ChannelMessage, + cancel context.CancelFunc, +) { attached := make(map[string]context.CancelFunc) type relayDelivery struct { op *operation @@ -181,9 +247,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha relay = &relayDelivery{op: op} relays[key] = relay } - streams.Add(1) - go func() { - defer streams.Done() + streams.Go(func() { defer op.unsubscribe(subscriber) send := func(e streamEvent) bool { terminal := "operation.done" == e.name @@ -218,7 +282,26 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha case <-streamCtx.Done(): return case <-subscriber.done: - slog.Warn(fmt.Sprintf("Closing DCompose connection from %s because the %d-update buffer for %s operation %s on stack %q is full.", peer, cap(subscriber.events), op.Action, op.ID, op.Stack), "peer", peer, "operation", op.ID, "stack", op.Stack, "action", op.Action, "capacity", cap(subscriber.events)) + slog.Warn( + fmt.Sprintf( + "Closing DCompose connection from %s because the %d-update buffer for %s operation %s on stack %q is full.", + peer, + cap(subscriber.events), + op.Action, + op.ID, + op.Stack, + ), + "peer", + peer, + "operation", + op.ID, + "stack", + op.Stack, + "action", + op.Action, + "capacity", + cap(subscriber.events), + ) cancel() return case e := <-subscriber.events: @@ -227,7 +310,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha } } } - }() + }) } for { select { @@ -272,7 +355,14 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha continue } if "operation.attach" == message.Type { - send(protocol.ChannelMessage{Type: "stream.error", Stream: message.Stream, Error: "operation streams are not available", Status: 404}) + send( + protocol.ChannelMessage{ + Type: "stream.error", + Stream: message.Stream, + Error: "operation streams are not available", + Status: 404, + }, + ) continue } if "relay.attach" == message.Type || "logs.attach" == message.Type { @@ -300,9 +390,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha } streamCtx, detach := context.WithCancel(ctx) attached[key] = detach - streams.Add(1) - go func() { - defer streams.Done() + streams.Go(func() { err := a.streamLogs(streamCtx, message.Resource, func(e streamEvent) bool { select { case outgoing <- protocol.ChannelMessage{Type: "stream.event", Stream: message.Stream, Event: e.name, Payload: e.data}: @@ -313,13 +401,12 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha }) if nil != err && nil == streamCtx.Err() { code := 502 - var apiErr *apiError - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*apiError](err); ok { code = apiErr.code } send(protocol.ChannelMessage{Type: "stream.error", Stream: message.Stream, Error: err.Error(), Status: code}) } - }() + }) } continue } @@ -345,9 +432,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha attach("shell:"+message.Shell, s.op, protocol.ChannelMessage{Type: "shell.event", Shell: message.Shell}) } s.requestClose() - streams.Add(1) - go func() { - defer streams.Done() + streams.Go(func() { select { case <-s.done: case <-ctx.Done(): @@ -367,14 +452,39 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha } } if nil != cleanupErr { - slog.Error(fmt.Sprintf("Could not close shell %s in container %q of stack %q for DCompose peer %s: %v", s.op.ID, s.container, s.op.Stack, peer, cleanupErr), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "peer", peer, "error", cleanupErr) - send(protocol.ChannelMessage{Type: "shell.error", Shell: message.Shell, Error: "shell cleanup failed. Retry closing the shell when the container engine is available"}) + slog.Error( + fmt.Sprintf( + "Could not close shell %s in container %q of stack %q for DCompose peer %s: %v", + s.op.ID, + s.container, + s.op.Stack, + peer, + cleanupErr, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "peer", + peer, + "error", + cleanupErr, + ) + send( + protocol.ChannelMessage{ + Type: "shell.error", + Shell: message.Shell, + Error: "shell cleanup failed. Retry closing the shell when the container engine is available", + }, + ) } if completed { e := event("operation.done", s.op.snapshot()) send(protocol.ChannelMessage{Type: "shell.event", Shell: message.Shell, Event: e.name, Payload: e.data}) } - }() + }) default: err = fault(400, "unsupported channel message") } diff --git a/internal/agent/channel_test.go b/internal/agent/channel_test.go index 0b1e4b4..305dd6d 100644 --- a/internal/agent/channel_test.go +++ b/internal/agent/channel_test.go @@ -28,7 +28,11 @@ func openChannel(t *testing.T, server *httptest.Server) *websocket.Conn { headers := http.Header{} headers.Set("Authorization", "Bearer test-secret") headers.Set(protocol.Header, strconv.Itoa(protocol.AgentAPI)) - conn, _, err := websocket.Dial(ctx, strings.Replace(server.URL, "https://", "wss://", 1)+"/channel", &websocket.DialOptions{HTTPClient: server.Client(), HTTPHeader: headers}) + conn, _, err := websocket.Dial( + ctx, + strings.Replace(server.URL, "https://", "wss://", 1)+"/channel", + &websocket.DialOptions{HTTPClient: server.Client(), HTTPHeader: headers}, + ) if nil != err { t.Fatal(err) } @@ -82,13 +86,16 @@ func TestChannelRuntime(t *testing.T) { server := channelServer(t, a) conn := openChannel(t, server) message := channelNext(t, conn) - if "event" != message.Type || "node.snapshot" != message.Event || !strings.Contains(string(message.Payload), `"name":"demo"`) || strings.Contains(string(message.Payload), "SECRET") { + if "event" != message.Type || "node.snapshot" != message.Event || !strings.Contains(string(message.Payload), `"name":"demo"`) || + strings.Contains(string(message.Payload), "SECRET") { t.Fatalf("initial snapshot: %+v", message) } var snapshot struct { Health nodeHealth `json:"health"` } - if nil != json.Unmarshal(message.Payload, &snapshot) || "ok" != snapshot.Health.Status || protocol.AgentAPI != snapshot.Health.APIVersion || a.config.Global.ImageCheckInterval != snapshot.Health.ImageCheckInterval { + if nil != json.Unmarshal(message.Payload, &snapshot) || "ok" != snapshot.Health.Status || + protocol.AgentAPI != snapshot.Health.APIVersion || + a.config.Global.ImageCheckInterval != snapshot.Health.ImageCheckInterval { t.Fatal("invalid channel health") } message = channelUntil(t, conn, "event", "stack.runtime") @@ -106,7 +113,8 @@ func TestChannelRuntime(t *testing.T) { } conn = openChannel(t, server) message = channelNext(t, conn) - if "node.snapshot" != message.Event || !strings.Contains(string(message.Payload), `"name":"external"`) || strings.Contains(string(message.Payload), `"name":"demo"`) { + if "node.snapshot" != message.Event || !strings.Contains(string(message.Payload), `"name":"external"`) || + strings.Contains(string(message.Payload), `"name":"demo"`) { t.Fatalf("reconnect inventory: %s", message.Payload) } data, _ := os.ReadFile(filepath.Join(fake, "event-pids")) @@ -158,7 +166,8 @@ func TestChannelShell(t *testing.T) { } channelSend(t, conn, protocol.ChannelMessage{Type: "shell.close", Shell: s.op.ID}) message = channelUntil(t, conn, "shell.event", "operation.done") - if nil != json.Unmarshal(message.Payload, &snapshot) || "succeeded" != snapshot.Status || nil == snapshot.ExitCode || 0 != *snapshot.ExitCode { + if nil != json.Unmarshal(message.Payload, &snapshot) || "succeeded" != snapshot.Status || nil == snapshot.ExitCode || + 0 != *snapshot.ExitCode { t.Fatalf("close: %s", message.Payload) } } @@ -291,15 +300,20 @@ func TestChannelErrors(t *testing.T) { if err := json.Unmarshal([]byte(logs.text()), &record); nil != err { t.Fatal(err) } - if test.level != record.Level || !strings.Contains(record.Message, "127.0.0.1:42") || !strings.Contains(record.Message, a.config.Global.NodeID) { + if test.level != record.Level || !strings.Contains(record.Message, "127.0.0.1:42") || + !strings.Contains(record.Message, a.config.Global.NodeID) { t.Fatal("incorrect connection log", logs.text()) } if "WARN" == test.level { - if test.err.Error() != record.Error || (!errors.Is(test.err, io.ErrUnexpectedEOF) && !strings.Contains(record.Message, record.Error)) { + if test.err.Error() != record.Error || + (!errors.Is(test.err, io.ErrUnexpectedEOF) && !strings.Contains(record.Message, record.Error)) { t.Fatal("connection failure cause was lost", logs.text()) } } - if status := websocket.CloseStatus(test.err); "DEBUG" == test.level && !strings.Contains(record.Message, strconv.Itoa(int(status))) { + if status := websocket.CloseStatus( + test.err, + ); "DEBUG" == test.level && + !strings.Contains(record.Message, strconv.Itoa(int(status))) { t.Fatal("connection close code was lost", logs.text()) } }) @@ -363,14 +377,14 @@ func TestChannelInputIsolation(t *testing.T) { if nil != err { t.Fatal(err) } - defer master.Close() - defer slave.Close() + defer func() { _ = master.Close() }() + defer func() { _ = slave.Close() }() cmd := exec.Command("stty", "raw", "-echo") cmd.Stdin = slave if err := cmd.Run(); nil != err { t.Fatal(err) } - s := &shellSession{op: &operation{operationSnapshot: operationSnapshot{ID: "blocked", Status: "running"}}, terminal: master} + s := &shellSession{op: &operation{ID: "blocked", Status: "running"}, terminal: master} a.shells["blocked"] = s conn := openChannel(t, channelServer(t, a)) channelUntil(t, conn, "event", "node.snapshot") @@ -399,7 +413,7 @@ func TestChannelInputIsolation(t *testing.T) { func TestChannelLargeSnapshot(t *testing.T) { a, _ := fixture(t) - a.shells["large"] = &shellSession{op: &operation{operationSnapshot: operationSnapshot{ID: "large", Status: "succeeded", Output: strings.Repeat("\x00", 1<<20)}}} + a.shells["large"] = &shellSession{op: &operation{ID: "large", Status: "succeeded", Output: strings.Repeat("\x00", 1<<20)}} conn := openChannel(t, channelServer(t, a)) channelSend(t, conn, protocol.ChannelMessage{Type: "shell.attach", Shell: "large"}) message := channelUntil(t, conn, "shell.event", "operation.snapshot") @@ -441,7 +455,8 @@ func TestChannelCleanupRetry(t *testing.T) { channelSend(t, conn, protocol.ChannelMessage{Type: "shell.close", Shell: s.op.ID}) message := channelUntil(t, conn, "shell.event", "operation.done") var snapshot operationSnapshot - if nil != json.Unmarshal(message.Payload, &snapshot) || "succeeded" != snapshot.Status || nil == snapshot.ExitCode || 0 != *snapshot.ExitCode { + if nil != json.Unmarshal(message.Payload, &snapshot) || "succeeded" != snapshot.Status || nil == snapshot.ExitCode || + 0 != *snapshot.ExitCode { t.Fatalf("cleanup retry: %s", message.Payload) } channelSend(t, conn, protocol.ChannelMessage{Type: "shell.close", Shell: s.op.ID}) diff --git a/internal/agent/compose.go b/internal/agent/compose.go index 22431f3..1e1cc87 100644 --- a/internal/agent/compose.go +++ b/internal/agent/compose.go @@ -172,7 +172,7 @@ func (a *agent) saveFiles(ctx context.Context, name, compose, env string, valida return fault(409, "destination stack directory already exists.") } if created { - defer os.Remove(dir) + defer func() { _ = os.Remove(dir) }() } // Only removes an empty, rejected creation. file, err := composeFile(dir) var apiErr *apiError @@ -194,12 +194,12 @@ func (a *agent) saveFiles(ctx context.Context, name, compose, env string, valida if nil != err { return err } - defer os.Remove(composeTemp) + defer func() { _ = os.Remove(composeTemp) }() envTemp, err := tempFile(dir, ".dagent-env-*", env) if nil != err { return err } - defer os.Remove(envTemp) + defer func() { _ = os.Remove(envTemp) }() if validate { // Validation resolves interpolation from the staged env, not service env_file // entries that may refer to the not-yet-installed .env. diff --git a/internal/agent/compose_test.go b/internal/agent/compose_test.go index 635903e..1f7600c 100644 --- a/internal/agent/compose_test.go +++ b/internal/agent/compose_test.go @@ -30,7 +30,7 @@ exit `+strconv.Itoa(code)+"\n") if nil != err { t.Fatal(err) } - op := &operation{operationSnapshot: operationSnapshot{Stack: "demo", Action: "pull"}} + op := &operation{Stack: "demo", Action: "pull"} err = a.runOperation(op, dir, base, "") if code != exitCode(err) { t.Fatalf("exit: %v", err) @@ -75,7 +75,8 @@ func TestPortBindings(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &result); nil != err { t.Fatal(err) } - if 1 != len(result.Services) || test.ports != result.Services[0].Ports || !reflect.DeepEqual(test.bindings, result.Services[0].PortBindings) { + if 1 != len(result.Services) || test.ports != result.Services[0].Ports || + !reflect.DeepEqual(test.bindings, result.Services[0].PortBindings) { t.Fatalf("unexpected services: %+v", result.Services) } }) @@ -105,7 +106,22 @@ func TestComposeEnv(t *testing.T) { if nil != err { t.Fatal(err) } - cmd := exec.CommandContext(ctx, "docker", "compose", "--project-name", s.Name, "--project-directory", s.Dir, "--env-file", s.EnvFile, "--file", s.ComposeFile, "config", "--format", "json") + cmd := exec.CommandContext( + ctx, + "docker", + "compose", + "--project-name", + s.Name, + "--project-directory", + s.Dir, + "--env-file", + s.EnvFile, + "--file", + s.ComposeFile, + "config", + "--format", + "json", + ) cmd.Dir = dir cmd.Env = append(cmd.Environ(), a.engine.Action(s, "update", "").Env...) output, err := cmd.CombinedOutput() @@ -125,7 +141,8 @@ func TestComposeEnv(t *testing.T) { t.Fatal(err) } web := parsed.Services["web"] - if image != web.Image || "updated" != web.Environment["DOCKER_NODE_TEST_VALUE"] || 1 != len(web.Volumes) || filepath.Join(dir, "data") != web.Volumes[0].Source { + if image != web.Image || "updated" != web.Environment["DOCKER_NODE_TEST_VALUE"] || 1 != len(web.Volumes) || + filepath.Join(dir, "data") != web.Volumes[0].Source { t.Fatalf("wrong env/context: %+v", web) } data, _ := os.ReadFile(filepath.Join(dir, "compose.yaml")) diff --git a/internal/agent/config.go b/internal/agent/config.go index 7ebb796..e770668 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -63,7 +63,11 @@ func generateToken(path string) error { } func readConfig(path string) (config, error) { - c := config{Global: globalConfig{Engine: "docker", Listen: "127.0.0.1:9077", LogLevel: "info", ImageCheckInterval: "6h"}, Auth: authConfig{AllowedIPs: []string{"0.0.0.0/0", "::/0"}}, path: path} + c := config{ + Global: globalConfig{Engine: "docker", Listen: "127.0.0.1:9077", LogLevel: "info", ImageCheckInterval: "6h"}, + Auth: authConfig{AllowedIPs: []string{"0.0.0.0/0", "::/0"}}, + path: path, + } meta, err := toml.DecodeFile(path, &c) if nil != err { return c, err @@ -148,13 +152,13 @@ func loadConfig(path string) (config, error) { return c, nil } -func saveGlobalSetting(path, key string, value interface{}) error { +func saveGlobalSetting(path, key string, value any) error { // Preserve relative references and unknown keys instead of encoding runtime defaults. - var document map[string]interface{} + var document map[string]any if _, err := toml.DecodeFile(path, &document); nil != err { return err } - document["global"].(map[string]interface{})[key] = value + document["global"].(map[string]any)[key] = value var data bytes.Buffer if err := toml.NewEncoder(&data).Encode(document); nil != err { return err @@ -189,7 +193,10 @@ func initializeConfig(path string, c *config) error { } } if err := os.MkdirAll(c.Global.StacksDir, 0750); nil != err { - return fmt.Errorf("create stacks_dir: %w. Create /opt/stacks and give the runtime user access, or supply agent.toml with a writable global.stacks_dir", err) + return fmt.Errorf( + "create stacks_dir: %w. Create /opt/stacks and give the runtime user access, or supply agent.toml with a writable global.stacks_dir", + err, + ) } if errors.Is(tokenErr, os.ErrNotExist) { if err := generateToken(tokenPath); nil != err { diff --git a/internal/agent/config_test.go b/internal/agent/config_test.go index 6d03509..a08cd60 100644 --- a/internal/agent/config_test.go +++ b/internal/agent/config_test.go @@ -34,7 +34,17 @@ func configFixture(t *testing.T, extra string, auth string) string { token := filepath.Join(dir, "token") write(t, token, "test-secret\n") path := filepath.Join(dir, "config.toml") - write(t, path, fmt.Sprintf("[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[auth]\ntoken = %q\n%s\n[global]\nstacks_dir = %q\n%s", "file:"+token, auth, dir, extra)) + write( + t, + path, + fmt.Sprintf( + "[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[auth]\ntoken = %q\n%s\n[global]\nstacks_dir = %q\n%s", + "file:"+token, + auth, + dir, + extra, + ), + ) return path } @@ -61,7 +71,15 @@ func TestToken(t *testing.T) { if _, err := generateCert(dir); nil != err { t.Fatal(err) } - write(t, configPath, fmt.Sprintf("[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[global]\nstacks_dir = %q\n[auth]\ntoken = %q", dir, "file:"+path)) + write( + t, + configPath, + fmt.Sprintf( + "[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[global]\nstacks_dir = %q\n[auth]\ntoken = %q", + dir, + "file:"+path, + ), + ) c, err := loadConfig(configPath) if nil != err || sha256.Sum256([]byte(secret)) != c.token { t.Fatalf("generated token cannot be loaded: %v", err) @@ -88,7 +106,10 @@ func TestConfig(t *testing.T) { if nil != err { t.Fatal(err) } - if "docker" != c.Global.Engine || "127.0.0.1:9077" != c.Global.Listen || "info" != c.Global.LogLevel || "6h" != c.Global.ImageCheckInterval || 2 != len(c.allowed) || sha256.Sum256([]byte("test-secret")) != c.token { + if "docker" != c.Global.Engine || "127.0.0.1:9077" != c.Global.Listen || "info" != c.Global.LogLevel || + "6h" != c.Global.ImageCheckInterval || + 2 != len(c.allowed) || + sha256.Sum256([]byte("test-secret")) != c.token { t.Fatalf("unexpected config: %+v", c) } c, err = loadConfig(configFixture(t, "log_level = 'warn'", "allowed_ips = []")) @@ -146,11 +167,21 @@ func TestConfigSources(t *testing.T) { return nil, context.Canceled }} t.Cleanup(func() { net.DefaultResolver = resolver }) - c, err := loadConfig(configFixture(t, "", "allowed_ips = ['192.0.2.3', 'dash_service.invalid', 'node.invalid.']\ntrusted_proxies = ['198.51.100.0/24', 'proxy_service.invalid']")) + c, err := loadConfig( + configFixture( + t, + "", + "allowed_ips = ['192.0.2.3', 'dash_service.invalid', 'node.invalid.']\ntrusted_proxies = ['198.51.100.0/24', 'proxy_service.invalid']", + ), + ) if nil != err { t.Fatal(err) } - if !slices.Equal(c.allowed, []netip.Prefix{netip.MustParsePrefix("192.0.2.3/32")}) || !slices.Equal(c.allowedHosts, []string{"dash_service.invalid", "node.invalid."}) || !slices.Equal(c.trusted, []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}) || !slices.Equal(c.trustedHosts, []string{"proxy_service.invalid"}) || 2 != len(c.Auth.TrustedProxies) { + if !slices.Equal(c.allowed, []netip.Prefix{netip.MustParsePrefix("192.0.2.3/32")}) || + !slices.Equal(c.allowedHosts, []string{"dash_service.invalid", "node.invalid."}) || + !slices.Equal(c.trusted, []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}) || + !slices.Equal(c.trustedHosts, []string{"proxy_service.invalid"}) || + 2 != len(c.Auth.TrustedProxies) { t.Fatalf("source entries were not separated: %+v", c) } if a := newAgent(context.Background(), c); 0 != len(a.dns.entries) { @@ -163,7 +194,9 @@ func TestConfigSources(t *testing.T) { func TestSourceEntries(t *testing.T) { prefixes, hosts, err := parseSourceEntries([]string{"192.0.2.3", "2001:db8::/64", "dash_service", "Node.Example."}) - if nil != err || !slices.Equal(prefixes, []netip.Prefix{netip.MustParsePrefix("192.0.2.3/32"), netip.MustParsePrefix("2001:db8::/64")}) || !slices.Equal(hosts, []string{"dash_service", "node.example."}) { + if nil != err || + !slices.Equal(prefixes, []netip.Prefix{netip.MustParsePrefix("192.0.2.3/32"), netip.MustParsePrefix("2001:db8::/64")}) || + !slices.Equal(hosts, []string{"dash_service", "node.example."}) { t.Fatalf("valid source entries: %v %v %v", prefixes, hosts, err) } for _, entry := range []string{"", "https://node.example", "node.example:80", "*.example", "bad..example", "-bad.example", "bad-.example", strings.Repeat("a", 64) + ".example"} { @@ -193,7 +226,15 @@ func TestConfigToken(t *testing.T) { path := configFixture(t, "", "") dir := filepath.Dir(path) write(t, filepath.Join(dir, "token"), test.content) - write(t, path, fmt.Sprintf("[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[global]\nstacks_dir = %q\n[auth]\ntoken = %q", dir, test.value)) + write( + t, + path, + fmt.Sprintf( + "[tls]\ncert_file = 'node.crt'\nkey_file = 'node.key'\n[global]\nstacks_dir = %q\n[auth]\ntoken = %q", + dir, + test.value, + ), + ) t.Chdir(t.TempDir()) c, err := loadConfig(path) if !test.valid { @@ -243,7 +284,8 @@ func TestConfigImageInterval(t *testing.T) { if nil != err { t.Fatal(err) } - if metadata.IsDefined("global", "image_check_config_file") || !metadata.IsDefined("global", "image_check_interval") || "30m" != stored.Global.ImageCheckInterval { + if metadata.IsDefined("global", "image_check_config_file") || !metadata.IsDefined("global", "image_check_interval") || + "30m" != stored.Global.ImageCheckInterval { t.Fatal("obsolete credential option remains or image scheduling option was removed") } } diff --git a/internal/agent/create_test.go b/internal/agent/create_test.go index d2449b4..3c26564 100644 --- a/internal/agent/create_test.go +++ b/internal/agent/create_test.go @@ -25,7 +25,8 @@ func TestCreate(t *testing.T) { var result struct { Name string `json:"name"` } - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || "copy" != result.Name || strconv.Itoa(protocol.AgentAPI) != w.Header().Get(protocol.Header) { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || "copy" != result.Name || + strconv.Itoa(protocol.AgentAPI) != w.Header().Get(protocol.Header) { t.Fatalf("create: %d %s", w.Code, w.Body) } dir := filepath.Join(a.config.Global.StacksDir, "copy") @@ -50,7 +51,8 @@ func TestCreate(t *testing.T) { } w = request(a, http.MethodGet, "/stacks/copy", "") var saved stack - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &saved) || compose != saved.Compose || env != saved.Env || "draft" != saved.Status { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &saved) || compose != saved.Compose || env != saved.Env || + "draft" != saved.Status { t.Fatalf("copy not readable: %d %s", w.Code, w.Body) } } @@ -91,7 +93,8 @@ func TestCreateConflict(t *testing.T) { var response struct { Error string `json:"error"` } - if 409 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &response) || "" == response.Error || strings.Contains(w.Body.String(), "secret") { + if 409 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &response) || "" == response.Error || + strings.Contains(w.Body.String(), "secret") { t.Fatalf("conflict: %d %s", w.Code, w.Body) } for name, want := range files { diff --git a/internal/agent/detached.go b/internal/agent/detached.go index 76fdb69..9ec37c8 100644 --- a/internal/agent/detached.go +++ b/internal/agent/detached.go @@ -34,7 +34,11 @@ func (a *agent) detachedOperation(name, action string) (bool, error) { return false, nil } if nil != a.selfErr { - return false, &engine.Error{Code: 503, Message: "Could not identify the node container. Check the DCOMPOSE_AGENT=true label or run this action from the host.", Cause: a.selfErr} + return false, &engine.Error{ + Code: 503, + Message: "Could not identify the node container. Check the DCOMPOSE_AGENT=true label or run this action from the host.", + Cause: a.selfErr, + } } return nil != a.selfContainer && name == a.selfContainer.Project, nil } @@ -47,7 +51,11 @@ func (a *agent) initDetached(cacheDir string) { a.detachedExecutor = executor a.selfContainer, a.selfErr = executor.SelfContainer(a.ctx) if nil != a.selfErr { - slog.Warn(fmt.Sprintf("Could not identify this node's container: %v. Lifecycle actions are disabled.", a.selfErr), "error", a.selfErr) + slog.Warn( + fmt.Sprintf("Could not identify this node's container: %v. Lifecycle actions are disabled.", a.selfErr), + "error", + a.selfErr, + ) } if nil != a.selfContainer { self := *a.selfContainer @@ -73,25 +81,58 @@ func (a *agent) initDetached(cacheDir string) { } requestFile := filepath.Join(a.operationsDir, entry.Name(), "request.json") if !entry.IsDir() { - slog.Warn(fmt.Sprintf("Ignoring detached operation entry %q because it is not a directory.", filepath.Join(a.operationsDir, entry.Name())), "path", filepath.Join(a.operationsDir, entry.Name())) + slog.Warn( + fmt.Sprintf( + "Ignoring detached operation entry %q because it is not a directory.", + filepath.Join(a.operationsDir, entry.Name()), + ), + "path", + filepath.Join(a.operationsDir, entry.Name()), + ) continue } request, err := readDetachedRequest(requestFile) if nil != err { - slog.Warn(fmt.Sprintf("Could not restore detached operation from request %q: %v", requestFile, err), "path", requestFile, "error", err) + slog.Warn( + fmt.Sprintf("Could not restore detached operation from request %q: %v", requestFile, err), + "path", + requestFile, + "error", + err, + ) continue } requests = append(requests, request) } sort.Slice(requests, func(i, j int) bool { return requests[i].CreatedAt.Before(requests[j].CreatedAt) }) for _, request := range requests { - op := &operation{operationSnapshot: operationSnapshot{ID: request.ID, Stack: request.Stack.Name, Action: request.Action, Status: "running"}, automatic: request.Automatic, service: request.Service} + op := &operation{ + ID: request.ID, + Stack: request.Stack.Name, + Action: request.Action, + Status: "running", + automatic: request.Automatic, + service: request.Service, + } result, err := readDetachedResult(filepath.Join(a.operationsDir, op.ID, "result.json"), op) if nil == err { _, _ = op.Write([]byte(result.Output)) op.Status, op.ExitCode = result.Status, result.ExitCode } else if !os.IsNotExist(err) { - slog.Warn(fmt.Sprintf("Could not restore the result of detached %s operation %s on stack %q from %q: %v", op.Action, op.ID, op.Stack, filepath.Join(a.operationsDir, op.ID, "result.json"), err), "operation", op.ID, "error", err) + slog.Warn( + fmt.Sprintf( + "Could not restore the result of detached %s operation %s on stack %q from %q: %v", + op.Action, + op.ID, + op.Stack, + filepath.Join(a.operationsDir, op.ID, "result.json"), + err, + ), + "operation", + op.ID, + "error", + err, + ) } a.ops[op.ID] = op a.order = append(a.order, op.ID) @@ -109,11 +150,9 @@ func (a *agent) initDetached(cacheDir string) { } a.logOperationResult(op) } - a.work.Add(1) - go func() { - defer a.work.Done() + a.work.Go(func() { a.observeDetached(op) - }() + }) } } @@ -127,7 +166,8 @@ func readDetachedRequest(path string) (detachedRequest, error) { return request, err } id, err := hex.DecodeString(request.ID) - if nil != err || 8 != len(id) || request.ID != hex.EncodeToString(id) || filepath.Base(filepath.Dir(path)) != request.ID || !stackName.MatchString(request.Stack.Name) { + if nil != err || 8 != len(id) || request.ID != hex.EncodeToString(id) || filepath.Base(filepath.Dir(path)) != request.ID || + !stackName.MatchString(request.Stack.Name) { return request, fmt.Errorf("invalid detached operation ID or stack name") } if "" != request.Engine && "docker" != request.Engine && "podman" != request.Engine { @@ -143,7 +183,11 @@ func readDetachedRequest(path string) (detachedRequest, error) { func (a *agent) prepareDetached(op *operation, base engine.Stack, service string) error { if "" == a.operationsDir { - return &engine.Error{Code: 503, Message: "Actions on this node's own stack require a writable persistent cache directory.", Cause: errors.New("node cache directory is unavailable")} + return &engine.Error{ + Code: 503, + Message: "Actions on this node's own stack require a writable persistent cache directory.", + Cause: errors.New("node cache directory is unavailable"), + } } directory := filepath.Join(a.operationsDir, op.ID) if err := os.MkdirAll(a.operationsDir, 0700); nil != err { @@ -153,7 +197,18 @@ func (a *agent) prepareDetached(op *operation, base engine.Stack, service string return &engine.Error{Code: 503, Message: "Could not prepare the action on this node's own stack.", Cause: err} } configDir := filepath.Dir(a.registries.root) - data, err := json.Marshal(detachedRequest{ID: op.ID, Engine: a.config.Global.Engine, Stack: base, Action: op.Action, Service: service, Automatic: op.automatic, ConfigDir: configDir, CreatedAt: time.Now().UTC()}) + data, err := json.Marshal( + detachedRequest{ + ID: op.ID, + Engine: a.config.Global.Engine, + Stack: base, + Action: op.Action, + Service: service, + Automatic: op.automatic, + ConfigDir: configDir, + CreatedAt: time.Now().UTC(), + }, + ) if nil == err { err = configutil.Write(filepath.Join(directory, "request.json"), data) } @@ -186,7 +241,10 @@ func readDetachedResult(path string, op *operation) (operationSnapshot, error) { if err := json.Unmarshal(data, &result); nil != err { return result, err } - if result.ID != op.ID || result.Stack != op.Stack || result.Action != op.Action || nil == result.ExitCode || 1<<20 < len(result.Output) || ("succeeded" != result.Status && "failed" != result.Status) || (0 == *result.ExitCode) != ("succeeded" == result.Status) { + if result.ID != op.ID || result.Stack != op.Stack || result.Action != op.Action || nil == result.ExitCode || + 1<<20 < len(result.Output) || + ("succeeded" != result.Status && "failed" != result.Status) || + (0 == *result.ExitCode) != ("succeeded" == result.Status) { return result, fmt.Errorf("invalid detached operation result") } return result, nil @@ -221,7 +279,20 @@ func (a *agent) observeDetached(op *operation) { terminal = true } else { if !os.IsNotExist(err) && lastError != err.Error() { - slog.Warn(fmt.Sprintf("Could not read the result of detached %s operation %s on stack %q from %q: %v", op.Action, op.ID, op.Stack, filepath.Join(directory, "result.json"), err), "operation", op.ID, "error", err) + slog.Warn( + fmt.Sprintf( + "Could not read the result of detached %s operation %s on stack %q from %q: %v", + op.Action, + op.ID, + op.Stack, + filepath.Join(directory, "result.json"), + err, + ), + "operation", + op.ID, + "error", + err, + ) lastError = err.Error() } file, err := os.Open(filepath.Join(directory, "output.log")) @@ -236,7 +307,20 @@ func (a *agent) observeDetached(op *operation) { } } if nil != err && !os.IsNotExist(err) && lastError != err.Error() { - slog.Warn(fmt.Sprintf("Could not read output for detached %s operation %s on stack %q from %q: %v", op.Action, op.ID, op.Stack, filepath.Join(directory, "output.log"), err), "operation", op.ID, "error", err) + slog.Warn( + fmt.Sprintf( + "Could not read output for detached %s operation %s on stack %q from %q: %v", + op.Action, + op.ID, + op.Stack, + filepath.Join(directory, "output.log"), + err, + ), + "operation", + op.ID, + "error", + err, + ) lastError = err.Error() } } @@ -247,7 +331,20 @@ func (a *agent) observeDetached(op *operation) { } if terminal && nil != err { // Completed helpers may already have been removed by the old node. - slog.Debug(fmt.Sprintf("Could not inspect completed helper %q for detached %s operation %s on stack %q during cleanup: %v", helperName, op.Action, op.ID, op.Stack, err), "operation", op.ID, "error", err) + slog.Debug( + fmt.Sprintf( + "Could not inspect completed helper %q for detached %s operation %s on stack %q during cleanup: %v", + helperName, + op.Action, + op.ID, + op.Stack, + err, + ), + "operation", + op.ID, + "error", + err, + ) return } if nil == err { @@ -255,7 +352,20 @@ func (a *agent) observeDetached(op *operation) { } if nil != err && (state.Running || (0 == state.ExitCode && "" == state.Output)) { if inspectError != err.Error() { - slog.Warn(fmt.Sprintf("Could not inspect helper %q for detached %s operation %s on stack %q: %v. Waiting for its result.", helperName, op.Action, op.ID, op.Stack, err), "operation", op.ID, "error", err) + slog.Warn( + fmt.Sprintf( + "Could not inspect helper %q for detached %s operation %s on stack %q: %v. Waiting for its result.", + helperName, + op.Action, + op.ID, + op.Stack, + err, + ), + "operation", + op.ID, + "error", + err, + ) inspectError = err.Error() } } else if !state.Running { @@ -280,7 +390,20 @@ func (a *agent) observeDetached(op *operation) { // Never remove the only diagnostics unless a terminal result is durable. if _, err := readDetachedResult(filepath.Join(directory, "result.json"), op); nil == err { if err := a.detachedExecutor.RemoveDetached(a.ctx, helperName); nil != err && nil == a.ctx.Err() { - slog.Warn(fmt.Sprintf("Could not remove helper container %q for detached %s operation %s on stack %q: %v", helperName, op.Action, op.ID, op.Stack, err), "operation", op.ID, "error", err) + slog.Warn( + fmt.Sprintf( + "Could not remove helper container %q for detached %s operation %s on stack %q: %v", + helperName, + op.Action, + op.ID, + op.Stack, + err, + ), + "operation", + op.ID, + "error", + err, + ) } } return @@ -301,7 +424,15 @@ func (a *agent) collectRelay(op *operation) { } path := filepath.Join(a.operationsDir, op.ID, "collected") if err := configutil.Write(path, nil); nil != err { - slog.Warn(fmt.Sprintf("Could not mark relay %s for stack %q as consumed: %v", op.ID, op.Stack, err), "relay", op.ID, "stack", op.Stack, "error", err) + slog.Warn( + fmt.Sprintf("Could not mark relay %s for stack %q as consumed: %v", op.ID, op.Stack, err), + "relay", + op.ID, + "stack", + op.Stack, + "error", + err, + ) return } a.mu.Lock() @@ -318,7 +449,20 @@ func (a *agent) collectRelay(op *operation) { func (a *agent) failDetached(op *operation, code int, err error) { _, _ = fmt.Fprintf(op, "\nError: %v\n", err) if writeErr := writeDetachedResult(filepath.Join(a.operationsDir, op.ID, "result.json"), op, code); nil != writeErr { - slog.Error(fmt.Sprintf("Could not save the result of detached %s operation %s on stack %q to %q: %v", op.Action, op.ID, op.Stack, filepath.Join(a.operationsDir, op.ID, "result.json"), writeErr), "operation", op.ID, "error", writeErr) + slog.Error( + fmt.Sprintf( + "Could not save the result of detached %s operation %s on stack %q to %q: %v", + op.Action, + op.ID, + op.Stack, + filepath.Join(a.operationsDir, op.ID, "result.json"), + writeErr, + ), + "operation", + op.ID, + "error", + writeErr, + ) } a.finishOperation(op, code) } @@ -369,7 +513,14 @@ func RunOperation(ctx context.Context, requestFile string) error { if err := a.initRegistries(request.ConfigDir); nil != err { return err } - op := &operation{operationSnapshot: operationSnapshot{ID: request.ID, Stack: request.Stack.Name, Action: request.Action, Status: "running"}, automatic: request.Automatic, service: request.Service} + op := &operation{ + ID: request.ID, + Stack: request.Stack.Name, + Action: request.Action, + Status: "running", + automatic: request.Automatic, + service: request.Service, + } file, err := os.OpenFile(filepath.Join(filepath.Dir(requestFile), "output.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if nil != err { return fmt.Errorf("open operation output: %w", err) diff --git a/internal/agent/detached_test.go b/internal/agent/detached_test.go index 5afdb11..5600c0a 100644 --- a/internal/agent/detached_test.go +++ b/internal/agent/detached_test.go @@ -63,8 +63,12 @@ func detachedFixture(t *testing.T) (*agent, *detachedDriver, context.CancelFunc) func detachedOp(t *testing.T, a *agent, id string) *operation { t.Helper() - op := &operation{operationSnapshot: operationSnapshot{ID: id, Stack: "self", Action: "update", Status: "running"}} - if err := a.prepareDetached(op, engine.Stack{Name: "self", Dir: "/stacks/self", ComposeFile: "/stacks/self/compose.yaml", EnvFile: "/stacks/self/.env"}, ""); nil != err { + op := &operation{ID: id, Stack: "self", Action: "update", Status: "running"} + if err := a.prepareDetached( + op, + engine.Stack{Name: "self", Dir: "/stacks/self", ComposeFile: "/stacks/self/compose.yaml", EnvFile: "/stacks/self/.env"}, + "", + ); nil != err { t.Fatal(err) } a.ops[id], a.busy[op.Stack] = op, true @@ -124,7 +128,7 @@ func TestSelfOperation(t *testing.T) { if "dagent-operation-"+input.ID != name || "self" != base.Name || "update" != input.Action { return errors.New("incorrect helper request") } - op := &operation{operationSnapshot: operationSnapshot{ID: input.ID, Stack: "self", Action: "update", Output: "helper completed\n"}} + op := &operation{ID: input.ID, Stack: "self", Action: "update", Output: "helper completed\n"} return writeDetachedResult(filepath.Join(filepath.Dir(path), "result.json"), op, 0) } driver.state = func(context.Context, string) (engine.DetachedState, error) { @@ -156,7 +160,8 @@ func TestPodmanSelfOperation(t *testing.T) { } var started, removed atomic.Int32 driver := podman.New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - if "podman" != cmd.Program || 4 > len(cmd.Args) || "--remote" != cmd.Args[0] || "--url" != cmd.Args[1] || host != cmd.Args[2] { + if "podman" != cmd.Program || 4 > len(cmd.Args) || "--remote" != cmd.Args[0] || "--url" != cmd.Args[1] || + host != cmd.Args[2] { return nil, nil, fmt.Errorf("unexpected Podman command: %+v", cmd) } args := cmd.Args[3:] @@ -179,7 +184,7 @@ func TestPodmanSelfOperation(t *testing.T) { return nil, nil, fmt.Errorf("unexpected helper request: %+v", input) } started.Add(1) - op := &operation{operationSnapshot: operationSnapshot{ID: input.ID, Stack: input.Stack.Name, Action: input.Action, Output: "podman helper completed\n"}} + op := &operation{ID: input.ID, Stack: input.Stack.Name, Action: input.Action, Output: "podman helper completed\n"} return nil, nil, writeDetachedResult(filepath.Join(filepath.Dir(path), "result.json"), op, 0) case "logs": return nil, nil, nil @@ -201,7 +206,10 @@ func TestPodmanSelfOperation(t *testing.T) { } result := completed(t, a, request(a, method, path, body)) a.work.Wait() - if "succeeded" != result.Status || nil == result.ExitCode || 0 != *result.ExitCode || "podman helper completed\n" != result.Output || 1 != started.Load() || 1 != removed.Load() { + if "succeeded" != result.Status || nil == result.ExitCode || 0 != *result.ExitCode || + "podman helper completed\n" != result.Output || + 1 != started.Load() || + 1 != removed.Load() { t.Fatalf("Podman helper result: %+v started=%d removed=%d", result, started.Load(), removed.Load()) } }) @@ -261,7 +269,8 @@ func TestDetachedLaunchFailure(t *testing.T) { a, driver, _ := detachedFixture(t) op := detachedOp(t, a, "0000000000000002") driver.start = func(_ context.Context, self engine.SelfContainer, name, path string, base engine.Stack) error { - if "node" != self.ID || "dagent-operation-"+op.ID != name || filepath.Join(a.operationsDir, op.ID, "request.json") != path || "self" != base.Name { + if "node" != self.ID || "dagent-operation-"+op.ID != name || filepath.Join(a.operationsDir, op.ID, "request.json") != path || + "self" != base.Name { t.Fatal("incorrect launch metadata") } return errors.New("helper launch denied") @@ -296,7 +305,11 @@ func TestDetachedMissingResult(t *testing.T) { } a.observeDetached(op) result, err := readDetachedResult(filepath.Join(a.operationsDir, op.ID, "result.json"), op) - if nil != err || "failed" != result.Status || nil == result.ExitCode || 0 == *result.ExitCode || (0 != code && code != *result.ExitCode) || !strings.Contains(result.Output, "helper panic details") || 1 != driver.removed.Load() || a.busy[op.Stack] { + if nil != err || "failed" != result.Status || nil == result.ExitCode || 0 == *result.ExitCode || + (0 != code && code != *result.ExitCode) || + !strings.Contains(result.Output, "helper panic details") || + 1 != driver.removed.Load() || + a.busy[op.Stack] { t.Fatalf("missing result: %+v %v", result, err) } }) @@ -323,7 +336,7 @@ func TestDetachedInspectOutage(t *testing.T) { t.Fatalf("outage changed helper result: %+v calls=%d", snapshot, calls) } matches := 0 - for _, line := range strings.Split(strings.TrimSpace(logs.text()), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(logs.text()), "\n") { var record struct { Level, Operation, Error string Message string `json:"msg"` @@ -431,7 +444,9 @@ func TestDetachedOutputAndCleanup(t *testing.T) { } a.observeDetached(op) result := op.snapshot() - if "progress € done\n" != result.Output || "failed" != result.Status || 7 != *result.ExitCode || !utf8.ValidString(result.Output) || 1 != driver.removed.Load() || a.busy[op.Stack] { + if "progress € done\n" != result.Output || "failed" != result.Status || 7 != *result.ExitCode || !utf8.ValidString(result.Output) || + 1 != driver.removed.Load() || + a.busy[op.Stack] { t.Fatalf("output/cleanup: %+v", result) } } @@ -532,7 +547,7 @@ func TestDetachedLogCap(t *testing.T) { if nil != err { t.Fatal(err) } - defer file.Close() + defer func() { _ = file.Close() }() log := &detachedLog{file: file, remaining: 1 << 20} data := []byte(strings.Repeat("x", (1<<20)+32)) if n, err := log.Write(data); len(data) != n || nil != err { @@ -554,7 +569,7 @@ func TestRunOperation(t *testing.T) { t.Fatal(err) } a.operationsDir = filepath.Join(directory, "operations") - op := &operation{operationSnapshot: operationSnapshot{ID: "000000000000000a", Stack: "self", Action: "restart", Status: "running"}} + op := &operation{ID: "000000000000000a", Stack: "self", Action: "restart", Status: "running"} if err := a.prepareDetached(op, base, "web"); nil != err { t.Fatal(err) } @@ -589,14 +604,21 @@ func TestRunOperationPodman(t *testing.T) { t.Setenv("DOCKER_CONTEXT", "wrong-context") t.Setenv("DOCKER_TLS_VERIFY", "1") t.Setenv("DOCKER_CERT_PATH", "/wrong/certificates") - write(t, filepath.Join(directory, "docker"), "#!/bin/sh\nprintf '%s|%s|%s|%s\\n' \"$DOCKER_HOST\" \"${DOCKER_CONTEXT-unset}\" \"${DOCKER_TLS_VERIFY-unset}\" \"${DOCKER_CERT_PATH-unset}\" >> \"$FAKE_DIR/compose-env\"\n"+readFixture(t, "docker.sh")) + write( + t, + filepath.Join(directory, "docker"), + "#!/bin/sh\nprintf '%s|%s|%s|%s\\n' \"$DOCKER_HOST\" \"${DOCKER_CONTEXT-unset}\" \"${DOCKER_TLS_VERIFY-unset}\" \"${DOCKER_CERT_PATH-unset}\" >> \"$FAKE_DIR/compose-env\"\n"+readFixture( + t, + "docker.sh", + ), + ) stackDir := seed(t, a, "self", "compose.yaml") base, err := a.engineStack("self", stackDir) if nil != err { t.Fatal(err) } a.operationsDir = filepath.Join(directory, "operations") - op := &operation{operationSnapshot: operationSnapshot{ID: "000000000000000f", Stack: "self", Action: "restart", Status: "running"}} + op := &operation{ID: "000000000000000f", Stack: "self", Action: "restart", Status: "running"} if err := a.prepareDetached(op, base, "web"); nil != err { t.Fatal(err) } @@ -604,14 +626,15 @@ func TestRunOperationPodman(t *testing.T) { t.Fatal(err) } result, err := readDetachedResult(filepath.Join(a.operationsDir, op.ID, "result.json"), op) - if nil != err || "succeeded" != result.Status || nil == result.ExitCode || 0 != *result.ExitCode || !strings.Contains(result.Output, "restart started") { + if nil != err || "succeeded" != result.Status || nil == result.ExitCode || 0 != *result.ExitCode || + !strings.Contains(result.Output, "restart started") { t.Fatalf("Podman helper result: %+v error=%v", result, err) } environment, err := os.ReadFile(filepath.Join(directory, "compose-env")) if nil != err || "" == strings.TrimSpace(string(environment)) { t.Fatalf("Compose environment: %q error=%v", environment, err) } - for _, line := range strings.Split(strings.TrimSpace(string(environment)), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(string(environment)), "\n") { if host+"|||" != line { t.Fatalf("Compose transport: %q", line) } diff --git a/internal/agent/engine_test.go b/internal/agent/engine_test.go index 8ea1bbf..87a21c2 100644 --- a/internal/agent/engine_test.go +++ b/internal/agent/engine_test.go @@ -103,13 +103,18 @@ func TestRuntimeSchedulingLabels(t *testing.T) { t.Fatalf("label inspection: %+v", cmd) } inspected = true - return []byte(`{"labels":{"io.dcompose.check-interval":"30m","io.dcompose.auto-update":"true","private.label":"private-label-value"}}`), nil, nil + return []byte( + `{"labels":{"io.dcompose.check-interval":"30m","io.dcompose.auto-update":"true","private.label":"private-label-value"}}`, + ), nil, nil } var driver engine.Driver = docker.New(run) if "podman" == name { driver = podman.New(run) } - services, err := driver.Containers(context.Background(), engine.Stack{Name: "demo", Dir: "/stacks/demo", ComposeFile: "/stacks/demo/compose.yaml", EnvFile: "/dev/null"}) + services, err := driver.Containers( + context.Background(), + engine.Stack{Name: "demo", Dir: "/stacks/demo", ComposeFile: "/stacks/demo/compose.yaml", EnvFile: "/dev/null"}, + ) if nil != err || !inspected || 1 != len(services) || "30m" != services[0].CheckInterval || !services[0].AutoUpdate { t.Fatalf("runtime labels: %+v %v", services, err) } @@ -131,9 +136,12 @@ func TestRuntimeExitCode(t *testing.T) { run := func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { calls++ if strings.Contains(strings.Join(cmd.Args, " "), "ps --all --format json") { - return []byte(`[{"Name":"failed","State":"exited","ExitCode":7},{"Name":"success","State":"exited","ExitCode":0},{"Name":"unknown","State":"exited"}]`), nil, nil + return []byte( + `[{"Name":"failed","State":"exited","ExitCode":7},{"Name":"success","State":"exited","ExitCode":0},{"Name":"unknown","State":"exited"}]`, + ), nil, nil } - if name != cmd.Program || !strings.Contains(strings.Join(cmd.Args, " "), "container inspect --format "+compose.ContainerInfoFormat) { + if name != cmd.Program || + !strings.Contains(strings.Join(cmd.Args, " "), "container inspect --format "+compose.ContainerInfoFormat) { t.Fatalf("unexpected runtime command: %+v", cmd) } return []byte(`{}`), nil, nil @@ -148,7 +156,8 @@ func TestRuntimeExitCode(t *testing.T) { Status string `json:"status"` Services []map[string]json.RawMessage `json:"services"` } - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &payload) || 3 != len(payload.Services) || "stopped" != payload.Status { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &payload) || 3 != len(payload.Services) || + "stopped" != payload.Status { t.Fatalf("runtime response: %d %s", w.Code, w.Body) } for i, want := range []string{"7", "0", ""} { @@ -174,12 +183,16 @@ func TestRuntimeTiming(t *testing.T) { run := func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { calls++ if strings.Contains(strings.Join(cmd.Args, " "), "ps --all --format json") { - return []byte(`[{"Name":"web","State":"running","CreatedAt":"stale Compose date","RunningFor":"38 seconds ago"}]`), nil, nil + return []byte( + `[{"Name":"web","State":"running","CreatedAt":"stale Compose date","RunningFor":"38 seconds ago"}]`, + ), nil, nil } if name != cmd.Program || 5 != len(cmd.Args) || compose.ContainerInfoFormat != cmd.Args[3] || "web" != cmd.Args[4] { t.Fatalf("unexpected inspection: %+v", cmd) } - return []byte(`{"labels":null,"created_at":"2026-11-03T02:39:58.237+02:00","started_at":"2026-11-03T05:45:00.123456789+02:00"}`), nil, nil + return []byte( + `{"labels":null,"created_at":"2026-11-03T02:39:58.237+02:00","started_at":"2026-11-03T05:45:00.123456789+02:00"}`, + ), nil, nil } a.engine = docker.New(run) if "podman" == name { @@ -193,7 +206,8 @@ func TestRuntimeTiming(t *testing.T) { if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &payload) || 1 != len(payload.Services) { t.Fatalf("runtime response: %d %s", w.Code, w.Body) } - if "2026-11-03T00:39:58.237Z" != payload.Services[0].CreatedAt || "2026-11-03T03:45:00.123456789Z" != payload.Services[0].StartedAt { + if "2026-11-03T00:39:58.237Z" != payload.Services[0].CreatedAt || + "2026-11-03T03:45:00.123456789Z" != payload.Services[0].StartedAt { t.Fatalf("runtime timing: %s", w.Body) } } diff --git a/internal/agent/errors_test.go b/internal/agent/errors_test.go index 13aede5..0fa7a36 100644 --- a/internal/agent/errors_test.go +++ b/internal/agent/errors_test.go @@ -39,7 +39,11 @@ func TestErrorResponse(t *testing.T) { var response struct { Error string `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || test.code != w.Code || "" == response.Error || strings.Contains(response.Error, "private diagnostic") || strings.Contains(response.Error, "/private/stacks/.env") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || test.code != w.Code || "" == response.Error || strings.Contains(response.Error, "private diagnostic") || + strings.Contains(response.Error, "/private/stacks/.env") { t.Fatal(w.Code, w.Body.String(), err) } var apiErr *engine.Error @@ -77,7 +81,11 @@ func TestInventoryFailure(t *testing.T) { var response struct { Error string `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 500 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), a.config.Global.StacksDir) || strings.Contains(w.Body.String(), "private-stacks") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 500 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), a.config.Global.StacksDir) || + strings.Contains(w.Body.String(), "private-stacks") { t.Fatal("inventory error exposed internals", w.Code, w.Body.String()) } } @@ -138,7 +146,8 @@ func TestProxySourceError(t *testing.T) { } else if strings.SplitN(test.peer, ":", 2)[0] != record.Source || !strings.Contains(record.Message, record.Source) { t.Fatal("missing rejected source", logs.String()) } - if strings.Contains(logs.String(), "wrong-secret") || strings.Contains(logs.String(), "test-secret") || strings.Contains(logs.String(), "malformed") { + if strings.Contains(logs.String(), "wrong-secret") || strings.Contains(logs.String(), "test-secret") || + strings.Contains(logs.String(), "malformed") { t.Fatal("rejection logs leaked credentials or forwarding input", logs.String()) } }) diff --git a/internal/agent/events.go b/internal/agent/events.go index 39ee933..7d6e4d7 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -28,8 +28,8 @@ func newSubscriber() *eventSubscriber { return &eventSubscriber{events: make(chan streamEvent, 64), done: make(chan struct{})} } -// interface{} is only the JSON encoding boundary for the typed event payloads. -func event(name string, payload interface{}) streamEvent { +// any (the interface{} alias) is only the JSON encoding boundary for the typed event payloads. +func event(name string, payload any) streamEvent { data, _ := json.Marshal(payload) return streamEvent{name, data} } @@ -246,9 +246,7 @@ func (a *agent) refreshRuntimes() { results := make(chan runtimeResult, 4) var workers sync.WaitGroup for i := 0; 4 > i; i++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { for name := range jobs { ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second) state, err := a.runtimeStatus(ctx, name) @@ -259,7 +257,7 @@ func (a *agent) refreshRuntimes() { return } } - }() + }) } defer func() { close(jobs); workers.Wait() }() tick := time.NewTicker(100 * time.Millisecond) @@ -341,7 +339,15 @@ func (a *agent) monitorRuntime() { delay = time.Second a.listenRuntime(delay) } else if nil == a.ctx.Err() { - slog.Warn(fmt.Sprintf("Could not probe %s for container events: %v. Retrying in %s.", a.config.Global.Engine, err, delay), "runtime", a.config.Global.Engine, "error", err, "retry_in", delay) + slog.Warn( + fmt.Sprintf("Could not probe %s for container events: %v. Retrying in %s.", a.config.Global.Engine, err, delay), + "runtime", + a.config.Global.Engine, + "error", + err, + "retry_in", + delay, + ) } if nil != a.ctx.Err() { return @@ -377,9 +383,28 @@ func (a *agent) listenRuntime(retry time.Duration) { } if err := scanner.Err(); nil != err { if context.Canceled == a.ctx.Err() { - slog.Debug(fmt.Sprintf("Reading %s container events was canceled: %v", a.config.Global.Engine, err), "runtime", a.config.Global.Engine, "error", err) + slog.Debug( + fmt.Sprintf("Reading %s container events was canceled: %v", a.config.Global.Engine, err), + "runtime", + a.config.Global.Engine, + "error", + err, + ) } else { - slog.Warn(fmt.Sprintf("Could not read %s container events: %v. Restarting the subscription in %s.", a.config.Global.Engine, err, retry), "runtime", a.config.Global.Engine, "error", err, "retry_in", retry) + slog.Warn( + fmt.Sprintf( + "Could not read %s container events: %v. Restarting the subscription in %s.", + a.config.Global.Engine, + err, + retry, + ), + "runtime", + a.config.Global.Engine, + "error", + err, + "retry_in", + retry, + ) } cancel() } @@ -401,19 +426,69 @@ func (a *agent) listenRuntime(retry time.Duration) { if err := waitCommand(ctx, cmd); nil == a.ctx.Err() { if nil != err { if detail := strings.TrimSpace(stderr.String()); "" != detail { - slog.Warn(fmt.Sprintf("The %s container event subscription stopped: %v: %s. Retrying in %s.", a.config.Global.Engine, err, detail, retry), "runtime", a.config.Global.Engine, "error", err, "stderr", detail, "retry_in", retry) + slog.Warn( + fmt.Sprintf( + "The %s container event subscription stopped: %v: %s. Retrying in %s.", + a.config.Global.Engine, + err, + detail, + retry, + ), + "runtime", + a.config.Global.Engine, + "error", + err, + "stderr", + detail, + "retry_in", + retry, + ) } else { - slog.Warn(fmt.Sprintf("The %s container event subscription stopped: %v. Retrying in %s.", a.config.Global.Engine, err, retry), "runtime", a.config.Global.Engine, "error", err, "retry_in", retry) + slog.Warn( + fmt.Sprintf("The %s container event subscription stopped: %v. Retrying in %s.", a.config.Global.Engine, err, retry), + "runtime", + a.config.Global.Engine, + "error", + err, + "retry_in", + retry, + ) } } else { - slog.Warn(fmt.Sprintf("The %s container event subscription ended with exit code 0. Retrying in %s.", a.config.Global.Engine, retry), "runtime", a.config.Global.Engine, "exit_code", 0, "retry_in", retry) + slog.Warn( + fmt.Sprintf( + "The %s container event subscription ended with exit code 0. Retrying in %s.", + a.config.Global.Engine, + retry, + ), + "runtime", + a.config.Global.Engine, + "exit_code", + 0, + "retry_in", + retry, + ) } } } else { if context.Canceled == a.ctx.Err() { - slog.Debug(fmt.Sprintf("Starting the %s container event subscription was canceled: %v", a.config.Global.Engine, err), "runtime", a.config.Global.Engine, "error", err) + slog.Debug( + fmt.Sprintf("Starting the %s container event subscription was canceled: %v", a.config.Global.Engine, err), + "runtime", + a.config.Global.Engine, + "error", + err, + ) } else { - slog.Warn(fmt.Sprintf("Could not start the %s container event subscription: %v. Retrying in %s.", a.config.Global.Engine, err, retry), "runtime", a.config.Global.Engine, "error", err, "retry_in", retry) + slog.Warn( + fmt.Sprintf("Could not start the %s container event subscription: %v. Retrying in %s.", a.config.Global.Engine, err, retry), + "runtime", + a.config.Global.Engine, + "error", + err, + "retry_in", + retry, + ) } } _ = writer.Close() diff --git a/internal/agent/events_test.go b/internal/agent/events_test.go index a5f2775..e122b07 100644 --- a/internal/agent/events_test.go +++ b/internal/agent/events_test.go @@ -86,7 +86,8 @@ func TestRuntimeProgressive(t *testing.T) { t.Fatalf("pending runtime: %+v", state) } } - if strings.Contains(string(data), "SECRET") || strings.Contains(string(data), "compose") || strings.Contains(string(data), "output") || strings.Contains(string(data), `"env"`) { + if strings.Contains(string(data), "SECRET") || strings.Contains(string(data), "compose") || strings.Contains(string(data), "output") || + strings.Contains(string(data), `"env"`) { t.Fatalf("unsafe snapshot: %s", data) } data = reader.until(t, "stack.runtime") @@ -173,7 +174,9 @@ func TestEventFilterBurst(t *testing.T) { t.Fatal("unmanaged events queued") } for i := 0; 1000 > i; i++ { - a.runtimeEvent([]byte(`{"Type":"container","Action":"health_status: healthy","Actor":{"Attributes":{"com.docker.compose.project":"demo"}}}`)) + a.runtimeEvent( + []byte(`{"Type":"container","Action":"health_status: healthy","Actor":{"Attributes":{"com.docker.compose.project":"demo"}}}`), + ) } if 1 != len(a.events.pending) || !a.events.pending["demo"] { t.Fatal("burst was not coalesced") @@ -190,7 +193,7 @@ func TestMonitorRecovery(t *testing.T) { if 2 != len(args) { t.Fatalf("missing subscription replay boundary: %s", argv) } - boundary := strings.SplitN(args[1], ">", 2)[0] + boundary, _, _ := strings.Cut(args[1], ">") if timestamp, err := strconv.ParseFloat(boundary, 64); nil != err || 0 >= timestamp || !strings.Contains(boundary, ".") { t.Fatalf("invalid replay timestamp: %s", boundary) } @@ -213,7 +216,11 @@ func TestMonitorRecovery(t *testing.T) { t.Fatal("monitor did not restart") } write(t, filepath.Join(fake, "ps"), `[{"State":"running"}]`) - write(t, filepath.Join(fake, "events-staged"), `{"Type":"container","Action":"start","Actor":{"Attributes":{"com.docker.compose.project":"demo"}}}`+"\n") + write( + t, + filepath.Join(fake, "events-staged"), + `{"Type":"container","Action":"start","Actor":{"Attributes":{"com.docker.compose.project":"demo"}}}`+"\n", + ) if err := os.Rename(filepath.Join(fake, "events-staged"), filepath.Join(fake, "events-input")); nil != err { t.Fatal(err) } @@ -370,7 +377,8 @@ func TestOperationEvents(t *testing.T) { Stacks []inventoryRuntime `json:"stacks"` } data := reconnected.until(t, "node.snapshot") - if nil != json.Unmarshal(data, &snapshot) || 1 != len(snapshot.Stacks) || !snapshot.Stacks[0].Busy || strings.Contains(string(data), "output") { + if nil != json.Unmarshal(data, &snapshot) || 1 != len(snapshot.Stacks) || !snapshot.Stacks[0].Busy || + strings.Contains(string(data), "output") { t.Fatalf("busy snapshot: %s", data) } _ = os.Remove(filepath.Join(fake, "block")) @@ -398,7 +406,8 @@ func TestRuntimeStatusSafe(t *testing.T) { dir := seed(t, a, "demo", "compose.yaml") write(t, filepath.Join(dir, ".env"), "SECRET=private") w := request(a, "GET", "/stacks/demo/status", "") - if 200 != w.Code || strings.Contains(w.Body.String(), "SECRET") || strings.Contains(w.Body.String(), "compose") || strings.Contains(w.Body.String(), `"env"`) { + if 200 != w.Code || strings.Contains(w.Body.String(), "SECRET") || strings.Contains(w.Body.String(), "compose") || + strings.Contains(w.Body.String(), `"env"`) { t.Fatalf("unsafe runtime: %s", w.Body) } } @@ -466,7 +475,7 @@ func TestRefreshBound(t *testing.T) { } func TestOperationUTF8(t *testing.T) { - op := &operation{operationSnapshot: operationSnapshot{ID: "utf8", Status: "running"}} + op := &operation{ID: "utf8", Status: "running"} s := newSubscriber() op.subscribers = map[*eventSubscriber]struct{}{s: {}} text := []byte("日") diff --git a/internal/agent/image_actions_test.go b/internal/agent/image_actions_test.go index 0c72d7c..ce21341 100644 --- a/internal/agent/image_actions_test.go +++ b/internal/agent/image_actions_test.go @@ -17,7 +17,11 @@ func executionFixture(t *testing.T) (*agent, string) { func TestServiceUpdateStopped(t *testing.T) { a, fake := executionFixture(t) - write(t, filepath.Join(fake, "ps"), `[{"Name":"web-1","Service":"web","State":"exited"},{"Name":"worker-1","Service":"worker","State":"running"}]`) + write( + t, + filepath.Join(fake, "ps"), + `[{"Name":"web-1","Service":"web","State":"exited"},{"Name":"worker-1","Service":"worker","State":"running"}]`, + ) op := completed(t, a, request(a, "POST", "/stacks/demo/actions", `{"action":"update","service":"web"}`)) argv, err := os.ReadFile(filepath.Join(fake, "argv")) if nil != err || "succeeded" != op.Status || strings.Contains(string(argv), "") || strings.Contains(string(argv), "") { @@ -34,7 +38,11 @@ func TestHelperRegistryService(t *testing.T) { t.Fatal(err) } a = restarted - write(t, filepath.Join(fake, "ps"), `[{"Name":"web-1","Service":"web","State":"running"},{"Name":"worker-1","Service":"worker","State":"running"}]`) + write( + t, + filepath.Join(fake, "ps"), + `[{"Name":"web-1","Service":"web","State":"running"},{"Name":"worker-1","Service":"worker","State":"running"}]`, + ) dir, err := a.stackPath("demo", false) if nil != err { t.Fatal(err) @@ -44,7 +52,7 @@ func TestHelperRegistryService(t *testing.T) { t.Fatal(err) } a.operationsDir = t.TempDir() - op := &operation{operationSnapshot: operationSnapshot{ID: "0123456789abcdef", Stack: "demo", Action: "update", Status: "running"}} + op := &operation{ID: "0123456789abcdef", Stack: "demo", Action: "update", Status: "running"} if err := a.prepareDetached(op, stack, "web"); nil != err { t.Fatal(err) } @@ -53,7 +61,9 @@ func TestHelperRegistryService(t *testing.T) { t.Fatal(err) } argv, err := os.ReadFile(filepath.Join(fake, "argv")) - if nil != err || !strings.Contains(string(argv), "<-->") || !strings.Contains(string(argv), "<-d><--no-deps><-->") || strings.Contains(string(argv), "secret") { + if nil != err || !strings.Contains(string(argv), "<-->") || + !strings.Contains(string(argv), "<-d><--no-deps><-->") || + strings.Contains(string(argv), "secret") { t.Fatalf("helper arguments: %s %v", argv, err) } env, err := os.ReadFile(filepath.Join(fake, "pull-config")) diff --git a/internal/agent/images_test.go b/internal/agent/images_test.go index c8118b0..6983825 100644 --- a/internal/agent/images_test.go +++ b/internal/agent/images_test.go @@ -39,21 +39,36 @@ func TestImageInspect(t *testing.T) { a, _ := fixture(t) dir := seed(t, a, "demo", "compose.yaml") services := []service{ - {ID: "web-id", Name: "web-1", Service: "web", Image: "nginx:latest", State: "running", CheckInterval: "30m", AutoUpdate: true}, + { + ID: "web-id", + Name: "web-1", + Service: "web", + Image: "nginx:latest", + State: "running", + CheckInterval: "30m", + AutoUpdate: true, + }, {ID: "worker-id", Name: "worker-1", Service: "worker", Image: "redis:latest", State: "exited"}, } inspected := []string{} a.engine = &inspectionDriver{ Driver: a.engine, containers: func(ctx context.Context, stack engine.Stack) ([]engine.Service, error) { - if "demo" != stack.Name || dir != stack.Dir || filepath.Join(dir, "compose.yaml") != stack.ComposeFile || nil != ctx.Err() { + if "demo" != stack.Name || dir != stack.Dir || filepath.Join(dir, "compose.yaml") != stack.ComposeFile || + nil != ctx.Err() { t.Fatalf("stack inspection: %+v %v", stack, ctx.Err()) } return services, nil }, inspect: func(_ context.Context, service engine.Service) engine.ImageTarget { inspected = append(inspected, service.Name) - return engine.ImageTarget{LocalID: "local-" + service.ID, Key: engine.ImageKey{Ref: service.Image, ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}} + return engine.ImageTarget{ + LocalID: "local-" + service.ID, + Key: engine.ImageKey{ + Ref: service.Image, + ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}, + }, + } }, } w := request(a, "POST", "/images/inspect", fmt.Sprintf(`{"stack":"demo","container":%q}`, container)) @@ -70,7 +85,10 @@ func TestImageInspect(t *testing.T) { } for i, service := range expected { image := response.Images[i] - if service.ID != image.ID || service.Name != image.Service.Name || service.Image != image.Target.Key.Ref || "local-"+service.ID != image.Target.LocalID || service.CheckInterval != image.Service.CheckInterval || service.AutoUpdate != image.Service.AutoUpdate { + if service.ID != image.ID || service.Name != image.Service.Name || service.Image != image.Target.Key.Ref || + "local-"+service.ID != image.Target.LocalID || + service.CheckInterval != image.Service.CheckInterval || + service.AutoUpdate != image.Service.AutoUpdate { t.Fatalf("inspection identity/target: %+v", image) } } @@ -137,7 +155,8 @@ func TestImageInspectResults(t *testing.T) { } w := request(a, "POST", "/images/inspect", `{"stack":"demo"}`) var response protocol.ImageInspectResponse - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &response) || 1 != len(response.Images) || target != response.Images[0].Target { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &response) || 1 != len(response.Images) || + target != response.Images[0].Target { t.Fatalf("typed inspection result: %d %s", w.Code, w.Body) } }) @@ -207,7 +226,9 @@ func TestImageInspectNative(t *testing.T) { args := strings.Join(cmd.Args, " ") switch { case strings.Contains(args, "ps --all --format json"): - return []byte(`[{"ID":"container-id","Name":"demo-web-1","Service":"web","Image":"docker.io/library/nginx:latest","State":"running"}]`), nil, nil + return []byte( + `[{"ID":"container-id","Name":"demo-web-1","Service":"web","Image":"docker.io/library/nginx:latest","State":"running"}]`, + ), nil, nil case strings.Contains(args, "{{json .Config.Labels}}"): return []byte(`{}`), nil, nil case strings.Contains(args, "container inspect"): @@ -216,7 +237,9 @@ func TestImageInspectNative(t *testing.T) { case strings.Contains(args, "image inspect"): calls++ if "podman" == name { - return []byte(`[{"Os":"linux","Architecture":"amd64","RepoTags":["docker.io/library/nginx:latest"],"RepoDigests":["docker.io/library/nginx@sha256:manifest"]}]`), nil, nil + return []byte( + `[{"Os":"linux","Architecture":"amd64","RepoTags":["docker.io/library/nginx:latest"],"RepoDigests":["docker.io/library/nginx@sha256:manifest"]}]`, + ), nil, nil } return []byte(`{"os":"linux","architecture":"amd64","digests":["docker.io/library/nginx@sha256:manifest"]}`), nil, nil default: @@ -234,7 +257,9 @@ func TestImageInspectNative(t *testing.T) { t.Fatalf("native inspection: %d %s calls=%d", w.Code, w.Body, calls) } image := response.Images[0] - if "container-id" != image.ID || id != image.Target.LocalID || "docker.io/library/nginx:latest" != image.Target.Key.Ref || "amd64" != image.Target.Key.Architecture || "" != image.Target.Status { + if "container-id" != image.ID || id != image.Target.LocalID || "docker.io/library/nginx:latest" != image.Target.Key.Ref || + "amd64" != image.Target.Key.Architecture || + "" != image.Target.Status { t.Fatalf("native target: %+v", image) } }) @@ -264,12 +289,17 @@ func TestImagesInvalidated(t *testing.T) { a.invalidateImages("demo", map[string]bool{"docker.io/library/alpine:latest": true, "nginx:latest": true}) e := <-subscriber.events var invalidated protocol.ImageInvalidated - if "images.invalidated" != e.name || nil != json.Unmarshal(e.data, &invalidated) || "demo" != invalidated.Stack || !reflect.DeepEqual(invalidated.Refs, []string{"docker.io/library/alpine:latest", "docker.io/library/nginx:latest", "docker.io/library/redis:latest"}) { + if "images.invalidated" != e.name || nil != json.Unmarshal(e.data, &invalidated) || "demo" != invalidated.Stack || + !reflect.DeepEqual( + invalidated.Refs, + []string{"docker.io/library/alpine:latest", "docker.io/library/nginx:latest", "docker.io/library/redis:latest"}, + ) { t.Fatalf("invalidation refs: %s %s", e.name, e.data) } a.invalidateImages("missing", nil) e = <-subscriber.events - if nil != json.Unmarshal(e.data, &invalidated) || "missing" != invalidated.Stack || 0 != len(invalidated.Refs) || strings.Contains(string(e.data), `"refs":null`) { + if nil != json.Unmarshal(e.data, &invalidated) || "missing" != invalidated.Stack || 0 != len(invalidated.Refs) || + strings.Contains(string(e.data), `"refs":null`) { t.Fatalf("unknown refs lost stack invalidation: %s", e.data) } } @@ -298,7 +328,8 @@ func TestImageOperationInvalidation(t *testing.T) { continue } var invalidated protocol.ImageInvalidated - if "images.invalidated" != e.name || nil != json.Unmarshal(e.data, &invalidated) || "demo" != invalidated.Stack || !reflect.DeepEqual(invalidated.Refs, []string{"docker.io/library/nginx:latest"}) { + if "images.invalidated" != e.name || nil != json.Unmarshal(e.data, &invalidated) || "demo" != invalidated.Stack || + !reflect.DeepEqual(invalidated.Refs, []string{"docker.io/library/nginx:latest"}) { t.Fatalf("operation invalidation: %s %s", e.name, e.data) } default: @@ -345,7 +376,9 @@ func TestRuntimeExecutionOnly(t *testing.T) { t.Fatal("channel connect or registry sync made an image decision") } argv, err := os.ReadFile(filepath.Join(fake, "argv")) - if nil != err || strings.Contains(string(argv), "") || strings.Contains(string(argv), `{"id":`) || strings.Contains(string(argv), "") || strings.Contains(string(argv), "") { + if nil != err || strings.Contains(string(argv), "") || strings.Contains(string(argv), `{"id":`) || + strings.Contains(string(argv), "") || + strings.Contains(string(argv), "") { t.Fatalf("runtime observation executed image inspection or an update: %s %v", argv, err) } } diff --git a/internal/agent/live_actions_test.go b/internal/agent/live_actions_test.go index 9ea2f83..d9128f7 100644 --- a/internal/agent/live_actions_test.go +++ b/internal/agent/live_actions_test.go @@ -50,7 +50,8 @@ func TestActionOutputUTF8(t *testing.T) { for _, expected := range []string{"日", large, "\uFFFD"} { e := <-subscriber.events var delta protocol.StackOutput - if "stack.output" != e.name || nil != json.Unmarshal(e.data, &delta) || "demo" != delta.Name || expected != delta.Output || !utf8.ValidString(delta.Output) { + if "stack.output" != e.name || nil != json.Unmarshal(e.data, &delta) || "demo" != delta.Name || expected != delta.Output || + !utf8.ValidString(delta.Output) { t.Fatalf("invalid output chunk: %s (%d bytes)", e.name, len(e.data)) } } @@ -106,7 +107,8 @@ func TestChannelRelay(t *testing.T) { } `json:"relays"` } w := request(a, "GET", "/relays", "") - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &listed) || 1 != len(listed.Relays) || op.ID != listed.Relays[0].ID || strings.Contains(w.Body.String(), "output") { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &listed) || 1 != len(listed.Relays) || op.ID != listed.Relays[0].ID || + strings.Contains(w.Body.String(), "output") { t.Fatalf("recoverable relays: %s", w.Body) } reconnected := openChannel(t, server) diff --git a/internal/agent/logs.go b/internal/agent/logs.go index d9ded85..88dae89 100644 --- a/internal/agent/logs.go +++ b/internal/agent/logs.go @@ -11,8 +11,7 @@ func (a *agent) streamLogs(parent context.Context, name string, send func(stream dir, err := a.stackPath(name, false) if nil != err { code := 502 - var apiErr *apiError - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*apiError](err); ok { code = apiErr.code } return fault(code, "stack logs unavailable") @@ -20,8 +19,7 @@ func (a *agent) streamLogs(parent context.Context, name string, send func(stream stack, err := a.engineStack(name, dir) if nil != err { code := 502 - var apiErr *apiError - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*apiError](err); ok { code = apiErr.code } return fault(code, "stack logs unavailable") diff --git a/internal/agent/node_logs_test.go b/internal/agent/node_logs_test.go index 8c20d31..95a8413 100644 --- a/internal/agent/node_logs_test.go +++ b/internal/agent/node_logs_test.go @@ -16,7 +16,9 @@ import ( ) func nodeLogger(buffer *nodeLogBuffer, local slog.Handler) *slog.Logger { - return slog.New(&nodeLogHandler{local: local, forward: slog.NewJSONHandler(buffer, &slog.HandlerOptions{AddSource: true}).WithGroup("attrs")}) + return slog.New( + &nodeLogHandler{local: local, forward: slog.NewJSONHandler(buffer, &slog.HandlerOptions{AddSource: true}).WithGroup("attrs")}, + ) } func TestNodeLogHandler(t *testing.T) { @@ -30,7 +32,12 @@ func TestNodeLogHandler(t *testing.T) { logger = logger.With("stack", "demo").WithGroup("request").With("service", "web") stamp := time.Date(2026, 10, 29, 12, 0, 0, 123456789, time.UTC) record := slog.NewRecord(stamp, slog.LevelError, "diagnostic", 0) - record.AddAttrs(slog.String("image", "example:latest"), slog.String("stderr", "registry denied"), slog.Any("error", errors.New("manifest failure")), slog.Group("operation", "id", "job", "attempt", 2)) + record.AddAttrs( + slog.String("image", "example:latest"), + slog.String("stderr", "registry denied"), + slog.Any("error", errors.New("manifest failure")), + slog.Group("operation", "id", "job", "attempt", 2), + ) if err := logger.Handler().Handle(context.Background(), record); nil != err { t.Fatal(err) } @@ -38,7 +45,8 @@ func TestNodeLogHandler(t *testing.T) { if 2 != len(entries) || slog.LevelDebug != entries[0].Level || slog.LevelError != entries[1].Level || !stamp.Equal(entries[1].Time) { t.Fatalf("record metadata: %+v", entries) } - if nil == entries[0].Source || !strings.HasSuffix(entries[0].Source.Function, ".TestNodeLogHandler") || 0 == entries[0].Source.Line || nil != entries[1].Source { + if nil == entries[0].Source || !strings.HasSuffix(entries[0].Source.Function, ".TestNodeLogHandler") || 0 == entries[0].Source.Line || + nil != entries[1].Source { t.Fatal("original callsite not preserved", entries) } var attrs struct { @@ -54,10 +62,15 @@ func TestNodeLogHandler(t *testing.T) { if err := json.Unmarshal(entries[1].Attrs["request"], &attrs); nil != err { t.Fatal(err) } - if `"demo"` != string(entries[1].Attrs["stack"]) || "web" != attrs.Service || "example:latest" != attrs.Image || "registry denied" != attrs.Stderr || "manifest failure" != attrs.Error || "job" != attrs.Operation.ID || 2 != attrs.Operation.Attempt { + if `"demo"` != string(entries[1].Attrs["stack"]) || "web" != attrs.Service || "example:latest" != attrs.Image || + "registry denied" != attrs.Stderr || + "manifest failure" != attrs.Error || + "job" != attrs.Operation.ID || + 2 != attrs.Operation.Attempt { t.Fatalf("structured fields: %+v %+v", entries[1], attrs) } - if strings.Contains(local.text(), "phase=filtered") || !strings.Contains(local.text(), "request.stderr=") || !strings.Contains(local.text(), "level=ERROR") { + if strings.Contains(local.text(), "phase=filtered") || !strings.Contains(local.text(), "request.stderr=") || + !strings.Contains(local.text(), "level=ERROR") { t.Fatalf("local handler not retained: %s", local.text()) } } @@ -92,13 +105,11 @@ func TestNodeLogBuffer(t *testing.T) { } var writers sync.WaitGroup for i := 0; 8 > i; i++ { - writers.Add(1) - go func() { - defer writers.Done() + writers.Go(func() { for i := 0; nodeLogCapacity > i; i++ { logger.Warn("concurrent") } - }() + }) } writers.Wait() buffer.close() diff --git a/internal/agent/operations.go b/internal/agent/operations.go index 2cbb5c3..7c160ff 100644 --- a/internal/agent/operations.go +++ b/internal/agent/operations.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "regexp" + "slices" "strings" "sync" "syscall" @@ -156,18 +157,34 @@ func (a *agent) startOperation(ctx context.Context, w http.ResponseWriter, name, return } code, reason := 500, "operation startup failed" - var apiErr *apiError - var engineErr *engine.Error - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[*apiError](err); ok { code, reason = apiErr.code, apiErr.message - } else if errors.As(err, &engineErr) { + } else if engineErr, ok := errors.AsType[*engine.Error](err); ok { code, reason = engineErr.Code, engineErr.Message } level := slog.LevelError if http.StatusConflict == code { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Automatic update could not start for stack %q, service %q (HTTP %d): %s", name, service, code, reason), "automatic", true, "stack", name, "service", service, "operation", id, "status", "rejected", "http_status", code, "reason", reason) + slog.Log( + ctx, + level, + fmt.Sprintf("Automatic update could not start for stack %q, service %q (HTTP %d): %s", name, service, code, reason), + "automatic", + true, + "stack", + name, + "service", + service, + "operation", + id, + "status", + "rejected", + "http_status", + code, + "reason", + reason, + ) }() switch action { case "deploy", "start", "stop", "restart", "pull", "update", "down", "delete": @@ -206,14 +223,7 @@ func (a *agent) startOperation(ctx context.Context, w http.ResponseWriter, name, if nil != err { return err } - found := false - for _, candidate := range services { - if service == candidate { - found = true - break - } - } - if !found { + if !slices.Contains(services, service) { return fault(400, "service is not defined in the Compose file") } } @@ -238,7 +248,7 @@ func (a *agent) startOperation(ctx context.Context, w http.ResponseWriter, name, return fmt.Errorf("relay ID: %w", err) } id = hex.EncodeToString(randomID[:]) - op = &operation{operationSnapshot: operationSnapshot{ID: id, Stack: name, Action: action, Status: "running"}, automatic: automatic, service: service} + op = &operation{ID: id, Stack: name, Action: action, Status: "running", automatic: automatic, service: service} if err := a.prepareDetached(op, base, service); nil != err { return err } @@ -291,7 +301,9 @@ func (a *agent) startOperation(ctx context.Context, w http.ResponseWriter, name, a.active-- delete(a.busy, name) a.setStackBusy(name, false) - a.publishRuntime(event("stack.action", protocol.StackAction{Name: name, Action: action, Phase: "exit", ExitCode: &code, Message: message})) + a.publishRuntime( + event("stack.action", protocol.StackAction{Name: name, Action: action, Phase: "exit", ExitCode: &code, Message: message}), + ) a.mu.Unlock() a.signalRuntime(name) }() @@ -373,7 +385,14 @@ func (a *agent) logAction(name, action, service string, automatic bool, status s level = slog.LevelError message = fmt.Sprintf("%s on node %q", failure, a.config.Global.NodeID) } - attrs := []slog.Attr{slog.Bool("automatic", automatic), slog.String("action", action), slog.String("stack", name), slog.String("service", service), slog.String("node", a.config.Global.NodeID), slog.String("status", status)} + attrs := []slog.Attr{ + slog.Bool("automatic", automatic), + slog.String("action", action), + slog.String("stack", name), + slog.String("service", service), + slog.String("node", a.config.Global.NodeID), + slog.String("status", status), + } if nil != code { attrs = append(attrs, slog.Int("exit_code", *code)) } @@ -408,7 +427,23 @@ func (a *agent) logOperationResult(op *operation) { message += fmt.Sprintf(", node %q", a.config.Global.NodeID) } message += fmt.Sprintf(" failed (exit code %d): %s", *snapshot.ExitCode, detail) - slog.Error(message, "operation", snapshot.ID, "action", snapshot.Action, "stack", snapshot.Stack, "service", op.service, "node", a.config.Global.NodeID, "status", snapshot.Status, "exit_code", *snapshot.ExitCode) + slog.Error( + message, + "operation", + snapshot.ID, + "action", + snapshot.Action, + "stack", + snapshot.Stack, + "service", + op.service, + "node", + a.config.Global.NodeID, + "status", + snapshot.Status, + "exit_code", + *snapshot.ExitCode, + ) } func (op *operation) logAutomatic() { @@ -438,7 +473,13 @@ func (op *operation) logAutomatic() { message += ": " + detail } } - attrs := []slog.Attr{slog.Bool("automatic", true), slog.String("stack", snapshot.Stack), slog.String("service", op.service), slog.String("operation", snapshot.ID), slog.String("status", snapshot.Status)} + attrs := []slog.Attr{ + slog.Bool("automatic", true), + slog.String("stack", snapshot.Stack), + slog.String("service", op.service), + slog.String("operation", snapshot.ID), + slog.String("status", snapshot.Status), + } if nil != snapshot.ExitCode { attrs = append(attrs, slog.Int("exit_code", *snapshot.ExitCode)) } diff --git a/internal/agent/operations_logging_test.go b/internal/agent/operations_logging_test.go index c2885e9..3de2acb 100644 --- a/internal/agent/operations_logging_test.go +++ b/internal/agent/operations_logging_test.go @@ -27,7 +27,7 @@ type operationLogRecord struct { func operationLogs(t *testing.T, logs *automaticLogBuffer) []operationLogRecord { t.Helper() var records []operationLogRecord - for _, line := range strings.Split(strings.TrimSpace(logs.text()), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(logs.text()), "\n") { if "" == line { continue } @@ -49,7 +49,10 @@ func checkFailureLog(t *testing.T, logs *automaticLogBuffer, a *agent, op *opera t.Fatalf("failure records: %+v", records) } record := records[0] - if "ERROR" != record.Level || op.ID != record.Operation || op.Action != record.Action || op.Stack != record.Stack || op.service != record.Service || a.config.Global.NodeID != record.Node || code != record.ExitCode { + if "ERROR" != record.Level || op.ID != record.Operation || op.Action != record.Action || op.Stack != record.Stack || + op.service != record.Service || + a.config.Global.NodeID != record.Node || + code != record.ExitCode { t.Fatalf("failure context: %+v", record) } for _, detail := range []string{op.ID, op.Action, op.Stack, op.service, a.config.Global.NodeID, fmt.Sprint(code), cause} { @@ -170,7 +173,7 @@ func TestManualLogBound(t *testing.T) { logs := captureAutomaticLogs(t) a, _ := executionFixture(t) cause := "registry.example: access denied" - op := &operation{operationSnapshot: operationSnapshot{ID: "manual", Stack: "demo", Action: "pull", Status: "running"}} + op := &operation{ID: "manual", Stack: "demo", Action: "pull", Status: "running"} _, _ = op.Write([]byte(strings.Repeat("progress €\r\n", 1000) + "\x1b[31m" + cause + "\x1b[0m\nError: exit status 7\n")) a.finishOperation(op, 7) checkFailureLog(t, logs, a, op, cause, 7) diff --git a/internal/agent/pairing.go b/internal/agent/pairing.go index ef53de7..3ce4dc2 100644 --- a/internal/agent/pairing.go +++ b/internal/agent/pairing.go @@ -27,7 +27,17 @@ func (a *agent) unpair(w http.ResponseWriter, r *http.Request) error { if "" == a.pairingToken { a.pairingToken = rand.Text() } - slog.Info(fmt.Sprintf("Reset pairing for node %q at the request of DCompose peer %s. The node is ready to pair again.", a.config.Global.NodeID, r.RemoteAddr), "node", a.config.Global.NodeID, "peer", r.RemoteAddr) + slog.Info( + fmt.Sprintf( + "Reset pairing for node %q at the request of DCompose peer %s. The node is ready to pair again.", + a.config.Global.NodeID, + r.RemoteAddr, + ), + "node", + a.config.Global.NodeID, + "peer", + r.RemoteAddr, + ) // Keep the pairing token local, outside forwarded node logs. fmt.Fprintf(os.Stderr, "TLS fingerprint: %s\n", fingerprint) fmt.Fprintf(os.Stderr, "Use this single-use pairing token in Nodes > Add node: %s\n", a.pairingToken) @@ -37,7 +47,10 @@ func (a *agent) unpair(w http.ResponseWriter, r *http.Request) error { func (a *agent) pair(w http.ResponseWriter, r *http.Request) error { w.Header().Set("Cache-Control", "no-store") - if supplied := r.Header.Values(protocol.Header); 0 != len(supplied) && (1 != len(supplied) || strconv.Itoa(protocol.AgentAPI) != supplied[0]) { + if supplied := r.Header.Values( + protocol.Header, + ); 0 != len(supplied) && + (1 != len(supplied) || strconv.Itoa(protocol.AgentAPI) != supplied[0]) { return fault(http.StatusUpgradeRequired, "Node version is incompatible. Update the node and server.") } var input struct { @@ -67,7 +80,15 @@ func (a *agent) pair(w http.ResponseWriter, r *http.Request) error { } a.config.Global.Paired = true a.pairingToken = "" - slog.Info(fmt.Sprintf("Paired node %q (%s) with DCompose peer %s.", name, a.config.Global.NodeID, r.RemoteAddr), "name", name, "node", a.config.Global.NodeID, "peer", r.RemoteAddr) + slog.Info( + fmt.Sprintf("Paired node %q (%s) with DCompose peer %s.", name, a.config.Global.NodeID, r.RemoteAddr), + "name", + name, + "node", + a.config.Global.NodeID, + "peer", + r.RemoteAddr, + ) respond(w, http.StatusOK, protocol.PairingResponse{ID: a.config.Global.NodeID, Name: name, Token: token, Fingerprint: fingerprint}) return nil } diff --git a/internal/agent/pairing_test.go b/internal/agent/pairing_test.go index a1fb10d..f507008 100644 --- a/internal/agent/pairing_test.go +++ b/internal/agent/pairing_test.go @@ -37,7 +37,11 @@ func TestPairing(t *testing.T) { if "" == token || "test-secret" == token { t.Fatal("missing temporary pairing token") } - if w := pairRequest(a, "wrong"); 401 != w.Code || token != a.pairingToken || a.config.Global.Paired || strings.Contains(w.Body.String(), token) { + if w := pairRequest( + a, + "wrong", + ); 401 != w.Code || token != a.pairingToken || a.config.Global.Paired || + strings.Contains(w.Body.String(), token) { t.Fatal(w.Code, w.Body.String()) } w := pairRequest(a, token) @@ -46,7 +50,9 @@ func TestPairing(t *testing.T) { t.Fatal(w.Code, w.Body.String(), err) } _, fingerprint, err := connectionInfo(c, path) - if nil != err || c.Global.NodeID != paired.ID || "" == paired.Name || "test-secret" != paired.Token || fingerprint != paired.Fingerprint || "no-store" != w.Header().Get("Cache-Control") { + if nil != err || c.Global.NodeID != paired.ID || "" == paired.Name || "test-secret" != paired.Token || + fingerprint != paired.Fingerprint || + "no-store" != w.Header().Get("Cache-Control") { t.Fatal("incorrect exchanged credentials", err) } if w := pairRequest(a, token); 409 != w.Code { @@ -74,7 +80,11 @@ func TestPairingSaveFailure(t *testing.T) { var response struct { Error string `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 500 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), c.path) || strings.Contains(w.Body.String(), token) || token != a.pairingToken || a.config.Global.Paired { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 500 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), c.path) || strings.Contains(w.Body.String(), token) || token != a.pairingToken || + a.config.Global.Paired { t.Fatal("failed save consumed pairing token", w.Code, w.Body.String()) } if !strings.Contains(logs.String(), c.path) { @@ -91,7 +101,11 @@ func TestPairingJSON(t *testing.T) { var response struct { Error string `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 400 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), "internal_field") || strings.Contains(w.Body.String(), "secret") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 400 != w.Code || "" == response.Error || strings.Contains(w.Body.String(), "internal_field") || + strings.Contains(w.Body.String(), "secret") { t.Fatal("decoder details exposed", w.Code, w.Body.String()) } } @@ -128,11 +142,14 @@ func TestUnpair(t *testing.T) { logs := captureAutomaticLogs(t) w := request(a, http.MethodPost, "/unpair", "") token := a.pairingToken - if http.StatusOK != w.Code || a.config.Global.Paired || "" == token || previous == token || "no-store" != w.Header().Get("Cache-Control") { + if http.StatusOK != w.Code || a.config.Global.Paired || "" == token || previous == token || + "no-store" != w.Header().Get("Cache-Control") { t.Fatal("node did not return to pairing mode", w.Code, w.Body.String()) } local, err := os.ReadFile(output.Name()) - if nil != err || !strings.Contains(string(local), token) || strings.Contains(logs.text(), token) || strings.Contains(w.Body.String(), token) || strings.Contains(w.Body.String(), "test-secret") { + if nil != err || !strings.Contains(string(local), token) || strings.Contains(logs.text(), token) || + strings.Contains(w.Body.String(), token) || + strings.Contains(w.Body.String(), "test-secret") { t.Fatal("pairing token was not restricted to local output", err) } _, fingerprint, err := connectionInfo(a.config, a.config.path) @@ -140,7 +157,9 @@ func TestUnpair(t *testing.T) { t.Fatal("pairing output did not include the node fingerprint", err) } reloaded, err := loadConfig(a.config.path) - if nil != err || reloaded.Global.Paired || reloaded.Global.NodeID != a.config.Global.NodeID || reloaded.Auth.Token != a.config.Auth.Token || "" == newAgent(context.Background(), reloaded).pairingToken { + if nil != err || reloaded.Global.Paired || reloaded.Global.NodeID != a.config.Global.NodeID || + reloaded.Auth.Token != a.config.Auth.Token || + "" == newAgent(context.Background(), reloaded).pairingToken { t.Fatal("pairing reset was not persisted", err) } if _, err := os.Stat(filepath.Join(fake, "argv")); !os.IsNotExist(err) { @@ -191,7 +210,8 @@ func TestUnpairSaveFailure(t *testing.T) { path := a.config.path a.config.path = filepath.Join(filepath.Dir(path), "missing.toml") w := request(a, http.MethodPost, "/unpair", "") - if http.StatusInternalServerError != w.Code || !a.config.Global.Paired || "" != a.pairingToken || strings.Contains(w.Body.String(), a.config.path) { + if http.StatusInternalServerError != w.Code || !a.config.Global.Paired || "" != a.pairingToken || + strings.Contains(w.Body.String(), a.config.path) { t.Fatal("failed save changed pairing", w.Code, w.Body.String()) } reloaded, err := loadConfig(path) diff --git a/internal/agent/process.go b/internal/agent/process.go index b130423..a4b74ef 100644 --- a/internal/agent/process.go +++ b/internal/agent/process.go @@ -52,8 +52,8 @@ func runTerminalCommand(ctx context.Context, cmd *exec.Cmd, output io.Writer) er if nil != err { return err } - defer master.Close() - defer slave.Close() + defer func() { _ = master.Close() }() + defer func() { _ = slave.Close() }() if err := pty.Setsize(master, &pty.Winsize{Rows: 30, Cols: 120}); nil != err { return err } diff --git a/internal/agent/registry.go b/internal/agent/registry.go index 0cf321d..7a0718f 100644 --- a/internal/agent/registry.go +++ b/internal/agent/registry.go @@ -88,7 +88,7 @@ func (a *agent) applyRegistries(snapshot protocol.RegistrySync) error { if err := os.Symlink(filepath.Base(dir), link); nil != err { return errors.New("could not publish managed registry snapshot") } - defer os.Remove(link) + defer func() { _ = os.Remove(link) }() previous, _ := os.Readlink(filepath.Join(root, "current")) if err := os.Rename(link, filepath.Join(root, "current")); nil != err { return errors.New("could not publish managed registry snapshot") diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go index 4a55195..707c9d2 100644 --- a/internal/agent/registry_test.go +++ b/internal/agent/registry_test.go @@ -32,7 +32,13 @@ func applyRegistryFixture(t *testing.T, a *agent) { func registrySnapshot() protocol.RegistrySync { return protocol.RegistrySync{Revision: "revision-1", Registries: []protocol.RegistryCredential{ - {ID: "background", Registry: "docker.io", Label: "Checks", Auth: loginAuth("background", "background-secret"), BackgroundChecks: true}, + { + ID: "background", + Registry: "docker.io", + Label: "Checks", + Auth: loginAuth("background", "background-secret"), + BackgroundChecks: true, + }, {ID: "pull", Registry: "docker.io", Auth: loginAuth("pull", "pull-secret")}, {ID: "token", Registry: "git.example.test", Auth: map[string]string{"identitytoken": "identity-secret"}}, }} @@ -107,9 +113,26 @@ func TestRegistryViews(t *testing.T) { func TestRegistryKeys(t *testing.T) { credentials := []protocol.RegistryCredential{ {ID: "hub", Registry: "https://index.docker.io/v1/", Auth: loginAuth("hub", "hub-secret"), BackgroundChecks: true}, - {ID: "access", Registry: "https://index.docker.io/v1/access-token", Auth: map[string]string{"identitytoken": "access-secret"}, BackgroundChecks: true}, + { + ID: "access", + Registry: "https://index.docker.io/v1/access-token", + Auth: map[string]string{"identitytoken": "access-secret"}, + BackgroundChecks: true, + }, {ID: "refresh", Registry: "https://index.docker.io/v1/refresh-token", Auth: map[string]string{"registrytoken": "refresh-secret"}}, - {ID: "git", Registry: "git.example.test", Auth: map[string]string{"auth": "original-auth", "username": "git", "password": "git-secret", "email": "git@example.com", "serveraddress": "git.example.test", "identitytoken": "identity", "registrytoken": "bearer"}}, + { + ID: "git", + Registry: "git.example.test", + Auth: map[string]string{ + "auth": "original-auth", + "username": "git", + "password": "git-secret", + "email": "git@example.com", + "serveraddress": "git.example.test", + "identitytoken": "identity", + "registrytoken": "bearer", + }, + }, } background, pull, err := registryConfigs(credentials) if nil != err || 2 != len(background.Auths) || 4 != len(pull.Auths) { @@ -142,13 +165,21 @@ func TestRegistryKeys(t *testing.T) { if nil != err || nil != json.Unmarshal(data, &restored) || !maps.Equal(credentials[3].Auth, restored.Registries[3].Auth) { t.Fatal("private snapshot changed native auth") } - if "access-secret" != pull.Auths[credentials[1].Registry]["identitytoken"] || "refresh-secret" != pull.Auths[credentials[2].Registry]["registrytoken"] { + if "access-secret" != pull.Auths[credentials[1].Registry]["identitytoken"] || + "refresh-secret" != pull.Auths[credentials[2].Registry]["registrytoken"] { t.Fatal("native token representation was lost") } } func TestRegistryNativeLogin(t *testing.T) { - auth := map[string]string{"username": "native", "password": "password:token", "email": "native@example.com", "serveraddress": "native.example", "identitytoken": "identity", "registrytoken": "bearer"} + auth := map[string]string{ + "username": "native", + "password": "password:token", + "email": "native@example.com", + "serveraddress": "native.example", + "identitytoken": "identity", + "registrytoken": "bearer", + } credentials := []protocol.RegistryCredential{{ID: "native", Registry: "native.example", Auth: auth, BackgroundChecks: true}} background, pull, err := registryConfigs(credentials) if nil != err { @@ -192,7 +223,10 @@ func TestRegistryPersistence(t *testing.T) { if err := restarted.initRegistries(dir); nil != err || a.pullConfigDir() != restarted.pullConfigDir() { t.Fatalf("restore: %v", err) } - if !maps.Equal(readDockerConfig(t, filepath.Join(restarted.pullConfigDir(), "config.json")).Auths["https://index.docker.io/v1/"], loginAuth("pull", "pull-secret")) { + if !maps.Equal( + readDockerConfig(t, filepath.Join(restarted.pullConfigDir(), "config.json")).Auths["https://index.docker.io/v1/"], + loginAuth("pull", "pull-secret"), + ) { t.Fatal("restart lost managed pull credentials") } if err := a.applyRegistries(protocol.RegistrySync{Revision: "empty", Registries: []protocol.RegistryCredential{}}); nil != err { @@ -238,7 +272,8 @@ func TestRegistryChannel(t *testing.T) { } if valid { var applied protocol.RegistryApplied - if "registry.applied" != reply.Type || nil != json.Unmarshal(reply.Payload, &applied) || snapshot.Revision != applied.Revision { + if "registry.applied" != reply.Type || nil != json.Unmarshal(reply.Payload, &applied) || + snapshot.Revision != applied.Revision { t.Fatalf("ack: %+v", reply) } if 0 == len(readDockerConfig(t, filepath.Join(a.pullConfigDir(), "config.json")).Auths) { diff --git a/internal/agent/registry_transport.go b/internal/agent/registry_transport.go index d3fe54c..a7d549c 100644 --- a/internal/agent/registry_transport.go +++ b/internal/agent/registry_transport.go @@ -55,14 +55,17 @@ func relayRegistry(ctx context.Context, input protocol.RegistryRequest, transpor } return protocol.RegistryResponse{Error: registryTransportError(err, input)}, nil } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() output := protocol.RegistryResponse{Status: response.StatusCode, Header: response.Header.Clone()} if http.MethodHead == input.Method { return output, nil } output.Body, err = io.ReadAll(io.LimitReader(response.Body, registryResponseLimit+1)) if nil != err { - return protocol.RegistryResponse{}, fault(http.StatusBadGateway, "Could not read registry response: "+registryTransportError(err, input)) + return protocol.RegistryResponse{}, fault( + http.StatusBadGateway, + "Could not read registry response: "+registryTransportError(err, input), + ) } if registryResponseLimit < len(output.Body) { return protocol.RegistryResponse{}, fault(http.StatusBadGateway, "Registry response body exceeds 8 MiB.") @@ -72,8 +75,7 @@ func relayRegistry(ctx context.Context, input protocol.RegistryRequest, transpor // Remove URL wrappers and request secrets before a transport failure leaves the node. func registryTransportError(err error, input protocol.RegistryRequest) string { - var addressError *url.Error - if errors.As(err, &addressError) { + if addressError, ok := errors.AsType[*url.Error](err); ok { err = addressError.Err } message := err.Error() diff --git a/internal/agent/registry_transport_test.go b/internal/agent/registry_transport_test.go index 4cbf1e3..1b7dc91 100644 --- a/internal/agent/registry_transport_test.go +++ b/internal/agent/registry_transport_test.go @@ -24,7 +24,8 @@ func TestRegistryRelay(t *testing.T) { t.Run(method, func(t *testing.T) { fixture := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) - if method != r.Method || "Bearer registry-token" != r.Header.Get("Authorization") || "application/json" != r.Header.Get("Accept") { + if method != r.Method || "Bearer registry-token" != r.Header.Get("Authorization") || + "application/json" != r.Header.Get("Accept") { t.Errorf("registry request: method=%s headers=%v", r.Method, r.Header) } if http.MethodPost == method && "refresh_token=secret" != string(body) { @@ -37,12 +38,17 @@ func TestRegistryRelay(t *testing.T) { _, _ = io.WriteString(w, "registry limit") })) defer fixture.Close() - input := protocol.RegistryRequest{Method: method, URL: fixture.URL + "/v2/repo/manifests/latest", Header: http.Header{"Authorization": {"Bearer registry-token"}, "Accept": {"application/json"}}} + input := protocol.RegistryRequest{ + Method: method, + URL: fixture.URL + "/v2/repo/manifests/latest", + Header: http.Header{"Authorization": {"Bearer registry-token"}, "Accept": {"application/json"}}, + } if http.MethodPost == method { input.Body = []byte("refresh_token=secret") } output, err := relayRegistry(context.Background(), input, fixture.Client().Transport) - if nil != err || "" != output.Error || 429 != output.Status || "120" != output.Header.Get("Retry-After") || 2 != len(output.Header.Values("WWW-Authenticate")) { + if nil != err || "" != output.Error || 429 != output.Status || "120" != output.Header.Get("Retry-After") || + 2 != len(output.Header.Values("WWW-Authenticate")) { t.Fatalf("registry response: %+v error=%v", output, err) } if http.MethodHead == method { @@ -62,11 +68,19 @@ func TestRegistryRelayRedirect(t *testing.T) { w.WriteHeader(http.StatusFound) })) defer fixture.Close() - output, err := relayRegistry(context.Background(), protocol.RegistryRequest{Method: http.MethodGet, URL: fixture.URL}, fixture.Client().Transport) + output, err := relayRegistry( + context.Background(), + protocol.RegistryRequest{Method: http.MethodGet, URL: fixture.URL}, + fixture.Client().Transport, + ) if nil != err || 302 != output.Status || "https://must-not-be-contacted.invalid/token" != output.Header.Get("Location") { t.Fatalf("redirect was not preserved: %+v %v", output, err) } - output, err = relayRegistry(context.Background(), protocol.RegistryRequest{Method: http.MethodGet, URL: fixture.URL}, http.DefaultTransport) + output, err = relayRegistry( + context.Background(), + protocol.RegistryRequest{Method: http.MethodGet, URL: fixture.URL}, + http.DefaultTransport, + ) if nil != err || "" == output.Error || 0 != output.Status { t.Fatalf("untrusted TLS accepted: %+v %v", output, err) } @@ -90,7 +104,12 @@ func TestRegistryRelayValidation(t *testing.T) { } func TestRegistryRelayFailure(t *testing.T) { - input := protocol.RegistryRequest{Method: http.MethodPost, URL: "https://registry.example/token?account=query-secret", Header: http.Header{"Authorization": {"Bearer header-secret"}}, Body: []byte("refresh_token=body-secret")} + input := protocol.RegistryRequest{ + Method: http.MethodPost, + URL: "https://registry.example/token?account=query-secret", + Header: http.Header{"Authorization": {"Bearer header-secret"}}, + Body: []byte("refresh_token=body-secret"), + } transport := registryRelayRoundTripper(func(*http.Request) (*http.Response, error) { return nil, &url.Error{Op: "Post", URL: input.URL, Err: errors.New("connection refused: query-secret header-secret body-secret")} }) @@ -116,9 +135,17 @@ func TestRegistryRelayFailure(t *testing.T) { func TestRegistryRelayBounds(t *testing.T) { transport := registryRelayRoundTripper(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(strings.Repeat("x", registryResponseLimit+1)))}, nil + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", registryResponseLimit+1))), + }, nil }) - _, err := relayRegistry(context.Background(), protocol.RegistryRequest{Method: http.MethodGet, URL: "https://registry.example"}, transport) + _, err := relayRegistry( + context.Background(), + protocol.RegistryRequest{Method: http.MethodGet, URL: "https://registry.example"}, + transport, + ) var failure *apiError if !errors.As(err, &failure) || http.StatusBadGateway != failure.code { t.Fatalf("oversized response: %v", err) diff --git a/internal/agent/run.go b/internal/agent/run.go index fdba696..3082933 100644 --- a/internal/agent/run.go +++ b/internal/agent/run.go @@ -43,7 +43,11 @@ func Run(parent context.Context, configPath, buildVersion string) error { return err } if cacheDir, err := os.UserCacheDir(); nil != err { - slog.Warn(fmt.Sprintf("Could not locate the node operation storage directory: %v. Detached stack operations are unavailable.", err), "error", err) + slog.Warn( + fmt.Sprintf("Could not locate the node operation storage directory: %v. Detached stack operations are unavailable.", err), + "error", + err, + ) a.initDetached("") } else { a.initDetached(cacheDir) @@ -63,7 +67,13 @@ func Run(parent context.Context, configPath, buildVersion string) error { shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdown); nil != err { - slog.Warn(fmt.Sprintf("Could not finish node shutdown at %s: %v. Closing active connections.", c.Global.Listen, err), "address", c.Global.Listen, "error", err) + slog.Warn( + fmt.Sprintf("Could not finish node shutdown at %s: %v. Closing active connections.", c.Global.Listen, err), + "address", + c.Global.Listen, + "error", + err, + ) _ = server.Close() } }() @@ -71,7 +81,23 @@ func Run(parent context.Context, configPath, buildVersion string) error { if "" != a.pairingToken { fmt.Fprintf(os.Stderr, "Use this single-use pairing token in Nodes > Add node: %s\n", a.pairingToken) } - slog.Info(fmt.Sprintf("Starting DCompose node %s at https://%s with stacks in %q and configuration %q.", version, c.Global.Listen, c.Global.StacksDir, configPath), "version", version, "address", "https://"+c.Global.Listen, "stacks_dir", c.Global.StacksDir, "config", configPath) + slog.Info( + fmt.Sprintf( + "Starting DCompose node %s at https://%s with stacks in %q and configuration %q.", + version, + c.Global.Listen, + c.Global.StacksDir, + configPath, + ), + "version", + version, + "address", + "https://"+c.Global.Listen, + "stacks_dir", + c.Global.StacksDir, + "config", + configPath, + ) err = server.ListenAndServeTLS(c.TLS.CertFile, c.TLS.KeyFile) // Also stop background work when bind/TLS startup fails. stop() diff --git a/internal/agent/shells.go b/internal/agent/shells.go index 13b3300..7b25f8e 100644 --- a/internal/agent/shells.go +++ b/internal/agent/shells.go @@ -76,15 +76,49 @@ func (a *agent) createShell(ctx context.Context, w http.ResponseWriter, r *http. if err := decodeBody(r, &input); nil != err { return err } - if nil == input.Shell || nil == input.Cols || nil == input.Rows || ("sh" != *input.Shell && "bash" != *input.Shell && "ash" != *input.Shell && "dash" != *input.Shell) || !shellSize(*input.Cols, *input.Rows) { + if nil == input.Shell || nil == input.Cols || nil == input.Rows || + ("sh" != *input.Shell && "bash" != *input.Shell && "ash" != *input.Shell && "dash" != *input.Shell) || + !shellSize(*input.Cols, *input.Rows) { return fault(400, "shell must be sh, bash, ash or dash. The cols and rows values must each be between 1 and 500") } container, err := a.resolveShellContainer(ctx, stack, input.Container) if nil != err { if context.Canceled == ctx.Err() { - slog.Debug(fmt.Sprintf("Shell lookup for container %q in stack %q requested by %s was canceled: %v", input.Container, stack, r.RemoteAddr, err), "container", input.Container, "stack", stack, "peer", r.RemoteAddr, "error", err) + slog.Debug( + fmt.Sprintf( + "Shell lookup for container %q in stack %q requested by %s was canceled: %v", + input.Container, + stack, + r.RemoteAddr, + err, + ), + "container", + input.Container, + "stack", + stack, + "peer", + r.RemoteAddr, + "error", + err, + ) } else { - slog.Error(fmt.Sprintf("Could not resolve shell container %q in stack %q requested by %s: %v", input.Container, stack, r.RemoteAddr, err), "container", input.Container, "stack", stack, "peer", r.RemoteAddr, "error", err) + slog.Error( + fmt.Sprintf( + "Could not resolve shell container %q in stack %q requested by %s: %v", + input.Container, + stack, + r.RemoteAddr, + err, + ), + "container", + input.Container, + "stack", + stack, + "peer", + r.RemoteAddr, + "error", + err, + ) } return err } @@ -94,7 +128,7 @@ func (a *agent) createShell(ctx context.Context, w http.ResponseWriter, r *http. } id := hex.EncodeToString(random[:]) s := &shellSession{ - op: &operation{operationSnapshot: operationSnapshot{ID: id, Stack: stack, Action: "shell", Status: "running"}, retainTail: true}, + op: &operation{ID: id, Stack: stack, Action: "shell", Status: "running", retainTail: true}, container: container, shell: *input.Shell, lastActivity: time.Now(), close: make(chan struct{}), ready: make(chan struct{}), done: make(chan struct{}), } @@ -126,7 +160,19 @@ func (a *agent) createShell(ctx context.Context, w http.ResponseWriter, r *http. go a.runShell(s, *input.Cols, *input.Rows) select { case <-s.ready: - slog.Debug(fmt.Sprintf("Shell %s running %s in container %q of stack %q is ready for %s.", id, s.shell, container, stack, r.RemoteAddr), "shell", id, "stack", stack, "container", container, "command", s.shell, "peer", r.RemoteAddr) + slog.Debug( + fmt.Sprintf("Shell %s running %s in container %q of stack %q is ready for %s.", id, s.shell, container, stack, r.RemoteAddr), + "shell", + id, + "stack", + stack, + "container", + container, + "command", + s.shell, + "peer", + r.RemoteAddr, + ) respond(w, 201, struct { ID string `json:"id"` Stack string `json:"stack"` @@ -139,9 +185,47 @@ func (a *agent) createShell(ctx context.Context, w http.ResponseWriter, r *http. case <-ctx.Done(): s.requestClose() if context.Canceled == ctx.Err() { - slog.Debug(fmt.Sprintf("Startup of shell %s in container %q of stack %q was canceled by DCompose peer %s: %v", id, container, stack, r.RemoteAddr, ctx.Err()), "peer", r.RemoteAddr, "shell", id, "stack", stack, "container", container, "error", ctx.Err()) + slog.Debug( + fmt.Sprintf( + "Startup of shell %s in container %q of stack %q was canceled by DCompose peer %s: %v", + id, + container, + stack, + r.RemoteAddr, + ctx.Err(), + ), + "peer", + r.RemoteAddr, + "shell", + id, + "stack", + stack, + "container", + container, + "error", + ctx.Err(), + ) } else { - slog.Error(fmt.Sprintf("Startup of shell %s in container %q of stack %q for %s timed out: %v", id, container, stack, r.RemoteAddr, ctx.Err()), "peer", r.RemoteAddr, "shell", id, "stack", stack, "container", container, "error", ctx.Err()) + slog.Error( + fmt.Sprintf( + "Startup of shell %s in container %q of stack %q for %s timed out: %v", + id, + container, + stack, + r.RemoteAddr, + ctx.Err(), + ), + "peer", + r.RemoteAddr, + "shell", + id, + "stack", + stack, + "container", + container, + "error", + ctx.Err(), + ) } return fault(502, "shell startup canceled") } @@ -187,11 +271,37 @@ func (s *shellSession) writeInput(data string) error { } s.lastActivity = time.Now() if err := s.terminal.SetWriteDeadline(time.Now().Add(2 * time.Second)); nil != err { - slog.Error(fmt.Sprintf("Could not set the input deadline for shell %s in container %q of stack %q: %v", s.op.ID, s.container, s.op.Stack, err), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "error", err) + slog.Error( + fmt.Sprintf( + "Could not set the input deadline for shell %s in container %q of stack %q: %v", + s.op.ID, + s.container, + s.op.Stack, + err, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "error", + err, + ) return fault(502, "shell input failed") } if _, err := io.WriteString(s.terminal, data); nil != err { - slog.Error(fmt.Sprintf("Could not write input to shell %s in container %q of stack %q: %v", s.op.ID, s.container, s.op.Stack, err), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "error", err) + slog.Error( + fmt.Sprintf("Could not write input to shell %s in container %q of stack %q: %v", s.op.ID, s.container, s.op.Stack, err), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "error", + err, + ) return fault(502, "shell input failed") } return nil @@ -208,7 +318,29 @@ func (s *shellSession) resize(cols, rows int) error { } s.lastActivity = time.Now() if err := pty.Setsize(s.resizePTY, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}); nil != err { - slog.Error(fmt.Sprintf("Could not resize shell %s in container %q of stack %q to %d columns by %d rows: %v", s.op.ID, s.container, s.op.Stack, cols, rows, err), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "cols", cols, "rows", rows, "error", err) + slog.Error( + fmt.Sprintf( + "Could not resize shell %s in container %q of stack %q to %d columns by %d rows: %v", + s.op.ID, + s.container, + s.op.Stack, + cols, + rows, + err, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "cols", + cols, + "rows", + rows, + "error", + err, + ) return fault(502, "shell resize failed") } return nil @@ -254,9 +386,38 @@ func (a *agent) retryShellCleanup(s *shellSession) error { s.cleanupErr = err s.mu.Unlock() if nil != err { - slog.Error(fmt.Sprintf("Could not clean up processes for shell %s in container %q of stack %q after retrying: %v", s.op.ID, s.container, s.op.Stack, err), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "error", err) + slog.Error( + fmt.Sprintf( + "Could not clean up processes for shell %s in container %q of stack %q after retrying: %v", + s.op.ID, + s.container, + s.op.Stack, + err, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "error", + err, + ) } else { - slog.Debug(fmt.Sprintf("Cleaned up processes for shell %s in container %q of stack %q after retrying.", s.op.ID, s.container, s.op.Stack), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container) + slog.Debug( + fmt.Sprintf( + "Cleaned up processes for shell %s in container %q of stack %q after retrying.", + s.op.ID, + s.container, + s.op.Stack, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + ) } } return err @@ -283,11 +444,68 @@ func (a *agent) runShell(s *shellSession, cols, rows int) { s.op.publish(event("operation.done", s.op.operationSnapshot)) s.op.mu.Unlock() if 0 == code { - slog.Debug(fmt.Sprintf("Shell %s in container %q of stack %q stopped with exit code %d: %s", s.op.ID, s.container, s.op.Stack, code, reason), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "exit_code", code, "reason", reason) + slog.Debug( + fmt.Sprintf( + "Shell %s in container %q of stack %q stopped with exit code %d: %s", + s.op.ID, + s.container, + s.op.Stack, + code, + reason, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "exit_code", + code, + "reason", + reason, + ) } else if nil != a.ctx.Err() && nil == cleanupErr { - slog.Debug(fmt.Sprintf("Shell %s in container %q of stack %q stopped during node shutdown with exit code %d: %s", s.op.ID, s.container, s.op.Stack, code, reason), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "exit_code", code, "reason", reason) + slog.Debug( + fmt.Sprintf( + "Shell %s in container %q of stack %q stopped during node shutdown with exit code %d: %s", + s.op.ID, + s.container, + s.op.Stack, + code, + reason, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "exit_code", + code, + "reason", + reason, + ) } else { - slog.Error(fmt.Sprintf("Shell %s in container %q of stack %q stopped with exit code %d: %s", s.op.ID, s.container, s.op.Stack, code, reason), "shell", s.op.ID, "stack", s.op.Stack, "container", s.container, "exit_code", code, "reason", reason) + slog.Error( + fmt.Sprintf( + "Shell %s in container %q of stack %q stopped with exit code %d: %s", + s.op.ID, + s.container, + s.op.Stack, + code, + reason, + ), + "shell", + s.op.ID, + "stack", + s.op.Stack, + "container", + s.container, + "exit_code", + code, + "reason", + reason, + ) } }() master, slave, err := shellTerminal() @@ -295,8 +513,8 @@ func (a *agent) runShell(s *shellSession, cols, rows int) { reason = "could not create the shell terminal: " + err.Error() return } - defer master.Close() - defer slave.Close() + defer func() { _ = master.Close() }() + defer func() { _ = slave.Close() }() // pty.Setsize calls File.Fd(), which switches pollable files back to // blocking mode. Use the slave for resize; the master must stay pollable. if err := pty.Setsize(slave, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}); nil != err { @@ -398,7 +616,7 @@ func (a *agent) runShell(s *shellSession, cols, rows int) { } _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) if nil != waited { - <-waited + _ = <-waited } _ = slave.Close() select { diff --git a/internal/agent/shells_test.go b/internal/agent/shells_test.go index 1463734..557c9a9 100644 --- a/internal/agent/shells_test.go +++ b/internal/agent/shells_test.go @@ -30,7 +30,12 @@ func shellFixture(t *testing.T) (*agent, string) { } func shellInspect(stack string, running bool) string { - return fmt.Sprintf(`[{"Id":%q,"State":{"Running":%t},"Config":{"Labels":{"com.docker.compose.project":%q}}}]`, strings.Repeat("a", 64), running, stack) + return fmt.Sprintf( + `[{"Id":%q,"State":{"Running":%t},"Config":{"Labels":{"com.docker.compose.project":%q}}}]`, + strings.Repeat("a", 64), + running, + stack, + ) } func createdShell(t *testing.T, a *agent, body string) *shellSession { @@ -87,7 +92,10 @@ func TestShellTerminal(t *testing.T) { t.Fatal("readiness marker leaked") } argv, _ := os.ReadFile(filepath.Join(fake, "argv")) - if !strings.Contains(string(argv), "<-it><--detach-keys=><--env><--env><"+s.container+"><-c>") { + if !strings.Contains( + string(argv), + "<-it><--detach-keys=><--env><--env><"+s.container+"><-c>", + ) { t.Fatalf("exec not pinned to ID: %s", argv) } for i := 0; 2 > i; i++ { @@ -295,12 +303,13 @@ func TestShellNaturalExit(t *testing.T) { s := createdShell(t, a, `{"container":"web"}`) shellInput(t, a, s, "printf 'final-output\\n'; exit 7\n") eventually(t, func() bool { return "running" != s.op.snapshot().Status }) - if op := s.op.snapshot(); "failed" != op.Status || nil == op.ExitCode || 7 != *op.ExitCode || !strings.Contains(op.Output, "final-output") { + if op := s.op.snapshot(); "failed" != op.Status || nil == op.ExitCode || 7 != *op.ExitCode || + !strings.Contains(op.Output, "final-output") { t.Fatalf("shell exit lost: %+v", op) } <-s.done var stopped bool - for _, line := range strings.Split(strings.TrimSpace(logs.text()), "\n") { + for line := range strings.SplitSeq(strings.TrimSpace(logs.text()), "\n") { var record struct { Level, Shell, Stack, Container, Reason string Message string `json:"msg"` @@ -332,8 +341,8 @@ func TestShellInputDeadline(t *testing.T) { if nil != err { t.Fatal(err) } - defer master.Close() - defer slave.Close() + defer func() { _ = master.Close() }() + defer func() { _ = slave.Close() }() if err := pty.Setsize(slave, &pty.Winsize{Cols: 90, Rows: 30}); nil != err { t.Fatal(err) } @@ -354,13 +363,13 @@ func TestShellInputDeadline(t *testing.T) { } case <-time.After(time.Second): _ = slave.Close() - <-done + _ = <-done t.Fatal("blocked shell input ignored write deadline") } } func TestShellOutputTail(t *testing.T) { - op := &operation{operationSnapshot: operationSnapshot{ID: "shell", Action: "shell", Status: "running"}, retainTail: true} + op := &operation{ID: "shell", Action: "shell", Status: "running", retainTail: true} s := newSubscriber() op.subscribers = map[*eventSubscriber]struct{}{s: {}} _, _ = op.Write([]byte("old-prefix" + strings.Repeat("€", (1<<20)/3))) @@ -375,7 +384,8 @@ func TestShellOutputTail(t *testing.T) { t.Fatal("live output stopped at cap") } output := op.snapshot().Output - if 1<<20 < len(output) || !utf8.ValidString(output) || strings.HasPrefix(output, "old-prefix") || !strings.HasSuffix(output, "new-output") { + if 1<<20 < len(output) || !utf8.ValidString(output) || strings.HasPrefix(output, "old-prefix") || + !strings.HasSuffix(output, "new-output") { t.Fatal("shell snapshot is not bounded UTF-8 suffix") } } diff --git a/internal/agent/source_messages_test.go b/internal/agent/source_messages_test.go index 6a5a4ec..d3ded66 100644 --- a/internal/agent/source_messages_test.go +++ b/internal/agent/source_messages_test.go @@ -65,7 +65,8 @@ func TestEventFailureMessage(t *testing.T) { if err := json.Unmarshal([]byte(logs.text()), &record); nil != err { t.Fatal(err) } - if "WARN" != record.Level || "docker" != record.Runtime || "exit status 8" != record.Error || "permission denied for event socket" != record.Stderr { + if "WARN" != record.Level || "docker" != record.Runtime || "exit status 8" != record.Error || + "permission denied for event socket" != record.Stderr { t.Fatalf("event fields: %+v", record) } for _, detail := range []string{record.Runtime, record.Error, record.Stderr, "2s"} { @@ -84,7 +85,7 @@ func TestShellInputMessage(t *testing.T) { if err := file.Close(); nil != err { t.Fatal(err) } - s := &shellSession{op: &operation{operationSnapshot: operationSnapshot{ID: "shell-42", Stack: "demo"}}, container: "demo-web", terminal: file} + s := &shellSession{op: &operation{ID: "shell-42", Stack: "demo"}, container: "demo-web", terminal: file} if err := s.writeInput("private-shell-input"); nil == err { t.Fatal("closed terminal accepted input") } @@ -95,7 +96,8 @@ func TestShellInputMessage(t *testing.T) { if err := json.Unmarshal([]byte(logs.text()), &record); nil != err { t.Fatal(err) } - if "ERROR" != record.Level || s.op.ID != record.Shell || s.op.Stack != record.Stack || s.container != record.Container || "" == record.Error { + if "ERROR" != record.Level || s.op.ID != record.Shell || s.op.Stack != record.Stack || s.container != record.Container || + "" == record.Error { t.Fatalf("shell input fields: %+v", record) } for _, detail := range []string{record.Shell, record.Stack, record.Container, record.Error} { diff --git a/internal/configutil/config.go b/internal/configutil/config.go index 74a4ada..7863359 100644 --- a/internal/configutil/config.go +++ b/internal/configutil/config.go @@ -73,8 +73,8 @@ func Resolve(value, configPath string) (string, error) { return "", fmt.Errorf("read secret file: %w", err) } result := string(data) - if strings.HasSuffix(result, "\r\n") { - result = strings.TrimSuffix(result, "\r\n") + if trimmed, ok := strings.CutSuffix(result, "\r\n"); ok { + result = trimmed } else if strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\r") { result = result[:len(result)-1] } @@ -94,8 +94,8 @@ func Write(path string, data []byte) error { if nil != err { return err } - defer os.Remove(file.Name()) - defer file.Close() + defer func() { _ = os.Remove(file.Name()) }() + defer func() { _ = file.Close() }() if _, err := file.Write(data); nil != err { return err } diff --git a/internal/engine/compose/compose.go b/internal/engine/compose/compose.go index 32e06bf..1be1995 100644 --- a/internal/engine/compose/compose.go +++ b/internal/engine/compose/compose.go @@ -42,7 +42,11 @@ func (c *CLI) Validate(ctx context.Context, s engine.Stack) error { // Do not resolve service env_file entries before staged files are installed. stdout, stderr, err := c.run(ctx, c.command(s, "config", "--quiet", "--no-env-resolution", "--services")) if nil != err { - return &engine.Error{Code: 400, Message: "Compose validation failed. Check the Compose and .env files.", Cause: fmt.Errorf("compose validation failed: %w: %s%s", err, stdout, stderr)} + return &engine.Error{ + Code: 400, + Message: "Compose validation failed. Check the Compose and .env files.", + Cause: fmt.Errorf("compose validation failed: %w: %s%s", err, stdout, stderr), + } } return nil } @@ -50,7 +54,11 @@ func (c *CLI) Validate(ctx context.Context, s engine.Stack) error { func (c *CLI) Services(ctx context.Context, s engine.Stack) ([]string, error) { stdout, stderr, err := c.run(ctx, c.command(s, "config", "--services")) if nil != err { - return nil, &engine.Error{Code: 502, Message: "Could not read Compose services. Try again.", Cause: fmt.Errorf("compose command failed: %w: %s", err, stderr)} + return nil, &engine.Error{ + Code: 502, + Message: "Could not read Compose services. Try again.", + Cause: fmt.Errorf("compose command failed: %w: %s", err, stderr), + } } return strings.Fields(string(stdout)), nil } @@ -104,7 +112,11 @@ type container struct { func (c *CLI) Containers(ctx context.Context, s engine.Stack) ([]engine.Service, error) { stdout, stderr, err := c.run(ctx, c.command(s, "ps", "--all", "--format", "json")) if nil != err { - return nil, &engine.Error{Code: 502, Message: "Could not read container information. Try again.", Cause: fmt.Errorf("compose ps: %w: %s", err, stderr)} + return nil, &engine.Error{ + Code: 502, + Message: "Could not read container information. Try again.", + Cause: fmt.Errorf("compose ps: %w: %s", err, stderr), + } } data := bytes.TrimSpace(stdout) containers := []container{} @@ -124,13 +136,20 @@ func (c *CLI) Containers(ctx context.Context, s engine.Stack) ([]engine.Service, } } if nil != err { - return nil, &engine.Error{Code: 502, Message: "Could not read container information. Try again.", Cause: fmt.Errorf("invalid compose ps output: %w", err)} + return nil, &engine.Error{ + Code: 502, + Message: "Could not read container information. Try again.", + Cause: fmt.Errorf("invalid compose ps output: %w", err), + } } services := make([]engine.Service, 0, len(containers)) for _, item := range containers { bindings := make([]engine.PortBinding, 0, len(item.Publishers)) for _, port := range item.Publishers { - bindings = append(bindings, engine.PortBinding{HostIP: port.URL, HostPort: port.PublishedPort, ContainerPort: port.TargetPort, Protocol: port.Protocol}) + bindings = append( + bindings, + engine.PortBinding{HostIP: port.URL, HostPort: port.PublishedPort, ContainerPort: port.TargetPort, Protocol: port.Protocol}, + ) } ports := item.Ports if "" == ports { diff --git a/internal/engine/compose/compose_test.go b/internal/engine/compose/compose_test.go index 5f18b19..da9371a 100644 --- a/internal/engine/compose/compose_test.go +++ b/internal/engine/compose/compose_test.go @@ -33,7 +33,8 @@ func TestCommands(t *testing.T) { t.Run(test.action, func(t *testing.T) { cmd := c.Action(s, test.action, test.service) want := append(append([]string{}, base...), test.args...) - if "docker" != cmd.Program || s.Dir != cmd.Dir || !reflect.DeepEqual(want, cmd.Args) || !reflect.DeepEqual([]string{"COMPOSE_ANSI=always", "COMPOSE_PROGRESS=tty"}, cmd.Env) { + if "docker" != cmd.Program || s.Dir != cmd.Dir || !reflect.DeepEqual(want, cmd.Args) || + !reflect.DeepEqual([]string{"COMPOSE_ANSI=always", "COMPOSE_PROGRESS=tty"}, cmd.Env) { t.Fatalf("command: %+v", cmd) } }) @@ -71,7 +72,14 @@ func TestValidation(t *testing.T) { t.Fatalf("validation diagnostic separation: %v", err) } } - if cmd := c.Action(s, "start", ""); !reflect.DeepEqual(append(append([]string{}, env...), "COMPOSE_ANSI=always", "COMPOSE_PROGRESS=tty"), cmd.Env) { + if cmd := c.Action( + s, + "start", + "", + ); !reflect.DeepEqual( + append(append([]string{}, env...), "COMPOSE_ANSI=always", "COMPOSE_PROGRESS=tty"), + cmd.Env, + ) { t.Fatalf("action lost runtime environment: %+v", cmd) } if cmd := c.Logs(s); !reflect.DeepEqual(env, cmd.Env) { @@ -124,7 +132,8 @@ func TestContainerOutputErrors(t *testing.T) { if !errors.As(err, &apiErr) || 502 != apiErr.Code || "" == apiErr.Message || !errors.As(err, &cause) { t.Fatalf("parse error: %v", err) } - if strings.Contains(apiErr.Message, "secret-token") || strings.Contains(apiErr.Message, "/tmp/private") || !strings.Contains(err.Error(), cause.Error()) { + if strings.Contains(apiErr.Message, "secret-token") || strings.Contains(apiErr.Message, "/tmp/private") || + !strings.Contains(err.Error(), cause.Error()) { t.Fatalf("parse diagnostic separation: %v", err) } } @@ -150,10 +159,12 @@ func TestRuntime(t *testing.T) { return []byte(data), []byte("warning must stay out of JSON"), nil }, nil) services, err := c.Containers(context.Background(), testStack()) - if nil != err || 1 != len(services) || "one" != services[0].ID || ":::8080->80/tcp" != services[0].Ports || 8080 != services[0].PortBindings[0].HostPort { + if nil != err || 1 != len(services) || "one" != services[0].ID || ":::8080->80/tcp" != services[0].Ports || + 8080 != services[0].PortBindings[0].HostPort { t.Fatalf("runtime: %+v %v", services, err) } - if "Up 2 hours (healthy)" != services[0].Status || "2026-10-25 09:00:00 +0000 UTC" != services[0].CreatedAt || "2 days ago" != services[0].RunningFor { + if "Up 2 hours (healthy)" != services[0].Status || "2026-10-25 09:00:00 +0000 UTC" != services[0].CreatedAt || + "2 days ago" != services[0].RunningFor { t.Fatalf("timing: %+v", services[0]) } } diff --git a/internal/engine/compose/container_info_test.go b/internal/engine/compose/container_info_test.go index e602728..33c4a94 100644 --- a/internal/engine/compose/container_info_test.go +++ b/internal/engine/compose/container_info_test.go @@ -55,8 +55,8 @@ func TestContainerInfoFormat(t *testing.T) { started := created.Add(469 * time.Millisecond) for _, native := range []bool{false, true} { var container struct { - Created interface{} - State struct{ StartedAt interface{} } + Created any + State struct{ StartedAt any } Config struct{ Labels map[string]string } } if native { @@ -65,7 +65,7 @@ func TestContainerInfoFormat(t *testing.T) { container.Created, container.State.StartedAt = created.Format(time.RFC3339Nano), started.Format(time.RFC3339Nano) } container.Config.Labels = map[string]string{"io.dcompose.auto-update": "true", "private": "private-label-value"} - format := template.Must(template.New("inspect").Funcs(template.FuncMap{"json": func(value interface{}) (string, error) { + format := template.Must(template.New("inspect").Funcs(template.FuncMap{"json": func(value any) (string, error) { data, err := json.Marshal(value) return string(data), err }}).Parse(ContainerInfoFormat)) diff --git a/internal/engine/compose/labels_test.go b/internal/engine/compose/labels_test.go index 64708fa..b2b95ef 100644 --- a/internal/engine/compose/labels_test.go +++ b/internal/engine/compose/labels_test.go @@ -18,7 +18,11 @@ func TestSchedulingLabels(t *testing.T) { {`null`, "", false}, } { var service engine.Service - if err := ApplyLabels(&service, []byte(test.data)); nil != err || test.interval != service.CheckInterval || test.auto != service.AutoUpdate { + if err := ApplyLabels( + &service, + []byte(test.data), + ); nil != err || test.interval != service.CheckInterval || + test.auto != service.AutoUpdate { t.Fatalf("labels: %+v %v", service, err) } } diff --git a/internal/engine/detached.go b/internal/engine/detached.go index f5f9c4e..dffd8de 100644 --- a/internal/engine/detached.go +++ b/internal/engine/detached.go @@ -26,7 +26,11 @@ func ValidateDetachedMounts(self SelfContainer, requestFile string, stack Stack) } } if nil == covering || ("bind" != covering.Type && "volume" != covering.Type) || (writable && !covering.RW) { - return &Error{Code: 409, Message: "This action requires persistent mounts for the node cache and stack files, with a writable cache. Run it from the host instead.", Cause: fmt.Errorf("path %q is not covered by a shared persistent mount (writable required: %t)", path, writable)} + return &Error{ + Code: 409, + Message: "This action requires persistent mounts for the node cache and stack files, with a writable cache. Run it from the host instead.", + Cause: fmt.Errorf("path %q is not covered by a shared persistent mount (writable required: %t)", path, writable), + } } } return nil diff --git a/internal/engine/docker/detached.go b/internal/engine/docker/detached.go index ddd462a..687bfe6 100644 --- a/internal/engine/docker/detached.go +++ b/internal/engine/docker/detached.go @@ -34,7 +34,11 @@ func (d *Docker) SelfContainer(ctx context.Context) (*engine.SelfContainer, erro return nil, nil } if 1 != len(ids) { - return nil, detachedError("Could not identify the node container.", errors.New("more than one running container has the DCOMPOSE_AGENT=true label on this Docker host"), nil) + return nil, detachedError( + "Could not identify the node container.", + errors.New("more than one running container has the DCOMPOSE_AGENT=true label on this Docker host"), + nil, + ) } stdout, stderr, err = d.run(ctx, command("", "inspect", "--type", "container", "--format", selfContainerFormat, "--", ids[0])) if nil != err { @@ -45,7 +49,11 @@ func (d *Docker) SelfContainer(ctx context.Context) (*engine.SelfContainer, erro return nil, detachedError("Could not identify the node container.", err, nil) } if !containerID.MatchString(self.ID) || ids[0] != self.ID || !immutableImage(self.Image) { - return nil, detachedError("Could not identify the node container.", errors.New("container inspect did not return a full container ID and immutable image ID"), nil) + return nil, detachedError( + "Could not identify the node container.", + errors.New("container inspect did not return a full container ID and immutable image ID"), + nil, + ) } return &self, nil } @@ -59,13 +67,31 @@ func (d *Docker) StartDetached(ctx context.Context, self engine.SelfContainer, n return err } if !containerID.MatchString(self.ID) || !immutableImage(self.Image) { - return &engine.Error{Code: 409, Message: "Could not start the action on this node's own stack.", Cause: errors.New("node container requires an exact container ID and immutable image ID")} + return &engine.Error{ + Code: 409, + Message: "Could not start the action on this node's own stack.", + Cause: errors.New("node container requires an exact container ID and immutable image ID"), + } } groups, err := os.Getgroups() if nil != err { return detachedError("Could not start the action on this node's own stack.", err, nil) } - args := []string{"run", "--detach", "--name", name, "--label", "io.dcompose.operation=" + strings.TrimPrefix(name, "dagent-operation-"), "--restart=no", "--pull", "never", "--volumes-from", self.ID, "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())} + args := []string{ + "run", + "--detach", + "--name", + name, + "--label", + "io.dcompose.operation=" + strings.TrimPrefix(name, "dagent-operation-"), + "--restart=no", + "--pull", + "never", + "--volumes-from", + self.ID, + "--user", + fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + } for _, group := range groups { args = append(args, "--group-add", strconv.Itoa(group)) } @@ -83,7 +109,19 @@ func (d *Docker) StartDetached(ctx context.Context, self engine.SelfContainer, n func (d *Docker) DetachedState(ctx context.Context, name string) (engine.DetachedState, error) { var state engine.DetachedState - stdout, stderr, err := d.run(ctx, command("", "inspect", "--type", "container", "--format", `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, "--", name)) + stdout, stderr, err := d.run( + ctx, + command( + "", + "inspect", + "--type", + "container", + "--format", + `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, + "--", + name, + ), + ) if nil != err { return state, detachedError("Could not check the action's status.", err, stderr) } diff --git a/internal/engine/docker/detached_test.go b/internal/engine/docker/detached_test.go index 84a8fd4..20dab42 100644 --- a/internal/engine/docker/detached_test.go +++ b/internal/engine/docker/detached_test.go @@ -50,10 +50,14 @@ func TestSelfContainer(t *testing.T) { if "docker" != cmd.Program || !reflect.DeepEqual(want, cmd.Args) { t.Fatalf("inspect: %+v", cmd) } - return []byte(`{"ID":"` + self.ID + `","Image":"` + self.Image + `","Project":"node","Mounts":[{"Type":"volume","Source":"/data/cache","Destination":"/cache","RW":true}]}`), nil, nil + return []byte( + `{"ID":"` + self.ID + `","Image":"` + self.Image + `","Project":"node","Mounts":[{"Type":"volume","Source":"/data/cache","Destination":"/cache","RW":true}]}`, + ), nil, nil }) got, err := d.SelfContainer(context.Background()) - if nil != err || nil == got || self.ID != got.ID || self.Image != got.Image || self.Project != got.Project || 1 != len(got.Mounts) || !got.Mounts[0].RW || 2 != calls { + if nil != err || nil == got || self.ID != got.ID || self.Image != got.Image || self.Project != got.Project || 1 != len(got.Mounts) || + !got.Mounts[0].RW || + 2 != calls { t.Fatalf("self: %+v, %v", got, err) } if strings.Contains(selfContainerFormat, "{{json .}}") || strings.Contains(selfContainerFormat, ".Config.Env") { @@ -138,7 +142,9 @@ func TestDetachedMounts(t *testing.T) { return } var fault *engine.Error - if !errors.As(err, &fault) || 409 != fault.Code || "" == fault.Message || nil == fault.Cause || strings.Contains(fault.Message, requestFile) || strings.Contains(fault.Message, "/private") { + if !errors.As(err, &fault) || 409 != fault.Code || "" == fault.Message || nil == fault.Cause || + strings.Contains(fault.Message, requestFile) || + strings.Contains(fault.Message, "/private") { t.Fatalf("mount error: %v", err) } }) @@ -151,7 +157,21 @@ func TestStartDetached(t *testing.T) { t.Setenv("DOCKER_TLS_VERIFY", "1") self, name, requestFile := detachedSelf(), "dagent-operation-test", "/cache/operations/request.json" d := New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - prefix := []string{"run", "--detach", "--name", name, "--label", "io.dcompose.operation=test", "--restart=no", "--pull", "never", "--volumes-from", self.ID, "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid())} + prefix := []string{ + "run", + "--detach", + "--name", + name, + "--label", + "io.dcompose.operation=test", + "--restart=no", + "--pull", + "never", + "--volumes-from", + self.ID, + "--user", + strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), + } if "docker" != cmd.Program || len(prefix) >= len(cmd.Args) || !reflect.DeepEqual(prefix, cmd.Args[:len(prefix)]) { t.Fatalf("run: %+v", cmd) } @@ -220,7 +240,15 @@ func TestDetachedState(t *testing.T) { } return []byte("stdout\n"), []byte("stderr\n"), nil } - want := []string{"inspect", "--type", "container", "--format", `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, "--", "dagent-operation-test"} + want := []string{ + "inspect", + "--type", + "container", + "--format", + `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, + "--", + "dagent-operation-test", + } if !reflect.DeepEqual(want, cmd.Args) { t.Fatalf("state inspect: %+v", cmd) } @@ -252,7 +280,9 @@ func TestDetachedLogsError(t *testing.T) { }) state, err := d.DetachedState(context.Background(), "dagent-operation-test") var fault *engine.Error - if !errors.As(err, &fault) || 502 != fault.Code || state.Running || 17 != state.ExitCode || "partial logs\nlogs unavailable" != state.Output || 2 != calls { + if !errors.As(err, &fault) || 502 != fault.Code || state.Running || 17 != state.ExitCode || + "partial logs\nlogs unavailable" != state.Output || + 2 != calls { t.Fatalf("state: %+v, error: %v", state, err) } } @@ -283,14 +313,23 @@ func TestDetachedCommandErrors(t *testing.T) { case "inspect": _, err = d.SelfContainer(context.Background()) case "start": - err = d.StartDetached(context.Background(), detachedSelf(), "dagent-operation-test", "/cache/operations/request.json", testStack()) + err = d.StartDetached( + context.Background(), + detachedSelf(), + "dagent-operation-test", + "/cache/operations/request.json", + testStack(), + ) case "state": _, err = d.DetachedState(context.Background(), "dagent-operation-test") case "remove": err = d.RemoveDetached(context.Background(), "dagent-operation-test") } var fault *engine.Error - if 1 != calls || !errors.As(err, &fault) || 502 != fault.Code || !errors.Is(err, cause) || !strings.Contains(fault.Cause.Error(), "daemon diagnostic") || strings.Contains(fault.Message, "diagnostic") || strings.Contains(err.Error(), "stdout-secret") { + if 1 != calls || !errors.As(err, &fault) || 502 != fault.Code || !errors.Is(err, cause) || + !strings.Contains(fault.Cause.Error(), "daemon diagnostic") || + strings.Contains(fault.Message, "diagnostic") || + strings.Contains(err.Error(), "stdout-secret") { t.Fatalf("calls=%d error=%v", calls, err) } }) diff --git a/internal/engine/docker/docker.go b/internal/engine/docker/docker.go index da0f029..8aa7034 100644 --- a/internal/engine/docker/docker.go +++ b/internal/engine/docker/docker.go @@ -63,7 +63,18 @@ func (d *Docker) Probe(ctx context.Context) error { } func (d *Docker) Events(since time.Time) engine.Command { - return command("", "events", "--since", fmt.Sprintf("%d.%09d", since.Unix(), since.Nanosecond()), "--format", "{{json .}}", "--filter", "type=container", "--filter", "label=com.docker.compose.project") + return command( + "", + "events", + "--since", + fmt.Sprintf("%d.%09d", since.Unix(), since.Nanosecond()), + "--format", + "{{json .}}", + "--filter", + "type=container", + "--filter", + "label=com.docker.compose.project", + ) } func (d *Docker) EventStack(line []byte) string { diff --git a/internal/engine/docker/docker_test.go b/internal/engine/docker/docker_test.go index da6ae49..12a8709 100644 --- a/internal/engine/docker/docker_test.go +++ b/internal/engine/docker/docker_test.go @@ -17,7 +17,17 @@ func testStack() engine.Stack { func TestEvents(t *testing.T) { d := New(nil) cmd := d.Events(time.Unix(12, 34)) - want := []string{"events", "--since", "12.000000034", "--format", "{{json .}}", "--filter", "type=container", "--filter", "label=com.docker.compose.project"} + want := []string{ + "events", + "--since", + "12.000000034", + "--format", + "{{json .}}", + "--filter", + "type=container", + "--filter", + "label=com.docker.compose.project", + } if !reflect.DeepEqual(want, cmd.Args) { t.Fatalf("events: %+v", cmd) } @@ -46,7 +56,24 @@ func TestShell(t *testing.T) { t.Fatalf("container: %s %v", container, err) } cmd := d.Shell(container, "bash", "session") - if !reflect.DeepEqual([]string{"exec", "-it", "--detach-keys=", "--env", "DOCKER_NODE_SHELL_ID=session", "--env", "TERM=xterm-256color", id, "sh", "-c", shellBootstrap, "docker-node-shell", "bash"}, cmd.Args) { + if !reflect.DeepEqual( + []string{ + "exec", + "-it", + "--detach-keys=", + "--env", + "DOCKER_NODE_SHELL_ID=session", + "--env", + "TERM=xterm-256color", + id, + "sh", + "-c", + shellBootstrap, + "docker-node-shell", + "bash", + }, + cmd.Args, + ) { t.Fatalf("shell: %+v", cmd) } } diff --git a/internal/engine/docker/images.go b/internal/engine/docker/images.go index 6ec17a4..30540fd 100644 --- a/internal/engine/docker/images.go +++ b/internal/engine/docker/images.go @@ -18,7 +18,10 @@ var imageIDReference = regexp.MustCompile(`^[a-f0-9]{12,64}$`) // ImageChanged compares the container's image with its local reference after pull. func (d *Docker) ImageChanged(ctx context.Context, s engine.Service) (bool, error) { - data, stderr, err := d.run(ctx, command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, s.Name)) + data, stderr, err := d.run( + ctx, + command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, s.Name), + ) if nil != err { return false, fmt.Errorf("inspect container image %s: %w: %s", s.Name, err, stderr) } @@ -46,17 +49,56 @@ func (d *Docker) ImageChanged(ctx context.Context, s engine.Service) (bool, erro func (d *Docker) InspectImage(ctx context.Context, s engine.Service) engine.ImageTarget { result := engine.ImageTarget{Status: "error", Reason: "inspect_error"} if "" == s.Name || strings.HasPrefix(s.Name, "-") { - slog.Warn(fmt.Sprintf("Could not check Docker image %q for container %q: container name is empty or starts with a dash.", s.Image, s.Name), "runtime", "docker", "container", s.Name, "image", s.Image, "error", "container name is empty or starts with a dash") + slog.Warn( + fmt.Sprintf( + "Could not check Docker image %q for container %q: container name is empty or starts with a dash.", + s.Image, + s.Name, + ), + "runtime", + "docker", + "container", + s.Name, + "image", + s.Image, + "error", + "container name is empty or starts with a dash", + ) return result } // Inspect only the actual container's image ID and reference, never its env. - data, stderr, err := d.run(ctx, command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, s.Name)) + data, stderr, err := d.run( + ctx, + command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, s.Name), + ) if nil != err { level := slog.LevelWarn if errors.Is(err, context.Canceled) || context.Canceled == ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Could not inspect Docker container %q for the update check of image %q: %v. %s", s.Name, s.Image, err, strings.TrimSpace(string(stderr))), "runtime", "docker", "command", "docker container inspect", "container", s.Name, "image", s.Image, "error", err, "stderr", string(stderr)) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Could not inspect Docker container %q for the update check of image %q: %v. %s", + s.Name, + s.Image, + err, + strings.TrimSpace(string(stderr)), + ), + "runtime", + "docker", + "command", + "docker container inspect", + "container", + s.Name, + "image", + s.Image, + "error", + err, + "stderr", + string(stderr), + ) return result } var container struct { @@ -64,11 +106,35 @@ func (d *Docker) InspectImage(ctx context.Context, s engine.Service) engine.Imag Ref string `json:"ref"` } if err := json.Unmarshal(bytes.TrimSpace(data), &container); nil != err { - slog.Warn(fmt.Sprintf("Could not parse the Docker image inspection for container %q using image %q: %v", s.Name, s.Image, err), "runtime", "docker", "container", s.Name, "image", s.Image, "error", err) + slog.Warn( + fmt.Sprintf("Could not parse the Docker image inspection for container %q using image %q: %v", s.Name, s.Image, err), + "runtime", + "docker", + "container", + s.Name, + "image", + s.Image, + "error", + err, + ) return result } if "" == container.ID { - slog.Warn(fmt.Sprintf("Could not read the Docker image ID for container %q using image %q: container inspection is missing the image ID.", s.Name, s.Image), "runtime", "docker", "container", s.Name, "image", s.Image, "error", "container inspection is missing the image ID") + slog.Warn( + fmt.Sprintf( + "Could not read the Docker image ID for container %q using image %q: container inspection is missing the image ID.", + s.Name, + s.Image, + ), + "runtime", + "docker", + "container", + s.Name, + "image", + s.Image, + "error", + "container inspection is missing the image ID", + ) return result } result.LocalID = container.ID @@ -76,17 +142,53 @@ func (d *Docker) InspectImage(ctx context.Context, s engine.Service) engine.Imag result.Status, result.Reason = "unsupported", "pinned_digest" return result } - if "" == container.Ref || strings.HasPrefix(container.Ref, "sha256:") || imageIDReference.MatchString(container.Ref) || strings.HasPrefix(container.Ref, "-") { + if "" == container.Ref || strings.HasPrefix(container.Ref, "sha256:") || imageIDReference.MatchString(container.Ref) || + strings.HasPrefix(container.Ref, "-") { result.Status, result.Reason = "unsupported", "local_image" return result } - data, stderr, err = d.run(ctx, command("", "image", "inspect", "--format", `{"os":{{json .Os}},"architecture":{{json .Architecture}},"variant":{{json .Variant}},"os.version":{{json .OsVersion}},"digests":{{json .RepoDigests}}}`, container.ID)) + data, stderr, err = d.run( + ctx, + command( + "", + "image", + "inspect", + "--format", + `{"os":{{json .Os}},"architecture":{{json .Architecture}},"variant":{{json .Variant}},"os.version":{{json .OsVersion}},"digests":{{json .RepoDigests}}}`, + container.ID, + ), + ) if nil != err { level := slog.LevelWarn if errors.Is(err, context.Canceled) || context.Canceled == ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Could not inspect local Docker image %q (%s) for the update check of container %q: %v. %s", container.Ref, container.ID, s.Name, err, strings.TrimSpace(string(stderr))), "runtime", "docker", "command", "docker image inspect", "container", s.Name, "image", container.Ref, "image_id", container.ID, "error", err, "stderr", string(stderr)) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Could not inspect local Docker image %q (%s) for the update check of container %q: %v. %s", + container.Ref, + container.ID, + s.Name, + err, + strings.TrimSpace(string(stderr)), + ), + "runtime", + "docker", + "command", + "docker image inspect", + "container", + s.Name, + "image", + container.Ref, + "image_id", + container.ID, + "error", + err, + "stderr", + string(stderr), + ) return result } var local struct { @@ -94,7 +196,19 @@ func (d *Docker) InspectImage(ctx context.Context, s engine.Service) engine.Imag Digests []string `json:"digests"` } if err := json.Unmarshal(bytes.TrimSpace(data), &local); nil != err { - slog.Warn(fmt.Sprintf("Could not parse local Docker image %q (%s) for container %q: %v", container.Ref, container.ID, s.Name, err), "runtime", "docker", "container", s.Name, "image", container.Ref, "image_id", container.ID, "error", err) + slog.Warn( + fmt.Sprintf("Could not parse local Docker image %q (%s) for container %q: %v", container.Ref, container.ID, s.Name, err), + "runtime", + "docker", + "container", + s.Name, + "image", + container.Ref, + "image_id", + container.ID, + "error", + err, + ) return result } // Failed registry lookup is never evidence that an image is locally built. @@ -114,7 +228,7 @@ func (d *Docker) InspectImage(ctx context.Context, s engine.Service) engine.Imag func (d *Docker) ImageRef(ref string) string { ref = strings.TrimPrefix(ref, "index.docker.io/") ref = strings.TrimPrefix(ref, "docker.io/") - first := strings.SplitN(ref, "/", 2)[0] + first, _, _ := strings.Cut(ref, "/") if !strings.Contains(ref, "/") { ref = "docker.io/library/" + ref } else if !strings.ContainsAny(first, ".:") && "localhost" != first { diff --git a/internal/engine/docker/images_test.go b/internal/engine/docker/images_test.go index 00de7ac..66b71b7 100644 --- a/internal/engine/docker/images_test.go +++ b/internal/engine/docker/images_test.go @@ -40,7 +40,10 @@ func TestImageChanged(t *testing.T) { return nil, []byte("inspect failed"), errors.New("inspect failed") } if "container" == cmd.Args[0] { - if !reflect.DeepEqual(cmd.Args, []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, "web"}) { + if !reflect.DeepEqual( + cmd.Args, + []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .Config.Image}}}`, "web"}, + ) { t.Fatalf("container: %+v", cmd) } return []byte(test.container), nil, nil @@ -108,7 +111,8 @@ func TestImageInspection(t *testing.T) { return nil, nil, nil }) result := d.InspectImage(context.Background(), engine.Service{Name: "web"}) - if digest("a") != result.LocalID || "docker.io/library/nginx:latest" != result.Key.Ref || testPlatform != result.Key.ImagePlatform || "" != result.Status { + if digest("a") != result.LocalID || "docker.io/library/nginx:latest" != result.Key.Ref || testPlatform != result.Key.ImagePlatform || + "" != result.Status { t.Fatalf("target: %+v", result) } } @@ -181,10 +185,12 @@ func TestImageDiagnostics(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatalf("decode logs: %v", err) } - if "WARN" != record.Level || "docker" != record.Runtime || "web" != record.Container || "nginx:latest" != record.Image || "" == record.Error { + if "WARN" != record.Level || "docker" != record.Runtime || "web" != record.Container || "nginx:latest" != record.Image || + "" == record.Error { t.Fatalf("missing diagnostics: %s", &logs) } - if test.commandError && ("sentinel command failure" != record.Error || "sentinel command stderr" != record.Stderr || "docker "+test.fail+" inspect" != record.Command) { + if test.commandError && + ("sentinel command failure" != record.Error || "sentinel command stderr" != record.Stderr || "docker "+test.fail+" inspect" != record.Command) { t.Fatalf("missing command details: %s", &logs) } details := []string{record.Container, record.Image, record.Stderr} diff --git a/internal/engine/docker/info.go b/internal/engine/docker/info.go index 54672a3..7a9d606 100644 --- a/internal/engine/docker/info.go +++ b/internal/engine/docker/info.go @@ -14,9 +14,27 @@ import ( func nodeInfoError(command string, err error, stderr []byte) error { if detail := bytes.TrimSpace(stderr); 0 < len(detail) { - slog.Error(fmt.Sprintf("Could not read Docker node information with %s: %v: %s", command, err, detail), "runtime", "docker", "command", command, "error", err, "stderr", string(detail)) + slog.Error( + fmt.Sprintf("Could not read Docker node information with %s: %v: %s", command, err, detail), + "runtime", + "docker", + "command", + command, + "error", + err, + "stderr", + string(detail), + ) } else { - slog.Error(fmt.Sprintf("Could not read Docker node information with %s: %v", command, err), "runtime", "docker", "command", command, "error", err) + slog.Error( + fmt.Sprintf("Could not read Docker node information with %s: %v", command, err), + "runtime", + "docker", + "command", + command, + "error", + err, + ) } return &engine.Error{Code: 502, Message: "Could not load node information. Try again."} } diff --git a/internal/engine/docker/info_test.go b/internal/engine/docker/info_test.go index b363754..e69a856 100644 --- a/internal/engine/docker/info_test.go +++ b/internal/engine/docker/info_test.go @@ -16,7 +16,8 @@ import ( func TestNodeInfo(t *testing.T) { infoFixture := readFixture(t, "info.json") statsFixture := readFixture(t, "stats.jsonl") - ctx := context.WithValue(context.Background(), struct{}{}, "request") + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "request") calls := 0 d := New(func(got context.Context, cmd engine.Command) ([]byte, []byte, error) { if ctx != got || "docker" != cmd.Program || "" != cmd.Dir || 0 != len(cmd.Env) { @@ -111,7 +112,9 @@ func TestNodeInfoErrors(t *testing.T) { }) _, err := d.NodeInfo(context.Background()) var apiErr *engine.Error - if !errors.As(err, &apiErr) || 502 != apiErr.Code || "" == apiErr.Message || strings.Contains(apiErr.Message, "secret-output") || strings.Contains(apiErr.Message, "/var/run/docker.sock") { + if !errors.As(err, &apiErr) || 502 != apiErr.Code || "" == apiErr.Message || + strings.Contains(apiErr.Message, "secret-output") || + strings.Contains(apiErr.Message, "/var/run/docker.sock") { t.Fatalf("error: %v", err) } var record struct { @@ -125,7 +128,8 @@ func TestNodeInfoErrors(t *testing.T) { if strings.HasPrefix(test.name, "stats ") { command = "docker stats" } - if "ERROR" != record.Level || "docker" != record.Runtime || command != record.Command || "" == record.Error || strings.Contains(logs.String(), "secret-") { + if "ERROR" != record.Level || "docker" != record.Runtime || command != record.Command || "" == record.Error || + strings.Contains(logs.String(), "secret-") { t.Fatalf("logs: %s", &logs) } if "" != test.fail && ("exit status 1" != record.Error || "permission denied for /var/run/docker.sock" != record.Stderr) { diff --git a/internal/engine/docker/shells.go b/internal/engine/docker/shells.go index f3a6126..d39e1cd 100644 --- a/internal/engine/docker/shells.go +++ b/internal/engine/docker/shells.go @@ -44,9 +44,35 @@ func (d *Docker) ResolveContainer(ctx context.Context, s engine.Stack, reference output, stderr, err := d.run(ctx, command(s.Dir, "inspect", "--type", "container", "--", reference)) if nil != err { if detail := strings.TrimSpace(string(stderr)); "" != detail { - slog.Error(fmt.Sprintf("Could not inspect Docker container %q in stack %q for a shell: %v: %s", reference, s.Name, err, detail), "runtime", "docker", "command", "docker inspect", "stack", s.Name, "container", reference, "error", err, "stderr", detail) + slog.Error( + fmt.Sprintf("Could not inspect Docker container %q in stack %q for a shell: %v: %s", reference, s.Name, err, detail), + "runtime", + "docker", + "command", + "docker inspect", + "stack", + s.Name, + "container", + reference, + "error", + err, + "stderr", + detail, + ) } else { - slog.Error(fmt.Sprintf("Could not inspect Docker container %q in stack %q for a shell: %v", reference, s.Name, err), "runtime", "docker", "command", "docker inspect", "stack", s.Name, "container", reference, "error", err) + slog.Error( + fmt.Sprintf("Could not inspect Docker container %q in stack %q for a shell: %v", reference, s.Name, err), + "runtime", + "docker", + "command", + "docker inspect", + "stack", + s.Name, + "container", + reference, + "error", + err, + ) } return "", &engine.Error{Code: 404, Message: "container not found"} } diff --git a/internal/engine/docker/shells_test.go b/internal/engine/docker/shells_test.go index d5fbfb7..12b4977 100644 --- a/internal/engine/docker/shells_test.go +++ b/internal/engine/docker/shells_test.go @@ -34,7 +34,9 @@ func TestShellInspectionMessage(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatal(err) } - if "ERROR" != record.Level || "docker" != record.Runtime || "demo" != record.Stack || "demo-web" != record.Container || "exit status 1" != record.Error || stderr != record.Stderr { + if "ERROR" != record.Level || "docker" != record.Runtime || "demo" != record.Stack || "demo-web" != record.Container || + "exit status 1" != record.Error || + stderr != record.Stderr { t.Fatalf("inspection fields: %+v", record) } for _, detail := range []string{record.Stack, record.Container, record.Error, stderr} { diff --git a/internal/engine/podman/detached_test.go b/internal/engine/podman/detached_test.go index c6fb60f..a4a4026 100644 --- a/internal/engine/podman/detached_test.go +++ b/internal/engine/podman/detached_test.go @@ -28,7 +28,26 @@ func TestStartDetached(t *testing.T) { t.Setenv("DCOMPOSE_TEST_SECRET", "not-in-arguments") self := detachedSelf() p := New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - prefix := []string{"--remote", "--url", host, "run", "--detach", "--name", "dagent-operation-test", "--label", "io.dcompose.operation=test", "--restart=no", "--pull", "never", "--volumes-from", self.ID, "--security-opt", "label=disable", "--user", strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid())} + prefix := []string{ + "--remote", + "--url", + host, + "run", + "--detach", + "--name", + "dagent-operation-test", + "--label", + "io.dcompose.operation=test", + "--restart=no", + "--pull", + "never", + "--volumes-from", + self.ID, + "--security-opt", + "label=disable", + "--user", + strconv.Itoa(os.Getuid()) + ":" + strconv.Itoa(os.Getgid()), + } if "podman" != cmd.Program || len(prefix) >= len(cmd.Args) || !reflect.DeepEqual(prefix, cmd.Args[:len(prefix)]) { t.Fatalf("run: %+v", cmd) } @@ -130,7 +149,8 @@ func TestDetachedState(t *testing.T) { func TestRemoveDetached(t *testing.T) { t.Setenv("CONTAINER_HOST", "unix:///mounted/podman.sock") p := New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - if "podman" != cmd.Program || !reflect.DeepEqual([]string{"--remote", "--url", "unix:///mounted/podman.sock", "rm", "--", "helper"}, cmd.Args) { + if "podman" != cmd.Program || + !reflect.DeepEqual([]string{"--remote", "--url", "unix:///mounted/podman.sock", "rm", "--", "helper"}, cmd.Args) { t.Fatalf("remove: %+v", cmd) } return nil, nil, nil @@ -165,7 +185,10 @@ func TestDetachedErrors(t *testing.T) { err = p.RemoveDetached(context.Background(), "helper") } var fault *engine.Error - if !errors.As(err, &fault) || 502 != fault.Code || !errors.Is(err, cause) || !strings.Contains(fault.Cause.Error(), "private diagnostic") || strings.Contains(fault.Message, "private") || strings.Contains(err.Error(), "stdout-secret") { + if !errors.As(err, &fault) || 502 != fault.Code || !errors.Is(err, cause) || + !strings.Contains(fault.Cause.Error(), "private diagnostic") || + strings.Contains(fault.Message, "private") || + strings.Contains(err.Error(), "stdout-secret") { t.Fatalf("error boundary: %v", err) } }) diff --git a/internal/engine/podman/images.go b/internal/engine/podman/images.go index 47aca4d..1450f51 100644 --- a/internal/engine/podman/images.go +++ b/internal/engine/podman/images.go @@ -19,7 +19,10 @@ var imageIDReference = regexp.MustCompile(`^[a-f0-9]{12,64}$`) // ImageChanged compares the container's image with its local reference after pull. func (p *Podman) ImageChanged(ctx context.Context, s engine.Service) (bool, error) { - data, stderr, err := p.run(ctx, p.command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, s.Name)) + data, stderr, err := p.run( + ctx, + p.command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, s.Name), + ) if nil != err { return false, fmt.Errorf("inspect container image %s: %w: %s", s.Name, err, stderr) } @@ -47,16 +50,55 @@ func (p *Podman) ImageChanged(ctx context.Context, s engine.Service) (bool, erro func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.ImageTarget { result := engine.ImageTarget{Status: "error", Reason: "inspect_error"} if "" == s.Name || strings.HasPrefix(s.Name, "-") { - slog.Warn(fmt.Sprintf("Could not check Podman image %q for container %q: container name is empty or starts with a dash.", s.Image, s.Name), "runtime", "podman", "container", s.Name, "image", s.Image, "error", "container name is empty or starts with a dash") + slog.Warn( + fmt.Sprintf( + "Could not check Podman image %q for container %q: container name is empty or starts with a dash.", + s.Image, + s.Name, + ), + "runtime", + "podman", + "container", + s.Name, + "image", + s.Image, + "error", + "container name is empty or starts with a dash", + ) return result } - data, stderr, err := p.run(ctx, p.command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, s.Name)) + data, stderr, err := p.run( + ctx, + p.command("", "container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, s.Name), + ) if nil != err { level := slog.LevelWarn if errors.Is(err, context.Canceled) || context.Canceled == ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Could not inspect Podman container %q for the update check of image %q: %v. %s", s.Name, s.Image, err, strings.TrimSpace(string(stderr))), "runtime", "podman", "command", "podman container inspect", "container", s.Name, "image", s.Image, "error", err, "stderr", string(stderr)) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Could not inspect Podman container %q for the update check of image %q: %v. %s", + s.Name, + s.Image, + err, + strings.TrimSpace(string(stderr)), + ), + "runtime", + "podman", + "command", + "podman container inspect", + "container", + s.Name, + "image", + s.Image, + "error", + err, + "stderr", + string(stderr), + ) return result } var container struct { @@ -64,7 +106,17 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag Ref string `json:"ref"` } if err := json.Unmarshal(bytes.TrimSpace(data), &container); nil != err { - slog.Warn(fmt.Sprintf("Could not parse the Podman image inspection for container %q using image %q: %v", s.Name, s.Image, err), "runtime", "podman", "container", s.Name, "image", s.Image, "error", err) + slog.Warn( + fmt.Sprintf("Could not parse the Podman image inspection for container %q using image %q: %v", s.Name, s.Image, err), + "runtime", + "podman", + "container", + s.Name, + "image", + s.Image, + "error", + err, + ) return result } result.LocalID = container.ID @@ -72,14 +124,29 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag result.LocalID = "sha256:" + container.ID } if !imageDigest.MatchString(result.LocalID) { - slog.Warn(fmt.Sprintf("Could not read the Podman image ID for container %q using image %q: container inspection is missing a valid image ID.", s.Name, s.Image), "runtime", "podman", "container", s.Name, "image", s.Image, "error", "container inspection is missing a valid image ID") + slog.Warn( + fmt.Sprintf( + "Could not read the Podman image ID for container %q using image %q: container inspection is missing a valid image ID.", + s.Name, + s.Image, + ), + "runtime", + "podman", + "container", + s.Name, + "image", + s.Image, + "error", + "container inspection is missing a valid image ID", + ) return result } if strings.Contains(container.Ref, "@") { result.Status, result.Reason = "unsupported", "pinned_digest" return result } - if "" == container.Ref || strings.HasPrefix(container.Ref, "sha256:") || imageIDReference.MatchString(container.Ref) || strings.HasPrefix(container.Ref, "-") { + if "" == container.Ref || strings.HasPrefix(container.Ref, "sha256:") || imageIDReference.MatchString(container.Ref) || + strings.HasPrefix(container.Ref, "-") { result.Status, result.Reason = "unsupported", "local_image" return result } @@ -92,7 +159,32 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag if errors.Is(err, context.Canceled) || context.Canceled == ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Could not inspect local Podman image %q (%s) for the update check of container %q: %v. %s", container.Ref, container.ID, s.Name, err, strings.TrimSpace(string(stderr))), "runtime", "podman", "command", "podman image inspect", "container", s.Name, "image", container.Ref, "image_id", container.ID, "error", err, "stderr", string(stderr)) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Could not inspect local Podman image %q (%s) for the update check of container %q: %v. %s", + container.Ref, + container.ID, + s.Name, + err, + strings.TrimSpace(string(stderr)), + ), + "runtime", + "podman", + "command", + "podman image inspect", + "container", + s.Name, + "image", + container.Ref, + "image_id", + container.ID, + "error", + err, + "stderr", + string(stderr), + ) return result } var images []struct { @@ -101,11 +193,43 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag Digests []string `json:"RepoDigests"` } if err := json.Unmarshal(data, &images); nil != err { - slog.Warn(fmt.Sprintf("Could not parse local Podman image %q (%s) for container %q: %v", container.Ref, container.ID, s.Name, err), "runtime", "podman", "container", s.Name, "image", container.Ref, "image_id", container.ID, "error", err) + slog.Warn( + fmt.Sprintf("Could not parse local Podman image %q (%s) for container %q: %v", container.Ref, container.ID, s.Name, err), + "runtime", + "podman", + "container", + s.Name, + "image", + container.Ref, + "image_id", + container.ID, + "error", + err, + ) return result } if 1 != len(images) { - slog.Warn(fmt.Sprintf("Could not read local Podman image %q (%s) for container %q: inspection returned %d images instead of exactly one image.", container.Ref, container.ID, s.Name, len(images)), "runtime", "podman", "container", s.Name, "image", container.Ref, "image_id", container.ID, "error", "image inspection must contain exactly one image", "image_count", len(images)) + slog.Warn( + fmt.Sprintf( + "Could not read local Podman image %q (%s) for container %q: inspection returned %d images instead of exactly one image.", + container.Ref, + container.ID, + s.Name, + len(images), + ), + "runtime", + "podman", + "container", + s.Name, + "image", + container.Ref, + "image_id", + container.ID, + "error", + "image inspection must contain exactly one image", + "image_count", + len(images), + ) return result } local := images[0] @@ -115,7 +239,8 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag } // Without a reported variant, ARM32 cannot distinguish v5/v6/v7. // ARM64's empty variant uses the same v8 baseline as the registry matcher. - if "" == local.OS || "" == local.Architecture || ("arm" == local.Architecture && "" == local.Variant) || ("windows" == local.OS && "" == local.OSVersion) { + if "" == local.OS || "" == local.Architecture || ("arm" == local.Architecture && "" == local.Variant) || + ("windows" == local.OS && "" == local.OSVersion) { result.Status, result.Reason = "unsupported", "platform_unknown" return result } @@ -145,8 +270,8 @@ func (p *Podman) InspectImage(ctx context.Context, s engine.Service) engine.Imag func (p *Podman) ImageRef(ref string) string { ref = strings.TrimPrefix(ref, "docker://") - if strings.HasPrefix(ref, "index.docker.io/") { - ref = "docker.io/" + strings.TrimPrefix(ref, "index.docker.io/") + if rest, ok := strings.CutPrefix(ref, "index.docker.io/"); ok { + ref = "docker.io/" + rest } if strings.HasPrefix(ref, "docker.io/") && 1 == strings.Count(ref, "/") { ref = "docker.io/library/" + strings.TrimPrefix(ref, "docker.io/") diff --git a/internal/engine/podman/images_test.go b/internal/engine/podman/images_test.go index 2575cf3..fe7456e 100644 --- a/internal/engine/podman/images_test.go +++ b/internal/engine/podman/images_test.go @@ -36,7 +36,10 @@ func TestImageChanged(t *testing.T) { return nil, []byte("inspect failed"), errors.New("inspect failed") } if "container" == cmd.Args[0] { - if !reflect.DeepEqual(cmd.Args, []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, "web"}) { + if !reflect.DeepEqual( + cmd.Args, + []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, "web"}, + ) { t.Fatalf("container: %+v", cmd) } return []byte(test.container), nil, nil @@ -56,7 +59,12 @@ func TestImageChanged(t *testing.T) { func TestInspectImage(t *testing.T) { fixture := readFixture(t, "image-inspect.json") - ambiguous := strings.Replace(fixture, `"RepoDigests":`, `"RepoTags":["quay.io/team/app:latest","example.com/app:latest"],"RepoDigests":`, 1) + ambiguous := strings.Replace( + fixture, + `"RepoDigests":`, + `"RepoTags":["quay.io/team/app:latest","example.com/app:latest"],"RepoDigests":`, + 1, + ) ambiguous = strings.Replace(ambiguous, "quay.io/team/app@sha256:123", "example.com/app@sha256:123", 1) id := strings.Repeat("a", 64) for _, test := range []struct { @@ -82,7 +90,10 @@ func TestInspectImage(t *testing.T) { t.Fatalf("runtime credentials changed: %+v", cmd) } if 1 == calls { - if !reflect.DeepEqual(cmd.Args, []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, "demo"}) { + if !reflect.DeepEqual( + cmd.Args, + []string{"container", "inspect", "--format", `{"id":{{json .Image}},"ref":{{json .ImageName}}}`, "demo"}, + ) { t.Fatalf("container command: %+v", cmd) } data, err := json.Marshal(map[string]string{"id": id, "ref": test.ref}) @@ -185,10 +196,13 @@ func TestImageDiagnostics(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatalf("decode logs: %v", err) } - if "WARN" != record.Level || "podman" != record.Runtime || "demo" != record.Container || "quay.io/team/app:edge" != record.Image || "" == record.Error { + if "WARN" != record.Level || "podman" != record.Runtime || "demo" != record.Container || + "quay.io/team/app:edge" != record.Image || + "" == record.Error { t.Fatalf("missing diagnostics: %s", &logs) } - if test.commandError && ("sentinel command failure" != record.Error || "sentinel command stderr" != record.Stderr || "podman "+test.fail+" inspect" != record.Command) { + if test.commandError && + ("sentinel command failure" != record.Error || "sentinel command stderr" != record.Stderr || "podman "+test.fail+" inspect" != record.Command) { t.Fatalf("missing command details: %s", &logs) } details := []string{record.Container, record.Image, record.Stderr} diff --git a/internal/engine/podman/info.go b/internal/engine/podman/info.go index 7d67d92..b34c1ab 100644 --- a/internal/engine/podman/info.go +++ b/internal/engine/podman/info.go @@ -54,7 +54,10 @@ type podmanInfo struct { func (p *Podman) info(ctx context.Context) (*podmanInfo, error) { if "" != p.host { endpoint, err := url.Parse(p.host) - if nil != err || "unix" != endpoint.Scheme || "" != endpoint.Host || nil != endpoint.User || !strings.HasPrefix(endpoint.Path, "/") || "" != endpoint.RawQuery || "" != endpoint.Fragment { + if nil != err || "unix" != endpoint.Scheme || "" != endpoint.Host || nil != endpoint.User || + !strings.HasPrefix(endpoint.Path, "/") || + "" != endpoint.RawQuery || + "" != endpoint.Fragment { return nil, errors.New("CONTAINER_HOST must be an absolute unix:/// socket URL") } } @@ -74,9 +77,27 @@ func (p *Podman) info(ctx context.Context) (*podmanInfo, error) { func nodeInfoError(command string, err error, stderr []byte) error { if detail := bytes.TrimSpace(stderr); 0 < len(detail) { - slog.Error(fmt.Sprintf("Could not read Podman node information with %s: %v: %s", command, err, detail), "runtime", "podman", "command", command, "error", err, "stderr", string(detail)) + slog.Error( + fmt.Sprintf("Could not read Podman node information with %s: %v: %s", command, err, detail), + "runtime", + "podman", + "command", + command, + "error", + err, + "stderr", + string(detail), + ) } else { - slog.Error(fmt.Sprintf("Could not read Podman node information with %s: %v", command, err), "runtime", "podman", "command", command, "error", err) + slog.Error( + fmt.Sprintf("Could not read Podman node information with %s: %v", command, err), + "runtime", + "podman", + "command", + command, + "error", + err, + ) } return &engine.Error{Code: 502, Message: "Could not load node information. Try again."} } diff --git a/internal/engine/podman/info_test.go b/internal/engine/podman/info_test.go index 459ad53..05d0cf9 100644 --- a/internal/engine/podman/info_test.go +++ b/internal/engine/podman/info_test.go @@ -17,7 +17,8 @@ func TestNodeInfo(t *testing.T) { // Native Podman 4.3 info schema and stats JSON from podman-stats(1). infoFixture := readFixture(t, "info.json") statsFixture := readFixture(t, "stats.json") - ctx := context.WithValue(context.Background(), struct{}{}, "request") + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "request") calls := 0 p := New(func(got context.Context, cmd engine.Command) ([]byte, []byte, error) { if ctx != got || "podman" != cmd.Program || "" != cmd.Dir || 0 != len(cmd.Env) { @@ -50,7 +51,8 @@ func TestNodeInfo(t *testing.T) { t.Fatalf("stats: %+v", info.Containers) } encoded, err := json.Marshal(info) - if nil != err || strings.Contains(string(encoded), "do-not-expose") || strings.Contains(string(encoded), "secret-storage") || !strings.Contains(string(encoded), `"runtime":`) { + if nil != err || strings.Contains(string(encoded), "do-not-expose") || strings.Contains(string(encoded), "secret-storage") || + !strings.Contains(string(encoded), `"runtime":`) { t.Fatalf("encoded: %s, error: %v", encoded, err) } } @@ -131,7 +133,9 @@ func TestNodeInfoErrors(t *testing.T) { }) _, err := p.NodeInfo(context.Background()) var apiErr *engine.Error - if !errors.As(err, &apiErr) || 502 != apiErr.Code || "" == apiErr.Message || strings.Contains(apiErr.Message, "secret-output") || strings.Contains(apiErr.Message, "permission denied for Podman socket") { + if !errors.As(err, &apiErr) || 502 != apiErr.Code || "" == apiErr.Message || + strings.Contains(apiErr.Message, "secret-output") || + strings.Contains(apiErr.Message, "permission denied for Podman socket") { t.Fatalf("error: %v", err) } var record struct { @@ -145,10 +149,12 @@ func TestNodeInfoErrors(t *testing.T) { if strings.HasPrefix(test.name, "stats ") { command = "podman stats" } - if "ERROR" != record.Level || "podman" != record.Runtime || command != record.Command || "" == record.Error || strings.Contains(logs.String(), "secret-") { + if "ERROR" != record.Level || "podman" != record.Runtime || command != record.Command || "" == record.Error || + strings.Contains(logs.String(), "secret-") { t.Fatalf("logs: %s", &logs) } - if "" != test.fail && (!strings.Contains(record.Error, "exit status 1") || !strings.Contains(record.Error+record.Stderr, "permission denied for Podman socket")) { + if "" != test.fail && + (!strings.Contains(record.Error, "exit status 1") || !strings.Contains(record.Error+record.Stderr, "permission denied for Podman socket")) { t.Fatalf("missing failure details: %s", &logs) } if "stats" == test.fail && "permission denied for Podman socket" != record.Stderr { diff --git a/internal/engine/podman/podman.go b/internal/engine/podman/podman.go index 2097b11..87f39bc 100644 --- a/internal/engine/podman/podman.go +++ b/internal/engine/podman/podman.go @@ -126,7 +126,8 @@ func (p *Podman) EventStack(line []byte) string { Status string `json:"Status"` Attributes map[string]string `json:"Attributes"` } - if nil != json.Unmarshal(line, &event) || "container" != event.Type || "exec" == event.Status || strings.HasPrefix(event.Status, "exec_") { + if nil != json.Unmarshal(line, &event) || "container" != event.Type || "exec" == event.Status || + strings.HasPrefix(event.Status, "exec_") { return "" } return event.Attributes["com.docker.compose.project"] diff --git a/internal/engine/podman/podman_test.go b/internal/engine/podman/podman_test.go index c6aac85..743cad3 100644 --- a/internal/engine/podman/podman_test.go +++ b/internal/engine/podman/podman_test.go @@ -21,7 +21,12 @@ func testStack() engine.Stack { func TestCheck(t *testing.T) { infoFixture := readFixture(t, "info.json") - wantEnv := []string{"DOCKER_HOST=unix:///run/user/1000/podman/podman.sock", "DOCKER_CONTEXT=", "DOCKER_TLS_VERIFY=", "DOCKER_CERT_PATH="} + wantEnv := []string{ + "DOCKER_HOST=unix:///run/user/1000/podman/podman.sock", + "DOCKER_CONTEXT=", + "DOCKER_TLS_VERIFY=", + "DOCKER_CERT_PATH=", + } t.Setenv("DOCKER_CONTEXT", "unrelated-docker") t.Setenv("DOCKER_HOST", "tcp://unrelated-docker:2376") t.Setenv("DOCKER_TLS_VERIFY", "1") @@ -93,7 +98,9 @@ func TestMountedSocket(t *testing.T) { if !reflect.DeepEqual(want, cmd.Args) { t.Fatalf("info: %+v", cmd) } - return []byte(strings.Replace(fixture, `"serviceIsRemote":false`, `"serviceIsRemote":`+strconv.FormatBool(remote), 1)), nil, nil + return []byte( + strings.Replace(fixture, `"serviceIsRemote":false`, `"serviceIsRemote":`+strconv.FormatBool(remote), 1), + ), nil, nil } if "docker" != cmd.Program || !reflect.DeepEqual(composeEnv(host), cmd.Env) { t.Fatalf("compose: %+v", cmd) @@ -272,7 +279,8 @@ func TestEvents(t *testing.T) { func TestImageRefs(t *testing.T) { p := New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - if "docker" != cmd.Program || !slices.Contains(cmd.Args, "--images") || !slices.Contains(cmd.Args, "--no-env-resolution") || !slices.Contains(cmd.Env, "DOCKER_CONTEXT=") { + if "docker" != cmd.Program || !slices.Contains(cmd.Args, "--images") || !slices.Contains(cmd.Args, "--no-env-resolution") || + !slices.Contains(cmd.Env, "DOCKER_CONTEXT=") { t.Fatalf("images: %+v", cmd) } return []byte("nginx nginx:latest example.com/a:v1"), nil, nil diff --git a/internal/engine/podman/self.go b/internal/engine/podman/self.go index 082bfe9..a02dc58 100644 --- a/internal/engine/podman/self.go +++ b/internal/engine/podman/self.go @@ -35,7 +35,10 @@ func (p *Podman) SelfContainer(ctx context.Context) (*engine.SelfContainer, erro return nil, nil } if 1 != len(ids) { - return nil, selfContainerError(errors.New("more than one running container has the DCOMPOSE_AGENT=true label on this Podman host"), nil) + return nil, selfContainerError( + errors.New("more than one running container has the DCOMPOSE_AGENT=true label on this Podman host"), + nil, + ) } stdout, stderr, err = p.run(ctx, p.command("", "inspect", "--type", "container", "--format", selfContainerFormat, "--", ids[0])) if nil != err { @@ -49,8 +52,12 @@ func (p *Podman) SelfContainer(ctx context.Context) (*engine.SelfContainer, erro return nil, selfContainerError(err, nil) } self := data.SelfContainer - if !containerID.MatchString(self.ID) || ids[0] != self.ID || !containerID.MatchString(strings.TrimPrefix(self.Image, "sha256:")) || "" == self.Project { - return nil, selfContainerError(errors.New("container inspect did not return the expected full container ID, immutable image ID and Compose project"), nil) + if !containerID.MatchString(self.ID) || ids[0] != self.ID || !containerID.MatchString(strings.TrimPrefix(self.Image, "sha256:")) || + "" == self.Project { + return nil, selfContainerError( + errors.New("container inspect did not return the expected full container ID, immutable image ID and Compose project"), + nil, + ) } // Podman omits tmpfs from Mounts. Keep it for shared-path validation. for destination := range data.Tmpfs { @@ -64,13 +71,33 @@ func (p *Podman) StartDetached(ctx context.Context, self engine.SelfContainer, n return err } if !containerID.MatchString(self.ID) || !containerID.MatchString(strings.TrimPrefix(self.Image, "sha256:")) { - return &engine.Error{Code: 409, Message: "Could not start the action on this node's own stack.", Cause: errors.New("node container requires an exact container ID and immutable image ID")} + return &engine.Error{ + Code: 409, + Message: "Could not start the action on this node's own stack.", + Cause: errors.New("node container requires an exact container ID and immutable image ID"), + } } groups, err := os.Getgroups() if nil != err { return detachedError("Could not start the action on this node's own stack.", err, nil) } - args := []string{"run", "--detach", "--name", name, "--label", "io.dcompose.operation=" + strings.TrimPrefix(name, "dagent-operation-"), "--restart=no", "--pull", "never", "--volumes-from", self.ID, "--security-opt", "label=disable", "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())} + args := []string{ + "run", + "--detach", + "--name", + name, + "--label", + "io.dcompose.operation=" + strings.TrimPrefix(name, "dagent-operation-"), + "--restart=no", + "--pull", + "never", + "--volumes-from", + self.ID, + "--security-opt", + "label=disable", + "--user", + fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + } for _, group := range groups { args = append(args, "--group-add", strconv.Itoa(group)) } @@ -99,7 +126,19 @@ func detachedError(message string, err error, stderr []byte) error { func (p *Podman) DetachedState(ctx context.Context, name string) (engine.DetachedState, error) { var state engine.DetachedState - stdout, stderr, err := p.run(ctx, p.command("", "inspect", "--type", "container", "--format", `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, "--", name)) + stdout, stderr, err := p.run( + ctx, + p.command( + "", + "inspect", + "--type", + "container", + "--format", + `{"Running":{{json .State.Running}},"ExitCode":{{json .State.ExitCode}}}`, + "--", + name, + ), + ) if nil != err { return state, detachedError("Could not check the action's status.", err, stderr) } diff --git a/internal/engine/podman/self_test.go b/internal/engine/podman/self_test.go index 0e44626..4f209cb 100644 --- a/internal/engine/podman/self_test.go +++ b/internal/engine/podman/self_test.go @@ -76,7 +76,8 @@ func TestSelfContainer(t *testing.T) { if test.found != (nil != self) || (nil != self && (id != self.ID || "node" != self.Project)) { t.Fatalf("self: %+v", self) } - if "found" == test.name && (2 != len(self.Mounts) || "tmpfs" != self.Mounts[1].Type || "/cache/tmp" != self.Mounts[1].Destination || !self.Mounts[0].RW) { + if "found" == test.name && + (2 != len(self.Mounts) || "tmpfs" != self.Mounts[1].Type || "/cache/tmp" != self.Mounts[1].Destination || !self.Mounts[0].RW) { t.Fatalf("mounts: %+v", self.Mounts) } }) diff --git a/internal/engine/podman/shells.go b/internal/engine/podman/shells.go index a15abec..6da5e2e 100644 --- a/internal/engine/podman/shells.go +++ b/internal/engine/podman/shells.go @@ -44,9 +44,35 @@ func (p *Podman) ResolveContainer(ctx context.Context, s engine.Stack, reference output, stderr, err := p.run(ctx, p.command(s.Dir, "inspect", "--type", "container", "--", reference)) if nil != err { if detail := strings.TrimSpace(string(stderr)); "" != detail { - slog.Error(fmt.Sprintf("Could not inspect Podman container %q in stack %q for a shell: %v: %s", reference, s.Name, err, detail), "runtime", "podman", "command", "podman inspect", "stack", s.Name, "container", reference, "error", err, "stderr", detail) + slog.Error( + fmt.Sprintf("Could not inspect Podman container %q in stack %q for a shell: %v: %s", reference, s.Name, err, detail), + "runtime", + "podman", + "command", + "podman inspect", + "stack", + s.Name, + "container", + reference, + "error", + err, + "stderr", + detail, + ) } else { - slog.Error(fmt.Sprintf("Could not inspect Podman container %q in stack %q for a shell: %v", reference, s.Name, err), "runtime", "podman", "command", "podman inspect", "stack", s.Name, "container", reference, "error", err) + slog.Error( + fmt.Sprintf("Could not inspect Podman container %q in stack %q for a shell: %v", reference, s.Name, err), + "runtime", + "podman", + "command", + "podman inspect", + "stack", + s.Name, + "container", + reference, + "error", + err, + ) } return "", &engine.Error{Code: 404, Message: "container not found"} } diff --git a/internal/engine/podman/shells_test.go b/internal/engine/podman/shells_test.go index e9d8e36..5ac06c0 100644 --- a/internal/engine/podman/shells_test.go +++ b/internal/engine/podman/shells_test.go @@ -35,7 +35,9 @@ func TestShellInspectionMessage(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatal(err) } - if "ERROR" != record.Level || "podman" != record.Runtime || "demo" != record.Stack || "demo-web" != record.Container || "exit status 1" != record.Error || stderr != record.Stderr { + if "ERROR" != record.Level || "podman" != record.Runtime || "demo" != record.Stack || "demo-web" != record.Container || + "exit status 1" != record.Error || + stderr != record.Stderr { t.Fatalf("inspection fields: %+v", record) } for _, detail := range []string{record.Stack, record.Container, record.Error, stderr} { @@ -54,7 +56,8 @@ func TestShell(t *testing.T) { fixture := readFixture(t, "container-inspect.json") id := strings.Repeat("a", 64) p := New(func(_ context.Context, cmd engine.Command) ([]byte, []byte, error) { - if "podman" != cmd.Program || testStack().Dir != cmd.Dir || !reflect.DeepEqual([]string{"inspect", "--type", "container", "--", "demo-web"}, cmd.Args) { + if "podman" != cmd.Program || testStack().Dir != cmd.Dir || + !reflect.DeepEqual([]string{"inspect", "--type", "container", "--", "demo-web"}, cmd.Args) { t.Fatalf("inspect: %+v", cmd) } return []byte(fixture), nil, nil @@ -64,7 +67,21 @@ func TestShell(t *testing.T) { t.Fatalf("container: %s %v", container, err) } cmd := p.Shell(container, "bash", "session") - want := []string{"exec", "-it", "--detach-keys=", "--env", "DCOMPOSE_SHELL_ID=session", "--env", "TERM=xterm-256color", id, "sh", "-c", shellBootstrap, "dcompose-shell", "bash"} + want := []string{ + "exec", + "-it", + "--detach-keys=", + "--env", + "DCOMPOSE_SHELL_ID=session", + "--env", + "TERM=xterm-256color", + id, + "sh", + "-c", + shellBootstrap, + "dcompose-shell", + "bash", + } if "podman" != cmd.Program || !reflect.DeepEqual(want, cmd.Args) || !strings.Contains(shellBootstrap, "$DCOMPOSE_SHELL_ID") { t.Fatalf("shell: %+v", cmd) } diff --git a/internal/engine/registry/auth.go b/internal/engine/registry/auth.go index d9c1f07..930cc1d 100644 --- a/internal/engine/registry/auth.go +++ b/internal/engine/registry/auth.go @@ -104,7 +104,9 @@ func resolveCredentials(data []byte, registry string) (credentials, error) { } for key, helper := range config.Helpers { if registry == authRegistry(key) && "" != helper { - return credentials{}, errors.New("registry login uses a credential helper; configure an inline registry login for metadata checks") + return credentials{}, errors.New( + "registry login uses a credential helper; configure an inline registry login for metadata checks", + ) } } if "" != config.Store { @@ -249,7 +251,13 @@ func (l *lookup) exchange(ctx context.Context, params map[string]string) (string var body io.Reader if "" != l.login.IdentityToken { method = http.MethodPost - values = url.Values{"grant_type": {"refresh_token"}, "refresh_token": {l.login.IdentityToken}, "service": {params["service"]}, "scope": {scope}, "client_id": {"docker"}} + values = url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {l.login.IdentityToken}, + "service": {params["service"]}, + "scope": {scope}, + "client_id": {"docker"}, + } body = strings.NewReader(values.Encode()) } else { if "" != l.login.Username { @@ -273,7 +281,7 @@ func (l *lookup) exchange(ctx context.Context, params map[string]string) (string if http.StatusOK != resp.StatusCode { return "", time.Time{}, responseError(resp) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var token struct { Token string `json:"token"` AccessToken string `json:"access_token"` diff --git a/internal/engine/registry/cooldown.go b/internal/engine/registry/cooldown.go index 312049f..59c1ecb 100644 --- a/internal/engine/registry/cooldown.go +++ b/internal/engine/registry/cooldown.go @@ -108,14 +108,38 @@ func (l *lookup) rateLimited(ctx context.Context, resp *http.Response) error { if nil != ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Registry %q rate-limited %s while checking image %q: %s. Metadata checks for this registry/login will retry after %s.", l.ref.registry, login, l.key.Ref, cause, until.UTC().Format(time.RFC3339)), "image", l.key.Ref, "registry", l.ref.registry, "error", cause, "retry_at", until) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Registry %q rate-limited %s while checking image %q: %s. Metadata checks for this registry/login will retry after %s.", + l.ref.registry, + login, + l.key.Ref, + cause, + until.UTC().Format(time.RFC3339), + ), + "image", + l.key.Ref, + "registry", + l.ref.registry, + "error", + cause, + "retry_at", + until, + ) } return &cooldownError{state: state} } func retryAfter(value string, now time.Time) (time.Time, bool) { value = strings.TrimSpace(value) - if seconds, err := strconv.ParseInt(value, 10, 64); nil == err && 0 <= seconds && seconds <= int64((time.Duration(1<<63-1))/time.Second) { + if seconds, err := strconv.ParseInt( + value, + 10, + 64, + ); nil == err && 0 <= seconds && + seconds <= int64((time.Duration(1<<63-1))/time.Second) { return now.Add(time.Duration(seconds) * time.Second), true } if until, err := http.ParseTime(value); nil == err { diff --git a/internal/engine/registry/manifest.go b/internal/engine/registry/manifest.go index ab1492b..54edf0f 100644 --- a/internal/engine/registry/manifest.go +++ b/internal/engine/registry/manifest.go @@ -159,7 +159,8 @@ func platformMatches(a, b engine.ImagePlatform) bool { } return p.Variant } - return "" != a.OS && "" != a.Architecture && a.OS == b.OS && a.Architecture == b.Architecture && a.OSVersion == b.OSVersion && variant(a) == variant(b) + return "" != a.OS && "" != a.Architecture && a.OS == b.OS && a.Architecture == b.Architecture && a.OSVersion == b.OSVersion && + variant(a) == variant(b) } type manifestError struct{ cause string } @@ -169,8 +170,7 @@ func invalidManifest(cause string) (engine.RegistryImage, error) { return engine.RegistryImage{Status: "error", Reason: "manifest_invalid"}, &manifestError{cause: cause} } func manifestFailure(err error) engine.RegistryImage { - var invalid *manifestError - if errors.As(err, &invalid) { + if _, ok := errors.AsType[*manifestError](err); ok { return engine.RegistryImage{Status: "error", Reason: "manifest_invalid"} } return engine.RegistryImage{Status: "error", Reason: "registry_error"} @@ -206,7 +206,7 @@ func (l *lookup) fetch(ctx context.Context, endpoint, expected string) ([]byte, if http.StatusOK != resp.StatusCode { return nil, "", "", responseError(resp) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(io.LimitReader(resp.Body, maxMetadataSize+1)) if nil != err { return nil, "", "", fmt.Errorf("could not read registry metadata: %w", err) diff --git a/internal/engine/registry/registry.go b/internal/engine/registry/registry.go index 51881f2..ea85782 100644 --- a/internal/engine/registry/registry.go +++ b/internal/engine/registry/registry.go @@ -36,7 +36,13 @@ func NewClient(httpClient *http.Client) *Client { if nil == httpClient { httpClient = &http.Client{Transport: http.DefaultTransport.(*http.Transport).Clone(), Timeout: 30 * time.Second} } - return &Client{http: httpClient, Now: time.Now, tokens: make(map[string]authorization), roots: make(map[rootKey]*rootState), cooldowns: make(map[cooldownKey]engine.RegistryCooldown)} + return &Client{ + http: httpClient, + Now: time.Now, + tokens: make(map[string]authorization), + roots: make(map[rootKey]*rootState), + cooldowns: make(map[cooldownKey]engine.RegistryCooldown), + } } func (c *Client) InspectConfig(ctx context.Context, key engine.ImageKey, data []byte, ages ...time.Duration) engine.RegistryImage { @@ -57,8 +63,7 @@ func (c *Client) InspectConfig(ctx context.Context, key engine.ImageKey, data [] if nil == err { return result } - var cooldown *cooldownError - if errors.As(err, &cooldown) { + if cooldown, ok := errors.AsType[*cooldownError](err); ok { checkedAt := result.CheckedAt if checkedAt.IsZero() { checkedAt = c.Now() @@ -75,7 +80,17 @@ func (c *Client) InspectConfig(ctx context.Context, key engine.ImageKey, data [] level = slog.LevelDebug } detail := sanitize(err.Error()) - slog.Log(ctx, level, fmt.Sprintf("Could not inspect registry image %q for updates: %s", key.Ref, detail), "image", key.Ref, "platform", key.ImagePlatform, "error", detail) + slog.Log( + ctx, + level, + fmt.Sprintf("Could not inspect registry image %q for updates: %s", key.Ref, detail), + "image", + key.Ref, + "platform", + key.ImagePlatform, + "error", + detail, + ) return result } @@ -107,7 +122,7 @@ func parseReference(ref string) (reference, error) { if "" == name || "" == tag { return reference{}, errors.New("image repository or tag is empty") } - for _, part := range strings.Split(name, "/") { + for part := range strings.SplitSeq(name, "/") { if "" == part || "." == part || ".." == part { return reference{}, errors.New("invalid image repository") } @@ -175,8 +190,7 @@ func (l *lookup) send(req *http.Request) (*http.Response, error) { if nil != resp { _ = resp.Body.Close() } - var urlErr *url.Error - if errors.As(err, &urlErr) { + if urlErr, ok := errors.AsType[*url.Error](err); ok { err = urlErr.Err } return nil, fmt.Errorf("%s registry metadata: %w", req.Method, err) @@ -203,7 +217,7 @@ func (l *lookup) sharedError(err error) error { } func responseError(resp *http.Response) error { - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) if nil != err { return fmt.Errorf("HTTP %d %s (could not read registry error: %v)", resp.StatusCode, http.StatusText(resp.StatusCode), err) @@ -246,7 +260,11 @@ func registryRedactor(data []byte) *strings.Replacer { for _, auth := range config.Auths { secrets := []string{auth.Auth, auth.Password, auth.IdentityToken, auth.RegistryToken} if "" != auth.Password { - secrets = append(secrets, auth.Username+":"+auth.Password, base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password))) + secrets = append( + secrets, + auth.Username+":"+auth.Password, + base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password)), + ) } if decoded, err := base64.StdEncoding.DecodeString(auth.Auth); nil == err { secrets = append(secrets, string(decoded)) diff --git a/internal/engine/registry/registry_test.go b/internal/engine/registry/registry_test.go index 3700cff..2929554 100644 --- a/internal/engine/registry/registry_test.go +++ b/internal/engine/registry/registry_test.go @@ -27,7 +27,7 @@ func TestMain(m *testing.M) { os.Exit(testutil.Run(m)) } var linuxAMD64 = engine.ImagePlatform{OS: "linux", Architecture: "amd64"} // encode handles heterogeneous fixtures only at the encoding/json test boundary. -func encode(t *testing.T, value interface{}) []byte { +func encode(t *testing.T, value any) []byte { t.Helper() data, err := json.Marshal(value) if nil != err { @@ -46,7 +46,12 @@ func serve(t *testing.T, handler http.HandlerFunc) (*Client, engine.ImageKey) { t.Helper() server := httptest.NewTLSServer(handler) t.Cleanup(server.Close) - return NewClient(server.Client()), engine.ImageKey{Ref: strings.TrimPrefix(server.URL, "https://") + "/team/app:latest", ImagePlatform: linuxAMD64} + return NewClient( + server.Client(), + ), engine.ImageKey{ + Ref: strings.TrimPrefix(server.URL, "https://") + "/team/app:latest", + ImagePlatform: linuxAMD64, + } } func respond(w http.ResponseWriter, r *http.Request, data []byte) { @@ -84,7 +89,7 @@ func TestIndex(t *testing.T) { w.WriteHeader(http.StatusNotFound) } }) - for i := 0; i < 2; i++ { + for range 2 { got := client.InspectConfig(context.Background(), key, []byte("{}")) var parsed manifest _ = json.Unmarshal(child, &parsed) @@ -92,7 +97,11 @@ func TestIndex(t *testing.T) { t.Fatalf("image: %+v", got) } } - want := map[string]int{"HEAD /v2/team/app/manifests/latest": 2, "GET /v2/team/app/manifests/latest": 1, "GET /v2/team/app/manifests/" + contentDigest(child): 1} + want := map[string]int{ + "HEAD /v2/team/app/manifests/latest": 2, + "GET /v2/team/app/manifests/latest": 1, + "GET /v2/team/app/manifests/" + contentDigest(child): 1, + } mu.Lock() defer mu.Unlock() if !reflect.DeepEqual(want, requests) { @@ -172,7 +181,7 @@ func TestSingle(t *testing.T) { } respond(w, r, root) }) - for i := 0; i < 2; i++ { + for range 2 { got := client.InspectConfig(context.Background(), key, []byte("{}")) if contentDigest(config) != got.Digest || "" != got.Status { t.Fatalf("image: %+v", got) @@ -240,7 +249,17 @@ func TestSharedIndex(t *testing.T) { arm := engine.ImagePlatform{OS: "linux", Architecture: "arm64"} amdManifest, _ := imageManifest(t, linuxAMD64, ociManifest) armManifest, _ := imageManifest(t, arm, ociManifest) - root := encode(t, manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, {Digest: contentDigest(armManifest), Platform: &arm}}}) + root := encode( + t, + manifest{ + SchemaVersion: 2, + MediaType: ociIndex, + Manifests: []descriptor{ + {Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, + {Digest: contentDigest(armManifest), Platform: &arm}, + }, + }, + ) var mu sync.Mutex var heads, roots, children int client, key := serve(t, func(w http.ResponseWriter, r *http.Request) { @@ -325,7 +344,17 @@ func TestObservationFailure(t *testing.T) { arm := engine.ImagePlatform{OS: "linux", Architecture: "arm64"} amdManifest, _ := imageManifest(t, linuxAMD64, ociManifest) armManifest, _ := imageManifest(t, arm, ociManifest) - root := encode(t, manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, {Digest: contentDigest(armManifest), Platform: &arm}}}) + root := encode( + t, + manifest{ + SchemaVersion: 2, + MediaType: ociIndex, + Manifests: []descriptor{ + {Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, + {Digest: contentDigest(armManifest), Platform: &arm}, + }, + }, + ) var requests atomic.Int32 client, key := serve(t, func(w http.ResponseWriter, r *http.Request) { requests.Add(1) @@ -361,7 +390,17 @@ func TestConcurrentRoot(t *testing.T) { arm := engine.ImagePlatform{OS: "linux", Architecture: "arm64"} amdManifest, _ := imageManifest(t, linuxAMD64, ociManifest) armManifest, _ := imageManifest(t, arm, ociManifest) - root := encode(t, manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, {Digest: contentDigest(armManifest), Platform: &arm}}}) + root := encode( + t, + manifest{ + SchemaVersion: 2, + MediaType: ociIndex, + Manifests: []descriptor{ + {Digest: contentDigest(amdManifest), Platform: &linuxAMD64}, + {Digest: contentDigest(armManifest), Platform: &arm}, + }, + }, + ) entered, release := make(chan struct{}), make(chan struct{}) var heads, roots, children atomic.Int32 client, key := serve(t, func(w http.ResponseWriter, r *http.Request) { @@ -391,7 +430,7 @@ func TestConcurrentRoot(t *testing.T) { var workers, started sync.WaitGroup started.Add(4) workers.Add(4) - for i := 0; i < 4; i++ { + for i := range 4 { go func() { defer workers.Done() target := key @@ -418,7 +457,10 @@ func TestRootChange(t *testing.T) { var mu sync.Mutex current := child rootFor := func(data []byte) []byte { - return encode(t, manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Digest: contentDigest(data), Platform: &linuxAMD64}}}) + return encode( + t, + manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Digest: contentDigest(data), Platform: &linuxAMD64}}}, + ) } roots, children := 0, 0 client, key := serve(t, func(w http.ResponseWriter, r *http.Request) { @@ -462,7 +504,14 @@ func TestInvalidMetadata(t *testing.T) { case "config_digest": root = []byte(`{"schemaVersion":2,"mediaType":"` + ociManifest + `","config":{"digest":"bad"}}`) case "child_digest": - root = encode(t, manifest{SchemaVersion: 2, MediaType: ociIndex, Manifests: []descriptor{{Platform: &linuxAMD64, Digest: "sha256:" + strings.Repeat("a", 64)}}}) + root = encode( + t, + manifest{ + SchemaVersion: 2, + MediaType: ociIndex, + Manifests: []descriptor{{Platform: &linuxAMD64, Digest: "sha256:" + strings.Repeat("a", 64)}}, + }, + ) case "blob_digest": config = []byte("wrong bytes") case "platform_missing": @@ -520,7 +569,8 @@ func TestAuth(t *testing.T) { if err := r.ParseForm(); nil != err { t.Error(err) } - if http.MethodPost != r.Method || "refresh_token" != r.Form.Get("grant_type") || "refresh-secret" != r.Form.Get("refresh_token") { + if http.MethodPost != r.Method || "refresh_token" != r.Form.Get("grant_type") || + "refresh-secret" != r.Form.Get("refresh_token") { t.Errorf("refresh grant: %s %#v", r.Method, r.Form) } } else { @@ -583,7 +633,7 @@ func TestAuth(t *testing.T) { case "anonymous": login = credentials{} } - for i := 0; i < 2; i++ { + for range 2 { configKey := host if "identity" == name { configKey = "https://" + host + "/v1/access-token" @@ -649,7 +699,12 @@ func TestCredentialIsolation(t *testing.T) { host := strings.TrimPrefix(server.URL, "https://") key := engine.ImageKey{Ref: host + "/team/app:latest", ImagePlatform: linuxAMD64} for _, password := range []string{"full-secret", "limited-secret", "full-secret"} { - if got := client.InspectConfig(context.Background(), key, authConfig(host, credentials{Username: "same-user", Password: password}), time.Hour); "" != got.Status { + if got := client.InspectConfig( + context.Background(), + key, + authConfig(host, credentials{Username: "same-user", Password: password}), + time.Hour, + ); "" != got.Status { t.Fatalf("image: %+v", got) } } @@ -702,7 +757,9 @@ func TestTokenExpiry(t *testing.T) { } func TestNativeAuthKeys(t *testing.T) { - data := []byte(`{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcjpwYXNz"},"https://index.docker.io/v1/access-token":{"identitytoken":"access-secret"},"https://index.docker.io/v1/refresh-token":{"registrytoken":"refresh-secret"},"https://private.test":{"registrytoken":"private-secret"}}}`) + data := []byte( + `{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcjpwYXNz"},"https://index.docker.io/v1/access-token":{"identitytoken":"access-secret"},"https://index.docker.io/v1/refresh-token":{"registrytoken":"refresh-secret"},"https://private.test":{"registrytoken":"private-secret"}}}`, + ) for _, host := range []string{"docker.io", "index.docker.io", "registry-1.docker.io"} { login, err := resolveCredentials(data, canonicalHost(host)) if nil != err || "user" != login.Username || "pass" != login.Password || "" != login.IdentityToken || "" != login.RegistryToken { @@ -762,7 +819,9 @@ func TestRateLimit(t *testing.T) { }) client.Now = func() time.Time { return now } got := client.InspectConfig(context.Background(), key, []byte("{}")) - if "error" != got.Status || "registry_error" != got.Reason || nil == got.Cooldown || now.Add(test.delay) != got.Cooldown.Until || 1 != got.Cooldown.Attempts { + if "error" != got.Status || "registry_error" != got.Reason || nil == got.Cooldown || + now.Add(test.delay) != got.Cooldown.Until || + 1 != got.Cooldown.Attempts { t.Fatalf("cooldown: %+v", got.Cooldown) } key.Ref = strings.Replace(key.Ref, "/team/app:", "/other/image:", 1) @@ -787,9 +846,13 @@ func TestCooldownAccounts(t *testing.T) { w.WriteHeader(http.StatusTooManyRequests) }) client.Now = func() time.Time { return now } - host := strings.SplitN(key.Ref, "/", 2)[0] + host, _, _ := strings.Cut(key.Ref, "/") for _, username := range []string{"one", "two", "one"} { - if got := client.InspectConfig(context.Background(), key, authConfig(host, credentials{Username: username, Password: "secret"})); nil == got.Cooldown { + if got := client.InspectConfig( + context.Background(), + key, + authConfig(host, credentials{Username: username, Password: "secret"}), + ); nil == got.Cooldown { t.Fatalf("missing cooldown: %+v", got) } } @@ -902,7 +965,9 @@ func TestCooldownLogs(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatal(err) } - if nil == got.Cooldown || "WARN" != record.Level || key.Ref != record.Image || got.Cooldown.Registry != record.Registry || !got.Cooldown.Until.Equal(record.RetryAt) || !strings.Contains(record.Error, fmt.Sprint(http.StatusTooManyRequests)) { + if nil == got.Cooldown || "WARN" != record.Level || key.Ref != record.Image || got.Cooldown.Registry != record.Registry || + !got.Cooldown.Until.Equal(record.RetryAt) || + !strings.Contains(record.Error, fmt.Sprint(http.StatusTooManyRequests)) { t.Fatalf("cooldown warning: %s", first) } if !strings.Contains(record.Message, key.Ref) { @@ -963,7 +1028,12 @@ func TestTransportFailure(t *testing.T) { for _, status := range []int{401, 403, 404, 429, 500} { t.Run(fmt.Sprint(status), func(t *testing.T) { client, key := serve(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }) - if got := client.InspectConfig(context.Background(), key, []byte("{}")); "error" != got.Status || "registry_error" != got.Reason { + if got := client.InspectConfig( + context.Background(), + key, + []byte("{}"), + ); "error" != got.Status || + "registry_error" != got.Reason { t.Fatalf("registry HTTP error: %+v", got) } }) @@ -1025,7 +1095,10 @@ func TestDiagnostics(t *testing.T) { if err := json.Unmarshal(logs.Bytes(), &record); nil != err { t.Fatal(err) } - if "WARN" != record.Level || key.Ref != record.Image || !strings.Contains(record.Error, fmt.Sprint(http.StatusForbidden)) || !strings.Contains(record.Error, "login rejected") || !strings.Contains(record.Message, key.Ref) || !strings.Contains(record.Message, "login rejected") { + if "WARN" != record.Level || key.Ref != record.Image || !strings.Contains(record.Error, fmt.Sprint(http.StatusForbidden)) || + !strings.Contains(record.Error, "login rejected") || + !strings.Contains(record.Message, key.Ref) || + !strings.Contains(record.Message, "login rejected") { t.Fatalf("missing human diagnostics: %s", &logs) } logs.Reset() @@ -1047,24 +1120,26 @@ func TestConcurrent(t *testing.T) { } }) var workers sync.WaitGroup - for i := 0; i < 4; i++ { - workers.Add(1) - go func() { - defer workers.Done() - for j := 0; j < 5; j++ { + for range 4 { + workers.Go(func() { + for range 5 { got := client.InspectConfig(context.Background(), key, []byte("{}")) if "" != got.Status { t.Errorf("image: %+v", got) } _ = client.Cooldowns() } - }() + }) } workers.Wait() } func TestAuthChallenge(t *testing.T) { - challenge := findChallenge([]string{`Basic realm="basic, realm", Bearer realm="https://auth.test/token?a=b", service="registry.test",scope="repository:team/app:pull"`}) + challenge := findChallenge( + []string{ + `Basic realm="basic, realm", Bearer realm="https://auth.test/token?a=b", service="registry.test",scope="repository:team/app:pull"`, + }, + ) if "bearer" != challenge.scheme || "https://auth.test/token?a=b" != challenge.params["realm"] { t.Fatalf("challenge: %+v", challenge) } diff --git a/internal/engine/registry/root.go b/internal/engine/registry/root.go index 88f3c7e..5697efa 100644 --- a/internal/engine/registry/root.go +++ b/internal/engine/registry/root.go @@ -106,7 +106,10 @@ func (l *lookup) refreshRoot(ctx context.Context, previous *rootObservation) (*r if nil != err { return nil, err } - platforms := &platformCache{images: make(map[engine.ImagePlatform]engine.RegistryImage), pending: make(map[engine.ImagePlatform]*imageFlight)} + platforms := &platformCache{ + images: make(map[engine.ImagePlatform]engine.RegistryImage), + pending: make(map[engine.ImagePlatform]*imageFlight), + } if nil != previous && previous.digest == digest { platforms = previous.platforms } diff --git a/internal/logging/console_test.go b/internal/logging/console_test.go index 73bb925..c8bf522 100644 --- a/internal/logging/console_test.go +++ b/internal/logging/console_test.go @@ -31,7 +31,23 @@ func TestConsoleAttrs(t *testing.T) { var output bytes.Buffer logger := slog.New(NewConsoleHandler(&output, nil)) logger = logger.With("node", "local", "msg", "bound").WithGroup("request").With("operation", "deploy").WithGroup("details") - logger.Info("diagnostic", "attempt", int64(9_007_199_254_740_993), "maximum", ^uint64(0), "path", `C:\stacks\compose.yaml`, "error", errors.New("failure"), "codes", []int{401, 403}, "automatic", true, slog.Group("nested", "value", 2), slog.Group("", "inline", 3)) + logger.Info( + "diagnostic", + "attempt", + int64(9_007_199_254_740_993), + "maximum", + ^uint64(0), + "path", + `C:\stacks\compose.yaml`, + "error", + errors.New("failure"), + "codes", + []int{401, 403}, + "automatic", + true, + slog.Group("nested", "value", 2), + slog.Group("", "inline", 3), + ) for _, attr := range []string{`node=local`, `msg=bound`, `request.operation=deploy`, `request.details.attempt=9007199254740993`, `request.details.maximum=18446744073709551615`, `request.details.path=C:\stacks\compose.yaml`, `request.details.error=failure`, `request.details.codes="[401 403]"`, `request.details.automatic=true`, `request.details.nested.value=2`, `request.details.inline=3`} { if !strings.Contains(output.String(), " "+attr) { t.Fatalf("missing attribute %q: %s", attr, output.String()) @@ -71,9 +87,7 @@ func TestConsoleConcurrent(t *testing.T) { stamp := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) var workers sync.WaitGroup for worker := 0; 8 > worker; worker++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { bound := handler.WithAttrs([]slog.Attr{slog.Int("worker", worker)}).WithGroup("entry") for index := 0; 20 > index; index++ { record := slog.NewRecord(stamp, slog.LevelInfo, fmt.Sprintf(`node "%d" path "C:\%d"`, worker, index), 0) @@ -82,7 +96,7 @@ func TestConsoleConcurrent(t *testing.T) { t.Error(err) } } - }() + }) } workers.Wait() lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") @@ -95,7 +109,13 @@ func TestConsoleConcurrent(t *testing.T) { } for worker := 0; 8 > worker; worker++ { for index := 0; 20 > index; index++ { - expected := fmt.Sprintf(`2026-10-29T12:00:00Z INFO node "%d" path "C:\%d" worker=%d entry.index=%d`, worker, index, worker, index) + expected := fmt.Sprintf( + `2026-10-29T12:00:00Z INFO node "%d" path "C:\%d" worker=%d entry.index=%d`, + worker, + index, + worker, + index, + ) if 1 != actual[expected] { t.Fatalf("mixed or missing line: %q", expected) } diff --git a/internal/server/api_key_test.go b/internal/server/api_key_test.go index b1caa32..249a934 100644 --- a/internal/server/api_key_test.go +++ b/internal/server/api_key_test.go @@ -67,8 +67,8 @@ func TestAPIKeyConfig(t *testing.T) { t.Fatal("API key reference was not persisted", err) } files := []string{path} - if strings.HasPrefix(reference, "file:") { - secretPath := configutil.Path(path, strings.TrimPrefix(reference, "file:")) + if filename, ok := strings.CutPrefix(reference, "file:"); ok { + secretPath := configutil.Path(path, filename) files = append(files, secretPath) data, err := os.ReadFile(secretPath) if nil != err || c.Auth.apiKey != strings.TrimSuffix(string(data), "\n") { @@ -304,9 +304,13 @@ func TestAPIKeyWebsocket(t *testing.T) { if "" != test.origin { headers.Set("Origin", test.origin) } - conn, response, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(app.URL, "http")+"/api/events", &websocket.DialOptions{HTTPHeader: headers}) + conn, response, err := websocket.Dial( + ctx, + "ws"+strings.TrimPrefix(app.URL, "http")+"/api/events", + &websocket.DialOptions{HTTPHeader: headers}, + ) if nil != conn { - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() } if nil == response || test.status != response.StatusCode { t.Fatal("unexpected Bearer websocket handshake", response, err) diff --git a/internal/server/backup.go b/internal/server/backup.go index ff70e66..f726321 100644 --- a/internal/server/backup.go +++ b/internal/server/backup.go @@ -73,13 +73,29 @@ func (s *Server) backup(w http.ResponseWriter, r *http.Request) { var upstream nodeHTTPError _ = errors.As(err, &upstream) detail := safeTransportError(err) - slog.Error(fmt.Sprintf("Could not back up %s: %s", nodeLogLabel(node), detail), "node", nodeLogLabel(node), "operation", "backup", "status", upstream.status, "error", detail) + slog.Error( + fmt.Sprintf("Could not back up %s: %s", nodeLogLabel(node), detail), + "node", + nodeLogLabel(node), + "operation", + "backup", + "status", + upstream.status, + "error", + detail, + ) apiError(w, http.StatusBadGateway, fmt.Sprintf("Could not back up node %s. Try again.", node.Name)) return } for _, file := range backup.Files { if !fs.ValidPath(file.Path) { - slog.Error(fmt.Sprintf("Could not back up %s: the node returned an invalid relative file path.", nodeLogLabel(node)), "node", nodeLogLabel(node), "operation", "backup") + slog.Error( + fmt.Sprintf("Could not back up %s: the node returned an invalid relative file path.", nodeLogLabel(node)), + "node", + nodeLogLabel(node), + "operation", + "backup", + ) apiError(w, http.StatusBadGateway, fmt.Sprintf("Could not back up node %s. Check the server logs.", node.Name)) return } @@ -128,13 +144,17 @@ func collectNodeBackup(ctx context.Context, node NodeConfig, client *http.Client if nil != err { return backup, err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() decoder := json.NewDecoder(io.LimitReader(response.Body, 16<<20)) if err := decoder.Decode(&backup); nil != err { return backup, nodeHTTPError{operation: "read backup files", status: response.StatusCode, message: err.Error()} } - if err := decoder.Decode(new(interface{})); io.EOF != err { - return backup, nodeHTTPError{operation: "read backup files", status: response.StatusCode, message: fmt.Sprintf("unexpected trailing data: %v", err)} + if err := decoder.Decode(new(any)); io.EOF != err { + return backup, nodeHTTPError{ + operation: "read backup files", + status: response.StatusCode, + message: fmt.Sprintf("unexpected trailing data: %v", err), + } } if http.StatusOK != response.StatusCode { return backup, nodeHTTPError{operation: "backup", status: response.StatusCode, message: backup.Error} diff --git a/internal/server/backup_test.go b/internal/server/backup_test.go index acd259e..1e4b4a4 100644 --- a/internal/server/backup_test.go +++ b/internal/server/backup_test.go @@ -40,11 +40,31 @@ func TestBackupExport(t *testing.T) { s.config.Auth.apiKey = strings.Repeat("cd", 32) s.config.Auth.OIDC = &OIDCConfig{ClientSecret: "file:oidc-secret", clientSecret: "oidc-secret"} s.config.Registries = []RegistryConfig{ - {ID: "registry", Registry: "https://index.docker.io/v1/", Label: "Personal", AuthFile: "file:registry-auth", auth: map[string]string{"username": "alice", "password": "registry-password", "auth": "native-encoded", "identitytoken": "native-identity", "registrytoken": "native-registry", "email": "alice@example.com", "serveraddress": "docker.io"}, BackgroundChecks: true}, - {ID: "refresh", Registry: "https://index.docker.io/v1/refresh-token", AuthFile: "file:refresh-auth", auth: map[string]string{"username": "", "password": "refresh-secret:client-id"}}, + { + ID: "registry", + Registry: "https://index.docker.io/v1/", + Label: "Personal", + AuthFile: "file:registry-auth", + auth: map[string]string{ + "username": "alice", + "password": "registry-password", + "auth": "native-encoded", + "identitytoken": "native-identity", + "registrytoken": "native-registry", + "email": "alice@example.com", + "serveraddress": "docker.io", + }, + BackgroundChecks: true, + }, + { + ID: "refresh", + Registry: "https://index.docker.io/v1/refresh-token", + AuthFile: "file:refresh-auth", + auth: map[string]string{"username": "", "password": "refresh-secret:client-id"}, + }, } for _, nodes := range [][]string{{"home"}, {"home", "other"}} { - body, err := json.Marshal(map[string]interface{}{"nodes": nodes, "password": "backup-password"}) + body, err := json.Marshal(map[string]any{"nodes": nodes, "password": "backup-password"}) if nil != err { t.Fatal(err) } @@ -64,7 +84,7 @@ func TestBackupExport(t *testing.T) { file.SetPassword("wrong-password") if reader, err := file.Open(); nil == err { _, err = io.ReadAll(reader) - reader.Close() + _ = reader.Close() if nil == err { t.Fatal("wrong password accepted") } @@ -75,7 +95,7 @@ func TestBackupExport(t *testing.T) { t.Fatal(err) } data, err := io.ReadAll(reader) - reader.Close() + _ = reader.Close() if nil != err { t.Fatal(err) } @@ -85,20 +105,27 @@ func TestBackupExport(t *testing.T) { if _, err := toml.Decode(files["dcompose.toml"], &config); nil != err { t.Fatal(err) } - if s.config.Auth.passwordHash != config.Auth.Password || strings.Repeat("ab", 32) != config.Auth.SessionKey || "oidc-secret" != config.Auth.OIDC.ClientSecret || "agent-test-token" != config.Nodes["home"].Token { + if s.config.Auth.passwordHash != config.Auth.Password || strings.Repeat("ab", 32) != config.Auth.SessionKey || + "oidc-secret" != config.Auth.OIDC.ClientSecret || + "agent-test-token" != config.Nodes["home"].Token { t.Fatal("backup did not resolve configuration secrets") } if s.config.Auth.apiKey != config.Auth.APIKey { t.Fatal("backup did not resolve API key") } - if 2 != len(config.Registries) || !reflect.DeepEqual(s.config.Registries[0].auth, config.Registries[0].Auth) || "" != config.Registries[0].AuthFile || "Personal" != config.Registries[0].Label || !config.Registries[0].BackgroundChecks { + if 2 != len(config.Registries) || !reflect.DeepEqual(s.config.Registries[0].auth, config.Registries[0].Auth) || + "" != config.Registries[0].AuthFile || + "Personal" != config.Registries[0].Label || + !config.Registries[0].BackgroundChecks { t.Fatal("encrypted backup did not resolve registry credentials") } registries, err := validateRegistries(config.Registries, "") if nil != err || !reflect.DeepEqual(s.config.Registries[0].auth, registries[0].auth) { t.Fatal("backup registry credentials cannot be restored", err) } - if "refresh-secret:client-id" != registries[1].auth["password"] || "refresh-secret:client-id" != config.Registries[1].Auth["password"] || "https://index.docker.io/v1/refresh-token" != config.Registries[1].Registry { + if "refresh-secret:client-id" != registries[1].auth["password"] || + "refresh-secret:client-id" != config.Registries[1].Auth["password"] || + "https://index.docker.io/v1/refresh-token" != config.Registries[1].Registry { t.Fatal("backup did not preserve independent OAuth auth records") } delete(files, "dcompose.toml") @@ -111,10 +138,12 @@ func TestBackupExport(t *testing.T) { t.Fatal("backup included wrong nodes or modified stack files") } } - if "file:node-token" != s.config.Nodes["home"].Token || "file:oidc-secret" != s.config.Auth.OIDC.ClientSecret || "file:api-key" != s.config.Auth.APIKey { + if "file:node-token" != s.config.Nodes["home"].Token || "file:oidc-secret" != s.config.Auth.OIDC.ClientSecret || + "file:api-key" != s.config.Auth.APIKey { t.Fatal("backup modified server configuration") } - if "file:registry-auth" != s.config.Registries[0].AuthFile || "file:refresh-auth" != s.config.Registries[1].AuthFile || nil != s.config.Registries[0].Auth { + if "file:registry-auth" != s.config.Registries[0].AuthFile || "file:refresh-auth" != s.config.Registries[1].AuthFile || + nil != s.config.Registries[0].Auth { t.Fatal("backup modified registry configuration") } } @@ -146,7 +175,11 @@ func TestBackupFailure(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "broken") || "application/zip" == w.Header().Get("Content-Type") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "broken") || + "application/zip" == w.Header().Get("Content-Type") { t.Fatal(w.Code, w.Body.String()) } var record struct { @@ -160,10 +193,13 @@ func TestBackupFailure(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &record); nil != err { t.Fatal(err, output.String()) } - if !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || "" == record.Operation || 500 != record.Status || !strings.Contains(record.Error, "stack broken: cannot read .env") { + if !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || "" == record.Operation || 500 != record.Status || + !strings.Contains(record.Error, "stack broken: cannot read .env") { t.Fatal("missing private backup failure details", output.String()) } - if "ERROR" != record.Level || !strings.Contains(record.Message, record.Node) || !strings.Contains(record.Message, record.Error) || strings.Contains(output.String(), "backup-password") || strings.Contains(output.String(), "agent-test-token") { + if "ERROR" != record.Level || !strings.Contains(record.Message, record.Node) || !strings.Contains(record.Message, record.Error) || + strings.Contains(output.String(), "backup-password") || + strings.Contains(output.String(), "agent-test-token") { t.Fatal("backup message hides failure context or leaks credentials", output.String()) } w = httptest.NewRecorder() @@ -190,7 +226,11 @@ func TestBackupPath(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "../compose.yml") || "application/zip" == w.Header().Get("Content-Type") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "../compose.yml") || + "application/zip" == w.Header().Get("Content-Type") { t.Fatal(w.Code, w.Body.String()) } var record struct { @@ -199,7 +239,11 @@ func TestBackupPath(t *testing.T) { Node string `json:"node"` Operation string `json:"operation"` } - if err := json.Unmarshal(output.Bytes(), &record); nil != err || "ERROR" != record.Level || !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || "" == record.Operation { + if err := json.Unmarshal( + output.Bytes(), + &record, + ); nil != err || "ERROR" != record.Level || !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || + "" == record.Operation { t.Fatal("missing backup diagnostic", output.String()) } if !strings.Contains(record.Message, "Home") || !strings.Contains(record.Message, up.URL) { diff --git a/internal/server/channel.go b/internal/server/channel.go index 6150713..bc5ac87 100644 --- a/internal/server/channel.go +++ b/internal/server/channel.go @@ -50,8 +50,7 @@ func (s *Server) Start(ctx context.Context) { s.hubContext, s.hubCancel = context.WithCancel(ctx) s.loadImageCooldowns() s.reconcileChannelsLocked(s.config, s.nodes) - s.hubWorkers.Add(1) - go func() { defer s.hubWorkers.Done(); s.watchImages() }() + s.hubWorkers.Go(s.watchImages) } // Close cancels and joins the server-owned channels. @@ -107,12 +106,19 @@ func (s *Server) reconcileChannelsLocked(config Config, clients map[string]*http continue } ctx, cancel := context.WithCancel(s.hubContext) - channel := &nodeChannel{id: id, node: node, client: clients[id], ctx: ctx, cancel: cancel, shells: make(map[string]map[chan protocol.ChannelMessage]struct{}), streams: make(map[string]*browserSubscription)} + channel := &nodeChannel{ + id: id, + node: node, + client: clients[id], + ctx: ctx, + cancel: cancel, + shells: make(map[string]map[chan protocol.ChannelMessage]struct{}), + streams: make(map[string]*browserSubscription), + } s.channels[id] = channel s.runtime[id] = &nodeRuntime{} s.stateLocked(id, "unavailable", "Connecting to node.") - s.hubWorkers.Add(1) - go func() { defer s.hubWorkers.Done(); s.runChannel(channel) }() + s.hubWorkers.Go(func() { s.runChannel(channel) }) } } @@ -193,14 +199,16 @@ func (s *Server) runChannel(channel *nodeChannel) { } channel.conn = conn channel.imageContext, channel.imageCancel = context.WithCancel(channel.ctx) - s.hubWorkers.Add(1) - go func() { - defer s.hubWorkers.Done() + s.hubWorkers.Go(func() { s.discoverRelays(channel.ctx, channel.id) - }() + }) err = s.syncRegistriesLocked(channel) s.hubMu.Unlock() - slog.Debug(fmt.Sprintf("Connected to %s. Authentication succeeded.", nodeLogLabel(channel.node)), "node", nodeLogLabel(channel.node)) + slog.Debug( + fmt.Sprintf("Connected to %s. Authentication succeeded.", nodeLogLabel(channel.node)), + "node", + nodeLogLabel(channel.node), + ) stopPing := socketHeartbeat(channel.ctx, conn, nodeLogLabel(channel.node)) for nil == err { var message protocol.ChannelMessage @@ -246,8 +254,7 @@ func (s *Server) runChannel(channel *nodeChannel) { if s.channels[channel.id] == channel && nil == channel.ctx.Err() { s.pauseNodeImagesLocked(channel.id) message := "Node is unavailable. Retrying." - var incompatible incompatibleAgent - if errors.As(err, &incompatible) { + if incompatible, ok := errors.AsType[incompatibleAgent](err); ok { message = incompatible.Error() } s.stateLocked(channel.id, "unavailable", message) @@ -258,7 +265,20 @@ func (s *Server) runChannel(channel *nodeChannel) { } if warnings.allow(time.Now()) { node, detail := nodeLogLabel(channel.node), safeTransportError(err) - slog.Warn(fmt.Sprintf("Connection to %s failed: %s. Retrying in %s.", node, strings.TrimSuffix(strings.TrimSpace(detail), "."), delay.String()), "node", node, "error", detail, "retry_in", delay) + slog.Warn( + fmt.Sprintf( + "Connection to %s failed: %s. Retrying in %s.", + node, + strings.TrimSuffix(strings.TrimSpace(detail), "."), + delay.String(), + ), + "node", + node, + "error", + detail, + "retry_in", + delay, + ) } timer := time.NewTimer(delay) select { @@ -305,12 +325,10 @@ func safeTransportError(err error) string { if nil == err { return "connection closed" } - var incompatible incompatibleAgent - if errors.As(err, &incompatible) { + if _, ok := errors.AsType[incompatibleAgent](err); ok { return fmt.Sprintf("node API version mismatch: server requires version %d", protocol.AgentAPI) } - var address *url.Error - if errors.As(err, &address) { + if address, ok := errors.AsType[*url.Error](err); ok { return safeTransportError(address.Err) } if errors.Is(err, context.Canceled) { @@ -335,36 +353,28 @@ func safeTransportError(err error) string { if errors.Is(err, errRuntimeEvent) { return err.Error() } - var upstream nodeHTTPError - if errors.As(err, &upstream) { + if upstream, ok := errors.AsType[nodeHTTPError](err); ok { return upstream.Error() } - var dns *net.DNSError - if errors.As(err, &dns) { + if dns, ok := errors.AsType[*net.DNSError](err); ok { return fmt.Sprintf("DNS lookup for %q failed: %s", dns.Name, dns.Err) } - var socket *net.OpError - if errors.As(err, &socket) { + if socket, ok := errors.AsType[*net.OpError](err); ok { return fmt.Sprintf("%s %s failed: %s", socket.Op, socket.Net, safeTransportError(socket.Err)) } - var errno syscall.Errno - if errors.As(err, &errno) { + if errno, ok := errors.AsType[syscall.Errno](err); ok { return errno.Error() } - var record tls.RecordHeaderError - if errors.As(err, &record) { + if record, ok := errors.AsType[tls.RecordHeaderError](err); ok { return "TLS handshake failed: " + record.Msg } - var alert tls.AlertError - if errors.As(err, &alert) { + if alert, ok := errors.AsType[tls.AlertError](err); ok { return alert.Error() } - var syntax *json.SyntaxError - if errors.As(err, &syntax) { + if syntax, ok := errors.AsType[*json.SyntaxError](err); ok { return fmt.Sprintf("node sent invalid JSON at byte %d: %s", syntax.Offset, syntax.Error()) } - var field *json.UnmarshalTypeError - if errors.As(err, &field) { + if field, ok := errors.AsType[*json.UnmarshalTypeError](err); ok { return fmt.Sprintf("node sent %s for JSON field %q, expected %s", field.Value, field.Field, field.Type) } if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { @@ -422,8 +432,20 @@ func socketHeartbeat(ctx context.Context, conn *websocket.Conn, label string) fu func (s *Server) shellEvent(channel *nodeChannel, message protocol.ChannelMessage) { if "shell.error" == message.Type { - slog.Error(fmt.Sprintf("Could not use shell %q on %s: %s", message.Shell, nodeLogLabel(channel.node), message.Error), "node", nodeLogLabel(channel.node), "shell", message.Shell, "error", message.Error) - message = protocol.ChannelMessage{Type: "shell.error", Shell: message.Shell, Error: "Could not use the shell. Reopen it and try again."} + slog.Error( + fmt.Sprintf("Could not use shell %q on %s: %s", message.Shell, nodeLogLabel(channel.node), message.Error), + "node", + nodeLogLabel(channel.node), + "shell", + message.Shell, + "error", + message.Error, + ) + message = protocol.ChannelMessage{ + Type: "shell.error", + Shell: message.Shell, + Error: "Could not use the shell. Reopen it and try again.", + } } else { switch message.Event { case "operation.snapshot", "operation.output", "operation.done": diff --git a/internal/server/channel_test.go b/internal/server/channel_test.go index e0ece8d..bae1720 100644 --- a/internal/server/channel_test.go +++ b/internal/server/channel_test.go @@ -60,7 +60,7 @@ func fakeChannel(t *testing.T) *channelFixture { f.relays.ServeHTTP(w, r) return } - jsonResponse(w, http.StatusOK, map[string]interface{}{"relays": []interface{}{}}) + jsonResponse(w, http.StatusOK, map[string]any{"relays": []any{}}) return } if nil != f.http { @@ -75,14 +75,18 @@ func fakeChannel(t *testing.T) *channelFixture { if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() conn.SetReadLimit(8 << 20) f.count.Add(1) if !ackRegistrySync(t, r.Context(), conn) { return } f.active.Store(conn) - if err := wsjson.Write(r.Context(), conn, protocol.ChannelMessage{Type: "event", Event: "node.snapshot", Payload: f.snapshot}); nil != err { + if err := wsjson.Write( + r.Context(), + conn, + protocol.ChannelMessage{Type: "event", Event: "node.snapshot", Payload: f.snapshot}, + ); nil != err { return } f.connections <- conn @@ -254,7 +258,7 @@ func browserSend(t *testing.T, conn *websocket.Conn, message protocol.ChannelMes func resourceMessage(t *testing.T, conn *websocket.Conn) protocol.ChannelMessage { t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() for { var message protocol.ChannelMessage @@ -304,16 +308,12 @@ func TestShellChannel(t *testing.T) { t.Cleanup(func() { _ = browser.CloseNow() }) browserSend(t, browser, protocol.ChannelMessage{Type: "shell.attach", Stream: "terminal", Node: "home", Shell: "session"}) nextCommand(t, f, "shell.attach") - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() for _, message := range []protocol.ChannelMessage{ {Type: "shell.input", Stream: "terminal", Node: "forged", Shell: "forged", Data: "echo test\n"}, {Type: "shell.resize", Stream: "terminal", Shell: "forged", Cols: 90, Rows: 35}, {Type: "shell.input", Stream: "terminal", Data: strings.Repeat("\x00", 64<<10)}, } { - if err := wsjson.Write(ctx, browser, message); nil != err { - t.Fatal(err) - } + browserSend(t, browser, message) got := nextCommand(t, f, message.Type) if got.Data != message.Data || got.Cols != message.Cols || got.Rows != message.Rows { t.Fatal("command changed") @@ -327,17 +327,26 @@ func TestShellChannel(t *testing.T) { if nil != err { t.Fatal(err) } - if err := wsjson.Write(ctx, upstream, protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.snapshot", Payload: large}); nil != err { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := wsjson.Write( + ctx, + upstream, + protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.snapshot", Payload: large}, + ); nil != err { t.Fatal(err) } got := resourceMessage(t, browser) if "operation.snapshot" != got.Event || 6<<20 > len(got.Payload) || "terminal" != got.Stream { t.Fatal("large terminal snapshot rejected", len(got.Payload), got.Stream) } - output := protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.output", Payload: json.RawMessage(`{"id":"session","output":"hello"}`)} - if err := wsjson.Write(ctx, upstream, output); nil != err { - t.Fatal(err) + output := protocol.ChannelMessage{ + Type: "shell.event", + Shell: "session", + Event: "operation.output", + Payload: json.RawMessage(`{"id":"session","output":"hello"}`), } + browserSend(t, upstream, output) got = resourceMessage(t, browser) if "operation.output" != got.Event || !strings.Contains(string(got.Payload), "hello") { t.Fatal("missing terminal output", got) @@ -414,7 +423,10 @@ func TestChannelRejection(t *testing.T) { func TestTransportDetails(t *testing.T) { cause := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED} err := &url.Error{Op: "Get", URL: "https://user:secret@node.invalid?token=secret", Err: cause} - if detail := safeTransportError(err); !strings.Contains(detail, cause.Op) || !strings.Contains(detail, cause.Net) || !strings.Contains(detail, cause.Err.Error()) || strings.Contains(detail, "secret") { + if detail := safeTransportError( + err, + ); !strings.Contains(detail, cause.Op) || !strings.Contains(detail, cause.Net) || !strings.Contains(detail, cause.Err.Error()) || + strings.Contains(detail, "secret") { t.Fatal("missing safe connection details", detail) } const reason = "message exceeded the configured read limit" @@ -447,7 +459,8 @@ func TestChannelRetryLog(t *testing.T) { if err := json.Unmarshal(line, &record); nil != err { t.Fatal(err) } - if "WARN" != record.Level || nodeLogLabel(node) != record.Node || time.Second != record.RetryIn || !strings.Contains(record.Error, cause.Err.Error()) { + if "WARN" != record.Level || nodeLogLabel(node) != record.Node || time.Second != record.RetryIn || + !strings.Contains(record.Error, cause.Err.Error()) { t.Fatal("missing structured retry context", string(line)) } for _, detail := range []string{record.Node, record.Error, time.Second.String()} { @@ -455,7 +468,8 @@ func TestChannelRetryLog(t *testing.T) { t.Fatal("retry message hides failure context", record.Message) } } - if strings.Contains(string(line), "secret") || strings.Contains(string(line), "/private") || strings.Contains(string(line), "user:") { + if strings.Contains(string(line), "secret") || strings.Contains(string(line), "/private") || + strings.Contains(string(line), "user:") { t.Fatal("transport URL leaked", string(line)) } case <-time.After(3 * time.Second): @@ -609,7 +623,11 @@ func TestLogStreamError(t *testing.T) { if "logs.attach" != attach.Type || "missing" != attach.Resource { t.Fatal(attach) } - if err := wsjson.Write(ctx, upstream, protocol.ChannelMessage{Type: "stream.error", Stream: attach.Stream, Status: 404, Error: "private transport secret"}); nil != err { + if err := wsjson.Write( + ctx, + upstream, + protocol.ChannelMessage{Type: "stream.error", Stream: attach.Stream, Status: 404, Error: "private transport secret"}, + ); nil != err { t.Fatal(err) } message := resourceMessage(t, browser) diff --git a/internal/server/config.go b/internal/server/config.go index 1ba4cab..bb81fbe 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -41,10 +41,10 @@ type GlobalConfig struct { type AuthConfig struct { Username string `toml:"username"` - Password string `toml:"password" json:"-"` + Password string `toml:"password" json:"-"` SessionKey string `toml:"session_key,omitempty" json:"-"` - APIKey string `toml:"api_key,omitempty" json:"-"` - OIDC *OIDCConfig `toml:"oidc,omitempty" json:"-"` + APIKey string `toml:"api_key,omitempty" json:"-"` + OIDC *OIDCConfig `toml:"oidc,omitempty" json:"-"` passwordHash string sessionKey string apiKey string @@ -63,11 +63,11 @@ func (c AuthConfig) setupRequired() bool { } type NodeConfig struct { - Name string `toml:"name" json:"name"` - URL string `toml:"url" json:"url"` - Token string `toml:"token" json:"-"` - Fingerprint string `toml:"fingerprint" json:"fingerprint"` - PublicHost string `toml:"public_host" json:"public_host"` + Name string `toml:"name" json:"name"` + URL string `toml:"url" json:"url"` + Token string `toml:"token" json:"-"` + Fingerprint string `toml:"fingerprint" json:"fingerprint"` + PublicHost string `toml:"public_host" json:"public_host"` Paused bool `toml:"paused,omitempty" json:"paused,omitempty"` token string } @@ -85,7 +85,11 @@ func LoadConfig(path string) (Config, error) { return c, err } if err := saveConfig(c); nil != err { - return c, fmt.Errorf("cannot initialize %s: %w. Make the configuration directory writable by the runtime user (Docker: set DCOMPOSE_UID and DCOMPOSE_GID to its owner)", c.path, err) + return c, fmt.Errorf( + "cannot initialize %s: %w. Make the configuration directory writable by the runtime user (Docker: set DCOMPOSE_UID and DCOMPOSE_GID to its owner)", + c.path, + err, + ) } } c, err = validateConfig(c) @@ -200,13 +204,16 @@ func validateResolvedConfig(c Config) (Config, error) { } if "" != c.Global.Origin { u, err := url.Parse(c.Global.Origin) - if nil != err || ("https" != u.Scheme && "http" != u.Scheme) || "" == u.Host || "" != u.Path || nil != u.User || "" != u.RawQuery || "" != u.Fragment { + if nil != err || ("https" != u.Scheme && "http" != u.Scheme) || "" == u.Host || "" != u.Path || nil != u.User || "" != u.RawQuery || + "" != u.Fragment { return c, fmt.Errorf("global.origin must be an HTTP(S) origin without a path") } } for _, origin := range c.Global.WebsocketOrigins { if _, valid := validWebsocketOrigin(origin); !valid { - return c, fmt.Errorf("global.websocket_origins must contain exact HTTP(S) origins without user information, paths, queries, fragments or wildcards") + return c, fmt.Errorf( + "global.websocket_origins must contain exact HTTP(S) origins without user information, paths, queries, fragments or wildcards", + ) } } if nil != c.Auth.OIDC { @@ -223,7 +230,8 @@ func validateResolvedConfig(c Config) (Config, error) { } for name, value := range map[string]string{"issuer": oidc.Issuer, "redirect_uri": oidc.RedirectURI} { u, err := url.Parse(value) - if nil != err || ("https" != u.Scheme && "http" != u.Scheme) || "" == u.Host || nil != u.User || "" != u.RawQuery || "" != u.Fragment { + if nil != err || ("https" != u.Scheme && "http" != u.Scheme) || "" == u.Host || nil != u.User || "" != u.RawQuery || + "" != u.Fragment { return c, fmt.Errorf("auth.oidc.%s must be an HTTP(S) URL", name) } } @@ -354,9 +362,7 @@ func cloneConfig(c Config) Config { c.Global.NodeOrder = slices.Clone(c.Global.NodeOrder) c.Global.WebsocketOrigins = slices.Clone(c.Global.WebsocketOrigins) nodes := make(map[string]NodeConfig, len(c.Nodes)) - for id, node := range c.Nodes { - nodes[id] = node - } + maps.Copy(nodes, c.Nodes) c.Nodes = nodes return c } diff --git a/internal/server/create_test.go b/internal/server/create_test.go index daf7051..7a4fafb 100644 --- a/internal/server/create_test.go +++ b/internal/server/create_test.go @@ -18,7 +18,10 @@ func TestCreateForward(t *testing.T) { var requests atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - if http.MethodPost != r.Method || "/stacks/copy" != r.URL.Path || "" != r.URL.RawQuery || "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || "" != r.Header.Get("Cookie") { + if http.MethodPost != r.Method || "/stacks/copy" != r.URL.Path || "" != r.URL.RawQuery || + "Bearer agent-test-token" != r.Header.Get("Authorization") || + strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || + "" != r.Header.Get("Cookie") { t.Error("incorrect create forwarding", r.Method, r.URL) } var input struct { @@ -26,7 +29,9 @@ func TestCreateForward(t *testing.T) { Env string `json:"env"` Overwrite *bool `json:"overwrite"` } - if err := json.NewDecoder(r.Body).Decode(&input); nil != err || "# raw\nservices: {}\n" != input.Compose || "KEY='raw'\n" != input.Env || nil == input.Overwrite || (`,"overwrite":true` == overwrite) != *input.Overwrite { + if err := json.NewDecoder(r.Body). + Decode(&input); nil != err || "# raw\nservices: {}\n" != input.Compose || "KEY='raw'\n" != input.Env || nil == input.Overwrite || + (`,"overwrite":true` == overwrite) != *input.Overwrite { t.Errorf("forwarded payload: %+v %v", input, err) } jsonResponse(w, http.StatusOK, map[string]string{"name": "copy"}) @@ -36,7 +41,8 @@ func TestCreateForward(t *testing.T) { body := `{"compose":"# raw\nservices: {}\n","env":"KEY='raw'\n"` + overwrite + `}` w := perform(t, s, http.MethodPost, "/api/nodes/home/stacks/copy?token=browser-secret", body) var result map[string]string - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || 1 != len(result) || "copy" != result["name"] || 1 != requests.Load() { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || 1 != len(result) || "copy" != result["name"] || + 1 != requests.Load() { t.Fatalf("create: %d %s", w.Code, w.Body) } w = httptest.NewRecorder() @@ -63,7 +69,10 @@ func TestCreateError(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if status != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || status != result.Error.Code || "" == result.Error.Message || strings.Contains(w.Body.String(), "SECRET") || strings.Contains(w.Body.String(), "/srv/") { + if status != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &result) || status != result.Error.Code || + "" == result.Error.Message || + strings.Contains(w.Body.String(), "SECRET") || + strings.Contains(w.Body.String(), "/srv/") { t.Fatalf("unsafe create error: %d %s", w.Code, w.Body) } }) diff --git a/internal/server/emitter_logging_test.go b/internal/server/emitter_logging_test.go index a96cdcc..8ab48ff 100644 --- a/internal/server/emitter_logging_test.go +++ b/internal/server/emitter_logging_test.go @@ -44,16 +44,35 @@ func TestViewWriteLogs(t *testing.T) { s := testServer(t, nil) node := NodeConfig{Name: "Home", URL: "https://user:secret@node.invalid/private?token=secret", token: "agent-test-token"} s.config.Nodes["home"] = node - channel := &nodeChannel{node: node, conn: conn, ctx: context.Background(), streams: make(map[string]*browserSubscription), shells: make(map[string]map[chan protocol.ChannelMessage]struct{})} + channel := &nodeChannel{ + node: node, + conn: conn, + ctx: context.Background(), + streams: make(map[string]*browserSubscription), + shells: make(map[string]map[chan protocol.ChannelMessage]struct{}), + } s.channels["home"] = channel - message := protocol.ChannelMessage{Type: test.kind, Stream: "browser", Node: "home", Resource: test.resource, Shell: test.shell, Data: "secret-shell-input", Cols: 80, Rows: 24} + message := protocol.ChannelMessage{ + Type: test.kind, + Stream: "browser", + Node: "home", + Resource: test.resource, + Shell: test.shell, + Data: "secret-shell-input", + Cols: 80, + Rows: 24, + } var response *protocol.ChannelMessage if "relay.attach" == test.kind { _, response = s.attachNode(context.Background(), message) } else if strings.HasSuffix(test.kind, ".attach") { _, response = s.attachBrowser(context.Background(), message) } else { - response = s.browserShellCommand(context.Background(), &browserSubscription{shell: test.shell, channel: channel, conn: conn}, message) + response = s.browserShellCommand( + context.Background(), + &browserSubscription{shell: test.shell, channel: channel, conn: conn}, + message, + ) } if nil == response || http.StatusBadGateway != response.Status { t.Fatal("write failure changed browser error", response) @@ -65,7 +84,11 @@ func TestViewWriteLogs(t *testing.T) { Operation string `json:"operation"` Error string `json:"error"` } - if err := json.Unmarshal(output.Bytes(), &record); nil != err || "ERROR" != record.Level || !strings.Contains(record.Node, node.Name) || !strings.Contains(record.Node, "https://node.invalid") || "" == record.Operation || "" == record.Error { + if err := json.Unmarshal( + output.Bytes(), + &record, + ); nil != err || "ERROR" != record.Level || !strings.Contains(record.Node, node.Name) || !strings.Contains(record.Node, "https://node.invalid") || "" == record.Operation || + "" == record.Error { t.Fatal("missing write failure diagnostic", output.String(), err) } resource := test.resource @@ -77,7 +100,8 @@ func TestViewWriteLogs(t *testing.T) { t.Fatal("write message hides context", record.Message) } } - if strings.Contains(output.String(), "secret") || strings.Contains(output.String(), "/private") || strings.Contains(response.Error, record.Error) { + if strings.Contains(output.String(), "secret") || strings.Contains(output.String(), "/private") || + strings.Contains(response.Error, record.Error) { t.Fatal("write failure leaked private details", output.String(), response) } }) @@ -92,8 +116,16 @@ func TestViewErrorLogs(t *testing.T) { slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil))) t.Cleanup(func() { slog.SetDefault(original) }) s := testServer(t, nil) - channel := &nodeChannel{node: NodeConfig{Name: "Home", URL: "https://node.invalid"}, streams: make(map[string]*browserSubscription)} - subscription := &browserSubscription{kind: kind, resource: "demo", stream: "stream-id", subscriber: make(chan protocol.ChannelMessage, 1)} + channel := &nodeChannel{ + node: NodeConfig{Name: "Home", URL: "https://node.invalid"}, + streams: make(map[string]*browserSubscription), + } + subscription := &browserSubscription{ + kind: kind, + resource: "demo", + stream: "stream-id", + subscriber: make(chan protocol.ChannelMessage, 1), + } channel.streams[subscription.stream] = subscription const cause = "private diagnostic: cannot open /srv/demo/compose.yml" s.streamEvent(channel, protocol.ChannelMessage{Type: "stream.error", Stream: subscription.stream, Status: 404, Error: cause}) @@ -110,7 +142,11 @@ func TestViewErrorLogs(t *testing.T) { Status int `json:"status"` Error string `json:"error"` } - if err := json.Unmarshal(output.Bytes(), &record); nil != err || "ERROR" != record.Level || kind != record.Operation || "demo" != record.Resource || "stream-id" != record.Stream || 404 != record.Status || cause != record.Error { + if err := json.Unmarshal( + output.Bytes(), + &record, + ); nil != err || "ERROR" != record.Level || kind != record.Operation || "demo" != record.Resource || "stream-id" != record.Stream || 404 != record.Status || + cause != record.Error { t.Fatal("missing stream failure diagnostic", output.String(), err) } for _, detail := range []string{channel.node.Name, channel.node.URL, subscription.resource, cause, "404"} { diff --git a/internal/server/event_tickets_test.go b/internal/server/event_tickets_test.go index a3eb2b3..a289617 100644 --- a/internal/server/event_tickets_test.go +++ b/internal/server/event_tickets_test.go @@ -18,7 +18,15 @@ const eventTicketOrigin = "https://integration.example" func mintEventTicket(t *testing.T, s *Server) string { t.Helper() - w := perform(t, s, http.MethodPost, "/api/auth/tickets", `{}`) + r := httptest.NewRequest(http.MethodPost, "/api/auth/tickets", strings.NewReader(`{}`)) + r.AddCookie(testSession(t, s)) + return mintEventTicketRequest(t, s, r) +} + +func mintEventTicketRequest(t *testing.T, s *Server, r *http.Request) string { + t.Helper() + w := httptest.NewRecorder() + s.ServeHTTP(w, r) if http.StatusCreated != w.Code { t.Fatal("could not mint ticket", w.Code) } @@ -71,7 +79,11 @@ func rejectEventTicket(t *testing.T, conn *websocket.Conn, allowClose bool) { return } var message protocol.ChannelMessage - if err := json.Unmarshal(data, &message); nil != err || "error" != message.Type || http.StatusUnauthorized != message.Status || "" == message.Error || "" != message.Stream { + if err := json.Unmarshal( + data, + &message, + ); nil != err || "error" != message.Type || http.StatusUnauthorized != message.Status || "" == message.Error || + "" != message.Stream { t.Fatal("unexpected ticket rejection frame") } if _, _, err := conn.Read(ctx); nil == err || nil != ctx.Err() { @@ -106,7 +118,9 @@ func TestTicketMux(t *testing.T) { browserSend(t, browser, message) commands = append(commands, channelCommand(t, f, message.Type)) } - if "" == commands[0].Stream || commands[0].Stream == commands[1].Stream || "demo" != commands[0].Resource || "other" != commands[1].Resource || "session" != commands[2].Shell { + if "" == commands[0].Stream || commands[0].Stream == commands[1].Stream || "demo" != commands[0].Resource || + "other" != commands[1].Resource || + "session" != commands[2].Shell { t.Fatal("ticket connection changed resource routing") } for _, frame := range []protocol.ChannelMessage{ @@ -240,6 +254,81 @@ func TestTicketReplay(t *testing.T) { rejectEventTicket(t, second, false) } +func TestTicketRevocation(t *testing.T) { + for _, credential := range []string{"session", "signing_key", "bearer"} { + for _, connected := range []bool{false, true} { + phase := "pending" + if connected { + phase = "connected" + } + t.Run(credential+"/"+phase, func(t *testing.T) { + s := testServer(t, nil) + s.config.Global.WebsocketOrigins = []string{eventTicketOrigin} + r := httptest.NewRequest(http.MethodPost, "/api/auth/tickets", strings.NewReader(`{}`)) + r.AddCookie(testSession(t, s)) + if "bearer" == credential { + s.config.Auth.apiKey = strings.Repeat("cd", 32) + r.Header.Set("Authorization", "Bearer "+s.config.Auth.apiKey) + } + ticket := mintEventTicketRequest(t, s, r) + app := httptest.NewServer(s) + t.Cleanup(app.Close) + conn := dialEventTicket(t, app, "/api/events", http.Header{"Origin": {eventTicketOrigin}}) + if connected { + authenticateEventTicket(t, conn, ticket) + } + + s.configMu.Lock() + switch credential { + case "session": + s.config.Auth.Username = "changed" + case "signing_key": + s.config.Auth.sessionKey = strings.Repeat("\xef", 32) + case "bearer": + s.config.Auth.apiKey = strings.Repeat("ef", 32) + } + s.configMu.Unlock() + + if connected { + browserSend(t, conn, protocol.ChannelMessage{Type: "shell.attach", Stream: "terminal", Node: "home", Shell: "session"}) + } else { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := wsjson.Write(ctx, conn, map[string]string{"type": "auth", "ticket": ticket}); nil != err { + t.Fatal(err) + } + } + rejectEventTicket(t, conn, false) + }) + } + } +} + +func TestTicketSessionExpiry(t *testing.T) { + s := testServer(t, nil) + s.config.Global.WebsocketOrigins = []string{eventTicketOrigin} + token, err := sessionToken(s.config.Auth, time.Now().Add(3*time.Second)) + if nil != err { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/api/auth/tickets", strings.NewReader(`{}`)) + r.AddCookie(&http.Cookie{Name: "session", Value: token}) + ticket := mintEventTicketRequest(t, s, r) + app := httptest.NewServer(s) + t.Cleanup(app.Close) + conn := dialEventTicket(t, app, "/api/events", http.Header{"Origin": {eventTicketOrigin}}) + authenticateEventTicket(t, conn, ticket) + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) + defer cancel() + var message protocol.ChannelMessage + if err := wsjson.Read(ctx, conn, &message); nil != err || "error" != message.Type || http.StatusUnauthorized != message.Status { + t.Fatal("idle ticket connection outlived its session", message, err) + } + if _, _, err := conn.Read(ctx); nil == err || nil != ctx.Err() { + t.Fatal("expired ticket connection did not close", err) + } +} + func TestTicketOrigins(t *testing.T) { fixture := newWorkspace(t) s, app := fixture.server, fixture.app @@ -262,7 +351,11 @@ func TestTicketOrigins(t *testing.T) { headers.Set("Origin", "https://unlisted.example") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - rejected, response, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(app.URL, "http")+"/api/events", &websocket.DialOptions{HTTPHeader: headers}) + rejected, response, err := websocket.Dial( + ctx, + "ws"+strings.TrimPrefix(app.URL, "http")+"/api/events", + &websocket.DialOptions{HTTPHeader: headers}, + ) if nil != rejected { _ = rejected.CloseNow() } diff --git a/internal/server/events.go b/internal/server/events.go index ca5bea5..7a43ee3 100644 --- a/internal/server/events.go +++ b/internal/server/events.go @@ -33,14 +33,15 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { if nil != err { return } - defer conn.CloseNow() - ticketConnection := !nativeOrigin || !s.authenticated(r) - if ticketConnection { + defer func() { _ = conn.CloseNow() }() + authRequest := r + if !nativeOrigin || !s.authenticated(r) { if !allowTicket { _ = writeChannel(r.Context(), conn, signInMessage()) return } - if !s.authenticateWorkspaceTicket(r.Context(), conn) { + authRequest = s.authenticateWorkspaceTicket(r.Context(), conn) + if nil == authRequest { return } } @@ -65,9 +66,7 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { }() commands := make(chan protocol.ChannelMessage, 64) outgoing := make(chan browserDelivery, 64) - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { defer cancel() for { var message protocol.ChannelMessage @@ -80,19 +79,11 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { return } } - }() + }) stopHeartbeat := socketHeartbeat(ctx, conn, "workspace browser at "+r.RemoteAddr) defer stopHeartbeat() - var authCheck, ticketExpiry <-chan time.Time - if ticketConnection { - timer := time.NewTimer(24 * time.Hour) - defer timer.Stop() - ticketExpiry = timer.C - } else { - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - authCheck = ticker.C - } + authCheck := time.NewTicker(15 * time.Second) + defer authCheck.Stop() for _, frame := range initial { if err := writeSocket(ctx, conn, frame); nil != err { return @@ -102,16 +93,13 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { select { case <-ctx.Done(): return - case <-ticketExpiry: - _ = writeChannel(ctx, conn, signInMessage()) - return - case <-authCheck: - if !s.authenticated(r) { + case <-authCheck.C: + if !s.authenticated(authRequest) { _ = writeChannel(ctx, conn, signInMessage()) return } case message := <-commands: - if !ticketConnection && !s.authenticated(r) { + if !s.authenticated(authRequest) { _ = writeChannel(ctx, conn, signInMessage()) return } @@ -133,11 +121,9 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { subscription, response = s.attachBrowser(ctx, message) if nil != subscription { subscriptions[message.Stream] = subscription - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { subscription.forward(outgoing) - }() + }) } case "shell.input", "shell.resize", "shell.close": response = s.browserShellCommand(ctx, subscription, message) @@ -171,25 +157,31 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) authenticateWorkspaceTicket(ctx context.Context, conn *websocket.Conn) bool { +func (s *Server) authenticateWorkspaceTicket(ctx context.Context, conn *websocket.Conn) *http.Request { conn.SetReadLimit(1024) authContext, cancel := context.WithTimeout(ctx, 5*time.Second) kind, data, err := conn.Read(authContext) cancel() if nil != err { - return false + return nil } var input struct { Type string `json:"type"` Ticket string `json:"ticket"` } if websocket.MessageText == kind && nil == json.Unmarshal(data, &input) && "auth" == input.Type { - if _, ok := s.consumeWebsocketTicket(input.Ticket); ok { - return nil == writeChannel(ctx, conn, protocol.ChannelMessage{Type: "auth.ok"}) + if ticket, ok := s.consumeWebsocketTicket(input.Ticket); ok { + authRequest := &http.Request{Header: ticket.auth} + if s.authenticated(authRequest) { + if nil == writeChannel(ctx, conn, protocol.ChannelMessage{Type: "auth.ok"}) { + return authRequest + } + return nil + } } } _ = writeChannel(ctx, conn, protocol.ChannelMessage{Type: "error", Status: 401, Error: "Invalid or expired connection ticket."}) - return false + return nil } func signInMessage() protocol.ChannelMessage { @@ -378,7 +370,7 @@ func (s *Server) stateLocked(id, state, message string) { s.broadcastLocked(runtimeFrame("node.state", payload)) } -func runtimeFrame(event string, payload interface{}) []byte { +func runtimeFrame(event string, payload any) []byte { data, _ := json.Marshal(payload) frame, _ := json.Marshal(protocol.ChannelMessage{Type: "event", Event: event, Payload: data}) return frame @@ -402,7 +394,8 @@ func runtimePayload(event string, data []byte) (map[string]json.RawMessage, erro nil == json.Unmarshal(payload["phase"], &phase) && ("start" == phase || "exit" == phase) if "exit" == phase { var code int - valid = valid && 0 < len(payload["exit_code"]) && "null" != string(payload["exit_code"]) && nil == json.Unmarshal(payload["exit_code"], &code) + valid = valid && 0 < len(payload["exit_code"]) && "null" != string(payload["exit_code"]) && + nil == json.Unmarshal(payload["exit_code"], &code) } if message, ok := payload["message"]; ok { valid = valid && isString(message) diff --git a/internal/server/events_test.go b/internal/server/events_test.go index 7e34f1f..334c19f 100644 --- a/internal/server/events_test.go +++ b/internal/server/events_test.go @@ -49,7 +49,12 @@ func TestBrowserMux(t *testing.T) { {Type: "stream.event", Stream: commands[0].Stream, Event: "message", Payload: json.RawMessage(`{"message":"log line"}`)}, {Type: "stream.event", Stream: commands[1].Stream, Event: "message", Payload: json.RawMessage(`{"message":"before"}`)}, {Type: "stream.event", Stream: commands[1].Stream, Event: "message", Payload: json.RawMessage(`{"message":"after"}`)}, - {Type: "shell.event", Shell: "session", Event: "operation.snapshot", Payload: json.RawMessage(`{"id":"session","output":"before"}`)}, + { + Type: "shell.event", + Shell: "session", + Event: "operation.snapshot", + Payload: json.RawMessage(`{"id":"session","output":"before"}`), + }, {Type: "shell.event", Shell: "session", Event: "operation.output", Payload: json.RawMessage(`{"id":"session","output":"after"}`)}, {Type: "event", Event: "stack.runtime", Payload: json.RawMessage(readFixture(t, "stack-runtime.json"))}, } @@ -73,7 +78,9 @@ func TestBrowserMux(t *testing.T) { } count++ } - if "message" != strings.Join(seen["logs"], ",") || "message,message" != strings.Join(seen["other-logs"], ",") || "operation.snapshot,operation.output" != strings.Join(seen["terminal"], ",") || "stack.runtime" != strings.Join(seen[""], ",") { + if "message" != strings.Join(seen["logs"], ",") || "message,message" != strings.Join(seen["other-logs"], ",") || + "operation.snapshot,operation.output" != strings.Join(seen["terminal"], ",") || + "stack.runtime" != strings.Join(seen[""], ",") { t.Fatal("wrong multiplexed events", seen) } for index, id := range []string{"logs", "other-logs", "terminal"} { @@ -126,7 +133,11 @@ func TestBrowserTabs(t *testing.T) { } browserSend(t, node, protocol.ChannelMessage{Type: "stream.event", Stream: command.Stream, Event: "message", Payload: payload}) } - if got := resourceMessage(t, one); "event" != got.Type || "same-id" != got.Stream || "message" != got.Event || !strings.Contains(string(got.Payload), "one") { + if got := resourceMessage( + t, + one, + ); "event" != got.Type || "same-id" != got.Stream || "message" != got.Event || + !strings.Contains(string(got.Payload), "one") { t.Fatal(got) } if got := resourceMessage(t, two); "same-id" != got.Stream || !strings.Contains(string(got.Payload), "two") { @@ -136,7 +147,16 @@ func TestBrowserTabs(t *testing.T) { if detach := channelCommand(t, f, "stream.detach"); first.Stream != detach.Stream { t.Fatal("closed another tab subscription", detach) } - browserSend(t, node, protocol.ChannelMessage{Type: "stream.event", Stream: second.Stream, Event: "message", Payload: json.RawMessage(`{"message":"still here"}`)}) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "stream.event", + Stream: second.Stream, + Event: "message", + Payload: json.RawMessage(`{"message":"still here"}`), + }, + ) if got := resourceMessage(t, two); !strings.Contains(string(got.Payload), "still here") { t.Fatal(got) } @@ -154,13 +174,40 @@ func TestShellObservers(t *testing.T) { attach := protocol.ChannelMessage{Type: "shell.attach", Stream: "same-id", Node: "home", Shell: "session"} browserSend(t, one, attach) nextCommand(t, f, "shell.attach") - browserSend(t, node, protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.snapshot", Payload: json.RawMessage(`{"id":"session","output":"first"}`)}) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "shell.event", + Shell: "session", + Event: "operation.snapshot", + Payload: json.RawMessage(`{"id":"session","output":"first"}`), + }, + ) resourceMessage(t, one) browserSend(t, two, attach) nextCommand(t, f, "shell.attach") // A new observer ignores deltas preceding its initial snapshot. - browserSend(t, node, protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.output", Payload: json.RawMessage(`{"id":"session","output":"next"}`)}) - browserSend(t, node, protocol.ChannelMessage{Type: "shell.event", Shell: "session", Event: "operation.snapshot", Payload: json.RawMessage(`{"id":"session","output":"firstnext"}`)}) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "shell.event", + Shell: "session", + Event: "operation.output", + Payload: json.RawMessage(`{"id":"session","output":"next"}`), + }, + ) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "shell.event", + Shell: "session", + Event: "operation.snapshot", + Payload: json.RawMessage(`{"id":"session","output":"firstnext"}`), + }, + ) if got := resourceMessage(t, two); "operation.snapshot" != got.Event { t.Fatal("delta preceded snapshot", got) } @@ -183,7 +230,12 @@ func TestBrowserAuth(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) var message protocol.ChannelMessage - if err := wsjson.Read(ctx, conn, &message); nil != err || "error" != message.Type || http.StatusUnauthorized != message.Status || "" == message.Error { + if err := wsjson.Read( + ctx, + conn, + &message, + ); nil != err || "error" != message.Type || http.StatusUnauthorized != message.Status || + "" == message.Error { t.Fatal("unexpected unauthenticated data", message, err) } if _, _, err := conn.Read(ctx); nil == err { @@ -240,7 +292,7 @@ func TestBrowserExpiry(t *testing.T) { if nil != err { t.Fatal(err) } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() var message protocol.ChannelMessage if err := wsjson.Read(ctx, conn, &message); nil != err || "error" != message.Type || 401 != message.Status || "" == message.Error { t.Fatal("idle browser did not notice session expiry", message, err) @@ -273,7 +325,16 @@ func TestBrowserNodeFailure(t *testing.T) { if got := resourceMessage(t, browser); "home" != got.Stream || "error" != got.Type || 502 != got.Status { t.Fatal("node failure did not invalidate its subscription", got) } - browserSend(t, healthy, protocol.ChannelMessage{Type: "stream.event", Stream: remaining.Stream, Event: "message", Payload: json.RawMessage(`{"message":"healthy"}`)}) + browserSend( + t, + healthy, + protocol.ChannelMessage{ + Type: "stream.event", + Stream: remaining.Stream, + Event: "message", + Payload: json.RawMessage(`{"message":"healthy"}`), + }, + ) if got := resourceMessage(t, browser); "other" != got.Stream || "message" != got.Event { t.Fatal("node failure affected another subscription", got) } @@ -386,7 +447,16 @@ func TestWorkspaceSync(t *testing.T) { if 0 != len(other) || 0 != len(f.commands) { t.Fatal("workspace sync affected other subscribers or node connection") } - browserSend(t, node, protocol.ChannelMessage{Type: "stream.event", Stream: attach.Stream, Event: "message", Payload: json.RawMessage(`{"message":"after sync"}`)}) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "stream.event", + Stream: attach.Stream, + Event: "message", + Payload: json.RawMessage(`{"message":"after sync"}`), + }, + ) if got := resourceMessage(t, browser); "logs" != got.Stream { t.Fatal("workspace sync detached logs", got) } diff --git a/internal/server/images.go b/internal/server/images.go index 2612e5f..a69d2dc 100644 --- a/internal/server/images.go +++ b/internal/server/images.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "net/url" "strings" @@ -94,7 +95,8 @@ func (c *imageChecks) interval(node string) time.Duration { func (s *Server) imageReadyLocked(node string) bool { channel := s.channels[node] - if nil == channel || nil == channel.conn || nil != channel.ctx.Err() || channel.registryFailed || channel.registryApplied != s.registrySync.Revision { + if nil == channel || nil == channel.conn || nil != channel.ctx.Err() || channel.registryFailed || + channel.registryApplied != s.registrySync.Revision { return false } runtime := s.runtime[node] @@ -201,9 +203,7 @@ func (s *Server) publishImagesLocked(key imageStackKey) { } s.decorateImagesLocked(key, stack) payload := make(map[string]json.RawMessage, len(stack)+1) - for field, value := range stack { - payload[field] = value - } + maps.Copy(payload, stack) payload["node"], _ = json.Marshal(key.node) s.broadcastLocked(runtimeFrame("stack.runtime", payload)) break @@ -306,7 +306,8 @@ func (s *Server) scheduleImagesLocked() { automatic := available && service.AutoUpdate && "running" == service.State && "" != service.Service if "" != service.CheckInterval { interval, err := time.ParseDuration(service.CheckInterval) - if nil == err && time.Minute <= interval && (state.timerIDs[service.Name] != service.ID || !now.Before(state.next[service.Name])) { + if nil == err && time.Minute <= interval && + (state.timerIDs[service.Name] != service.ID || !now.Before(state.next[service.Name])) { state.timerIDs[service.Name] = service.ID state.next[service.Name] = now.Add(interval) if !available { @@ -339,7 +340,8 @@ func (s *Server) imageWorkLocked(key imageStackKey, state *imageStack) bool { } for _, service := range state.services { checked := state.checked[service.Name] - if !checked.available(service) && "" == service.CheckInterval && s.backgroundImageLocked(state, service) && ("" != checked.key.purpose || !s.imageFreshLocked(checked, service, s.images.interval(key.node))) { + if !checked.available(service) && "" == service.CheckInterval && s.backgroundImageLocked(state, service) && + ("" != checked.key.purpose || !s.imageFreshLocked(checked, service, s.images.interval(key.node))) { return true } if "" != service.ID && state.autoPending[service.Service] == service.ID && !s.images.now().Before(state.autoNext[service.Service]) { @@ -354,7 +356,8 @@ func (s *Server) imageFreshLocked(checked checkedImage, service engine.Service, return false } if entry := s.images.cache[checked.key]; "" != checked.key.Ref { - return nil != entry && nil == entry.done && entry.result.CheckedAt.Equal(*checked.update.CheckedAt) && s.registryFreshLocked(entry.result, maxAge) + return nil != entry && nil == entry.done && entry.result.CheckedAt.Equal(*checked.update.CheckedAt) && + s.registryFreshLocked(entry.result, maxAge) } if "error" == checked.update.Status { maxAge = imageErrorRetry @@ -376,9 +379,7 @@ func (s *Server) watchImages() { jobs := make(chan imageStackKey) var workers sync.WaitGroup for i := 0; 4 > i; i++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { for key := range jobs { s.refreshImages(key) s.hubMu.Lock() @@ -387,7 +388,7 @@ func (s *Server) watchImages() { } s.hubMu.Unlock() } - }() + }) } defer func() { close(jobs); workers.Wait() }() tick := time.NewTicker(100 * time.Millisecond) @@ -415,7 +416,7 @@ func (s *Server) watchImages() { } } -func (s *Server) imageRequest(ctx context.Context, node, method, path string, input, output interface{}) error { +func (s *Server) imageRequest(ctx context.Context, node, method, path string, input, output any) error { var body io.Reader if nil != input { data, err := json.Marshal(input) @@ -428,7 +429,7 @@ func (s *Server) imageRequest(ctx context.Context, node, method, path string, in if nil != err { return err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if 200 > response.StatusCode || 300 <= response.StatusCode { return readNodeHTTPError(response, "image update request") } @@ -454,9 +455,7 @@ func (s *Server) refreshImages(key imageStackKey) { inspection := state.inspection state.inspection = nil autoPending := make(map[string]string, len(state.autoPending)) - for service, id := range state.autoPending { - autoPending[service] = id - } + maps.Copy(autoPending, state.autoPending) nodeContext := channel.imageContext s.hubMu.Unlock() ctx, cancel := context.WithTimeout(nodeContext, 45*time.Second) @@ -469,7 +468,14 @@ func (s *Server) refreshImages(key imageStackKey) { if nil != inspection { inspected = *inspection } else { - if err := s.imageRequest(ctx, key.node, "POST", "/images/inspect", protocol.ImageInspectRequest{Stack: key.stack}, &inspected); nil != err { + if err := s.imageRequest( + ctx, + key.node, + "POST", + "/images/inspect", + protocol.ImageInspectRequest{Stack: key.stack}, + &inspected, + ); nil != err { s.imageCheckFailed(key, revision, err) return } @@ -490,7 +496,8 @@ func (s *Server) refreshImages(key imageStackKey) { purpose := "" maxAge := interval config := background - if manual || custom[service.Name] || ("" != service.CheckInterval && "" != service.ID && autoPending[service.Service] == service.ID) { + if manual || custom[service.Name] || + ("" != service.CheckInterval && "" != service.ID && autoPending[service.Service] == service.ID) { purpose = "explicit" config = explicit if manual { @@ -527,7 +534,12 @@ func (s *Server) refreshImages(key imageStackKey) { fresh := !custom[service.Name] && previous.key.purpose == purpose && s.imageFreshLocked(previous, service, age) if !allowed { at := s.images.now().UTC() - state.checked[service.Name] = checkedImage{id: service.ID, image: service.Image, key: imageKey{ImageKey: image.Target.Key, node: key.node}, update: engine.ImageUpdate{CheckedAt: &at}} + state.checked[service.Name] = checkedImage{ + id: service.ID, + image: service.Image, + key: imageKey{ImageKey: image.Target.Key, node: key.node}, + update: engine.ImageUpdate{CheckedAt: &at}, + } s.hubMu.Unlock() continue } @@ -544,7 +556,12 @@ func (s *Server) refreshImages(key imageStackKey) { if fresh { continue } - checked := checkedImage{id: service.ID, image: service.Image, localID: image.Target.LocalID, key: imageKey{ImageKey: image.Target.Key, purpose: purpose, node: key.node}} + checked := checkedImage{ + id: service.ID, + image: service.Image, + localID: image.Target.LocalID, + key: imageKey{ImageKey: image.Target.Key, purpose: purpose, node: key.node}, + } result := engine.RegistryImage{Status: image.Target.Status, Reason: image.Target.Reason, CheckedAt: s.images.now().UTC()} if "" == result.Status { data, _ := json.Marshal(config) @@ -657,7 +674,11 @@ func (s *Server) imageCheckFailed(key imageStackKey, revision uint64, err error) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { level = slog.LevelDebug } - slog.Log(context.Background(), level, fmt.Sprintf("Could not check image updates for stack %q on node %q: %s", key.stack, key.node, safeTransportError(err))) + slog.Log( + context.Background(), + level, + fmt.Sprintf("Could not check image updates for stack %q on node %q: %s", key.stack, key.node, safeTransportError(err)), + ) s.hubMu.Lock() defer s.hubMu.Unlock() state := s.images.stacks[key] @@ -672,7 +693,11 @@ func (s *Server) imageCheckFailed(key imageStackKey, revision uint64, err error) if state.checked[service.Name].available(service) { continue } - state.checked[service.Name] = checkedImage{id: service.ID, image: service.Image, update: engine.ImageUpdate{Status: "error", Reason: "inspect_error", CheckedAt: &now}} + state.checked[service.Name] = checkedImage{ + id: service.ID, + image: service.Image, + update: engine.ImageUpdate{Status: "error", Reason: "inspect_error", CheckedAt: &now}, + } if state.custom[service.Name] { state.next[service.Name] = now.Add(imageErrorRetry) } @@ -729,7 +754,11 @@ func (s *Server) requestImageChecks(w http.ResponseWriter, r *http.Request, node if nil != ctx.Err() { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Could not request image updates for stack %q on node %q: %s", stack, node, safeTransportError(err))) + slog.Log( + ctx, + level, + fmt.Sprintf("Could not request image updates for stack %q on node %q: %s", stack, node, safeTransportError(err)), + ) var upstream nodeHTTPError if errors.As(err, &upstream) && 404 == upstream.status { apiError(w, 404, "Stack not found.") @@ -838,7 +867,10 @@ func (s *Server) rememberAutoUpdatesLocked(state *imageStack, custom map[string] } for _, service := range state.services { checked := state.checked[service.Name] - if service.AutoUpdate && "running" == service.State && "" != service.Service && (custom[service.Name] || ("" == service.CheckInterval && "" == checked.key.purpose)) && "available" == checked.update.Status && checked.id == service.ID { + if service.AutoUpdate && "running" == service.State && "" != service.Service && + (custom[service.Name] || ("" == service.CheckInterval && "" == checked.key.purpose)) && + "available" == checked.update.Status && + checked.id == service.ID { state.autoPending[service.Service] = service.ID } } @@ -853,7 +885,9 @@ func (s *Server) startImageUpdate(ctx context.Context, key imageStackKey) { } service := "" for _, candidate := range state.services { - if candidate.AutoUpdate && "running" == candidate.State && "" != candidate.Service && state.autoPending[candidate.Service] == candidate.ID && !s.images.now().Before(state.autoNext[candidate.Service]) { + if candidate.AutoUpdate && "running" == candidate.State && "" != candidate.Service && + state.autoPending[candidate.Service] == candidate.ID && + !s.images.now().Before(state.autoNext[candidate.Service]) { service = candidate.Service state.autoNext[service] = s.images.now().Add(imageErrorRetry) break @@ -892,11 +926,34 @@ func (s *Server) startImageUpdate(ctx context.Context, key imageStackKey) { if errors.As(err, &upstream) && 409 == upstream.status { level = slog.LevelDebug } - slog.Log(ctx, level, fmt.Sprintf("Automatic update could not start for stack %q, service %q on node %q: %s", key.stack, service, key.node, safeTransportError(err))) + slog.Log( + ctx, + level, + fmt.Sprintf( + "Automatic update could not start for stack %q, service %q on node %q: %s", + key.stack, + service, + key.node, + safeTransportError(err), + ), + ) return } if "accepted" != response.Status { - slog.Error(fmt.Sprintf("Automatic update for stack %q, service %q on node %q was not accepted: invalid node response", key.stack, service, key.node), "node", key.node, "stack", key.stack, "service", service) + slog.Error( + fmt.Sprintf( + "Automatic update for stack %q, service %q on node %q was not accepted: invalid node response", + key.stack, + service, + key.node, + ), + "node", + key.node, + "stack", + key.stack, + "service", + service, + ) return } accepted = true diff --git a/internal/server/images_test.go b/internal/server/images_test.go index ccf7158..6767ddf 100644 --- a/internal/server/images_test.go +++ b/internal/server/images_test.go @@ -146,7 +146,8 @@ func TestImageAvailableExpiry(t *testing.T) { work := s.imageWorkLocked(imageStackKey{"home", "demo"}, state) retained := state.checked["web"] s.hubMu.Unlock() - if work || "available" != retained.update.Status || !checked.update.CheckedAt.Equal(*retained.update.CheckedAt) || 3 != imageRequests(f) { + if work || "available" != retained.update.Status || !checked.update.CheckedAt.Equal(*retained.update.CheckedAt) || + 3 != imageRequests(f) { t.Fatal("known availability expired or scheduled another lookup", work, imageRequests(f)) } }) @@ -179,7 +180,8 @@ func TestImageManualMixed(t *testing.T) { s.hubMu.Lock() retained := s.images.stacks[imageStackKey{"home", "demo"}].checked["web"] s.hubMu.Unlock() - if "available" != retained.update.Status || !checked.update.CheckedAt.Equal(*retained.update.CheckedAt) || 4 != imageRequests(f) { + if "available" != retained.update.Status || !checked.update.CheckedAt.Equal(*retained.update.CheckedAt) || + 4 != imageRequests(f) { t.Fatal("mixed check replaced known availability or skipped the current image", retained.update, imageRequests(f)) } }) @@ -215,7 +217,11 @@ func TestImageInspectFailure(t *testing.T) { state := newImageStack() state.services = []engine.Service{imageService("web"), imageService("worker")} at := time.Now().UTC() - state.checked["web"] = checkedImage{id: "web-id", image: state.services[0].Image, update: engine.ImageUpdate{Status: "available", CheckedAt: &at}} + state.checked["web"] = checkedImage{ + id: "web-id", + image: state.services[0].Image, + update: engine.ImageUpdate{Status: "available", CheckedAt: &at}, + } s.images.stacks[key] = state s.imageCheckFailed(key, state.revision, context.DeadlineExceeded) if "available" != state.checked["web"].update.Status || "error" != state.checked["worker"].update.Status { @@ -249,7 +255,7 @@ func TestImageAvailableAuto(t *testing.T) { f.mu.Lock() f.services["demo"][0] = service f.mu.Unlock() - payload, _ := json.Marshal(map[string]interface{}{"name": "demo", "status": "running", "services": []engine.Service{service}}) + payload, _ := json.Marshal(map[string]any{"name": "demo", "status": "running", "services": []engine.Service{service}}) sendRuntime(t, conn, "stack.runtime", string(payload)) clock.Store(now.Add(31 * time.Minute).UnixNano()) awaitImage(t, func() bool { return 1 == len(imageActions(f)) }) @@ -337,14 +343,14 @@ func TestImageInvalidation(t *testing.T) { // Same reference, new container identity must force a new central observation. f.mu.Lock() f.services["demo"][0].ID = "replacement" - runtime, _ := json.Marshal(map[string]interface{}{"name": "demo", "status": "running", "services": f.services["demo"]}) + runtime, _ := json.Marshal(map[string]any{"name": "demo", "status": "running", "services": f.services["demo"]}) f.mu.Unlock() sendRuntime(t, conn, "stack.runtime", string(runtime)) awaitImage(t, func() bool { return 5 == imageRequests(f) && "available" == imageStatus(s, "home", "demo", "web") }) // A changed image reference must invalidate availability even with the same container ID. f.mu.Lock() f.services["demo"][0].Image = "docker.io/library/nginx:stable" - runtime, _ = json.Marshal(map[string]interface{}{"name": "demo", "status": "running", "services": f.services["demo"]}) + runtime, _ = json.Marshal(map[string]any{"name": "demo", "status": "running", "services": f.services["demo"]}) f.mu.Unlock() sendRuntime(t, conn, "stack.runtime", string(runtime)) awaitImage(t, func() bool { return 8 == imageRequests(f) && "available" == imageStatus(s, "home", "demo", "web") }) @@ -400,7 +406,7 @@ func TestImageScheduleIntervals(t *testing.T) { f.mu.Lock() f.services["demo"][0] = service f.mu.Unlock() - payload, _ := json.Marshal(map[string]interface{}{"name": "demo", "status": "running", "services": []engine.Service{service}}) + payload, _ := json.Marshal(map[string]any{"name": "demo", "status": "running", "services": []engine.Service{service}}) sendRuntime(t, conn, "stack.runtime", string(payload)) awaitImage(t, func() bool { s.hubMu.Lock() @@ -475,8 +481,15 @@ func TestImageCooldownAccount(t *testing.T) { } s := imageServer(t, f, false) lookup := func(username string) engine.RegistryImage { - data, _ := json.Marshal(map[string]interface{}{"auths": map[string]interface{}{"docker.io": map[string]string{"username": username, "password": "secret"}}}) - return s.registryClient.InspectConfig(registryNodeContext(context.Background(), "home"), engine.ImageKey{Ref: "docker.io/library/nginx:latest", ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}, data, 0) + data, _ := json.Marshal( + map[string]any{"auths": map[string]any{"docker.io": map[string]string{"username": username, "password": "secret"}}}, + ) + return s.registryClient.InspectConfig( + registryNodeContext(context.Background(), "home"), + engine.ImageKey{Ref: "docker.io/library/nginx:latest", OS: "linux", Architecture: "amd64"}, + data, + 0, + ) } first := lookup("one") second := lookup("one") @@ -508,7 +521,12 @@ func TestImagePurposes(t *testing.T) { } s := imageServer(t, f, true) s.config.Registries = []RegistryConfig{ - {ID: "background", Registry: "docker.io", BackgroundChecks: true, auth: map[string]string{"username": "background", "password": "background-secret"}}, + { + ID: "background", + Registry: "docker.io", + BackgroundChecks: true, + auth: map[string]string{"username": "background", "password": "background-secret"}, + }, {ID: "pull", Registry: "docker.io", auth: map[string]string{"username": "pull", "password": "pull-secret"}}, } s.registrySync = registrySnapshot(s.config.Registries) @@ -535,11 +553,16 @@ func TestImageNodeFailure(t *testing.T) { one, two := imageNode(t), imageNode(t) one.metadata = func(r *http.Request) *http.Response { return metadataResponse(r, 503, "node route unavailable") } s := imageServer(t, one, false) - node := NodeConfig{Name: "away", URL: two.channel.server.URL, token: "agent-test-token", Fingerprint: testFingerprint(two.channel.server)} + node := NodeConfig{ + Name: "away", + URL: two.channel.server.URL, + token: "agent-test-token", + Fingerprint: testFingerprint(two.channel.server), + } s.config.Nodes["away"] = node s.nodes["away"] = nodeClient(node) config, _ := json.Marshal(registry.DockerConfig{Auths: map[string]map[string]string{}}) - key := imageKey{ImageKey: engine.ImageKey{Ref: "docker.io/library/nginx:latest", ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}, purpose: "explicit", node: "home"} + key := imageKey{Ref: "docker.io/library/nginx:latest", OS: "linux", Architecture: "amd64", purpose: "explicit", node: "home"} first := s.registryImage(registryNodeContext(context.Background(), "home"), key, config, time.Hour, time.Hour) key.node = "away" second := s.registryImage(registryNodeContext(context.Background(), "away"), key, config, time.Hour, time.Hour) @@ -554,7 +577,7 @@ func TestImageManualCacheScope(t *testing.T) { startChannels(t, s) _ = nextConnection(t, f.channel) imageReady(t, s, "home") - key := imageKey{ImageKey: engine.ImageKey{Ref: "private.example/other:latest"}, node: "away", purpose: "explicit"} + key := imageKey{Ref: "private.example/other:latest", node: "away", purpose: "explicit"} entry := &imageCacheEntry{result: engine.RegistryImage{Digest: "retained", CheckedAt: time.Now()}} s.hubMu.Lock() s.images.cache[key] = entry @@ -578,11 +601,16 @@ func TestImageFleetCooldown(t *testing.T) { return response } s := imageServer(t, one, false) - node := NodeConfig{Name: "away", URL: two.channel.server.URL, token: "agent-test-token", Fingerprint: testFingerprint(two.channel.server)} + node := NodeConfig{ + Name: "away", + URL: two.channel.server.URL, + token: "agent-test-token", + Fingerprint: testFingerprint(two.channel.server), + } s.config.Nodes["away"] = node s.nodes["away"] = nodeClient(node) config, _ := json.Marshal(registry.DockerConfig{Auths: map[string]map[string]string{}}) - key := imageKey{ImageKey: engine.ImageKey{Ref: "docker.io/library/nginx:latest", ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}, purpose: "explicit", node: "home"} + key := imageKey{Ref: "docker.io/library/nginx:latest", OS: "linux", Architecture: "amd64", purpose: "explicit", node: "home"} first := s.registryImage(registryNodeContext(context.Background(), "home"), key, config, 0, time.Hour) key.node = "away" second := s.registryImage(registryNodeContext(context.Background(), "away"), key, config, 0, time.Hour) diff --git a/internal/server/log_history.go b/internal/server/log_history.go index cf15f42..fea8cc8 100644 --- a/internal/server/log_history.go +++ b/internal/server/log_history.go @@ -199,7 +199,15 @@ func (h *logHistory) writeRecord(data []byte, rendered string) (int, error) { if err := json.Unmarshal(data, &record); nil != err { return 0, err } - entry := logEntry{Timestamp: record.Time, Level: logLevelName(record.Level), Logger: "server", Message: record.Message, Rendered: rendered, Fields: record.Fields, Source: record.Source} + entry := logEntry{ + Timestamp: record.Time, + Level: logLevelName(record.Level), + Logger: "server", + Message: record.Message, + Rendered: rendered, + Fields: record.Fields, + Source: record.Source, + } if nil == entry.Fields { entry.Fields = make(map[string]json.RawMessage) } @@ -322,11 +330,23 @@ func (h *historyHandler) Handle(ctx context.Context, record slog.Record) error { } func (h *historyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &historyHandler{local: h.local.WithAttrs(attrs), render: h.render.WithAttrs(attrs), store: h.store.WithAttrs(attrs), output: h.output, history: h.history} + return &historyHandler{ + local: h.local.WithAttrs(attrs), + render: h.render.WithAttrs(attrs), + store: h.store.WithAttrs(attrs), + output: h.output, + history: h.history, + } } func (h *historyHandler) WithGroup(name string) slog.Handler { - return &historyHandler{local: h.local.WithGroup(name), render: h.render.WithGroup(name), store: h.store.WithGroup(name), output: h.output, history: h.history} + return &historyHandler{ + local: h.local.WithGroup(name), + render: h.render.WithGroup(name), + store: h.store.WithGroup(name), + output: h.output, + history: h.history, + } } // Records written before rendered text was captured still expose a complete @@ -350,8 +370,8 @@ func renderStoredLog(entry logEntry) string { } sort.Strings(keys) for _, key := range keys { - // interface{} is the legacy JSON boundary, retaining native field values. - var value interface{} + // any is the legacy JSON boundary, retaining native field values. + var value any decoder := json.NewDecoder(bytes.NewReader(entry.Fields[key])) decoder.UseNumber() _ = decoder.Decode(&value) @@ -387,9 +407,9 @@ func (f logFilter) matches(entry logEntry) bool { } if "" != f.search { encoded, _ := json.Marshal(entry) - // interface{} is the JSON search boundary. Decode escaped string values + // any is the JSON search boundary. Decode escaped string values // while preserving integer text in diagnostic fields. - var record interface{} + var record any decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.UseNumber() _ = decoder.Decode(&record) diff --git a/internal/server/log_history_test.go b/internal/server/log_history_test.go index 91981cd..2adf3ee 100644 --- a/internal/server/log_history_test.go +++ b/internal/server/log_history_test.go @@ -47,17 +47,25 @@ func historyPage(t *testing.T, h *logHistory, filter logFilter) logPage { func TestHistoryCapture(t *testing.T) { s := testServer(t, nil) stderr := startLogHistory(t, s) - slog.With("service", "web").WithGroup("details").Error("diagnostic", "error", errors.New("manifest failure"), "codes", []int{401, 403}, "automatic", true, "attempt", int64(9_007_199_254_740_993)) + slog.With("service", "web"). + WithGroup("details"). + Error("diagnostic", "error", errors.New("manifest failure"), "codes", []int{401, 403}, "automatic", true, "attempt", int64(9_007_199_254_740_993)) page := historyPage(t, s.logHistory, logFilter{}) if 1 != len(page.Entries) || nil != page.NextBefore || page.LatestID != page.Entries[0].ID || nil == page.OldestID || page.Truncated { t.Fatalf("page: %+v", page) } entry := page.Entries[0] - if "error" != entry.Level || "server" != entry.Logger || `"web"` != string(entry.Fields["service"]) || entry.Timestamp.IsZero() || nil == entry.Source || !strings.HasSuffix(entry.Source.Function, ".TestHistoryCapture") || 0 == entry.Source.Line { + if "error" != entry.Level || "server" != entry.Logger || `"web"` != string(entry.Fields["service"]) || entry.Timestamp.IsZero() || + nil == entry.Source || + !strings.HasSuffix(entry.Source.Function, ".TestHistoryCapture") || + 0 == entry.Source.Line { t.Fatalf("entry: %+v", entry) } var details map[string]json.RawMessage - if nil != json.Unmarshal(entry.Fields["details"], &details) || `"manifest failure"` != string(details["error"]) || "9007199254740993" != string(details["attempt"]) || "true" != string(details["automatic"]) || "[401,403]" != string(details["codes"]) { + if nil != json.Unmarshal(entry.Fields["details"], &details) || `"manifest failure"` != string(details["error"]) || + "9007199254740993" != string(details["attempt"]) || + "true" != string(details["automatic"]) || + "[401,403]" != string(details["codes"]) { t.Fatalf("fields: %s", entry.Fields["details"]) } if !strings.Contains(stderr.String(), "manifest failure") { @@ -153,16 +161,21 @@ func TestHistoryNodeOrder(t *testing.T) { startLogHistory(t, s) slog.Info("local") channel := &nodeChannel{id: "home", node: NodeConfig{Name: "Home"}, ctx: context.Background()} - payload := json.RawMessage(`{"entries":[{"time":"2026-10-29T12:00:00Z","level":"ERROR","msg":"newer","attrs":{"stderr":"registry denied"}},{"time":"2026-10-28T12:00:00Z","level":"WARN","msg":"buffered","attrs":{"platforms":{"linux/amd64":"example:latest"}}}]}`) + payload := json.RawMessage( + `{"entries":[{"time":"2026-10-29T12:00:00Z","level":"ERROR","msg":"newer","attrs":{"stderr":"registry denied"}},{"time":"2026-10-28T12:00:00Z","level":"WARN","msg":"buffered","attrs":{"platforms":{"linux/amd64":"example:latest"}}}]}`, + ) if err := s.nodeLogs(channel, payload); nil != err { t.Fatal(err) } page := historyPage(t, s.logHistory, logFilter{}) - if 3 != len(page.Entries) || page.Entries[0].ID >= page.Entries[1].ID || page.Entries[1].ID >= page.Entries[2].ID || !page.Entries[2].Timestamp.Before(page.Entries[1].Timestamp) || nil != page.Entries[1].Source { + if 3 != len(page.Entries) || page.Entries[0].ID >= page.Entries[1].ID || page.Entries[1].ID >= page.Entries[2].ID || + !page.Entries[2].Timestamp.Before(page.Entries[1].Timestamp) || + nil != page.Entries[1].Source { t.Fatalf("append/source order: %+v", page) } filtered := historyPage(t, s.logHistory, logFilter{node: "home", search: "linux/amd64"}) - if 1 != len(filtered.Entries) || "warning" != filtered.Entries[0].Level || `{"linux/amd64":"example:latest"}` != string(filtered.Entries[0].Fields["platforms"]) { + if 1 != len(filtered.Entries) || "warning" != filtered.Entries[0].Level || + `{"linux/amd64":"example:latest"}` != string(filtered.Entries[0].Fields["platforms"]) { t.Fatalf("platform diagnostic fields: %+v", filtered) } } @@ -171,7 +184,9 @@ func TestHistorySearch(t *testing.T) { s := testServer(t, nil) startLogHistory(t, s) channel := &nodeChannel{id: "home", node: NodeConfig{Name: "Home"}, ctx: context.Background()} - payload := json.RawMessage(`{"entries":[{"time":"2026-10-29T12:00:00Z","level":"ERROR","msg":"diagnostic","source":{"file":"node.go","function":"node.check","line":42},"attrs":{"stderr":"Échec\nRegistry DENIED","attempt":9007199254740993}}]}`) + payload := json.RawMessage( + `{"entries":[{"time":"2026-10-29T12:00:00Z","level":"ERROR","msg":"diagnostic","source":{"file":"node.go","function":"node.check","line":42},"attrs":{"stderr":"Échec\nRegistry DENIED","attempt":9007199254740993}}]}`, + ) if err := s.nodeLogs(channel, payload); nil != err { t.Fatal(err) } @@ -251,7 +266,12 @@ func TestHistoryRendered(t *testing.T) { message := `node "local" path "C:\stacks\compose.yaml"` for _, level := range []slog.Level{slog.LevelDebug, slog.LevelWarn, slog.LevelError} { record := slog.NewRecord(stamp, level, message, 0) - record.AddAttrs(slog.String("image", "example:latest"), slog.String("stderr", "first line\nsecond line"), slog.Any("error", errors.New("manifest failure")), slog.Group("details", "attempt", 2, "automatic", true)) + record.AddAttrs( + slog.String("image", "example:latest"), + slog.String("stderr", "first line\nsecond line"), + slog.Any("error", errors.New("manifest failure")), + slog.Group("details", "attempt", 2, "automatic", true), + ) if err := logger.Handler().Handle(context.Background(), record); nil != err { t.Fatal(err) } @@ -294,14 +314,12 @@ func TestHistoryRenderedConcurrent(t *testing.T) { logger := slog.New(newHistoryHandler(logging.NewConsoleHandler(&local, nil), h)) var workers sync.WaitGroup for worker := 0; 8 > worker; worker++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { bound := logger.With("worker", worker).WithGroup("entry") for index := 0; 20 > index; index++ { bound.Info("diagnostic", "index", index, "error", errors.New("manifest failure")) } - }() + }) } workers.Wait() lines := make(map[string]int) @@ -318,7 +336,9 @@ func TestHistoryRenderedConcurrent(t *testing.T) { var fields struct { Index int `json:"index"` } - if nil != json.Unmarshal(entry.Fields["entry"], &fields) || 0 == lines[entry.Rendered] || !strings.Contains(entry.Rendered, " worker="+string(entry.Fields["worker"])+" ") || !strings.Contains(entry.Rendered, " entry.index="+strconv.Itoa(fields.Index)+" ") { + if nil != json.Unmarshal(entry.Fields["entry"], &fields) || 0 == lines[entry.Rendered] || + !strings.Contains(entry.Rendered, " worker="+string(entry.Fields["worker"])+" ") || + !strings.Contains(entry.Rendered, " entry.index="+strconv.Itoa(fields.Index)+" ") { t.Fatal("concurrent rendered record mixed", entry) } lines[entry.Rendered]-- @@ -327,7 +347,9 @@ func TestHistoryRenderedConcurrent(t *testing.T) { func TestHistoryRenderedLegacy(t *testing.T) { path := filepath.Join(t.TempDir(), "dcompose.log") - legacy := []byte(`{"id":123,"timestamp":"2026-10-29T12:00:00.123456789Z","level":"warning","logger":"server","message":"node \"local\" path \"C:\\stacks\\compose.yaml\"","fields":{"stderr":"first line\nsecond line","error":"manifest failure","automatic":true,"attempt":9007199254740993,"codes":[401,403],"details":{"service":"web"}}}` + "\n") + legacy := []byte( + `{"id":123,"timestamp":"2026-10-29T12:00:00.123456789Z","level":"warning","logger":"server","message":"node \"local\" path \"C:\\stacks\\compose.yaml\"","fields":{"stderr":"first line\nsecond line","error":"manifest failure","automatic":true,"attempt":9007199254740993,"codes":[401,403],"details":{"service":"web"}}}` + "\n", + ) if err := os.WriteFile(path, legacy, 0600); nil != err { t.Fatal(err) } @@ -339,13 +361,22 @@ func TestHistoryRenderedLegacy(t *testing.T) { stamp := time.Date(2026, 10, 29, 12, 0, 0, 123456789, time.UTC) message := `node "local" path "C:\stacks\compose.yaml"` record := slog.NewRecord(stamp, slog.LevelWarn, message, 0) - record.AddAttrs(slog.Any("attempt", json.Number("9007199254740993")), slog.Bool("automatic", true), slog.Any("codes", []json.Number{"401", "403"}), slog.Any("details", map[string]string{"service": "web"}), slog.String("error", "manifest failure"), slog.String("stderr", "first line\nsecond line")) + record.AddAttrs( + slog.Any("attempt", json.Number("9007199254740993")), + slog.Bool("automatic", true), + slog.Any("codes", []json.Number{"401", "403"}), + slog.Any("details", map[string]string{"service": "web"}), + slog.String("error", "manifest failure"), + slog.String("stderr", "first line\nsecond line"), + ) var expected bytes.Buffer if err := logging.NewConsoleHandler(&expected, nil).Handle(context.Background(), record); nil != err { t.Fatal(err) } page := historyPage(t, h, logFilter{}) - if 1 != len(page.Entries) || expected.String() != page.Entries[0].Rendered || !strings.Contains(page.Entries[0].Rendered, " "+message+" ") || "9007199254740993" != string(page.Entries[0].Fields["attempt"]) { + if 1 != len(page.Entries) || expected.String() != page.Entries[0].Rendered || + !strings.Contains(page.Entries[0].Rendered, " "+message+" ") || + "9007199254740993" != string(page.Entries[0].Fields["attempt"]) { t.Fatal("legacy rendered fallback lost content", page) } subscriber, backlog, _, err := h.subscribe(logFilter{}) @@ -362,7 +393,14 @@ func TestHistoryRenderedLegacy(t *testing.T) { func TestHistoryRenderedPreserved(t *testing.T) { path := filepath.Join(t.TempDir(), "dcompose.log") rendered := "time=2026-10-29T12:00:00Z level=INFO msg=\"node \\\"local\\\"\"\n" - entry := logEntry{ID: 123, Timestamp: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC), Level: "info", Logger: "server", Message: `node "local"`, Rendered: rendered} + entry := logEntry{ + ID: 123, + Timestamp: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC), + Level: "info", + Logger: "server", + Message: `node "local"`, + Rendered: rendered, + } data, err := json.Marshal(entry) if nil != err { t.Fatal(err) diff --git a/internal/server/logging_test.go b/internal/server/logging_test.go index d4d115a..5f4d105 100644 --- a/internal/server/logging_test.go +++ b/internal/server/logging_test.go @@ -89,7 +89,11 @@ func TestProxyLogs(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || w.Code != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), test.detail) { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || w.Code != response.Error.Code || "" == response.Error.Message || + strings.Contains(w.Body.String(), test.detail) { t.Fatal("unexpected public error", w.Body.String()) } var record struct { @@ -103,7 +107,11 @@ func TestProxyLogs(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &record); nil != err { t.Fatal(err, output.String()) } - if "ERROR" != record.Level || !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, "https://node.invalid") || "" == record.Operation || test.status != record.Status || test.detail != record.Error { + if "ERROR" != record.Level || !strings.Contains(record.Node, "Home") || + !strings.Contains(record.Node, "https://node.invalid") || + "" == record.Operation || + test.status != record.Status || + test.detail != record.Error { t.Fatal("missing structured failure context", output.String()) } for _, detail := range []string{"Home", "https://node.invalid", test.detail} { diff --git a/internal/server/logs.go b/internal/server/logs.go index 4ab7f54..c766871 100644 --- a/internal/server/logs.go +++ b/internal/server/logs.go @@ -11,7 +11,13 @@ import ( func parseLogFilter(r *http.Request, stream bool) (logFilter, error) { query := r.URL.Query() - filter := logFilter{limit: 100, node: query.Get("node"), logger: query.Get("logger"), search: strings.ToLower(query.Get("search")), levels: make(map[string]bool)} + filter := logFilter{ + limit: 100, + node: query.Get("node"), + logger: query.Get("logger"), + search: strings.ToLower(query.Get("search")), + levels: make(map[string]bool), + } for key, values := range query { switch key { case "limit", "before", "after", "levels", "node", "logger", "search": @@ -46,7 +52,7 @@ func parseLogFilter(r *http.Request, stream bool) (logFilter, error) { *target = value } if query.Has("levels") { - for _, level := range strings.Split(query.Get("levels"), ",") { + for level := range strings.SplitSeq(query.Get("levels"), ",") { level = strings.ToLower(strings.TrimSpace(level)) if "warn" == level { level = "warning" @@ -113,13 +119,13 @@ func (s *Server) streamLogs(w http.ResponseWriter, r *http.Request) { if nil != err { return false } - return write([]byte(fmt.Sprintf("event: log_lines\nid: %d\ndata: %s\n\n", entry.ID, data))) + return write(fmt.Appendf(nil, "event: log_lines\nid: %d\ndata: %s\n\n", entry.ID, data)) } if !write([]byte(": connected\n\n")) { return } metadata, _ := json.Marshal(status) - if !write([]byte(fmt.Sprintf("event: log_status\ndata: %s\n\n", metadata))) { + if !write(fmt.Appendf(nil, "event: log_status\ndata: %s\n\n", metadata)) { return } ticker := time.NewTicker(15 * time.Second) diff --git a/internal/server/logs_test.go b/internal/server/logs_test.go index 74b164d..ec29963 100644 --- a/internal/server/logs_test.go +++ b/internal/server/logs_test.go @@ -21,7 +21,9 @@ func TestLogsAPI(t *testing.T) { for _, query := range []string{"", "?levels=warn&node=server&logger=server&search=registry"} { w := perform(t, s, "GET", "/api/logs"+query, "") var page logPage - if http.StatusOK != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &page) || 1 != len(page.Entries) || "" == page.Entries[0].Rendered || `"first line\nRegistry DENIED"` != string(page.Entries[0].Fields["stderr"]) { + if http.StatusOK != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &page) || 1 != len(page.Entries) || + "" == page.Entries[0].Rendered || + `"first line\nRegistry DENIED"` != string(page.Entries[0].Fields["stderr"]) { t.Fatal(w.Code, w.Body.String()) } } @@ -60,7 +62,8 @@ func TestLogQueries(t *testing.T) { for _, path := range []string{"/api/logs", "/api/logs?levels=warning,error&limit=500&after=0", "/api/logs?before=0"} { w := perform(t, s, "GET", path, "") var page logPage - if http.StatusOK != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &page) || nil == page.Entries || 0 != len(page.Entries) || nil != page.OldestID { + if http.StatusOK != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &page) || nil == page.Entries || 0 != len(page.Entries) || + nil != page.OldestID { t.Fatal(path, w.Code, w.Body.String()) } } @@ -108,11 +111,11 @@ func nextLogStatus(t *testing.T, reader *bufio.Reader) logStatus { if nil != err { t.Fatal(err) } - if strings.HasPrefix(line, "event: ") { - event = strings.TrimSpace(strings.TrimPrefix(line, "event: ")) + if value, ok := strings.CutPrefix(line, "event: "); ok { + event = strings.TrimSpace(value) } - if strings.HasPrefix(line, "data: ") { - if "log_status" != event || nil != json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &status) { + if value, ok := strings.CutPrefix(line, "data: "); ok { + if "log_status" != event || nil != json.Unmarshal([]byte(value), &status) { t.Fatal("missing stream retention metadata", event, line) } return status @@ -161,7 +164,8 @@ func TestLogStream(t *testing.T) { } first := nextLogEvent(t, reader) second := nextLogEvent(t, reader) - if page.Entries[0].ID != first.ID || page.Entries[2].ID != second.ID || page.Entries[0].Rendered != first.Rendered || page.Entries[2].Rendered != second.Rendered { + if page.Entries[0].ID != first.ID || page.Entries[2].ID != second.ID || page.Entries[0].Rendered != first.Rendered || + page.Entries[2].Rendered != second.Rendered { t.Fatal("filtered backlog", first, second) } slog.Info("live gap") diff --git a/internal/server/node_logs.go b/internal/server/node_logs.go index 279f76e..ca7f952 100644 --- a/internal/server/node_logs.go +++ b/internal/server/node_logs.go @@ -24,9 +24,9 @@ func (s *Server) nodeLogs(channel *nodeChannel, payload json.RawMessage) error { case "node_id", "node_name", "_from_node", "_node_source": continue } - // interface{} is the JSON boundary: attributes may contain any native + // any is the JSON boundary: attributes may contain any native // JSON value. UseNumber retains integer diagnostic fields exactly. - var value interface{} + var value any decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&value); nil != err { diff --git a/internal/server/node_logs_test.go b/internal/server/node_logs_test.go index 5da4cb3..656cafc 100644 --- a/internal/server/node_logs_test.go +++ b/internal/server/node_logs_test.go @@ -57,7 +57,9 @@ func (o *nodeLogOutput) next(t *testing.T) map[string]json.RawMessage { func TestNodeLogs(t *testing.T) { output := captureNodeLogs(t, slog.LevelDebug) channel := &nodeChannel{id: "home", node: NodeConfig{Name: "Home"}, ctx: context.Background()} - payload := json.RawMessage(`{"entries":[{"time":"2026-10-29T12:00:00.123456789Z","level":"ERROR","msg":"diagnostic","attrs":{"node_id":"forged","node_name":"forged","_from_node":false,"stack":"demo","service":"web","operation":"job","image":"example:latest","stderr":"registry denied","error":"manifest failure","attempt":9007199254740993,"automatic":true,"details":{"registry":"example","codes":[401,403]}}}]}`) + payload := json.RawMessage( + `{"entries":[{"time":"2026-10-29T12:00:00.123456789Z","level":"ERROR","msg":"diagnostic","attrs":{"node_id":"forged","node_name":"forged","_from_node":false,"stack":"demo","service":"web","operation":"job","image":"example:latest","stderr":"registry denied","error":"manifest failure","attempt":9007199254740993,"automatic":true,"details":{"registry":"example","codes":[401,403]}}}]}`, + ) if err := (&Server{}).nodeLogs(channel, payload); nil != err { t.Fatal(err) } @@ -77,7 +79,8 @@ func TestNodeLogs(t *testing.T) { Registry string `json:"registry"` Codes []int `json:"codes"` } - if nil != json.Unmarshal(record["details"], &details) || "example" != details.Registry || 2 != len(details.Codes) || 401 != details.Codes[0] { + if nil != json.Unmarshal(record["details"], &details) || "example" != details.Registry || 2 != len(details.Codes) || + 401 != details.Codes[0] { t.Fatalf("nested attrs lost: %s", record["details"]) } } @@ -85,7 +88,10 @@ func TestNodeLogs(t *testing.T) { func TestNodeLogLevel(t *testing.T) { output := captureNodeLogs(t, slog.LevelWarn) channel := &nodeChannel{id: "home", ctx: context.Background()} - if err := (&Server{}).nodeLogs(channel, json.RawMessage(`{"entries":[{"level":"DEBUG"},{"level":"INFO"},{"level":"WARN"}]}`)); nil != err { + if err := (&Server{}).nodeLogs( + channel, + json.RawMessage(`{"entries":[{"level":"DEBUG"},{"level":"INFO"},{"level":"WARN"}]}`), + ); nil != err { t.Fatal(err) } if `"WARN"` != string(output.next(t)["level"]) || 0 != len(output.lines) { @@ -109,12 +115,15 @@ func TestChannelNodeLogs(t *testing.T) { t.Cleanup(func() { s.hubMu.Lock(); delete(s.watchers, frames); s.hubMu.Unlock() }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - payload := json.RawMessage(`{"entries":[{"time":"2026-10-29T12:00:00.123456789Z","level":"ERROR","msg":"diagnostic","source":{"file":"node.go","function":"node.check","line":42},"attrs":{"image":"example:latest","stderr":"registry denied\nsecond line","node_id":"forged","_node_source":{"file":"forged"}}}]}`) + payload := json.RawMessage( + `{"entries":[{"time":"2026-10-29T12:00:00.123456789Z","level":"ERROR","msg":"diagnostic","source":{"file":"node.go","function":"node.check","line":42},"attrs":{"image":"example:latest","stderr":"registry denied\nsecond line","node_id":"forged","_node_source":{"file":"forged"}}}]}`, + ) if err := wsjson.Write(ctx, conn, protocol.ChannelMessage{Type: "node.logs", Node: "forged", Payload: payload}); nil != err { t.Fatal(err) } record := output.next(t) - if `"home"` != string(record["node_id"]) || `"Home"` != string(record["node_name"]) || `"registry denied\nsecond line"` != string(record["stderr"]) { + if `"home"` != string(record["node_id"]) || `"Home"` != string(record["node_name"]) || + `"registry denied\nsecond line"` != string(record["stderr"]) { t.Fatalf("channel log: %+v", record) } select { @@ -129,7 +138,12 @@ func TestChannelNodeLogs(t *testing.T) { t.Fatalf("central node history: %+v", page) } entry := page.Entries[0] - if "node.home" != entry.Logger || "error" != entry.Level || `"home"` != string(entry.Fields["node_id"]) || `"Home"` != string(entry.Fields["node_name"]) || nil == entry.Source || "node.go" != entry.Source.File || 42 != entry.Source.Line || "2026-10-29T12:00:00.123456789Z" != entry.Timestamp.Format(time.RFC3339Nano) { + if "node.home" != entry.Logger || "error" != entry.Level || `"home"` != string(entry.Fields["node_id"]) || + `"Home"` != string(entry.Fields["node_name"]) || + nil == entry.Source || + "node.go" != entry.Source.File || + 42 != entry.Source.Line || + "2026-10-29T12:00:00.123456789Z" != entry.Timestamp.Format(time.RFC3339Nano) { t.Fatalf("central node entry: %+v", entry) } if _, exists := entry.Fields["_from_node"]; exists { diff --git a/internal/server/node_order_test.go b/internal/server/node_order_test.go index bc5f6d2..3998615 100644 --- a/internal/server/node_order_test.go +++ b/internal/server/node_order_test.go @@ -57,7 +57,13 @@ func TestNodeOrder(t *testing.T) { up := httptest.NewTLSServer(nil) defer up.Close() s := persistedServer(t, up) - w := perform(t, s, "POST", "/api/settings/nodes", fmt.Sprintf(`{"name":"Alpha","url":%q,"token":"new-token","fingerprint":%q}`, up.URL, testFingerprint(up))) + w := perform( + t, + s, + "POST", + "/api/settings/nodes", + fmt.Sprintf(`{"name":"Alpha","url":%q,"token":"new-token","fingerprint":%q}`, up.URL, testFingerprint(up)), + ) var other safeNode if err := json.Unmarshal(w.Body.Bytes(), &other); nil != err || 200 != w.Code || "" == other.ID { t.Fatal(w.Code, w.Body.String(), err) @@ -76,12 +82,24 @@ func TestNodeOrder(t *testing.T) { } s = New(loaded, nil) assertNodeOrder(t, s, "home", other.ID) - w = perform(t, s, "PUT", "/api/settings/nodes/home", fmt.Sprintf(`{"name":"Zulu","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up))) + w = perform( + t, + s, + "PUT", + "/api/settings/nodes/home", + fmt.Sprintf(`{"name":"Zulu","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up)), + ) if 200 != w.Code { t.Fatal(w.Code, w.Body.String()) } assertNodeOrder(t, s, "home", other.ID) - w = perform(t, s, "POST", "/api/settings/nodes", fmt.Sprintf(`{"name":"0 Before","url":%q,"token":"new-token","fingerprint":%q}`, up.URL, testFingerprint(up))) + w = perform( + t, + s, + "POST", + "/api/settings/nodes", + fmt.Sprintf(`{"name":"0 Before","url":%q,"token":"new-token","fingerprint":%q}`, up.URL, testFingerprint(up)), + ) var added safeNode if err := json.Unmarshal(w.Body.Bytes(), &added); nil != err || 200 != w.Code || "" == added.ID { t.Fatal(w.Code, w.Body.String(), err) @@ -197,7 +215,16 @@ func TestNodeOrderChannel(t *testing.T) { if !unchanged || clients["home"] != currentClients["home"] || 1 != f.count.Load() { t.Fatal("order changed node connections or runtime") } - browserSend(t, node, protocol.ChannelMessage{Type: "stream.event", Stream: attach.Stream, Event: "message", Payload: json.RawMessage(`{"message":"still attached"}`)}) + browserSend( + t, + node, + protocol.ChannelMessage{ + Type: "stream.event", + Stream: attach.Stream, + Event: "message", + Payload: json.RawMessage(`{"message":"still attached"}`), + }, + ) if got := resourceMessage(t, browser); "logs" != got.Stream || !strings.Contains(string(got.Payload), "still attached") { t.Fatal("order detached logs", got) } diff --git a/internal/server/oidc.go b/internal/server/oidc.go index 9780842..707e327 100644 --- a/internal/server/oidc.go +++ b/internal/server/oidc.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/subtle" "net/http" "net/url" @@ -19,6 +20,35 @@ type oidcLogin struct { idToken *oidc.IDTokenVerifier } +type oidcProviderCache struct { + issuer string + clientID string + expires time.Time + provider *oidc.Provider + verifier *oidc.IDTokenVerifier +} + +func (s *Server) oidcProvider(ctx context.Context, config *OIDCConfig) (*oidc.Provider, *oidc.IDTokenVerifier, error) { + s.oidcMu.Lock() + cache := s.oidcCache + s.oidcMu.Unlock() + if config.Issuer == cache.issuer && config.ClientID == cache.clientID && time.Now().Before(cache.expires) { + return cache.provider, cache.verifier, nil + } + provider, err := oidc.NewProvider(ctx, config.Issuer) + if nil != err { + return nil, nil, err + } + verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID}) + s.oidcMu.Lock() + s.oidcCache = oidcProviderCache{ + issuer: config.Issuer, clientID: config.ClientID, + expires: time.Now().Add(time.Minute), provider: provider, verifier: verifier, + } + s.oidcMu.Unlock() + return provider, verifier, nil +} + func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { c, _ := s.snapshot() if nil == c.Auth.OIDC || c.Auth.setupRequired() { @@ -26,7 +56,7 @@ func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { return } ctx := oidc.ClientContext(r.Context(), &http.Client{Timeout: 15 * time.Second}) - provider, err := oidc.NewProvider(ctx, c.Auth.OIDC.Issuer) + provider, verifier, err := s.oidcProvider(ctx, c.Auth.OIDC) if nil != err { apiError(w, 502, "Could not reach your sign-in provider. Try again.") return @@ -40,7 +70,7 @@ func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { RedirectURL: c.Auth.OIDC.RedirectURI, Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, }, - idToken: provider.Verifier(&oidc.Config{ClientID: c.Auth.OIDC.ClientID}), + idToken: verifier, } state := oauth2.GenerateVerifier() s.oidcMu.Lock() @@ -51,8 +81,24 @@ func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { } s.oidcPending[state] = pending s.oidcMu.Unlock() - http.SetCookie(w, &http.Cookie{Name: "dcompose_oidc", Value: state, Path: "/api/auth/oidc", HttpOnly: true, Secure: c.Global.SecureCookie || strings.HasPrefix(c.Auth.OIDC.RedirectURI, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: 300}) - http.Redirect(w, r, pending.oauth.AuthCodeURL(state, oidc.Nonce(pending.nonce), oauth2.S256ChallengeOption(pending.verifier)), http.StatusFound) + http.SetCookie( + w, + &http.Cookie{ + Name: "dcompose_oidc", + Value: state, + Path: "/api/auth/oidc", + HttpOnly: true, + Secure: c.Global.SecureCookie || strings.HasPrefix(c.Auth.OIDC.RedirectURI, "https://"), + SameSite: http.SameSiteLaxMode, + MaxAge: 300, + }, + ) + http.Redirect( + w, + r, + pending.oauth.AuthCodeURL(state, oidc.Nonce(pending.nonce), oauth2.S256ChallengeOption(pending.verifier)), + http.StatusFound, + ) } func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { @@ -61,10 +107,21 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { if nil != c.Auth.OIDC { secure = secure || strings.HasPrefix(c.Auth.OIDC.RedirectURI, "https://") } - http.SetCookie(w, &http.Cookie{Name: "dcompose_oidc", Path: "/api/auth/oidc", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1}) + http.SetCookie( + w, + &http.Cookie{ + Name: "dcompose_oidc", + Path: "/api/auth/oidc", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }, + ) state := r.URL.Query().Get("state") binding, err := r.Cookie("dcompose_oidc") - if nil == c.Auth.OIDC || c.Auth.setupRequired() || "" == state || nil != err || 1 != subtle.ConstantTimeCompare([]byte(state), []byte(binding.Value)) { + if nil == c.Auth.OIDC || c.Auth.setupRequired() || "" == state || nil != err || + 1 != subtle.ConstantTimeCompare([]byte(state), []byte(binding.Value)) { apiError(w, 400, "External sign-in failed. Try signing in again.") return } @@ -99,11 +156,13 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { apiError(w, 400, "External sign-in failed. Try signing in again.") return } - if "" == strings.TrimSpace(identity.Subject) || identity.IssuedAt.IsZero() || 1 != subtle.ConstantTimeCompare([]byte(identity.Nonce), []byte(pending.nonce)) { + if "" == strings.TrimSpace(identity.Subject) || identity.IssuedAt.IsZero() || + 1 != subtle.ConstantTimeCompare([]byte(identity.Nonce), []byte(pending.nonce)) { apiError(w, 400, "External sign-in failed. Try signing in again.") return } - if ("" != claims.AuthorizedParty && pending.oauth.ClientID != claims.AuthorizedParty) || (1 < len(identity.Audience) && pending.oauth.ClientID != claims.AuthorizedParty) { + if ("" != claims.AuthorizedParty && pending.oauth.ClientID != claims.AuthorizedParty) || + (1 < len(identity.Audience) && pending.oauth.ClientID != claims.AuthorizedParty) { apiError(w, 400, "External sign-in failed. Try signing in again.") return } diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go index 8b41b48..f0687c9 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -12,6 +13,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -26,6 +28,7 @@ type fakeOIDC struct { nonce string challenge string mode string + discovery atomic.Int32 } func newFakeOIDC(t *testing.T) *fakeOIDC { @@ -39,15 +42,21 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/.well-known/openid-configuration": + f.discovery.Add(1) issuer := f.server.URL if "discovery" == f.mode { issuer += "/wrong" } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "issuer": issuer, "authorization_endpoint": f.server.URL + "/authorize", "token_endpoint": f.server.URL + "/token", "jwks_uri": f.server.URL + "/keys", "id_token_signing_alg_values_supported": []string{"ES256"}, + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": issuer, + "authorization_endpoint": f.server.URL + "/authorize", + "token_endpoint": f.server.URL + "/token", + "jwks_uri": f.server.URL + "/keys", + "id_token_signing_alg_values_supported": []string{"ES256"}, }) case "/keys": - _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "test", Algorithm: "ES256", Use: "sig"}}}) + _ = json.NewEncoder(w). + Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "test", Algorithm: "ES256", Use: "sig"}}}) case "/token": if err := r.ParseForm(); nil != err { t.Error(err) @@ -56,7 +65,11 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { if !ok { client, secret = r.Form.Get("client_id"), r.Form.Get("client_secret") } - if "dcompose" != client || "client-secret" != secret || "authorization_code" != r.Form.Get("grant_type") || "test-code" != r.Form.Get("code") || "http://localhost:8082/api/auth/oidc/callback" != r.Form.Get("redirect_uri") || "" == f.challenge || f.challenge != oauth2.S256ChallengeFromVerifier(r.Form.Get("code_verifier")) { + if "dcompose" != client || "client-secret" != secret || "authorization_code" != r.Form.Get("grant_type") || + "test-code" != r.Form.Get("code") || + "http://localhost:8082/api/auth/oidc/callback" != r.Form.Get("redirect_uri") || + "" == f.challenge || + f.challenge != oauth2.S256ChallengeFromVerifier(r.Form.Get("code_verifier")) { t.Error("invalid code exchange, client authentication or PKCE verifier") http.Error(w, "invalid_request", 400) return @@ -65,7 +78,14 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { http.Error(w, "provider error", 400) return } - claims := map[string]interface{}{"iss": f.server.URL, "aud": "dcompose", "sub": "provider-user", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "nonce": f.nonce} + claims := map[string]any{ + "iss": f.server.URL, + "aud": "dcompose", + "sub": "provider-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "nonce": f.nonce, + } switch f.mode { case "issuer": claims["iss"] = "https://wrong.example" @@ -97,7 +117,10 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { return } } - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: signingKey}, (&jose.SignerOptions{}).WithHeader("kid", "test")) + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: signingKey}, + (&jose.SignerOptions{}).WithHeader("kid", "test"), + ) if nil != err { t.Error(err) return @@ -113,7 +136,7 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { t.Error(err) return } - response := map[string]interface{}{"access_token": "unused-access-token", "token_type": "Bearer", "id_token": raw} + response := map[string]any{"access_token": "unused-access-token", "token_type": "Bearer", "id_token": raw} if "missing_token" == f.mode { delete(response, "id_token") } @@ -137,7 +160,11 @@ func startOIDC(t *testing.T, s *Server, f *fakeOIDC) (*http.Cookie, string) { t.Fatal(err) } q := u.Query() - if f.server.URL+"/authorize" != u.Scheme+"://"+u.Host+u.Path || "dcompose" != q.Get("client_id") || "code" != q.Get("response_type") || "openid profile email" != q.Get("scope") || "S256" != q.Get("code_challenge_method") || "" == q.Get("state") || "" == q.Get("nonce") { + if f.server.URL+"/authorize" != u.Scheme+"://"+u.Host+u.Path || "dcompose" != q.Get("client_id") || "code" != q.Get("response_type") || + "openid profile email" != q.Get("scope") || + "S256" != q.Get("code_challenge_method") || + "" == q.Get("state") || + "" == q.Get("nonce") { t.Fatal("invalid authorization request", u) } f.nonce, f.challenge = q.Get("nonce"), q.Get("code_challenge") @@ -154,7 +181,13 @@ func TestOIDC(t *testing.T) { f := newFakeOIDC(t) f.mode = mode s := testServer(t, nil) - s.config.Auth.OIDC = &OIDCConfig{Issuer: f.server.URL, ClientID: "dcompose", ClientSecret: "client-secret", clientSecret: "client-secret", RedirectURI: "http://localhost:8082/api/auth/oidc/callback"} + s.config.Auth.OIDC = &OIDCConfig{ + Issuer: f.server.URL, + ClientID: "dcompose", + ClientSecret: "client-secret", + clientSecret: "client-secret", + RedirectURI: "http://localhost:8082/api/auth/oidc/callback", + } if "public_origin" == mode { s.config.Global.Origin = "https://public.example" s.config.Global.SecureCookie = true @@ -211,7 +244,8 @@ func TestOIDC(t *testing.T) { r.AddCookie(session) w = httptest.NewRecorder() s.ServeHTTP(w, r) - if 200 != w.Code || !strings.Contains(w.Body.String(), `"username":"admin"`) || strings.Contains(w.Body.String(), "provider-user") { + if 200 != w.Code || !strings.Contains(w.Body.String(), `"username":"admin"`) || + strings.Contains(w.Body.String(), "provider-user") { t.Fatal("OIDC did not map to local administrator", w.Body.String()) } r = httptest.NewRequest("GET", path, nil) @@ -238,13 +272,99 @@ func TestOIDCDiscovery(t *testing.T) { } } +func TestOIDCCache(t *testing.T) { + f := newFakeOIDC(t) + s := testServer(t, nil) + s.config.Auth.OIDC = &OIDCConfig{ + Issuer: f.server.URL, + ClientID: "dcompose", + clientSecret: "client-secret", + RedirectURI: "http://localhost:8082/api/auth/oidc/callback", + } + before := time.Now() + startOIDC(t, s, f) + cache := s.oidcCache + if cache.expires.Before(before.Add(time.Minute)) || cache.expires.After(time.Now().Add(time.Minute)) { + t.Fatal("provider cache does not expire after one minute") + } + startOIDC(t, s, f) + if 1 != f.discovery.Load() || !cache.expires.Equal(s.oidcCache.expires) { + t.Fatal("provider and verifier were not reused within cache TTL") + } + s.oidcCache.expires = time.Now().Add(-time.Second) + startOIDC(t, s, f) + if 2 != f.discovery.Load() || !time.Now().Before(s.oidcCache.expires) { + t.Fatal("expired provider cache was not refreshed") + } +} + +func TestOIDCLoginValues(t *testing.T) { + f := newFakeOIDC(t) + s := testServer(t, nil) + s.config.Auth.OIDC = &OIDCConfig{ + Issuer: f.server.URL, + ClientID: "dcompose", + clientSecret: "client-secret", + RedirectURI: "http://localhost:8082/api/auth/oidc/callback", + } + _, first := startOIDC(t, s, f) + firstNonce, firstChallenge := f.nonce, f.challenge + _, second := startOIDC(t, s, f) + if first == second || firstNonce == f.nonce || firstChallenge == f.challenge || + s.oidcPending[first].verifier == s.oidcPending[second].verifier { + t.Fatal("cached discovery reused login state, nonce or PKCE verifier") + } + for _, state := range []string{first, second} { + login := s.oidcPending[state] + if state == login.nonce || state == login.verifier || login.nonce == login.verifier { + t.Fatal("login state, nonce and PKCE verifier are not independent") + } + } + if 1 != f.discovery.Load() { + t.Fatal("login values test did not use cached discovery") + } +} + +func TestOIDCCacheContext(t *testing.T) { + f := newFakeOIDC(t) + s := testServer(t, nil) + s.config.Auth.OIDC = &OIDCConfig{ + Issuer: f.server.URL, + ClientID: "dcompose", + clientSecret: "client-secret", + RedirectURI: "http://localhost:8082/api/auth/oidc/callback", + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := httptest.NewRequest("GET", "/api/auth/oidc/login", nil).WithContext(ctx) + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if 302 != w.Code { + t.Fatal(w.Code, w.Body.String()) + } + cancel() + cookie, state := startOIDC(t, s, f) + r = httptest.NewRequest("GET", "/api/auth/oidc/callback?state="+url.QueryEscape(state)+"&code=test-code", nil) + r.AddCookie(cookie) + w = httptest.NewRecorder() + s.ServeHTTP(w, r) + if 302 != w.Code || 1 != f.discovery.Load() { + t.Fatal("cached provider could not verify after discovery request cancellation", w.Code, w.Body.String()) + } +} + func TestOIDCConfig(t *testing.T) { s := persistedServer(t, nil) dir := filepath.Dir(s.config.path) if err := configutil.Write(filepath.Join(dir, "oidc.secret"), []byte("client-secret\n")); nil != err { t.Fatal(err) } - s.config.Auth.OIDC = &OIDCConfig{Issuer: "https://id.example", ClientID: "dcompose", ClientSecret: "file:oidc.secret", RedirectURI: "https://app.example/api/auth/oidc/callback"} + s.config.Auth.OIDC = &OIDCConfig{ + Issuer: "https://id.example", + ClientID: "dcompose", + ClientSecret: "file:oidc.secret", + RedirectURI: "https://app.example/api/auth/oidc/callback", + } if err := saveConfig(s.config); nil != err { t.Fatal(err) } @@ -278,7 +398,8 @@ func TestOIDCConfig(t *testing.T) { t.Fatal("OIDC blocked CLI password recovery", err) } c, err = LoadConfig(c.path) - if nil != err || "file:oidc.secret" != c.Auth.OIDC.ClientSecret || nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("recovery-password")) { + if nil != err || "file:oidc.secret" != c.Auth.OIDC.ClientSecret || + nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("recovery-password")) { t.Fatal("password recovery lost OIDC configuration", err) } c.Auth.Username, c.Auth.Password, c.Auth.passwordHash = "", "", "" @@ -289,7 +410,10 @@ func TestOIDCConfig(t *testing.T) { } for name, value := range map[string]string{"issuer": "not-a-url", "client_id": "", "client_secret": "", "redirect_uri": "invalid"} { t.Run(name, func(t *testing.T) { - text := fmt.Sprintf("[auth.oidc]\nissuer = %q\nclient_id = 'app'\nclient_secret = 'secret'\nredirect_uri = 'https://app.example/api/auth/oidc/callback'\n", "https://id.example") + text := fmt.Sprintf( + "[auth.oidc]\nissuer = %q\nclient_id = 'app'\nclient_secret = 'secret'\nredirect_uri = 'https://app.example/api/auth/oidc/callback'\n", + "https://id.example", + ) switch name { case "issuer": text = strings.Replace(text, `"https://id.example"`, fmt.Sprintf("%q", value), 1) diff --git a/internal/server/pairing.go b/internal/server/pairing.go index ee1f05e..8488c0e 100644 --- a/internal/server/pairing.go +++ b/internal/server/pairing.go @@ -27,7 +27,7 @@ func resetNodePairing(ctx context.Context, node NodeConfig, client *http.Client) if nil != err { return err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if http.StatusOK != response.StatusCode { return readNodeHTTPError(response, "reset pairing") } @@ -115,7 +115,10 @@ func (s *Server) pairNode(w http.ResponseWriter, r *http.Request) { // Without a supplied pin, pairing obtains it for subsequent connections. InsecureSkipVerify: true, }} - client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + client = &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } } client.Timeout = 15 * time.Second defer client.CloseIdleConnections() @@ -125,16 +128,30 @@ func (s *Server) pairNode(w http.ResponseWriter, r *http.Request) { detail := safeTransportError(err) slog.Error(fmt.Sprintf("Could not pair with %s: %s", label, detail), "node", label, "operation", "pair", "error", detail) if errors.Is(err, errFingerprint) { - apiError(w, http.StatusBadGateway, "Node TLS fingerprint does not match. Copy the fingerprint from the node console and try again.") + apiError( + w, + http.StatusBadGateway, + "Node TLS fingerprint does not match. Copy the fingerprint from the node console and try again.", + ) return } apiError(w, http.StatusBadGateway, "Could not pair with the node. Check its address and network connection.") return } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if http.StatusOK != response.StatusCode { err := readNodeHTTPError(response, "pair") - slog.Error(fmt.Sprintf("Could not pair with %s: %s", label, err.Error()), "node", label, "operation", "pair", "status", response.StatusCode, "error", err.Error()) + slog.Error( + fmt.Sprintf("Could not pair with %s: %s", label, err.Error()), + "node", + label, + "operation", + "pair", + "status", + response.StatusCode, + "error", + err.Error(), + ) message := "Could not pair with the node. Try again." switch response.StatusCode { case http.StatusUnauthorized: @@ -151,7 +168,17 @@ func (s *Server) pairNode(w http.ResponseWriter, r *http.Request) { } var paired protocol.PairingResponse if err := json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&paired); nil != err { - slog.Error(fmt.Sprintf("Could not pair with %s: invalid JSON response (HTTP %d): %v", label, response.StatusCode, err), "node", label, "operation", "pair", "status", response.StatusCode, "error", err) + slog.Error( + fmt.Sprintf("Could not pair with %s: invalid JSON response (HTTP %d): %v", label, response.StatusCode, err), + "node", + label, + "operation", + "pair", + "status", + response.StatusCode, + "error", + err, + ) apiError(w, http.StatusBadGateway, "Could not pair with the node. Try again.") return } diff --git a/internal/server/pairing_test.go b/internal/server/pairing_test.go index 321c706..a69c434 100644 --- a/internal/server/pairing_test.go +++ b/internal/server/pairing_test.go @@ -23,17 +23,31 @@ func TestPairNode(t *testing.T) { var input struct { Token string `json:"token"` } - if err := json.NewDecoder(r.Body).Decode(&input); nil != err || "temporary-token" != input.Token || "/pair" != r.URL.Path || http.MethodPost != r.Method || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { + if err := json.NewDecoder(r.Body). + Decode(&input); nil != err || "temporary-token" != input.Token || "/pair" != r.URL.Path || http.MethodPost != r.Method || + strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { t.Error("incorrect pairing request", err) } - jsonResponse(w, 200, protocol.PairingResponse{ID: "node-id", Name: "node-hostname", Token: "permanent-key", Fingerprint: testFingerprint(up)}) + jsonResponse( + w, + 200, + protocol.PairingResponse{ID: "node-id", Name: "node-hostname", Token: "permanent-key", Fingerprint: testFingerprint(up)}, + ) })) defer up.Close() s := persistedServer(t, nil) frames, _ := s.subscribeWorkspace() - w := perform(t, s, http.MethodPost, "/api/settings/nodes/pair", fmt.Sprintf(`{"name":"","url":%q,"pairing_token":"temporary-token"}`, strings.TrimPrefix(up.URL, "https://"))) + w := perform( + t, + s, + http.MethodPost, + "/api/settings/nodes/pair", + fmt.Sprintf(`{"name":"","url":%q,"pairing_token":"temporary-token"}`, strings.TrimPrefix(up.URL, "https://")), + ) var paired safeNode - if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &paired) || "node-hostname" != paired.Name || "permanent-key" != paired.Token || strings.Contains(w.Body.String(), "temporary-token") { + if 200 != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &paired) || "node-hostname" != paired.Name || + "permanent-key" != paired.Token || + strings.Contains(w.Body.String(), "temporary-token") { t.Fatal(w.Code, w.Body.String()) } c, err := LoadConfig(s.config.path) @@ -41,7 +55,8 @@ func TestPairNode(t *testing.T) { t.Fatal(err) } node := c.Nodes["node-id"] - if up.URL != node.URL || "node-hostname" != node.Name || "permanent-key" != node.token || testFingerprint(up) != node.Fingerprint || !strings.HasPrefix(node.Token, "file:secrets/") { + if up.URL != node.URL || "node-hostname" != node.Name || "permanent-key" != node.token || testFingerprint(up) != node.Fingerprint || + !strings.HasPrefix(node.Token, "file:secrets/") { t.Fatal("paired connection was not saved") } select { @@ -64,7 +79,11 @@ func TestPairNodePin(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&input); nil != err || "temporary-token" != input.Token { t.Error("incorrect pairing token", err) } - jsonResponse(w, http.StatusOK, protocol.PairingResponse{ID: "node-id", Name: "Home", Token: "permanent-key", Fingerprint: "response-pin"}) + jsonResponse( + w, + http.StatusOK, + protocol.PairingResponse{ID: "node-id", Name: "Home", Token: "permanent-key", Fingerprint: "response-pin"}, + ) })) defer up.Close() for _, test := range []struct { @@ -129,7 +148,11 @@ func TestPairNodeFailure(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), up.URL) || strings.Contains(w.Body.String(), "private sentinel") || 0 != len(s.config.Nodes) { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), up.URL) || strings.Contains(w.Body.String(), "private sentinel") || + 0 != len(s.config.Nodes) { t.Fatal(w.Code, w.Body.String()) } var record struct { @@ -143,7 +166,9 @@ func TestPairNodeFailure(t *testing.T) { if err := json.Unmarshal(output.Bytes(), &record); nil != err { t.Fatal(err, output.String()) } - if !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || "" == record.Operation || upstreamStatus != record.Status || !strings.Contains(record.Error, "private sentinel: /srv/agent.toml token=secret") { + if !strings.Contains(record.Node, "Home") || !strings.Contains(record.Node, up.URL) || "" == record.Operation || + upstreamStatus != record.Status || + !strings.Contains(record.Error, "private sentinel: /srv/agent.toml token=secret") { t.Fatal("missing private pairing failure details", output.String()) } if "ERROR" != record.Level || !strings.Contains(record.Message, record.Node) || !strings.Contains(record.Message, record.Error) { @@ -170,7 +195,11 @@ func TestPairNodeFailure(t *testing.T) { Operation string `json:"operation"` Error string `json:"error"` } - if err := json.Unmarshal(output.Bytes(), &record); nil != err || "" == record.Operation || "" == record.Error || !strings.Contains(record.Message, "Home") || !strings.Contains(record.Message, up.URL) || !strings.Contains(record.Message, record.Error) || strings.Contains(w.Body.String(), record.Error) || strings.Contains(output.String(), "wrong") { + if err := json.Unmarshal( + output.Bytes(), + &record, + ); nil != err || "" == record.Operation || "" == record.Error || !strings.Contains(record.Message, "Home") || !strings.Contains(record.Message, up.URL) || !strings.Contains(record.Message, record.Error) || strings.Contains(w.Body.String(), record.Error) || + strings.Contains(output.String(), "wrong") { t.Fatal("pairing transport message hides context or leaks token", output.String(), err) } } @@ -182,7 +211,13 @@ func TestPairResponseLog(t *testing.T) { })) defer up.Close() s := persistedServer(t, nil) - w := perform(t, s, http.MethodPost, "/api/settings/nodes/pair", fmt.Sprintf(`{"name":"Home","url":%q,"pairing_token":"temporary-token"}`, up.URL)) + w := perform( + t, + s, + http.MethodPost, + "/api/settings/nodes/pair", + fmt.Sprintf(`{"name":"Home","url":%q,"pairing_token":"temporary-token"}`, up.URL), + ) if http.StatusBadGateway != w.Code { t.Fatal("invalid pairing response changed public error", w.Code, w.Body.String()) } @@ -194,7 +229,11 @@ func TestPairResponseLog(t *testing.T) { Error string `json:"error"` Status int `json:"status"` } - if err := json.Unmarshal(line, &record); nil != err || "ERROR" != record.Level || http.StatusOK != record.Status || "" == record.Error { + if err := json.Unmarshal( + line, + &record, + ); nil != err || "ERROR" != record.Level || http.StatusOK != record.Status || + "" == record.Error { t.Fatal("missing invalid pairing response diagnostic", string(line), err) } for _, detail := range []string{"Home", up.URL, record.Error, strconv.Itoa(http.StatusOK)} { @@ -227,7 +266,11 @@ func TestPairNodeRejected(t *testing.T) { if "/pair" != r.URL.Path || "" != r.Header.Get("Authorization") { t.Error("pairing exposed existing node credentials") } - jsonResponse(w, http.StatusOK, protocol.PairingResponse{ID: test.id, Name: "new-node", Token: test.token, Fingerprint: testFingerprint(up)}) + jsonResponse( + w, + http.StatusOK, + protocol.PairingResponse{ID: test.id, Name: "new-node", Token: test.token, Fingerprint: testFingerprint(up)}, + ) })) defer up.Close() s := persistedServer(t, victim) @@ -236,8 +279,15 @@ func TestPairNodeRejected(t *testing.T) { if nil != err { t.Fatal(err) } - w := perform(t, s, http.MethodPost, "/api/settings/nodes/pair", fmt.Sprintf(`{"url":%q,"pairing_token":"temporary-token"}`, up.URL)) - if test.status != w.Code || strings.Contains(w.Body.String(), existing.token) || strings.Contains(w.Body.String(), "new-node-token") { + w := perform( + t, + s, + http.MethodPost, + "/api/settings/nodes/pair", + fmt.Sprintf(`{"url":%q,"pairing_token":"temporary-token"}`, up.URL), + ) + if test.status != w.Code || strings.Contains(w.Body.String(), existing.token) || + strings.Contains(w.Body.String(), "new-node-token") { t.Fatalf("pairing response: %d %s", w.Code, w.Body.String()) } if 1 != len(s.config.Nodes) || existing != s.config.Nodes["home"] { diff --git a/internal/server/pause_test.go b/internal/server/pause_test.go index b959798..9282744 100644 --- a/internal/server/pause_test.go +++ b/internal/server/pause_test.go @@ -21,7 +21,14 @@ func savePause(t *testing.T, s *Server, paused *bool) { t.Helper() c, _ := s.snapshot() node := c.Nodes["home"] - body := nodeConnectionRequest{Name: node.Name, URL: node.URL, Token: node.token, Fingerprint: node.Fingerprint, PublicHost: node.PublicHost, Paused: paused} + body := nodeConnectionRequest{ + Name: node.Name, + URL: node.URL, + Token: node.token, + Fingerprint: node.Fingerprint, + PublicHost: node.PublicHost, + Paused: paused, + } data, err := json.Marshal(body) if nil != err { t.Fatal(err) @@ -119,7 +126,11 @@ func TestPausedRequests(t *testing.T) { if _, err := (registryTransport{server: s}).RoundTrip(request); !errors.Is(err, errNodePaused) { t.Fatal("unguarded registry request", err) } - if _, failure := s.attachNode(ctx, protocol.ChannelMessage{Type: "relay.attach", Node: "home", Resource: "update"}); nil == failure || 409 != failure.Status { + if _, failure := s.attachNode( + ctx, + protocol.ChannelMessage{Type: "relay.attach", Node: "home", Resource: "update"}, + ); nil == failure || + 409 != failure.Status { t.Fatal("unguarded stream attachment", failure) } s.discoverRelays(ctx, "home") @@ -140,7 +151,10 @@ func TestPauseChannel(t *testing.T) { nextConnection(t, f) awaitCache(t, s, `"stacks"`, "") watcher, _ := s.subscribeWorkspace() - subscription, failure := s.attachNode(context.Background(), protocol.ChannelMessage{Type: "logs.attach", Node: "home", Resource: "demo"}) + subscription, failure := s.attachNode( + context.Background(), + protocol.ChannelMessage{Type: "logs.attach", Node: "home", Resource: "demo"}, + ) if nil != failure { t.Fatal(failure) } @@ -155,7 +169,8 @@ func TestPauseChannel(t *testing.T) { t.Fatal("pause did not cancel active node work") } s.hubMu.Lock() - if nil != s.channels["home"] || nil != channel.conn || 0 != len(channel.streams) || 0 != len(channel.shells) || nil != s.runtime["home"].snapshot { + if nil != s.channels["home"] || nil != channel.conn || 0 != len(channel.streams) || 0 != len(channel.shells) || + nil != s.runtime["home"].snapshot { t.Fatal("pause retained channel or cached runtime") } for key := range s.images.stacks { @@ -175,7 +190,12 @@ func TestPauseChannel(t *testing.T) { if message := <-shell.subscriber; "shell.error" != message.Type || 409 != message.Status { t.Fatal("active shell did not receive paused status", message) } - if failure := s.browserShellCommand(context.Background(), shell, protocol.ChannelMessage{Type: "shell.input", Stream: "terminal", Data: "ls"}); nil == failure || 409 != failure.Status { + if failure := s.browserShellCommand( + context.Background(), + shell, + protocol.ChannelMessage{Type: "shell.input", Stream: "terminal", Data: "ls"}, + ); nil == failure || + 409 != failure.Status { t.Fatal("paused shell accepted input", failure) } newWatcher, initial := s.subscribeWorkspace() diff --git a/internal/server/proxy.go b/internal/server/proxy.go index 942d4b1..bf40138 100644 --- a/internal/server/proxy.go +++ b/internal/server/proxy.go @@ -102,7 +102,7 @@ func nodeRequest(ctx context.Context, node NodeConfig, client *http.Client, meth return nil, err } if http.StatusUpgradeRequired == response.StatusCode { - response.Body.Close() + _ = response.Body.Close() return nil, incompatibleAgent{} } return response, nil @@ -127,7 +127,7 @@ func checkNode(ctx context.Context, node NodeConfig, client *http.Client) (nodeH if nil != err { return health, err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if 200 != response.StatusCode { return health, readNodeHTTPError(response, "node health check") } @@ -135,7 +135,7 @@ func checkNode(ctx context.Context, node NodeConfig, client *http.Client) (nodeH if err := decoder.Decode(&health); nil != err { return health, errNodeHealth } - if err := decoder.Decode(new(interface{})); io.EOF != err { + if err := decoder.Decode(new(any)); io.EOF != err { return health, errNodeHealth } if protocol.AgentAPI != health.APIVersion { @@ -196,8 +196,22 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { proxyError(w, err) return } - slog.Error(fmt.Sprintf("Could not %s on %s: %v", operation, label, err), "node", label, "operation", operation, "status", http.StatusServiceUnavailable, "error", err) - apiError(w, http.StatusServiceUnavailable, "Registry credentials are not synchronized with this node. Check registry settings or reconnect the node.") + slog.Error( + fmt.Sprintf("Could not %s on %s: %v", operation, label, err), + "node", + label, + "operation", + operation, + "status", + http.StatusServiceUnavailable, + "error", + err, + ) + apiError( + w, + http.StatusServiceUnavailable, + "Registry credentials are not synchronized with this node. Check registry settings or reconnect the node.", + ) return } } @@ -213,7 +227,7 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { proxyError(w, err) return } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() var payload map[string]json.RawMessage decoder := json.NewDecoder(io.LimitReader(response.Body, 16<<20)) if err := decoder.Decode(&payload); nil != err || nil == payload { @@ -221,16 +235,34 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { if nil != err { detail = safeTransportError(err) } - slog.Error(fmt.Sprintf("Could not %s on %s: invalid JSON response (HTTP %d): %s", operation, label, response.StatusCode, detail), "node", label, "operation", operation, "status", response.StatusCode, "error", detail) + slog.Error( + fmt.Sprintf("Could not %s on %s: invalid JSON response (HTTP %d): %s", operation, label, response.StatusCode, detail), + "node", + label, + "operation", + operation, + "status", + response.StatusCode, + "error", + detail, + ) apiError(w, 502, "Could not load data from this node. Try again.") return } - if err := decoder.Decode(new(interface{})); io.EOF != err { + if err := decoder.Decode(new(any)); io.EOF != err { detail := "expected only one JSON object" if nil != err { detail = safeTransportError(err) } - slog.Error(fmt.Sprintf("Could not %s on %s: extra or invalid response data (HTTP %d): %s", operation, label, response.StatusCode, detail), "node", label, "operation", operation, "status", response.StatusCode) + slog.Error( + fmt.Sprintf("Could not %s on %s: extra or invalid response data (HTTP %d): %s", operation, label, response.StatusCode, detail), + "node", + label, + "operation", + operation, + "status", + response.StatusCode, + ) apiError(w, 502, "Could not load data from this node. Try again.") return } @@ -240,7 +272,17 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { if "" == detail { detail = http.StatusText(response.StatusCode) } - slog.Error(fmt.Sprintf("Could not %s on %s (HTTP %d): %s", operation, label, response.StatusCode, detail), "node", label, "operation", operation, "status", response.StatusCode, "error", detail) + slog.Error( + fmt.Sprintf("Could not %s on %s (HTTP %d): %s", operation, label, response.StatusCode, detail), + "node", + label, + "operation", + operation, + "status", + response.StatusCode, + "error", + detail, + ) } if 200 > response.StatusCode || 300 <= response.StatusCode { message := safeMessage @@ -281,7 +323,13 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { _ = json.Unmarshal(payload["status"], &status) _ = json.Unmarshal(payload["relay"], &relay) if "accepted" != status || ("" != relay && !identity.MatchString(relay)) { - slog.Error(fmt.Sprintf("Could not %s on %s: invalid action acceptance response", operation, label), "node", label, "operation", operation) + slog.Error( + fmt.Sprintf("Could not %s on %s: invalid action acceptance response", operation, label), + "node", + label, + "operation", + operation, + ) apiError(w, http.StatusBadGateway, "Could not run the stack action. Check the node logs.") return } @@ -422,7 +470,7 @@ func allowedRoute(method string, p []string) (statusOnly bool, safeMessage strin } func proxyBody(w http.ResponseWriter, r *http.Request, p []string) ([]byte, bool) { - var payload interface{} + var payload any switch { case 2 == len(p) && "images" == p[0] && "check" == p[1]: input := &struct{}{} @@ -449,7 +497,11 @@ func proxyBody(w http.ResponseWriter, r *http.Request, p []string) ([]byte, bool Container *string `json:"container,omitempty"` }{} if 0 != len(input.Container) { - if err := json.Unmarshal(input.Container, &request.Container); nil != err || nil == request.Container || "" == strings.TrimSpace(*request.Container) { + if err := json.Unmarshal( + input.Container, + &request.Container, + ); nil != err || nil == request.Container || + "" == strings.TrimSpace(*request.Container) { apiError(w, 400, "Specify a nonempty container name.") return nil, false } @@ -496,7 +548,8 @@ func proxyBody(w http.ResponseWriter, r *http.Request, p []string) ([]byte, bool apiError(w, 400, "Invalid stack action.") return nil, false } - if "" != input.Service && (!serviceName.MatchString(input.Service) || ("start" != input.Action && "stop" != input.Action && "restart" != input.Action && "update" != input.Action)) { + if "" != input.Service && + (!serviceName.MatchString(input.Service) || ("start" != input.Action && "stop" != input.Action && "restart" != input.Action && "update" != input.Action)) { apiError(w, 400, "Use a valid service name with start, stop, restart or update only.") return nil, false } @@ -511,7 +564,9 @@ func proxyBody(w http.ResponseWriter, r *http.Request, p []string) ([]byte, bool if !decodeBody(w, r, &input, 16<<10) { return nil, false } - if "" == strings.TrimSpace(input.Container) || ("sh" != input.Shell && "bash" != input.Shell && "ash" != input.Shell && "dash" != input.Shell) || !validSize(input.Cols, input.Rows) { + if "" == strings.TrimSpace(input.Container) || + ("sh" != input.Shell && "bash" != input.Shell && "ash" != input.Shell && "dash" != input.Shell) || + !validSize(input.Cols, input.Rows) { apiError(w, 400, "Specify a container, an allowed shell (sh, bash, ash or dash), and 1-500 columns and rows.") return nil, false } @@ -544,8 +599,7 @@ func proxyError(w http.ResponseWriter, err error) { apiError(w, http.StatusConflict, errNodePaused.Error()) return } - var incompatible incompatibleAgent - if errors.As(err, &incompatible) { + if incompatible, ok := errors.AsType[incompatibleAgent](err); ok { apiError(w, http.StatusUpgradeRequired, incompatible.Error()) return } diff --git a/internal/server/registry.go b/internal/server/registry.go index f9e3fce..2faa3a3 100644 --- a/internal/server/registry.go +++ b/internal/server/registry.go @@ -18,12 +18,12 @@ import ( ) type RegistryConfig struct { - ID string `toml:"id" json:"id"` - Registry string `toml:"registry" json:"registry"` - Label string `toml:"label" json:"label"` - Auth map[string]string `toml:"auth,omitempty" json:"-"` + ID string `toml:"id" json:"id"` + Registry string `toml:"registry" json:"registry"` + Label string `toml:"label" json:"label"` + Auth map[string]string `toml:"auth,omitempty" json:"-"` AuthFile string `toml:"auth_file,omitempty" json:"-"` - BackgroundChecks bool `toml:"background_checks" json:"background_checks"` + BackgroundChecks bool `toml:"background_checks" json:"background_checks"` auth map[string]string } @@ -49,7 +49,7 @@ func registrySecretField(field string) bool { return "username" != field && "email" != field && "serveraddress" != field } -func publicRegistries(registries []RegistryConfig) interface{} { +func publicRegistries(registries []RegistryConfig) any { rows := make([]safeRegistry, 0, len(registries)) for _, registry := range registries { auth := maps.Clone(registry.auth) @@ -65,7 +65,18 @@ func publicRegistries(registries []RegistryConfig) interface{} { username = decoded } } - rows = append(rows, safeRegistry{ID: registry.ID, Registry: registry.Registry, Label: registry.Label, Auth: auth, BackgroundChecks: registry.BackgroundChecks, HasAuth: 0 < len(registry.auth), Username: username}) + rows = append( + rows, + safeRegistry{ + ID: registry.ID, + Registry: registry.Registry, + Label: registry.Label, + Auth: auth, + BackgroundChecks: registry.BackgroundChecks, + HasAuth: 0 < len(registry.auth), + Username: username, + }, + ) } return struct { Registries []safeRegistry `json:"registries"` @@ -101,7 +112,8 @@ func validateRegistries(rows []RegistryConfig, path string) ([]RegistryConfig, e } } } - if "" == registry.auth["auth"] && "" == registry.auth["password"] && "" == registry.auth["identitytoken"] && "" == registry.auth["registrytoken"] { + if "" == registry.auth["auth"] && "" == registry.auth["password"] && "" == registry.auth["identitytoken"] && + "" == registry.auth["registrytoken"] { return nil, fmt.Errorf("registry authentication is required") } if "" != registry.AuthFile { @@ -115,7 +127,16 @@ func validateRegistries(rows []RegistryConfig, path string) ([]RegistryConfig, e func registrySnapshot(registries []RegistryConfig) protocol.RegistrySync { rows := make([]protocol.RegistryCredential, 0, len(registries)) for _, registry := range registries { - rows = append(rows, protocol.RegistryCredential{ID: registry.ID, Registry: registry.Registry, Label: registry.Label, Auth: maps.Clone(registry.auth), BackgroundChecks: registry.BackgroundChecks}) + rows = append( + rows, + protocol.RegistryCredential{ + ID: registry.ID, + Registry: registry.Registry, + Label: registry.Label, + Auth: maps.Clone(registry.auth), + BackgroundChecks: registry.BackgroundChecks, + }, + ) } data, _ := json.Marshal(rows) revision := sha256.Sum256(data) @@ -151,7 +172,13 @@ func (s *Server) saveRegistries(w http.ResponseWriter, r *http.Request) { row.ID = hex.EncodeToString(id[:]) } old := existing[row.ID] - registry := RegistryConfig{ID: row.ID, Registry: row.Registry, Label: row.Label, auth: maps.Clone(row.Auth), BackgroundChecks: row.BackgroundChecks} + registry := RegistryConfig{ + ID: row.ID, + Registry: row.Registry, + Label: row.Label, + auth: maps.Clone(row.Auth), + BackgroundChecks: row.BackgroundChecks, + } for field, value := range registry.auth { if "" == value && registrySecretField(field) { registry.auth[field] = old.auth[field] diff --git a/internal/server/registry_channel_test.go b/internal/server/registry_channel_test.go index 1560593..9eeec0c 100644 --- a/internal/server/registry_channel_test.go +++ b/internal/server/registry_channel_test.go @@ -18,7 +18,13 @@ import ( // HTTP-only proxy tests model a completed handshake without opening a socket. func assumeRegistriesApplied(s *Server) { - s.channels["home"] = &nodeChannel{ctx: context.Background(), imageContext: context.Background(), conn: new(websocket.Conn), registryRevision: s.registrySync.Revision, registryApplied: s.registrySync.Revision} + s.channels["home"] = &nodeChannel{ + ctx: context.Background(), + imageContext: context.Background(), + conn: new(websocket.Conn), + registryRevision: s.registrySync.Revision, + registryApplied: s.registrySync.Revision, + } s.runtime["home"] = &nodeRuntime{state: map[string]json.RawMessage{"state": json.RawMessage(`"connected"`)}} } @@ -29,7 +35,9 @@ func ackRegistrySync(t *testing.T, ctx context.Context, conn *websocket.Conn) bo return false } var snapshot protocol.RegistrySync - if "registry.sync" != message.Type || "" != message.Node || nil != json.Unmarshal(message.Payload, &snapshot) || "" == snapshot.Revision || nil == snapshot.Registries { + if "registry.sync" != message.Type || "" != message.Node || nil != json.Unmarshal(message.Payload, &snapshot) || + "" == snapshot.Revision || + nil == snapshot.Registries { t.Error("missing initial registry snapshot") return false } @@ -58,7 +66,7 @@ func newRegistryChannel(t *testing.T) *registryChannelFixture { } if "/channel" != r.URL.Path { if http.MethodGet == r.Method && "/relays" == r.URL.Path { - jsonResponse(w, http.StatusOK, map[string]interface{}{"relays": []interface{}{}}) + jsonResponse(w, http.StatusOK, map[string]any{"relays": []any{}}) return } f.requests.Add(1) @@ -69,10 +77,18 @@ func newRegistryChannel(t *testing.T) *registryChannelFixture { if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() conn.SetReadLimit(2 << 20) // An eager runtime snapshot must not make the browser ready before apply. - if nil != wsjson.Write(r.Context(), conn, protocol.ChannelMessage{Type: "event", Event: "node.snapshot", Payload: json.RawMessage(`{"health":{"status":"ok"},"stacks":[]}`)}) { + if nil != wsjson.Write( + r.Context(), + conn, + protocol.ChannelMessage{ + Type: "event", + Event: "node.snapshot", + Payload: json.RawMessage(`{"health":{"status":"ok"},"stacks":[]}`), + }, + ) { return } for { @@ -128,7 +144,8 @@ func TestRegistryBarrier(t *testing.T) { delete(s.watchers, frames) s.hubMu.Unlock() for _, frame := range initial { - if strings.Contains(string(frame), `"node.snapshot"`) || strings.Contains(string(frame), `"state":"connected"`) || strings.Contains(string(frame), "registry.sync") { + if strings.Contains(string(frame), `"node.snapshot"`) || strings.Contains(string(frame), `"state":"connected"`) || + strings.Contains(string(frame), "registry.sync") { t.Fatal("browser became ready before registry apply") } } @@ -166,12 +183,24 @@ func TestRegistryResync(t *testing.T) { startChannels(t, s) first := nextRegistry(t, f) applyRegistry(t, first, first.snapshot.Revision) - registryRows(t, perform(t, s, "PUT", "/api/settings/registries", `{"registries":[{"registry":"https://index.docker.io/v1/","label":"Checks","auth":{"username":"alice","password":"sync-secret"},"background_checks":true},{"registry":"https://index.docker.io/v1/refresh-token","auth":{"username":"","password":"sync-refresh:client-id"},"background_checks":false}]}`)) + registryRows( + t, + perform( + t, + s, + "PUT", + "/api/settings/registries", + `{"registries":[{"registry":"https://index.docker.io/v1/","label":"Checks","auth":{"username":"alice","password":"sync-secret"},"background_checks":true},{"registry":"https://index.docker.io/v1/refresh-token","auth":{"username":"","password":"sync-refresh:client-id"},"background_checks":false}]}`, + ), + ) changed := nextRegistry(t, f) - if first.snapshot.Revision == changed.snapshot.Revision || 2 != len(changed.snapshot.Registries) || "sync-secret" != changed.snapshot.Registries[0].Auth["password"] || "Checks" != changed.snapshot.Registries[0].Label { + if first.snapshot.Revision == changed.snapshot.Revision || 2 != len(changed.snapshot.Registries) || + "sync-secret" != changed.snapshot.Registries[0].Auth["password"] || + "Checks" != changed.snapshot.Registries[0].Label { t.Fatal("edit did not publish full effective credentials") } - if "https://index.docker.io/v1/refresh-token" != changed.snapshot.Registries[1].Registry || "sync-refresh:client-id" != changed.snapshot.Registries[1].Auth["password"] { + if "https://index.docker.io/v1/refresh-token" != changed.snapshot.Registries[1].Registry || + "sync-refresh:client-id" != changed.snapshot.Registries[1].Auth["password"] { t.Fatal("registry sync omitted an independent OAuth auth record") } loaded, err := LoadConfig(s.config.path) @@ -205,7 +234,9 @@ func TestRegistryResync(t *testing.T) { frames := s.workspaceFramesLocked() s.hubMu.Unlock() for _, frame := range frames { - if strings.Contains(string(frame), "sync-secret") || strings.Contains(string(frame), "sync-refresh") || strings.Contains(string(frame), "registry.sync") || strings.Contains(string(frame), "registry.applied") { + if strings.Contains(string(frame), "sync-secret") || strings.Contains(string(frame), "sync-refresh") || + strings.Contains(string(frame), "registry.sync") || + strings.Contains(string(frame), "registry.applied") { t.Fatal("workspace leaked registry channel data") } } @@ -299,7 +330,11 @@ func TestRegistryTimeout(t *testing.T) { func TestRegistryBrowserBoundary(t *testing.T) { f := newWorkspace(t) browser := browserStream(t, f.app.URL+"/api/events", f.server) - browserSend(t, browser, protocol.ChannelMessage{Type: "registry.sync", Node: "home", Payload: json.RawMessage(`{"revision":"forged","registries":[]}`)}) + browserSend( + t, + browser, + protocol.ChannelMessage{Type: "registry.sync", Node: "home", Payload: json.RawMessage(`{"revision":"forged","registries":[]}`)}, + ) message := resourceMessage(t, browser) if "error" != message.Type || 400 != message.Status { t.Fatal("browser registry command was not rejected") diff --git a/internal/server/registry_inspect_test.go b/internal/server/registry_inspect_test.go index d6e3183..9a57cd8 100644 --- a/internal/server/registry_inspect_test.go +++ b/internal/server/registry_inspect_test.go @@ -29,10 +29,17 @@ func metadataResponse(request *http.Request, status int, body string) *http.Resp } func nativeMetadata(request *http.Request) *http.Response { - amd := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"digest":"sha256:` + strings.Repeat("a", 64) + `"},"layers":[]}` + amd := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"digest":"sha256:` + strings.Repeat( + "a", + 64, + ) + `"},"layers":[]}` arm := strings.ReplaceAll(amd, strings.Repeat("a", 64), strings.Repeat("b", 64)) amdDigest, armDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(amd))), fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(arm))) - root := fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":%q,"platform":{"os":"linux","architecture":"amd64"}},{"digest":%q,"platform":{"os":"linux","architecture":"arm64"}}]}`, amdDigest, armDigest) + root := fmt.Sprintf( + `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":%q,"platform":{"os":"linux","architecture":"amd64"}},{"digest":%q,"platform":{"os":"linux","architecture":"arm64"}}]}`, + amdDigest, + armDigest, + ) body := root if strings.HasSuffix(request.URL.Path, "/"+amdDigest) { body = amd @@ -61,9 +68,16 @@ func imageService(name string) engine.Service { func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { t.Helper() - f := &imageNodeFixture{channel: fakeChannel(t), services: map[string][]engine.Service{"demo": services}, local: make(map[string]string), metadata: nativeMetadata} - stacks := []map[string]interface{}{{"name": "demo", "status": "running", "loading": false, "services": services}} - f.channel.snapshot, _ = json.Marshal(map[string]interface{}{"health": map[string]string{"status": "ok", "image_check_interval": "6h"}, "stacks": stacks}) + f := &imageNodeFixture{ + channel: fakeChannel(t), + services: map[string][]engine.Service{"demo": services}, + local: make(map[string]string), + metadata: nativeMetadata, + } + stacks := []map[string]any{{"name": "demo", "status": "running", "loading": false, "services": services}} + f.channel.snapshot, _ = json.Marshal( + map[string]any{"health": map[string]string{"status": "ok", "image_check_interval": "6h"}, "stacks": stacks}, + ) f.channel.http = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case "/images/inspect" == r.URL.Path: @@ -83,7 +97,20 @@ func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { if "" != input.Container && input.Container != service.Name { continue } - images = append(images, protocol.ImageInspection{ID: service.ID, Service: service, Target: engine.ImageTarget{LocalID: f.local[service.Name], Key: engine.ImageKey{Ref: service.Image, ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}}}) + images = append( + images, + protocol.ImageInspection{ + ID: service.ID, + Service: service, + Target: engine.ImageTarget{ + LocalID: f.local[service.Name], + Key: engine.ImageKey{ + Ref: service.Image, + ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}, + }, + }, + }, + ) } _ = json.NewEncoder(w).Encode(protocol.ImageInspectResponse{Images: images}) case "/registry/request" == r.URL.Path: @@ -100,7 +127,7 @@ func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { metadata := f.metadata f.mu.Unlock() response := metadata(request) - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, _ := io.ReadAll(response.Body) _ = json.NewEncoder(w).Encode(protocol.RegistryResponse{Status: response.StatusCode, Header: response.Header, Body: body}) case strings.HasSuffix(r.URL.Path, "/actions"): @@ -129,7 +156,7 @@ func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { service.ID += "-updated" } } - runtime, _ := json.Marshal(map[string]interface{}{"name": "demo", "status": "running", "busy": true, "services": f.services["demo"]}) + runtime, _ := json.Marshal(map[string]any{"name": "demo", "status": "running", "busy": true, "services": f.services["demo"]}) f.mu.Unlock() sendRuntime(t, conn, "stack.runtime", string(runtime)) sendRuntime(t, conn, "stack.action", `{"name":"demo","action":"update","phase":"exit","exit_code":0}`) @@ -137,7 +164,8 @@ func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { case "/stacks/demo/status" == r.URL.Path || "/stacks/demo" == r.URL.Path: f.mu.Lock() defer f.mu.Unlock() - _ = json.NewEncoder(w).Encode(map[string]interface{}{"name": "demo", "status": "running", "services": f.services["demo"], "compose": "services: {}", "env": ""}) + _ = json.NewEncoder(w). + Encode(map[string]any{"name": "demo", "status": "running", "services": f.services["demo"], "compose": "services: {}", "env": ""}) default: t.Error("unexpected image-node route", r.URL.Path) w.WriteHeader(404) @@ -149,7 +177,14 @@ func imageNode(t *testing.T, services ...engine.Service) *imageNodeFixture { func imageServer(t *testing.T, f *imageNodeFixture, background bool) *Server { t.Helper() s := testServer(t, f.channel.server) - s.config.Registries = []RegistryConfig{{ID: "login", Registry: "docker.io", BackgroundChecks: background, auth: map[string]string{"username": "test", "password": "test-secret"}}} + s.config.Registries = []RegistryConfig{ + { + ID: "login", + Registry: "docker.io", + BackgroundChecks: background, + auth: map[string]string{"username": "test", "password": "test-secret"}, + }, + } s.registrySync = registrySnapshot(s.config.Registries) s.registryClient = registry.NewClient(&http.Client{Transport: registryTransport{server: s}}) return s @@ -191,7 +226,12 @@ func imageStatus(s *Server, node, stack, container string) string { func TestRegistrySharedMetadata(t *testing.T) { one, two := imageNode(t), imageNode(t) s := imageServer(t, one, false) - node := NodeConfig{Name: "away", URL: two.channel.server.URL, token: "agent-test-token", Fingerprint: testFingerprint(two.channel.server)} + node := NodeConfig{ + Name: "away", + URL: two.channel.server.URL, + token: "agent-test-token", + Fingerprint: testFingerprint(two.channel.server), + } s.config.Nodes["away"] = node s.nodes["away"] = nodeClient(node) now := time.Now().UTC().Truncate(time.Second) @@ -199,7 +239,7 @@ func TestRegistrySharedMetadata(t *testing.T) { clock.Store(now.UnixNano()) s.registryClient.Now = func() time.Time { return time.Unix(0, clock.Load()).UTC() } config, _ := json.Marshal(registry.DockerConfig{Auths: map[string]map[string]string{}}) - key := imageKey{ImageKey: engine.ImageKey{Ref: "docker.io/library/nginx:latest", ImagePlatform: engine.ImagePlatform{OS: "linux", Architecture: "amd64"}}, purpose: "explicit"} + key := imageKey{Ref: "docker.io/library/nginx:latest", OS: "linux", Architecture: "amd64", purpose: "explicit"} lookup := func(node string, age time.Duration) engine.RegistryImage { return s.registryClient.InspectConfig(registryNodeContext(context.Background(), node), key.ImageKey, config, age) } diff --git a/internal/server/registry_test.go b/internal/server/registry_test.go index 3f4795b..f4979a7 100644 --- a/internal/server/registry_test.go +++ b/internal/server/registry_test.go @@ -41,11 +41,31 @@ func TestRegistryAddress(t *testing.T) { func TestRegistries(t *testing.T) { s := persistedServer(t, nil) - auth := map[string]string{"auth": base64.StdEncoding.EncodeToString([]byte("encoded-user:encoded-password")), "username": "alice", "password": "private-password", "identitytoken": "private-identity", "registrytoken": "private-token", "email": "alice@example.com", "serveraddress": "https://index.docker.io/v1/"} - body, _ := json.Marshal(map[string]interface{}{"registries": []registryInput{{Registry: "https://index.docker.io/v1/", Label: "Personal / checks", Auth: auth, BackgroundChecks: true}, {Registry: "registry-1.docker.io", Auth: map[string]string{"registrytoken": "second-token"}}}}) + auth := map[string]string{ + "auth": base64.StdEncoding.EncodeToString([]byte("encoded-user:encoded-password")), + "username": "alice", + "password": "private-password", + "identitytoken": "private-identity", + "registrytoken": "private-token", + "email": "alice@example.com", + "serveraddress": "https://index.docker.io/v1/", + } + body, _ := json.Marshal( + map[string]any{ + "registries": []registryInput{ + {Registry: "https://index.docker.io/v1/", Label: "Personal / checks", Auth: auth, BackgroundChecks: true}, + {Registry: "registry-1.docker.io", Auth: map[string]string{"registrytoken": "second-token"}}, + }, + }, + ) w := perform(t, s, "PUT", "/api/settings/registries", string(body)) rows := registryRows(t, w) - if 2 != len(rows) || rows[0].ID == rows[1].ID || "" == rows[0].ID || "https://index.docker.io/v1/" != rows[0].Registry || !rows[0].HasAuth || !rows[0].BackgroundChecks || rows[1].BackgroundChecks || "Personal / checks" != rows[0].Label || "alice" != rows[0].Username { + if 2 != len(rows) || rows[0].ID == rows[1].ID || "" == rows[0].ID || "https://index.docker.io/v1/" != rows[0].Registry || + !rows[0].HasAuth || + !rows[0].BackgroundChecks || + rows[1].BackgroundChecks || + "Personal / checks" != rows[0].Label || + "alice" != rows[0].Username { t.Fatal("registry metadata not retained") } for field, value := range rows[0].Auth { @@ -65,7 +85,8 @@ func TestRegistries(t *testing.T) { } } loaded, err := LoadConfig(s.config.path) - if nil != err || 2 != len(loaded.Registries) || !maps.Equal(auth, loaded.Registries[0].auth) || "Personal / checks" != loaded.Registries[0].Label { + if nil != err || 2 != len(loaded.Registries) || !maps.Equal(auth, loaded.Registries[0].auth) || + "Personal / checks" != loaded.Registries[0].Label { t.Fatal("native registry persistence failed", err) } for _, registry := range loaded.Registries { @@ -88,17 +109,28 @@ func TestRegistries(t *testing.T) { } // Masked auth fields round-trip without exposing or changing secrets. input := []registryInput{{ID: rows[0].ID, Registry: rows[0].Registry, Label: "", Auth: rows[0].Auth, BackgroundChecks: false}} - body, _ = json.Marshal(map[string]interface{}{"registries": input}) + body, _ = json.Marshal(map[string]any{"registries": input}) registryRows(t, perform(t, s, "PUT", "/api/settings/registries", string(body))) current, _ := s.snapshot() - if !maps.Equal(auth, current.Registries[0].auth) || loaded.Registries[0].AuthFile != current.Registries[0].AuthFile || "" != current.Registries[0].Label || current.Registries[0].BackgroundChecks { + if !maps.Equal(auth, current.Registries[0].auth) || loaded.Registries[0].AuthFile != current.Registries[0].AuthFile || + "" != current.Registries[0].Label || + current.Registries[0].BackgroundChecks { t.Fatal("blank secret preservation or metadata editing failed") } } func TestRegistryReplace(t *testing.T) { s := persistedServer(t, nil) - rows := registryRows(t, perform(t, s, "PUT", "/api/settings/registries", `{"registries":[{"registry":"ghcr.io","auth":{"username":"alice","password":"original-secret"},"background_checks":true}]}`)) + rows := registryRows( + t, + perform( + t, + s, + "PUT", + "/api/settings/registries", + `{"registries":[{"registry":"ghcr.io","auth":{"username":"alice","password":"original-secret"},"background_checks":true}]}`, + ), + ) id := rows[0].ID old, _ := s.snapshot() for _, body := range []string{ @@ -113,7 +145,10 @@ func TestRegistryReplace(t *testing.T) { t.Fatal("invalid registry replacement accepted") } } - body := fmt.Sprintf(`{"registries":[{"id":%q,"registry":"registry.example:5000","auth":{"username":"renamed","password":""},"background_checks":false}]}`, id) + body := fmt.Sprintf( + `{"registries":[{"id":%q,"registry":"registry.example:5000","auth":{"username":"renamed","password":""},"background_checks":false}]}`, + id, + ) registryRows(t, perform(t, s, "PUT", "/api/settings/registries", body)) current, _ := s.snapshot() if "original-secret" != current.Registries[0].auth["password"] || "alice" != old.Registries[0].auth["username"] { @@ -136,27 +171,50 @@ func TestRegistryReplace(t *testing.T) { func TestRegistryLogin(t *testing.T) { s := persistedServer(t, nil) - rows := registryRows(t, perform(t, s, "PUT", "/api/settings/registries", `{"registries":[{"registry":"docker.io","auth":{"auth":"YWxpY2U6cGFzczp3b3Jk"}}]}`)) + rows := registryRows( + t, + perform( + t, + s, + "PUT", + "/api/settings/registries", + `{"registries":[{"registry":"docker.io","auth":{"auth":"YWxpY2U6cGFzczp3b3Jk"}}]}`, + ), + ) if "alice" != rows[0].Username { t.Fatal("encoded username not available for editing") } body := fmt.Sprintf(`{"registries":[{"id":%q,"registry":"docker.io","auth":{"auth":"","username":"bob","password":""}}]}`, rows[0].ID) registryRows(t, perform(t, s, "PUT", "/api/settings/registries", body)) current, _ := s.snapshot() - if 1 != len(current.Registries[0].auth) || base64.StdEncoding.EncodeToString([]byte("bob:pass:word")) != current.Registries[0].auth["auth"] { + if 1 != len(current.Registries[0].auth) || + base64.StdEncoding.EncodeToString([]byte("bob:pass:word")) != current.Registries[0].auth["auth"] { t.Fatal("username edit did not preserve password or native shape") } - body = fmt.Sprintf(`{"registries":[{"id":%q,"registry":"docker.io","auth":{"auth":"","username":"bob","password":"new:password"}}]}`, rows[0].ID) + body = fmt.Sprintf( + `{"registries":[{"id":%q,"registry":"docker.io","auth":{"auth":"","username":"bob","password":"new:password"}}]}`, + rows[0].ID, + ) registryRows(t, perform(t, s, "PUT", "/api/settings/registries", body)) current, _ = s.snapshot() - if 1 != len(current.Registries[0].auth) || base64.StdEncoding.EncodeToString([]byte("bob:new:password")) != current.Registries[0].auth["auth"] { + if 1 != len(current.Registries[0].auth) || + base64.StdEncoding.EncodeToString([]byte("bob:new:password")) != current.Registries[0].auth["auth"] { t.Fatal("password replacement failed") } } func TestRegistryDuplicates(t *testing.T) { s := persistedServer(t, nil) - rows := registryRows(t, perform(t, s, "PUT", "/api/settings/registries", `{"registries":[{"registry":"docker.io","auth":{"password":"first"}},{"registry":"docker.io","auth":{"password":"second"}}]}`)) + rows := registryRows( + t, + perform( + t, + s, + "PUT", + "/api/settings/registries", + `{"registries":[{"registry":"docker.io","auth":{"password":"first"}},{"registry":"docker.io","auth":{"password":"second"}}]}`, + ), + ) if 2 != len(rows) || rows[0].ID == rows[1].ID { t.Fatal("distinct logins for the same registry were not retained") } @@ -164,14 +222,28 @@ func TestRegistryDuplicates(t *testing.T) { func TestRegistrySaveFailure(t *testing.T) { s := persistedServer(t, nil) - rows := registryRows(t, perform(t, s, "PUT", "/api/settings/registries", `{"registries":[{"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":"original-secret"}}]}`)) + rows := registryRows( + t, + perform( + t, + s, + "PUT", + "/api/settings/registries", + `{"registries":[{"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":"original-secret"}}]}`, + ), + ) before, _ := s.snapshot() beforeRevision := s.registrySync.Revision s.config.path = t.TempDir() - body := fmt.Sprintf(`{"registries":[{"id":%q,"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":"failed-replacement"}}]}`, rows[0].ID) + body := fmt.Sprintf( + `{"registries":[{"id":%q,"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":"failed-replacement"}}]}`, + rows[0].ID, + ) w := perform(t, s, "PUT", "/api/settings/registries", body) after, _ := s.snapshot() - if 500 != w.Code || strings.Contains(w.Body.String(), "failed-replacement") || !reflect.DeepEqual(before.Registries, after.Registries) || beforeRevision != s.registrySync.Revision { + if 500 != w.Code || strings.Contains(w.Body.String(), "failed-replacement") || + !reflect.DeepEqual(before.Registries, after.Registries) || + beforeRevision != s.registrySync.Revision { t.Fatal("failed save replaced active registry credentials") } data, err := os.ReadFile(configutil.Path(before.path, strings.TrimPrefix(before.Registries[0].AuthFile, "file:"))) @@ -185,7 +257,16 @@ func TestRegistrySaveFailure(t *testing.T) { } func TestRegistryRevision(t *testing.T) { - rows := []RegistryConfig{{ID: "a", Registry: "docker.io", Label: "Checks", auth: map[string]string{"password": "secret"}, AuthFile: "file:first", BackgroundChecks: true}} + rows := []RegistryConfig{ + { + ID: "a", + Registry: "docker.io", + Label: "Checks", + auth: map[string]string{"password": "secret"}, + AuthFile: "file:first", + BackgroundChecks: true, + }, + } first := registrySnapshot(rows) rows[0].AuthFile = "file:second" if first.Revision != registrySnapshot(rows).Revision { @@ -197,7 +278,8 @@ func TestRegistryRevision(t *testing.T) { } rows[0].auth["password"] = "secret" rows[0].BackgroundChecks = false - if first.Revision == registrySnapshot(rows).Revision || first.Revision == registrySnapshot(nil).Revision || nil == registrySnapshot(nil).Registries { + if first.Revision == registrySnapshot(rows).Revision || first.Revision == registrySnapshot(nil).Revision || + nil == registrySnapshot(nil).Registries { t.Fatal("effective settings or empty snapshot revision invalid") } } @@ -206,7 +288,9 @@ func TestRegistryAuthKeys(t *testing.T) { s := persistedServer(t, nil) body := `{"registries":[{"registry":"https://index.docker.io/v1/","auth":{"auth":"hub-secret"},"background_checks":true},{"registry":"https://index.docker.io/v1/access-token","auth":{"identitytoken":"access-secret"}},{"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":"refresh-secret:client-id"},"background_checks":true},{"registry":"registry.example/path","auth":{"registrytoken":"registry-bearer"}}]}` rows := registryRows(t, perform(t, s, "PUT", "/api/settings/registries", body)) - if 4 != len(rows) || "https://index.docker.io/v1/" != rows[0].Registry || "https://index.docker.io/v1/access-token" != rows[1].Registry || "https://index.docker.io/v1/refresh-token" != rows[2].Registry { + if 4 != len(rows) || "https://index.docker.io/v1/" != rows[0].Registry || + "https://index.docker.io/v1/access-token" != rows[1].Registry || + "https://index.docker.io/v1/refresh-token" != rows[2].Registry { t.Fatal("original auth keys not preserved") } loaded, err := LoadConfig(s.config.path) @@ -214,10 +298,14 @@ func TestRegistryAuthKeys(t *testing.T) { t.Fatal("OAuth auth record persistence failed", err) } before, _ := s.snapshot() - body = fmt.Sprintf(`{"registries":[{"id":%q,"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":""},"background_checks":false}]}`, rows[2].ID) + body = fmt.Sprintf( + `{"registries":[{"id":%q,"registry":"https://index.docker.io/v1/refresh-token","auth":{"password":""},"background_checks":false}]}`, + rows[2].ID, + ) registryRows(t, perform(t, s, "PUT", "/api/settings/registries", body)) after, _ := s.snapshot() - if 1 != len(after.Registries) || 4 != len(before.Registries) || "refresh-secret:client-id" != after.Registries[0].auth["password"] || after.Registries[0].BackgroundChecks { + if 1 != len(after.Registries) || 4 != len(before.Registries) || "refresh-secret:client-id" != after.Registries[0].auth["password"] || + after.Registries[0].BackgroundChecks { t.Fatal("independent auth row editing or deletion failed") } } diff --git a/internal/server/registry_transport.go b/internal/server/registry_transport.go index fb95da3..d59f554 100644 --- a/internal/server/registry_transport.go +++ b/internal/server/registry_transport.go @@ -25,7 +25,7 @@ type registryTransport struct { func (transport registryTransport) RoundTrip(request *http.Request) (*http.Response, error) { if nil != request.Body { - defer request.Body.Close() + defer func() { _ = request.Body.Close() }() } nodeID, _ := request.Context().Value(registryNodeKey{}).(string) if "" == nodeID { @@ -50,7 +50,7 @@ func (transport registryTransport) RoundTrip(request *http.Request) (*http.Respo if nil != err { return nil, fmt.Errorf("registry request through node %q failed: %w", nodeID, err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if http.StatusOK != response.StatusCode { return nil, readNodeHTTPError(response, fmt.Sprintf("registry request through node %q", nodeID)) } diff --git a/internal/server/registry_transport_test.go b/internal/server/registry_transport_test.go index 0c11fe1..f3aa531 100644 --- a/internal/server/registry_transport_test.go +++ b/internal/server/registry_transport_test.go @@ -23,7 +23,8 @@ func registryBridgeFixture(t *testing.T, handler http.HandlerFunc) *httptest.Ser if testHealth(w, r) { return } - if http.MethodPost != r.Method || "/registry/request" != r.URL.Path || "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { + if http.MethodPost != r.Method || "/registry/request" != r.URL.Path || "Bearer agent-test-token" != r.Header.Get("Authorization") || + strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { t.Errorf("agent transport request: %s %s headers=%v", r.Method, r.URL.Path, r.Header) w.WriteHeader(http.StatusUnauthorized) return @@ -43,25 +44,37 @@ func TestRegistryTransport(t *testing.T) { t.Error(err) return } - if method != input.Method || "https://private.invalid/token" != input.URL || "Bearer registry-token" != input.Header.Get("Authorization") || "refresh_token=secret" != string(input.Body) { + if method != input.Method || "https://private.invalid/token" != input.URL || + "Bearer registry-token" != input.Header.Get("Authorization") || + "refresh_token=secret" != string(input.Body) { t.Errorf("registry wire request: %+v", input) } _ = json.NewEncoder(w).Encode(protocol.RegistryResponse{ Status: http.StatusTooManyRequests, - Header: http.Header{"Retry-After": {"60"}, "Www-Authenticate": {`Bearer realm="https://auth.invalid"`, `Basic realm="private"`}}, - Body: []byte("registry limit"), + Header: http.Header{ + "Retry-After": {"60"}, + "Www-Authenticate": {`Bearer realm="https://auth.invalid"`, `Basic realm="private"`}, + }, + Body: []byte("registry limit"), }) }) s := testServer(t, fixture) - request, _ := http.NewRequestWithContext(registryNodeContext(context.Background(), "home"), method, "https://private.invalid/token", strings.NewReader("refresh_token=secret")) + request, _ := http.NewRequestWithContext( + registryNodeContext(context.Background(), "home"), + method, + "https://private.invalid/token", + strings.NewReader("refresh_token=secret"), + ) request.Header.Set("Authorization", "Bearer registry-token") response, err := (registryTransport{server: s}).RoundTrip(request) if nil != err { t.Fatal(err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, _ := io.ReadAll(response.Body) - if 429 != response.StatusCode || "60" != response.Header.Get("Retry-After") || 2 != len(response.Header.Values("WWW-Authenticate")) || request != response.Request { + if 429 != response.StatusCode || "60" != response.Header.Get("Retry-After") || + 2 != len(response.Header.Values("WWW-Authenticate")) || + request != response.Request { t.Fatalf("registry response: %+v", response) } if http.MethodHead == method { @@ -86,7 +99,12 @@ func TestRegistryTransportNode(t *testing.T) { node := NodeConfig{Name: "Away", URL: away.URL, token: "agent-test-token", Fingerprint: testFingerprint(away)} s.config.Nodes["away"] = node s.nodes["away"] = nodeClient(node) - request, _ := http.NewRequestWithContext(registryNodeContext(context.Background(), "away"), http.MethodGet, "https://private.invalid/v2/", nil) + request, _ := http.NewRequestWithContext( + registryNodeContext(context.Background(), "away"), + http.MethodGet, + "https://private.invalid/v2/", + nil, + ) response, err := (registryTransport{server: s}).RoundTrip(request) if nil != err { t.Fatal(err) @@ -182,15 +200,26 @@ func TestRegistryTransportBounds(t *testing.T) { var requests atomic.Int32 fixture := registryBridgeFixture(t, func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - _ = json.NewEncoder(w).Encode(protocol.RegistryResponse{Status: http.StatusOK, Header: http.Header{"X-Padding": {strings.Repeat("x", (12<<20)+1)}}, Body: []byte("metadata")}) + _ = json.NewEncoder(w). + Encode(protocol.RegistryResponse{Status: http.StatusOK, Header: http.Header{"X-Padding": {strings.Repeat("x", (12<<20)+1)}}, Body: []byte("metadata")}) }) s := testServer(t, fixture) transport := registryTransport{server: s} - request, _ := http.NewRequestWithContext(registryNodeContext(context.Background(), "home"), http.MethodGet, "https://private.invalid/v2/", nil) + request, _ := http.NewRequestWithContext( + registryNodeContext(context.Background(), "home"), + http.MethodGet, + "https://private.invalid/v2/", + nil, + ) if _, err := transport.RoundTrip(request); nil == err { t.Fatalf("response bound: %v", err) } - request, _ = http.NewRequestWithContext(registryNodeContext(context.Background(), "home"), http.MethodPost, "https://private.invalid/token", strings.NewReader(strings.Repeat("x", (1<<20)+1))) + request, _ = http.NewRequestWithContext( + registryNodeContext(context.Background(), "home"), + http.MethodPost, + "https://private.invalid/token", + strings.NewReader(strings.Repeat("x", (1<<20)+1)), + ) if _, err := transport.RoundTrip(request); nil == err || 1 != requests.Load() { t.Fatalf("request bound: %v", err) } diff --git a/internal/server/relay.go b/internal/server/relay.go index 10b3ecc..024e7eb 100644 --- a/internal/server/relay.go +++ b/internal/server/relay.go @@ -40,7 +40,13 @@ func (s *Server) discoverRelays(ctx context.Context, node string) { } if err := s.imageRequest(ctx, node, "GET", "/relays", nil, &response); nil != err { if nil == ctx.Err() && !errors.Is(err, errNodePaused) { - slog.Warn(fmt.Sprintf("Could not discover self-update relays on node %q: %s", node, safeTransportError(err)), "node", node, "error", safeTransportError(err)) + slog.Warn( + fmt.Sprintf("Could not discover self-update relays on node %q: %s", node, safeTransportError(err)), + "node", + node, + "error", + safeTransportError(err), + ) } return } @@ -63,16 +69,14 @@ func (s *Server) startRelay(node, id string) { return } s.relays[key] = true - s.hubWorkers.Add(1) - go func() { - defer s.hubWorkers.Done() + s.hubWorkers.Go(func() { defer func() { s.hubMu.Lock() delete(s.relays, key) s.hubMu.Unlock() }() s.relaySelfUpdate(s.hubContext, key) - }() + }) } func (s *Server) relaySelfUpdate(ctx context.Context, key relayKey) { @@ -101,7 +105,9 @@ func (s *Server) relaySelfUpdate(ctx context.Context, key relayKey) { switch message.Event { case "relay.snapshot", "relay.done": var snapshot relaySnapshot - if nil != json.Unmarshal(message.Payload, &snapshot) || key.id != snapshot.ID || !stackName.MatchString(snapshot.Stack) || "" == snapshot.Action { + if nil != json.Unmarshal(message.Payload, &snapshot) || key.id != snapshot.ID || + !stackName.MatchString(snapshot.Stack) || + "" == snapshot.Action { return false } stack, action = snapshot.Stack, snapshot.Action diff --git a/internal/server/relay_test.go b/internal/server/relay_test.go index d009dab..3739c25 100644 --- a/internal/server/relay_test.go +++ b/internal/server/relay_test.go @@ -30,7 +30,9 @@ func TestRelayFailure(t *testing.T) { Node, Name, Action, Phase, Message string ExitCode int `json:"exit_code"` } - if nil != json.Unmarshal(frame.Payload, &payload) || "home" != payload.Node || "demo" != payload.Name || "update" != payload.Action || "exit" != payload.Phase || code != payload.ExitCode { + if nil != json.Unmarshal(frame.Payload, &payload) || "home" != payload.Node || "demo" != payload.Name || "update" != payload.Action || + "exit" != payload.Phase || + code != payload.ExitCode { t.Fatal("invalid helper failure context", string(frame.Payload)) } for _, detail := range []string{"home", "demo", "update", cause} { @@ -59,7 +61,9 @@ func relayOutput(t *testing.T, browser *websocket.Conn, want string) { Node, Name, Output string ID string `json:"id"` } - if nil != json.Unmarshal(message.Payload, &output) || "" != message.Stream || "" != output.ID || "home" != output.Node || "demo" != output.Name || want != output.Output { + if nil != json.Unmarshal(message.Payload, &output) || "" != message.Stream || "" != output.ID || "home" != output.Node || + "demo" != output.Name || + want != output.Output { t.Fatal("helper replayed output or exposed private identity", string(message.Payload)) } return @@ -76,7 +80,7 @@ func TestRelayResume(t *testing.T) { if pending.Load() { refs = append(refs, map[string]string{"id": "self-helper", "stack": "demo", "action": "update"}) } - jsonResponse(w, http.StatusOK, map[string]interface{}{"relays": refs}) + jsonResponse(w, http.StatusOK, map[string]any{"relays": refs}) }) f.http = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if http.MethodPost != r.Method || "/stacks/demo/actions" != r.URL.Path { @@ -97,7 +101,8 @@ func TestRelayResume(t *testing.T) { two := workspaceSnapshot(t, app.URL, s) w := perform(t, s, http.MethodPost, "/api/nodes/home/stacks/demo/actions", `{"action":"update"}`) var accepted map[string]string - if http.StatusAccepted != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &accepted) || 1 != len(accepted) || "accepted" != accepted["status"] { + if http.StatusAccepted != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &accepted) || 1 != len(accepted) || + "accepted" != accepted["status"] { t.Fatal("private helper identity escaped acceptance response", w.Code, w.Body.String()) } attach := channelCommand(t, f, "relay.attach") @@ -105,7 +110,7 @@ func TestRelayResume(t *testing.T) { t.Fatal("private helper did not attach", attach) } old, delta, next := "\x1b[32mhelper started\x1b[0m\r\n", "pulling\r", "finished\r\n" - send := func(conn *websocket.Conn, stream, event string, payload interface{}) { + send := func(conn *websocket.Conn, stream, event string, payload any) { t.Helper() data, err := json.Marshal(payload) if nil != err { @@ -113,7 +118,12 @@ func TestRelayResume(t *testing.T) { } browserSend(t, conn, protocol.ChannelMessage{Type: "stream.event", Stream: stream, Event: event, Payload: data}) } - send(node, attach.Stream, "relay.snapshot", map[string]interface{}{"id": "self-helper", "stack": "demo", "action": "update", "status": "running", "output": old}) + send( + node, + attach.Stream, + "relay.snapshot", + map[string]any{"id": "self-helper", "stack": "demo", "action": "update", "status": "running", "output": old}, + ) for _, browser := range []*websocket.Conn{one, two} { relayOutput(t, browser, old) } @@ -127,19 +137,39 @@ func TestRelayResume(t *testing.T) { if "self-helper" != resumed.Resource || "" == resumed.Stream || attach.Stream == resumed.Stream { t.Fatal("helper did not resume on a fresh stream", resumed) } - send(node, resumed.Stream, "relay.snapshot", map[string]interface{}{"id": "self-helper", "stack": "demo", "action": "update", "status": "running", "output": old + delta + next}) + send( + node, + resumed.Stream, + "relay.snapshot", + map[string]any{"id": "self-helper", "stack": "demo", "action": "update", "status": "running", "output": old + delta + next}, + ) for _, browser := range []*websocket.Conn{one, two} { relayOutput(t, browser, next) } pending.Store(false) - send(node, resumed.Stream, "relay.done", map[string]interface{}{"id": "self-helper", "stack": "demo", "action": "update", "status": "succeeded", "output": old + delta + next, "exit_code": 0}) + send( + node, + resumed.Stream, + "relay.done", + map[string]any{ + "id": "self-helper", + "stack": "demo", + "action": "update", + "status": "succeeded", + "output": old + delta + next, + "exit_code": 0, + }, + ) for _, browser := range []*websocket.Conn{one, two} { message := stackMessage(t, browser, "stack.action") var exit struct { Node, Name, Action, Phase string ExitCode *int `json:"exit_code"` } - if nil != json.Unmarshal(message.Payload, &exit) || "home" != exit.Node || "demo" != exit.Name || "update" != exit.Action || "exit" != exit.Phase || nil == exit.ExitCode || 0 != *exit.ExitCode { + if nil != json.Unmarshal(message.Payload, &exit) || "home" != exit.Node || "demo" != exit.Name || "update" != exit.Action || + "exit" != exit.Phase || + nil == exit.ExitCode || + 0 != *exit.ExitCode { t.Fatal("helper completion was not a live stack exit", string(message.Payload)) } } diff --git a/internal/server/server.go b/internal/server/server.go index 2c663cd..2c9818c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,6 +34,7 @@ type Server struct { loginNext time.Time oidcMu sync.Mutex oidcPending map[string]oidcLogin + oidcCache oidcProviderCache ticketMu sync.Mutex tickets map[string]websocketTicket hubMu sync.Mutex @@ -56,7 +57,17 @@ type Server struct { } func New(config Config, assets fs.FS, versions ...string) *Server { - s := &Server{config: cloneConfig(config), assets: assets, version: "dev", nodes: make(map[string]*http.Client), oidcPending: make(map[string]oidcLogin), tickets: make(map[string]websocketTicket), channels: make(map[string]*nodeChannel), runtime: make(map[string]*nodeRuntime), watchers: make(map[chan []byte]struct{})} + s := &Server{ + config: cloneConfig(config), + assets: assets, + version: "dev", + nodes: make(map[string]*http.Client), + oidcPending: make(map[string]oidcLogin), + tickets: make(map[string]websocketTicket), + channels: make(map[string]*nodeChannel), + runtime: make(map[string]*nodeRuntime), + watchers: make(map[chan []byte]struct{}), + } s.registrySync = registrySnapshot(config.Registries) s.registryClient = registry.NewClient(&http.Client{Transport: registryTransport{server: s}, Timeout: 30 * time.Second}) s.images = newImageChecks() @@ -78,10 +89,10 @@ func (s *Server) snapshot() (Config, map[string]*http.Client) { } func apiError(w http.ResponseWriter, status int, message string) { - jsonResponse(w, status, map[string]interface{}{"error": map[string]interface{}{"code": status, "message": message}}) + jsonResponse(w, status, map[string]any{"error": map[string]any{"code": status, "message": message}}) } -func jsonResponse(w http.ResponseWriter, status int, payload interface{}) { +func jsonResponse(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) @@ -102,7 +113,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } path := strings.TrimSuffix(r.URL.Path, "/") if "/api/version" == path && "GET" == r.Method { - jsonResponse(w, 200, map[string]interface{}{"version": s.version, "api_version": protocol.ServerAPI, "agent_api_version": protocol.AgentAPI}) + jsonResponse( + w, + 200, + map[string]any{"version": s.version, "api_version": protocol.ServerAPI, "agent_api_version": protocol.AgentAPI}, + ) return } if "/api/auth/login" == path && "POST" == r.Method { @@ -148,7 +163,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.createWebsocketTicket(w, r) case "/api/auth/user" == path && "GET" == r.Method: config, _ := s.snapshot() - jsonResponse(w, 200, map[string]interface{}{"account": map[string]string{"username": config.Auth.Username}, "authenticated": true}) + jsonResponse(w, 200, map[string]any{"account": map[string]string{"username": config.Auth.Username}, "authenticated": true}) case "/api/settings" == path || strings.HasPrefix(path, "/api/settings/"): s.settings(w, r, path) case "/api/auth/logout" == path && "POST" == r.Method: @@ -173,7 +188,10 @@ func sameOrigin(r *http.Request) bool { return true } parsed, err := url.Parse(origin) - if nil != err || ("http" != parsed.Scheme && "https" != parsed.Scheme) || "" == parsed.Host || "" != parsed.Path || "" != parsed.RawQuery || "" != parsed.Fragment || nil != parsed.User { + if nil != err || ("http" != parsed.Scheme && "https" != parsed.Scheme) || "" == parsed.Host || "" != parsed.Path || + "" != parsed.RawQuery || + "" != parsed.Fragment || + nil != parsed.User { return false } return strings.EqualFold(parsed.Host, r.Host) @@ -252,12 +270,15 @@ func credentialRevision(auth AuthConfig) string { } func sessionToken(auth AuthConfig, expires time.Time) (string, error) { - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte(auth.sessionKey)}, (&jose.SignerOptions{}).WithType("JWT")) + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: []byte(auth.sessionKey)}, + (&jose.SignerOptions{}).WithType("JWT"), + ) if nil != err { return "", err } return jwt.Signed(signer).Claims(sessionClaims{ - Claims: jwt.Claims{Expiry: jwt.NewNumericDate(expires)}, + Expiry: jwt.NewNumericDate(expires), CredentialRevision: credentialRevision(auth), }).Serialize() } @@ -273,24 +294,46 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request, config Co apiError(w, 500, "Could not sign you in. Try again.") return false } - http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/", HttpOnly: true, Secure: secure || nil != r.TLS, SameSite: http.SameSiteStrictMode, MaxAge: 86400, Expires: expires}) + http.SetCookie( + w, + &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure || nil != r.TLS, + SameSite: http.SameSiteStrictMode, + MaxAge: 86400, + Expires: expires, + }, + ) return true } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { config, _ := s.snapshot() - http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", HttpOnly: true, Secure: config.Global.SecureCookie || nil != r.TLS, SameSite: http.SameSiteStrictMode, MaxAge: -1}) + http.SetCookie( + w, + &http.Cookie{ + Name: "session", + Path: "/", + HttpOnly: true, + Secure: config.Global.SecureCookie || nil != r.TLS, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }, + ) jsonResponse(w, 200, map[string]bool{"authenticated": false}) } -func decodeBody(w http.ResponseWriter, r *http.Request, target interface{}, limit int64) bool { +func decodeBody(w http.ResponseWriter, r *http.Request, target any, limit int64) bool { decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, limit)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); nil != err { apiError(w, 400, "Invalid JSON request.") return false } - if err := decoder.Decode(new(interface{})); io.EOF != err { + if err := decoder.Decode(new(any)); io.EOF != err { apiError(w, 400, "Invalid JSON request.") return false } @@ -311,7 +354,7 @@ func (s *Server) listNodes(w http.ResponseWriter) { node := config.Nodes[id] nodes = append(nodes, publicNodeInfo{ID: id, Name: node.Name, PublicHost: strings.Trim(node.PublicHost, "[]"), Paused: node.Paused}) } - jsonResponse(w, 200, map[string]interface{}{"nodes": nodes}) + jsonResponse(w, 200, map[string]any{"nodes": nodes}) } func (s *Server) static(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a535aea..a48ca07 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -37,9 +37,25 @@ func testServer(t *testing.T, upstream *httptest.Server) *Server { if nil != err { t.Fatal(err) } - c := Config{Auth: AuthConfig{Username: "admin", Password: string(hash), passwordHash: string(hash), SessionKey: strings.Repeat("ab", 32), sessionKey: strings.Repeat("\xab", 32)}, Nodes: map[string]NodeConfig{}} + c := Config{ + Auth: AuthConfig{ + Username: "admin", + Password: string(hash), + passwordHash: string(hash), + SessionKey: strings.Repeat("ab", 32), + sessionKey: strings.Repeat("\xab", 32), + }, + Nodes: map[string]NodeConfig{}, + } if nil != upstream { - c.Nodes["home"] = NodeConfig{Name: "Home", URL: upstream.URL, Token: "agent-test-token", token: "agent-test-token", PublicHost: "public.example", Fingerprint: testFingerprint(upstream)} + c.Nodes["home"] = NodeConfig{ + Name: "Home", + URL: upstream.URL, + Token: "agent-test-token", + token: "agent-test-token", + PublicHost: "public.example", + Fingerprint: testFingerprint(upstream), + } } s := New(c, fstest.MapFS{"index.html": {Data: []byte("SPA")}, "assets/app.js": {Data: []byte("script")}}) s.registryClient = registry.NewClient(&http.Client{Transport: registryTestTransport(func(r *http.Request) (*http.Response, error) { @@ -174,7 +190,8 @@ func TestForward(t *testing.T) { var requests atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || "" != r.Header.Get("Cookie") { + if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || + "" != r.Header.Get("Cookie") { t.Error("incorrect upstream credentials") } if "/stacks/demo/actions" != r.URL.Path || "POST" != r.Method || "" != r.URL.RawQuery { @@ -215,7 +232,8 @@ func TestNodeInfo(t *testing.T) { info := readFixture(t, "node-info.json") var requests atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || "" != r.Header.Get("Cookie") { + if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || + "" != r.Header.Get("Cookie") { t.Error("incorrect upstream credentials or API version") } requests.Add(1) @@ -228,7 +246,10 @@ func TestNodeInfo(t *testing.T) { defer up.Close() s := testServer(t, up) w := perform(t, s, http.MethodGet, "/api/nodes/home/info?command=destroy&token=browser-secret", "untrusted") - if 200 != w.Code || 1 != requests.Load() || !strings.Contains(w.Body.String(), `"runtime":`) || strings.Contains(w.Body.String(), `"docker":`) || !strings.Contains(w.Body.String(), `"cpu":"2.50%"`) || !strings.Contains(w.Body.String(), `"memory_bytes":16777216000`) { + if 200 != w.Code || 1 != requests.Load() || !strings.Contains(w.Body.String(), `"runtime":`) || + strings.Contains(w.Body.String(), `"docker":`) || + !strings.Contains(w.Body.String(), `"cpu":"2.50%"`) || + !strings.Contains(w.Body.String(), `"memory_bytes":16777216000`) { t.Fatalf("info: %d %s", w.Code, w.Body) } for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { @@ -263,7 +284,11 @@ func TestNodeInfoError(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "secret") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 502 != w.Code || 502 != response.Error.Code || "" == response.Error.Message || + strings.Contains(w.Body.String(), "secret") { t.Fatalf("info error: %d %s", w.Code, w.Body) } } @@ -360,18 +385,26 @@ func TestStatic(t *testing.T) { func TestAggregate(t *testing.T) { up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if http.MethodGet == r.Method && "/relays" == r.URL.Path { - jsonResponse(w, http.StatusOK, map[string]interface{}{"relays": []interface{}{}}) + jsonResponse(w, http.StatusOK, map[string]any{"relays": []any{}}) return } conn, err := websocket.Accept(w, r, nil) if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() if !ackRegistrySync(t, r.Context(), conn) { return } - _ = wsjson.Write(r.Context(), conn, protocol.ChannelMessage{Type: "event", Event: "node.snapshot", Payload: json.RawMessage(`{"node":"forged","health":{"status":"ok"},"stacks":[]}`)}) + _ = wsjson.Write( + r.Context(), + conn, + protocol.ChannelMessage{ + Type: "event", + Event: "node.snapshot", + Payload: json.RawMessage(`{"node":"forged","health":{"status":"ok"},"stacks":[]}`), + }, + ) _, _, _ = conn.Read(r.Context()) })) defer up.Close() @@ -424,7 +457,7 @@ func TestReconnect(t *testing.T) { var attempts atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if http.MethodGet == r.Method && "/relays" == r.URL.Path { - jsonResponse(w, http.StatusOK, map[string]interface{}{"relays": []interface{}{}}) + jsonResponse(w, http.StatusOK, map[string]any{"relays": []any{}}) return } attempt := attempts.Add(1) @@ -436,11 +469,19 @@ func TestReconnect(t *testing.T) { if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() if !ackRegistrySync(t, r.Context(), conn) { return } - _ = wsjson.Write(r.Context(), conn, protocol.ChannelMessage{Type: "event", Event: "node.snapshot", Payload: json.RawMessage(`{"health":{"status":"ok"},"stacks":[]}`)}) + _ = wsjson.Write( + r.Context(), + conn, + protocol.ChannelMessage{ + Type: "event", + Event: "node.snapshot", + Payload: json.RawMessage(`{"health":{"status":"ok"},"stacks":[]}`), + }, + ) _, _, _ = conn.Read(r.Context()) })) defer up.Close() @@ -502,7 +543,12 @@ func TestConfig(t *testing.T) { t.Fatal(err) } hash, _ := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) - config := fmt.Sprintf("[global]\nlisten = '127.0.0.1:8080'\n[auth]\nusername = 'admin'\npassword = '%s'\n[nodes.home]\nurl = '%s'\nfingerprint = '%s'\ntoken = 'file:token'\n", hash, up.URL, testFingerprint(up)) + config := fmt.Sprintf( + "[global]\nlisten = '127.0.0.1:8080'\n[auth]\nusername = 'admin'\npassword = '%s'\n[nodes.home]\nurl = '%s'\nfingerprint = '%s'\ntoken = 'file:token'\n", + hash, + up.URL, + testFingerprint(up), + ) path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte(config), 0600); nil != err { t.Fatal(err) diff --git a/internal/server/settings.go b/internal/server/settings.go index 10af8f7..22bfafd 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -31,10 +31,19 @@ type safeNode struct { } func publicNode(id string, node NodeConfig) safeNode { - return safeNode{ID: id, Name: node.Name, URL: node.URL, Fingerprint: node.Fingerprint, PublicHost: node.PublicHost, HasToken: "" != node.token, Token: node.token, Paused: node.Paused} + return safeNode{ + ID: id, + Name: node.Name, + URL: node.URL, + Fingerprint: node.Fingerprint, + PublicHost: node.PublicHost, + HasToken: "" != node.token, + Token: node.token, + Paused: node.Paused, + } } -func (s *Server) publicSettings(c Config) interface{} { +func (s *Server) publicSettings(c Config) any { nodes := make([]safeNode, 0, len(c.Nodes)) for _, id := range orderedNodeIDs(c) { nodes = append(nodes, publicNode(id, c.Nodes[id])) @@ -68,7 +77,15 @@ type nodeConnectionRequest struct { } func (input nodeInput) node(existing NodeConfig) NodeConfig { - node := NodeConfig{Name: input.Name, URL: input.URL, Fingerprint: input.Fingerprint, PublicHost: input.PublicHost, Token: existing.Token, token: existing.token, Paused: existing.Paused} + node := NodeConfig{ + Name: input.Name, + URL: input.URL, + Fingerprint: input.Fingerprint, + PublicHost: input.PublicHost, + Token: existing.Token, + token: existing.token, + Paused: existing.Paused, + } if nil != input.Paused { node.Paused = *input.Paused } @@ -118,8 +135,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request, path string) { s.saveNode(w, r, "") return } - if strings.HasPrefix(path, "/api/settings/nodes/") { - id := strings.TrimPrefix(path, "/api/settings/nodes/") + if id, ok := strings.CutPrefix(path, "/api/settings/nodes/"); ok { if identity.MatchString(id) { if "PUT" == r.Method { s.saveNode(w, r, id) @@ -222,7 +238,15 @@ func (s *Server) saveNode(w http.ResponseWriter, r *http.Request, id string) { if !decodeBody(w, r, &connection, 32<<10) { return } - input := nodeInput{ID: id, Name: connection.Name, URL: connection.URL, Token: connection.Token, Fingerprint: connection.Fingerprint, PublicHost: connection.PublicHost, Paused: connection.Paused} + input := nodeInput{ + ID: id, + Name: connection.Name, + URL: connection.URL, + Token: connection.Token, + Fingerprint: connection.Fingerprint, + PublicHost: connection.PublicHost, + Paused: connection.Paused, + } if "" == id { var identity [8]byte if _, err := rand.Read(identity[:]); nil != err { @@ -315,7 +339,13 @@ func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request, id string) { if err := resetNodePairing(r.Context(), node, s.nodes[id]); nil != err { detail := strings.TrimSuffix(strings.TrimSpace(safeTransportError(err)), ".") result.Warning = "Node was removed, but pairing could not be reset." - slog.Warn(fmt.Sprintf("Removed %s from server configuration, but could not reset its pairing: %s.", nodeLogLabel(node), detail), "node", nodeLogLabel(node), "error", detail) + slog.Warn( + fmt.Sprintf("Removed %s from server configuration, but could not reset its pairing: %s.", nodeLogLabel(node), detail), + "node", + nodeLogLabel(node), + "error", + detail, + ) } else { slog.Info(fmt.Sprintf("Disconnected %s and reset its pairing.", nodeLogLabel(node)), "node", nodeLogLabel(node)) } @@ -352,10 +382,15 @@ func (s *Server) testNode(w http.ResponseWriter, r *http.Request) { defer cancel() health, err := checkNode(ctx, node, client) if nil != err { - var incompatible incompatibleAgent detail := safeTransportError(err) - slog.Error(fmt.Sprintf("Connection test for %s failed: %s", nodeLogLabel(node), detail), "node", nodeLogLabel(node), "error", detail) - if errors.As(err, &incompatible) { + slog.Error( + fmt.Sprintf("Connection test for %s failed: %s", nodeLogLabel(node), detail), + "node", + nodeLogLabel(node), + "error", + detail, + ) + if incompatible, ok := errors.AsType[incompatibleAgent](err); ok { apiError(w, 426, incompatible.Error()) } else { apiError(w, 502, "Cannot connect to the node. Check its token, address and public-key fingerprint.") diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8c2dfc5..6e15921 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -101,7 +101,13 @@ func TestSettingsRefs(t *testing.T) { t.Fatal(err) } s = New(loaded, nil, "v2.3") - w := perform(t, s, "PUT", "/api/settings/nodes/home", fmt.Sprintf(`{"name":"Home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up))) + w := perform( + t, + s, + "PUT", + "/api/settings/nodes/home", + fmt.Sprintf(`{"name":"Home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up)), + ) if 200 != w.Code { t.Fatal(w.Code, w.Body.String()) } @@ -136,7 +142,8 @@ func TestNodeCRUD(t *testing.T) { s := persistedServer(t, nil) body := fmt.Sprintf(`{"name":"Home","url":%q,"token":"new-secret-token","fingerprint":%q}`, up.URL, testFingerprint(up)) w := perform(t, s, "POST", "/api/settings/nodes", body) - if 200 != w.Code || !strings.Contains(w.Body.String(), `"token":"new-secret-token"`) || !strings.Contains(w.Body.String(), `"has_token":true`) { + if 200 != w.Code || !strings.Contains(w.Body.String(), `"token":"new-secret-token"`) || + !strings.Contains(w.Body.String(), `"has_token":true`) { t.Fatal(w.Code, w.Body.String()) } var created safeNode @@ -160,7 +167,13 @@ func TestNodeCRUD(t *testing.T) { } oldConfig, oldClients := s.snapshot() oldClient := oldClients[created.ID] - w = perform(t, s, "PUT", "/api/settings/nodes/"+created.ID, fmt.Sprintf(`{"name":"Updated","url":"https://127.0.0.1:9088","token":"","fingerprint":%q}`, testFingerprint(up))) + w = perform( + t, + s, + "PUT", + "/api/settings/nodes/"+created.ID, + fmt.Sprintf(`{"name":"Updated","url":"https://127.0.0.1:9088","token":"","fingerprint":%q}`, testFingerprint(up)), + ) if 200 != w.Code { t.Fatal(w.Code, w.Body.String()) } @@ -194,7 +207,8 @@ func TestDisconnectNode(t *testing.T) { var calls atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if http.MethodPost != r.Method || "/unpair" != r.URL.Path || "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { + if http.MethodPost != r.Method || "/unpair" != r.URL.Path || "Bearer agent-test-token" != r.Header.Get("Authorization") || + strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) { t.Error("incorrect unpair request") } switch mode { @@ -221,7 +235,8 @@ func TestDisconnectNode(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &result); nil != err || http.StatusOK != w.Code || "ok" != result.Status { t.Fatal("disconnect failed", w.Code, w.Body.String(), err) } - if ("reset" != mode) != ("" != result.Warning) || strings.Contains(w.Body.String(), "private reset failure") || strings.Contains(w.Body.String(), "agent-test-token") { + if ("reset" != mode) != ("" != result.Warning) || strings.Contains(w.Body.String(), "private reset failure") || + strings.Contains(w.Body.String(), "agent-test-token") { t.Fatal("incorrect reset result", w.Body.String()) } expected := int32(1) @@ -287,13 +302,20 @@ func TestAccount(t *testing.T) { if 401 != w.Code { t.Fatal(w.Code) } - w = perform(t, s, "POST", "/api/settings/account", `{"username":"new-admin","password":"new-password","current_password":"test-password"}`) + w = perform( + t, + s, + "POST", + "/api/settings/account", + `{"username":"new-admin","password":"new-password","current_password":"test-password"}`, + ) if 200 != w.Code || 1 != len(w.Result().Cookies()) { t.Fatal(w.Code, w.Body.String()) } currentSession := w.Result().Cookies()[0] c, err := LoadConfig(s.config.path) - if nil != err || "new-admin" != c.Auth.Username || "file:secrets/password.hash" != c.Auth.Password || nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("new-password")) { + if nil != err || "new-admin" != c.Auth.Username || "file:secrets/password.hash" != c.Auth.Password || + nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("new-password")) { t.Fatal("credentials not persisted", err) } r := httptest.NewRequest("GET", "/api/auth/user", nil) @@ -338,7 +360,13 @@ func TestAccountRollback(t *testing.T) { t.Fatal(err) } before, _ := os.ReadFile(s.config.path) - w := perform(t, s, "POST", "/api/settings/account", `{"username":"changed","password":"new-password","current_password":"test-password"}`) + w := perform( + t, + s, + "POST", + "/api/settings/account", + `{"username":"changed","password":"new-password","current_password":"test-password"}`, + ) after, _ := os.ReadFile(s.config.path) if 500 != w.Code || !bytes.Equal(before, after) || "admin" != s.config.Auth.Username { t.Fatal("failed secret write changed credentials", w.Code) @@ -369,7 +397,8 @@ func TestSetPassword(t *testing.T) { t.Fatal(err) } c, err = LoadConfig(path) - if nil != err || "file:custom.hash" != c.Auth.Password || "inline-secret" != c.Nodes["home"].Token || nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("second-password")) { + if nil != err || "file:custom.hash" != c.Auth.Password || "inline-secret" != c.Nodes["home"].Token || + nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("second-password")) { t.Fatal("set-password lost config or reference", err) } stat, _ := os.Stat(path) @@ -432,7 +461,8 @@ func TestNodeTest(t *testing.T) { var requests atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || "/health" != r.URL.Path { + if "Bearer agent-test-token" != r.Header.Get("Authorization") || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || + "/health" != r.URL.Path { t.Error("incorrect health probe") } testHealth(w, r) @@ -440,9 +470,16 @@ func TestNodeTest(t *testing.T) { defer up.Close() s := persistedServer(t, up) before, _ := os.ReadFile(s.config.path) - w := perform(t, s, "POST", "/api/settings/nodes/test", fmt.Sprintf(`{"id":"home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up))) + w := perform( + t, + s, + "POST", + "/api/settings/nodes/test", + fmt.Sprintf(`{"id":"home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up)), + ) after, _ := os.ReadFile(s.config.path) - if 200 != w.Code || 1 != requests.Load() || !bytes.Equal(before, after) || fmt.Sprintf(`{"version":"test","api_version":%d}`, protocol.AgentAPI) != strings.TrimSpace(w.Body.String()) { + if 200 != w.Code || 1 != requests.Load() || !bytes.Equal(before, after) || + fmt.Sprintf(`{"version":"test","api_version":%d}`, protocol.AgentAPI) != strings.TrimSpace(w.Body.String()) { t.Fatal(w.Code, w.Body.String()) } } @@ -483,7 +520,11 @@ func TestProtocol(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || status != response.Error.Code || "" == response.Error.Message || strings.Contains(w.Body.String(), "private protocol refusal") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || status != response.Error.Code || "" == response.Error.Message || + strings.Contains(w.Body.String(), "private protocol refusal") { t.Fatal("missing protocol error", w.Body.String()) } } @@ -513,14 +554,24 @@ func TestNodeTestVersion(t *testing.T) { })) defer up.Close() s := testServer(t, up) - w := perform(t, s, "POST", "/api/settings/nodes/test", fmt.Sprintf(`{"id":"home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up))) + w := perform( + t, + s, + "POST", + "/api/settings/nodes/test", + fmt.Sprintf(`{"id":"home","url":%q,"fingerprint":%q}`, up.URL, testFingerprint(up)), + ) var response struct { Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 426 != w.Code || 426 != response.Error.Code || "" == response.Error.Message || 1 != requests.Load() || strings.Contains(w.Body.String(), "private protocol refusal") { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 426 != w.Code || 426 != response.Error.Code || "" == response.Error.Message || 1 != requests.Load() || + strings.Contains(w.Body.String(), "private protocol refusal") { t.Fatal(w.Code, w.Body.String(), requests.Load()) } }) @@ -529,7 +580,8 @@ func TestNodeTestVersion(t *testing.T) { func TestProtocolEvents(t *testing.T) { up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if "/channel" != r.URL.Path || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || "Bearer agent-test-token" != r.Header.Get("Authorization") { + if "/channel" != r.URL.Path || strconv.Itoa(protocol.AgentAPI) != r.Header.Get(protocol.Header) || + "Bearer agent-test-token" != r.Header.Get("Authorization") { t.Error("incorrect channel handshake") } w.WriteHeader(http.StatusUpgradeRequired) @@ -561,7 +613,8 @@ func TestProtocolEvents(t *testing.T) { State string `json:"state"` Message string `json:"message"` } - if "event" != event.Type || nil != json.Unmarshal(event.Payload, &state) || "home" != state.Node || "unavailable" != state.State || "" == state.Message { + if "event" != event.Type || nil != json.Unmarshal(event.Payload, &state) || "home" != state.Node || "unavailable" != state.State || + "" == state.Message { t.Fatal("missing protocol state", string(data)) } states++ @@ -576,9 +629,7 @@ func TestConfigRace(t *testing.T) { s := persistedServer(t, up) var workers sync.WaitGroup for i := 0; 4 > i; i++ { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { for j := 0; 12 > j; j++ { for _, path := range []string{"/api/settings", "/api/nodes", "/api/nodes/home/stacks", "/api/auth/user"} { if 200 != perform(t, s, "GET", path, "").Code { @@ -586,10 +637,16 @@ func TestConfigRace(t *testing.T) { } } } - }() + }) } for i := 0; 12 > i; i++ { - if 200 != perform(t, s, "PUT", "/api/settings/nodes/home", fmt.Sprintf(`{"name":"Home %d","url":%q,"fingerprint":%q}`, i, up.URL, testFingerprint(up))).Code { + if 200 != perform( + t, + s, + "PUT", + "/api/settings/nodes/home", + fmt.Sprintf(`{"name":"Home %d","url":%q,"fingerprint":%q}`, i, up.URL, testFingerprint(up)), + ).Code { t.Fatal("concurrent write failed") } } diff --git a/internal/server/setup.go b/internal/server/setup.go index a590442..8374cd7 100644 --- a/internal/server/setup.go +++ b/internal/server/setup.go @@ -36,7 +36,15 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) { } c, err := changeCredentials(cloneConfig(s.config), input.Username, input.Password) if nil != err { - slog.Error(fmt.Sprintf("Could not save the first account in configuration %q: %v. Check configuration directory permissions.", s.config.path, err), "error", err) + slog.Error( + fmt.Sprintf( + "Could not save the first account in configuration %q: %v. Check configuration directory permissions.", + s.config.path, + err, + ), + "error", + err, + ) apiError(w, 500, "Could not save the account. Check the configuration directory's permissions.") return } diff --git a/internal/server/setup_test.go b/internal/server/setup_test.go index 4459ecb..ade0564 100644 --- a/internal/server/setup_test.go +++ b/internal/server/setup_test.go @@ -24,7 +24,8 @@ func TestSetup(t *testing.T) { } s := New(c, nil) w := perform(t, s, "GET", "/api/auth/status", "") - if 200 != w.Code || !strings.Contains(w.Body.String(), `"setup_required":true`) || !strings.Contains(w.Body.String(), `"authenticated":false`) { + if 200 != w.Code || !strings.Contains(w.Body.String(), `"setup_required":true`) || + !strings.Contains(w.Body.String(), `"authenticated":false`) { t.Fatal(w.Body.String()) } for _, path := range []string{"/api/nodes", "/api/settings", "/api/auth/user"} { @@ -63,7 +64,8 @@ func TestSetup(t *testing.T) { t.Fatal(w.Body.String()) } c, err = LoadConfig(path) - if nil != err || c.Auth.setupRequired() || "file:secrets/password.hash" != c.Auth.Password || nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("test-password")) { + if nil != err || c.Auth.setupRequired() || "file:secrets/password.hash" != c.Auth.Password || + nil != bcrypt.CompareHashAndPassword([]byte(c.Auth.passwordHash), []byte("test-password")) { t.Fatal("setup not persisted", err) } data, err := os.ReadFile(path) @@ -171,7 +173,11 @@ func TestSetupSaveFailure(t *testing.T) { Level string `json:"level"` Error string `json:"error"` } - if err := json.Unmarshal(output.Bytes(), &record); nil != err || "ERROR" != record.Level || "" == record.Error || !strings.Contains(record.Message, c.path) || !strings.Contains(record.Message, record.Error) || strings.Contains(output.String(), "setup-secret-password") { + if err := json.Unmarshal( + output.Bytes(), + &record, + ); nil != err || "ERROR" != record.Level || "" == record.Error || !strings.Contains(record.Message, c.path) || !strings.Contains(record.Message, record.Error) || + strings.Contains(output.String(), "setup-secret-password") { t.Fatal("setup message hides failure context or leaks password", output.String(), err) } } @@ -180,7 +186,11 @@ func TestSetupKeepsConfig(t *testing.T) { up := httptest.NewTLSServer(nil) defer up.Close() path := filepath.Join(t.TempDir(), "dcompose.toml") - data := fmt.Sprintf("[global]\nlisten = '127.0.0.1:8085'\norigin = 'https://app.example'\nsecure_cookie = true\n[nodes.home]\nurl = '%s'\nfingerprint = '%s'\ntoken = 'node-secret'\n", up.URL, testFingerprint(up)) + data := fmt.Sprintf( + "[global]\nlisten = '127.0.0.1:8085'\norigin = 'https://app.example'\nsecure_cookie = true\n[nodes.home]\nurl = '%s'\nfingerprint = '%s'\ntoken = 'node-secret'\n", + up.URL, + testFingerprint(up), + ) if err := os.WriteFile(path, []byte(data), 0600); nil != err { t.Fatal(err) } @@ -193,7 +203,8 @@ func TestSetupKeepsConfig(t *testing.T) { t.Fatal(w.Code, w.Body.String()) } c, err = LoadConfig(path) - if nil != err || "127.0.0.1:8085" != c.Global.Listen || "https://app.example" != c.Global.Origin || "node-secret" != c.Nodes["home"].token { + if nil != err || "127.0.0.1:8085" != c.Global.Listen || "https://app.example" != c.Global.Origin || + "node-secret" != c.Nodes["home"].token { t.Fatal("setup lost existing configuration", err) } } diff --git a/internal/server/shell.go b/internal/server/shell.go index 356fbd7..7e8bf72 100644 --- a/internal/server/shell.go +++ b/internal/server/shell.go @@ -11,7 +11,11 @@ import ( "github.com/arabcoders/dcompose/internal/protocol" ) -func (s *Server) browserShellCommand(ctx context.Context, subscription *browserSubscription, message protocol.ChannelMessage) *protocol.ChannelMessage { +func (s *Server) browserShellCommand( + ctx context.Context, + subscription *browserSubscription, + message protocol.ChannelMessage, +) *protocol.ChannelMessage { if nil == subscription || "" == subscription.shell { return browserError(message.Stream, 404, "Shell is not open. Reopen the shell and try again.") } @@ -44,7 +48,17 @@ func (s *Server) browserShellCommand(ctx context.Context, subscription *browserS action = "resize" } detail := safeTransportError(err) - slog.Error(fmt.Sprintf("Could not %s shell %q on %s: %s", action, subscription.shell, nodeLogLabel(subscription.channel.node), detail), "node", nodeLogLabel(subscription.channel.node), "operation", action, "shell", subscription.shell, "error", detail) + slog.Error( + fmt.Sprintf("Could not %s shell %q on %s: %s", action, subscription.shell, nodeLogLabel(subscription.channel.node), detail), + "node", + nodeLogLabel(subscription.channel.node), + "operation", + action, + "shell", + subscription.shell, + "error", + detail, + ) return browserError(message.Stream, 502, "Connection lost. Reopen the shell.") } return nil diff --git a/internal/server/stack_actions_test.go b/internal/server/stack_actions_test.go index 2aca717..a3c6b70 100644 --- a/internal/server/stack_actions_test.go +++ b/internal/server/stack_actions_test.go @@ -19,7 +19,8 @@ func TestStackAccepted(t *testing.T) { var requests atomic.Int32 up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) - if !(http.MethodPost == r.Method && "/stacks/demo/actions" == r.URL.Path) && !(http.MethodDelete == r.Method && "/stacks/demo" == r.URL.Path) { + if !(http.MethodPost == r.Method && "/stacks/demo/actions" == r.URL.Path) && + !(http.MethodDelete == r.Method && "/stacks/demo" == r.URL.Path) { t.Error("unexpected upstream action", r.Method, r.URL.Path) } jsonResponse(w, http.StatusAccepted, map[string]string{"status": "accepted"}) @@ -35,7 +36,8 @@ func TestStackAccepted(t *testing.T) { } { w := perform(t, s, input.method, input.path, input.body) var accepted map[string]string - if http.StatusAccepted != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &accepted) || 1 != len(accepted) || "accepted" != accepted["status"] { + if http.StatusAccepted != w.Code || nil != json.Unmarshal(w.Body.Bytes(), &accepted) || 1 != len(accepted) || + "accepted" != accepted["status"] { t.Fatal("action returned an operation instead of acceptance", w.Code, w.Body.String()) } r := httptest.NewRequest(input.method, input.path, strings.NewReader(input.body)) @@ -142,7 +144,10 @@ func TestStackLiveOutput(t *testing.T) { var action struct { Node, Name, Action, Phase string } - if nil != json.Unmarshal(message.Payload, &action) || "event" != message.Type || "" != message.Stream || "home" != action.Node || "demo" != action.Name || "deploy" != action.Action || "start" != action.Phase { + if nil != json.Unmarshal(message.Payload, &action) || "event" != message.Type || "" != message.Stream || "home" != action.Node || + "demo" != action.Name || + "deploy" != action.Action || + "start" != action.Phase { t.Fatal("live action lost trusted node context", message, string(message.Payload)) } } @@ -158,7 +163,8 @@ func TestStackLiveOutput(t *testing.T) { var output struct { Node, Name, Output string } - if nil != json.Unmarshal(message.Payload, &output) || "" != message.Stream || "home" != output.Node || "demo" != output.Name || chunk != output.Output { + if nil != json.Unmarshal(message.Payload, &output) || "" != message.Stream || "home" != output.Node || "demo" != output.Name || + chunk != output.Output { t.Fatal("live PTY bytes or node context changed", string(message.Payload)) } } @@ -175,7 +181,11 @@ func TestStackLiveOutput(t *testing.T) { Node, Name, Action, Phase, Message string ExitCode *int `json:"exit_code"` } - if nil != json.Unmarshal(message.Payload, &exit) || "home" != exit.Node || "demo" != exit.Name || "deploy" != exit.Action || "exit" != exit.Phase || nil == exit.ExitCode || 1 != *exit.ExitCode || "" == exit.Message { + if nil != json.Unmarshal(message.Payload, &exit) || "home" != exit.Node || "demo" != exit.Name || "deploy" != exit.Action || + "exit" != exit.Phase || + nil == exit.ExitCode || + 1 != *exit.ExitCode || + "" == exit.Message { t.Fatal("failure completion lost live context", string(message.Payload)) } } diff --git a/internal/server/stream.go b/internal/server/stream.go index e2f5c36..0ba2a96 100644 --- a/internal/server/stream.go +++ b/internal/server/stream.go @@ -67,7 +67,16 @@ func (s *Server) attachNode(ctx context.Context, message protocol.ChannelMessage return nil, browserError(message.Stream, 502, "Node is unavailable. Try again when it reconnects.") } ctx, cancel := context.WithCancel(ctx) - subscription := &browserSubscription{id: message.Stream, kind: message.Type, resource: message.Resource, channel: channel, conn: channel.conn, subscriber: make(chan protocol.ChannelMessage, 64), ctx: ctx, cancel: cancel} + subscription := &browserSubscription{ + id: message.Stream, + kind: message.Type, + resource: message.Resource, + channel: channel, + conn: channel.conn, + subscriber: make(chan protocol.ChannelMessage, 64), + ctx: ctx, + cancel: cancel, + } command := protocol.ChannelMessage{Type: message.Type, Resource: message.Resource} if "shell.attach" == message.Type { subscription.shell = message.Shell @@ -91,7 +100,21 @@ func (s *Server) attachNode(ctx context.Context, message protocol.ChannelMessage view = fmt.Sprintf("shell %q", subscription.shell) } detail := safeTransportError(err) - slog.Error(fmt.Sprintf("Could not open %s on %s: %s", view, nodeLogLabel(channel.node), detail), "node", nodeLogLabel(channel.node), "operation", subscription.kind, "resource", subscription.resource, "stream", subscription.stream, "shell", subscription.shell, "error", detail) + slog.Error( + fmt.Sprintf("Could not open %s on %s: %s", view, nodeLogLabel(channel.node), detail), + "node", + nodeLogLabel(channel.node), + "operation", + subscription.kind, + "resource", + subscription.resource, + "stream", + subscription.stream, + "shell", + subscription.shell, + "error", + detail, + ) s.detachBrowser(subscription) return nil, browserError(message.Stream, 502, "Could not open the requested view. Try again.") } @@ -159,7 +182,8 @@ func (subscription *browserSubscription) forward(outgoing chan<- browserDelivery if !send(message, false) { return } - if "error" == message.Type || "shell.error" == message.Type || "operation.done" == message.Event || "done" == message.Event || "error" == message.Event { + if "error" == message.Type || "shell.error" == message.Type || "operation.done" == message.Event || "done" == message.Event || + "error" == message.Event { send(protocol.ChannelMessage{}, true) return } @@ -193,7 +217,21 @@ func (s *Server) streamEvent(channel *nodeChannel, message protocol.ChannelMessa } else { detail += "." } - slog.Error(detail, "node", nodeLogLabel(channel.node), "operation", subscription.kind, "resource", subscription.resource, "stream", subscription.stream, "status", message.Status, "error", message.Error) + slog.Error( + detail, + "node", + nodeLogLabel(channel.node), + "operation", + subscription.kind, + "resource", + subscription.resource, + "stream", + subscription.stream, + "status", + message.Status, + "error", + message.Error, + ) message = protocol.ChannelMessage{Type: "error", Status: status, Error: "Could not load the requested view. Try again."} } else { switch message.Event { diff --git a/internal/server/tickets.go b/internal/server/tickets.go index c7de17e..aa7e16e 100644 --- a/internal/server/tickets.go +++ b/internal/server/tickets.go @@ -12,6 +12,7 @@ import ( type websocketTicket struct { expires time.Time + auth http.Header } func (s *Server) createWebsocketTicket(w http.ResponseWriter, r *http.Request) { @@ -30,6 +31,12 @@ func (s *Server) createWebsocketTicket(w http.ResponseWriter, r *http.Request) { return } value := base64.RawURLEncoding.EncodeToString(token[:]) + auth := make(http.Header) + if authorization := r.Header.Get("Authorization"); "" != authorization { + auth.Set("Authorization", authorization) + } else if cookie, err := r.Cookie("session"); nil == err { + auth.Set("Cookie", cookie.String()) + } now := time.Now() s.ticketMu.Lock() for value, ticket := range s.tickets { @@ -37,10 +44,10 @@ func (s *Server) createWebsocketTicket(w http.ResponseWriter, r *http.Request) { delete(s.tickets, value) } } - s.tickets[value] = websocketTicket{expires: now.Add(30 * time.Second)} + s.tickets[value] = websocketTicket{expires: now.Add(30 * time.Second), auth: auth} s.ticketMu.Unlock() - jsonResponse(w, http.StatusCreated, map[string]interface{}{"ticket": value, "expires_in": 30}) + jsonResponse(w, http.StatusCreated, map[string]any{"ticket": value, "expires_in": 30}) } func (s *Server) consumeWebsocketTicket(value string) (websocketTicket, bool) { @@ -74,7 +81,10 @@ func ticketOrigin(r *http.Request, origins []string) bool { func validWebsocketOrigin(value string) (*url.URL, bool) { u, err := url.Parse(value) - if nil != err || ("http" != u.Scheme && "https" != u.Scheme) || "" == u.Hostname() || nil != u.User || "" != u.Path || "" != u.RawQuery || "" != u.Fragment || strings.ContainsAny(value, "*?#") { + if nil != err || ("http" != u.Scheme && "https" != u.Scheme) || "" == u.Hostname() || nil != u.User || "" != u.Path || + "" != u.RawQuery || + "" != u.Fragment || + strings.ContainsAny(value, "*?#") { return nil, false } if strings.HasPrefix(u.Host, "[") { diff --git a/internal/server/tickets_test.go b/internal/server/tickets_test.go index 1116a81..9810634 100644 --- a/internal/server/tickets_test.go +++ b/internal/server/tickets_test.go @@ -20,6 +20,7 @@ func TestTicketMint(t *testing.T) { t.Run(auth, func(t *testing.T) { s := testServer(t, nil) r := httptest.NewRequest("POST", "/api/auth/tickets", strings.NewReader(`{}`)) + r.AddCookie(&http.Cookie{Name: "unrelated", Value: "other-secret"}) if "session" == auth { r.AddCookie(testSession(t, s)) } else { @@ -47,6 +48,17 @@ func TestTicketMint(t *testing.T) { if !exists || ticket.expires.Before(before.Add(30*time.Second)) || ticket.expires.After(time.Now().Add(30*time.Second)) { t.Fatal("incorrect ticket expiry") } + if 1 != len(ticket.auth) || !s.authenticated(&http.Request{Header: ticket.auth}) { + t.Fatal("ticket did not preserve its issuing credential") + } + if "session" == auth { + cookie, err := r.Cookie("session") + if nil != err || cookie.String() != ticket.auth.Get("Cookie") { + t.Fatal("ticket retained unrelated cookies") + } + } else if r.Header.Get("Authorization") != ticket.auth.Get("Authorization") { + t.Fatal("ticket did not preserve bearer authentication") + } if 0 != len(w.Result().Cookies()) { t.Fatal("ticket issuance created a session") } @@ -67,7 +79,11 @@ func TestTicketBody(t *testing.T) { Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(w.Body.Bytes(), &response); nil != err || 400 != w.Code || 400 != response.Error.Code || "" == response.Error.Message { + if err := json.Unmarshal( + w.Body.Bytes(), + &response, + ); nil != err || 400 != w.Code || 400 != response.Error.Code || + "" == response.Error.Message { t.Fatal("invalid ticket body accepted or incorrect error response", w.Code, err) } } @@ -154,14 +170,12 @@ func TestTicketConcurrent(t *testing.T) { var workers sync.WaitGroup start := make(chan struct{}) for range 16 { - workers.Add(1) - go func() { - defer workers.Done() + workers.Go(func() { <-start if _, valid := s.consumeWebsocketTicket("ticket"); valid { winners.Add(1) } - }() + }) } close(start) workers.Wait() @@ -253,7 +267,9 @@ func TestWebsocketOrigins(t *testing.T) { } if test.valid { stored, err := readConfig(path) - if nil != err || 1 != len(c.Global.WebsocketOrigins) || test.origin != c.Global.WebsocketOrigins[0] || 1 != len(stored.Global.WebsocketOrigins) || test.origin != stored.Global.WebsocketOrigins[0] { + if nil != err || 1 != len(c.Global.WebsocketOrigins) || test.origin != c.Global.WebsocketOrigins[0] || + 1 != len(stored.Global.WebsocketOrigins) || + test.origin != stored.Global.WebsocketOrigins[0] { t.Fatal("origins not preserved", err) } } diff --git a/mise.toml b/mise.toml index bb8b3e1..cbcff9d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,9 @@ [tools] air = "1.62.0" -bun = "latest" +bun = "1.4.4" go = "1.27.3" +golangci-lint = "2.15.0" +node = "22" [tasks."dev:server"] description = "Live reload: dcompose"