From 60561a99c6d3660b6576da92ceaaabf6914191f1 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 8 Nov 2026 20:40:21 +0300 Subject: [PATCH 1/7] chore: fix docs --- CONTRIBUTING.md | 22 ++++- README.md | 11 ++- compose.yaml | 4 +- install.sh | 249 +++++++++++++++++++++++++++++++++++++----------- installation.md | 20 +++- 5 files changed, 236 insertions(+), 70 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e95633..4717d7d 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,6 +22,14 @@ documentation will be rejected. Write tests that check behavior, not just increa ## Development setup +Branch from `dev`: + +```sh +git switch dev +git pull origin dev +git switch -c feature/descriptive-name +``` + You'll need Go 1.27.3+ and Bun 1.4+. Run commands from the repository root. Install frontend dependencies with: ```sh @@ -113,6 +121,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 +141,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..d9a5b2c 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). @@ -26,6 +29,8 @@ curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/master/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/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/install.sh b/install.sh index bebafe9..f76c9f2 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,45 @@ need() { command -v "$1" >/dev/null || fail "Install $1 and run the installer again." } +print_args() { + local argument + for argument in "$@"; do + if [[ '$TEMP_DIR' == "$TEMP_DIR" && $argument == "$TEMP_DIR"* ]]; then + printf '"$TEMP_DIR"%q ' "${argument#"$TEMP_DIR"}" + else + printf '%q ' "$argument" + fi + done +} + +run() { + if [[ true == $DRY_RUN ]]; then + print_args "$@" + printf '\n' + else + "$@" + fi +} + +write_file() { + local path=$1 + shift + if [[ true == $DRY_RUN ]]; then + printf "cat <<'DCOMPOSE_INSTALL_FILE' > " + print_args "$path" + printf '\n' + "$@" + printf 'DCOMPOSE_INSTALL_FILE\n' + else + "$@" >"$path" + fi +} + as_root() { if [[ $EUID == 0 ]]; then - "$@" + run "$@" else - sudo "$@" + run sudo "$@" fi } @@ -35,7 +70,7 @@ as_user() { local user=$1 shift if [[ $(id -un) == "$user" ]]; then - "$@" + run "$@" else as_root runuser -u "$user" -- "$@" fi @@ -47,10 +82,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 +93,18 @@ 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 [[ true == $DRY_RUN ]]; then + docker_cli info + else + docker_cli info >/dev/null + fi } ask() { @@ -89,6 +128,10 @@ choose() { } set_admin_password() { + if [[ true == $DRY_RUN ]]; then + "$@" + return + fi local retry until "$@" "$TEMP_DIR/agent.toml" + write_file "$TEMP_DIR/agent.toml" write_agent_config as_root install -m 0600 -o "$NODE_USER" -g "$NODE_GROUP" "$TEMP_DIR/agent.toml" "$NODE_CONFIG" fi if [[ $ROLE != node ]]; then if [[ $ROLE == both ]]; then FINGERPRINT=$(fingerprint) + if [[ true == $DRY_RUN ]]; then + printf 'FINGERPRINT=$(%s)\n' "$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" + write_file "$TEMP_DIR/dcompose.toml" write_server_config + if [[ true == $DRY_RUN && both == $ROLE ]]; then + printf '%s' 'sed -i "s|^fingerprint = \"\"$|fingerprint = \"$FINGERPRINT\"|" ' + print_args "$TEMP_DIR/dcompose.toml" + printf '\n' + fi as_root install -m 0600 -o "$SERVER_USER" -g "$SERVER_GROUP" "$TEMP_DIR/dcompose.toml" "$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 + if [[ true == $DRY_RUN ]]; then + printf 'if %s && [[ -n $(%s) ]]; then\n' "$(as_root test -d "$INSTALL_DIR")" "$(as_root find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit)" + run printf '%s\n' "$INSTALL_DIR isn't empty. Installation stopped." + run exit 1 + printf 'fi\n' + elif 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 if [[ $ENGINE == podman ]]; then prepare_podman_socket fi - if [[ -n $(docker_cli ps --all --filter label=com.docker.compose.project=dcompose --format '{{.ID}}') ]]; then + if [[ true == $DRY_RUN ]]; then + printf 'if [[ -n $(%s) ]]; then\n' "$(docker_cli ps --all --filter label=com.docker.compose.project=dcompose --format '{{.ID}}')" + run printf '%s\n' 'A dcompose Compose project already exists. Installation stopped.' + run exit 1 + printf 'fi\n' + elif [[ -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 [[ server != $ROLE ]]; then + if [[ true == $DRY_RUN ]]; then + printf 'if [[ -n $(%s) ]]; then\n' "$(docker_cli ps --all --filter label=DCOMPOSE_AGENT=true --format '{{.ID}}')" + run printf '%s\n' 'A DCompose node container already exists on this host. Installation stopped.' + run exit 1 + printf 'fi\n' + elif [[ -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 +425,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,7 +434,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" + write_file "$TEMP_DIR/compose.yaml" write_compose as_root install -m 0644 -o "$INSTALL_UID" -g "$INSTALL_GID" "$TEMP_DIR/compose.yaml" "$INSTALL_DIR/compose.yaml" if [[ $ROLE != node ]]; then as_root install -d -m 0700 -o "$SERVER_USER" -g "$SERVER_GROUP" "$SERVER_DIR" "$SERVER_DIR/secrets" @@ -346,6 +450,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 +471,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 +484,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 +508,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 +530,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 +549,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,10 +557,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" + write_file "$TEMP_DIR/dagent.service" write_podman_unit if [[ $PODMAN_MODE == rootless ]]; then - mkdir -p "$(dirname "$node_unit")" - install -m 0644 "$TEMP_DIR/dagent.service" "$node_unit" + run mkdir -p "$(dirname "$node_unit")" + run 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" fi @@ -460,9 +574,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 +588,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 +609,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 +624,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 +663,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 +675,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 +701,35 @@ 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='$TEMP_DIR' + printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ + 'TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dcompose-install.XXXXXX")' \ + "trap 'rm -rf -- \"\$TEMP_DIR\"' EXIT" 'umask 077' + 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 +737,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..3f9a15a 100644 --- a/installation.md +++ b/installation.md @@ -21,7 +21,15 @@ curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/master/install.sh bash install.sh ``` -Use `bash install.sh --help` to see its options. +The installer defaults to `--dry-run`: it prints the commands and generated files without installing anything. +Review the output, then run `bash install.sh --execute` to install. To save the commands for manual execution: + +```sh +bash install.sh --dry-run > install-commands.sh +``` + +Review `install-commands.sh` before running it with Bash. Prompts and warnings stay in the terminal. +Use `bash install.sh --help` to see all options. ## Choose the deployment @@ -31,15 +39,17 @@ Use `bash install.sh --help` to see its options. 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. +The installer creates these directories before starting containers. New Docker Compose installation and stack +directories belong to your account. Existing stack ownership isn't changed. -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 ``` -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 +77,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: From f204ee272e64d2f7691a833770df26f9508bd566 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 8 Nov 2026 20:48:21 +0300 Subject: [PATCH 2/7] chore: fix githooks --- .githooks/install | 2 +- .githooks/pre-commit | 10 +++++----- CONTRIBUTING.md | 5 +++-- README.md | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) 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..07105e9 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -99,7 +99,7 @@ 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 bun --cwd="$PWD/frontend/" run nuxt prepare --envName=development echo "Frontend preparation passed." >&2 fi @@ -110,10 +110,10 @@ if [ "$go_changed" -eq 1 ]; then 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" bun --cwd="$PWD/frontend/" run format:check + run_step "Frontend: Linter" bun --cwd="$PWD/frontend/" run lint + run_step "Frontend: Tests" bun --cwd="$PWD/frontend/" test + run_step "Frontend: TSC" bun --cwd="$PWD/frontend/" run lint:tsc fi extra_dir=$(git rev-parse --git-path hooks)/pre-commit.d diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4717d7d..c15e586 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,8 +48,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 diff --git a/README.md b/README.md index d9a5b2c..e74102f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ 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 ``` From 7ab8911f88280147bf76f2f5870b3cdef1e2ef18 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 8 Nov 2026 21:20:10 +0300 Subject: [PATCH 3/7] fix: installer for typical usage --- install.sh | 154 ++++++++++++++++-------------------------------- installation.md | 12 ++-- 2 files changed, 56 insertions(+), 110 deletions(-) diff --git a/install.sh b/install.sh index f76c9f2..c617ab1 100755 --- a/install.sh +++ b/install.sh @@ -24,20 +24,9 @@ need() { command -v "$1" >/dev/null || fail "Install $1 and run the installer again." } -print_args() { - local argument - for argument in "$@"; do - if [[ '$TEMP_DIR' == "$TEMP_DIR" && $argument == "$TEMP_DIR"* ]]; then - printf '"$TEMP_DIR"%q ' "${argument#"$TEMP_DIR"}" - else - printf '%q ' "$argument" - fi - done -} - run() { if [[ true == $DRY_RUN ]]; then - print_args "$@" + printf '%q ' "$@" printf '\n' else "$@" @@ -45,16 +34,19 @@ run() { } write_file() { - local path=$1 - shift + local path=$1 mode=$2 owner=$3 group=$4 + shift 4 if [[ true == $DRY_RUN ]]; then - printf "cat <<'DCOMPOSE_INSTALL_FILE' > " - print_args "$path" - printf '\n' + printf '\n# Save this content to %s:\n' "$path" "$@" - printf 'DCOMPOSE_INSTALL_FILE\n' + printf '\n' + as_root chmod "$mode" "$path" + as_root chown "$owner:$group" "$path" + printf '\n' else - "$@" >"$path" + local temporary=$TEMP_DIR/$(basename "$path") + "$@" >"$temporary" + as_root install -m "$mode" -o "$owner" -g "$group" "$temporary" "$path" fi } @@ -99,10 +91,8 @@ prepare_podman_socket() { 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." - if [[ true == $DRY_RUN ]]; then - docker_cli info - else + 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 } @@ -166,15 +156,11 @@ port() { } refuse_existing() { + if [[ true == $DRY_RUN ]]; then + return + fi local path for path in "$@"; do - if [[ true == $DRY_RUN ]]; then - printf 'if %s || %s; then\n' "$(as_root test -e "$path")" "$(as_root test -L "$path")" - run printf '%s\n' "$path already exists. Installation stopped." - run exit 1 - printf 'fi\n' - continue - fi if as_root test -e "$path" || as_root test -L "$path"; then fail "$path already exists. This installer doesn't replace existing installations." fi @@ -188,26 +174,20 @@ create_account() { } prepare_stacks() { - local access_user=$NODE_USER - if [[ compose == $METHOD && docker == $ENGINE ]]; then - # The Docker node runs as root even when the host directories belong to the installer user. - access_user=root - fi if [[ true == $DRY_RUN ]]; then - printf 'if ! %s; then\n' "$(as_root test -d "$STACKS_DIR")" - as_root install -d -m 0750 -o "$NODE_USER" -g "$NODE_GROUP" "$STACKS_DIR" - printf 'fi\n' - printf 'if ! %s || ! %s || ! %s; then\n' "$(as_user "$access_user" test -r "$STACKS_DIR")" "$(as_user "$access_user" test -w "$STACKS_DIR")" "$(as_user "$access_user" test -x "$STACKS_DIR")" - run printf '%s\n' "Give $access_user read/write access to $STACKS_DIR. Installation stopped." - run exit 1 - printf 'fi\n' + if [[ ! -d $STACKS_DIR ]]; then + as_root install -d -m 0750 -o "$NODE_USER" -g "$NODE_GROUP" "$STACKS_DIR" + fi return fi if ! as_root test -d "$STACKS_DIR"; then as_root install -d -m 0750 -o "$NODE_USER" -g "$NODE_GROUP" "$STACKS_DIR" fi - if ! as_user "$access_user" test -r "$STACKS_DIR" || ! as_user "$access_user" test -w "$STACKS_DIR" || ! as_user "$access_user" test -x "$STACKS_DIR"; then - fail "Give $access_user read/write access to $STACKS_DIR, then run the installer again. Existing file ownership wasn't changed." + if [[ compose == $METHOD && docker == $ENGINE ]]; then + return + fi + if ! as_user "$NODE_USER" test -r "$STACKS_DIR" || ! as_user "$NODE_USER" test -w "$STACKS_DIR" || ! as_user "$NODE_USER" test -x "$STACKS_DIR"; then + fail "Give $NODE_USER read/write access to $STACKS_DIR, then run the installer again. Existing file ownership wasn't changed." fi } @@ -292,20 +272,15 @@ EOF volumes: - ./config/dagent:/config - dagent_cache:/cache - - type: bind EOF - printf ' source: ' + printf ' - ' if [[ $ENGINE == podman ]]; then - yaml_string "$PODMAN_SOCKET" - printf '\n target: /run/podman/podman.sock\n' + yaml_string "$PODMAN_SOCKET:/run/podman/podman.sock" else - yaml_string "$DOCKER_SOCKET" - printf '\n target: /var/run/docker.sock\n' + yaml_string "$DOCKER_SOCKET:/var/run/docker.sock" fi - printf ' - type: bind\n source: ' - yaml_string "$STACKS_DIR" - printf '\n target: ' - yaml_string "$STACKS_DIR" + printf '\n - ' + yaml_string "$STACKS_DIR:$STACKS_DIR" printf '\n\nvolumes:\n dagent_cache:\n' fi } @@ -337,9 +312,9 @@ download_binary() { fail "Couldn't download $package. Choose a published release with --version." run unzip -q "$TEMP_DIR/$package.zip" -d "$TEMP_DIR" || fail "Couldn't unpack $package." if [[ true == $DRY_RUN ]]; then - printf '(cd ' - print_args "$TEMP_DIR/$package" - printf '&& sha256sum --check SHA256SUMS)\n' + run cd "$TEMP_DIR/$package" + run sha256sum --check SHA256SUMS + run cd .. else (cd "$TEMP_DIR/$package" && sha256sum --check SHA256SUMS) || fail "Checksum verification failed for $binary." fi @@ -347,7 +322,7 @@ download_binary() { fingerprint() { if [[ true == $DRY_RUN ]]; then - printf '%s | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | openssl base64 -A' "$(as_root openssl x509 -in "$NODE_DIR/node.crt" -pubkey -noout)" + printf '%s | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | openssl base64 -A\n' "$(as_root openssl x509 -in "$NODE_DIR/node.crt" -pubkey -noout)" return fi as_root openssl x509 -in "$NODE_DIR/node.crt" -pubkey -noout | @@ -358,55 +333,37 @@ fingerprint() { save_configs() { if [[ $ROLE != server ]]; then - write_file "$TEMP_DIR/agent.toml" write_agent_config - 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 'FINGERPRINT=$(%s)\n' "$FINGERPRINT" - FINGERPRINT= + 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_file "$TEMP_DIR/dcompose.toml" write_server_config - if [[ true == $DRY_RUN && both == $ROLE ]]; then - printf '%s' 'sed -i "s|^fingerprint = \"\"$|fingerprint = \"$FINGERPRINT\"|" ' - print_args "$TEMP_DIR/dcompose.toml" - printf '\n' - fi - 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 [[ true == $DRY_RUN ]]; then - printf 'if %s && [[ -n $(%s) ]]; then\n' "$(as_root test -d "$INSTALL_DIR")" "$(as_root find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit)" - run printf '%s\n' "$INSTALL_DIR isn't empty. Installation stopped." - run exit 1 - printf 'fi\n' - elif 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 [[ true == $DRY_RUN ]]; then - printf 'if [[ -n $(%s) ]]; then\n' "$(docker_cli ps --all --filter label=com.docker.compose.project=dcompose --format '{{.ID}}')" - run printf '%s\n' 'A dcompose Compose project already exists. Installation stopped.' - run exit 1 - printf 'fi\n' - elif [[ -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 ]]; then - if [[ true == $DRY_RUN ]]; then - printf 'if [[ -n $(%s) ]]; then\n' "$(docker_cli ps --all --filter label=DCOMPOSE_AGENT=true --format '{{.ID}}')" - run printf '%s\n' 'A DCompose node container already exists on this host. Installation stopped.' - run exit 1 - printf 'fi\n' - elif [[ -n $(docker_cli ps --all --filter label=DCOMPOSE_AGENT=true --format '{{.ID}}') ]]; then + 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 @@ -434,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_file "$TEMP_DIR/compose.yaml" write_compose - 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 @@ -557,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_file "$TEMP_DIR/dagent.service" write_podman_unit if [[ $PODMAN_MODE == rootless ]]; then run mkdir -p "$(dirname "$node_unit")" - run 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" fi + write_file "$node_unit" 0644 "$NODE_USER" "$NODE_GROUP" write_podman_unit fi fi save_configs @@ -718,10 +671,7 @@ main() { fi printf '\n' >&2 if [[ true == $DRY_RUN ]]; then - TEMP_DIR='$TEMP_DIR' - printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \ - 'TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dcompose-install.XXXXXX")' \ - "trap 'rm -rf -- \"\$TEMP_DIR\"' EXIT" 'umask 077' + TEMP_DIR=. else local confirm choose confirm 'Continue (yes/no)' no yes no diff --git a/installation.md b/installation.md index 3f9a15a..1d986ac 100644 --- a/installation.md +++ b/installation.md @@ -21,14 +21,12 @@ curl -fL https://raw.githubusercontent.com/arabcoders/dcompose/master/install.sh bash install.sh ``` -The installer defaults to `--dry-run`: it prints the commands and generated files without installing anything. -Review the output, then run `bash install.sh --execute` to install. To save the commands for manual execution: +Running without flags previews the installation. To install: ```sh -bash install.sh --dry-run > install-commands.sh +bash install.sh --execute ``` -Review `install-commands.sh` before running it with Bash. Prompts and warnings stay in the terminal. Use `bash install.sh --help` to see all options. ## Choose the deployment @@ -38,15 +36,13 @@ Use `bash install.sh --help` to see all 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. -The installer creates these directories before starting containers. New Docker Compose installation and stack -directories belong to your account. Existing stack ownership isn't changed. +Choose your stack and installation directories when prompted. 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. For Compose, use `--version dev` to install the development image. From f2e293fd02be3e1c65517d651dfe8659f9f44f75 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 8 Nov 2026 21:29:56 +0300 Subject: [PATCH 4/7] tests: fix flakey test --- internal/server/channel_test.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/server/channel_test.go b/internal/server/channel_test.go index e0ece8d..56cbc81 100644 --- a/internal/server/channel_test.go +++ b/internal/server/channel_test.go @@ -254,7 +254,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 +304,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,6 +323,8 @@ func TestShellChannel(t *testing.T) { if nil != err { t.Fatal(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) } @@ -335,9 +333,7 @@ func TestShellChannel(t *testing.T) { 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) - } + 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) From 87a42bf455f4b43a09b944cde166b33955ec54e0 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 9 Nov 2026 19:31:13 +0300 Subject: [PATCH 5/7] fix: update go-jose dependency and improve websocket tickets --- go.mod | 2 +- go.sum | 4 +- internal/server/event_tickets_test.go | 85 ++++++++++++++++++++++++++- internal/server/events.go | 44 +++++++------- internal/server/oidc.go | 34 ++++++++++- internal/server/oidc_test.go | 69 ++++++++++++++++++++++ internal/server/server.go | 1 + internal/server/tickets.go | 9 ++- internal/server/tickets_test.go | 12 ++++ 9 files changed, 229 insertions(+), 31 deletions(-) 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/internal/server/event_tickets_test.go b/internal/server/event_tickets_test.go index a3eb2b3..d3f5d67 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) } @@ -240,6 +248,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 diff --git a/internal/server/events.go b/internal/server/events.go index ca5bea5..1c23841 100644 --- a/internal/server/events.go +++ b/internal/server/events.go @@ -34,13 +34,14 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { return } defer conn.CloseNow() - ticketConnection := !nativeOrigin || !s.authenticated(r) - if ticketConnection { + 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 } } @@ -83,16 +84,8 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { }() 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 +95,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 } @@ -171,25 +161,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 { diff --git a/internal/server/oidc.go b/internal/server/oidc.go index 9780842..919f705 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() diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go index 8b41b48..9f22bf8 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,6 +42,7 @@ 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" @@ -238,6 +242,71 @@ 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) diff --git a/internal/server/server.go b/internal/server/server.go index 2c663cd..5f083cb 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 diff --git a/internal/server/tickets.go b/internal/server/tickets.go index c7de17e..ca58376 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,7 +44,7 @@ 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}) diff --git a/internal/server/tickets_test.go b/internal/server/tickets_test.go index 1116a81..2170608 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") } From ebde1f68c0d84922130c41331014a7a32c796064 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 9 Nov 2026 20:00:56 +0300 Subject: [PATCH 6/7] chore: lint fixes --- .githooks/pre-commit | 18 +++---- .github/workflows/build-pr.yml | 38 ++++++------- .github/workflows/main.yml | 53 +++++++++++++------ .golangci.yml | 15 ++++++ CONTRIBUTING.md | 8 +-- cmd/dcompose/main_test.go | 4 +- cmd/dcompose/password.go | 8 +-- cmd/dcompose/password_test.go | 10 ++-- internal/agent/agent.go | 20 +++---- internal/agent/auth.go | 2 +- internal/agent/auth_test.go | 3 +- internal/agent/auto_update_logging_test.go | 4 +- internal/agent/channel.go | 21 +++----- internal/agent/channel_test.go | 8 +-- internal/agent/compose.go | 6 +-- internal/agent/compose_test.go | 2 +- internal/agent/config.go | 6 +-- internal/agent/detached.go | 10 ++-- internal/agent/detached_test.go | 16 +++--- internal/agent/events.go | 10 ++-- internal/agent/events_test.go | 4 +- internal/agent/image_actions_test.go | 2 +- internal/agent/logs.go | 6 +-- internal/agent/node_logs_test.go | 6 +-- internal/agent/operations.go | 18 ++----- internal/agent/operations_logging_test.go | 4 +- internal/agent/process.go | 4 +- internal/agent/registry.go | 2 +- internal/agent/registry_transport.go | 5 +- internal/agent/shells.go | 8 +-- internal/agent/shells_test.go | 10 ++-- internal/agent/source_messages_test.go | 2 +- internal/configutil/config.go | 8 +-- .../engine/compose/container_info_test.go | 6 +-- internal/engine/docker/images.go | 2 +- internal/engine/docker/info_test.go | 3 +- internal/engine/podman/images.go | 4 +- internal/engine/podman/info_test.go | 3 +- internal/engine/registry/auth.go | 2 +- internal/engine/registry/manifest.go | 5 +- internal/engine/registry/registry.go | 10 ++-- internal/engine/registry/registry_test.go | 22 ++++---- internal/logging/console_test.go | 6 +-- internal/server/api_key_test.go | 6 +-- internal/server/backup.go | 4 +- internal/server/backup_test.go | 6 +-- internal/server/channel.go | 45 ++++++---------- internal/server/channel_test.go | 4 +- internal/server/config.go | 4 +- internal/server/events.go | 16 +++--- internal/server/events_test.go | 2 +- internal/server/images.go | 19 +++---- internal/server/images_test.go | 18 +++---- internal/server/log_history.go | 8 +-- internal/server/log_history_test.go | 6 +-- internal/server/logs.go | 6 +-- internal/server/logs_test.go | 8 +-- internal/server/node_logs.go | 4 +- internal/server/oidc_test.go | 6 +-- internal/server/pairing.go | 4 +- internal/server/proxy.go | 15 +++--- internal/server/registry.go | 2 +- internal/server/registry_channel_test.go | 4 +- internal/server/registry_inspect_test.go | 12 ++--- internal/server/registry_test.go | 4 +- internal/server/registry_transport.go | 4 +- internal/server/registry_transport_test.go | 2 +- internal/server/relay.go | 6 +-- internal/server/relay_test.go | 10 ++-- internal/server/server.go | 16 +++--- internal/server/server_test.go | 8 +-- internal/server/settings.go | 8 ++- internal/server/settings_test.go | 6 +-- internal/server/tickets.go | 2 +- internal/server/tickets_test.go | 6 +-- mise.toml | 4 +- 76 files changed, 324 insertions(+), 355 deletions(-) create mode 100644 .golangci.yml diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 07105e9..3fc832e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -66,7 +66,7 @@ run_extra_dir() { check_go_format() { local files - files=$(gofmt -l cmd internal) || return 1 + files=$(mise exec -- gofmt -l cmd internal) || return 1 if [ -n "$files" ]; then printf '%s\n' "$files" >&2 return 1 @@ -80,7 +80,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 +99,21 @@ EOF if [ "$frontend_changed" -eq 1 ]; then echo "Preparing frontend..." >&2 - NODE_ENV=development bun --cwd="$PWD/frontend/" run nuxt prepare --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: 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/" run format:check - run_step "Frontend: Linter" bun --cwd="$PWD/frontend/" run lint - run_step "Frontend: Tests" bun --cwd="$PWD/frontend/" test - run_step "Frontend: TSC" bun --cwd="$PWD/frontend/" run 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..f4dd9db --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,15 @@ +version: '2' + +linters: + default: none + enable: + - govet + - staticcheck + - ineffassign + - unused + - errcheck + - modernize + settings: + staticcheck: + checks: + - SA* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c15e586..8712501 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,9 @@ git pull origin dev git switch -c feature/descriptive-name ``` -You'll need Go 1.27.3+ and Bun 1.4+. Run commands from the repository root. Install frontend dependencies with: +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 @@ -66,8 +68,8 @@ bun --cwd=$PWD/frontend/ format:check For Go changes: ```sh -go test -race ./cmd/... ./internal/... -go vet ./cmd/... ./internal/... +mise exec -- go test -race ./cmd/... ./internal/... +mise exec -- golangci-lint run ./cmd/... ./internal/... ``` ## Local hot reload diff --git a/cmd/dcompose/main_test.go b/cmd/dcompose/main_test.go index 37edd18..696f486 100644 --- a/cmd/dcompose/main_test.go +++ b/cmd/dcompose/main_test.go @@ -45,7 +45,7 @@ func TestLogLevel(t *testing.T) { 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 +94,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/internal/agent/agent.go b/internal/agent/agent.go index 09bf0a2..800633e 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 } @@ -109,8 +107,8 @@ func respondError(w http.ResponseWriter, r *http.Request, err 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 { @@ -282,15 +280,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/auth.go b/internal/agent/auth.go index 6be9485..843df5a 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 } diff --git a/internal/agent/auth_test.go b/internal/agent/auth_test.go index 4cfeee4..afc0b12 100644 --- a/internal/agent/auth_test.go +++ b/internal/agent/auth_test.go @@ -27,7 +27,8 @@ 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) { diff --git a/internal/agent/auto_update_logging_test.go b/internal/agent/auto_update_logging_test.go index afe294a..c79f103 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 } @@ -243,7 +243,7 @@ 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) } diff --git a/internal/agent/channel.go b/internal/agent/channel.go index 2958bb1..2c007d4 100644 --- a/internal/agent/channel.go +++ b/internal/agent/channel.go @@ -181,9 +181,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 @@ -227,7 +225,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha } } } - }() + }) } for { select { @@ -300,9 +298,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 +309,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 +340,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(): @@ -374,7 +367,7 @@ func (a *agent) channelCommands(ctx context.Context, peer string, commands <-cha 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..c4a7115 100644 --- a/internal/agent/channel_test.go +++ b/internal/agent/channel_test.go @@ -363,14 +363,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 +399,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") 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..93e4413 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) diff --git a/internal/agent/config.go b/internal/agent/config.go index 7ebb796..304f53e 100644 --- a/internal/agent/config.go +++ b/internal/agent/config.go @@ -148,13 +148,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 diff --git a/internal/agent/detached.go b/internal/agent/detached.go index 76fdb69..8d15b2c 100644 --- a/internal/agent/detached.go +++ b/internal/agent/detached.go @@ -85,7 +85,7 @@ func (a *agent) initDetached(cacheDir string) { } 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)) @@ -109,11 +109,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) - }() + }) } } @@ -369,7 +367,7 @@ 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..1965030 100644 --- a/internal/agent/detached_test.go +++ b/internal/agent/detached_test.go @@ -63,7 +63,7 @@ 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"}} + 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) } @@ -124,7 +124,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) { @@ -179,7 +179,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 @@ -323,7 +323,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"` @@ -532,7 +532,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 +554,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) } @@ -596,7 +596,7 @@ func TestRunOperationPodman(t *testing.T) { 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) } @@ -611,7 +611,7 @@ func TestRunOperationPodman(t *testing.T) { 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/events.go b/internal/agent/events.go index 39ee933..4f40421 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) diff --git a/internal/agent/events_test.go b/internal/agent/events_test.go index a5f2775..d972f78 100644 --- a/internal/agent/events_test.go +++ b/internal/agent/events_test.go @@ -190,7 +190,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) } @@ -466,7 +466,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..4e20f07 100644 --- a/internal/agent/image_actions_test.go +++ b/internal/agent/image_actions_test.go @@ -44,7 +44,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) } 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..deb1bbc 100644 --- a/internal/agent/node_logs_test.go +++ b/internal/agent/node_logs_test.go @@ -92,13 +92,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..cf0af49 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,11 +157,9 @@ 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 @@ -206,14 +205,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 +230,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 } diff --git a/internal/agent/operations_logging_test.go b/internal/agent/operations_logging_test.go index c2885e9..89f4b6e 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 } @@ -170,7 +170,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/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_transport.go b/internal/agent/registry_transport.go index d3fe54c..90b5582 100644 --- a/internal/agent/registry_transport.go +++ b/internal/agent/registry_transport.go @@ -55,7 +55,7 @@ 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 @@ -72,8 +72,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/shells.go b/internal/agent/shells.go index 13b3300..acb1a11 100644 --- a/internal/agent/shells.go +++ b/internal/agent/shells.go @@ -94,7 +94,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{}), } @@ -295,8 +295,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 +398,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..2595e32 100644 --- a/internal/agent/shells_test.go +++ b/internal/agent/shells_test.go @@ -300,7 +300,7 @@ func TestShellNaturalExit(t *testing.T) { } <-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 +332,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 +354,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))) diff --git a/internal/agent/source_messages_test.go b/internal/agent/source_messages_test.go index 6a5a4ec..9d7eaa1 100644 --- a/internal/agent/source_messages_test.go +++ b/internal/agent/source_messages_test.go @@ -84,7 +84,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") } 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/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/docker/images.go b/internal/engine/docker/images.go index 6ec17a4..dee5194 100644 --- a/internal/engine/docker/images.go +++ b/internal/engine/docker/images.go @@ -114,7 +114,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/info_test.go b/internal/engine/docker/info_test.go index b363754..8ebda47 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) { diff --git a/internal/engine/podman/images.go b/internal/engine/podman/images.go index 47aca4d..27ac709 100644 --- a/internal/engine/podman/images.go +++ b/internal/engine/podman/images.go @@ -145,8 +145,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/info_test.go b/internal/engine/podman/info_test.go index 459ad53..85993b6 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) { diff --git a/internal/engine/registry/auth.go b/internal/engine/registry/auth.go index d9c1f07..98cfb94 100644 --- a/internal/engine/registry/auth.go +++ b/internal/engine/registry/auth.go @@ -273,7 +273,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/manifest.go b/internal/engine/registry/manifest.go index ab1492b..15d288a 100644 --- a/internal/engine/registry/manifest.go +++ b/internal/engine/registry/manifest.go @@ -169,8 +169,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 +205,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..ab5bbc6 100644 --- a/internal/engine/registry/registry.go +++ b/internal/engine/registry/registry.go @@ -57,8 +57,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() @@ -107,7 +106,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 +174,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 +201,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) diff --git a/internal/engine/registry/registry_test.go b/internal/engine/registry/registry_test.go index 3700cff..af7f30e 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 { @@ -84,7 +84,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) @@ -172,7 +172,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) @@ -391,7 +391,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 @@ -583,7 +583,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" @@ -787,7 +787,7 @@ 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 { t.Fatalf("missing cooldown: %+v", got) @@ -1047,18 +1047,16 @@ 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() } diff --git a/internal/logging/console_test.go b/internal/logging/console_test.go index 73bb925..3ccf0c2 100644 --- a/internal/logging/console_test.go +++ b/internal/logging/console_test.go @@ -71,9 +71,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 +80,7 @@ func TestConsoleConcurrent(t *testing.T) { t.Error(err) } } - }() + }) } workers.Wait() lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") diff --git a/internal/server/api_key_test.go b/internal/server/api_key_test.go index b1caa32..cb4fc54 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") { @@ -306,7 +306,7 @@ func TestAPIKeyWebsocket(t *testing.T) { } 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..4609c71 100644 --- a/internal/server/backup.go +++ b/internal/server/backup.go @@ -128,12 +128,12 @@ 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 { + 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 { diff --git a/internal/server/backup_test.go b/internal/server/backup_test.go index acd259e..34c36d2 100644 --- a/internal/server/backup_test.go +++ b/internal/server/backup_test.go @@ -44,7 +44,7 @@ func TestBackupExport(t *testing.T) { {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 +64,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 +75,7 @@ func TestBackupExport(t *testing.T) { t.Fatal(err) } data, err := io.ReadAll(reader) - reader.Close() + _ = reader.Close() if nil != err { t.Fatal(err) } diff --git a/internal/server/channel.go b/internal/server/channel.go index 6150713..68216c2 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. @@ -111,8 +110,7 @@ func (s *Server) reconcileChannelsLocked(config Config, clients map[string]*http 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,11 +191,9 @@ 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)) @@ -246,8 +242,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) @@ -305,12 +300,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 +328,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) { diff --git a/internal/server/channel_test.go b/internal/server/channel_test.go index 56cbc81..9584184 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,7 +75,7 @@ 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) { diff --git a/internal/server/config.go b/internal/server/config.go index 1ba4cab..a30e48c 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -354,9 +354,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/events.go b/internal/server/events.go index 1c23841..8b6a761 100644 --- a/internal/server/events.go +++ b/internal/server/events.go @@ -33,7 +33,7 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() authRequest := r if !nativeOrigin || !s.authenticated(r) { if !allowTicket { @@ -66,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 @@ -81,7 +79,7 @@ func (s *Server) workspaceEvents(w http.ResponseWriter, r *http.Request) { return } } - }() + }) stopHeartbeat := socketHeartbeat(ctx, conn, "workspace browser at "+r.RemoteAddr) defer stopHeartbeat() authCheck := time.NewTicker(15 * time.Second) @@ -123,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) @@ -374,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 diff --git a/internal/server/events_test.go b/internal/server/events_test.go index 7e34f1f..4d67076 100644 --- a/internal/server/events_test.go +++ b/internal/server/events_test.go @@ -240,7 +240,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) diff --git a/internal/server/images.go b/internal/server/images.go index 2612e5f..126d4b1 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" @@ -201,9 +202,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 @@ -376,9 +375,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 +384,7 @@ func (s *Server) watchImages() { } s.hubMu.Unlock() } - }() + }) } defer func() { close(jobs); workers.Wait() }() tick := time.NewTicker(100 * time.Millisecond) @@ -415,7 +412,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 +425,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 +451,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) diff --git a/internal/server/images_test.go b/internal/server/images_test.go index ccf7158..7f9381a 100644 --- a/internal/server/images_test.go +++ b/internal/server/images_test.go @@ -249,7 +249,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 +337,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 +400,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 +475,8 @@ 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") @@ -539,7 +539,7 @@ func TestImageNodeFailure(t *testing.T) { 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 +554,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 @@ -582,7 +582,7 @@ func TestImageFleetCooldown(t *testing.T) { 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..4d49c36 100644 --- a/internal/server/log_history.go +++ b/internal/server/log_history.go @@ -350,8 +350,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 +387,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..fdd8444 100644 --- a/internal/server/log_history_test.go +++ b/internal/server/log_history_test.go @@ -294,14 +294,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) diff --git a/internal/server/logs.go b/internal/server/logs.go index 4ab7f54..27b0d16 100644 --- a/internal/server/logs.go +++ b/internal/server/logs.go @@ -46,7 +46,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 +113,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..c38f7b3 100644 --- a/internal/server/logs_test.go +++ b/internal/server/logs_test.go @@ -108,11 +108,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 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/oidc_test.go b/internal/server/oidc_test.go index 9f22bf8..2a03427 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -47,7 +47,7 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { if "discovery" == f.mode { issuer += "/wrong" } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + _ = 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": @@ -69,7 +69,7 @@ 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" @@ -117,7 +117,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") } diff --git a/internal/server/pairing.go b/internal/server/pairing.go index ee1f05e..3690f2e 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") } @@ -131,7 +131,7 @@ func (s *Server) pairNode(w http.ResponseWriter, r *http.Request) { 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()) diff --git a/internal/server/proxy.go b/internal/server/proxy.go index 942d4b1..0aeee29 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 { @@ -213,7 +213,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 { @@ -225,7 +225,7 @@ func (s *Server) proxy(w http.ResponseWriter, r *http.Request, route string) { 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) @@ -422,7 +422,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{}{} @@ -544,8 +544,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..646dc9b 100644 --- a/internal/server/registry.go +++ b/internal/server/registry.go @@ -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) diff --git a/internal/server/registry_channel_test.go b/internal/server/registry_channel_test.go index 1560593..8e1242d 100644 --- a/internal/server/registry_channel_test.go +++ b/internal/server/registry_channel_test.go @@ -58,7 +58,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,7 +69,7 @@ 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":[]}`)}) { diff --git a/internal/server/registry_inspect_test.go b/internal/server/registry_inspect_test.go index d6e3183..8cded3c 100644 --- a/internal/server/registry_inspect_test.go +++ b/internal/server/registry_inspect_test.go @@ -62,8 +62,8 @@ 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}) + 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: @@ -100,7 +100,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 +129,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 +137,7 @@ 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) @@ -199,7 +199,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..d489c71 100644 --- a/internal/server/registry_test.go +++ b/internal/server/registry_test.go @@ -42,7 +42,7 @@ 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"}}}}) + 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 { @@ -88,7 +88,7 @@ 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 { 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..564c972 100644 --- a/internal/server/registry_transport_test.go +++ b/internal/server/registry_transport_test.go @@ -59,7 +59,7 @@ func TestRegistryTransport(t *testing.T) { 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 { t.Fatalf("registry response: %+v", response) diff --git a/internal/server/relay.go b/internal/server/relay.go index 10b3ecc..ab5dfbf 100644 --- a/internal/server/relay.go +++ b/internal/server/relay.go @@ -63,16 +63,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) { diff --git a/internal/server/relay_test.go b/internal/server/relay_test.go index d009dab..c0ecea0 100644 --- a/internal/server/relay_test.go +++ b/internal/server/relay_test.go @@ -76,7 +76,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 { @@ -105,7 +105,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 +113,7 @@ 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,12 +127,12 @@ 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 { diff --git a/internal/server/server.go b/internal/server/server.go index 5f083cb..d00904a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,10 +79,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) @@ -103,7 +103,7 @@ 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 { @@ -149,7 +149,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: @@ -258,7 +258,7 @@ func sessionToken(auth AuthConfig, expires time.Time) (string, error) { return "", err } return jwt.Signed(signer).Claims(sessionClaims{ - Claims: jwt.Claims{Expiry: jwt.NewNumericDate(expires)}, + Expiry: jwt.NewNumericDate(expires), CredentialRevision: credentialRevision(auth), }).Serialize() } @@ -284,14 +284,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { 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 } @@ -312,7 +312,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..8d06116 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -360,14 +360,14 @@ 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 } @@ -424,7 +424,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,7 +436,7 @@ func TestReconnect(t *testing.T) { if nil != err { return } - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() if !ackRegistrySync(t, r.Context(), conn) { return } diff --git a/internal/server/settings.go b/internal/server/settings.go index 10af8f7..c1b6a14 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -34,7 +34,7 @@ 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} } -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])) @@ -118,8 +118,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) @@ -352,10 +351,9 @@ 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) { + 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..92f23dc 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -576,9 +576,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,7 +584,7 @@ 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 { diff --git a/internal/server/tickets.go b/internal/server/tickets.go index ca58376..5e44725 100644 --- a/internal/server/tickets.go +++ b/internal/server/tickets.go @@ -47,7 +47,7 @@ func (s *Server) createWebsocketTicket(w http.ResponseWriter, r *http.Request) { 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) { diff --git a/internal/server/tickets_test.go b/internal/server/tickets_test.go index 2170608..9693cfc 100644 --- a/internal/server/tickets_test.go +++ b/internal/server/tickets_test.go @@ -166,14 +166,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() 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" From 0bfc6d9070ddd8e1db94af46cf98dceef706bf05 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 9 Nov 2026 20:06:23 +0300 Subject: [PATCH 7/7] chore: fix golines --- .githooks/pre-commit | 11 +- .golangci.yml | 8 + CONTRIBUTING.md | 3 + cmd/dagent/main.go | 23 +- cmd/dagent/main_test.go | 9 +- cmd/dcompose/main.go | 38 +++- cmd/dcompose/main_test.go | 8 +- internal/agent/agent.go | 33 ++- internal/agent/agent_test.go | 19 +- internal/agent/auth.go | 48 +++- internal/agent/auth_test.go | 18 +- internal/agent/auto_update_logging_test.go | 30 ++- internal/agent/backup_test.go | 6 +- internal/agent/cert.go | 6 +- internal/agent/channel.go | 143 ++++++++++-- internal/agent/channel_test.go | 33 ++- internal/agent/compose_test.go | 23 +- internal/agent/config.go | 11 +- internal/agent/config_test.go | 58 ++++- internal/agent/create_test.go | 9 +- internal/agent/detached.go | 189 ++++++++++++++-- internal/agent/detached_test.go | 39 +++- internal/agent/engine_test.go | 30 ++- internal/agent/errors_test.go | 15 +- internal/agent/events.go | 93 +++++++- internal/agent/events_test.go | 19 +- internal/agent/image_actions_test.go | 16 +- internal/agent/images_test.go | 57 ++++- internal/agent/live_actions_test.go | 6 +- internal/agent/node_logs_test.go | 23 +- internal/agent/operations.go | 59 ++++- internal/agent/operations_logging_test.go | 5 +- internal/agent/pairing.go | 27 ++- internal/agent/pairing_test.go | 36 ++- internal/agent/registry_test.go | 49 +++- internal/agent/registry_transport.go | 5 +- internal/agent/registry_transport_test.go | 43 +++- internal/agent/run.go | 32 ++- internal/agent/shells.go | 246 +++++++++++++++++++-- internal/agent/shells_test.go | 18 +- internal/agent/source_messages_test.go | 6 +- internal/engine/compose/compose.go | 29 ++- internal/engine/compose/compose_test.go | 21 +- internal/engine/compose/labels_test.go | 6 +- internal/engine/detached.go | 6 +- internal/engine/docker/detached.go | 48 +++- internal/engine/docker/detached_test.go | 55 ++++- internal/engine/docker/docker.go | 13 +- internal/engine/docker/docker_test.go | 31 ++- internal/engine/docker/images.go | 134 ++++++++++- internal/engine/docker/images_test.go | 14 +- internal/engine/docker/info.go | 22 +- internal/engine/docker/info_test.go | 7 +- internal/engine/docker/shells.go | 30 ++- internal/engine/docker/shells_test.go | 4 +- internal/engine/podman/detached_test.go | 29 ++- internal/engine/podman/images.go | 147 +++++++++++- internal/engine/podman/images_test.go | 24 +- internal/engine/podman/info.go | 27 ++- internal/engine/podman/info_test.go | 13 +- internal/engine/podman/podman.go | 3 +- internal/engine/podman/podman_test.go | 14 +- internal/engine/podman/self.go | 51 ++++- internal/engine/podman/self_test.go | 3 +- internal/engine/podman/shells.go | 30 ++- internal/engine/podman/shells_test.go | 23 +- internal/engine/registry/auth.go | 12 +- internal/engine/registry/cooldown.go | 28 ++- internal/engine/registry/manifest.go | 3 +- internal/engine/registry/registry.go | 26 ++- internal/engine/registry/registry_test.go | 109 +++++++-- internal/engine/registry/root.go | 5 +- internal/logging/console_test.go | 26 ++- internal/server/api_key_test.go | 6 +- internal/server/backup.go | 26 ++- internal/server/backup_test.go | 68 +++++- internal/server/channel.go | 47 +++- internal/server/channel_test.go | 36 ++- internal/server/config.go | 32 ++- internal/server/create_test.go | 17 +- internal/server/emitter_logging_test.go | 52 ++++- internal/server/event_tickets_test.go | 16 +- internal/server/events.go | 3 +- internal/server/events_test.go | 90 +++++++- internal/server/images.go | 92 ++++++-- internal/server/images_test.go | 44 +++- internal/server/log_history.go | 26 ++- internal/server/log_history_test.go | 66 ++++-- internal/server/logging_test.go | 12 +- internal/server/logs.go | 8 +- internal/server/logs_test.go | 10 +- internal/server/node_logs_test.go | 26 ++- internal/server/node_order_test.go | 35 ++- internal/server/oidc.go | 41 +++- internal/server/oidc_test.go | 85 +++++-- internal/server/pairing.go | 35 ++- internal/server/pairing_test.go | 78 +++++-- internal/server/pause_test.go | 30 ++- internal/server/proxy.go | 73 +++++- internal/server/registry.go | 45 +++- internal/server/registry_channel_test.go | 53 ++++- internal/server/registry_inspect_test.go | 56 ++++- internal/server/registry_test.go | 128 +++++++++-- internal/server/registry_transport_test.go | 49 +++- internal/server/relay.go | 12 +- internal/server/relay_test.go | 44 +++- internal/server/server.go | 54 ++++- internal/server/server_test.go | 64 +++++- internal/server/settings.go | 47 +++- internal/server/settings_test.go | 95 ++++++-- internal/server/setup.go | 10 +- internal/server/setup_test.go | 21 +- internal/server/shell.go | 18 +- internal/server/stack_actions_test.go | 20 +- internal/server/stream.go | 46 +++- internal/server/tickets.go | 5 +- internal/server/tickets_test.go | 10 +- 117 files changed, 3748 insertions(+), 626 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3fc832e..c37fc1b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -64,15 +64,6 @@ run_extra_dir() { done } -check_go_format() { - local files - files=$(mise exec -- 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 @@ -104,7 +95,7 @@ if [ "$frontend_changed" -eq 1 ]; then fi if [ "$go_changed" -eq 1 ]; then - run_step "Go: Formatter" check_go_format + 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 diff --git a/.golangci.yml b/.golangci.yml index f4dd9db..b9e9fa9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,13 @@ version: '2' +formatters: + enable: + - gofmt + - golines + settings: + golines: + max-len: 140 + linters: default: none enable: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8712501..6c37222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,10 +68,13 @@ bun --cwd=$PWD/frontend/ format:check For Go changes: ```sh +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: 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 696f486..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,7 +42,8 @@ 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 ") { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 800633e..47ea563 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -100,7 +100,21 @@ 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"` @@ -112,7 +126,17 @@ func decodeBody(r *http.Request, target any) error { 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 @@ -176,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 } 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 843df5a..0f1982f 100644 --- a/internal/agent/auth.go +++ b/internal/agent/auth.go @@ -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 afc0b12..0ca262e 100644 --- a/internal/agent/auth_test.go +++ b/internal/agent/auth_test.go @@ -34,10 +34,16 @@ func TestHostAuth(t *testing.T) { 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() @@ -154,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 @@ -169,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 c79f103..37c9acd 100644 --- a/internal/agent/auto_update_logging_test.go +++ b/internal/agent/auto_update_logging_test.go @@ -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{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 2c007d4..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 @@ -216,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: @@ -270,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 { @@ -360,8 +452,33 @@ 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()) diff --git a/internal/agent/channel_test.go b/internal/agent/channel_test.go index c4a7115..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()) } }) @@ -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_test.go b/internal/agent/compose_test.go index 93e4413..1f7600c 100644 --- a/internal/agent/compose_test.go +++ b/internal/agent/compose_test.go @@ -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 304f53e..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 @@ -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 8d15b2c..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{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) @@ -125,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 { @@ -141,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 { @@ -151,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) } @@ -184,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 @@ -219,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")) @@ -234,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() } } @@ -245,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 { @@ -253,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 { @@ -278,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 @@ -299,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() @@ -316,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) } @@ -367,7 +513,14 @@ func RunOperation(ctx context.Context, requestFile string) error { if err := a.initRegistries(request.ConfigDir); nil != err { return err } - op := &operation{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 1965030..5600c0a 100644 --- a/internal/agent/detached_test.go +++ b/internal/agent/detached_test.go @@ -64,7 +64,11 @@ func detachedFixture(t *testing.T) (*agent, *detachedDriver, context.CancelFunc) func detachedOp(t *testing.T, a *agent, id string) *operation { t.Helper() 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 { + 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 @@ -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:] @@ -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) } }) @@ -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) } } @@ -589,7 +604,14 @@ 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 { @@ -604,7 +626,8 @@ 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")) 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 4f40421..7d6e4d7 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -339,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 @@ -375,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() } @@ -399,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 d972f78..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") @@ -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) } } diff --git a/internal/agent/image_actions_test.go b/internal/agent/image_actions_test.go index 4e20f07..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) @@ -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/node_logs_test.go b/internal/agent/node_logs_test.go index deb1bbc..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()) } } diff --git a/internal/agent/operations.go b/internal/agent/operations.go index cf0af49..7c160ff 100644 --- a/internal/agent/operations.go +++ b/internal/agent/operations.go @@ -166,7 +166,25 @@ func (a *agent) startOperation(ctx context.Context, w http.ResponseWriter, name, 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": @@ -283,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) }() @@ -365,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)) } @@ -400,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() { @@ -430,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 89f4b6e..3de2acb 100644 --- a/internal/agent/operations_logging_test.go +++ b/internal/agent/operations_logging_test.go @@ -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} { 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/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 90b5582..a7d549c 100644 --- a/internal/agent/registry_transport.go +++ b/internal/agent/registry_transport.go @@ -62,7 +62,10 @@ func relayRegistry(ctx context.Context, input protocol.RegistryRequest, transpor } 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.") 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 acb1a11..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 } @@ -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() diff --git a/internal/agent/shells_test.go b/internal/agent/shells_test.go index 2595e32..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,7 +303,8 @@ 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 @@ -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 9d7eaa1..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"} { @@ -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/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/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 dee5194..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. 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 8ebda47..e69a856 100644 --- a/internal/engine/docker/info_test.go +++ b/internal/engine/docker/info_test.go @@ -112,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 { @@ -126,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 27ac709..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 } 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 85993b6..05d0cf9 100644 --- a/internal/engine/podman/info_test.go +++ b/internal/engine/podman/info_test.go @@ -51,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) } } @@ -132,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 { @@ -146,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 98cfb94..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 { 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 15d288a..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 } diff --git a/internal/engine/registry/registry.go b/internal/engine/registry/registry.go index ab5bbc6..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 { @@ -74,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 } @@ -244,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 af7f30e..2929554 100644 --- a/internal/engine/registry/registry_test.go +++ b/internal/engine/registry/registry_test.go @@ -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) { @@ -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) { @@ -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) { @@ -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 { @@ -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) @@ -789,7 +848,11 @@ func TestCooldownAccounts(t *testing.T) { client.Now = func() time.Time { return now } 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() @@ -1062,7 +1135,11 @@ func TestConcurrent(t *testing.T) { } 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 3ccf0c2..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()) @@ -93,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 cb4fc54..249a934 100644 --- a/internal/server/api_key_test.go +++ b/internal/server/api_key_test.go @@ -304,7 +304,11 @@ 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 func() { _ = conn.CloseNow() }() } diff --git a/internal/server/backup.go b/internal/server/backup.go index 4609c71..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 } @@ -134,7 +150,11 @@ func collectNodeBackup(ctx context.Context, node NodeConfig, client *http.Client return backup, nodeHTTPError{operation: "read backup files", status: response.StatusCode, message: err.Error()} } 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)} + 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 34c36d2..1e4b4a4 100644 --- a/internal/server/backup_test.go +++ b/internal/server/backup_test.go @@ -40,8 +40,28 @@ 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]any{"nodes": nodes, "password": "backup-password"}) @@ -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 68216c2..bc5ac87 100644 --- a/internal/server/channel.go +++ b/internal/server/channel.go @@ -106,7 +106,15 @@ 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.") @@ -196,7 +204,11 @@ func (s *Server) runChannel(channel *nodeChannel) { }) 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 @@ -253,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 { @@ -407,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 9584184..bae1720 100644 --- a/internal/server/channel_test.go +++ b/internal/server/channel_test.go @@ -82,7 +82,11 @@ func fakeChannel(t *testing.T) *channelFixture { 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 @@ -325,14 +329,23 @@ func TestShellChannel(t *testing.T) { } 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 { + 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"}`)} + 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") { @@ -410,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" @@ -443,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()} { @@ -451,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): @@ -605,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 a30e48c..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) } } 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 d3f5d67..a289617 100644 --- a/internal/server/event_tickets_test.go +++ b/internal/server/event_tickets_test.go @@ -79,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() { @@ -114,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{ @@ -345,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 8b6a761..7a43ee3 100644 --- a/internal/server/events.go +++ b/internal/server/events.go @@ -394,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 4d67076..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 { @@ -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 126d4b1..a69d2dc 100644 --- a/internal/server/images.go +++ b/internal/server/images.go @@ -95,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] @@ -305,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 { @@ -338,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]) { @@ -353,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 @@ -464,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 } @@ -485,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 { @@ -522,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 } @@ -539,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) @@ -652,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] @@ -667,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) } @@ -724,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.") @@ -833,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 } } @@ -848,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 @@ -887,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 7f9381a..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 { @@ -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]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) + 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,7 +553,12 @@ 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{}}) @@ -578,7 +601,12 @@ 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{}}) diff --git a/internal/server/log_history.go b/internal/server/log_history.go index 4d49c36..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 diff --git a/internal/server/log_history_test.go b/internal/server/log_history_test.go index fdd8444..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) } @@ -316,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]-- @@ -325,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) } @@ -337,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{}) @@ -360,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 27b0d16..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": diff --git a/internal/server/logs_test.go b/internal/server/logs_test.go index c38f7b3..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()) } } @@ -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_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 919f705..707e327 100644 --- a/internal/server/oidc.go +++ b/internal/server/oidc.go @@ -81,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) { @@ -91,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 } @@ -129,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 2a03427..f0687c9 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -48,10 +48,15 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { issuer += "/wrong" } _ = 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"}, + "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) @@ -60,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 @@ -69,7 +78,14 @@ func newFakeOIDC(t *testing.T) *fakeOIDC { http.Error(w, "provider error", 400) return } - 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} + 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" @@ -101,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 @@ -141,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") @@ -158,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 @@ -215,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) @@ -245,7 +275,12 @@ 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"} + 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 @@ -266,11 +301,17 @@ func TestOIDCCache(t *testing.T) { 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"} + 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 { + 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} { @@ -287,7 +328,12 @@ func TestOIDCLoginValues(t *testing.T) { 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"} + 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) @@ -313,7 +359,12 @@ func TestOIDCConfig(t *testing.T) { 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) } @@ -347,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 = "", "", "" @@ -358,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 3690f2e..8488c0e 100644 --- a/internal/server/pairing.go +++ b/internal/server/pairing.go @@ -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,7 +128,11 @@ 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.") @@ -134,7 +141,17 @@ func (s *Server) pairNode(w http.ResponseWriter, r *http.Request) { 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 0aeee29..bf40138 100644 --- a/internal/server/proxy.go +++ b/internal/server/proxy.go @@ -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 } } @@ -221,7 +235,17 @@ 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 } @@ -230,7 +254,15 @@ 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: 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 } @@ -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 } diff --git a/internal/server/registry.go b/internal/server/registry.go index 646dc9b..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 } @@ -65,7 +65,18 @@ func publicRegistries(registries []RegistryConfig) any { 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 8e1242d..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 } @@ -72,7 +80,15 @@ func newRegistryChannel(t *testing.T) *registryChannelFixture { 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 8cded3c..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} + 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.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: @@ -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]any{"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) diff --git a/internal/server/registry_test.go b/internal/server/registry_test.go index d489c71..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]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"}}}}) + 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 { @@ -91,14 +112,25 @@ func TestRegistries(t *testing.T) { 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_test.go b/internal/server/registry_transport_test.go index 564c972..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,17 +44,27 @@ 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 { @@ -61,7 +72,9 @@ func TestRegistryTransport(t *testing.T) { } 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 ab5dfbf..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 } @@ -99,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 c0ecea0..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 @@ -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") @@ -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]any{"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]any{"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]any{"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 d00904a..2c9818c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,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() @@ -103,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]any{"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 { @@ -174,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) @@ -253,7 +270,10 @@ 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 } @@ -274,13 +294,35 @@ 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}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8d06116..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) } } @@ -371,7 +396,15 @@ func TestAggregate(t *testing.T) { 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() @@ -440,7 +473,15 @@ func TestReconnect(t *testing.T) { 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 c1b6a14..22bfafd 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -31,7 +31,16 @@ 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) any { @@ -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 } @@ -221,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 { @@ -314,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,7 +383,13 @@ func (s *Server) testNode(w http.ResponseWriter, r *http.Request) { health, err := checkNode(ctx, node, client) if nil != err { detail := safeTransportError(err) - slog.Error(fmt.Sprintf("Connection test for %s failed: %s", nodeLogLabel(node), detail), "node", nodeLogLabel(node), "error", detail) + 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 { diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 92f23dc..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++ @@ -587,7 +640,13 @@ 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 5e44725..aa7e16e 100644 --- a/internal/server/tickets.go +++ b/internal/server/tickets.go @@ -81,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 9693cfc..9810634 100644 --- a/internal/server/tickets_test.go +++ b/internal/server/tickets_test.go @@ -79,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) } } @@ -263,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) } }