From cf16dca93a8367f618ba22acc09e857a57edc3fd Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 23 Oct 2026 16:27:59 +0200 Subject: [PATCH 1/3] feat!: v2 host agent (spectate-agent, protocol v2) Replace the frozen v1 agent (spectated) on main with the v2 agent that was developed in spectate/platform (agent/). - Module path github.com/spectate/agent/v2 (v1 was released as v1.x.y). - Tags v2.x.y; releases via goreleaser on tag push, never marked latest so v1's install-latest.sh keeps resolving releases/latest to v1.5.0. - install.sh downloads from this repository's releases and verifies checksums.txt; each release ships install.sh pinned to its version. - CI: gofmt, go mod verify/tidy, vet (linux + darwin), test -race, build, shellcheck and goreleaser check; actions pinned by commit SHA. - v1 source stays available at the v1.x.y tags; install-latest.sh is kept unchanged for existing v1 installs. --- .env.example | 4 - .github/dependabot.yml | 9 +- .github/workflows/build.yml | 66 - .github/workflows/ci.yml | 76 + .github/workflows/release.yml | 82 +- .github/workflows/vet-lint.yml | 27 - .gitignore | 135 +- .goreleaser.darwin.yaml | 49 - .goreleaser.linux.yaml | 55 - .goreleaser.yaml | 98 +- .releaserc.json | 23 - CHANGELOG.md | 88 - Makefile | 70 + README.md | 162 +- cmd/auth.go | 26 - cmd/install.go | 29 - cmd/root.go | 48 - cmd/spectate-agent/main.go | 237 + cmd/start.go | 29 - cmd/uninstall.go | 29 - go.mod | 51 +- go.sum | 227 +- install.sh | 142 + internal/agent/agent.go | 463 ++ internal/agent/agent_test.go | 341 + internal/backoff/backoff.go | 42 + internal/backoff/backoff_test.go | 35 + internal/collector/collector.go | 71 + internal/collector/collector_test.go | 146 + internal/collector/hostinfo.go | 87 + internal/collector/system.go | 279 + internal/config/config.go | 228 + internal/config/config_test.go | 56 + internal/encode/decode.go | 95 + internal/encode/encode.go | 183 + internal/encode/encode_test.go | 259 + internal/encode/testdata/golden-batch.json | 14 + internal/encode/testdata/golden-batch.pb.gz | Bin 0 -> 350 bytes internal/http/client.go | 86 - internal/logger/logger.go | 70 - internal/logger/logger_darwin.go | 24 - internal/logger/logger_nix.go | 10 - internal/metrics/metrics.go | 110 + internal/metrics/metrics_test.go | 35 + internal/proto/agentv2/ingest.pb.go | 622 ++ internal/service/service.go | 96 + internal/service/service_darwin.go | 71 + internal/service/service_linux.go | 44 + internal/service/service_other.go | 13 + internal/service/service_test.go | 21 + internal/shared/bytes.go | 3 - internal/shared/strings.go | 10 - internal/spool/spool.go | 203 + internal/spool/spool_test.go | 113 + internal/transport/transport.go | 203 + internal/transport/transport_test.go | 71 + internal/version/version.go | 27 +- main/main.go | 9 - package-lock.json | 6831 ------------------- package.json | 7 - pkg/app/service.go | 255 - pkg/auth/auth.go | 70 - pkg/checks/strategies/cpu_times.go | 97 - pkg/checks/strategies/disk_usage.go | 113 - pkg/checks/strategies/load.go | 55 - pkg/checks/strategies/memory.go | 72 - pkg/checks/strategies/network.go | 121 - pkg/checks/strategies/strategies.go | 32 - pkg/checks/strategies/uptime.go | 53 - pkg/collector/collector.go | 67 - pkg/collector/types.go | 8 - pkg/config/config.go | 78 - pkg/config/config_darwin.go | 22 - pkg/config/config_nix.go | 9 - pkg/metrics/metrics.go | 89 - pkg/metrics/metrics_batch.go | 63 - pkg/proto/metrics_payload.proto | 66 - pkg/proto/pb/metrics_payload.pb.go | 654 -- pkg/service/service.go | 44 - pkg/service/service_darwin.go | 19 - pkg/service/service_nix.go | 16 - pkg/telemetry/telemetry.go | 36 - proto/spectate/agent/v2/ingest.proto | 107 + 83 files changed, 4834 insertions(+), 10052 deletions(-) delete mode 100644 .env.example delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/vet-lint.yml delete mode 100644 .goreleaser.darwin.yaml delete mode 100644 .goreleaser.linux.yaml delete mode 100644 .releaserc.json delete mode 100644 CHANGELOG.md create mode 100644 Makefile delete mode 100644 cmd/auth.go delete mode 100644 cmd/install.go delete mode 100644 cmd/root.go create mode 100644 cmd/spectate-agent/main.go delete mode 100644 cmd/start.go delete mode 100644 cmd/uninstall.go create mode 100755 install.sh create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/backoff/backoff.go create mode 100644 internal/backoff/backoff_test.go create mode 100644 internal/collector/collector.go create mode 100644 internal/collector/collector_test.go create mode 100644 internal/collector/hostinfo.go create mode 100644 internal/collector/system.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/encode/decode.go create mode 100644 internal/encode/encode.go create mode 100644 internal/encode/encode_test.go create mode 100644 internal/encode/testdata/golden-batch.json create mode 100644 internal/encode/testdata/golden-batch.pb.gz delete mode 100644 internal/http/client.go delete mode 100644 internal/logger/logger.go delete mode 100644 internal/logger/logger_darwin.go delete mode 100644 internal/logger/logger_nix.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/metrics/metrics_test.go create mode 100644 internal/proto/agentv2/ingest.pb.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_darwin.go create mode 100644 internal/service/service_linux.go create mode 100644 internal/service/service_other.go create mode 100644 internal/service/service_test.go delete mode 100644 internal/shared/bytes.go delete mode 100644 internal/shared/strings.go create mode 100644 internal/spool/spool.go create mode 100644 internal/spool/spool_test.go create mode 100644 internal/transport/transport.go create mode 100644 internal/transport/transport_test.go delete mode 100644 main/main.go delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 pkg/app/service.go delete mode 100644 pkg/auth/auth.go delete mode 100644 pkg/checks/strategies/cpu_times.go delete mode 100644 pkg/checks/strategies/disk_usage.go delete mode 100644 pkg/checks/strategies/load.go delete mode 100644 pkg/checks/strategies/memory.go delete mode 100644 pkg/checks/strategies/network.go delete mode 100644 pkg/checks/strategies/strategies.go delete mode 100644 pkg/checks/strategies/uptime.go delete mode 100644 pkg/collector/collector.go delete mode 100644 pkg/collector/types.go delete mode 100644 pkg/config/config.go delete mode 100644 pkg/config/config_darwin.go delete mode 100644 pkg/config/config_nix.go delete mode 100644 pkg/metrics/metrics.go delete mode 100644 pkg/metrics/metrics_batch.go delete mode 100644 pkg/proto/metrics_payload.proto delete mode 100644 pkg/proto/pb/metrics_payload.pb.go delete mode 100644 pkg/service/service.go delete mode 100644 pkg/service/service_darwin.go delete mode 100644 pkg/service/service_nix.go delete mode 100644 pkg/telemetry/telemetry.go create mode 100644 proto/spectate/agent/v2/ingest.proto diff --git a/.env.example b/.env.example deleted file mode 100644 index a4e5b59..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -ENVIRONMENT=development -API_BASE_URL=http://localhost:8000/agent-api -LOG_LEVEL=debug -SENTRY_DSN= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5570e31..c507d44 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,10 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 5fa3632..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Build and attach to release -concurrency: - group: main-build-release - cancel-in-progress: true -on: - create: - tags: - - v* - workflow_dispatch: - -jobs: - build-linux: - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: '1.23.3' - - name: Get version - id: get_version - run: echo ::set-output name=VERSION::$(git describe --tags) - - name: Install cross-compilers - run: sudo apt-get -y install gcc-aarch64-linux-gnu - - name: Run goreleaser - uses: goreleaser/goreleaser-action@v2 - with: - version: latest - args: release --clean --config .goreleaser.linux.yaml - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - ENVIRONMENT: ${{ secrets.ENVIRONMENT }} - API_BASE_URL: ${{ secrets.API_BASE_URL }} - LOG_LEVEL: ${{ secrets.LOG_LEVEL }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - GO111MODULE: on - - build-darwin: - if: startsWith(github.ref, 'refs/tags/') - runs-on: macos-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: '1.23.3' - - name: Get version - id: get_version - run: echo ::set-output name=VERSION::$(git describe --tags) - - name: Run goreleaser - uses: goreleaser/goreleaser-action@v2 - with: - version: latest - args: release --clean --config .goreleaser.darwin.yaml - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - ENVIRONMENT: ${{ secrets.ENVIRONMENT }} - API_BASE_URL: ${{ secrets.API_BASE_URL }} - LOG_LEVEL: ${{ secrets.LOG_LEVEL }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - GO111MODULE: on diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..833e7ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # The job id is the status check required by the main branch protection. + vet-lint: + name: vet-lint + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error title=gofmt::Run gofmt -w on: $unformatted" + exit 1 + fi + + - name: Verify modules + run: | + go mod verify + go mod tidy -diff + + - name: Vet (linux, darwin) + run: | + go vet ./... + GOOS=darwin go vet ./... + + - name: Test + run: go test -race ./... + + - name: Build + run: | + CGO_ENABLED=0 go build -trimpath ./... + CGO_ENABLED=0 GOOS=darwin go build -trimpath ./... + + - name: Lint install.sh + run: shellcheck install.sh + + goreleaser: + name: goreleaser check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the release configuration + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + distribution: goreleaser + version: "~> v2" + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e1986f..369c8f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,51 +1,67 @@ -name: Create Release -concurrency: - group: main-release - cancel-in-progress: true +# Publishes a GitHub release for every v2.x.y tag (git tag -s v2.0.0 && git push origin v2.0.0). +# Releases are not marked "latest": that stays on v1 (spectated) for its installers. +name: Release + on: - workflow_dispatch: + push: + tags: + - "v2.*.*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - vet-lint: - if: "!contains(github.event.head_commit.message, 'chore(release):')" - runs-on: ubuntu-latest + test: + name: vet + test -race + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - go-version: '1.21.x' + persist-credentials: false - - name: Install dependencies - run: | - go version - go mod download + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod - - name: Run vet + - name: Vet and test run: | + test -z "$(gofmt -l .)" go vet ./... + GOOS=darwin go vet ./... + go test -race ./... - semantic-release: - needs: vet-lint - runs-on: ubuntu-latest - outputs: - version_updated: ${{ steps.check_version.outputs.version_updated }} + release: + name: goreleaser + needs: test + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write steps: - - uses: actions/checkout@v2 + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - name: Setup Node - uses: actions/setup-node@v2 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - node-version: '20' + go-version-file: go.mod + cache: false - - name: NPM install - run: npm ci - - - name: Run semantic-release - run: npx semantic-release + - name: Release + uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vet-lint.yml b/.github/workflows/vet-lint.yml deleted file mode 100644 index db7b2b9..0000000 --- a/.github/workflows/vet-lint.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Run vet -on: - push: - pull_request: - branches: - - main - -jobs: - vet-lint: - if: "!contains(github.event.head_commit.message, 'chore(release):')" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Setup Go - uses: actions/setup-go@v2 - with: - go-version: '1.21.x' - - - name: Install dependencies - run: | - go version - go mod download - - - name: Run vet - run: | - go vet ./... diff --git a/.gitignore b/.gitignore index e897cc1..082819c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,132 +1,3 @@ -### VisualStudioCode template -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -### JetBrains template -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea - -# CMake -cmake-build-*/ -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -### Go template -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work - -### macOS template -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### SublimeText template -# Cache files for Sublime Text -*.tmlanguage.cache -*.tmPreferences.cache -*.stTheme.cache - -# Workspace files are user-specific -*.sublime-workspace - -# Project files should be checked into the repository, unless a significant -# proportion of contributors will probably not be using Sublime Text -# *.sublime-project - -# SFTP configuration file -sftp-config.json -sftp-config-alt*.json - -# Package control specific files -Package Control.last-run -Package Control.ca-list -Package Control.ca-bundle -Package Control.system-ca-bundle -Package Control.cache/ -Package Control.ca-certs/ -Package Control.merged-ca-bundle -Package Control.user-ca-bundle -oscrypto-ca-bundle.crt -bh_unicode_properties.cache - -# Sublime-github package stores a github token in this file -# https://packagecontrol.io/packages/sublime-github -GitHub.sublime-settings - -bin/ - -.env - -dist/ -node_modules -.idea +/bin/ +/dist/ +/build/ diff --git a/.goreleaser.darwin.yaml b/.goreleaser.darwin.yaml deleted file mode 100644 index 9e6c6fe..0000000 --- a/.goreleaser.darwin.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj -project_name: spectated - -before: - hooks: - - go mod tidy - -release: - github: - owner: spectate - name: agent - -builds: - - id: "spectated" - main: "./main/main.go" - binary: "spectated" - env: - - CGO_ENABLED=1 - - ENVIRONMENT=production - - API_BASE_URL=https://api.spectate.net/agent-api - - LOG_LEVEL=info - - SENTRY_DSN={{ .Env.SENTRY_DSN }} - goos: - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -X 'github.com/spectate/agent/internal/version.Version={{.Version}}' - - -X 'github.com/spectate/agent/internal/version.BuildDate={{.Date}}' - - -X 'github.com/spectate/agent/internal/version.Environment={{ .Env.ENVIRONMENT }}' - - -X 'github.com/spectate/agent/internal/logger.LogLevel={{ .Env.LOG_LEVEL }}' - - -X 'github.com/spectate/agent/internal/http.ApiBaseUrl={{ .Env.API_BASE_URL }}' - - -X 'github.com/spectate/agent/pkg/telemetry.SentryDsn={{ .Env.SENTRY_DSN }}' - -universal_binaries: - - replace: false - -archives: - - id: binary - format: binary - name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - id: archive - format: tar.gz - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' - -checksum: - name_template: '{{ .ProjectName }}_{{ .Version }}_darwin_checksums.txt' diff --git a/.goreleaser.linux.yaml b/.goreleaser.linux.yaml deleted file mode 100644 index c63af91..0000000 --- a/.goreleaser.linux.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj -project_name: spectated - -before: - hooks: - - go mod tidy - -release: - github: - owner: spectate - name: agent - -builds: - - id: "spectated" - main: "./main/main.go" - binary: "spectated" - env: - - CGO_ENABLED=0 - - ENVIRONMENT=production - - API_BASE_URL=https://api.spectate.net/agent-api - - LOG_LEVEL=info - - SENTRY_DSN={{ .Env.SENTRY_DSN }} - goos: - - linux - goarch: - - amd64 - - arm64 - ldflags: - - -X 'github.com/spectate/agent/internal/version.Version={{.Version}}' - - -X 'github.com/spectate/agent/internal/version.BuildDate={{.Date}}' - - -X 'github.com/spectate/agent/internal/version.Environment={{ .Env.ENVIRONMENT }}' - - -X 'github.com/spectate/agent/internal/logger.LogLevel={{ .Env.LOG_LEVEL }}' - - -X 'github.com/spectate/agent/internal/http.ApiBaseUrl={{ .Env.API_BASE_URL }}' - - -X 'github.com/spectate/agent/pkg/telemetry.SentryDsn={{ .Env.SENTRY_DSN }}' - overrides: - - goos: linux - goarch: arm64 - env: - - CC=aarch64-linux-gnu-gcc - - goos: linux - goarch: amd64 - env: - - CC=gcc - -archives: - - id: binary - format: binary - name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - id: archive - format: tar.gz - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' - -checksum: - name_template: '{{ .ProjectName }}_{{ .Version }}_linux_checksums.txt' diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0759a66..7cce922 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,51 +1,73 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj -project_name: spectated +version: 2 +project_name: spectate-agent + +# Released by .github/workflows/release.yml when a v2.x.y tag is pushed +# (`make snapshot` builds locally without publishing). before: hooks: - - go mod tidy - -release: - github: - owner: spectate - name: agent + - go mod tidy -diff + - go test ./... + # install.sh with this release as its default version, published as a + # release asset (see README "Installer"). + - mkdir -p build + - sh -c 'sed "s/^DEFAULT_VERSION=\"\"$/DEFAULT_VERSION=\"{{ .Version }}\"/" install.sh > build/install.sh' + - sh -c 'grep -q "^DEFAULT_VERSION=\"{{ .Version }}\"$" build/install.sh' builds: - - id: "spectated" - main: "./main/main.go" - binary: "spectated" + - id: spectate-agent + main: ./cmd/spectate-agent + binary: spectate-agent env: - - CGO_ENABLED=1 - - ENVIRONMENT={{ .Env.ENVIRONMENT }} - - API_BASE_URL={{ .Env.API_BASE_URL }} - - LOG_LEVEL={{ .Env.LOG_LEVEL }} - - SENTRY_DSN={{ .Env.SENTRY_DSN }} - goos: - # - linux - # - windows - - darwin - goarch: - - amd64 - - arm64 + - CGO_ENABLED=0 + flags: + - -trimpath + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - - -X 'github.com/spectate/agent/internal/version.Version={{.Version}}' - - -X 'github.com/spectate/agent/internal/version.BuildDate={{.Date}}' - - -X 'github.com/spectate/agent/internal/version.Environment={{ .Env.ENVIRONMENT }}' - - -X 'github.com/spectate/agent/internal/logger.LogLevel={{ .Env.LOG_LEVEL }}' - - -X 'github.com/spectate/agent/internal/http.ApiBaseUrl={{ .Env.API_BASE_URL }}' - - -X 'github.com/spectate/agent/pkg/telemetry.SentryDsn={{ .Env.SENTRY_DSN }}' - -universal_binaries: - - replace: false + - -s -w + - -X github.com/spectate/agent/v2/internal/version.Version={{ .Version }} + - -X github.com/spectate/agent/v2/internal/version.Commit={{ .ShortCommit }} + - -X github.com/spectate/agent/v2/internal/version.BuildDate={{ .Date }} + - -X github.com/spectate/agent/v2/internal/version.DefaultAPIURL={{ envOrDefault "API_URL" "https://api.spectate.net" }} + goos: [linux, darwin] + goarch: [amd64, arm64] archives: - id: binary - format: binary - name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - id: archive - format: tar.gz - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' + formats: [binary] + name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}" + - id: tarball + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + files: + - README.md + - LICENSE checksum: - name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt' + name_template: checksums.txt + extra_files: + - glob: build/install.sh + +release: + github: + owner: spectate + name: agent + # v1 (spectated) installers download releases/latest/download/spectated_*, + # so v1.5.0 must stay the "latest" release until v1 is retired. install.sh + # finds the newest v2 release itself. + make_latest: false + prerelease: auto + extra_files: + - glob: build/install.sh + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^chore\\(deps\\)" + - "^Merge " diff --git a/.releaserc.json b/.releaserc.json deleted file mode 100644 index 3c2d60a..0000000 --- a/.releaserc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - "@semantic-release/changelog", - "@semantic-release/github", - [ - "@semantic-release/git", - { - "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" - } - ] - ], - "branches": [ - { - "name": "next", - "channel": "next", - "prerelease": true - }, - "main" - ], - "preset": "angular" -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 0835ebd..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,88 +0,0 @@ -# [1.5.0](https://github.com/spectate/agent/compare/v1.4.3...v1.5.0) (2024-11-16) - - -### Features - -* update go to 1.23.3 ([7afbfc2](https://github.com/spectate/agent/commit/7afbfc2e2cb38146a8dcc70dfd6338117c17b104)) - -## [1.4.3](https://github.com/spectate/agent/compare/v1.4.2...v1.4.3) (2024-11-16) - - -### Bug Fixes - -* update semantic-release and force maintenance update ([53e4f8d](https://github.com/spectate/agent/commit/53e4f8d3f723787708e3a977acb161d57bcd4404)) - -## [1.4.2](https://github.com/spectate/agent/compare/v1.4.1...v1.4.2) (2023-12-30) - - -### Bug Fixes - -* **check-net:** add wildcards and update defaults to fix sending unne… ([#20](https://github.com/spectate/agent/issues/20)) ([c76834e](https://github.com/spectate/agent/commit/c76834e2aa6b607bdfe35a94d44b82132b39a777)) - -## [1.4.1](https://github.com/spectate/agent/compare/v1.4.0...v1.4.1) (2023-12-27) - - -### Bug Fixes - -* **check-net:** remove println call in network strategy ([#19](https://github.com/spectate/agent/issues/19)) ([1b989b9](https://github.com/spectate/agent/commit/1b989b939ac62cf96cd7938d0ec6a38799cb3f87)) - -# [1.4.0](https://github.com/spectate/agent/compare/v1.3.0...v1.4.0) (2023-12-27) - - -### Features - -* **check-net:** implement network interface metrics ([#17](https://github.com/spectate/agent/issues/17)) ([0c4202f](https://github.com/spectate/agent/commit/0c4202f6e64fb0b572a612b1571268b10fd723a7)) - -# [1.3.0](https://github.com/spectate/agent/compare/v1.2.3...v1.3.0) (2023-11-07) - - -### Bug Fixes - -* **disk-usage:** correct invalid percentage calculation ([#11](https://github.com/spectate/agent/issues/11)) ([d151713](https://github.com/spectate/agent/commit/d15171382698a91645caef3ccf99a250585f3f7c)) - - -### Features - -* **installer:** add argument for upgrading agent ([#12](https://github.com/spectate/agent/issues/12)) ([aff138c](https://github.com/spectate/agent/commit/aff138c2f854c4c49e62b6722f1ee48a5e049b84)) - -## [1.2.3](https://github.com/spectate/agent/compare/v1.2.2...v1.2.3) (2023-10-29) - - -### Bug Fixes - -* **service:** split service config for nix and darwin ([#8](https://github.com/spectate/agent/issues/8)) ([c4ed474](https://github.com/spectate/agent/commit/c4ed4744a029d533f12d87b18a83992f201797b4)) - -## [1.2.2](https://github.com/spectate/agent/compare/v1.2.1...v1.2.2) (2023-10-29) - - -### Bug Fixes - -* **service:** use lowercased service name ([9ea2949](https://github.com/spectate/agent/commit/9ea294916325568a0abc53e09a75e245673b20f1)) - -## [1.2.1](https://github.com/spectate/agent/compare/v1.2.0...v1.2.1) (2023-10-29) - - -### Bug Fixes - -* **installer:** update check for valid OS ([#6](https://github.com/spectate/agent/issues/6)) ([8815fd3](https://github.com/spectate/agent/commit/8815fd337d093d0db268f0cf7ef58fb5bbeb12b8)) - -# [1.2.0](https://github.com/spectate/agent/compare/v1.1.0...v1.2.0) (2023-10-28) - - -### Features - -* add installer and install script for linux and darwin ([#5](https://github.com/spectate/agent/issues/5)) ([cf448b9](https://github.com/spectate/agent/commit/cf448b9893d4bed4820f68455224515e61519afc)) - -# [1.1.0](https://github.com/spectate/agent/compare/v1.0.0...v1.1.0) (2023-10-27) - - -### Features - -* **check-disk-usage:** add excludes for filesystem, mountpoints and … ([#4](https://github.com/spectate/agent/issues/4)) ([7dd7ced](https://github.com/spectate/agent/commit/7dd7cedfafd197489ac710a2e0406966a9fa705a)) - -# 1.0.0 (2023-10-25) - - -### Features - -* initial release ([df9f4fa](https://github.com/spectate/agent/commit/df9f4faa93a9397123d3065eb76ecb220d047015)) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1a6fce1 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +# Spectate host agent (protocol v2) + +BINARY := spectate-agent +PKG := github.com/spectate/agent/v2 +VERSION ?= $(shell git describe --tags --match 'v2.*' --always --dirty 2>/dev/null | sed 's|^v||' || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +API_URL ?= https://api.spectate.net +LDFLAGS := -s -w \ + -X '$(PKG)/internal/version.Version=$(VERSION)' \ + -X '$(PKG)/internal/version.Commit=$(COMMIT)' \ + -X '$(PKG)/internal/version.BuildDate=$(DATE)' \ + -X '$(PKG)/internal/version.DefaultAPIURL=$(API_URL)' +GOFLAGS := -trimpath +# Copy of the canonical schema in spectate/platform (laravel/proto). The M +# option maps it to this module whatever its go_package says. +PROTO := spectate/agent/v2/ingest.proto + +.PHONY: all build dist test race vet fmt proto golden bench release snapshot clean + +all: vet test build + +build: + CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/spectate-agent + +# Static Linux binaries (primary targets) plus macOS (best effort). +dist: + @for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \ + os=$${target%/*}; arch=$${target#*/}; \ + echo "building $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build $(GOFLAGS) -ldflags "$(LDFLAGS)" \ + -o dist/$(BINARY)_$${os}_$${arch} ./cmd/spectate-agent || exit 1; \ + done + cd dist && shasum -a 256 $(BINARY)_* > checksums.txt + +test: + go test ./... + +race: + go test -race ./... + +vet: + go vet ./... + GOOS=linux go vet ./... + +fmt: + gofmt -w . + +# Go bindings from proto/ (copy it from spectate/platform laravel/proto first). +# PHP bindings are generated in the platform repo. +proto: + protoc --proto_path=proto --go_out=. --go_opt=module=$(PKG) \ + --go_opt=M$(PROTO)=$(PKG)/internal/proto/agentv2\;agentv2 proto/$(PROTO) + +# Regenerate the cross-language fixture decoded by the platform's +# laravel/tests/Feature/HostAgentV2Test.php (copy both testdata files there). +golden: + go test ./internal/encode -run Golden -update + +bench: + go test ./internal/encode -run '^$$' -bench . -benchmem + +release: + goreleaser release --clean + +snapshot: + goreleaser release --snapshot --clean + +clean: + rm -rf bin dist diff --git a/README.md b/README.md index 7589e2e..48d0856 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,153 @@ -# Spectate Agent +# spectate-agent -Spectate Agent (`spectated`) is a service that allows you to monitor infrastructure using Spectate. This repository contains its source code. +The Spectate host monitoring agent, protocol v2. It replaces the v1 agent +(`spectated`), which keeps working against the frozen `/agent-api/host/*` +endpoints; see [v1 (spectated)](#v1-spectated). The agent registers with +`POST /agent-api/v2/register` and ships metrics to `POST /agent-api/v2/ingest` +(gzip + protobuf, schema in [`proto/spectate/agent/v2/ingest.proto`](proto/spectate/agent/v2/ingest.proto)). -[![License: Apache License 2.0](https://img.shields.io/badge/License-Apache%20License%202.0-yellow.svg)](https://opensource.org/license/apache-2-0/) +[![License: Apache License 2.0](https://img.shields.io/badge/License-Apache%20License%202.0-yellow.svg)](LICENSE) -## Installation +- Collects every 15 s (server-configurable) and ships once a minute in one + gzip-compressed, columnar protobuf batch. A typical minute (58 series, 232 + samples) is about 1 KB on the wire. +- Collects the same metrics, with the same keys and units, as v1: CPU, memory and + swap, load, uptime, disk usage per mountpoint, network per interface. The + server stores v1 and v2 data identically. +- Keeps working through outages. Batches that fail to send go to a bounded + on-disk spool (default 50 MB / 24 h) and are replayed oldest first, with + exponential backoff, jitter and `Retry-After`. The server de-duplicates + replays. +- Takes its config from the server. Every ingest response carries the collection + interval, enabled collectors and disk/interface filters, and the agent applies + them without a restart. +- Shuts down cleanly. On `SIGTERM` it flushes the current window, or spools it + when offline. +- Small: a static binary (about 10 MB) with about 13 MB RSS, running as an + unprivileged user under a hardened systemd unit. +- Reports on itself: `agent.spool.bytes`, `agent.spool.batches`, + `agent.batches.dropped`, `agent.samples.dropped`, `agent.send.failures`, + `agent.send.latency_ms`, `agent.memory.heap_mb` and `agent.memory.sys_mb`. -Run the install-latest.sh script from the root of this repository: -```bash -chmod +x install-latest.sh -./install-latest.sh +Linux (amd64/arm64) is the primary target. macOS is best effort (launchd user agent). + +## Install (Linux) + +```sh +curl -fsSL https://get.spectate.net/agent.sh | sudo sh -s -- --token ``` -## Usage +`install.sh` does the following: + +1. Downloads the release binary from this repository's GitHub releases and + verifies it against the release's `checksums.txt`. +2. Creates the `spectate-agent` system user. +3. Writes `/etc/spectate-agent/agent.env`. +4. Registers the host, which stores its state in `/var/lib/spectate-agent/state.json` (mode 0600). +5. Installs and starts `spectate-agent.service`. + +Other modes: + +| Flag | Purpose | +|------|---------| +| `--import-legacy` | Moves from the v1 agent: reuses the host token from `/etc/spectated/config.yaml` (same host, same history) and uninstalls `spectated` | +| `--upgrade` | Replaces the binary and restarts the service | +| `--binary ./spectate-agent` | Installs a locally built binary | +| `--api-url URL` | Uses a non-production API | +| `--version 2.0.1` | Installs a specific release | + +Every release publishes `install.sh` as an asset with that release as its +default version; the copy in this repository installs the newest `v2.x.y` +release (found through the GitHub API, because `releases/latest` stays on v1). + +To remove the agent: `sudo spectate-agent uninstall`. State in `/var/lib/spectate-agent` is kept. -For *nix systems, run the following command: -```bash -sudo systemctl start spectated -# to stop the service, run: -sudo systemctl stop spectated +## Commands + +``` +spectate-agent register --token [--api-url URL] [--force] +spectate-agent import-legacy [--config /etc/spectated/config.yaml] +spectate-agent run # foreground (what the service runs) +spectate-agent install [--user U] # root: systemd unit (Linux) / launchd (macOS) +spectate-agent uninstall +spectate-agent status +spectate-agent version ``` -For MacOS, run the following command: -```bash -launchctl load -w ~/Library/LaunchAgents/spectated.plist -# to stop the service, run: -launchctl unload -w ~/Library/LaunchAgents/spectated.plist +Local settings come from the environment (`/etc/spectate-agent/agent.env` under systemd): + +| Variable | Default | +|----------|---------| +| `SPECTATE_API_URL` | URL used at registration, else `https://api.spectate.net` | +| `SPECTATE_STATE_DIR` | `$STATE_DIRECTORY` (systemd), else `/var/lib/spectate-agent` | +| `SPECTATE_SPOOL_MAX_MB` | `50` | +| `SPECTATE_SPOOL_MAX_AGE` | `24h` | +| `SPECTATE_LOG_LEVEL` | `info` | + +Everything else (intervals, collectors, filters) is set server-side and returned +on every ingest response. + +## Layout + ``` +cmd/spectate-agent CLI +internal/agent runner: collect loop, ship loop, spool replay, backoff, shutdown flush +internal/collector gopsutil collectors (v1-compatible keys/units) + host info +internal/metrics per-window series accumulator (bounded) +internal/encode v2 encoder (string table, delta timestamps, decimal/double values, gzip) + reference decoder +internal/spool bounded on-disk FIFO +internal/transport HTTP client (register/ingest, error classification) +internal/backoff capped exponential backoff with equal jitter +internal/service systemd unit / launchd plist +internal/proto/agentv2 generated from proto/spectate/agent/v2/ingest.proto +proto/ copy of the schema; the canonical one lives with the API +``` + +## Develop + +```sh +make vet race # go vet (darwin + linux) and go test -race ./... +make build # bin/spectate-agent +make dist # static linux/darwin amd64/arm64 binaries + checksums +make proto # regenerate the Go bindings after updating proto/ +make golden # refresh the fixture the API test suite decodes +make bench # encoder benchmark (reports bytes/batch) +make snapshot # goreleaser build without publishing +``` + +The module path is `github.com/spectate/agent/v2`: the v1 agent was released +as `v1.x.y` of `github.com/spectate/agent`, so Go's import compatibility rule +requires the `/v2` suffix for `v2.x.y` tags. + +## Release + +Tag `main` with `v2.x.y` and push the tag: + +```sh +git tag -s v2.0.0 -m v2.0.0 && git push origin v2.0.0 +``` + +`.github/workflows/release.yml` runs the tests and goreleaser, which publishes +`spectate-agent__` binaries, tarballs, `install.sh` and +`checksums.txt`. Tags with a suffix (`v2.1.0-rc.1`) become pre-releases and are +skipped by the installer. v2 releases are never marked "latest" (see below). + +The tests include unit tests for encoding, the spool, backoff, collectors and +transport. They also include end-to-end runs of the full agent against an +`httptest` API, covering batching, server config, the shutdown flush, outage +spooling with in-order replay, restart replay, dropped rejects and host info +resend. A golden batch in `internal/encode/testdata` is decoded by the API's +test suite, which keeps the Go and PHP implementations in lockstep: after +`make golden`, copy both testdata files to the API repository. + +## v1 (spectated) -## License +The v1 agent is frozen and still runs at customers. Its source is the +`v1.5.0` tag (and the earlier `v1.x.y` tags), its releases stay as they are, +and v1.5.0 stays the "latest" release: the v1 installer, `install-latest.sh` +in this repository, downloads +`releases/latest/download/spectated__.tar.gz`. Keep that file +unchanged and do not mark a v2 release as latest until v1 is retired. -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details +Move a v1 host to v2 with `install.sh --import-legacy`: the host keeps its +token, identity and history. diff --git a/cmd/auth.go b/cmd/auth.go deleted file mode 100644 index 80b5acd..0000000 --- a/cmd/auth.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/spectate/agent/pkg/auth" - "github.com/spf13/cobra" -) - -// startCmd represents the start command -var authCmd = &cobra.Command{ - Use: "auth", - Short: "Authorize this server with Spectate", - Long: `Authorize this server with your Spectate team using the provided token.`, - Example: "spectated auth ", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - err := auth.Authorize(args[0]) - if err != nil { - fmt.Println("Authentication has failed, please try again. If the problem persists, please contact us.") - } - }, -} - -func init() { - rootCmd.AddCommand(authCmd) -} diff --git a/cmd/install.go b/cmd/install.go deleted file mode 100644 index 2f13883..0000000 --- a/cmd/install.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/spectate/agent/pkg/service" - "github.com/spf13/cobra" -) - -// installCmd represents the start command -var installCmd = &cobra.Command{ - Use: "install", - Short: "Install spectated as service", - Long: `Installs spectated as a service into the system's service manager.`, - Run: func(cmd *cobra.Command, args []string) { - s, err := service.NewService() - if err != nil { - panic(err) - } - if err := s.Install(); err != nil { - fmt.Printf("Failed to install service: %s\n", err) - } else { - fmt.Println("Spectated has been installed successfully.") - } - }, -} - -func init() { - rootCmd.AddCommand(installCmd) -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 7b8aacd..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - logger "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/version" - "github.com/spectate/agent/pkg/config" - "github.com/spectate/agent/pkg/telemetry" - "os" - - "github.com/spf13/cobra" -) - -var verbose bool - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "spectated", - Short: "Spectated is a lightweight monitoring agent", - Long: `Spectated is Spectate's lightweight monitoring agent that can be used to monitor a server -by running it in the background. - -Version: ` + version.Version + ` -Built on: ` + version.BuildDate, -} - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - cobra.OnInitialize( - config.InitConfig, - func() { - logger.InitLogger(verbose) - }, - telemetry.InitTelemetry, - ) - defer logger.ShutdownLogger() - - rootCmd.PersistentFlags().StringVar(&config.CfgFile, "config", "", "config file") - - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") -} diff --git a/cmd/spectate-agent/main.go b/cmd/spectate-agent/main.go new file mode 100644 index 0000000..3b79c99 --- /dev/null +++ b/cmd/spectate-agent/main.go @@ -0,0 +1,237 @@ +// Command spectate-agent is the Spectate host monitoring agent (protocol v2). +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "runtime/debug" + "strings" + "syscall" + "time" + + "github.com/spectate/agent/v2/internal/agent" + "github.com/spectate/agent/v2/internal/collector" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/encode" + "github.com/spectate/agent/v2/internal/service" + "github.com/spectate/agent/v2/internal/spool" + "github.com/spectate/agent/v2/internal/transport" + "github.com/spectate/agent/v2/internal/version" +) + +const usage = `spectate-agent — Spectate host monitoring agent + +Usage: + spectate-agent register --token [--api-url URL] [--force] + spectate-agent import-legacy [--config /etc/spectated/config.yaml] [--api-url URL] + spectate-agent run + spectate-agent install [--user spectate-agent] (root; systemd / launchd) + spectate-agent uninstall + spectate-agent status + spectate-agent version + +Environment: + SPECTATE_API_URL API base URL (default from registration, else ` + "https://api.spectate.net" + `) + SPECTATE_STATE_DIR state + spool directory (default /var/lib/spectate-agent) + SPECTATE_SPOOL_MAX_MB spool size bound (default 50) + SPECTATE_SPOOL_MAX_AGE spool age bound (default 24h) + SPECTATE_LOG_LEVEL debug | info | warn | error (default info) +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + + settings := config.LoadSettings() + logger := newLogger(settings.LogLevel) + slog.SetDefault(logger) + + cmd, args := os.Args[1], os.Args[2:] + var err error + switch cmd { + case "run": + err = runAgent(settings, logger) + case "register": + err = register(settings, args) + case "import-legacy": + err = importLegacy(settings, args) + case "install": + err = install(args) + case "uninstall": + err = service.Uninstall() + case "status": + err = status(settings) + case "version", "--version", "-v": + fmt.Printf("spectate-agent %s (commit %s, built %s)\n", version.Version, version.Commit, version.BuildDate) + case "help", "--help", "-h": + fmt.Print(usage) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", cmd, usage) + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func newLogger(level string) *slog.Logger { + var l slog.Level + if err := l.UnmarshalText([]byte(level)); err != nil { + l = slog.LevelInfo + } + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})) +} + +func apiURL(settings config.Settings, flagValue, stored string) string { + for _, v := range []string{flagValue, settings.APIURL, stored} { + if v != "" { + return strings.TrimRight(v, "/") + } + } + return version.DefaultAPIURL +} + +func runAgent(settings config.Settings, logger *slog.Logger) error { + // The agent's working set is a few MB; keep the GC from letting it grow. + debug.SetMemoryLimit(48 << 20) + debug.SetGCPercent(50) + + st, err := config.LoadState(settings.StateDir) + if err != nil { + return err + } + sp, err := spool.Open(filepath.Join(settings.StateDir, "spool"), settings.SpoolMaxBytes, settings.SpoolMaxAge) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return agent.New(agent.Options{ + Client: transport.New(apiURL(settings, "", st.APIURL), st.HostToken), + Spool: sp, + State: st, + StateDir: settings.StateDir, + Logger: logger, + }).Run(ctx) +} + +func register(settings config.Settings, args []string) error { + fs := flag.NewFlagSet("register", flag.ExitOnError) + token := fs.String("token", os.Getenv("SPECTATE_INSTALL_TOKEN"), "team install token") + url := fs.String("api-url", "", "API base URL") + force := fs.Bool("force", false, "replace an existing registration") + _ = fs.Parse(args) + + if *token == "" { + return errors.New("--token is required") + } + if existing, err := config.LoadState(settings.StateDir); err == nil && existing.HostToken != "" && !*force { + return fmt.Errorf("already registered as host %s (use --force to register again)", existing.HostUUID) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + info, err := collector.HostInfo(ctx) + if err != nil { + return fmt.Errorf("gather host info: %w", err) + } + hostInfo, cpus := collector.HostInfoMaps(info) + base := apiURL(settings, *url, "") + + resp, err := transport.New(base, "").Register(ctx, transport.RegisterRequest{ + Token: *token, + Hostname: collector.Hostname(info), + HostInfo: hostInfo, + CPUInfo: cpus, + AgentVersion: version.Version, + HostInfoHash: encode.HostInfoHash(info), + }) + if err != nil { + return fmt.Errorf("register: %w", err) + } + + if err := config.SaveState(settings.StateDir, config.State{ + APIURL: base, + HostToken: resp.HostToken, + HostUUID: resp.HostUUID, + Remote: resp.Config.Normalize(), + }); err != nil { + return fmt.Errorf("save state: %w", err) + } + fmt.Printf("Registered host %s (%s). Start the agent with `systemctl start %s` or `spectate-agent run`.\n", + collector.Hostname(info), resp.HostUUID, service.Name) + return nil +} + +func importLegacy(settings config.Settings, args []string) error { + fs := flag.NewFlagSet("import-legacy", flag.ExitOnError) + path := fs.String("config", config.LegacyConfigPath(), "v1 agent (spectated) config file") + url := fs.String("api-url", "", "API base URL") + _ = fs.Parse(args) + + token, err := config.LegacyToken(*path) + if err != nil { + return err + } + if err := config.SaveState(settings.StateDir, config.State{ + APIURL: apiURL(settings, *url, ""), + HostToken: token, + Remote: config.DefaultRemote(), + }); err != nil { + return err + } + fmt.Println("Imported the host token of the v1 agent; this host keeps its identity and history.") + fmt.Println("Stop and remove the v1 agent (`spectated uninstall`) before starting spectate-agent.") + return nil +} + +func install(args []string) error { + fs := flag.NewFlagSet("install", flag.ExitOnError) + user := fs.String("user", service.Name, "service user (Linux)") + envFile := fs.String("env-file", "/etc/spectate-agent/agent.env", "EnvironmentFile for the unit (Linux)") + _ = fs.Parse(args) + + bin, err := os.Executable() + if err != nil { + return err + } + if bin, err = filepath.EvalSymlinks(bin); err != nil { + return err + } + if err := service.Install(service.Options{Binary: bin, User: *user, EnvFile: *envFile}); err != nil { + return err + } + fmt.Println("spectate-agent service installed and started.") + return nil +} + +func status(settings config.Settings) error { + st, err := config.LoadState(settings.StateDir) + if err != nil { + return err + } + sp, err := spool.Open(filepath.Join(settings.StateDir, "spool"), settings.SpoolMaxBytes, settings.SpoolMaxAge) + if err != nil { + return err + } + fmt.Printf("version: %s\n", version.Version) + fmt.Printf("api: %s\n", apiURL(settings, "", st.APIURL)) + fmt.Printf("host uuid: %s\n", st.HostUUID) + fmt.Printf("state dir: %s\n", settings.StateDir) + fmt.Printf("collection: every %ds, shipped every %ds\n", st.Remote.CollectionInterval, st.Remote.IngestInterval) + fmt.Printf("collectors: %s\n", strings.Join(st.Remote.Collectors, ", ")) + fmt.Printf("spool: %d batches, %d bytes\n", sp.Len(), sp.Bytes()) + return nil +} diff --git a/cmd/start.go b/cmd/start.go deleted file mode 100644 index c95fbc1..0000000 --- a/cmd/start.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/service" - "github.com/spf13/cobra" -) - -// startCmd represents the start command -var startCmd = &cobra.Command{ - Use: "start", - Short: "Start Spectated", - Long: `Start the Spectated agent. This will start the agent in the background and will - monitor the server.`, - Run: func(cmd *cobra.Command, args []string) { - s, err := service.NewService() - if err != nil { - logger.Log.Panic().Err(err).Msg("Failed to create service") - } - - if err := s.Run(); err != nil { - logger.Log.Panic().Err(err).Msg("Failed to run service") - } - }, -} - -func init() { - rootCmd.AddCommand(startCmd) -} diff --git a/cmd/uninstall.go b/cmd/uninstall.go deleted file mode 100644 index 0d081c9..0000000 --- a/cmd/uninstall.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/spectate/agent/pkg/service" - "github.com/spf13/cobra" -) - -// uninstallCmd represents the start command -var uninstallCmd = &cobra.Command{ - Use: "uninstall", - Short: "Uninstall the spectated service", - Long: `Uninstalls spectated from the system's service manager.`, - Run: func(cmd *cobra.Command, args []string) { - s, err := service.NewService() - if err != nil { - panic(err) - } - if err := s.Uninstall(); err != nil { - fmt.Printf("Failed to install service: %s\n", err) - } else { - fmt.Println("Spectated has been uninstalled.") - } - }, -} - -func init() { - rootCmd.AddCommand(uninstallCmd) -} diff --git a/go.mod b/go.mod index 3cc5204..f50887e 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,19 @@ -module github.com/spectate/agent +module github.com/spectate/agent/v2 -go 1.23 +go 1.26.0 require ( - github.com/getsentry/sentry-go v0.29.1 - github.com/go-resty/resty/v2 v2.16.0 - github.com/kardianos/service v1.2.2 - github.com/rs/zerolog v1.33.0 - github.com/shirou/gopsutil/v3 v3.24.5 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 - google.golang.org/protobuf v1.35.2 - gopkg.in/natefinch/lumberjack.v2 v2.2.1 + github.com/shirou/gopsutil/v4 v4.26.9 + google.golang.org/protobuf v1.36.12 ) require ( - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.9.0 // indirect + github.com/ebitengine/purego v0.11.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index ff48375..7b1964a 100644 --- a/go.sum +++ b/go.sum @@ -1,217 +1,30 @@ -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= -github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= -github.com/getsentry/sentry-go v0.29.1 h1:DyZuChN8Hz3ARxGVV8ePaNXh1dQ7d76AiB117xcREwA= -github.com/getsentry/sentry-go v0.29.1/go.mod h1:x3AtIzN01d6SiWkderzaH28Tm0lgkafpJ5Bm3li39O0= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-resty/resty/v2 v2.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= -github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= -github.com/go-resty/resty/v2 v2.16.0 h1:qpKalHWI2bpp9BIKlyT8TYWEJXOk1NuKbfiT3RRnzWc= -github.com/go-resty/resty/v2 v2.16.0/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= -github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= -github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= -github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/shirou/gopsutil/v3 v3.24.3 h1:eoUGJSmdfLzJ3mxIhmOAhgKEKgQkeOwKpz1NbhVnuPE= -github.com/shirou/gopsutil/v3 v3.24.3/go.mod h1:JpND7O217xa72ewWz9zN2eIIkPWsDN/3pl0H8Qt0uwg= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= -github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= +github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= -golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..feb9559 --- /dev/null +++ b/install.sh @@ -0,0 +1,142 @@ +#!/bin/sh +# Spectate host agent installer (Linux, systemd). +# +# curl -fsSL https://get.spectate.net/agent.sh | sudo sh -s -- --token +# +# Options: +# --token TOKEN team install token (or SPECTATE_INSTALL_TOKEN) +# --api-url URL API base URL (default https://api.spectate.net) +# --version VERSION release to install, e.g. 2.0.1 (default: this +# installer's release, else the newest v2 release) +# --binary PATH install a local binary instead of downloading +# --import-legacy reuse the v1 agent's (spectated) host token instead of registering +# --upgrade only replace the binary and restart the service +# +# Creates the unprivileged `spectate-agent` user, installs the binary to +# /usr/local/bin, writes /etc/spectate-agent/agent.env, registers the host and +# enables the hardened systemd unit. State lives in /var/lib/spectate-agent. +set -eu + +REPO="spectate/agent" +# Set to the release version in the install.sh asset of each GitHub release; +# empty in the repository, which installs the newest v2 release. +DEFAULT_VERSION="" +BIN="/usr/local/bin/spectate-agent" +USER_NAME="spectate-agent" +ETC_DIR="/etc/spectate-agent" +STATE_DIR="/var/lib/spectate-agent" + +TOKEN="${SPECTATE_INSTALL_TOKEN:-}" +API_URL="${SPECTATE_API_URL:-https://api.spectate.net}" +VERSION="${DEFAULT_VERSION:-latest}" +LOCAL_BINARY="" +IMPORT_LEGACY=0 +UPGRADE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --token) TOKEN="$2"; shift 2 ;; + --api-url) API_URL="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --binary) LOCAL_BINARY="$2"; shift 2 ;; + --import-legacy) IMPORT_LEGACY=1; shift ;; + --upgrade) UPGRADE=1; shift ;; + -h|--help) sed -n '2,17p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +fail() { echo "error: $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || fail "please run as root (sudo)" +[ "$(uname -s)" = "Linux" ] || fail "this installer supports Linux; on macOS run \`spectate-agent install\` as your user" +command -v systemctl >/dev/null 2>&1 || fail "systemd is required" + +case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) fail "unsupported architecture $(uname -m)" ;; +esac + +if [ "$UPGRADE" -eq 0 ] && [ "$IMPORT_LEGACY" -eq 0 ] && [ -z "$TOKEN" ]; then + fail "--token is required (or --import-legacy / --upgrade)" +fi + +# 1. Binary +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +if [ -n "$LOCAL_BINARY" ]; then + cp "$LOCAL_BINARY" "$TMP/spectate-agent" +else + if [ "$VERSION" = "latest" ]; then + # Not releases/latest: that stays on the v1 agent (spectated) while v1 + # installs are supported. Releases are listed newest first; pre-releases + # (v2.1.0-rc.1) do not match. + VERSION="$(curl -fsSL -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$REPO/releases?per_page=30" \ + | grep -o '"tag_name": *"v2\.[0-9][0-9]*\.[0-9][0-9]*"' | head -n 1 | sed 's/.*"v\(.*\)"/\1/')" \ + || VERSION="" + [ -n "$VERSION" ] || fail "could not find the latest release; pass --version (see https://github.com/$REPO/releases)" + fi + VERSION="${VERSION#v}" + BASE="https://github.com/$REPO/releases/download/v$VERSION" + echo "Downloading spectate-agent $VERSION (linux/$ARCH)..." + curl -fsSL "$BASE/spectate-agent_linux_$ARCH" -o "$TMP/spectate-agent" + curl -fsSL "$BASE/checksums.txt" -o "$TMP/checksums.txt" + (cd "$TMP" && grep " spectate-agent_linux_$ARCH\$" checksums.txt | sed "s/spectate-agent_linux_$ARCH/spectate-agent/" | sha256sum -c -) \ + || fail "checksum verification failed" +fi +install -m 0755 "$TMP/spectate-agent" "$BIN" +echo "Installed $("$BIN" version)" + +if [ "$UPGRADE" -eq 1 ]; then + systemctl restart spectate-agent + echo "Upgraded and restarted spectate-agent." + exit 0 +fi + +# 2. Unprivileged service user +if ! id "$USER_NAME" >/dev/null 2>&1; then + useradd --system --no-create-home --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$USER_NAME" 2>/dev/null \ + || useradd -r -M -d "$STATE_DIR" -s /sbin/nologin "$USER_NAME" +fi + +# 3. Config + state directories +install -d -m 0755 "$ETC_DIR" +if [ ! -f "$ETC_DIR/agent.env" ]; then + cat > "$ETC_DIR/agent.env" </dev/null 2>&1; then + runuser -u "$USER_NAME" -- env SPECTATE_STATE_DIR="$STATE_DIR" SPECTATE_API_URL="$API_URL" "$@" + else + su -s /bin/sh "$USER_NAME" -c "SPECTATE_STATE_DIR='$STATE_DIR' SPECTATE_API_URL='$API_URL' $*" + fi +} + +if [ -f "$STATE_DIR/state.json" ]; then + echo "Already registered; keeping the existing identity." +elif [ "$IMPORT_LEGACY" -eq 1 ]; then + SPECTATE_STATE_DIR="$STATE_DIR" "$BIN" import-legacy --api-url "$API_URL" + chown "$USER_NAME:$USER_NAME" "$STATE_DIR/state.json" + if command -v spectated >/dev/null 2>&1; then + echo "Removing the v1 agent service (spectated)..." + spectated uninstall || true + fi +else + SPECTATE_INSTALL_TOKEN="$TOKEN" as_agent "$BIN" register --api-url "$API_URL" +fi + +# 5. Service +"$BIN" install --user "$USER_NAME" --env-file "$ETC_DIR/agent.env" +echo +echo "Done. Logs: journalctl -u spectate-agent -f Status: sudo -u $USER_NAME SPECTATE_STATE_DIR=$STATE_DIR $BIN status" diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..d30003e --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,463 @@ +// Package agent wires collection, batching, spooling and shipping. +// +// collect loop ── every collection_interval (aligned to the wall clock) ──► Accumulator +// ship loop ── every next_ingest_in ──► encode + gzip ──► send ──(fail)──► spool +// └── spool non-empty ──► replay oldest first +// +// Failed sends back off exponentially with jitter (honouring Retry-After); +// batches the server rejects as malformed or too large are dropped. On +// shutdown the current window is flushed (or spooled when offline). +package agent + +import ( + "context" + "errors" + "log/slog" + "math/rand/v2" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/spectate/agent/v2/internal/backoff" + "github.com/spectate/agent/v2/internal/collector" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/encode" + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" + "github.com/spectate/agent/v2/internal/spool" + "github.com/spectate/agent/v2/internal/transport" + "github.com/spectate/agent/v2/internal/version" +) + +// maxWindowSamples bounds memory per shipping window (~50 bytes per sample). +const maxWindowSamples = 50_000 + +// Options configures a Runner. Zero values select production defaults. +type Options struct { + Client *transport.Client + Spool *spool.Spool + State config.State + StateDir string // persist server config here ("" disables) + Collectors []collector.Collector + Logger *slog.Logger + + // HostInfo gathers host identity (default collector.HostInfo). + HostInfo func(ctx context.Context) (*pb.HostInfo, error) + // HostInfoRefresh is how often host info is re-gathered (default 10m). + HostInfoRefresh time.Duration + // Backoff for failed sends (default 5s doubling up to 5m, jittered). + Backoff *backoff.Backoff + // DrainPause between replayed spool batches (default 500ms). + DrainPause time.Duration + // ShutdownTimeout bounds the final flush (default 5s). + ShutdownTimeout time.Duration + // FirstShipDelay overrides the randomised delay before the first batch. + FirstShipDelay time.Duration + // IntervalUnit is the duration of one "second" of server config + // (default time.Second; tests use milliseconds). + IntervalUnit time.Duration +} + +// Runner is one running agent. +type Runner struct { + opts Options + log *slog.Logger + instance string + seq atomic.Uint64 + acc *metrics.Accumulator + + mu sync.Mutex + remote config.Remote + shipEvery time.Duration + hostInfo *pb.HostInfo + hostInfoHash string + sendHostInfo bool + retryAt time.Time + cfgChanged chan struct{} + + lastLatencyMs atomic.Int64 + sendFailures atomic.Uint64 + droppedBatches atomic.Uint64 + shipped atomic.Uint64 +} + +// New returns a runner. +func New(opts Options) *Runner { + if opts.Logger == nil { + opts.Logger = slog.Default() + } + if opts.Collectors == nil { + opts.Collectors = collector.System() + } + if opts.HostInfo == nil { + opts.HostInfo = collector.HostInfo + } + if opts.HostInfoRefresh == 0 { + opts.HostInfoRefresh = 10 * time.Minute + } + if opts.Backoff == nil { + opts.Backoff = backoff.New(5*time.Second, 5*time.Minute) + } + if opts.DrainPause == 0 { + opts.DrainPause = 500 * time.Millisecond + } + if opts.IntervalUnit == 0 { + opts.IntervalUnit = time.Second + } + if opts.ShutdownTimeout == 0 { + opts.ShutdownTimeout = 5 * time.Second + } + r := &Runner{ + opts: opts, + log: opts.Logger, + instance: config.NewInstanceID(), + acc: metrics.NewAccumulator(maxWindowSamples), + cfgChanged: make(chan struct{}, 1), + } + r.applyRemote(opts.State.Remote.Normalize(), 0, false) + return r +} + +// Run collects and ships until ctx is cancelled, then flushes. +func (r *Runner) Run(ctx context.Context) error { + r.refreshHostInfo(ctx) + r.log.Info("agent started", "version", version.Version, "instance", r.instance, + "api", r.opts.Client.BaseURL, "spooled_batches", r.opts.Spool.Len()) + + collectCtx, stopCollect := context.WithCancel(ctx) + var wg sync.WaitGroup + wg.Go(func() { r.collectLoop(collectCtx) }) + + r.shipLoop(ctx) + + stopCollect() + wg.Wait() + + flushCtx, cancel := context.WithTimeout(context.Background(), r.opts.ShutdownTimeout) + defer cancel() + r.ship(flushCtx, true) + r.log.Info("agent stopped", "spooled_batches", r.opts.Spool.Len()) + return nil +} + +// Remote returns the active server-driven config. +func (r *Runner) Remote() config.Remote { + r.mu.Lock() + defer r.mu.Unlock() + return r.remote +} + +func (r *Runner) collectLoop(ctx context.Context) { + // Prime counter-based collectors (CPU, network) so the first aligned + // round already has deltas. + r.collectRound(ctx, time.Now()) + + for { + interval := time.Duration(r.Remote().CollectionInterval) * r.opts.IntervalUnit + now := time.Now() + next := now.Truncate(interval).Add(interval) + timer := time.NewTimer(next.Sub(now)) + + select { + case <-ctx.Done(): + timer.Stop() + return + case <-r.cfgChanged: + timer.Stop() + continue + case <-timer.C: + r.collectRound(ctx, next) + } + } +} + +func (r *Runner) collectRound(ctx context.Context, at time.Time) { + remote := r.Remote() + ts := at.UnixMilli() + timeout := max(time.Duration(remote.CollectionInterval)*r.opts.IntervalUnit/2, time.Second) + + for _, c := range r.opts.Collectors { + if !remote.Enabled(c.Name()) { + continue + } + cctx, cancel := context.WithTimeout(ctx, timeout) + err := c.Collect(cctx, at, func(metric string, labels []metrics.Label, value float64) { + r.acc.Add(metric, labels, ts, value) + }) + cancel() + if err != nil && !errors.Is(err, context.Canceled) { + r.log.Debug("collector failed", "collector", c.Name(), "err", err) + } + } +} + +func (r *Runner) shipLoop(ctx context.Context) { + first := r.opts.FirstShipDelay + if first == 0 { + // Spread a fleet's batches over the interval instead of all at :00. + first = time.Duration(rand.Int64N(int64(r.shipInterval()))) + } + shipTimer := time.NewTimer(first) + defer shipTimer.Stop() + retryTimer := time.NewTimer(time.Hour) + retryTimer.Stop() + defer retryTimer.Stop() + hostInfoTicker := time.NewTicker(r.opts.HostInfoRefresh) + defer hostInfoTicker.Stop() + + // Replay anything left over from a previous run. + if r.opts.Spool.Len() > 0 { + r.drain(ctx) + } + + for { + r.armRetry(retryTimer) + select { + case <-ctx.Done(): + return + case <-shipTimer.C: + r.ship(ctx, false) + shipTimer.Reset(r.shipInterval()) + case <-retryTimer.C: + r.drain(ctx) + case <-hostInfoTicker.C: + r.refreshHostInfo(ctx) + } + } +} + +func (r *Runner) armRetry(t *time.Timer) { + r.mu.Lock() + at := r.retryAt + r.mu.Unlock() + t.Stop() + if !at.IsZero() && r.opts.Spool.Len() > 0 { + t.Reset(max(time.Until(at), 0)) + } +} + +func (r *Runner) shipInterval() time.Duration { + r.mu.Lock() + defer r.mu.Unlock() + return r.shipEvery +} + +func (r *Runner) backingOff() bool { + r.mu.Lock() + defer r.mu.Unlock() + return !r.retryAt.IsZero() && time.Now().Before(r.retryAt) +} + +// ship cuts the current window and sends it, spooling on failure. On the +// final flush nothing is replayed: the spool is kept for the next start. +func (r *Runner) ship(ctx context.Context, final bool) { + body, err := r.cut() + if err != nil { + r.log.Error("encode batch", "err", err) + return + } + + if r.opts.Spool.Len() == 0 && !r.backingOff() { + err := r.send(ctx, body) + if err == nil { + return + } + if transport.Classify(err) == transport.Drop { + r.droppedBatches.Add(1) + r.log.Error("batch rejected by server, dropping", "err", err) + return + } + r.failed(err) + } + + if err := r.opts.Spool.Push(body, time.Now()); err != nil { + r.droppedBatches.Add(1) + r.log.Error("spool batch", "err", err) + return + } + if !final && !r.backingOff() { + r.drain(ctx) + } +} + +// drain replays spooled batches oldest first until the spool is empty or a +// send fails. +func (r *Runner) drain(ctx context.Context) { + for ctx.Err() == nil { + entry, body, ok, err := r.opts.Spool.Oldest() + if err != nil { + r.log.Error("read spool", "err", err) + return + } + if !ok { + return + } + err = r.send(ctx, body) + switch { + case err == nil: + case transport.Classify(err) == transport.Drop: + r.droppedBatches.Add(1) + r.log.Error("spooled batch rejected by server, dropping", "batch", entry.Name, "err", err) + default: + r.failed(err) + return + } + if err := r.opts.Spool.Remove(entry); err != nil { + r.log.Error("remove spooled batch", "err", err) + return + } + if r.opts.Spool.Len() > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(r.opts.DrainPause): + } + } + } +} + +func (r *Runner) send(ctx context.Context, body []byte) error { + start := time.Now() + resp, err := r.opts.Client.Ingest(ctx, body) + if err != nil { + r.sendFailures.Add(1) + return err + } + r.lastLatencyMs.Store(time.Since(start).Milliseconds()) + r.shipped.Add(1) + + r.mu.Lock() + r.retryAt = time.Time{} + r.opts.Backoff.Reset() + if resp.ResendHostInfo { + r.sendHostInfo = true + } + r.mu.Unlock() + + r.applyRemote(resp.Config, resp.NextIngestIn, true) + return nil +} + +func (r *Runner) failed(err error) { + r.mu.Lock() + delay := r.opts.Backoff.Next() + if ra := transport.RetryAfter(err); ra > delay { + delay = ra + } + r.retryAt = time.Now().Add(delay) + attempts := r.opts.Backoff.Attempts() + r.mu.Unlock() + + var apiErr *transport.Error + level := slog.LevelWarn + if errors.As(err, &apiErr) && (apiErr.Status == 401 || apiErr.Status == 403) { + level = slog.LevelError // token revoked or host deleted: data spools until fixed + } + r.log.Log(context.Background(), level, "ship failed, spooling", "err", err, "retry_in", delay.Round(time.Second), "attempt", attempts) +} + +// cut drains the window, appends agent health samples and encodes the batch. +func (r *Runner) cut() ([]byte, error) { + now := time.Now() + if r.Remote().Enabled("agent") { + r.healthSamples(now) + } + series := r.acc.Drain() + + r.mu.Lock() + env := encode.Envelope{ + AgentVersion: version.Version, + AgentInstance: r.instance, + BatchSeq: r.seq.Add(1), + CreatedAtMs: now.UnixMilli(), + HostInfoHash: r.hostInfoHash, + } + if r.sendHostInfo && r.hostInfo != nil { + env.HostInfo = r.hostInfo + // Once queued it is delivered in order; if it is lost (spool + // eviction) the server asks again via resend_host_info. + r.sendHostInfo = false + } + r.mu.Unlock() + + return encode.Marshal(encode.Build(env, series)) +} + +func (r *Runner) healthSamples(now time.Time) { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + ts := now.UnixMilli() + add := func(metric string, v float64) { r.acc.Add(metric, nil, ts, v) } + + add("agent.spool.bytes", float64(r.opts.Spool.Bytes())) + add("agent.spool.batches", float64(r.opts.Spool.Len())) + add("agent.batches.dropped", float64(r.droppedBatches.Load()+r.opts.Spool.Dropped())) + add("agent.samples.dropped", float64(r.acc.Dropped())) + add("agent.send.failures", float64(r.sendFailures.Load())) + add("agent.send.latency_ms", float64(r.lastLatencyMs.Load())) + add("agent.memory.heap_mb", float64(int64(ms.HeapAlloc/1024))/1024) + add("agent.memory.sys_mb", float64(int64(ms.Sys/1024))/1024) +} + +func (r *Runner) refreshHostInfo(ctx context.Context) { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + info, err := r.opts.HostInfo(cctx) + if err != nil { + r.log.Warn("gather host info", "err", err) + return + } + hash := encode.HostInfoHash(info) + + r.mu.Lock() + defer r.mu.Unlock() + if hash != r.hostInfoHash { + r.hostInfo = info + r.hostInfoHash = hash + r.sendHostInfo = true + } +} + +// applyRemote installs a server config; nextIngestIn (seconds) overrides the +// shipping interval when positive. +func (r *Runner) applyRemote(remote config.Remote, nextIngestIn int, persist bool) { + remote = remote.Normalize() + + r.mu.Lock() + changed := !remote.Equal(r.remote) + collectionChanged := remote.CollectionInterval != r.remote.CollectionInterval + r.remote = remote + every := remote.IngestInterval + if nextIngestIn > 0 { + every = min(max(nextIngestIn, 10), 600) + } + r.shipEvery = time.Duration(every) * r.opts.IntervalUnit + r.mu.Unlock() + + if !changed { + return + } + for _, c := range r.opts.Collectors { + if cc, ok := c.(collector.Configurable); ok { + cc.Configure(remote) + } + } + if collectionChanged { + select { + case r.cfgChanged <- struct{}{}: + default: + } + } + if persist { + r.log.Info("applied server config", "collection_interval", remote.CollectionInterval, + "ingest_interval", remote.IngestInterval, "collectors", remote.Collectors) + if r.opts.StateDir != "" { + st := r.opts.State + st.Remote = remote + if err := config.SaveState(r.opts.StateDir, st); err != nil { + r.log.Warn("persist server config", "err", err) + } + } + } +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..7e16df0 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,341 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/spectate/agent/v2/internal/backoff" + "github.com/spectate/agent/v2/internal/collector" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/encode" + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" + "github.com/spectate/agent/v2/internal/spool" + "github.com/spectate/agent/v2/internal/transport" +) + +// counter emits a monotonically increasing value so tests can detect loss, +// duplication and reordering across batches. +type counter struct{ n atomic.Int64 } + +func (*counter) Name() string { return "cpu" } + +func (c *counter) Collect(_ context.Context, _ time.Time, emit collector.Emit) error { + emit("test.counter", []metrics.Label{{Key: "k", Value: "v"}}, float64(c.n.Add(1))) + return nil +} + +type received struct { + req *pb.IngestRequest + series []*metrics.Series +} + +// fakeAPI is a minimal v2 ingest endpoint. +type fakeAPI struct { + t *testing.T + mu sync.Mutex + batches []received + failNext int // respond 503 to this many requests + status int // when non-zero, respond with this status + resend bool // ask for host info once + config string // JSON config to return + srv *httptest.Server +} + +func newFakeAPI(t *testing.T) *fakeAPI { + f := &fakeAPI{t: t, config: `{"collection_interval":15,"ingest_interval":60,"collectors":["cpu","agent"]}`} + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeAPI) handle(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agent-api/v2/ingest" || r.Header.Get("Authorization") != "Bearer hst_test" || + r.Header.Get("Content-Encoding") != "gzip" { + http.Error(w, `{"error":{"code":"BAD"}}`, http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + + f.mu.Lock() + defer f.mu.Unlock() + if f.failNext > 0 { + f.failNext-- + w.Header().Set("Retry-After", "0") + http.Error(w, "down", http.StatusServiceUnavailable) + return + } + if f.status != 0 { + w.WriteHeader(f.status) + _, _ = w.Write([]byte(`{"error":{"code":"INVALID_PAYLOAD","message":"nope"}}`)) + return + } + req, err := encode.Unmarshal(body) + if err != nil { + f.t.Errorf("server could not decode batch: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + series, err := encode.Expand(req) + if err != nil { + f.t.Errorf("server could not expand batch: %v", err) + } + f.batches = append(f.batches, received{req, series}) + resend := f.resend + f.resend = false + _ = json.NewEncoder(w).Encode(map[string]any{ + "accepted_samples": 1, + "duplicate": false, + "resend_host_info": resend, + "config": json.RawMessage(f.config), + "next_ingest_in": 60, + }) +} + +func (f *fakeAPI) snapshot() []received { + f.mu.Lock() + defer f.mu.Unlock() + return append([]received(nil), f.batches...) +} + +func (f *fakeAPI) set(fn func(*fakeAPI)) { + f.mu.Lock() + defer f.mu.Unlock() + fn(f) +} + +func newRunner(t *testing.T, api *fakeAPI, sp *spool.Spool, c collector.Collector) *Runner { + t.Helper() + if sp == nil { + var err error + if sp, err = spool.Open(t.TempDir(), 1<<20, time.Hour); err != nil { + t.Fatal(err) + } + } + return New(Options{ + Client: transport.New(api.srv.URL, "hst_test"), + Spool: sp, + State: config.State{HostToken: "hst_test", Remote: config.DefaultRemote()}, + Collectors: []collector.Collector{c}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + HostInfo: func(context.Context) (*pb.HostInfo, error) { + return &pb.HostInfo{Info: []*pb.Label{{Key: "hostname", Value: "test-01"}}}, nil + }, + Backoff: backoff.New(2*time.Millisecond, 10*time.Millisecond), + DrainPause: time.Millisecond, + FirstShipDelay: 20 * time.Millisecond, + IntervalUnit: time.Millisecond, // "15s" collection = 15ms, "60s" shipping = 60ms + HostInfoRefresh: time.Hour, + }) +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(5 * time.Millisecond) + } +} + +// counterValues returns all test.counter values in received order. +func counterValues(batches []received) []float64 { + var out []float64 + for _, b := range batches { + for _, s := range b.series { + if s.Metric == "test.counter" { + out = append(out, s.Values...) + } + } + } + return out +} + +func assertContiguous(t *testing.T, values []float64) { + t.Helper() + for i, v := range values { + if v != float64(i+1) { + t.Fatalf("sample %d = %v: samples lost, duplicated or reordered: %v", i, v, values) + } + } +} + +func TestEndToEndBatchingConfigAndShutdownFlush(t *testing.T) { + api := newFakeAPI(t) + api.config = `{"collection_interval":20,"ingest_interval":60,"collectors":["cpu","agent"],"disk":{"exclude_mountpoints":["/boot"]}}` + c := &counter{} + r := newRunner(t, api, nil, c) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = r.Run(ctx); close(done) }() + + waitFor(t, "3 batches", func() bool { return len(api.snapshot()) >= 3 }) + cancel() + <-done + + batches := api.snapshot() + first := batches[0].req + if first.ProtocolVersion != 2 || first.BatchSeq != 1 || first.HostInfo == nil || first.HostInfoHash == "" { + t.Fatalf("first batch envelope = %+v", first) + } + for i, b := range batches[1:] { + if b.req.HostInfo != nil { + t.Fatalf("batch %d resent unchanged host info", i+2) + } + if b.req.BatchSeq != uint64(i+2) || b.req.AgentInstance != first.AgentInstance { + t.Fatalf("batch %d seq = %d", i+2, b.req.BatchSeq) + } + } + + // Several samples per series per batch: one series dictionary entry, many points. + var multi bool + for _, b := range batches { + for _, s := range b.series { + if s.Metric == "test.counter" && len(s.Values) > 1 { + multi = true + if s.Labels[0] != (metrics.Label{Key: "k", Value: "v"}) { + t.Fatalf("labels = %+v", s.Labels) + } + } + } + } + if !multi { + t.Fatal("expected batches with several samples per series") + } + + // Health metrics ride along. + var health bool + for _, s := range batches[len(batches)-1].series { + health = health || s.Metric == "agent.send.latency_ms" + } + if !health { + t.Fatal("missing agent health series") + } + + // Server config applied; the shutdown flush delivered the last window. + if got := r.Remote(); got.CollectionInterval != 20 || len(got.Disk.ExcludeMountpoints) != 1 { + t.Fatalf("server config not applied: %+v", got) + } + assertContiguous(t, counterValues(batches)) + if int64(len(counterValues(batches))) != c.n.Load() { + t.Fatalf("shipped %d of %d samples", len(counterValues(batches)), c.n.Load()) + } +} + +func TestOutageSpoolsAndReplaysInOrder(t *testing.T) { + api := newFakeAPI(t) + api.failNext = 6 + c := &counter{} + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + r := newRunner(t, api, sp, c) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = r.Run(ctx); close(done) }() + + waitFor(t, "spooling", func() bool { return sp.Len() > 0 }) + waitFor(t, "recovery", func() bool { + return len(api.snapshot()) >= 5 && sp.Len() == 0 + }) + cancel() + <-done + + batches := api.snapshot() + for i := 1; i < len(batches); i++ { + if batches[i].req.BatchSeq <= batches[i-1].req.BatchSeq { + t.Fatalf("replay out of order: seq %d after %d", batches[i].req.BatchSeq, batches[i-1].req.BatchSeq) + } + } + assertContiguous(t, counterValues(batches)) + if r.sendFailures.Load() < 6 { + t.Fatalf("failures = %d", r.sendFailures.Load()) + } +} + +func TestOfflineShutdownSpoolsToDiskAndNextStartReplays(t *testing.T) { + api := newFakeAPI(t) + api.failNext = 1 << 30 // API unreachable for the whole first run + dir := t.TempDir() + sp, _ := spool.Open(dir, 1<<20, time.Hour) + c := &counter{} + r := newRunner(t, api, sp, c) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = r.Run(ctx); close(done) }() + waitFor(t, "spooled batches", func() bool { return sp.Len() >= 2 }) + cancel() + <-done + + if len(api.snapshot()) != 0 { + t.Fatal("nothing should have been accepted") + } + spooled := sp.Len() + + // "Restart": a new process with the API back up replays the spool first. + api.set(func(f *fakeAPI) { f.failNext = 0 }) + sp2, _ := spool.Open(dir, 1<<20, time.Hour) + if sp2.Len() != spooled { + t.Fatalf("spool did not survive restart: %d != %d", sp2.Len(), spooled) + } + r2 := newRunner(t, api, sp2, c) + ctx2, cancel2 := context.WithCancel(context.Background()) + done2 := make(chan struct{}) + go func() { _ = r2.Run(ctx2); close(done2) }() + waitFor(t, "replay", func() bool { return sp2.Len() == 0 && len(api.snapshot()) > spooled }) + cancel2() + <-done2 + + batches := api.snapshot() + if batches[0].req.AgentInstance == batches[len(batches)-1].req.AgentInstance { + t.Fatal("replayed batches keep their original instance id") + } + assertContiguous(t, counterValues(batches)) +} + +func TestRejectedBatchesAreDroppedNotRetried(t *testing.T) { + api := newFakeAPI(t) + api.status = http.StatusRequestEntityTooLarge + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + r := newRunner(t, api, sp, &counter{}) + + ctx := context.Background() + r.collectRound(ctx, time.Now()) + r.ship(ctx, false) + + if sp.Len() != 0 || r.droppedBatches.Load() != 1 || r.backingOff() { + t.Fatalf("413 must drop the batch: spool=%d dropped=%d", sp.Len(), r.droppedBatches.Load()) + } +} + +func TestServerCanRequestHostInfo(t *testing.T) { + api := newFakeAPI(t) + r := newRunner(t, api, nil, &counter{}) + ctx := context.Background() + r.refreshHostInfo(ctx) + + r.ship(ctx, false) // includes host info (first batch) + api.set(func(f *fakeAPI) { f.resend = true }) + r.ship(ctx, false) // without; response asks for it + r.ship(ctx, false) // includes it again + r.ship(ctx, false) // without + + got := api.snapshot() + want := []bool{true, false, true, false} + for i, b := range got { + if (b.req.HostInfo != nil) != want[i] { + t.Fatalf("batch %d host info present = %v, want %v", i+1, b.req.HostInfo != nil, want[i]) + } + } +} diff --git a/internal/backoff/backoff.go b/internal/backoff/backoff.go new file mode 100644 index 0000000..e44b30b --- /dev/null +++ b/internal/backoff/backoff.go @@ -0,0 +1,42 @@ +// Package backoff implements capped exponential backoff with jitter. +package backoff + +import ( + "math/rand/v2" + "time" +) + +// Backoff yields growing, jittered delays: attempt n waits a random duration in +// [d/2, d] where d = min(Max, Base*2^n) ("equal jitter"), so a fleet that lost +// the API at the same moment does not reconnect in lockstep. +type Backoff struct { + Base time.Duration + Max time.Duration + attempt int + rand func() float64 +} + +// New returns a backoff starting at base and capped at max. +func New(base, max time.Duration) *Backoff { + return &Backoff{Base: base, Max: max, rand: rand.Float64} +} + +// Next returns the delay before the next attempt and advances the attempt counter. +func (b *Backoff) Next() time.Duration { + d := b.Base + for i := 0; i < b.attempt && d < b.Max; i++ { + d *= 2 + } + if d > b.Max { + d = b.Max + } + b.attempt++ + half := d / 2 + return half + time.Duration(b.rand()*float64(d-half)) +} + +// Attempts returns the number of consecutive failures recorded. +func (b *Backoff) Attempts() int { return b.attempt } + +// Reset is called after a success. +func (b *Backoff) Reset() { b.attempt = 0 } diff --git a/internal/backoff/backoff_test.go b/internal/backoff/backoff_test.go new file mode 100644 index 0000000..6e2904f --- /dev/null +++ b/internal/backoff/backoff_test.go @@ -0,0 +1,35 @@ +package backoff + +import ( + "testing" + "time" +) + +func TestGrowsAndCaps(t *testing.T) { + b := New(5*time.Second, time.Minute) + b.rand = func() float64 { return 1 } // upper bound + want := []time.Duration{5, 10, 20, 40, 60, 60} + for i, w := range want { + if got := b.Next(); got != w*time.Second { + t.Fatalf("attempt %d: %v, want %v", i, got, w*time.Second) + } + } + b.Reset() + if b.Attempts() != 0 || b.Next() != 5*time.Second { + t.Fatal("reset should restart at base") + } +} + +func TestJitterStaysWithinHalfAndFull(t *testing.T) { + b := New(time.Second, 10*time.Second) + for range 1000 { + b.Reset() + for i := range 6 { + d := b.Next() + full := min(time.Second< full { + t.Fatalf("attempt %d: %v outside [%v, %v]", i, d, full/2, full) + } + } + } +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go new file mode 100644 index 0000000..dcbb546 --- /dev/null +++ b/internal/collector/collector.go @@ -0,0 +1,71 @@ +// Package collector gathers system metrics. Metric keys and units match the v1 +// agent exactly (memory/swap in MB, disk and network in KB, CPU in percent) so +// the server stores v1 and v2 data identically: +// +// system.cpu.{user,system,interrupt,iowait,idle,stolen,guest} +// system.memory.{total,free,used,available,available_percent} +// system.swap.{total,free,used,in,out,free_percent} +// system.load.{1,5,15} +// system.uptime +// system.disk_usage.{total,free,used,used_percent,inodes_*} labels: mountpoint, device, fstype +// system.net.{bytes,packets}_{sent,recv}, {err,drop,fifo}_{in,out} label: interface +// +// Network values are amounts per 60 seconds regardless of the collection +// interval (v1 collected once a minute), so dashboards and thresholds keep +// their meaning at 15s resolution. +package collector + +import ( + "context" + "math" + "strings" + "time" + + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/metrics" +) + +const ( + kb = 1024.0 + mb = 1024.0 * 1024.0 +) + +// Emit records one sample of the current collection round. +type Emit func(metric string, labels []metrics.Label, value float64) + +// Collector produces samples for one subsystem. +type Collector interface { + Name() string + Collect(ctx context.Context, now time.Time, emit Emit) error +} + +// Configurable collectors receive the server-driven config. +type Configurable interface { + Configure(cfg config.Remote) +} + +// System returns the system collectors (the "agent" health collector is +// owned by the runner). +func System() []Collector { + return []Collector{&CPU{}, &Memory{}, &Load{}, &Disk{}, &Net{}, &Uptime{}} +} + +// round rounds to 3 decimals so values fit the compact decimal wire encoding; +// that is far below what any dashboard or threshold distinguishes. +func round(v float64) float64 { return math.Round(v*1000) / 1000 } + +// match reports whether name equals an entry or starts with a `prefix*` entry. +func match(patterns []string, name string, lower bool) bool { + if lower { + name = strings.ToLower(name) + } + for _, p := range patterns { + if lower { + p = strings.ToLower(p) + } + if p == name || (strings.HasSuffix(p, "*") && strings.HasPrefix(name, p[:len(p)-1])) { + return true + } + } + return false +} diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go new file mode 100644 index 0000000..151d3ad --- /dev/null +++ b/internal/collector/collector_test.go @@ -0,0 +1,146 @@ +package collector + +import ( + "context" + "testing" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/net" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/metrics" +) + +type sample struct { + labels []metrics.Label + value float64 +} + +func record() (Emit, map[string][]sample) { + got := map[string][]sample{} + return func(m string, l []metrics.Label, v float64) { got[m] = append(got[m], sample{l, v}) }, got +} + +func TestCPUPercentagesMatchV1Formula(t *testing.T) { + prev := cpu.TimesStat{User: 100, Nice: 0, System: 50, Idle: 800, Iowait: 10, Irq: 5, Softirq: 5, Steal: 0, Guest: 0} + cur := cpu.TimesStat{User: 130, Nice: 0, System: 60, Idle: 850, Iowait: 15, Irq: 7, Softirq: 8, Steal: 0, Guest: 0} + emit, got := record() + CPUPercentages(prev, cur, emit) + + // elapsed = 100 ticks + want := map[string]float64{ + "system.cpu.user": 30, + "system.cpu.system": 15, // system + irq + softirq, as in v1 + "system.cpu.interrupt": 5, + "system.cpu.iowait": 5, + "system.cpu.idle": 50, + "system.cpu.stolen": 0, + "system.cpu.guest": 0, + } + for k, v := range want { + if len(got[k]) != 1 || got[k][0].value != v { + t.Errorf("%s = %v, want %v", k, got[k], v) + } + } +} + +func TestNetDeltasNormaliseToPerMinute(t *testing.T) { + prev := map[string]net.IOCountersStat{ + "eth0": {Name: "eth0", BytesRecv: 1024 * 100, PacketsRecv: 10}, + "docker0": {Name: "docker0"}, + "eth1": {Name: "eth1", BytesRecv: 5000}, + } + cur := []net.IOCountersStat{ + {Name: "eth0", BytesRecv: 1024*100 + 1024*25, PacketsRecv: 20}, + {Name: "docker0", BytesRecv: 999}, + {Name: "eth1", BytesRecv: 10}, // counter reset + {Name: "eth2", BytesRecv: 10}, // new interface, no baseline + } + n := &Net{} + n.Configure(config.DefaultRemote()) + emit, got := record() + NetDeltas(prev, cur, 15*time.Second, n.Excluded, emit) + + recv := got["system.net.bytes_recv"] + if len(recv) != 1 || recv[0].labels[0].Value != "eth0" { + t.Fatalf("only eth0 should report, got %+v", recv) + } + if recv[0].value != 100 { // 25 KB in 15s = 100 KB per minute + t.Fatalf("bytes_recv = %v KB/min, want 100", recv[0].value) + } + if got["system.net.packets_recv"][0].value != 40 { + t.Fatalf("packets_recv = %v, want 40", got["system.net.packets_recv"][0].value) + } + if len(got["system.net.fifo_out"]) != 1 { + t.Fatal("all ten v1 net metrics must be emitted") + } +} + +func TestNetFilterSemantics(t *testing.T) { + n := &Net{} + n.Configure(config.DefaultRemote()) + for iface, excluded := range map[string]bool{ + "eth0": false, "en0": false, "lo0": false, // explicitly included + "docker0": true, "veth12ab": true, "lo": true, "virbr0": true, // wildcard / exact excludes + "ens3": false, "enp0s3": false, "eno1": false, "wlan0": false, "bond0": false, // v1 hid these via en* + } { + if n.Excluded(iface) != excluded { + t.Errorf("Excluded(%q) = %v, want %v", iface, !excluded, excluded) + } + } +} + +func TestDiskFilters(t *testing.T) { + d := &Disk{} + cfg := config.DefaultRemote() + cfg.Disk.ExcludeMountpoints = []string{"/snap*"} + d.Configure(cfg) + cases := []struct { + p disk.PartitionStat + want bool + }{ + {disk.PartitionStat{Mountpoint: "/", Device: "/dev/sda1", Fstype: "ext4"}, false}, + {disk.PartitionStat{Mountpoint: "/boot/efi", Device: "/dev/sda2", Fstype: "VFAT"}, true}, + {disk.PartitionStat{Mountpoint: "/snap/core/1", Device: "/dev/loop0", Fstype: "x"}, true}, + } + for _, c := range cases { + if d.Excluded(c.p) != c.want { + t.Errorf("Excluded(%v) = %v", c.p, !c.want) + } + } + cfg.Disk.IncludeMountpoints = []string{"/data"} + d.Configure(cfg) + if !d.Excluded(disk.PartitionStat{Mountpoint: "/"}) || d.Excluded(disk.PartitionStat{Mountpoint: "/data"}) { + t.Fatal("include_mountpoints should restrict to the listed mounts") + } +} + +// TestSystemCollectorsRunOnThisHost is a smoke test: every collector must run +// without error and emit finite, v1-named metrics on the build host. +func TestSystemCollectorsRunOnThisHost(t *testing.T) { + ctx := context.Background() + emit, got := record() + for _, c := range System() { + if cc, ok := c.(Configurable); ok { + cc.Configure(config.DefaultRemote()) + } + for round := range 2 { // second round yields CPU/net deltas + if round == 1 { + time.Sleep(300 * time.Millisecond) + } + if err := c.Collect(ctx, time.Now(), emit); err != nil { + t.Fatalf("%s: %v", c.Name(), err) + } + } + } + for _, m := range []string{"system.cpu.idle", "system.memory.total", "system.load.1", "system.uptime"} { + if len(got[m]) == 0 { + t.Errorf("missing %s", m) + } + } + info, err := HostInfo(ctx) + if err != nil || Hostname(info) == "" { + t.Fatalf("host info: %v", err) + } +} diff --git a/internal/collector/hostinfo.go b/internal/collector/hostinfo.go new file mode 100644 index 0000000..41f5c40 --- /dev/null +++ b/internal/collector/hostinfo.go @@ -0,0 +1,87 @@ +package collector + +import ( + "context" + "strconv" + "strings" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/host" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" +) + +// HostInfo gathers the host identity with the same keys the v1 agent sent as +// `host.info` / `host.cpus` INFO metrics. +func HostInfo(ctx context.Context) (*pb.HostInfo, error) { + h, err := host.InfoWithContext(ctx) + if err != nil { + return nil, err + } + info := &pb.HostInfo{Info: labels( + "hostname", h.Hostname, + "os", h.OS, + "platform", h.Platform, + "platform_family", h.PlatformFamily, + "platform_version", h.PlatformVersion, + "kernel_version", h.KernelVersion, + "kernel_arch", h.KernelArch, + "virtualization_system", h.VirtualizationSystem, + "virtualization_role", h.VirtualizationRole, + "host_id", h.HostID, + )} + + cpus, err := cpu.InfoWithContext(ctx) + if err == nil { + for _, c := range cpus { + info.Cpus = append(info.Cpus, &pb.HostInfo_Cpu{Info: labels( + "vendor_id", c.VendorID, + "family", c.Family, + "model", c.Model, + "stepping", strconv.Itoa(int(c.Stepping)), + "physical_id", c.PhysicalID, + "core_id", c.CoreID, + "cores", strconv.Itoa(int(c.Cores)), + "model_name", c.ModelName, + "mhz", strconv.Itoa(int(c.Mhz)), + "cache_size", strconv.Itoa(int(c.CacheSize)), + "flags", strings.Join(c.Flags, ","), + "microcode", c.Microcode, + )}) + } + } + return info, nil +} + +// HostInfoMaps converts host info to the JSON shape of the register endpoint. +func HostInfoMaps(info *pb.HostInfo) (map[string]string, []map[string]string) { + toMap := func(ls []*pb.Label) map[string]string { + m := make(map[string]string, len(ls)) + for _, l := range ls { + m[l.Key] = l.Value + } + return m + } + var cpus []map[string]string + for _, c := range info.GetCpus() { + cpus = append(cpus, toMap(c.Info)) + } + return toMap(info.GetInfo()), cpus +} + +// Hostname returns the hostname from host info. +func Hostname(info *pb.HostInfo) string { + for _, l := range info.GetInfo() { + if l.Key == "hostname" { + return l.Value + } + } + return "" +} + +func labels(kv ...string) []*pb.Label { + out := make([]*pb.Label, 0, len(kv)/2) + for i := 0; i+1 < len(kv); i += 2 { + out = append(out, &pb.Label{Key: kv[i], Value: kv[i+1]}) + } + return out +} diff --git a/internal/collector/system.go b/internal/collector/system.go new file mode 100644 index 0000000..999a216 --- /dev/null +++ b/internal/collector/system.go @@ -0,0 +1,279 @@ +package collector + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/metrics" +) + +// CPU reports the share of CPU time per state since the previous round. +type CPU struct { + last *cpu.TimesStat +} + +func (*CPU) Name() string { return "cpu" } + +func (c *CPU) Collect(ctx context.Context, _ time.Time, emit Emit) error { + times, err := cpu.TimesWithContext(ctx, false) + if err != nil { + return err + } + if len(times) == 0 { + return errors.New("no cpu times") + } + cur := times[0] + prev := c.last + c.last = &cur + if prev == nil { + return nil + } + CPUPercentages(*prev, cur, emit) + return nil +} + +// CPUPercentages emits the v1 agent's CPU breakdown between two samples. +func CPUPercentages(prev, cur cpu.TimesStat, emit Emit) { + total := func(t cpu.TimesStat) float64 { + return t.User + t.System + t.Idle + t.Nice + t.Iowait + t.Irq + t.Softirq + t.Steal + } + elapsed := total(cur) - total(prev) + if elapsed <= 0 { + return + } + pct := func(d float64) float64 { return round(max(0, min(100, d/elapsed*100))) } + + emit("system.cpu.user", nil, pct((cur.User+cur.Nice)-(prev.User+prev.Nice))) + emit("system.cpu.system", nil, pct((cur.System+cur.Irq+cur.Softirq)-(prev.System+prev.Irq+prev.Softirq))) + emit("system.cpu.interrupt", nil, pct((cur.Irq+cur.Softirq)-(prev.Irq+prev.Softirq))) + emit("system.cpu.iowait", nil, pct(cur.Iowait-prev.Iowait)) + emit("system.cpu.idle", nil, pct(cur.Idle-prev.Idle)) + emit("system.cpu.stolen", nil, pct(cur.Steal-prev.Steal)) + emit("system.cpu.guest", nil, pct(cur.Guest-prev.Guest)) +} + +// Memory reports RAM and swap. +type Memory struct{} + +func (Memory) Name() string { return "memory" } + +func (Memory) Collect(ctx context.Context, _ time.Time, emit Emit) error { + v, err := mem.VirtualMemoryWithContext(ctx) + if err != nil { + return err + } + emit("system.memory.total", nil, round(float64(v.Total)/mb)) + emit("system.memory.free", nil, round(float64(v.Free)/mb)) + emit("system.memory.used", nil, round(float64(v.Used)/mb)) + emit("system.memory.available", nil, round(float64(v.Available)/mb)) + emit("system.memory.available_percent", nil, round(100-v.UsedPercent)) + + s, err := mem.SwapMemoryWithContext(ctx) + if err != nil { + return nil // no swap information (e.g. some containers) + } + emit("system.swap.total", nil, round(float64(s.Total)/mb)) + emit("system.swap.free", nil, round(float64(s.Free)/mb)) + emit("system.swap.used", nil, round(float64(s.Used)/mb)) + emit("system.swap.in", nil, round(float64(s.Sin)/mb)) + emit("system.swap.out", nil, round(float64(s.Sout)/mb)) + freePct := 100.0 + if s.Total > 0 { + freePct = 100 - s.UsedPercent + } + emit("system.swap.free_percent", nil, round(freePct)) + return nil +} + +// Load reports load averages. +type Load struct{} + +func (Load) Name() string { return "load" } + +func (Load) Collect(ctx context.Context, _ time.Time, emit Emit) error { + l, err := load.AvgWithContext(ctx) + if err != nil { + return err + } + emit("system.load.1", nil, round(l.Load1)) + emit("system.load.5", nil, round(l.Load5)) + emit("system.load.15", nil, round(l.Load15)) + return nil +} + +// Uptime reports seconds since boot. +type Uptime struct{} + +func (Uptime) Name() string { return "uptime" } + +func (Uptime) Collect(ctx context.Context, _ time.Time, emit Emit) error { + u, err := host.UptimeWithContext(ctx) + if err != nil { + return err + } + emit("system.uptime", nil, float64(u)) + return nil +} + +// Disk reports usage per mounted filesystem. +type Disk struct { + mu sync.RWMutex + includeMountpoints []string + excludeMountpoints []string + excludeDevices []string + excludeFilesystems []string +} + +func (*Disk) Name() string { return "disk" } + +func (d *Disk) Configure(cfg config.Remote) { + d.mu.Lock() + defer d.mu.Unlock() + d.includeMountpoints = cfg.Disk.IncludeMountpoints + d.excludeMountpoints = cfg.Disk.ExcludeMountpoints + d.excludeDevices = cfg.Disk.ExcludeDevices + d.excludeFilesystems = cfg.Disk.ExcludeFilesystems +} + +// Excluded applies the include/exclude lists (case-insensitive, `prefix*` wildcards). +func (d *Disk) Excluded(p disk.PartitionStat) bool { + d.mu.RLock() + defer d.mu.RUnlock() + if len(d.includeMountpoints) > 0 && !match(d.includeMountpoints, p.Mountpoint, true) { + return true + } + return match(d.excludeFilesystems, p.Fstype, true) || + match(d.excludeDevices, p.Device, true) || + match(d.excludeMountpoints, p.Mountpoint, true) +} + +func (d *Disk) Collect(ctx context.Context, _ time.Time, emit Emit) error { + parts, err := disk.PartitionsWithContext(ctx, false) + if err != nil && len(parts) == 0 { + return err + } + seen := make(map[string]bool, len(parts)) + for _, p := range parts { + if seen[p.Mountpoint] || d.Excluded(p) { + continue + } + seen[p.Mountpoint] = true + + u, err := disk.UsageWithContext(ctx, p.Mountpoint) + if err != nil || u.Total == 0 { + continue + } + labels := []metrics.Label{{Key: "mountpoint", Value: p.Mountpoint}, {Key: "device", Value: p.Device}, {Key: "fstype", Value: p.Fstype}} + emit("system.disk_usage.total", labels, round(float64(u.Total)/kb)) + emit("system.disk_usage.free", labels, round(float64(u.Free)/kb)) + emit("system.disk_usage.used", labels, round(float64(u.Used)/kb)) + emit("system.disk_usage.used_percent", labels, round(u.UsedPercent)) + emit("system.disk_usage.inodes_total", labels, float64(u.InodesTotal)) + emit("system.disk_usage.inodes_used", labels, float64(u.InodesUsed)) + emit("system.disk_usage.inodes_free", labels, float64(u.InodesFree)) + emit("system.disk_usage.inodes_used_percent", labels, round(u.InodesUsedPercent)) + } + return nil +} + +// Net reports per-interface traffic since the previous round, normalised to +// amounts per 60 seconds. +type Net struct { + mu sync.RWMutex + include []string + exclude []string + last map[string]net.IOCountersStat + lastTime time.Time +} + +func (*Net) Name() string { return "net" } + +func (n *Net) Configure(cfg config.Remote) { + n.mu.Lock() + defer n.mu.Unlock() + n.include = cfg.Net.IncludeInterfaces + n.exclude = cfg.Net.ExcludeInterfaces +} + +// Excluded applies the v1 semantics: include (exact) beats exclude (wildcards). +func (n *Net) Excluded(iface string) bool { + n.mu.RLock() + defer n.mu.RUnlock() + if match(n.include, iface, false) { + return false + } + return match(n.exclude, iface, false) +} + +func (n *Net) Collect(ctx context.Context, now time.Time, emit Emit) error { + counters, err := net.IOCountersWithContext(ctx, true) + if err != nil { + return err + } + prev, prevTime := n.last, n.lastTime + n.last = make(map[string]net.IOCountersStat, len(counters)) + n.lastTime = now + for _, c := range counters { + n.last[c.Name] = c + } + if prev == nil { + return nil + } + NetDeltas(prev, counters, now.Sub(prevTime), n.Excluded, emit) + return nil +} + +// NetDeltas emits per-interface deltas scaled to 60 seconds. Interfaces whose +// counters went backwards (reset, reattach) are skipped for this round. +func NetDeltas(prev map[string]net.IOCountersStat, cur []net.IOCountersStat, elapsed time.Duration, excluded func(string) bool, emit Emit) { + if elapsed <= 0 { + return + } + scale := 60 / elapsed.Seconds() + for _, c := range cur { + p, ok := prev[c.Name] + if !ok || excluded(c.Name) { + continue + } + pairs := [...]struct { + metric string + cur, prev uint64 + bytes bool + }{ + {"system.net.bytes_sent", c.BytesSent, p.BytesSent, true}, + {"system.net.bytes_recv", c.BytesRecv, p.BytesRecv, true}, + {"system.net.packets_sent", c.PacketsSent, p.PacketsSent, false}, + {"system.net.packets_recv", c.PacketsRecv, p.PacketsRecv, false}, + {"system.net.err_in", c.Errin, p.Errin, false}, + {"system.net.err_out", c.Errout, p.Errout, false}, + {"system.net.drop_in", c.Dropin, p.Dropin, false}, + {"system.net.drop_out", c.Dropout, p.Dropout, false}, + {"system.net.fifo_in", c.Fifoin, p.Fifoin, false}, + {"system.net.fifo_out", c.Fifoout, p.Fifoout, false}, + } + reset := false + for _, pr := range pairs { + reset = reset || pr.cur < pr.prev + } + if reset { + continue + } + labels := []metrics.Label{{Key: "interface", Value: c.Name}} + for _, pr := range pairs { + delta := float64(pr.cur-pr.prev) * scale + if pr.bytes { + delta /= kb + } + emit(pr.metric, labels, round(delta)) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4d0106d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,228 @@ +// Package config holds the agent's local settings (environment), its +// persisted state (host token, last server config) and the server-driven +// remote config. +package config + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "github.com/spectate/agent/v2/internal/version" +) + +// Remote is the server-driven config returned on register and every ingest. +// Defaults mirror laravel/config/host_agent.php so an agent that never reached +// the API behaves like one that did. +type Remote struct { + CollectionInterval int `json:"collection_interval"` + IngestInterval int `json:"ingest_interval"` + Collectors []string `json:"collectors"` + Disk struct { + IncludeMountpoints []string `json:"include_mountpoints"` + ExcludeMountpoints []string `json:"exclude_mountpoints"` + ExcludeDevices []string `json:"exclude_devices"` + ExcludeFilesystems []string `json:"exclude_filesystems"` + } `json:"disk"` + Net struct { + IncludeInterfaces []string `json:"include_interfaces"` + ExcludeInterfaces []string `json:"exclude_interfaces"` + } `json:"net"` +} + +// DefaultRemote returns the built-in remote config. +func DefaultRemote() Remote { + var r Remote + r.CollectionInterval = 15 + r.IngestInterval = 60 + r.Collectors = []string{"cpu", "memory", "load", "disk", "net", "uptime", "agent"} + r.Disk.ExcludeFilesystems = []string{"squashfs", "vfat"} + r.Net.IncludeInterfaces = []string{"en0", "eth0", "lo0"} + r.Net.ExcludeInterfaces = []string{"lo", "br*", "bridge*", "docker*", "veth*", "virbr*", "cni*", "flannel*", "cali*", "utun*", "llw*", "ap*", "anpi*", "awdl*", "gif*", "stf*"} + return r +} + +// Normalize clamps intervals to sane bounds; a zero-value Remote becomes the default. +func (r Remote) Normalize() Remote { + d := DefaultRemote() + if r.IngestInterval == 0 && r.CollectionInterval == 0 && r.Collectors == nil { + return d + } + if r.IngestInterval <= 0 { + r.IngestInterval = d.IngestInterval + } + r.IngestInterval = min(max(r.IngestInterval, 30), 600) + if r.CollectionInterval <= 0 { + r.CollectionInterval = d.CollectionInterval + } + r.CollectionInterval = min(max(r.CollectionInterval, 10), r.IngestInterval) + return r +} + +// Enabled reports whether a collector is enabled. +func (r Remote) Enabled(name string) bool { return slices.Contains(r.Collectors, name) } + +// Equal compares two configs. +func (r Remote) Equal(o Remote) bool { + a, _ := json.Marshal(r) + b, _ := json.Marshal(o) + return string(a) == string(b) +} + +// Settings are local, operator-controlled settings read from the environment +// (systemd: EnvironmentFile=/etc/spectate-agent/agent.env). +type Settings struct { + APIURL string + StateDir string + SpoolMaxBytes int64 + SpoolMaxAge time.Duration + LogLevel string +} + +// LoadSettings reads SPECTATE_* environment variables. +func LoadSettings() Settings { + s := Settings{ + APIURL: env("SPECTATE_API_URL", ""), + StateDir: env("SPECTATE_STATE_DIR", env("STATE_DIRECTORY", defaultStateDir())), + SpoolMaxBytes: 50 << 20, + SpoolMaxAge: 24 * time.Hour, + LogLevel: env("SPECTATE_LOG_LEVEL", "info"), + } + if v, err := strconv.ParseInt(env("SPECTATE_SPOOL_MAX_MB", ""), 10, 64); err == nil && v > 0 { + s.SpoolMaxBytes = v << 20 + } + if v, err := time.ParseDuration(env("SPECTATE_SPOOL_MAX_AGE", "")); err == nil && v > 0 { + s.SpoolMaxAge = v + } + // systemd may pass several colon-separated state directories. + if i := strings.IndexByte(s.StateDir, ':'); i > 0 { + s.StateDir = s.StateDir[:i] + } + return s +} + +func defaultStateDir() string { + if runtime.GOOS == "darwin" { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, "Library", "Application Support", "spectate-agent") + } + } + return "/var/lib/spectate-agent" +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +// State is persisted in /state.json (mode 0600). +type State struct { + APIURL string `json:"api_url"` + HostToken string `json:"host_token"` + HostUUID string `json:"host_uuid,omitempty"` + Remote Remote `json:"config"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ErrNotRegistered is returned when no host token is stored. +var ErrNotRegistered = errors.New("agent is not registered: run `spectate-agent register --token `") + +// StatePath returns the state file path in dir. +func StatePath(dir string) string { return filepath.Join(dir, "state.json") } + +// LoadState reads the persisted state. +func LoadState(dir string) (State, error) { + var st State + data, err := os.ReadFile(StatePath(dir)) + if errors.Is(err, os.ErrNotExist) { + return st, ErrNotRegistered + } + if err != nil { + return st, err + } + if err := json.Unmarshal(data, &st); err != nil { + return st, fmt.Errorf("parse %s: %w", StatePath(dir), err) + } + if st.HostToken == "" { + return st, ErrNotRegistered + } + st.Remote = st.Remote.Normalize() + if st.APIURL == "" { + st.APIURL = version.DefaultAPIURL + } + return st, nil +} + +// SaveState writes the state atomically. +func SaveState(dir string, st State) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + st.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + tmp := StatePath(dir) + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, StatePath(dir)) +} + +// NewInstanceID returns a random id identifying this agent process's batches. +func NewInstanceID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// LegacyToken reads the host token of the v1 agent (spectated) so a host can +// switch agents without registering again. It understands the YAML the v1 +// agent writes (`host:\n token: hst_...`). +func LegacyToken(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + inHost := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indented := line[0] == ' ' || line[0] == '\t' + if !indented { + inHost = trimmed == "host:" + continue + } + if inHost && strings.HasPrefix(trimmed, "token:") { + token := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "token:")), `"'`) + if token != "" { + return token, nil + } + } + } + return "", fmt.Errorf("no host.token in %s", path) +} + +// LegacyConfigPath is where the v1 agent keeps its config. +func LegacyConfigPath() string { + if runtime.GOOS == "darwin" { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".spectated", "config.yaml") + } + } + return "/etc/spectated/config.yaml" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d67de3e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestStateRoundTripAndPermissions(t *testing.T) { + dir := t.TempDir() + if _, err := LoadState(dir); !errors.Is(err, ErrNotRegistered) { + t.Fatalf("missing state should be ErrNotRegistered, got %v", err) + } + if err := SaveState(dir, State{APIURL: "https://x", HostToken: "hst_a", HostUUID: "u"}); err != nil { + t.Fatal(err) + } + st, err := LoadState(dir) + if err != nil || st.HostToken != "hst_a" || st.Remote.IngestInterval != 60 { + t.Fatalf("state = %+v, %v", st, err) + } + info, _ := os.Stat(StatePath(dir)) + if info.Mode().Perm() != 0o600 { + t.Fatalf("state file mode = %v, want 0600 (it holds the host token)", info.Mode().Perm()) + } +} + +func TestNormalizeClamps(t *testing.T) { + r := Remote{CollectionInterval: 1, IngestInterval: 5000, Collectors: []string{"cpu"}}.Normalize() + if r.CollectionInterval != 10 || r.IngestInterval != 600 { + t.Fatalf("clamped = %+v", r) + } + r = Remote{CollectionInterval: 120, IngestInterval: 60, Collectors: []string{}}.Normalize() + if r.CollectionInterval != 60 { + t.Fatal("collection interval must not exceed ingest interval") + } + if !(Remote{}).Normalize().Equal(DefaultRemote()) { + t.Fatal("zero config should become the default") + } +} + +func TestLegacyToken(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + yaml := "checks:\n net:\n token: not-this\nhost:\n token: hst_legacy123\ntelemetry:\n error_reporting: true\nversion: 1\n" + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + token, err := LegacyToken(path) + if err != nil || token != "hst_legacy123" { + t.Fatalf("token = %q, %v", token, err) + } + _ = os.WriteFile(path, []byte("host:\n token: \"\"\n"), 0o600) + if _, err := LegacyToken(path); err == nil { + t.Fatal("empty token must be an error") + } +} diff --git a/internal/encode/decode.go b/internal/encode/decode.go new file mode 100644 index 0000000..98611d6 --- /dev/null +++ b/internal/encode/decode.go @@ -0,0 +1,95 @@ +package encode + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" + "google.golang.org/protobuf/proto" +) + +// Unmarshal decompresses and parses a gzip-compressed IngestRequest. It is the +// reference decoder used by tests; the server implements the same rules in PHP. +func Unmarshal(body []byte) (*pb.IngestRequest, error) { + r, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + raw, err := io.ReadAll(r) + if err != nil { + return nil, err + } + req := &pb.IngestRequest{} + if err := proto.Unmarshal(raw, req); err != nil { + return nil, err + } + return req, nil +} + +// Expand reverses Build: it resolves the string table and delta encodings. +func Expand(req *pb.IngestRequest) ([]*metrics.Series, error) { + str := func(i uint32) (string, error) { + if int(i) >= len(req.Strings) { + if i == 0 { + return "", nil + } + return "", fmt.Errorf("unknown string %d", i) + } + return req.Strings[i], nil + } + + out := make([]*metrics.Series, 0, len(req.Series)) + for n, s := range req.Series { + name, err := str(s.Metric) + if err != nil { + return nil, fmt.Errorf("series[%d]: %w", n, err) + } + if len(s.Labels)%2 != 0 { + return nil, fmt.Errorf("series[%d]: odd label refs", n) + } + series := &metrics.Series{Metric: name} + for i := 0; i < len(s.Labels); i += 2 { + k, err := str(s.Labels[i]) + if err != nil { + return nil, err + } + v, err := str(s.Labels[i+1]) + if err != nil { + return nil, err + } + series.Labels = append(series.Labels, metrics.Label{Key: k, Value: v}) + } + + ts := req.BaseTimestampMs + for _, d := range s.TimestampDeltas { + ts += d + series.Timestamps = append(series.Timestamps, ts) + } + + switch { + case len(s.Values) > 0: + series.Values = append(series.Values, s.Values...) + default: + if s.Scale > MaxScale+9 { + return nil, fmt.Errorf("series[%d]: scale %d", n, s.Scale) + } + p := 1.0 + for range s.Scale { + p *= 10 + } + var sum int64 + for _, d := range s.ValueDeltas { + sum += d + series.Values = append(series.Values, float64(sum)/p) + } + } + if len(series.Values) != len(series.Timestamps) { + return nil, fmt.Errorf("series[%d]: %d values for %d timestamps", n, len(series.Values), len(series.Timestamps)) + } + out = append(out, series) + } + return out, nil +} diff --git a/internal/encode/encode.go b/internal/encode/encode.go new file mode 100644 index 0000000..bea51da --- /dev/null +++ b/internal/encode/encode.go @@ -0,0 +1,183 @@ +// Package encode turns a shipping window into a gzip-compressed v2 +// IngestRequest (see laravel/proto/spectate/agent/v2/ingest.proto). +package encode + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "sync" + + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" + "google.golang.org/protobuf/proto" +) + +// ProtocolVersion is the wire protocol implemented by this package. +const ProtocolVersion = 2 + +// MaxScale is the largest decimal scale tried for the decimal value encoding. +const MaxScale = 6 + +// maxExactInt is 2^53: integers up to this magnitude are exact as float64. +const maxExactInt = 1 << 53 + +var pow10 = [...]float64{1, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6} + +// Envelope is the per-batch metadata. +type Envelope struct { + AgentVersion string + AgentInstance string + BatchSeq uint64 + CreatedAtMs int64 + HostInfoHash string + HostInfo *pb.HostInfo // nil unless it must be (re)sent +} + +// Build assembles the IngestRequest for one window. +func Build(env Envelope, series []*metrics.Series) *pb.IngestRequest { + req := &pb.IngestRequest{ + ProtocolVersion: ProtocolVersion, + AgentVersion: env.AgentVersion, + AgentInstance: env.AgentInstance, + BatchSeq: env.BatchSeq, + CreatedAtMs: env.CreatedAtMs, + HostInfoHash: env.HostInfoHash, + HostInfo: env.HostInfo, + } + + strs := newStringTable() + base := int64(math.MaxInt64) + for _, s := range series { + if len(s.Timestamps) > 0 && s.Timestamps[0] < base { + base = s.Timestamps[0] + } + } + if base == math.MaxInt64 { + base = 0 + } + req.BaseTimestampMs = base + + req.Series = make([]*pb.Series, 0, len(series)) + for _, s := range series { + if len(s.Timestamps) == 0 || len(s.Timestamps) != len(s.Values) { + continue + } + + out := &pb.Series{ + Metric: strs.ref(s.Metric), + TimestampDeltas: make([]int64, len(s.Timestamps)), + } + if len(s.Labels) > 0 { + out.Labels = make([]uint32, 0, 2*len(s.Labels)) + for _, l := range s.Labels { + out.Labels = append(out.Labels, strs.ref(l.Key), strs.ref(l.Value)) + } + } + + prev := base + for i, ts := range s.Timestamps { + out.TimestampDeltas[i] = ts - prev + prev = ts + } + + if scale, ints, ok := DecimalEncode(s.Values); ok { + out.Scale = uint32(scale) + out.ValueDeltas = make([]int64, len(ints)) + var p int64 + for i, v := range ints { + out.ValueDeltas[i] = v - p + p = v + } + } else { + out.Values = append([]float64(nil), s.Values...) + } + + req.Series = append(req.Series, out) + } + + req.Strings = strs.list + return req +} + +// DecimalEncode finds the smallest scale (0..MaxScale) at which every value is +// exactly float64(int)/10^scale, the operation the server performs when +// decoding. It returns the scaled integers, or ok=false when only the raw +// double encoding is lossless. +func DecimalEncode(values []float64) (scale int, ints []int64, ok bool) { + ints = make([]int64, len(values)) +outer: + for scale = 0; scale <= MaxScale; scale++ { + p := pow10[scale] + for i, v := range values { + r := math.Round(v * p) + if math.Abs(r) > maxExactInt || r/p != v || (v == 0 && math.Signbit(v)) { + continue outer + } + ints[i] = int64(r) + } + return scale, ints, true + } + return 0, nil, false +} + +// Marshal serialises and gzip-compresses the request. +func Marshal(req *pb.IngestRequest) ([]byte, error) { + raw, err := proto.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal ingest request: %w", err) + } + return Gzip(raw) +} + +var gzipPool = sync.Pool{New: func() any { + w, _ := gzip.NewWriterLevel(nil, gzip.BestCompression) + return w +}} + +// Gzip compresses b. +func Gzip(b []byte) ([]byte, error) { + var buf bytes.Buffer + buf.Grow(len(b)/3 + 64) + + w := gzipPool.Get().(*gzip.Writer) + defer gzipPool.Put(w) + w.Reset(&buf) + + if _, err := w.Write(b); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// HostInfoHash is the hex sha256 of the deterministic encoding of info. +func HostInfoHash(info *pb.HostInfo) string { + b, _ := proto.MarshalOptions{Deterministic: true}.Marshal(info) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +type stringTable struct { + index map[string]uint32 + list []string +} + +func newStringTable() *stringTable { + return &stringTable{index: map[string]uint32{"": 0}, list: []string{""}} +} + +func (t *stringTable) ref(s string) uint32 { + if i, ok := t.index[s]; ok { + return i + } + i := uint32(len(t.list)) + t.index[s] = i + t.list = append(t.list, s) + return i +} diff --git a/internal/encode/encode_test.go b/internal/encode/encode_test.go new file mode 100644 index 0000000..998d4ff --- /dev/null +++ b/internal/encode/encode_test.go @@ -0,0 +1,259 @@ +package encode + +import ( + "encoding/json" + "flag" + "math" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" +) + +var update = flag.Bool("update", false, "rewrite testdata golden files") + +func TestDecimalEncode(t *testing.T) { + cases := []struct { + name string + values []float64 + scale int + ok bool + }{ + {"integers", []float64{0, 1, 1024, -7, 1 << 40}, 0, true}, + {"two decimals", []float64{0.75, 1.25, 0.5}, 2, true}, + {"awkward fractions", []float64{0.1, 0.2, 0.3}, 1, true}, + {"mixed", []float64{15923.445, 99.999, 1e-6}, 6, true}, + {"irrational", []float64{1.0 / 3}, 0, false}, + {"pi", []float64{math.Pi}, 0, false}, + {"negative zero", []float64{math.Copysign(0, -1)}, 0, false}, + {"beyond 2^53", []float64{1e17}, 0, false}, + {"too big for scale", []float64{1e12 + 0.1234567}, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + scale, ints, ok := DecimalEncode(c.values) + if ok != c.ok { + t.Fatalf("ok = %v, want %v", ok, c.ok) + } + if !ok { + return + } + if scale != c.scale { + t.Fatalf("scale = %d, want %d", scale, c.scale) + } + for i, v := range c.values { + if got := float64(ints[i]) / pow10[scale]; got != v { + t.Fatalf("value %d: %v round-trips to %v", i, v, got) + } + } + }) + } +} + +func TestBuildRoundTrip(t *testing.T) { + acc := metrics.NewAccumulator(1000) + disk := []metrics.Label{{Key: "mountpoint", Value: "/"}, {Key: "device", Value: "/dev/sda1"}, {Key: "fstype", Value: "ext4"}} + base := int64(1_792_490_400_250) + for i := range int64(4) { + ts := base + i*15_000 + acc.Add("system.cpu.idle", nil, ts, 70.125-float64(i)) + acc.Add("system.disk_usage.used_percent", disk, ts, 41.237) + acc.Add("system.disk_usage.total", disk, ts, 102_687_744) + acc.Add("custom.ratio", []metrics.Label{{Key: "zone", Value: "eu"}}, ts+int64(i), 1.0/3+float64(i)) + } + acc.Add("late", nil, base-5_000, -1.5) // earlier than everything: becomes the base + + series := acc.Drain() + req := Build(Envelope{AgentVersion: "2.0.0", AgentInstance: "i", BatchSeq: 3}, series) + + if req.BaseTimestampMs != base-5_000 { + t.Fatalf("base = %d", req.BaseTimestampMs) + } + if req.Strings[0] != "" { + t.Fatal("string 0 must be empty") + } + // Labels are shared through the string table: "/" appears once. + count := 0 + for _, s := range req.Strings { + if s == "/" { + count++ + } + } + if count != 1 { + t.Fatalf(`"/" appears %d times in the string table`, count) + } + if req.Series[0].Scale != 3 || len(req.Series[0].Values) != 0 { + t.Fatalf("cpu.idle should use decimal encoding at scale 3, got scale %d", req.Series[0].Scale) + } + if len(req.Series[3].Values) != 4 { + t.Fatal("custom.ratio should fall back to doubles") + } + if got := req.Series[1].TimestampDeltas; !reflect.DeepEqual(got, []int64{5_000, 15_000, 15_000, 15_000}) { + t.Fatalf("timestamp deltas = %v", got) + } + + body, err := Marshal(req) + if err != nil { + t.Fatal(err) + } + decoded, err := Unmarshal(body) + if err != nil { + t.Fatal(err) + } + expanded, err := Expand(decoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(expanded, series) { + t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", expanded, series) + } +} + +func TestHostInfoHashIsStable(t *testing.T) { + a := &pb.HostInfo{Info: []*pb.Label{{Key: "hostname", Value: "web-01"}}} + b := &pb.HostInfo{Info: []*pb.Label{{Key: "hostname", Value: "web-01"}}} + c := &pb.HostInfo{Info: []*pb.Label{{Key: "hostname", Value: "web-02"}}} + if HostInfoHash(a) != HostInfoHash(b) || HostInfoHash(a) == HostInfoHash(c) || len(HostInfoHash(a)) != 64 { + t.Fatal("host info hash must be a stable sha256 hex digest") + } +} + +// TestGoldenBatch writes the fixture the Laravel suite decodes +// (tests/Feature/HostAgentV2Test.php: "accepts a batch produced by the Go agent"). +// Regenerate with: go test ./internal/encode -run Golden -update +func TestGoldenBatch(t *testing.T) { + acc := metrics.NewAccumulator(1000) + base := int64(1_792_490_400_000) // 2026-10-20 10:00:00 UTC + disk := []metrics.Label{{Key: "mountpoint", Value: "/"}, {Key: "device", Value: "/dev/nvme0n1p1"}, {Key: "fstype", Value: "ext4"}} + idle := []float64{71.234, 68.5, 65.125, 60.0} + for i, v := range idle { + ts := base + int64(i)*15_000 + acc.Add("system.cpu.idle", nil, ts, v) + acc.Add("system.disk_usage.used_percent", disk, ts, 41.237) + acc.Add("system.net.bytes_recv", []metrics.Label{{Key: "interface", Value: "eth0"}}, ts, 3391.5+float64(i)) + } + info := &pb.HostInfo{Info: []*pb.Label{{Key: "hostname", Value: "golden-01"}, {Key: "os", Value: "linux"}}} + req := Build(Envelope{ + AgentVersion: "2.0.0-golden", + AgentInstance: "golden", + BatchSeq: 1, + CreatedAtMs: base + 60_000, + HostInfoHash: HostInfoHash(info), + HostInfo: info, + }, acc.Drain()) + + body, err := Marshal(req) + if err != nil { + t.Fatal(err) + } + expected, _ := json.MarshalIndent(map[string]any{ + "cpu_idle": idle, + "disk_labels": []string{"/", "/dev/nvme0n1p1", "ext4"}, + "hostname": "golden-01", + }, "", " ") + + dir := filepath.Join("testdata") + if *update { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "golden-batch.pb.gz"), body, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "golden-batch.json"), append(expected, '\n'), 0o644); err != nil { + t.Fatal(err) + } + } + + stored, err := os.ReadFile(filepath.Join(dir, "golden-batch.pb.gz")) + if err != nil { + t.Fatalf("missing golden file (run with -update): %v", err) + } + got, err := Unmarshal(stored) + if err != nil { + t.Fatal(err) + } + a, _ := Expand(got) + b, _ := Expand(req) + if !reflect.DeepEqual(a, b) { + t.Fatal("golden batch is stale; run with -update") + } +} + +// typicalMinute mirrors typicalMinute() in laravel/tests/Feature/HostAgentV2Test.php: +// 4 rounds at 15s, 7 cpu + 5 memory + 6 swap + 3 load + uptime series, 2 disks +// x 8, 2 interfaces x 10 (58 series, 232 samples). +func typicalMinute() []*metrics.Series { + acc := metrics.NewAccumulator(10_000) + start := int64(1_792_490_400_123) + for round := range 4 { + ts := start + int64(round)*15_000 + r := float64(round) + add := func(m string, v float64, l ...metrics.Label) { acc.Add(m, l, ts, math.Round(v*1000)/1000) } + for i, k := range []string{"user", "system", "interrupt", "iowait", "stolen", "guest"} { + add("system.cpu."+k, []float64{18.112, 6.03, 0.41, 1.207, 0, 0}[i]+r*0.731) + } + add("system.cpu.idle", 71.234-r*3.5) + add("system.memory.total", 15923.445) + add("system.memory.free", 1203.113-r*12.5) + add("system.memory.used", 9871.07+r*11.25) + add("system.memory.available", 5642.875-r*9.125) + add("system.memory.available_percent", 35.437-r*0.057) + for i, k := range []string{"total", "free", "used", "in", "out", "free_percent"} { + add("system.swap."+k, []float64{2048, 1536, 512, 10.25, 31.5, 75}[i]) + } + add("system.load.1", 0.82+r*0.05) + add("system.load.5", 0.71) + add("system.load.15", 0.65) + add("system.uptime", 1_234_567+r*15) + for _, d := range [][3]string{{"/", "/dev/nvme0n1p1", "ext4"}, {"/var/lib/docker", "/dev/nvme1n1", "xfs"}} { + l := []metrics.Label{{Key: "mountpoint", Value: d[0]}, {Key: "device", Value: d[1]}, {Key: "fstype", Value: d[2]}} + for i, k := range []string{"total", "free", "used", "used_percent", "inodes_total", "inodes_used", "inodes_free", "inodes_used_percent"} { + add("system.disk_usage."+k, []float64{102687744, 60342011, 42345733 + r*4, 41.237, 6553600, 412331 + r, 6141269 - r, 6.292}[i], l...) + } + } + for _, iface := range []string{"eth0", "eth1"} { + l := metrics.Label{Key: "interface", Value: iface} + for i, k := range []string{"bytes_sent", "bytes_recv", "packets_sent", "packets_recv", "err_in", "err_out", "drop_in", "drop_out", "fifo_in", "fifo_out"} { + add("system.net."+k, []float64{812.375 + r*17, 3391.5 + r*17, 1204 + r*17, 2877 + r*17, 0, 0, 0, 0, 0, 0}[i], l) + } + } + } + return acc.Drain() +} + +func TestTypicalMinuteSize(t *testing.T) { + series := typicalMinute() + req := Build(Envelope{AgentVersion: "2.0.0", AgentInstance: "0123456789abcdef", BatchSeq: 1, HostInfoHash: HostInfoHash(&pb.HostInfo{})}, series) + body, err := Marshal(req) + if err != nil { + t.Fatal(err) + } + raw, _ := Unmarshal(body) + samples := 0 + for _, s := range series { + samples += len(s.Values) + } + t.Logf("typical minute: %d series, %d samples, %d bytes gzip (%.1f bytes/sample)", len(series), samples, len(body), float64(len(body))/float64(samples)) + if raw == nil || len(body) > 1500 { + t.Fatalf("typical minute is %d bytes gzip, expected well under 1.5 KB", len(body)) + } +} + +func BenchmarkEncodeTypicalMinute(b *testing.B) { + series := typicalMinute() + env := Envelope{AgentVersion: "2.0.0", AgentInstance: "0123456789abcdef", BatchSeq: 1} + var n int + b.ReportAllocs() + for b.Loop() { + body, err := Marshal(Build(env, series)) + if err != nil { + b.Fatal(err) + } + n = len(body) + } + b.ReportMetric(float64(n), "bytes/batch") +} diff --git a/internal/encode/testdata/golden-batch.json b/internal/encode/testdata/golden-batch.json new file mode 100644 index 0000000..065d8a6 --- /dev/null +++ b/internal/encode/testdata/golden-batch.json @@ -0,0 +1,14 @@ +{ + "cpu_idle": [ + 71.234, + 68.5, + 65.125, + 60 + ], + "disk_labels": [ + "/", + "/dev/nvme0n1p1", + "ext4" + ], + "hostname": "golden-01" +} diff --git a/internal/encode/testdata/golden-batch.pb.gz b/internal/encode/testdata/golden-batch.pb.gz new file mode 100644 index 0000000000000000000000000000000000000000..a73e904941e2e5baef6a311cc8f33767ae0db7d3 GIT binary patch literal 350 zcmV-k0ipgMiwFP!00002|6GkRYwA!O#gnLcNqoEqK^NbnICLn2@!ljR+R6D&#V;T? zx&LUWxe4Bzpj)9r>Da-q(4kX5g&&|>DTU7c0G&(GrG*0r&iQeUq1nG(2RW!e&zB5% zM?ZL1J$t*q+3$RJ5(O}df+QMJ6u8ujU5tG2LNp9eICfbShGXn8HjDt%QQ(i_$ip_dA~`OtLF&)hy6FTGd)_F~#K wG+VFL8~QKf2c$o28;YV#{~M;QS

)+S5Po>aAAmGXMbp{|B$}S$zQj0O9na2LJ#7 literal 0 HcmV?d00001 diff --git a/internal/http/client.go b/internal/http/client.go deleted file mode 100644 index cf76234..0000000 --- a/internal/http/client.go +++ /dev/null @@ -1,86 +0,0 @@ -package http - -import ( - "github.com/getsentry/sentry-go" - "github.com/go-resty/resty/v2" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/version" - "github.com/spectate/agent/pkg/proto/pb" - "github.com/spf13/viper" - "google.golang.org/protobuf/proto" -) - -var ApiBaseUrl = "http://localhost:8000/agent-api" - -type Client struct { - client *resty.Client -} - -func NewClient() *Client { - logger.Log.Info().Msg("Initializing HTTP client") - - client := resty.New() - client.SetBaseURL(ApiBaseUrl) - client.SetHeader("Content-Type", "application/json") - client.SetHeader("Accept", "application/json") - client.SetHeader("User-Agent", "Spectated/"+version.Version+" (Build date: "+version.BuildDate+")") - - tokenValue := viper.Get("host.token") - token, tokenOk := tokenValue.(string) - - if tokenOk && token != "" { - client.SetHeader("X-Spectated-Token", token) - } - - logger.Log.Info().Msg("Finished initializing HTTP client") - - return &Client{ - client: client, - } -} - -type Authorize struct { - Token string `json:"token"` - Hostname string `json:"hostname"` -} - -type AuthorizeSuccess struct { - Token string `json:"token"` -} - -func (http *Client) Authorize(payload Authorize) (*resty.Response, error) { - resp, err := http.client.R(). - SetBody(payload). - SetResult(&AuthorizeSuccess{}). - Post("/host/authorize") - - if err != nil { - sentry.CaptureException(err) - return nil, err - } - - return resp, nil -} - -func (http *Client) Payload(payload *pb.MetricsPayload) (*resty.Response, error) { - data, err := proto.Marshal(payload) - if err != nil { - return nil, err - } - - resp, err := http.client.R(). - SetHeader("Content-Type", "application/x-protobuf"). - SetBody(data). - Post("/host/ingest") - - if err != nil { - return nil, err - } - - if resp.IsError() { - sentry.CaptureMessage("Error sending payload: " + resp.String()) - logger.Log.Error().Msgf("Error sending payload: %s", resp.String()) - } - - return resp, nil -} diff --git a/internal/logger/logger.go b/internal/logger/logger.go deleted file mode 100644 index d11f071..0000000 --- a/internal/logger/logger.go +++ /dev/null @@ -1,70 +0,0 @@ -package logger - -import ( - "github.com/rs/zerolog" - "github.com/rs/zerolog/pkgerrors" - "github.com/spectate/agent/internal/version" - "gopkg.in/natefinch/lumberjack.v2" - "io" - "os" - "time" -) - -var ( - Log zerolog.Logger - file *os.File - LogLevel = "debug" -) - -func InitLogger(verbose bool) { - initLoggerConstants() - - zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - - //goland:noinspection GoBoolExpressions We set Environment in the build process - isDevelopment := version.Environment == "development" - - var output io.Writer = zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: time.RFC3339, - } - - level, err := zerolog.ParseLevel(LogLevel) - if err != nil { - panic(err) - } - - if !isDevelopment { - fileLogger := &lumberjack.Logger{ - Filename: AgentLog, - MaxSize: 10, - MaxBackups: 5, - MaxAge: 7, - Compress: true, - } - - if verbose { - output = zerolog.MultiLevelWriter(os.Stdout, fileLogger) - } else { - output = zerolog.MultiLevelWriter(fileLogger) - } - zerolog.SetGlobalLevel(level) - } - - if verbose { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } - - Log = zerolog.New(output). - With(). - Timestamp(). - Str("version", version.Version). - Logger() -} - -func ShutdownLogger() { - if file != nil { - file.Close() - } -} diff --git a/internal/logger/logger_darwin.go b/internal/logger/logger_darwin.go deleted file mode 100644 index 74b71a2..0000000 --- a/internal/logger/logger_darwin.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build darwin - -package logger - -import ( - "log" - "os" -) - -var ( - BaseDir string - AgentLog string -) - -func init() { - userHomeDir, err := os.UserHomeDir() - if err != nil { - log.Fatalf("Failed to get user home directory: %v", err) - } - BaseDir = userHomeDir + "/.spectated/logs" - AgentLog = BaseDir + "/spectated.log" -} - -func initLoggerConstants() {} diff --git a/internal/logger/logger_nix.go b/internal/logger/logger_nix.go deleted file mode 100644 index e02add1..0000000 --- a/internal/logger/logger_nix.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !darwin - -package logger - -const ( - BaseDir = "/var/log/spectate-agent" - AgentLog = "/var/log/spectate-agent/spectated.log" -) - -func initLoggerConstants() {} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..107f82d --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,110 @@ +// Package metrics holds collected samples between shipping windows. +// +// Samples are grouped by series (metric name + ordered label set) as they are +// added, so a shipping window maps 1:1 onto the columnar v2 payload. +package metrics + +import ( + "math" + "strings" + "sync" +) + +// Label is one key/value pair. Label order is significant: it is preserved on +// the wire and becomes label_names/label_values server-side. +type Label struct { + Key string + Value string +} + +// Series is one metric + label set with its samples in insertion order. +type Series struct { + Metric string + Labels []Label + Timestamps []int64 // unix ms + Values []float64 +} + +// Accumulator collects samples for the current shipping window. It is safe +// for concurrent use. +type Accumulator struct { + mu sync.Mutex + maxPoints int + points int + index map[string]int + series []*Series + dropped uint64 + keyBuf strings.Builder +} + +// NewAccumulator returns an accumulator that holds at most maxPoints samples +// per window; further samples are counted as dropped. +func NewAccumulator(maxPoints int) *Accumulator { + return &Accumulator{maxPoints: maxPoints, index: make(map[string]int)} +} + +// Add records one sample. Non-finite values are ignored. +func (a *Accumulator) Add(metric string, labels []Label, tsMs int64, value float64) { + if metric == "" || math.IsNaN(value) || math.IsInf(value, 0) { + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + if a.points >= a.maxPoints { + a.dropped++ + return + } + + a.keyBuf.Reset() + a.keyBuf.WriteString(metric) + for _, l := range labels { + a.keyBuf.WriteByte(0) + a.keyBuf.WriteString(l.Key) + a.keyBuf.WriteByte(0) + a.keyBuf.WriteString(l.Value) + } + key := a.keyBuf.String() + + i, ok := a.index[key] + if !ok { + i = len(a.series) + a.index[key] = i + a.series = append(a.series, &Series{Metric: metric, Labels: append([]Label(nil), labels...)}) + } + + s := a.series[i] + s.Timestamps = append(s.Timestamps, tsMs) + s.Values = append(s.Values, value) + a.points++ +} + +// Drain returns the current window's series and starts a new window. +func (a *Accumulator) Drain() []*Series { + a.mu.Lock() + defer a.mu.Unlock() + + out := a.series + a.series = nil + a.index = make(map[string]int, len(out)) + a.points = 0 + + return out +} + +// Len returns the number of samples in the current window. +func (a *Accumulator) Len() int { + a.mu.Lock() + defer a.mu.Unlock() + + return a.points +} + +// Dropped returns the total number of samples dropped because the window was full. +func (a *Accumulator) Dropped() uint64 { + a.mu.Lock() + defer a.mu.Unlock() + + return a.dropped +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..4475839 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,35 @@ +package metrics + +import ( + "math" + "testing" +) + +func TestAccumulatorGroupsSeriesAndBoundsMemory(t *testing.T) { + a := NewAccumulator(5) + disk := []Label{{Key: "mountpoint", Value: "/"}} + a.Add("m", nil, 1, 1) + a.Add("m", disk, 1, 2) + a.Add("m", nil, 2, 3) + a.Add("m", disk, 2, 4) + a.Add("nan", nil, 2, math.NaN()) // ignored + a.Add("m", nil, 3, 5) + a.Add("m", nil, 4, 6) // over the bound + a.Add("m", nil, 5, 7) // over the bound + + series := a.Drain() + if len(series) != 2 || len(series[0].Values) != 3 || len(series[1].Values) != 2 { + t.Fatalf("series = %+v", series) + } + if series[1].Labels[0].Value != "/" || a.Dropped() != 2 || a.Len() != 0 { + t.Fatalf("labels/dropped/len wrong: %+v %d %d", series[1].Labels, a.Dropped(), a.Len()) + } + + // Callers may reuse their label slice: the accumulator keeps a copy. + labels := []Label{{Key: "k", Value: "a"}} + a.Add("x", labels, 1, 1) + labels[0].Value = "b" + if got := a.Drain()[0].Labels[0].Value; got != "a" { + t.Fatalf("label aliasing: %q", got) + } +} diff --git a/internal/proto/agentv2/ingest.pb.go b/internal/proto/agentv2/ingest.pb.go new file mode 100644 index 0000000..ecdd6eb --- /dev/null +++ b/internal/proto/agentv2/ingest.pb.go @@ -0,0 +1,622 @@ +// Spectate host agent ingest protocol, version 2. +// +// One IngestRequest carries one shipping window (normally 60s) of samples +// collected at a higher resolution (normally every 15s). The body is sent +// gzip-compressed (`Content-Encoding: gzip`, `Content-Type: +// application/x-protobuf; proto=spectate.agent.v2.IngestRequest`) to +// `POST /agent-api/v2/ingest` with `Authorization: Bearer `. +// +// Layout is columnar: +// * all strings (metric names, label keys and values) live once in +// `strings`; series reference them by index, +// * every series (metric + label set) appears once and carries its samples +// as packed, delta-encoded timestamp and value columns. +// +// The canonical copy of this file lives in laravel/proto; the Go agent +// generates its bindings from it (see agent/Makefile). + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc v5.27.0 +// source: spectate/agent/v2/ingest.proto + +package agentv2 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type IngestRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Always 2 for this schema. + ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + // Agent build version, e.g. "2.0.0". + AgentVersion string `protobuf:"bytes,2,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + // Random id generated when the agent's spool is created; together with + // `batch_seq` it identifies a batch so replays can be de-duplicated. + AgentInstance string `protobuf:"bytes,3,opt,name=agent_instance,json=agentInstance,proto3" json:"agent_instance,omitempty"` + // Monotonic per agent_instance, starting at 1. Spooled batches are replayed + // in increasing order. + BatchSeq uint64 `protobuf:"varint,4,opt,name=batch_seq,json=batchSeq,proto3" json:"batch_seq,omitempty"` + // Unix time (ms) at which the batch was cut. + CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Hex sha256 of the agent's canonical HostInfo. Always present. + HostInfoHash string `protobuf:"bytes,6,opt,name=host_info_hash,json=hostInfoHash,proto3" json:"host_info_hash,omitempty"` + // Only present when the host info changed since it was last acknowledged, + // on the first batch after start, or when the server asked for it + // (`resend_host_info` in the response). + HostInfo *HostInfo `protobuf:"bytes,7,opt,name=host_info,json=hostInfo,proto3" json:"host_info,omitempty"` + // Base timestamp (unix ms). The first timestamp of each series is encoded + // relative to it. + BaseTimestampMs int64 `protobuf:"varint,8,opt,name=base_timestamp_ms,json=baseTimestampMs,proto3" json:"base_timestamp_ms,omitempty"` + // String table. Index 0 is always the empty string. + Strings []string `protobuf:"bytes,9,rep,name=strings,proto3" json:"strings,omitempty"` + // Series dictionary + samples; the position in this list is the series id. + Series []*Series `protobuf:"bytes,10,rep,name=series,proto3" json:"series,omitempty"` +} + +func (x *IngestRequest) Reset() { + *x = IngestRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IngestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestRequest) ProtoMessage() {} + +func (x *IngestRequest) ProtoReflect() protoreflect.Message { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestRequest.ProtoReflect.Descriptor instead. +func (*IngestRequest) Descriptor() ([]byte, []int) { + return file_spectate_agent_v2_ingest_proto_rawDescGZIP(), []int{0} +} + +func (x *IngestRequest) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *IngestRequest) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *IngestRequest) GetAgentInstance() string { + if x != nil { + return x.AgentInstance + } + return "" +} + +func (x *IngestRequest) GetBatchSeq() uint64 { + if x != nil { + return x.BatchSeq + } + return 0 +} + +func (x *IngestRequest) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *IngestRequest) GetHostInfoHash() string { + if x != nil { + return x.HostInfoHash + } + return "" +} + +func (x *IngestRequest) GetHostInfo() *HostInfo { + if x != nil { + return x.HostInfo + } + return nil +} + +func (x *IngestRequest) GetBaseTimestampMs() int64 { + if x != nil { + return x.BaseTimestampMs + } + return 0 +} + +func (x *IngestRequest) GetStrings() []string { + if x != nil { + return x.Strings + } + return nil +} + +func (x *IngestRequest) GetSeries() []*Series { + if x != nil { + return x.Series + } + return nil +} + +type Series struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Index into IngestRequest.strings of the metric name (v1 agent keys, e.g. + // "system.cpu.idle", "system.disk_usage.used_percent"). + Metric uint32 `protobuf:"varint,1,opt,name=metric,proto3" json:"metric,omitempty"` + // Packed label pairs as string-table indexes: [key0, value0, key1, value1, ...]. + // Order is significant and preserved (it becomes label_names/label_values). + Labels []uint32 `protobuf:"varint,2,rep,packed,name=labels,proto3" json:"labels,omitempty"` + // Sample timestamps in ms, delta-encoded: t[0] = base_timestamp_ms + d[0], + // t[i] = t[i-1] + d[i]. + TimestampDeltas []int64 `protobuf:"zigzag64,3,rep,packed,name=timestamp_deltas,json=timestampDeltas,proto3" json:"timestamp_deltas,omitempty"` + // Values are carried in exactly one of the two encodings below; both have + // one entry per timestamp. + // + // Decimal encoding: integers delta-encoded like timestamps; the value is + // the running sum divided by 10^scale. Used when every value in the series + // is exactly representable with `scale` decimals (counters, sizes, + // rounded percentages). + Scale uint32 `protobuf:"varint,4,opt,name=scale,proto3" json:"scale,omitempty"` + ValueDeltas []int64 `protobuf:"zigzag64,5,rep,packed,name=value_deltas,json=valueDeltas,proto3" json:"value_deltas,omitempty"` + // Raw IEEE-754 doubles, used when the decimal encoding would be lossy. + Values []float64 `protobuf:"fixed64,6,rep,packed,name=values,proto3" json:"values,omitempty"` +} + +func (x *Series) Reset() { + *x = Series{} + if protoimpl.UnsafeEnabled { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Series) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Series) ProtoMessage() {} + +func (x *Series) ProtoReflect() protoreflect.Message { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Series.ProtoReflect.Descriptor instead. +func (*Series) Descriptor() ([]byte, []int) { + return file_spectate_agent_v2_ingest_proto_rawDescGZIP(), []int{1} +} + +func (x *Series) GetMetric() uint32 { + if x != nil { + return x.Metric + } + return 0 +} + +func (x *Series) GetLabels() []uint32 { + if x != nil { + return x.Labels + } + return nil +} + +func (x *Series) GetTimestampDeltas() []int64 { + if x != nil { + return x.TimestampDeltas + } + return nil +} + +func (x *Series) GetScale() uint32 { + if x != nil { + return x.Scale + } + return 0 +} + +func (x *Series) GetValueDeltas() []int64 { + if x != nil { + return x.ValueDeltas + } + return nil +} + +func (x *Series) GetValues() []float64 { + if x != nil { + return x.Values + } + return nil +} + +type Label struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` +} + +func (x *Label) Reset() { + *x = Label{} + if protoimpl.UnsafeEnabled { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Label) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Label) ProtoMessage() {} + +func (x *Label) ProtoReflect() protoreflect.Message { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Label.ProtoReflect.Descriptor instead. +func (*Label) Descriptor() ([]byte, []int) { + return file_spectate_agent_v2_ingest_proto_rawDescGZIP(), []int{2} +} + +func (x *Label) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Label) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type HostInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Same keys as the v1 `host.info` INFO metric: hostname, os, platform, + // platform_family, platform_version, kernel_version, kernel_arch, + // virtualization_system, virtualization_role, host_id. + Info []*Label `protobuf:"bytes,1,rep,name=info,proto3" json:"info,omitempty"` + // One entry per logical CPU package/core as reported by the OS, with the + // same keys as the v1 `host.cpus` INFO metric. + Cpus []*HostInfo_Cpu `protobuf:"bytes,2,rep,name=cpus,proto3" json:"cpus,omitempty"` +} + +func (x *HostInfo) Reset() { + *x = HostInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HostInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HostInfo) ProtoMessage() {} + +func (x *HostInfo) ProtoReflect() protoreflect.Message { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HostInfo.ProtoReflect.Descriptor instead. +func (*HostInfo) Descriptor() ([]byte, []int) { + return file_spectate_agent_v2_ingest_proto_rawDescGZIP(), []int{3} +} + +func (x *HostInfo) GetInfo() []*Label { + if x != nil { + return x.Info + } + return nil +} + +func (x *HostInfo) GetCpus() []*HostInfo_Cpu { + if x != nil { + return x.Cpus + } + return nil +} + +type HostInfo_Cpu struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Info []*Label `protobuf:"bytes,1,rep,name=info,proto3" json:"info,omitempty"` +} + +func (x *HostInfo_Cpu) Reset() { + *x = HostInfo_Cpu{} + if protoimpl.UnsafeEnabled { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HostInfo_Cpu) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HostInfo_Cpu) ProtoMessage() {} + +func (x *HostInfo_Cpu) ProtoReflect() protoreflect.Message { + mi := &file_spectate_agent_v2_ingest_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HostInfo_Cpu.ProtoReflect.Descriptor instead. +func (*HostInfo_Cpu) Descriptor() ([]byte, []int) { + return file_spectate_agent_v2_ingest_proto_rawDescGZIP(), []int{3, 0} +} + +func (x *HostInfo_Cpu) GetInfo() []*Label { + if x != nil { + return x.Info + } + return nil +} + +var File_spectate_agent_v2_ingest_proto protoreflect.FileDescriptor + +var file_spectate_agent_v2_ingest_proto_rawDesc = []byte{ + 0x0a, 0x1e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2f, 0x76, 0x32, 0x2f, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x11, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x22, 0xa0, 0x03, 0x0a, 0x0d, 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, + 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x0f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, + 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x73, 0x65, 0x71, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x08, 0x62, 0x61, 0x74, 0x63, 0x68, 0x53, 0x65, 0x71, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, + 0x0e, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, + 0x61, 0x73, 0x68, 0x12, 0x38, 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, + 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2a, 0x0a, + 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, + 0x6d, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x4d, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x74, 0x72, + 0x69, 0x6e, 0x67, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x74, 0x72, 0x69, + 0x6e, 0x67, 0x73, 0x12, 0x31, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x0a, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x69, 0x65, 0x73, 0x52, 0x06, + 0x73, 0x65, 0x72, 0x69, 0x65, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x65, + 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x61, 0x62, + 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x73, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x64, + 0x65, 0x6c, 0x74, 0x61, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x12, 0x52, 0x0f, 0x74, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x73, 0x12, 0x14, 0x0a, 0x05, + 0x73, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x63, 0x61, + 0x6c, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x64, 0x65, 0x6c, 0x74, + 0x61, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x12, 0x52, 0x0b, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x44, + 0x65, 0x6c, 0x74, 0x61, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, + 0x06, 0x20, 0x03, 0x28, 0x01, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x2f, 0x0a, + 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xa2, + 0x01, 0x0a, 0x08, 0x48, 0x6f, 0x73, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2c, 0x0a, 0x04, 0x69, + 0x6e, 0x66, 0x6f, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x73, 0x70, 0x65, 0x63, + 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x61, + 0x62, 0x65, 0x6c, 0x52, 0x04, 0x69, 0x6e, 0x66, 0x6f, 0x12, 0x33, 0x0a, 0x04, 0x63, 0x70, 0x75, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, + 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x48, 0x6f, 0x73, 0x74, + 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x43, 0x70, 0x75, 0x52, 0x04, 0x63, 0x70, 0x75, 0x73, 0x1a, 0x33, + 0x0a, 0x03, 0x43, 0x70, 0x75, 0x12, 0x2c, 0x0a, 0x04, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x52, 0x04, 0x69, + 0x6e, 0x66, 0x6f, 0x42, 0x64, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2f, 0x76, 0x32, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x76, 0x32, 0x3b, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x76, 0x32, 0xca, 0x02, 0x0c, 0x41, 0x70, 0x70, 0x5c, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5c, 0x56, + 0x32, 0xe2, 0x02, 0x15, 0x41, 0x70, 0x70, 0x5c, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x5c, 0x56, 0x32, + 0x5c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_spectate_agent_v2_ingest_proto_rawDescOnce sync.Once + file_spectate_agent_v2_ingest_proto_rawDescData = file_spectate_agent_v2_ingest_proto_rawDesc +) + +func file_spectate_agent_v2_ingest_proto_rawDescGZIP() []byte { + file_spectate_agent_v2_ingest_proto_rawDescOnce.Do(func() { + file_spectate_agent_v2_ingest_proto_rawDescData = protoimpl.X.CompressGZIP(file_spectate_agent_v2_ingest_proto_rawDescData) + }) + return file_spectate_agent_v2_ingest_proto_rawDescData +} + +var file_spectate_agent_v2_ingest_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_spectate_agent_v2_ingest_proto_goTypes = []interface{}{ + (*IngestRequest)(nil), // 0: spectate.agent.v2.IngestRequest + (*Series)(nil), // 1: spectate.agent.v2.Series + (*Label)(nil), // 2: spectate.agent.v2.Label + (*HostInfo)(nil), // 3: spectate.agent.v2.HostInfo + (*HostInfo_Cpu)(nil), // 4: spectate.agent.v2.HostInfo.Cpu +} +var file_spectate_agent_v2_ingest_proto_depIdxs = []int32{ + 3, // 0: spectate.agent.v2.IngestRequest.host_info:type_name -> spectate.agent.v2.HostInfo + 1, // 1: spectate.agent.v2.IngestRequest.series:type_name -> spectate.agent.v2.Series + 2, // 2: spectate.agent.v2.HostInfo.info:type_name -> spectate.agent.v2.Label + 4, // 3: spectate.agent.v2.HostInfo.cpus:type_name -> spectate.agent.v2.HostInfo.Cpu + 2, // 4: spectate.agent.v2.HostInfo.Cpu.info:type_name -> spectate.agent.v2.Label + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_spectate_agent_v2_ingest_proto_init() } +func file_spectate_agent_v2_ingest_proto_init() { + if File_spectate_agent_v2_ingest_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_spectate_agent_v2_ingest_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IngestRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_spectate_agent_v2_ingest_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Series); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_spectate_agent_v2_ingest_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Label); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_spectate_agent_v2_ingest_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HostInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_spectate_agent_v2_ingest_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HostInfo_Cpu); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_spectate_agent_v2_ingest_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_spectate_agent_v2_ingest_proto_goTypes, + DependencyIndexes: file_spectate_agent_v2_ingest_proto_depIdxs, + MessageInfos: file_spectate_agent_v2_ingest_proto_msgTypes, + }.Build() + File_spectate_agent_v2_ingest_proto = out.File + file_spectate_agent_v2_ingest_proto_rawDesc = nil + file_spectate_agent_v2_ingest_proto_goTypes = nil + file_spectate_agent_v2_ingest_proto_depIdxs = nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..88071ba --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,96 @@ +// Package service installs the agent as a system service: a hardened systemd +// unit running as an unprivileged user on Linux, a launchd agent on macOS. +package service + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "text/template" +) + +// Name is the service/unit name. +const Name = "spectate-agent" + +// Options for Install. +type Options struct { + Binary string // absolute path of the agent binary + User string // service user (Linux) + EnvFile string // optional EnvironmentFile (Linux) +} + +// UnitPath is where the systemd unit is written. +const UnitPath = "/etc/systemd/system/" + Name + ".service" + +var unitTemplate = template.Must(template.New("unit").Parse(`[Unit] +Description=Spectate host monitoring agent +Documentation=https://docs.spectate.net/host-agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{.User}} +Group={{.User}} +ExecStart={{.Binary}} run +EnvironmentFile=-{{.EnvFile}} +Restart=always +RestartSec=5 +TimeoutStopSec=15 +KillSignal=SIGTERM + +# State (host token, server config, spool) lives in /var/lib/spectate-agent. +StateDirectory=spectate-agent +StateDirectoryMode=0700 + +# Hardening: the agent only reads /proc, /sys and filesystem stats. +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +CapabilityBoundingSet= +SystemCallArchitectures=native +MemoryMax=64M +Nice=10 + +[Install] +WantedBy=multi-user.target +`)) + +// Unit renders the systemd unit. +func Unit(o Options) (string, error) { + if o.User == "" { + o.User = Name + } + if o.EnvFile == "" { + o.EnvFile = "/etc/spectate-agent/agent.env" + } + var b bytes.Buffer + if err := unitTemplate.Execute(&b, o); err != nil { + return "", err + } + return b.String(), nil +} + +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %v: %w", name, args, err) + } + return nil +} diff --git a/internal/service/service_darwin.go b/internal/service/service_darwin.go new file mode 100644 index 0000000..ed754b5 --- /dev/null +++ b/internal/service/service_darwin.go @@ -0,0 +1,71 @@ +//go:build darwin + +package service + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "text/template" +) + +var plistTemplate = template.Must(template.New("plist").Parse(` + + + + Labelnet.spectate.agent + ProgramArguments + {{.Binary}}run + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardErrorPath{{.Log}} + + +`)) + +func plistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", "net.spectate.agent.plist"), nil +} + +// Install writes a per-user launchd agent and loads it (best effort on macOS). +func Install(o Options) error { + path, err := plistPath() + if err != nil { + return err + } + home, _ := os.UserHomeDir() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + if err := plistTemplate.Execute(f, map[string]string{ + "Binary": o.Binary, + "Log": filepath.Join(home, "Library", "Logs", "spectate-agent.log"), + }); err != nil { + return fmt.Errorf("write plist: %w", err) + } + return run("launchctl", "load", "-w", path) +} + +// Uninstall unloads and removes the launchd agent. +func Uninstall() error { + path, err := plistPath() + if err != nil { + return err + } + _ = run("launchctl", "unload", "-w", path) + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go new file mode 100644 index 0000000..d203d1d --- /dev/null +++ b/internal/service/service_linux.go @@ -0,0 +1,44 @@ +//go:build linux + +package service + +import ( + "errors" + "fmt" + "os" + "os/exec" +) + +// Install writes the systemd unit, then enables and starts it. The service +// user must exist (install.sh creates it). +func Install(o Options) error { + if os.Geteuid() != 0 { + return errors.New("install must run as root") + } + if _, err := exec.LookPath("systemctl"); err != nil { + return errors.New("systemd not found; run `spectate-agent run` under your init system") + } + unit, err := Unit(o) + if err != nil { + return err + } + if err := os.WriteFile(UnitPath, []byte(unit), 0o644); err != nil { + return fmt.Errorf("write unit: %w", err) + } + if err := run("systemctl", "daemon-reload"); err != nil { + return err + } + return run("systemctl", "enable", "--now", Name) +} + +// Uninstall stops and removes the unit (state in /var/lib is kept). +func Uninstall() error { + if os.Geteuid() != 0 { + return errors.New("uninstall must run as root") + } + _ = run("systemctl", "disable", "--now", Name) + if err := os.Remove(UnitPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return run("systemctl", "daemon-reload") +} diff --git a/internal/service/service_other.go b/internal/service/service_other.go new file mode 100644 index 0000000..1c9cba3 --- /dev/null +++ b/internal/service/service_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !darwin + +package service + +import "errors" + +// Install is not supported on this platform. +func Install(Options) error { + return errors.New("service install is only supported on Linux and macOS") +} + +// Uninstall is not supported on this platform. +func Uninstall() error { return errors.New("service uninstall is only supported on Linux and macOS") } diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..7618c32 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,21 @@ +package service + +import ( + "strings" + "testing" +) + +func TestUnitRunsUnprivilegedAndHardened(t *testing.T) { + unit, err := Unit(Options{Binary: "/usr/local/bin/spectate-agent"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "User=spectate-agent", "ExecStart=/usr/local/bin/spectate-agent run", "StateDirectory=spectate-agent", + "EnvironmentFile=-/etc/spectate-agent/agent.env", "NoNewPrivileges=yes", "ProtectSystem=strict", "CapabilityBoundingSet=\n", + } { + if !strings.Contains(unit, want) { + t.Errorf("unit missing %q", want) + } + } +} diff --git a/internal/shared/bytes.go b/internal/shared/bytes.go deleted file mode 100644 index 64007a4..0000000 --- a/internal/shared/bytes.go +++ /dev/null @@ -1,3 +0,0 @@ -package shared - -const MbSize float64 = 1024 * 1024 diff --git a/internal/shared/strings.go b/internal/shared/strings.go deleted file mode 100644 index c52957f..0000000 --- a/internal/shared/strings.go +++ /dev/null @@ -1,10 +0,0 @@ -package shared - -func StrSliceContains(slice []string, str string) bool { - for _, item := range slice { - if item == str { - return true - } - } - return false -} diff --git a/internal/spool/spool.go b/internal/spool/spool.go new file mode 100644 index 0000000..c07d58f --- /dev/null +++ b/internal/spool/spool.go @@ -0,0 +1,203 @@ +// Package spool is a bounded on-disk FIFO of encoded batches that could not be +// shipped. Each batch is one file named so that lexical order is creation +// order; batches are replayed oldest first and the oldest are dropped when the +// spool exceeds its byte or age bound. +package spool + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const suffix = ".batch" + +// Entry is one spooled batch. +type Entry struct { + Name string + Size int64 + CreatedAt time.Time +} + +// Spool is safe for concurrent use. +type Spool struct { + dir string + maxBytes int64 + maxAge time.Duration + now func() time.Time + + mu sync.Mutex + entries []Entry // oldest first + bytes int64 + dropped uint64 + counter uint64 +} + +// Open loads (or creates) the spool in dir. +func Open(dir string, maxBytes int64, maxAge time.Duration) (*Spool, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create spool dir: %w", err) + } + s := &Spool{dir: dir, maxBytes: maxBytes, maxAge: maxAge, now: time.Now} + + items, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, it := range items { + name := it.Name() + if it.IsDir() { + continue + } + if strings.HasSuffix(name, ".tmp") { + _ = os.Remove(filepath.Join(dir, name)) // interrupted write + continue + } + if !strings.HasSuffix(name, suffix) { + continue + } + info, err := it.Info() + if err != nil { + continue + } + created, ok := parseName(name) + if !ok { + continue + } + s.entries = append(s.entries, Entry{Name: name, Size: info.Size(), CreatedAt: created}) + s.bytes += info.Size() + } + sort.Slice(s.entries, func(i, j int) bool { return s.entries[i].Name < s.entries[j].Name }) + + s.mu.Lock() + s.pruneLocked() + s.mu.Unlock() + + return s, nil +} + +// Push appends a batch. The write is atomic (temp file + rename). +func (s *Spool) Push(data []byte, createdAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.counter++ + name := fmt.Sprintf("%016d-%06d%s", createdAt.UnixMilli(), s.counter%1_000_000, suffix) + // Keep FIFO order even if the wall clock steps backwards. + if n := len(s.entries); n > 0 && name <= s.entries[n-1].Name { + last, _ := parseName(s.entries[n-1].Name) + createdAt = last + name = fmt.Sprintf("%016d-%06d%s", last.UnixMilli(), s.counter%1_000_000, suffix) + if name <= s.entries[n-1].Name { + name = strings.TrimSuffix(s.entries[n-1].Name, suffix) + "z" + suffix + } + } + + path := filepath.Join(s.dir, name) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("write spool file: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("commit spool file: %w", err) + } + + s.entries = append(s.entries, Entry{Name: name, Size: int64(len(data)), CreatedAt: createdAt}) + s.bytes += int64(len(data)) + s.pruneLocked() + return nil +} + +// Oldest returns the oldest batch, or ok=false when the spool is empty. +func (s *Spool) Oldest() (Entry, []byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.pruneLocked() + for len(s.entries) > 0 { + e := s.entries[0] + data, err := os.ReadFile(filepath.Join(s.dir, e.Name)) + if errors.Is(err, os.ErrNotExist) { + s.removeFirstLocked() + continue + } + if err != nil { + return Entry{}, nil, false, err + } + return e, data, true, nil + } + return Entry{}, nil, false, nil +} + +// Remove deletes a batch once it has been shipped (or rejected for good). +func (s *Spool) Remove(e Entry) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, cur := range s.entries { + if cur.Name == e.Name { + s.entries = append(s.entries[:i], s.entries[i+1:]...) + s.bytes -= cur.Size + break + } + } + err := os.Remove(filepath.Join(s.dir, e.Name)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// Len returns the number of spooled batches. +func (s *Spool) Len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.entries) +} + +// Bytes returns the total size of spooled batches. +func (s *Spool) Bytes() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.bytes +} + +// Dropped returns how many batches were evicted by the size/age bounds. +func (s *Spool) Dropped() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.dropped +} + +func (s *Spool) pruneLocked() { + cutoff := s.now().Add(-s.maxAge) + for len(s.entries) > 0 { + e := s.entries[0] + if s.bytes <= s.maxBytes && (s.maxAge <= 0 || !e.CreatedAt.Before(cutoff)) { + return + } + _ = os.Remove(filepath.Join(s.dir, e.Name)) + s.removeFirstLocked() + s.dropped++ + } +} + +func (s *Spool) removeFirstLocked() { + s.bytes -= s.entries[0].Size + s.entries = s.entries[1:] +} + +func parseName(name string) (time.Time, bool) { + var ms int64 + if _, err := fmt.Sscanf(name, "%016d-", &ms); err != nil { + return time.Time{}, false + } + return time.UnixMilli(ms), true +} diff --git a/internal/spool/spool_test.go b/internal/spool/spool_test.go new file mode 100644 index 0000000..ea5c6dd --- /dev/null +++ b/internal/spool/spool_test.go @@ -0,0 +1,113 @@ +package spool + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestFIFOAndReopen(t *testing.T) { + dir := t.TempDir() + s, err := Open(dir, 1<<20, time.Hour) + if err != nil { + t.Fatal(err) + } + now := time.Now() + for i := range 5 { + if err := s.Push([]byte(fmt.Sprintf("batch-%d", i)), now.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatal(err) + } + } + if s.Len() != 5 || s.Bytes() != 5*7 { + t.Fatalf("len=%d bytes=%d", s.Len(), s.Bytes()) + } + + e, data, ok, err := s.Oldest() + if err != nil || !ok || string(data) != "batch-0" { + t.Fatalf("oldest = %q %v %v", data, ok, err) + } + if err := s.Remove(e); err != nil { + t.Fatal(err) + } + + // Reopen: order and accounting survive a restart. + s2, err := Open(dir, 1<<20, time.Hour) + if err != nil { + t.Fatal(err) + } + for i := 1; i < 5; i++ { + e, data, ok, err := s2.Oldest() + if err != nil || !ok || string(data) != fmt.Sprintf("batch-%d", i) { + t.Fatalf("replay %d = %q", i, data) + } + _ = s2.Remove(e) + } + if _, _, ok, _ := s2.Oldest(); ok || s2.Bytes() != 0 { + t.Fatal("spool should be empty") + } +} + +func TestSameMillisecondAndClockStepKeepOrder(t *testing.T) { + s, _ := Open(t.TempDir(), 1<<20, 0) + now := time.Now() + _ = s.Push([]byte("a"), now) + _ = s.Push([]byte("b"), now) + _ = s.Push([]byte("c"), now.Add(-time.Hour)) // clock stepped back + var got []byte + for { + e, data, ok, _ := s.Oldest() + if !ok { + break + } + got = append(got, data...) + _ = s.Remove(e) + } + if string(got) != "abc" { + t.Fatalf("order = %q", got) + } +} + +func TestByteBoundDropsOldest(t *testing.T) { + s, _ := Open(t.TempDir(), 25, time.Hour) + now := time.Now() + for i := range 5 { + _ = s.Push(bytes.Repeat([]byte{byte('0' + i)}, 10), now.Add(time.Duration(i)*time.Second)) + } + if s.Len() != 2 || s.Bytes() != 20 || s.Dropped() != 3 { + t.Fatalf("len=%d bytes=%d dropped=%d", s.Len(), s.Bytes(), s.Dropped()) + } + _, data, _, _ := s.Oldest() + if data[0] != '3' { + t.Fatalf("oldest kept batch = %c, want 3", data[0]) + } +} + +func TestAgeBoundDropsExpired(t *testing.T) { + s, _ := Open(t.TempDir(), 1<<20, 24*time.Hour) + now := time.Now() + _ = s.Push([]byte("old"), now.Add(-25*time.Hour)) + _ = s.Push([]byte("new"), now) + if s.Len() != 1 || s.Dropped() != 1 { + t.Fatalf("len=%d dropped=%d", s.Len(), s.Dropped()) + } +} + +func TestInterruptedWritesAreCleanedUp(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "0000000000000001-000001.batch.tmp"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + s, err := Open(dir, 1<<20, time.Hour) + if err != nil { + t.Fatal(err) + } + if s.Len() != 0 { + t.Fatal("tmp files must not be replayed") + } + if _, err := os.Stat(filepath.Join(dir, "0000000000000001-000001.batch.tmp")); !os.IsNotExist(err) { + t.Fatal("tmp file should be removed") + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..84fe608 --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,203 @@ +// Package transport talks to the Spectate v2 agent API. +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/version" +) + +const ( + registerPath = "/agent-api/v2/register" + ingestPath = "/agent-api/v2/ingest" + maxResponse = 1 << 20 +) + +// Client is an HTTP client for one API base URL. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New returns a client with conservative timeouts. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTP: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 2, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + } +} + +// Error is a non-2xx API response. +type Error struct { + Status int + Code string + Message string + RetryAfter time.Duration +} + +func (e *Error) Error() string { + if e.Code != "" { + return fmt.Sprintf("api: %d %s: %s", e.Status, e.Code, e.Message) + } + return fmt.Sprintf("api: HTTP %d", e.Status) +} + +// Disposition says what to do with a batch after a failed send. +type Disposition int + +const ( + // Retry later (network error, 5xx, 408, 429, auth problems). + Retry Disposition = iota + // Drop: the server will never accept this batch (400, 413, 422). + Drop +) + +// Classify maps an ingest error to a disposition. +func Classify(err error) Disposition { + var apiErr *Error + if errors.As(err, &apiErr) { + switch apiErr.Status { + case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity: + return Drop + } + } + return Retry +} + +// RetryAfter returns the server-requested delay, if any. +func RetryAfter(err error) time.Duration { + var apiErr *Error + if errors.As(err, &apiErr) { + return apiErr.RetryAfter + } + return 0 +} + +// RegisterRequest is the JSON body of POST /agent-api/v2/register. +type RegisterRequest struct { + Token string `json:"token"` + Hostname string `json:"hostname,omitempty"` + HostInfo map[string]string `json:"host_info,omitempty"` + CPUInfo []map[string]string `json:"cpu_info,omitempty"` + AgentVersion string `json:"agent_version"` + HostInfoHash string `json:"host_info_hash,omitempty"` +} + +// RegisterResponse is returned by register. +type RegisterResponse struct { + HostUUID string `json:"host_uuid"` + HostToken string `json:"host_token"` + Config config.Remote `json:"config"` + NextIngestIn int `json:"next_ingest_in"` +} + +// IngestResponse is returned by ingest. +type IngestResponse struct { + AcceptedSamples int `json:"accepted_samples"` + Duplicate bool `json:"duplicate"` + ResendHostInfo bool `json:"resend_host_info"` + Config config.Remote `json:"config"` + NextIngestIn int `json:"next_ingest_in"` +} + +// Register exchanges an install token for a host token. +func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+registerPath, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + var out RegisterResponse + if err := c.do(httpReq, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Ingest ships one gzip-compressed v2 batch. +func (c *Client) Ingest(ctx context.Context, gzBody []byte) (*IngestResponse, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+ingestPath, bytes.NewReader(gzBody)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/x-protobuf; proto=spectate.agent.v2.IngestRequest") + httpReq.Header.Set("Content-Encoding", "gzip") + httpReq.Header.Set("Authorization", "Bearer "+c.Token) + + var out IngestResponse + if err := c.do(httpReq, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) do(req *http.Request, out any) error { + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", version.UserAgent()) + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse)) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + apiErr := &Error{Status: resp.StatusCode} + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + if json.Unmarshal(data, &payload) == nil { + apiErr.Code = payload.Error.Code + apiErr.Message = payload.Error.Message + if apiErr.Message == "" { + apiErr.Message = payload.Message + } + } + if s := resp.Header.Get("Retry-After"); s != "" { + if secs, err := strconv.Atoi(s); err == nil && secs > 0 { + apiErr.RetryAfter = time.Duration(secs) * time.Second + } + } + return apiErr + } + + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 0000000..b20f8c3 --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,71 @@ +package transport + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRegisterSendsJSONAndParsesConfig(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if r.URL.Path != "/agent-api/v2/register" || json.NewDecoder(r.Body).Decode(&req) != nil || req.Token != "install" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"INVALID_INSTALL_TOKEN","message":"Invalid or expired install token."}}`)) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"host_uuid":"u-1","host_token":"hst_x","next_ingest_in":60,"config":{"collection_interval":15,"ingest_interval":60,"collectors":["cpu"]}}`)) + })) + defer srv.Close() + + c := New(srv.URL+"/", "") + resp, err := c.Register(context.Background(), RegisterRequest{Token: "install", AgentVersion: "2"}) + if err != nil || resp.HostToken != "hst_x" || resp.Config.CollectionInterval != 15 { + t.Fatalf("register = %+v, %v", resp, err) + } + + _, err = c.Register(context.Background(), RegisterRequest{Token: "bad"}) + var apiErr *Error + if !errors.As(err, &apiErr) || apiErr.Status != 401 || apiErr.Code != "INVALID_INSTALL_TOKEN" { + t.Fatalf("err = %v", err) + } +} + +func TestIngestErrorsAreClassified(t *testing.T) { + status := http.StatusTooManyRequests + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Encoding") != "gzip" || r.Header.Get("Authorization") != "Bearer hst_t" { + t.Errorf("missing headers: %v", r.Header) + } + w.Header().Set("Retry-After", "7") + w.WriteHeader(status) + })) + defer srv.Close() + c := New(srv.URL, "hst_t") + + _, err := c.Ingest(context.Background(), []byte{0x1f, 0x8b}) + if Classify(err) != Retry || RetryAfter(err) != 7*time.Second { + t.Fatalf("429: %v / %v", Classify(err), RetryAfter(err)) + } + for _, s := range []int{400, 413, 422} { + status = s + if _, err := c.Ingest(context.Background(), nil); Classify(err) != Drop { + t.Fatalf("%d should drop", s) + } + } + for _, s := range []int{401, 403, 500, 502, 503} { + status = s + if _, err := c.Ingest(context.Background(), nil); Classify(err) != Retry { + t.Fatalf("%d should retry", s) + } + } + srv.Close() + if _, err := c.Ingest(context.Background(), nil); Classify(err) != Retry { + t.Fatal("network errors should retry") + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 47e185f..cb04027 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,21 +1,20 @@ +// Package version carries build metadata, set via -ldflags at release time. package version -import ( - "fmt" - "time" -) +import "runtime" var ( - Version = "0.0.0+dev" - BuildDate = "2023-10-25T12:01:25Z" - Environment = "development" + // Version is the agent release (e.g. "2.0.0"). + Version = "2.0.0-dev" + // Commit is the git commit the binary was built from. + Commit = "unknown" + // BuildDate is the RFC 3339 build timestamp. + BuildDate = "unknown" + // DefaultAPIURL is the API base URL used when none is configured. + DefaultAPIURL = "https://api.spectate.net" ) -func init() { - parsedDate, err := time.Parse(time.RFC3339, BuildDate) - if err != nil { - fmt.Printf("Error parsing BuildDate: %v\n", err) - } else { - BuildDate = parsedDate.Format("Mon Jan 2 15:04:05 MST 2006") - } +// UserAgent identifies the agent to the API. +func UserAgent() string { + return "spectate-agent/" + Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")" } diff --git a/main/main.go b/main/main.go deleted file mode 100644 index f40d060..0000000 --- a/main/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "github.com/spectate/agent/cmd" -) - -func main() { - cmd.Execute() -} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d81eba8..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6831 +0,0 @@ -{ - "name": "agent", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "devDependencies": { - "@semantic-release/changelog": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "semantic-release": "^24.2.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@octokit/auth-token": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", - "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", - "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.0.0", - "@octokit/request": "^9.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.0.0", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", - "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request": "^9.0.0", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.5.tgz", - "integrity": "sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.6.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-retry": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.1.2.tgz", - "integrity": "sha512-XOWnPpH2kJ5VTwozsxGurw+svB2e61aWlmk5EVIYZPwFK5F9h4cyPyj9CIKRyMXMHSwpIsI3mPOdpMmrRhe7UQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/request-error": "^6.0.0", - "@octokit/types": "^13.0.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=6" - } - }, - "node_modules/@octokit/plugin-throttling": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.3.2.tgz", - "integrity": "sha512-FqpvcTpIWFpMMwIeSoypoJXysSAQ3R+ALJhXXSG1HTP3YZOIeLmcNcimKaXxTcws+Sh6yoRl13SJ5r8sXc1Fhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.0.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "^6.0.0" - } - }, - "node_modules/@octokit/request": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", - "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^10.0.0", - "@octokit/request-error": "^6.0.1", - "@octokit/types": "^13.1.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.5.tgz", - "integrity": "sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/types": "^13.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.1.tgz", - "integrity": "sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^22.2.0" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true, - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", - "integrity": "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@semantic-release/changelog": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", - "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", - "dev": true, - "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "fs-extra": "^11.0.0", - "lodash": "^4.17.4" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" - } - }, - "node_modules/@semantic-release/changelog/node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", - "dev": true, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@semantic-release/changelog/node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/changelog/node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@semantic-release/changelog/node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/commit-analyzer": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.0.tgz", - "integrity": "sha512-KtXWczvTAB1ZFZ6B4O+w8HkfYm/OgQb1dUGNFZtDgQ0csggrmkq8sTxhd+lwGF8kMb59/RnG9o4Tn7M/I8dQ9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "import-from-esm": "^1.0.3", - "lodash-es": "^4.17.21", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@semantic-release/git": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", - "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", - "dev": true, - "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "debug": "^4.0.0", - "dir-glob": "^3.0.0", - "execa": "^5.0.0", - "lodash": "^4.17.4", - "micromatch": "^4.0.0", - "p-reduce": "^2.0.0" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" - } - }, - "node_modules/@semantic-release/git/node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", - "dev": true, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@semantic-release/git/node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/git/node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@semantic-release/git/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@semantic-release/git/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@semantic-release/git/node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/git/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/git/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@semantic-release/git/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/git/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/git/node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@semantic-release/git/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/@semantic-release/git/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@semantic-release/github": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-11.0.1.tgz", - "integrity": "sha512-Z9cr0LgU/zgucbT9cksH0/pX9zmVda9hkDPcgIE0uvjMQ8w/mElDivGjx1w1pEQ+MuQJ5CBq3VCF16S6G4VH3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@octokit/core": "^6.0.0", - "@octokit/plugin-paginate-rest": "^11.0.0", - "@octokit/plugin-retry": "^7.0.0", - "@octokit/plugin-throttling": "^9.0.0", - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "debug": "^4.3.4", - "dir-glob": "^3.0.1", - "globby": "^14.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "issue-parser": "^7.0.0", - "lodash-es": "^4.17.21", - "mime": "^4.0.0", - "p-filter": "^4.0.0", - "url-join": "^5.0.0" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=24.1.0" - } - }, - "node_modules/@semantic-release/npm": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-12.0.1.tgz", - "integrity": "sha512-/6nntGSUGK2aTOI0rHPwY3ZjgY9FkXmEHbW9Kr+62NVOsyqpKKeP0lrCH+tphv+EsNdJNmqqwijTEnVWUMQ2Nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "execa": "^9.0.0", - "fs-extra": "^11.0.0", - "lodash-es": "^4.17.21", - "nerf-dart": "^1.0.0", - "normalize-url": "^8.0.0", - "npm": "^10.5.0", - "rc": "^1.2.8", - "read-pkg": "^9.0.0", - "registry-auth-token": "^5.0.0", - "semver": "^7.1.2", - "tempy": "^3.0.0" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/@semantic-release/release-notes-generator": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.1.tgz", - "integrity": "sha512-K0w+5220TM4HZTthE5dDpIuFrnkN1NfTGPidJFm04ULT1DEZ9WG89VNXN7F0c+6nMEpWgqmPvb7vY7JkB2jyyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "get-stream": "^7.0.0", - "import-from-esm": "^1.0.3", - "into-stream": "^7.0.0", - "lodash-es": "^4.17.21", - "read-package-up": "^11.0.0" - }, - "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/aggregate-error": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", - "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^5.2.0", - "indent-string": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/argv-formatter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", - "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", - "dev": true - }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", - "dev": true, - "license": "MIT" - }, - "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/bottleneck": { - "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/clean-stack": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", - "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "5.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "dev": true, - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-highlight/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/compare-func": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", - "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-ify": "^1.0.0", - "dot-prop": "^5.1.0" - } - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/conventional-changelog-angular": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", - "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", - "dev": true, - "license": "ISC", - "dependencies": { - "compare-func": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-changelog-writer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.0.tgz", - "integrity": "sha512-TQcoYGRatlAnT2qEWDON/XSfnVG38JzA7E0wcGScu7RElQBkg9WWgZd1peCWFcWDh1xfb2CfsrcvOn1bbSzztA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/semver": "^7.5.5", - "conventional-commits-filter": "^5.0.0", - "handlebars": "^4.7.7", - "meow": "^13.0.0", - "semver": "^7.5.2" - }, - "bin": { - "conventional-changelog-writer": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-commits-filter": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", - "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/conventional-commits-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.0.0.tgz", - "integrity": "sha512-TbsINLp48XeMXR8EvGjTnKGsZqBemisPoyWESlpRyR8lif0lcwzqz+NMtYSj1ooF/WYjSuu7wX0CtdeeMEQAmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-hrtime": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", - "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, - "dependencies": { - "readable-stream": "^2.0.2" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/emojilib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", - "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/env-ci": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.1.0.tgz", - "integrity": "sha512-Z8dnwSDbV1XYM9SBF2J0GcNVvmfmfh3a49qddGIROhBoVro6MZVTji15z/sJbQ2ko2ei8n988EU1wzoLU/tF+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^8.0.0", - "java-properties": "^1.0.2" - }, - "engines": { - "node": "^18.17 || >=20.6.1" - } - }, - "node_modules/env-ci/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/env-ci/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/env-ci/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/env-ci/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/env-ci/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/env-ci/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/env-ci/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.1.tgz", - "integrity": "sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.3", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.0", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up-simple": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz", - "integrity": "sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-versions": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", - "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver-regex": "^4.0.5", - "super-regex": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/function-timeout": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", - "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/git-log-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.0.tgz", - "integrity": "sha512-rnCVNfkTL8tdNryFuaY0fYiBWEBcgF748O6ZI61rslBvr2o7U65c2/6npCRqH40vuAhtgtDiqLTJjBVdrejCzA==", - "dev": true, - "dependencies": { - "argv-formatter": "~1.0.0", - "spawn-error-forwarder": "~1.0.0", - "split2": "~1.0.0", - "stream-combiner2": "~1.1.1", - "through2": "~2.0.0", - "traverse": "~0.6.6" - } - }, - "node_modules/git-log-parser/node_modules/split2": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", - "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", - "dev": true, - "dependencies": { - "through2": "~2.0.0" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", - "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/path-type": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", - "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/hook-std": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", - "integrity": "sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hosted-git-info": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.0.tgz", - "integrity": "sha512-4nw3vOVR+vHUOT8+U4giwe2tcGv+R3pwwRidUe67DoMBTjhrfr6rZYJVVwdkBE+Um050SG+X9tf0Jo4fOpn01w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", - "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-from-esm": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", - "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "import-meta-resolve": "^4.0.0" - }, - "engines": { - "node": ">=16.20" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/index-to-position": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", - "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.capitalize": "^4.2.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.uniqby": "^4.7.0" - }, - "engines": { - "node": "^18.17 || >=20.6.1" - } - }, - "node_modules/java-properties": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", - "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/load-json-file/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.capitalize": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", - "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.uniqby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", - "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/marked-terminal": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.2.1.tgz", - "integrity": "sha512-rQ1MoMFXZICWNsKMiiHwP/Z+92PLKskTPXj+e7uwXmuMPkNn7iTqC+IvDekVm1MPeC9wYQeLxeFaOvudRR/XbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "ansi-regex": "^6.1.0", - "chalk": "^5.3.0", - "cli-highlight": "^2.1.11", - "cli-table3": "^0.6.5", - "node-emoji": "^2.1.3", - "supports-hyperlinks": "^3.1.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "marked": ">=1 <15" - } - }, - "node_modules/marked-terminal/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.4.tgz", - "integrity": "sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa" - ], - "license": "MIT", - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nerf-dart": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", - "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-emoji": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.1.3.tgz", - "integrity": "sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.6.0", - "char-regex": "^1.0.2", - "emojilib": "^2.4.0", - "skin-tone": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-url": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", - "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.0.tgz", - "integrity": "sha512-ZanDioFylI9helNhl2LNd+ErmVD+H5I53ry41ixlLyCBgkuYb+58CvbAp99hW+zr5L9W4X7CchSoeqKdngOLSw==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmhook", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "normalize-package-data", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which", - "write-file-atomic" - ], - "dev": true, - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^8.0.0", - "@npmcli/config": "^9.0.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", - "@npmcli/promise-spawn": "^8.0.1", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "@sigstore/tuf": "^2.3.4", - "abbrev": "^3.0.0", - "archy": "~1.0.0", - "cacache": "^19.0.1", - "chalk": "^5.3.0", - "ci-info": "^4.0.0", - "cli-columns": "^4.0.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^10.4.5", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^8.0.0", - "ini": "^5.0.0", - "init-package-json": "^7.0.1", - "is-cidr": "^5.1.0", - "json-parse-even-better-errors": "^4.0.0", - "libnpmaccess": "^9.0.0", - "libnpmdiff": "^7.0.0", - "libnpmexec": "^9.0.0", - "libnpmfund": "^6.0.0", - "libnpmhook": "^11.0.0", - "libnpmorg": "^7.0.0", - "libnpmpack": "^8.0.0", - "libnpmpublish": "^10.0.0", - "libnpmsearch": "^8.0.0", - "libnpmteam": "^7.0.0", - "libnpmversion": "^7.0.0", - "make-fetch-happen": "^14.0.1", - "minimatch": "^9.0.5", - "minipass": "^7.1.1", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^10.2.0", - "nopt": "^8.0.0", - "normalize-package-data": "^7.0.0", - "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-profile": "^11.0.1", - "npm-registry-fetch": "^18.0.1", - "npm-user-validate": "^3.0.0", - "p-map": "^4.0.0", - "pacote": "^19.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "qrcode-terminal": "^0.12.0", - "read": "^4.0.0", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "ssri": "^12.0.0", - "supports-color": "^9.4.0", - "tar": "^6.2.1", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.0", - "which": "^5.0.0", - "write-file-atomic": "^6.0.0" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/metavuln-calculator": "^8.0.0", - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.1", - "@npmcli/query": "^4.0.0", - "@npmcli/redact": "^3.0.0", - "@npmcli/run-script": "^9.0.1", - "bin-links": "^5.0.0", - "cacache": "^19.0.1", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^8.0.0", - "npm-install-checks": "^7.1.0", - "npm-package-arg": "^12.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.1", - "pacote": "^19.0.0", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "proggy": "^3.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "ssri": "^12.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "9.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^4.0.1", - "@npmcli/package-json": "^6.0.1", - "ci-info": "^4.0.0", - "ini": "^5.0.0", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^10.0.0", - "proc-log": "^5.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "4.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^19.0.0", - "json-parse-even-better-errors": "^4.0.0", - "pacote": "^19.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^8.0.0", - "json-parse-even-better-errors": "^4.0.0", - "normalize-package-data": "^7.0.0", - "proc-log": "^5.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.1.2" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "9.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^10.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "2.3.2", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/core": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.3.2", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "2.3.2", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "make-fetch-happen": "^13.0.1", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/@npmcli/agent": { - "version": "2.2.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/@npmcli/fs": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/cacache": { - "version": "18.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/make-fetch-happen": { - "version": "13.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", - "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/minipass-fetch": { - "version": "3.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/proc-log": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/ssri": { - "version": "10.0.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/unique-filename": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/unique-slug": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "2.3.4", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.2", - "tuf-js": "^2.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/verify": { - "version": "1.2.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.2" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/aggregate-error": { - "version": "3.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^7.0.0", - "npm-normalize-package-bin": "^4.0.0", - "proc-log": "^5.0.0", - "read-cmd-shim": "^5.0.0", - "write-file-atomic": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "2.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "19.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/p-map": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.0.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "4.1.1", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^5.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/clean-stack": { - "version": "2.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.3.6", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/diff": { - "version": "5.2.0", - "dev": true, - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "10.4.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/indent-string": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ini": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "7.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^6.0.0", - "npm-package-arg": "^12.0.0", - "promzard": "^2.0.0", - "read": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "5.1.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^4.1.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/is-lambda": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "3.4.3", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "9.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "binary-extensions": "^2.3.0", - "diff": "^5.1.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "tar": "^6.2.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "9.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/run-script": "^9.0.1", - "ci-info": "^4.0.0", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0", - "proc-log": "^5.0.0", - "read": "^4.0.0", - "read-package-json-fast": "^4.0.0", - "semver": "^7.3.7", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmhook": { - "version": "11.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^8.0.0", - "@npmcli/run-script": "^9.0.1", - "npm-package-arg": "^12.0.0", - "pacote": "^19.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "10.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^7.0.0", - "npm-package-arg": "^12.0.0", - "npm-registry-fetch": "^18.0.1", - "proc-log": "^5.0.0", - "semver": "^7.3.7", - "sigstore": "^2.2.0", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^18.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.1", - "@npmcli/run-script": "^9.0.1", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "10.4.3", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "14.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "9.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/negotiator": { - "version": "0.6.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "10.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^4.1.0", - "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^4.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/agent": { - "version": "2.2.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/fs": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/abbrev": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { - "version": "18.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { - "version": "13.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", - "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minipass-fetch": { - "version": "3.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/nopt": { - "version": "7.2.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/proc-log": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/ssri": { - "version": "10.0.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/unique-filename": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/unique-slug": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/which": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/nopt/node_modules/abbrev": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^8.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "12.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "9.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^7.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "10.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", - "npm-package-arg": "^12.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "11.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "18.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^3.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^14.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^12.0.0", - "proc-log": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/package-json-from-dist": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/npm/node_modules/pacote": { - "version": "19.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^6.0.0", - "@npmcli/installed-package-contents": "^3.0.0", - "@npmcli/package-json": "^6.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "@npmcli/run-script": "^9.0.0", - "cacache": "^19.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^12.0.0", - "npm-packlist": "^9.0.0", - "npm-pick-manifest": "^10.0.0", - "npm-registry-fetch": "^18.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^2.2.0", - "ssri": "^12.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/proggy": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-inflight": { - "version": "1.0.1", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "dev": true, - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^2.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/rimraf": { - "version": "5.0.10", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.6.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "2.3.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.2", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.2", - "@sigstore/sign": "^2.3.2", - "@sigstore/tuf": "^2.3.4", - "@sigstore/verify": "^1.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.1", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "dev": true, - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.18", - "dev": true, - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "dev": true, - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/npm/node_modules/ssri": { - "version": "12.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "9.4.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "2.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "2.0.1", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/@npmcli/agent": { - "version": "2.2.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/@npmcli/fs": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/cacache": { - "version": "18.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/make-fetch-happen": { - "version": "13.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", - "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/minipass-fetch": { - "version": "3.0.5", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/proc-log": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/ssri": { - "version": "10.0.6", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/unique-filename": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js/node_modules/unique-slug": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "3.0.1", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/which": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "6.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-each-series": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", - "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-filter": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", - "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-map": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-map": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.2.tgz", - "integrity": "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-reduce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", - "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true, - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", - "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", - "dev": true, - "dependencies": { - "find-up": "^2.0.0", - "load-json-file": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", - "dev": true, - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", - "dev": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", - "dev": true, - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pkg-conf/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/read-package-up": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", - "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0", - "read-pkg": "^9.0.0", - "type-fest": "^4.6.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/parse-json": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", - "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.22.13", - "index-to-position": "^0.1.2", - "type-fest": "^4.7.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/registry-auth-token": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", - "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/npm-conf": "^2.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/semantic-release": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.0.tgz", - "integrity": "sha512-fQfn6e/aYToRtVJYKqneFM1Rg3KP2gh3wSWtpYsLlz6uaPKlISrTzvYAFn+mYWo07F0X1Cz5ucU89AVE8X1mbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semantic-release/commit-analyzer": "^13.0.0-beta.1", - "@semantic-release/error": "^4.0.0", - "@semantic-release/github": "^11.0.0", - "@semantic-release/npm": "^12.0.0", - "@semantic-release/release-notes-generator": "^14.0.0-beta.1", - "aggregate-error": "^5.0.0", - "cosmiconfig": "^9.0.0", - "debug": "^4.0.0", - "env-ci": "^11.0.0", - "execa": "^9.0.0", - "figures": "^6.0.0", - "find-versions": "^6.0.0", - "get-stream": "^6.0.0", - "git-log-parser": "^1.2.0", - "hook-std": "^3.0.0", - "hosted-git-info": "^8.0.0", - "import-from-esm": "^1.3.1", - "lodash-es": "^4.17.21", - "marked": "^12.0.0", - "marked-terminal": "^7.0.0", - "micromatch": "^4.0.2", - "p-each-series": "^3.0.0", - "p-reduce": "^3.0.0", - "read-package-up": "^11.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.2", - "semver-diff": "^4.0.0", - "signale": "^1.2.1", - "yargs": "^17.5.1" - }, - "bin": { - "semantic-release": "bin/semantic-release.js" - }, - "engines": { - "node": ">=20.8.1" - } - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "dev": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semver-regex": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", - "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/signale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", - "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", - "dev": true, - "dependencies": { - "chalk": "^2.3.2", - "figures": "^2.0.0", - "pkg-conf": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/signale/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/signale/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/signale/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/signale/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/signale/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/signale/node_modules/figures": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", - "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/signale/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/signale/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/skin-tone": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", - "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-emoji-modifier-base": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spawn-error-forwarder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", - "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", - "dev": true - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/stream-combiner2": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", - "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", - "dev": true, - "dependencies": { - "duplexer2": "~0.1.0", - "readable-stream": "^2.0.2" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/super-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", - "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-timeout": "^1.0.1", - "time-span": "^5.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz", - "integrity": "sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/time-span": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", - "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "convert-hrtime": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/traverse": { - "version": "0.6.7", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", - "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/type-fest": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.0.tgz", - "integrity": "sha512-3IMSWgP7C5KSQqmo1wjhKrwsvXAtF33jO3QY+Uy++ia7hqvgSK6iXbbg5PbDBc1P2ZbNEDgejOrN4YooXvhwCw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/unicode-emoji-modifier-base": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", - "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/url-join": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", - "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 1caf281..0000000 --- a/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "devDependencies": { - "@semantic-release/changelog": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "semantic-release": "^24.2.0" - } -} diff --git a/pkg/app/service.go b/pkg/app/service.go deleted file mode 100644 index 73e8bd6..0000000 --- a/pkg/app/service.go +++ /dev/null @@ -1,255 +0,0 @@ -package app - -import ( - "context" - "fmt" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/host" - "github.com/spectate/agent/internal/http" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/version" - "github.com/spectate/agent/pkg/checks/strategies" - "github.com/spectate/agent/pkg/collector" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "github.com/spf13/viper" - "strconv" - "strings" - "sync" - "time" -) - -type App struct { - client *http.Client - strategies map[time.Duration][]strategies.Strategy - dataCh chan []*pb.MetricsPayload_Metric - wg sync.WaitGroup -} - -func NewApp() *App { - logger.Log.Info().Msg("Starting Spectated " + version.Version + " (Build date: " + version.BuildDate + ")") - - client := http.NewClient() - - registeredStrategies := strategies.Registrar.Strategies - - return &App{ - client: client, - strategies: registeredStrategies, - } -} - -func (app *App) Start(ctx context.Context) { - tokenValue := viper.Get("host.token") - token, ok := tokenValue.(string) - if !ok || token == "" { - fmt.Println("No token found. Please follow the instructions on https://app.spectate.net to set up your host.") - return - } - - app.dataCh = make(chan []*pb.MetricsPayload_Metric) - - go func() { - err := app.initialMetrics(ctx) - if err != nil { - logger.Log.Err(err).Msg("Failed to send initial metrics") - return - } - }() - - app.bootstrapMonitoring(ctx) - - coll := collector.NewCollector(app.client, app.dataCh) - err := coll.CollectAndSend(ctx) - if err != nil { - logger.Log.Err(err) - return - } -} - -func (app *App) Stop() { - logger.Log.Info().Msg("Stopping Spectated") -} - -// initialMetrics sends metrics that are not collected periodically, such -// as the host info, cpu info, agent version, etc. -func (app *App) initialMetrics(ctx context.Context) error { - logger.Log.Debug().Msg("Collect initial metrics") - - batch := metrics.NewMetricsBatch(app.dataCh) - batch.Info("agent.version", []*pb.MetricsPayload_Label{ - { - Key: "version", - Value: version.Version, - }, - }, nil) - - hostInfo, err := host.Info() - if err != nil { - logger.Log.Err(err).Msg("Failed to get host info") - return err - } - - batch.Info("host.info", []*pb.MetricsPayload_Label{ - { - Key: "hostname", - Value: hostInfo.Hostname, - }, - { - Key: "os", - Value: hostInfo.OS, - }, - { - Key: "platform", - Value: hostInfo.Platform, - }, - { - Key: "platform_family", - Value: hostInfo.PlatformFamily, - }, - { - Key: "platform_version", - Value: hostInfo.PlatformVersion, - }, - { - Key: "kernel_version", - Value: hostInfo.KernelVersion, - }, - { - Key: "kernel_arch", - Value: hostInfo.KernelArch, - }, - { - Key: "virtualization_system", - Value: hostInfo.VirtualizationSystem, - }, - { - Key: "virtualization_role", - Value: hostInfo.VirtualizationRole, - }, - { - Key: "host_id", - Value: hostInfo.HostID, - }, - }, nil) - - cpuInfo, err := cpu.Info() - if err != nil { - logger.Log.Err(err).Msg("Failed to get cpu info") - return err - } - - for _, c := range cpuInfo { - batch.Info("host.cpus", []*pb.MetricsPayload_Label{ - { - Key: "vendor_id", - Value: c.VendorID, - }, - { - Key: "family", - Value: c.Family, - }, - { - Key: "model", - Value: c.Model, - }, - { - Key: "stepping", - Value: strconv.Itoa(int(c.Stepping)), - }, - { - Key: "physical_id", - Value: c.PhysicalID, - }, - { - Key: "core_id", - Value: c.CoreID, - }, - { - Key: "cores", - Value: strconv.Itoa(int(c.Cores)), - }, - { - Key: "model_name", - Value: c.ModelName, - }, - { - Key: "mhz", - Value: strconv.Itoa(int(c.Mhz)), - }, - { - Key: "cache_size", - Value: strconv.Itoa(int(c.CacheSize)), - }, - { - Key: "flags", - Value: strings.Join(c.Flags, ","), - }, - { - Key: "microcode", - Value: c.Microcode, - }, - }, - []*pb.MetricsPayload_Label{ - { - Key: "cpu", - Value: strconv.Itoa(int(c.CPU)), - }, - }, - ) - } - - batch.Commit() - - logger.Log.Debug().Msg("Committed initial metrics") - - return nil -} - -func (app *App) bootstrapMonitoring(ctx context.Context) { - // For all registered strategies, log them - for period, strategySlice := range app.strategies { - for _, strategy := range strategySlice { - logger.Log.Info().Str("strategy", strategy.Options().Namespace+"."+strategy.Options().Id). - Str("frequency", period.String()). - Msg("Registered strategy") - } - } - - for period, strategySlice := range app.strategies { - for _, strategy := range strategySlice { - strategy := strategy - - // Run all strategies once - go func(s strategies.Strategy) { - err := s.Run(ctx, app.dataCh) - if err != nil { - // TODO: Handle errors appropriately. Skip this strategy for now - logger.Log.Err(err) - return - } - }(strategy) - - // Start ticker - go func(s strategies.Strategy, period time.Duration) { - logger.Log.Debug().Msg("Running " + s.Options().Id + " every " + period.String()) - ticker := time.NewTicker(period) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - err := s.Run(ctx, app.dataCh) - if err != nil { - // TODO: Handle errors appropriately. Skip this strategy for now - logger.Log.Err(err) - continue - } - case <-ctx.Done(): - return - } - } - }(strategy, period) - } - } -} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go deleted file mode 100644 index 5261f8d..0000000 --- a/pkg/auth/auth.go +++ /dev/null @@ -1,70 +0,0 @@ -package auth - -import ( - "bufio" - "fmt" - "github.com/getsentry/sentry-go" - "github.com/shirou/gopsutil/v3/host" - "github.com/spectate/agent/internal/http" - "github.com/spectate/agent/pkg/config" - "github.com/spf13/viper" - "os" - "strings" -) - -func Authorize(token string) error { - client := http.NewClient() - - existingToken := viper.Get("host.token") - if existingToken != "" { - requestOverwriteConfirmation() - } - - hostInfo, err := host.Info() - if err != nil { - sentry.CaptureException(err) - return err - } - - response, err := client.Authorize(http.Authorize{ - Token: token, - Hostname: hostInfo.Hostname, - }) - if err != nil { - return err - } - - result := response.Result().(*http.AuthorizeSuccess) - - viper.Set("host.token", result.Token) - config.Update() - - return nil -} - -func requestOverwriteConfirmation() { - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Print("An existing token was found, overwrite? (y/n): ") - response, err := reader.ReadString('\n') - - if err != nil { - fmt.Println("Invalid input, please try again.") - continue - } - - // Trim spaces and make the response lower-case for comparison - response = strings.ToLower(strings.TrimSpace(response)) - - if response == "y" || response == "yes" { - fmt.Println("Confirmed.") - break - } else if response == "n" || response == "no" { - fmt.Println("Aborting.") - os.Exit(1) - } else { - fmt.Println("Invalid response, please type 'y' or 'n'") - } - } -} diff --git a/pkg/checks/strategies/cpu_times.go b/pkg/checks/strategies/cpu_times.go deleted file mode 100644 index 4bbf9ec..0000000 --- a/pkg/checks/strategies/cpu_times.go +++ /dev/null @@ -1,97 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -type CpuTimes struct { - opts Options - cores float64 - lastCycle float64 - lastTimes cpu.TimesStat -} - -func init() { - cpuInfo, err := cpu.Info() - if err != nil { - logger.Log.Error().Err(err).Msg("Failed to get CPU info") - return - } - var cores float64 - for _, info := range cpuInfo { - cores += float64(info.Cores) - } - Registrar.RegisterStrategy(&CpuTimes{ - opts: Options{ - Namespace: "system", - Id: "cpu", - Frequency: time.Minute, - }, - cores: cores, - }) -} - -func (s *CpuTimes) Options() Options { - return s.opts -} - -func (s *CpuTimes) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - cpuTimes, err := cpu.Times(false) - if err != nil { - logger.Log.Error().Err(err).Msg("Failed to get CPU times") - return err - } else if len(cpuTimes) < 1 { - logger.Log.Error().Msg("No CPU times found") - return nil - } - - t := cpuTimes[0] - - total := t.User + t.System + t.Idle + t.Nice + - t.Iowait + t.Irq + t.Softirq + t.Steal - cycle := total / s.cores - - if s.lastCycle != 0 { - toPercent := 100 / (cycle - s.lastCycle) - - user := ((t.User + t.Nice) - (s.lastTimes.User + s.lastTimes.Nice)) / s.cores - system := ((t.System + t.Irq + t.Softirq) - (s.lastTimes.System + s.lastTimes.Irq + s.lastTimes.Softirq)) / s.cores - interrupt := ((t.Irq + t.Softirq) - (s.lastTimes.Irq + s.lastTimes.Softirq)) / s.cores - iowait := (t.Iowait - s.lastTimes.Iowait) / s.cores - idle := (t.Idle - s.lastTimes.Idle) / s.cores - stolen := (t.Steal - s.lastTimes.Steal) / s.cores - guest := (t.Guest - s.lastTimes.Guest) / s.cores - - batch.Gauge("system.cpu.user", user*toPercent, nil) - batch.Gauge("system.cpu.system", system*toPercent, nil) - batch.Gauge("system.cpu.interrupt", interrupt*toPercent, nil) - batch.Gauge("system.cpu.iowait", iowait*toPercent, nil) - batch.Gauge("system.cpu.idle", idle*toPercent, nil) - batch.Gauge("system.cpu.stolen", stolen*toPercent, nil) - batch.Gauge("system.cpu.guest", guest*toPercent, nil) - } - - batch.Commit() - - s.lastCycle = cycle - s.lastTimes = t - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} diff --git a/pkg/checks/strategies/disk_usage.go b/pkg/checks/strategies/disk_usage.go deleted file mode 100644 index f606ace..0000000 --- a/pkg/checks/strategies/disk_usage.go +++ /dev/null @@ -1,113 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/disk" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/shared" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "github.com/spf13/viper" - "strings" - "time" -) - -type DiskUsage struct { - opts Options - excludedDevices []string - excludedFilesystems []string - excludedMountPoints []string -} - -func init() { - Registrar.RegisterStrategy(&DiskUsage{ - opts: Options{ - Namespace: "system", - Id: "disk_usage", - Frequency: time.Minute, - }, - }) -} - -func (s *DiskUsage) Options() Options { - return s.opts -} - -func (s *DiskUsage) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - partitionInfo, err := disk.Partitions(false) - if err != nil { - return err - } - - s.excludedFilesystems = viper.GetStringSlice("checks.disk_usage.exclude.filesystems") - s.excludedDevices = viper.GetStringSlice("checks.disk_usage.exclude.devices") - s.excludedMountPoints = viper.GetStringSlice("checks.disk_usage.exclude.mount_points") - - for _, partition := range partitionInfo { - if s.isExcluded(partition) { - continue - } - - usage, err := disk.Usage(partition.Mountpoint) - if err != nil { - logger.Log.Error().Err(err).Msgf("Failed to get disk usage for %s", partition.Mountpoint) - continue - } - - labels := make([]*pb.MetricsPayload_Label, 0) - labels = append(labels, &pb.MetricsPayload_Label{ - Key: "mountpoint", - Value: partition.Mountpoint, - }) - labels = append(labels, &pb.MetricsPayload_Label{ - Key: "device", - Value: partition.Device, - }) - labels = append(labels, &pb.MetricsPayload_Label{ - Key: "fstype", - Value: partition.Fstype, - }) - - // Convert to KB and percent - batch.Gauge("system.disk_usage.total", float64(usage.Total)/1024, labels) - batch.Gauge("system.disk_usage.free", float64(usage.Free)/1024, labels) - batch.Gauge("system.disk_usage.used", float64(usage.Used)/1024, labels) - batch.Gauge("system.disk_usage.used_percent", usage.UsedPercent, labels) - batch.Gauge("system.disk_usage.inodes_total", float64(usage.InodesTotal), labels) - batch.Gauge("system.disk_usage.inodes_used", float64(usage.InodesUsed), labels) - batch.Gauge("system.disk_usage.inodes_free", float64(usage.InodesFree), labels) - batch.Gauge("system.disk_usage.inodes_used_percent", usage.InodesUsedPercent, labels) - } - - batch.Commit() - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} - -func (s *DiskUsage) isExcluded(partition disk.PartitionStat) bool { - if shared.StrSliceContains(s.excludedFilesystems, strings.ToLower(partition.Fstype)) { - return true - } - - if shared.StrSliceContains(s.excludedDevices, strings.ToLower(partition.Device)) { - return true - } - - if shared.StrSliceContains(s.excludedMountPoints, strings.ToLower(partition.Mountpoint)) { - return true - } - - return false -} diff --git a/pkg/checks/strategies/load.go b/pkg/checks/strategies/load.go deleted file mode 100644 index 3d34049..0000000 --- a/pkg/checks/strategies/load.go +++ /dev/null @@ -1,55 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/load" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -type LoadAvg struct { - opts Options -} - -func init() { - Registrar.RegisterStrategy(&LoadAvg{ - opts: Options{ - Namespace: "system", - Id: "load", - Frequency: time.Minute, - }, - }) -} - -func (s *LoadAvg) Options() Options { - return s.opts -} - -func (s *LoadAvg) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - loadAvg, err := load.Avg() - if err != nil { - return err - } - - batch.Gauge("system.load.1", loadAvg.Load1, nil) - batch.Gauge("system.load.5", loadAvg.Load5, nil) - batch.Gauge("system.load.15", loadAvg.Load15, nil) - - batch.Commit() - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} diff --git a/pkg/checks/strategies/memory.go b/pkg/checks/strategies/memory.go deleted file mode 100644 index 9a903fb..0000000 --- a/pkg/checks/strategies/memory.go +++ /dev/null @@ -1,72 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/mem" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/shared" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -type Memory struct { - opts Options -} - -func init() { - Registrar.RegisterStrategy(&Memory{ - opts: Options{ - Namespace: "system", - Id: "memory", - Frequency: time.Minute, - }, - }) -} - -func (s *Memory) Options() Options { - return s.opts -} - -func (s *Memory) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - vMem, err := mem.VirtualMemory() - if err != nil { - return err - } - - // Values are returned in bytes, convert to MB - - batch.Gauge("system.memory.total", float64(vMem.Total)/shared.MbSize, nil) - batch.Gauge("system.memory.free", float64(vMem.Free)/shared.MbSize, nil) - batch.Gauge("system.memory.used", float64(vMem.Used)/shared.MbSize, nil) - batch.Gauge("system.memory.available", float64(vMem.Available)/shared.MbSize, nil) - batch.Gauge("system.memory.available_percent", 100-vMem.UsedPercent, nil) - - swap, err := mem.SwapMemory() - if err != nil { - return err - } - - batch.Gauge("system.swap.total", float64(swap.Total)/shared.MbSize, nil) - batch.Gauge("system.swap.free", float64(swap.Free)/shared.MbSize, nil) - batch.Gauge("system.swap.used", float64(swap.Used)/shared.MbSize, nil) - batch.Gauge("system.swap.in", float64(swap.Sin)/shared.MbSize, nil) - batch.Gauge("system.swap.out", float64(swap.Sout)/shared.MbSize, nil) - batch.Gauge("system.swap.free_percent", 100-swap.UsedPercent, nil) - - batch.Commit() - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} diff --git a/pkg/checks/strategies/network.go b/pkg/checks/strategies/network.go deleted file mode 100644 index 052fc39..0000000 --- a/pkg/checks/strategies/network.go +++ /dev/null @@ -1,121 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/net" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/shared" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "github.com/spf13/viper" - "strings" - "time" -) - -type Net struct { - opts Options - includedInterfaces []string - excludedInterfaces []string - lastIOCounters []net.IOCountersStat -} - -func init() { - Registrar.RegisterStrategy(&Net{ - opts: Options{ - Namespace: "system", - Id: "net", - Frequency: time.Minute, - }, - }) -} - -func (s *Net) Options() Options { - return s.opts -} - -func (s *Net) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - netIOCounters, err := net.IOCounters(true) - if err != nil { - return err - } - - s.includedInterfaces = viper.GetStringSlice("checks.net.include.interfaces") - s.excludedInterfaces = viper.GetStringSlice("checks.net.exclude.interfaces") - - // We call a network interface "iface" to avoid confusion with the Go interface type - for _, ioCount := range netIOCounters { - if s.isExcluded(ioCount.Name) { - continue - } - - var lastIOCount *net.IOCountersStat = nil - - for i, l := range s.lastIOCounters { - if l.Name == ioCount.Name { - lastIOCount = &l - - // Update the lastIOCounters list with the new value - s.lastIOCounters[i] = ioCount - break - } - } - - // If we don't have a lastIOCount, add it to the list and continue to the next - // interface as we can't calculate the difference - if lastIOCount == nil { - s.lastIOCounters = append(s.lastIOCounters, ioCount) - continue - } - - labels := make([]*pb.MetricsPayload_Label, 0) - labels = append(labels, &pb.MetricsPayload_Label{ - Key: "interface", - Value: ioCount.Name, - }) - - // Convert to KB and use the difference between the current and last counter value - batch.Gauge("system.net.bytes_sent", float64(ioCount.BytesSent-lastIOCount.BytesSent)/1024, labels) - batch.Gauge("system.net.bytes_recv", float64(ioCount.BytesRecv-lastIOCount.BytesRecv)/1024, labels) - batch.Gauge("system.net.packets_sent", float64(ioCount.PacketsSent-lastIOCount.PacketsSent), labels) - batch.Gauge("system.net.packets_recv", float64(ioCount.PacketsRecv-lastIOCount.PacketsRecv), labels) - batch.Gauge("system.net.err_in", float64(ioCount.Errin-lastIOCount.Errin), labels) - batch.Gauge("system.net.err_out", float64(ioCount.Errout-lastIOCount.Errout), labels) - batch.Gauge("system.net.drop_in", float64(ioCount.Dropin-lastIOCount.Dropin), labels) - batch.Gauge("system.net.drop_out", float64(ioCount.Dropout-lastIOCount.Dropout), labels) - batch.Gauge("system.net.fifo_in", float64(ioCount.Fifoin-lastIOCount.Fifoin), labels) - batch.Gauge("system.net.fifo_out", float64(ioCount.Fifoout-lastIOCount.Fifoout), labels) - } - - batch.Commit() - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} - -func (s *Net) isExcluded(iface string) bool { - if shared.StrSliceContains(s.includedInterfaces, iface) { - // Included interfaces take precedence over excluded interfaces - return false - } - - for _, excludedInterface := range s.excludedInterfaces { - // If the interface is an exact match or a wildcard match, return true - if iface == excludedInterface || - (strings.HasSuffix(excludedInterface, "*") && strings.HasPrefix(iface, excludedInterface[:len(excludedInterface)-1])) { - return true - } - } - - return false -} diff --git a/pkg/checks/strategies/strategies.go b/pkg/checks/strategies/strategies.go deleted file mode 100644 index b2b5655..0000000 --- a/pkg/checks/strategies/strategies.go +++ /dev/null @@ -1,32 +0,0 @@ -package strategies - -import ( - "context" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -// Strategy provides a common interface for different monitoring tasks. -type Strategy interface { - Options() Options - Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error -} - -type Options struct { - Namespace string - Id string - Frequency time.Duration -} - -type StrategyRegistrar struct { - Strategies map[time.Duration][]Strategy -} - -var Registrar = &StrategyRegistrar{ - Strategies: make(map[time.Duration][]Strategy), -} - -func (r *StrategyRegistrar) RegisterStrategy(strategy Strategy) { - duration := strategy.Options().Frequency - r.Strategies[duration] = append(r.Strategies[duration], strategy) -} diff --git a/pkg/checks/strategies/uptime.go b/pkg/checks/strategies/uptime.go deleted file mode 100644 index c16bff1..0000000 --- a/pkg/checks/strategies/uptime.go +++ /dev/null @@ -1,53 +0,0 @@ -package strategies - -import ( - "context" - "github.com/shirou/gopsutil/v3/host" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/metrics" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -type Uptime struct { - opts Options -} - -func init() { - Registrar.RegisterStrategy(&Uptime{ - opts: Options{ - Namespace: "system", - Id: "uptime", - Frequency: time.Minute, - }, - }) -} - -func (s *Uptime) Options() Options { - return s.opts -} - -func (s *Uptime) Run(ctx context.Context, dataCh chan<- []*pb.MetricsPayload_Metric) error { - batch := metrics.NewMetricsBatch(dataCh) - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Running strategy") - - uptime, err := host.Uptime() - if err != nil { - return err - } - - batch.Gauge("system.uptime", float64(uptime), nil) - - batch.Commit() - - logger.Log.Debug(). - Str("namespace", s.opts.Namespace). - Str("id", s.opts.Id). - Msg("Completed strategy") - - return nil -} diff --git a/pkg/collector/collector.go b/pkg/collector/collector.go deleted file mode 100644 index bd13813..0000000 --- a/pkg/collector/collector.go +++ /dev/null @@ -1,67 +0,0 @@ -package collector - -import ( - "context" - "github.com/getsentry/sentry-go" - "github.com/spectate/agent/internal/http" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/proto/pb" - "time" -) - -type Collector struct { - client *http.Client - collectedData []*pb.MetricsPayload_Metric - dataCh <-chan []*pb.MetricsPayload_Metric -} - -func NewCollector(client *http.Client, dataCh <-chan []*pb.MetricsPayload_Metric) *Collector { - logger.Log.Info().Msg("Initializing collector") - return &Collector{ - client: client, - collectedData: []*pb.MetricsPayload_Metric{}, - dataCh: dataCh, - } -} - -func (c *Collector) CollectAndSend(ctx context.Context) error { - ticker := time.NewTicker(1 * time.Minute) - - logger.Log.Debug().Msg("Starting collector") - - for { - select { - case <-ticker.C: - // Send collected data every minute - if len(c.collectedData) > 0 { - err := c.sendData(c.collectedData) - if err != nil { - logger.Log.Error().Err(err).Msg("Failed to send data to API. Collecting data queued next cycle.") - sentry.CaptureException(err) - } else { - // Clear data after sending - c.collectedData = []*pb.MetricsPayload_Metric{} - } - } - case data := <-c.dataCh: - c.collectedData = append(c.collectedData, data...) - case <-ctx.Done(): - return nil - } - } -} - -func (c *Collector) sendData(data []*pb.MetricsPayload_Metric) error { - logger.Log.Debug().Msg("Pushing collected data to API") - - _, err := c.client.Payload(&pb.MetricsPayload{ - Metrics: data, - }) - - if err != nil { - logger.Log.Error().Err(err).Msg("Failed to send data to API") - return err - } - - return nil -} diff --git a/pkg/collector/types.go b/pkg/collector/types.go deleted file mode 100644 index 938d6a0..0000000 --- a/pkg/collector/types.go +++ /dev/null @@ -1,8 +0,0 @@ -package collector - -// StrategyData contains the data generated by a strategy. -type StrategyData struct { - Strategy string `json:"strategy"` - Data interface{} `json:"data"` - Timestamp int64 `json:"timestamp"` -} diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index ea6a3eb..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,78 +0,0 @@ -package config - -import ( - "fmt" - "github.com/spectate/agent/internal/logger" - "github.com/spf13/viper" - "os" -) - -var CfgFile string - -// InitConfig reads in config file and ENV variables if set. -func InitConfig() { - initConfigConstants() - - if CfgFile != "" { - // Use config file from the flag. - viper.SetConfigFile(CfgFile) - } else { - // Check if the config directory exists, if not, create it. - if _, err := os.Stat(BaseDir); os.IsNotExist(err) { - err = os.MkdirAll(BaseDir, 0755) - if err != nil { - fmt.Println("Failed to create config directory:", err) - os.Exit(1) - } - } - - // Search config in directory with name "config" (without extension). - viper.AddConfigPath(BaseDir) - viper.SetConfigType("yaml") - viper.SetConfigName("config") - } - - viper.AutomaticEnv() // read in environment variables that match - - // If a config file is found, read it in. Else write defaults to file. - if err := viper.ReadInConfig(); err == nil { - logger.Log.Debug(). - Msg("Using config file: " + viper.ConfigFileUsed()) - } - - setDefaults() - - err := viper.WriteConfigAs(BaseDir + "/config.yaml") - if err != nil { - logger.Log.Panic().Err(err).Msg("Failed to write config file") - os.Exit(1) - } - -} - -func setDefaults() { - viper.SetDefault("telemetry.error_reporting", true) - - viper.SetDefault("checks.disk_usage.exclude.devices", []string{}) - viper.SetDefault("checks.disk_usage.exclude.filesystems", []string{"squashfs", "vfat"}) - viper.SetDefault("checks.disk_usage.exclude.mount_points", []string{}) - - // We have excluded some interfaces by default to avoid collecting metrics from - // interfaces that are not relevant to the user in most setups. For example, we don't want to - // collect metrics from docker interfaces. - - // Include is used to override wildcards in exclude. Include does not support wildcards. - viper.SetDefault("checks.net.include.interfaces", []string{"en0", "eth0", "lo0"}) - viper.SetDefault("checks.net.exclude.interfaces", []string{"br*", "docker*", "lo", "veth*", "utun*", "llw*", "utun*", "bridge*", "ap*", "anpi*", "awdl*", "gif*", "stf*", "en*"}) - - viper.SetDefault("host.token", "") - viper.SetDefault("version", 1) -} - -func Update() { - err := viper.WriteConfigAs(BaseDir + "/config.yaml") - if err != nil { - logger.Log.Panic().Err(err).Msg("Failed to write config file") - os.Exit(1) - } -} diff --git a/pkg/config/config_darwin.go b/pkg/config/config_darwin.go deleted file mode 100644 index e66b72a..0000000 --- a/pkg/config/config_darwin.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build darwin - -package config - -import ( - "log" - "os" -) - -var ( - BaseDir string -) - -func init() { - userHomeDir, err := os.UserHomeDir() - if err != nil { - log.Fatalf("Failed to get user home directory: %v", err) - } - BaseDir = userHomeDir + "/.spectated" -} - -func initConfigConstants() {} diff --git a/pkg/config/config_nix.go b/pkg/config/config_nix.go deleted file mode 100644 index b8faeba..0000000 --- a/pkg/config/config_nix.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !darwin - -package config - -const ( - BaseDir = "/etc/spectated" -) - -func initConfigConstants() {} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go deleted file mode 100644 index 0ab97ea..0000000 --- a/pkg/metrics/metrics.go +++ /dev/null @@ -1,89 +0,0 @@ -package metrics - -import ( - "github.com/spectate/agent/pkg/proto/pb" - "google.golang.org/protobuf/types/known/timestamppb" - "sync" -) - -// Batch allows the collection of multiple data points, and to send it to the Collector after finalization. -type Batch struct { - data []*pb.MetricsPayload_Metric - mux sync.Mutex - dataCh chan<- []*pb.MetricsPayload_Metric -} - -// NewMetricsBatch creates and returns a new MetricsBatch. -func NewMetricsBatch(dataCh chan<- []*pb.MetricsPayload_Metric) *Batch { - return &Batch{ - data: []*pb.MetricsPayload_Metric{}, - mux: sync.Mutex{}, - dataCh: dataCh, - } -} - -// add accepts a metric key and a value, inserting it into the batch data. -// the key should be formatted by .. (e.g. system.cpu.user) -func (s *Batch) add(key string, value interface{}, labels []*pb.MetricsPayload_Label, metricType pb.MetricsPayload_MetricType) { - s.mux.Lock() - defer s.mux.Unlock() - - var metricValue *pb.MetricsPayload_Metric - - switch metricType { - case pb.MetricsPayload_GAUGE: - metricValue = &pb.MetricsPayload_Metric{ - Key: key, - Labels: labels, - Type: metricType, - Value: &pb.MetricsPayload_Metric_GaugeValue{ - GaugeValue: &pb.MetricsPayload_GaugeValue{ - Value: value.(float64), - }, - }, - } - case pb.MetricsPayload_INFO: - metricValue = &pb.MetricsPayload_Metric{ - Key: key, - Labels: labels, - Type: metricType, - Value: &pb.MetricsPayload_Metric_InfoValue{ - InfoValue: &pb.MetricsPayload_InfoValue{ - Info: value.([]*pb.MetricsPayload_Label), - }, - }, - } - default: - return - } - - metricValue.Timestamp = timestamppb.Now() - s.data = append(s.data, metricValue) -} - -// Gauge accepts a metric key and a value, inserting it into the batch data. -func (s *Batch) Gauge(key string, value float64, labels []*pb.MetricsPayload_Label) { - s.add(key, value, labels, pb.MetricsPayload_GAUGE) -} - -// Info accepts a metric key and a value, inserting it into the batch data. -func (s *Batch) Info(key string, value []*pb.MetricsPayload_Label, labels []*pb.MetricsPayload_Label) { - s.add(key, value, labels, pb.MetricsPayload_INFO) -} - -// Commit finalizes the data, adding a timestamp, and then sends it to Collector. -// Data gets grouped by timestamp, and then sent to the API. -func (s *Batch) Commit() { - s.mux.Lock() - defer s.mux.Unlock() - - if len(s.data) == 0 { - // No data was committed, just ignore. - return - } - - s.dataCh <- s.data - - // Reset data - s.data = []*pb.MetricsPayload_Metric{} -} diff --git a/pkg/metrics/metrics_batch.go b/pkg/metrics/metrics_batch.go deleted file mode 100644 index d031b97..0000000 --- a/pkg/metrics/metrics_batch.go +++ /dev/null @@ -1,63 +0,0 @@ -package metrics - -// -//import ( -// "sync" -// "time" -//) -// -//// MetricsBatch allows the collection of multiple data points, and to send it to the Collector after finalization. -//type MetricsBatch struct { -// data map[string]float64 -// mux sync.Mutex -// dataCh chan<- Payload -//} -// -//// NewMetricsBatch creates and returns a new MetricsBatch. -//func NewMetricsBatch(dataCh chan<- Payload) *MetricsBatch { -// return &MetricsBatch{ -// data: make(map[string]float64), -// mux: sync.Mutex{}, -// dataCh: dataCh, -// } -//} -// -//// add accepts a metric key and a value, inserting it into the batch data. -//// the key should be formatted by .. (e.g. system.cpu.user) -//func (s *MetricsBatch) add(key string, value float64) { -// s.mux.Lock() -// defer s.mux.Unlock() -// -// s.data[key] = value -//} -// -//// Gauge accepts a metric key and a value, inserting it into the batch data. -//func (s *MetricsBatch) Gauge(key string, value float64) { -// s.add(key, value) -//} -// -//// Payload is used to accumulate all metric data with their timestamp. -//type Payload struct { -// Data map[string]float64 `json:"data"` -// Timestamp int64 `json:"timestamp"` -//} -// -//// Commit finalizes the data, adding a timestamp, and then sends it to Collector. -//// Data gets grouped by timestamp, and then sent to the API. -//func (s *MetricsBatch) Commit() { -// s.mux.Lock() -// defer s.mux.Unlock() -// -// if len(s.data) == 0 { -// // No data was committed, just ignore. -// return -// } -// -// payload := Payload{ -// Data: s.data, -// Timestamp: time.Now().UnixMilli(), -// } -// s.data = make(map[string]float64) -// -// s.dataCh <- payload -//} diff --git a/pkg/proto/metrics_payload.proto b/pkg/proto/metrics_payload.proto deleted file mode 100644 index 624a1e4..0000000 --- a/pkg/proto/metrics_payload.proto +++ /dev/null @@ -1,66 +0,0 @@ -syntax = "proto3"; - -package spectate.agent; - -option go_package = "pkg/proto/pb"; - -import "google/protobuf/timestamp.proto"; - -message MetricsPayload { - - // The data type of a Metric value. Deferred from OpenMetrics types. - enum MetricType { - UNKNOWN = 0; // Default value. Should not be used. - GAUGE = 1; - INFO = 4; -// We currently don't support these types. Uncomment when we do. -// COUNTER = 2; -// STATE_SET = 3; -// HISTOGRAM = 5; -// GAUGE_HISTOGRAM = 6; -// SUMMARY = 7; - } - - message Label { - string key = 1; - string value = 2; - } - - message Metric { - string key = 1; - MetricType type = 2; - repeated Label labels = 3; - - oneof value { - UnknownValue unknown_value = 4; - GaugeValue gauge_value = 5; - InfoValue info_value = 9; -// CounterValue counter_value = 6; -// HistogramValue histogram_value = 7; -// StateSetValue state_set_value = 8; -// SummaryValue summary_value = 10; - } - - google.protobuf.Timestamp timestamp = 10; - } - - // Value for UNKNOWN Metric. - message UnknownValue { - // Required. - double value = 1; - } - - // Value for GAUGE Metric. - message GaugeValue { - // Required. - double value = 1; - } - - // Value for INFO Metric. - message InfoValue { - // Optional. - repeated Label info = 1; - } - - repeated Metric metrics = 1; -} diff --git a/pkg/proto/pb/metrics_payload.pb.go b/pkg/proto/pb/metrics_payload.pb.go deleted file mode 100644 index 29bfbfd..0000000 --- a/pkg/proto/pb/metrics_payload.pb.go +++ /dev/null @@ -1,654 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.31.0 -// protoc v4.24.4 -// source: pkg/proto/metrics_payload.proto - -package pb - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// The data type of a Metric value. Deferred from OpenMetrics types. -type MetricsPayload_MetricType int32 - -const ( - MetricsPayload_UNKNOWN MetricsPayload_MetricType = 0 // Default value. Should not be used. - MetricsPayload_GAUGE MetricsPayload_MetricType = 1 - MetricsPayload_INFO MetricsPayload_MetricType = 4 -) - -// Enum value maps for MetricsPayload_MetricType. -var ( - MetricsPayload_MetricType_name = map[int32]string{ - 0: "UNKNOWN", - 1: "GAUGE", - 4: "INFO", - } - MetricsPayload_MetricType_value = map[string]int32{ - "UNKNOWN": 0, - "GAUGE": 1, - "INFO": 4, - } -) - -func (x MetricsPayload_MetricType) Enum() *MetricsPayload_MetricType { - p := new(MetricsPayload_MetricType) - *p = x - return p -} - -func (x MetricsPayload_MetricType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (MetricsPayload_MetricType) Descriptor() protoreflect.EnumDescriptor { - return file_pkg_proto_metrics_payload_proto_enumTypes[0].Descriptor() -} - -func (MetricsPayload_MetricType) Type() protoreflect.EnumType { - return &file_pkg_proto_metrics_payload_proto_enumTypes[0] -} - -func (x MetricsPayload_MetricType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use MetricsPayload_MetricType.Descriptor instead. -func (MetricsPayload_MetricType) EnumDescriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 0} -} - -type MetricsPayload struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Metrics []*MetricsPayload_Metric `protobuf:"bytes,1,rep,name=metrics,proto3" json:"metrics,omitempty"` -} - -func (x *MetricsPayload) Reset() { - *x = MetricsPayload{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload) ProtoMessage() {} - -func (x *MetricsPayload) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload.ProtoReflect.Descriptor instead. -func (*MetricsPayload) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0} -} - -func (x *MetricsPayload) GetMetrics() []*MetricsPayload_Metric { - if x != nil { - return x.Metrics - } - return nil -} - -type MetricsPayload_Label struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *MetricsPayload_Label) Reset() { - *x = MetricsPayload_Label{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload_Label) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload_Label) ProtoMessage() {} - -func (x *MetricsPayload_Label) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload_Label.ProtoReflect.Descriptor instead. -func (*MetricsPayload_Label) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 0} -} - -func (x *MetricsPayload_Label) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *MetricsPayload_Label) GetValue() string { - if x != nil { - return x.Value - } - return "" -} - -type MetricsPayload_Metric struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - Type MetricsPayload_MetricType `protobuf:"varint,2,opt,name=type,proto3,enum=spectate.agent.MetricsPayload_MetricType" json:"type,omitempty"` - Labels []*MetricsPayload_Label `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty"` - // Types that are assignable to Value: - // - // *MetricsPayload_Metric_UnknownValue - // *MetricsPayload_Metric_GaugeValue - // *MetricsPayload_Metric_InfoValue - Value isMetricsPayload_Metric_Value `protobuf_oneof:"value"` - Timestamp *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=timestamp,proto3" json:"timestamp,omitempty"` -} - -func (x *MetricsPayload_Metric) Reset() { - *x = MetricsPayload_Metric{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload_Metric) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload_Metric) ProtoMessage() {} - -func (x *MetricsPayload_Metric) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload_Metric.ProtoReflect.Descriptor instead. -func (*MetricsPayload_Metric) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 1} -} - -func (x *MetricsPayload_Metric) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *MetricsPayload_Metric) GetType() MetricsPayload_MetricType { - if x != nil { - return x.Type - } - return MetricsPayload_UNKNOWN -} - -func (x *MetricsPayload_Metric) GetLabels() []*MetricsPayload_Label { - if x != nil { - return x.Labels - } - return nil -} - -func (m *MetricsPayload_Metric) GetValue() isMetricsPayload_Metric_Value { - if m != nil { - return m.Value - } - return nil -} - -func (x *MetricsPayload_Metric) GetUnknownValue() *MetricsPayload_UnknownValue { - if x, ok := x.GetValue().(*MetricsPayload_Metric_UnknownValue); ok { - return x.UnknownValue - } - return nil -} - -func (x *MetricsPayload_Metric) GetGaugeValue() *MetricsPayload_GaugeValue { - if x, ok := x.GetValue().(*MetricsPayload_Metric_GaugeValue); ok { - return x.GaugeValue - } - return nil -} - -func (x *MetricsPayload_Metric) GetInfoValue() *MetricsPayload_InfoValue { - if x, ok := x.GetValue().(*MetricsPayload_Metric_InfoValue); ok { - return x.InfoValue - } - return nil -} - -func (x *MetricsPayload_Metric) GetTimestamp() *timestamppb.Timestamp { - if x != nil { - return x.Timestamp - } - return nil -} - -type isMetricsPayload_Metric_Value interface { - isMetricsPayload_Metric_Value() -} - -type MetricsPayload_Metric_UnknownValue struct { - UnknownValue *MetricsPayload_UnknownValue `protobuf:"bytes,4,opt,name=unknown_value,json=unknownValue,proto3,oneof"` -} - -type MetricsPayload_Metric_GaugeValue struct { - GaugeValue *MetricsPayload_GaugeValue `protobuf:"bytes,5,opt,name=gauge_value,json=gaugeValue,proto3,oneof"` -} - -type MetricsPayload_Metric_InfoValue struct { - InfoValue *MetricsPayload_InfoValue `protobuf:"bytes,9,opt,name=info_value,json=infoValue,proto3,oneof"` -} - -func (*MetricsPayload_Metric_UnknownValue) isMetricsPayload_Metric_Value() {} - -func (*MetricsPayload_Metric_GaugeValue) isMetricsPayload_Metric_Value() {} - -func (*MetricsPayload_Metric_InfoValue) isMetricsPayload_Metric_Value() {} - -// Value for UNKNOWN Metric. -type MetricsPayload_UnknownValue struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Required. - Value float64 `protobuf:"fixed64,1,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *MetricsPayload_UnknownValue) Reset() { - *x = MetricsPayload_UnknownValue{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload_UnknownValue) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload_UnknownValue) ProtoMessage() {} - -func (x *MetricsPayload_UnknownValue) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[3] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload_UnknownValue.ProtoReflect.Descriptor instead. -func (*MetricsPayload_UnknownValue) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 2} -} - -func (x *MetricsPayload_UnknownValue) GetValue() float64 { - if x != nil { - return x.Value - } - return 0 -} - -// Value for GAUGE Metric. -type MetricsPayload_GaugeValue struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Required. - Value float64 `protobuf:"fixed64,1,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *MetricsPayload_GaugeValue) Reset() { - *x = MetricsPayload_GaugeValue{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload_GaugeValue) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload_GaugeValue) ProtoMessage() {} - -func (x *MetricsPayload_GaugeValue) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload_GaugeValue.ProtoReflect.Descriptor instead. -func (*MetricsPayload_GaugeValue) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 3} -} - -func (x *MetricsPayload_GaugeValue) GetValue() float64 { - if x != nil { - return x.Value - } - return 0 -} - -// Value for INFO Metric. -type MetricsPayload_InfoValue struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Optional. - Info []*MetricsPayload_Label `protobuf:"bytes,1,rep,name=info,proto3" json:"info,omitempty"` -} - -func (x *MetricsPayload_InfoValue) Reset() { - *x = MetricsPayload_InfoValue{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *MetricsPayload_InfoValue) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MetricsPayload_InfoValue) ProtoMessage() {} - -func (x *MetricsPayload_InfoValue) ProtoReflect() protoreflect.Message { - mi := &file_pkg_proto_metrics_payload_proto_msgTypes[5] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MetricsPayload_InfoValue.ProtoReflect.Descriptor instead. -func (*MetricsPayload_InfoValue) Descriptor() ([]byte, []int) { - return file_pkg_proto_metrics_payload_proto_rawDescGZIP(), []int{0, 4} -} - -func (x *MetricsPayload_InfoValue) GetInfo() []*MetricsPayload_Label { - if x != nil { - return x.Info - } - return nil -} - -var File_pkg_proto_metrics_payload_proto protoreflect.FileDescriptor - -var file_pkg_proto_metrics_payload_proto_rawDesc = []byte{ - 0x0a, 0x1f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x73, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x12, 0x0e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x22, 0x8d, 0x06, 0x0a, 0x0e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x50, 0x61, - 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x3f, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, - 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x50, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x52, 0x07, 0x6d, - 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x1a, 0x2f, 0x0a, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0xc7, 0x03, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, - 0x61, 0x64, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x12, 0x3c, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x50, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, - 0x73, 0x12, 0x52, 0x0a, 0x0d, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, - 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, - 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x48, 0x00, 0x52, 0x0c, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x4c, 0x0a, 0x0b, 0x67, 0x61, 0x75, 0x67, 0x65, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x73, 0x70, 0x65, - 0x63, 0x74, 0x61, 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, - 0x69, 0x63, 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x47, 0x61, 0x75, 0x67, 0x65, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x67, 0x61, 0x75, 0x67, 0x65, 0x56, 0x61, - 0x6c, 0x75, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x69, 0x6e, 0x66, 0x6f, 0x5f, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, - 0x74, 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, - 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x49, 0x6e, 0x66, 0x6f, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x48, 0x00, 0x52, 0x09, 0x69, 0x6e, 0x66, 0x6f, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x38, - 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x07, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x1a, 0x24, 0x0a, 0x0c, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x22, 0x0a, 0x0a, 0x47, 0x61, 0x75, 0x67, 0x65, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x45, 0x0a, 0x09, 0x49, - 0x6e, 0x66, 0x6f, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x38, 0x0a, 0x04, 0x69, 0x6e, 0x66, 0x6f, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x73, 0x70, 0x65, 0x63, 0x74, 0x61, 0x74, - 0x65, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x50, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x52, 0x04, 0x69, 0x6e, - 0x66, 0x6f, 0x22, 0x2e, 0x0a, 0x0a, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, - 0x05, 0x47, 0x41, 0x55, 0x47, 0x45, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, - 0x10, 0x04, 0x42, 0x0e, 0x5a, 0x0c, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_pkg_proto_metrics_payload_proto_rawDescOnce sync.Once - file_pkg_proto_metrics_payload_proto_rawDescData = file_pkg_proto_metrics_payload_proto_rawDesc -) - -func file_pkg_proto_metrics_payload_proto_rawDescGZIP() []byte { - file_pkg_proto_metrics_payload_proto_rawDescOnce.Do(func() { - file_pkg_proto_metrics_payload_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_proto_metrics_payload_proto_rawDescData) - }) - return file_pkg_proto_metrics_payload_proto_rawDescData -} - -var file_pkg_proto_metrics_payload_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pkg_proto_metrics_payload_proto_msgTypes = make([]protoimpl.MessageInfo, 6) -var file_pkg_proto_metrics_payload_proto_goTypes = []interface{}{ - (MetricsPayload_MetricType)(0), // 0: spectate.agent.MetricsPayload.MetricType - (*MetricsPayload)(nil), // 1: spectate.agent.MetricsPayload - (*MetricsPayload_Label)(nil), // 2: spectate.agent.MetricsPayload.Label - (*MetricsPayload_Metric)(nil), // 3: spectate.agent.MetricsPayload.Metric - (*MetricsPayload_UnknownValue)(nil), // 4: spectate.agent.MetricsPayload.UnknownValue - (*MetricsPayload_GaugeValue)(nil), // 5: spectate.agent.MetricsPayload.GaugeValue - (*MetricsPayload_InfoValue)(nil), // 6: spectate.agent.MetricsPayload.InfoValue - (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp -} -var file_pkg_proto_metrics_payload_proto_depIdxs = []int32{ - 3, // 0: spectate.agent.MetricsPayload.metrics:type_name -> spectate.agent.MetricsPayload.Metric - 0, // 1: spectate.agent.MetricsPayload.Metric.type:type_name -> spectate.agent.MetricsPayload.MetricType - 2, // 2: spectate.agent.MetricsPayload.Metric.labels:type_name -> spectate.agent.MetricsPayload.Label - 4, // 3: spectate.agent.MetricsPayload.Metric.unknown_value:type_name -> spectate.agent.MetricsPayload.UnknownValue - 5, // 4: spectate.agent.MetricsPayload.Metric.gauge_value:type_name -> spectate.agent.MetricsPayload.GaugeValue - 6, // 5: spectate.agent.MetricsPayload.Metric.info_value:type_name -> spectate.agent.MetricsPayload.InfoValue - 7, // 6: spectate.agent.MetricsPayload.Metric.timestamp:type_name -> google.protobuf.Timestamp - 2, // 7: spectate.agent.MetricsPayload.InfoValue.info:type_name -> spectate.agent.MetricsPayload.Label - 8, // [8:8] is the sub-list for method output_type - 8, // [8:8] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name -} - -func init() { file_pkg_proto_metrics_payload_proto_init() } -func file_pkg_proto_metrics_payload_proto_init() { - if File_pkg_proto_metrics_payload_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_pkg_proto_metrics_payload_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload_Label); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload_Metric); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload_UnknownValue); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload_GaugeValue); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MetricsPayload_InfoValue); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - file_pkg_proto_metrics_payload_proto_msgTypes[2].OneofWrappers = []interface{}{ - (*MetricsPayload_Metric_UnknownValue)(nil), - (*MetricsPayload_Metric_GaugeValue)(nil), - (*MetricsPayload_Metric_InfoValue)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_pkg_proto_metrics_payload_proto_rawDesc, - NumEnums: 1, - NumMessages: 6, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_pkg_proto_metrics_payload_proto_goTypes, - DependencyIndexes: file_pkg_proto_metrics_payload_proto_depIdxs, - EnumInfos: file_pkg_proto_metrics_payload_proto_enumTypes, - MessageInfos: file_pkg_proto_metrics_payload_proto_msgTypes, - }.Build() - File_pkg_proto_metrics_payload_proto = out.File - file_pkg_proto_metrics_payload_proto_rawDesc = nil - file_pkg_proto_metrics_payload_proto_goTypes = nil - file_pkg_proto_metrics_payload_proto_depIdxs = nil -} diff --git a/pkg/service/service.go b/pkg/service/service.go deleted file mode 100644 index defc13c..0000000 --- a/pkg/service/service.go +++ /dev/null @@ -1,44 +0,0 @@ -package service - -import ( - "context" - "github.com/kardianos/service" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/pkg/app" -) - -type program struct { - exit chan struct{} -} - -func (p *program) Start(s service.Service) error { - go p.run() - return nil -} - -func (p *program) Stop(s service.Service) error { - close(p.exit) - return nil -} - -func (p *program) run() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - agent := app.NewApp() - - agent.Start(ctx) - - <-p.exit -} - -func NewService() (service.Service, error) { - prg := &program{exit: make(chan struct{})} - - s, err := service.New(prg, serviceConfig) - if err != nil { - logger.Log.Fatal().Err(err) - } - - return s, err -} diff --git a/pkg/service/service_darwin.go b/pkg/service/service_darwin.go deleted file mode 100644 index 3062a31..0000000 --- a/pkg/service/service_darwin.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build darwin - -package service - -import ( - "github.com/kardianos/service" -) - -var serviceConfig = &service.Config{ - Name: "spectated", - DisplayName: "Spectated", - Description: "Spectate host monitoring agent", - Arguments: []string{"start"}, - Option: service.KeyValue{ - "UserService": true, - }, -} - -func init() {} diff --git a/pkg/service/service_nix.go b/pkg/service/service_nix.go deleted file mode 100644 index 1912cf9..0000000 --- a/pkg/service/service_nix.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !darwin - -package service - -import ( - "github.com/kardianos/service" -) - -var serviceConfig = &service.Config{ - Name: "spectated", - DisplayName: "Spectated", - Description: "Spectate host monitoring agent", - Arguments: []string{"start"}, -} - -func init() {} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go deleted file mode 100644 index 0a59441..0000000 --- a/pkg/telemetry/telemetry.go +++ /dev/null @@ -1,36 +0,0 @@ -package telemetry - -import ( - "github.com/getsentry/sentry-go" - "github.com/spectate/agent/internal/logger" - "github.com/spectate/agent/internal/version" - "github.com/spf13/viper" -) - -var SentryDsn string - -func InitTelemetry() { - enabled := viper.GetBool("telemetry.error_reporting") - - if !enabled { - logger.Log.Info().Msg("Telemetry disabled, skipping Sentry initialization") - return - } - - if SentryDsn == "" { - logger.Log.Warn().Msg("SENTRY_DSN not set, skipping Sentry initialization") - return - } - - err := sentry.Init(sentry.ClientOptions{ - Dsn: SentryDsn, - TracesSampleRate: 1.0, - Release: version.Version, - }) - - if err != nil { - logger.Log.Fatal().Msgf("sentry.Init: %s", err) - } - - return -} diff --git a/proto/spectate/agent/v2/ingest.proto b/proto/spectate/agent/v2/ingest.proto new file mode 100644 index 0000000..5d48ce6 --- /dev/null +++ b/proto/spectate/agent/v2/ingest.proto @@ -0,0 +1,107 @@ +// Spectate host agent ingest protocol, version 2. +// +// One IngestRequest carries one shipping window (normally 60s) of samples +// collected at a higher resolution (normally every 15s). The body is sent +// gzip-compressed (`Content-Encoding: gzip`, `Content-Type: +// application/x-protobuf; proto=spectate.agent.v2.IngestRequest`) to +// `POST /agent-api/v2/ingest` with `Authorization: Bearer `. +// +// Layout is columnar: +// * all strings (metric names, label keys and values) live once in +// `strings`; series reference them by index, +// * every series (metric + label set) appears once and carries its samples +// as packed, delta-encoded timestamp and value columns. +// +// The canonical copy of this file lives in laravel/proto; the Go agent +// generates its bindings from it (see agent/Makefile). +syntax = "proto3"; + +package spectate.agent.v2; + +option go_package = "github.com/spectate/agent/v2/internal/proto/agentv2;agentv2"; +option php_namespace = "App\\Proto\\V2"; +option php_metadata_namespace = "App\\Proto\\V2\\Metadata"; + +message IngestRequest { + // Always 2 for this schema. + uint32 protocol_version = 1; + + // Agent build version, e.g. "2.0.0". + string agent_version = 2; + + // Random id generated when the agent's spool is created; together with + // `batch_seq` it identifies a batch so replays can be de-duplicated. + string agent_instance = 3; + + // Monotonic per agent_instance, starting at 1. Spooled batches are replayed + // in increasing order. + uint64 batch_seq = 4; + + // Unix time (ms) at which the batch was cut. + int64 created_at_ms = 5; + + // Hex sha256 of the agent's canonical HostInfo. Always present. + string host_info_hash = 6; + + // Only present when the host info changed since it was last acknowledged, + // on the first batch after start, or when the server asked for it + // (`resend_host_info` in the response). + HostInfo host_info = 7; + + // Base timestamp (unix ms). The first timestamp of each series is encoded + // relative to it. + int64 base_timestamp_ms = 8; + + // String table. Index 0 is always the empty string. + repeated string strings = 9; + + // Series dictionary + samples; the position in this list is the series id. + repeated Series series = 10; +} + +message Series { + // Index into IngestRequest.strings of the metric name (v1 agent keys, e.g. + // "system.cpu.idle", "system.disk_usage.used_percent"). + uint32 metric = 1; + + // Packed label pairs as string-table indexes: [key0, value0, key1, value1, ...]. + // Order is significant and preserved (it becomes label_names/label_values). + repeated uint32 labels = 2; + + // Sample timestamps in ms, delta-encoded: t[0] = base_timestamp_ms + d[0], + // t[i] = t[i-1] + d[i]. + repeated sint64 timestamp_deltas = 3; + + // Values are carried in exactly one of the two encodings below; both have + // one entry per timestamp. + // + // Decimal encoding: integers delta-encoded like timestamps; the value is + // the running sum divided by 10^scale. Used when every value in the series + // is exactly representable with `scale` decimals (counters, sizes, + // rounded percentages). + uint32 scale = 4; + repeated sint64 value_deltas = 5; + + // Raw IEEE-754 doubles, used when the decimal encoding would be lossy. + repeated double values = 6; +} + +message Label { + string key = 1; + string value = 2; +} + +message HostInfo { + // Same keys as the v1 `host.info` INFO metric: hostname, os, platform, + // platform_family, platform_version, kernel_version, kernel_arch, + // virtualization_system, virtualization_role, host_id. + repeated Label info = 1; + + // One entry per logical CPU package/core as reported by the OS, with the + // same keys as the v1 `host.cpus` INFO metric. + repeated Cpu cpus = 2; + + message Cpu { + repeated Label info = 1; + } +} From 44791c34ab2305478831d208ca82c06a71ebee3a Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 23 Oct 2026 17:16:14 +0200 Subject: [PATCH 2/3] feat: deleted hosts, clock offset, hung mounts, CA fallback, signed self-update - 410 HOST_DELETED is its own disposition: the agent clears the spool, logs one error, persists host_deleted in state.json and exits 78, which the unit does not restart on (RestartPreventExitStatus=78). Later starts exit the same way without calling the API; register --force clears it. - 401 keeps spooling but retries hourly (honouring a longer Retry-After) and logs at ERROR at most once an hour. - Every request carries X-Agent-Time (unix ms at send time). A clock_offset_ms beyond 30s is warned about hourly; timestamps are not shifted (the server corrects them). - Collectors run concurrently under the round deadline; a stuck collector is reported once and not restarted until it returns. Each mountpoint's statfs has its own deadline and a hanging mountpoint is skipped until its call returns. Network/automount filesystems are excluded by default (needed on macOS; gopsutil already drops them on Linux). - Bundled CA roots (x509roots/fallback) for hosts without a CA bundle. - spectate-agent update [--version] [--check] [--if-newer]: newest stable v2 release via the GitHub API, ed25519-signed checksums.txt verified with the key embedded at build time, sha256 check, atomic replace with a self-test, systemctl restart. Downloads only from GitHub hosts. - agent_update from ingest is logged daily and shown in status; optional daily auto-update timer (install --auto-update, install.sh --auto-update). - cmd/release-sign (keygen/sign/verify); goreleaser signs checksums.txt and uploads checksums.txt.sig; the release fails without the key. install.sh verifies the signature with OpenSSL 3 when available. --- .github/workflows/release.yml | 14 + .goreleaser.yaml | 29 +- Makefile | 9 +- README.md | 109 ++++++- cmd/release-sign/main.go | 145 ++++++++++ cmd/spectate-agent/main.go | 145 +++++++++- cmd/spectate-agent/update_e2e_test.go | 204 ++++++++++++++ go.mod | 1 + go.sum | 2 + install.sh | 55 +++- internal/agent/agent.go | 333 ++++++++++++++++++++-- internal/agent/agent_test.go | 24 +- internal/agent/lifecycle_test.go | 392 ++++++++++++++++++++++++++ internal/collector/collector_test.go | 91 ++++++ internal/collector/system.go | 108 ++++++- internal/config/config.go | 31 +- internal/service/service.go | 66 ++++- internal/service/service_darwin.go | 20 ++ internal/service/service_linux.go | 50 +++- internal/service/service_other.go | 9 + internal/service/service_test.go | 21 ++ internal/spool/spool.go | 17 ++ internal/spool/spool_test.go | 17 ++ internal/transport/transport.go | 36 ++- internal/transport/transport_test.go | 78 ++++- internal/update/update.go | 378 +++++++++++++++++++++++++ internal/update/update_test.go | 265 +++++++++++++++++ internal/version/version.go | 113 +++++++- internal/version/version_test.go | 30 ++ 29 files changed, 2727 insertions(+), 65 deletions(-) create mode 100644 cmd/release-sign/main.go create mode 100644 cmd/spectate-agent/update_e2e_test.go create mode 100644 internal/agent/lifecycle_test.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go create mode 100644 internal/version/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 369c8f5..cb1854f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,18 @@ jobs: go-version-file: go.mod cache: false + # Never publish an unsigned release: agents verify checksums.txt.sig + # with the public key embedded at build time (README "Release signing"). + - name: Require the release signing key + env: + SPECTATE_RELEASE_SIGNING_KEY: ${{ secrets.SPECTATE_RELEASE_SIGNING_KEY }} + SPECTATE_RELEASE_PUBLIC_KEY: ${{ vars.SPECTATE_RELEASE_PUBLIC_KEY }} + run: | + if [ -z "$SPECTATE_RELEASE_SIGNING_KEY" ] || [ -z "$SPECTATE_RELEASE_PUBLIC_KEY" ]; then + echo "::error title=Release signing::Set the SPECTATE_RELEASE_SIGNING_KEY secret and the SPECTATE_RELEASE_PUBLIC_KEY variable (README, Release signing)" + exit 1 + fi + - name: Release uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: @@ -65,3 +77,5 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SPECTATE_RELEASE_SIGNING_KEY: ${{ secrets.SPECTATE_RELEASE_SIGNING_KEY }} + SPECTATE_RELEASE_PUBLIC_KEY: ${{ vars.SPECTATE_RELEASE_PUBLIC_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7cce922..8069938 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,17 +3,26 @@ version: 2 project_name: spectate-agent # Released by .github/workflows/release.yml when a v2.x.y tag is pushed -# (`make snapshot` builds locally without publishing). +# (`make snapshot` builds locally without publishing or signing). +# +# Release signing: checksums.txt is signed with the ed25519 key in +# $SPECTATE_RELEASE_SIGNING_KEY (checksums.txt.sig), and the public key in +# $SPECTATE_RELEASE_PUBLIC_KEY is embedded in the binaries, which verify the +# signature in `spectate-agent update`. Without the private key the sign +# step fails and nothing is published. before: hooks: - go mod tidy -diff - go test ./... - # install.sh with this release as its default version, published as a - # release asset (see README "Installer"). + # install.sh with this release as its default version and the release + # public key, published as a release asset (see README "Install"). - mkdir -p build - - sh -c 'sed "s/^DEFAULT_VERSION=\"\"$/DEFAULT_VERSION=\"{{ .Version }}\"/" install.sh > build/install.sh' + - sh -c 'sed -e "s/^DEFAULT_VERSION=\"\"$/DEFAULT_VERSION=\"{{ .Version }}\"/" -e "s|^UPDATE_PUBLIC_KEY=.*$|UPDATE_PUBLIC_KEY=\"{{ envOrDefault "SPECTATE_RELEASE_PUBLIC_KEY" "" }}\"|" install.sh > build/install.sh' - sh -c 'grep -q "^DEFAULT_VERSION=\"{{ .Version }}\"$" build/install.sh' + - sh -c 'grep -qx "UPDATE_PUBLIC_KEY=\"{{ envOrDefault "SPECTATE_RELEASE_PUBLIC_KEY" "" }}\"" build/install.sh' + # Signs checksums.txt (see signs below). + - go build -trimpath -o build/release-sign ./cmd/release-sign builds: - id: spectate-agent @@ -30,6 +39,7 @@ builds: - -X github.com/spectate/agent/v2/internal/version.Commit={{ .ShortCommit }} - -X github.com/spectate/agent/v2/internal/version.BuildDate={{ .Date }} - -X github.com/spectate/agent/v2/internal/version.DefaultAPIURL={{ envOrDefault "API_URL" "https://api.spectate.net" }} + - -X github.com/spectate/agent/v2/internal/version.UpdatePublicKey={{ envOrDefault "SPECTATE_RELEASE_PUBLIC_KEY" "" }} goos: [linux, darwin] goarch: [amd64, arm64] @@ -49,6 +59,17 @@ checksum: extra_files: - glob: build/install.sh +# checksums.txt.sig: base64 ed25519 signature of checksums.txt, uploaded as a +# release asset. release-sign fails without $SPECTATE_RELEASE_SIGNING_KEY, or +# when $SPECTATE_RELEASE_PUBLIC_KEY is set and is not its public half. +signs: + - id: checksums + artifacts: checksum + cmd: ./build/release-sign + args: ["sign", "${artifact}"] + signature: "${artifact}.sig" + output: true + release: github: owner: spectate diff --git a/Makefile b/Makefile index 1a6fce1..a2f9a56 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,15 @@ VERSION ?= $(shell git describe --tags --match 'v2.*' --always --dirty 2>/dev/nu COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) API_URL ?= https://api.spectate.net +# Release signing public key (base64 ed25519) that `spectate-agent update` +# verifies checksums.txt.sig with; empty for development builds. +UPDATE_PUBLIC_KEY ?= $(SPECTATE_RELEASE_PUBLIC_KEY) LDFLAGS := -s -w \ -X '$(PKG)/internal/version.Version=$(VERSION)' \ -X '$(PKG)/internal/version.Commit=$(COMMIT)' \ -X '$(PKG)/internal/version.BuildDate=$(DATE)' \ - -X '$(PKG)/internal/version.DefaultAPIURL=$(API_URL)' + -X '$(PKG)/internal/version.DefaultAPIURL=$(API_URL)' \ + -X '$(PKG)/internal/version.UpdatePublicKey=$(UPDATE_PUBLIC_KEY)' GOFLAGS := -trimpath # Copy of the canonical schema in spectate/platform (laravel/proto). The M # option maps it to this module whatever its go_package says. @@ -63,8 +67,9 @@ bench: release: goreleaser release --clean +# Unsigned: release signing needs $SPECTATE_RELEASE_SIGNING_KEY (CI only). snapshot: - goreleaser release --snapshot --clean + goreleaser release --snapshot --clean --skip=sign clean: rm -rf bin dist diff --git a/README.md b/README.md index 48d0856..d65aee5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,22 @@ endpoints; see [v1 (spectated)](#v1-spectated). The agent registers with them without a restart. - Shuts down cleanly. On `SIGTERM` it flushes the current window, or spools it when offline. +- Survives hung filesystems. Collectors run concurrently with a deadline per + round, and each mountpoint's `statfs` has its own deadline: a dead NFS mount + delays nothing, and is skipped (not queried again) until its call returns. + Network filesystems are excluded by default. +- Stops when its host is deleted. When the API answers `410 HOST_DELETED` the + agent clears its spool, logs `This host was removed from Spectate. Remove the + agent: sudo spectate-agent uninstall`, records `host_deleted` in + `state.json` and exits with status 78, which the unit does not restart on + (`RestartPreventExitStatus=78`); later starts exit the same way without + contacting the API, until `register --force`. A rejected host token (`401`) + keeps spooling but retries hourly instead of every few minutes. +- Reports its clock. Every request carries `X-Agent-Time` (unix ms at send + time); the server corrects sample timestamps by the offset and the agent + warns hourly when it exceeds 30 s. +- Updates itself on request: `sudo spectate-agent update` installs the newest + release after verifying its signature (see [Updating](#updating)). - Small: a static binary (about 10 MB) with about 13 MB RSS, running as an unprivileged user under a hardened systemd unit. - Reports on itself: `agent.spool.bytes`, `agent.spool.batches`, @@ -52,6 +68,7 @@ Other modes: |------|---------| | `--import-legacy` | Moves from the v1 agent: reuses the host token from `/etc/spectated/config.yaml` (same host, same history) and uninstalls `spectated` | | `--upgrade` | Replaces the binary and restarts the service | +| `--auto-update` | Also installs a daily timer that runs `spectate-agent update --if-newer` (see [Updating](#updating)) | | `--binary ./spectate-agent` | Installs a locally built binary | | `--api-url URL` | Uses a non-production API | | `--version 2.0.1` | Installs a specific release | @@ -60,7 +77,15 @@ Every release publishes `install.sh` as an asset with that release as its default version; the copy in this repository installs the newest `v2.x.y` release (found through the GitHub API, because `releases/latest` stays on v1). -To remove the agent: `sudo spectate-agent uninstall`. State in `/var/lib/spectate-agent` is kept. +To remove the agent: `sudo spectate-agent uninstall` (also removes the update +timer). State in `/var/lib/spectate-agent` is kept. + +The installer verifies the binary against the release's `checksums.txt`. The +copy of `install.sh` published with each release also carries the release +public key and verifies the signature of `checksums.txt` when OpenSSL 3 is +installed (`openssl pkeyutl -rawin`); without it, it warns and relies on the +checksum. The copy in this repository has no key and checks checksums only. +`spectate-agent update` always verifies the signature. ## Commands @@ -68,12 +93,15 @@ To remove the agent: `sudo spectate-agent uninstall`. State in `/var/lib/spectat spectate-agent register --token [--api-url URL] [--force] spectate-agent import-legacy [--config /etc/spectated/config.yaml] spectate-agent run # foreground (what the service runs) -spectate-agent install [--user U] # root: systemd unit (Linux) / launchd (macOS) +spectate-agent install [--user U] [--auto-update] # root: systemd unit (Linux) / launchd (macOS) spectate-agent uninstall -spectate-agent status +spectate-agent update [--version X] [--check] [--if-newer] [--no-restart] # root +spectate-agent status # includes an available update, a deleted host spectate-agent version ``` +`run` exits with status 78 when the host was deleted in Spectate (see above). + Local settings come from the environment (`/etc/spectate-agent/agent.env` under systemd): | Variable | Default | @@ -87,10 +115,43 @@ Local settings come from the environment (`/etc/spectate-agent/agent.env` under Everything else (intervals, collectors, filters) is set server-side and returned on every ingest response. +## Updating + +```sh +sudo spectate-agent update --check # current and newest version +sudo spectate-agent update # install the newest v2 release and restart the service +sudo spectate-agent update --version 2.1.0 # a specific release (also older or pre-release) +``` + +`update` lists the repository's releases through the GitHub API (not +`releases/latest`, which stays on v1) and picks the highest stable `v2.x.y`. It +downloads `spectate-agent_linux_`, `checksums.txt` and +`checksums.txt.sig`; it verifies the ed25519 signature of `checksums.txt` with +the public key compiled into the binary, then the binary's sha256. It only +talks to `api.github.com`, `github.com` and GitHub's asset hosts +(`objects.githubusercontent.com`, `release-assets.githubusercontent.com`). The +new binary is written next to the old one, fsynced, test-run (`version` must +report the release) and renamed over it, then `systemctl restart +spectate-agent` runs. On macOS it prints the `launchctl` commands instead. +Development builds have no key and refuse to update unless +`--insecure-skip-signature` is passed. + +The agent itself runs unprivileged on a read-only filesystem and cannot replace +its binary. When the API announces a newer release (`agent_update` in the +ingest response), it logs `spectate-agent X is available: sudo spectate-agent +update` once a day and `spectate-agent status` shows it. + +Automatic updates are opt-in: `install.sh --auto-update` (or `spectate-agent +install --auto-update`) installs `spectate-agent-update.service` (oneshot, root, +`spectate-agent update --if-newer`) and `spectate-agent-update.timer` (daily, +`RandomizedDelaySec=6h`, `Persistent=true`). `--if-newer` exits 0 without +output when the agent is current, and does not start an agent that is stopped. + ## Layout ``` cmd/spectate-agent CLI +cmd/release-sign release signing keys and checksums.txt.sig (CI, owner) internal/agent runner: collect loop, ship loop, spool replay, backoff, shutdown flush internal/collector gopsutil collectors (v1-compatible keys/units) + host info internal/metrics per-window series accumulator (bounded) @@ -98,7 +159,8 @@ internal/encode v2 encoder (string table, delta timestamps, decimal/doubl internal/spool bounded on-disk FIFO internal/transport HTTP client (register/ingest, error classification) internal/backoff capped exponential backoff with equal jitter -internal/service systemd unit / launchd plist +internal/service systemd units (agent, update timer) / launchd plist +internal/update self-update: GitHub releases, signature + checksum verification, atomic replace internal/proto/agentv2 generated from proto/spectate/agent/v2/ingest.proto proto/ copy of the schema; the canonical one lives with the API ``` @@ -112,7 +174,7 @@ make dist # static linux/darwin amd64/arm64 binaries + checksums make proto # regenerate the Go bindings after updating proto/ make golden # refresh the fixture the API test suite decodes make bench # encoder benchmark (reports bytes/batch) -make snapshot # goreleaser build without publishing +make snapshot # goreleaser build without publishing (unsigned) ``` The module path is `github.com/spectate/agent/v2`: the v1 agent was released @@ -128,9 +190,40 @@ git tag -s v2.0.0 -m v2.0.0 && git push origin v2.0.0 ``` `.github/workflows/release.yml` runs the tests and goreleaser, which publishes -`spectate-agent__` binaries, tarballs, `install.sh` and -`checksums.txt`. Tags with a suffix (`v2.1.0-rc.1`) become pre-releases and are -skipped by the installer. v2 releases are never marked "latest" (see below). +`spectate-agent__` binaries, tarballs, `install.sh`, `checksums.txt` +and `checksums.txt.sig`. Tags with a suffix (`v2.1.0-rc.1`) become pre-releases +and are skipped by the installer and `update`. v2 releases are never marked +"latest" (see below). + +### Release signing + +Updates are trusted through an ed25519 key pair. The private key signs +`checksums.txt` in the release workflow; the public key is compiled into every +binary (`-X .../internal/version.UpdatePublicKey`) and into the release's +`install.sh`. Set it up once: + +1. Generate the pair on a trusted machine: + + ```sh + go run ./cmd/release-sign keygen + ``` + +2. Store `SPECTATE_RELEASE_SIGNING_KEY` as a repository **secret** and + `SPECTATE_RELEASE_PUBLIC_KEY` as a repository **variable** (Settings > + Secrets and variables > Actions), e.g. + `gh secret set SPECTATE_RELEASE_SIGNING_KEY -R spectate/agent` and + `gh variable set SPECTATE_RELEASE_PUBLIC_KEY -R spectate/agent --body `. + Keep an offline copy of the private key. + +The release job fails, and publishes nothing, when either is missing or when +they do not belong together (`release-sign sign` checks). Check a published +release with `SPECTATE_RELEASE_PUBLIC_KEY= go run ./cmd/release-sign +verify checksums.txt` (next to the downloaded `checksums.txt.sig`). + +Rotating the key: agents trust only the key they were built with, so after +switching the secret and variable, `spectate-agent update` on hosts with older +binaries rejects new releases; move those hosts with `install.sh --upgrade` +(checksum-verified) once. The tests include unit tests for encoding, the spool, backoff, collectors and transport. They also include end-to-end runs of the full agent against an diff --git a/cmd/release-sign/main.go b/cmd/release-sign/main.go new file mode 100644 index 0000000..564935c --- /dev/null +++ b/cmd/release-sign/main.go @@ -0,0 +1,145 @@ +// Command release-sign makes and checks the ed25519 signatures that protect +// agent updates. The release workflow signs checksums.txt with it, and +// `spectate-agent update` verifies checksums.txt.sig with the public key +// embedded at build time. +// +// go run ./cmd/release-sign keygen # prints a new key pair (once, by the owner) +// go run ./cmd/release-sign sign # writes .sig; key from $SPECTATE_RELEASE_SIGNING_KEY +// go run ./cmd/release-sign verify # checks .sig against $SPECTATE_RELEASE_PUBLIC_KEY +// +// Keys and signatures are standard base64: the private key is the 32-byte +// ed25519 seed, the public key the 32-byte public key. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/spectate/agent/v2/internal/update" +) + +const ( + privateEnv = "SPECTATE_RELEASE_SIGNING_KEY" + publicEnv = "SPECTATE_RELEASE_PUBLIC_KEY" +) + +const usage = `usage: + release-sign keygen print a new key pair + release-sign sign write .sig (private key from $` + privateEnv + `) + release-sign verify verify .sig (public key from $` + publicEnv + `) +` + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "release-sign:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + switch args[0] { + case "keygen": + return keygen() + case "sign": + if len(args) != 2 { + return errors.New("sign takes one file") + } + return sign(args[1]) + case "verify": + if len(args) != 2 { + return errors.New("verify takes one file") + } + return verify(args[1]) + default: + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + return nil +} + +func keygen() error { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + fmt.Println("# Repository secret (Settings > Secrets and variables > Actions > Secrets). Keep it private.") + fmt.Printf("%s=%s\n", privateEnv, base64.StdEncoding.EncodeToString(priv.Seed())) + fmt.Println("# Repository variable (Settings > Secrets and variables > Actions > Variables). Embedded in every build.") + fmt.Printf("%s=%s\n", publicEnv, base64.StdEncoding.EncodeToString(pub)) + return nil +} + +func privateKey() (ed25519.PrivateKey, error) { + s := strings.TrimSpace(os.Getenv(privateEnv)) + if s == "" { + return nil, fmt.Errorf("$%s is not set: refusing to publish an unsigned release", privateEnv) + } + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("$%s is not valid base64", privateEnv) + } + switch len(b) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(b), nil + case ed25519.PrivateKeySize: + return ed25519.PrivateKey(b), nil + } + return nil, fmt.Errorf("$%s must be a base64 ed25519 seed (32 bytes) or private key (64 bytes)", privateEnv) +} + +func sign(path string) error { + priv, err := privateKey() + if err != nil { + return err + } + // Catch a secret/variable mismatch before publishing: agents built with + // the variable's key would reject every update signed by this key. + if s := os.Getenv(publicEnv); s != "" { + pub, err := update.ParsePublicKey(s) + if err != nil { + return fmt.Errorf("$%s: %w", publicEnv, err) + } + if !pub.Equal(priv.Public()) { + return fmt.Errorf("$%s does not belong to $%s", publicEnv, privateEnv) + } + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, data)) + if err := os.WriteFile(path+".sig", []byte(sig+"\n"), 0o644); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "signed %s -> %s.sig\n", path, path) + return nil +} + +func verify(path string) error { + pub, err := update.ParsePublicKey(os.Getenv(publicEnv)) + if err != nil { + return fmt.Errorf("$%s: %w", publicEnv, err) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + sig, err := os.ReadFile(path + ".sig") + if err != nil { + return err + } + if err := update.VerifySignature(pub, data, sig); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "%s.sig: good signature\n", path) + return nil +} diff --git a/cmd/spectate-agent/main.go b/cmd/spectate-agent/main.go index 3b79c99..2b3ff5b 100644 --- a/cmd/spectate-agent/main.go +++ b/cmd/spectate-agent/main.go @@ -3,6 +3,7 @@ package main import ( "context" + "crypto/ed25519" "errors" "flag" "fmt" @@ -10,11 +11,16 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "runtime/debug" "strings" "syscall" "time" + // Bundled Mozilla CA roots, used only when the host has no CA bundle + // (minimal images, appliances): otherwise TLS to the API would fail. + _ "golang.org/x/crypto/x509roots/fallback" + "github.com/spectate/agent/v2/internal/agent" "github.com/spectate/agent/v2/internal/collector" "github.com/spectate/agent/v2/internal/config" @@ -22,6 +28,7 @@ import ( "github.com/spectate/agent/v2/internal/service" "github.com/spectate/agent/v2/internal/spool" "github.com/spectate/agent/v2/internal/transport" + "github.com/spectate/agent/v2/internal/update" "github.com/spectate/agent/v2/internal/version" ) @@ -31,8 +38,9 @@ Usage: spectate-agent register --token [--api-url URL] [--force] spectate-agent import-legacy [--config /etc/spectated/config.yaml] [--api-url URL] spectate-agent run - spectate-agent install [--user spectate-agent] (root; systemd / launchd) + spectate-agent install [--user spectate-agent] [--auto-update] (root; systemd / launchd) spectate-agent uninstall + spectate-agent update [--version X] [--check] [--if-newer] (root) spectate-agent status spectate-agent version @@ -59,6 +67,9 @@ func main() { switch cmd { case "run": err = runAgent(settings, logger) + if errors.Is(err, agent.ErrHostDeleted) { + os.Exit(service.ExitHostDeleted) // already logged; the unit does not restart on it + } case "register": err = register(settings, args) case "import-legacy": @@ -67,6 +78,8 @@ func main() { err = install(args) case "uninstall": err = service.Uninstall() + case "update": + err = selfUpdate(args) case "status": err = status(settings) case "version", "--version", "-v": @@ -137,6 +150,9 @@ func register(settings config.Settings, args []string) error { return errors.New("--token is required") } if existing, err := config.LoadState(settings.StateDir); err == nil && existing.HostToken != "" && !*force { + if existing.HostDeleted { + return fmt.Errorf("host %s was removed from Spectate; use --force to register this machine again", existing.HostUUID) + } return fmt.Errorf("already registered as host %s (use --force to register again)", existing.HostUUID) } @@ -161,7 +177,11 @@ func register(settings config.Settings, args []string) error { if err != nil { return fmt.Errorf("register: %w", err) } + if off := time.Duration(resp.ClockOffsetMs) * time.Millisecond; off.Abs() > 30*time.Second { + fmt.Fprintf(os.Stderr, "warning: host clock is off by %ds; samples are corrected by the server, but fix NTP\n", int64(off.Abs().Seconds())) + } + // A fresh state: also clears the host_deleted marker of a previous registration. if err := config.SaveState(settings.StateDir, config.State{ APIURL: base, HostToken: resp.HostToken, @@ -201,6 +221,7 @@ func install(args []string) error { fs := flag.NewFlagSet("install", flag.ExitOnError) user := fs.String("user", service.Name, "service user (Linux)") envFile := fs.String("env-file", "/etc/spectate-agent/agent.env", "EnvironmentFile for the unit (Linux)") + autoUpdate := fs.Bool("auto-update", false, "also install a daily timer that runs `update --if-newer` (Linux)") _ = fs.Parse(args) bin, err := os.Executable() @@ -210,10 +231,16 @@ func install(args []string) error { if bin, err = filepath.EvalSymlinks(bin); err != nil { return err } - if err := service.Install(service.Options{Binary: bin, User: *user, EnvFile: *envFile}); err != nil { + if *autoUpdate && runtime.GOOS != "linux" { + return errors.New("--auto-update is only supported on Linux (systemd)") + } + if err := service.Install(service.Options{Binary: bin, User: *user, EnvFile: *envFile, AutoUpdate: *autoUpdate}); err != nil { return err } fmt.Println("spectate-agent service installed and started.") + if *autoUpdate { + fmt.Printf("Automatic updates enabled (%s.timer, daily).\n", service.UpdateName) + } return nil } @@ -233,5 +260,119 @@ func status(settings config.Settings) error { fmt.Printf("collection: every %ds, shipped every %ds\n", st.Remote.CollectionInterval, st.Remote.IngestInterval) fmt.Printf("collectors: %s\n", strings.Join(st.Remote.Collectors, ", ")) fmt.Printf("spool: %d batches, %d bytes\n", sp.Len(), sp.Bytes()) + if st.AvailableUpdate != nil && version.Compare(st.AvailableUpdate.Version, version.Version) > 0 { + fmt.Printf("update: %s available: sudo spectate-agent update\n", st.AvailableUpdate.Version) + } + if st.HostDeleted { + fmt.Println("host: removed from Spectate; the agent no longer runs. Remove it: sudo spectate-agent uninstall") + } + return nil +} + +// selfUpdate replaces this binary with a verified release and restarts the +// service. The running agent cannot do this itself (it is unprivileged), so +// this runs as root, by hand or from the auto-update timer. +func selfUpdate(args []string) error { + fs := flag.NewFlagSet("update", flag.ExitOnError) + want := fs.String("version", "", "install this release (e.g. 2.1.0; may be older or a pre-release)") + check := fs.Bool("check", false, "only print the current and latest version") + ifNewer := fs.Bool("if-newer", false, "do nothing, quietly, unless a newer release exists (for the timer)") + insecure := fs.Bool("insecure-skip-signature", false, "skip the release signature check (sha256 is still verified)") + noRestart := fs.Bool("no-restart", false, "do not restart the service") + _ = fs.Parse(args) + + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + return fmt.Errorf("update is not supported on %s", runtime.GOOS) + } + apiURL := update.DefaultAPIURL + if v := os.Getenv("SPECTATE_UPDATE_API_URL"); v != "" { + if !version.IsDev() { + return errors.New("SPECTATE_UPDATE_API_URL is only honoured by development builds") + } + apiURL = v + } + + var pub ed25519.PublicKey + switch { + case *insecure: + if !*check { + fmt.Fprintln(os.Stderr, "warning: --insecure-skip-signature: the release signature is not verified") + } + case version.UpdatePublicKey != "": + var err error + if pub, err = update.ParsePublicKey(version.UpdatePublicKey); err != nil { + return err + } + case !*check: + return errors.New("this build has no release signing key (development build): refusing to update " + + "without signature verification; pass --insecure-skip-signature to update anyway") + } + + client, err := update.New(apiURL, pub) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + var rel *update.Release + if *want != "" { + rel, err = client.ByVersion(ctx, *want) + } else { + rel, err = client.Latest(ctx) + } + if err != nil { + return fmt.Errorf("find release: %w", err) + } + target := rel.Version() + cmp := version.Compare(target, version.Version) + + if *check { + fmt.Printf("current: %s\nlatest: %s\n", version.Version, target) + if cmp > 0 { + fmt.Println("An update is available: sudo spectate-agent update") + } + return nil + } + if cmp <= 0 && (*ifNewer || *want == "") { + if !*ifNewer { + fmt.Printf("spectate-agent %s is up to date (newest release: %s).\n", version.Version, target) + } + return nil + } + + bin, err := os.Executable() + if err != nil { + return err + } + if bin, err = filepath.EvalSymlinks(bin); err != nil { + return err + } + fmt.Printf("Updating spectate-agent %s -> %s (%s)\n", version.Version, target, update.BinaryName(runtime.GOOS, runtime.GOARCH)) + data, err := client.Download(ctx, rel, runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } + if err := update.Install(data, bin, target); err != nil { + return fmt.Errorf("install %s: %w", bin, err) + } + if pub != nil { + fmt.Printf("Installed spectate-agent %s at %s (signature and checksum verified).\n", target, bin) + } else { + fmt.Printf("Installed spectate-agent %s at %s (checksum verified, signature NOT verified).\n", target, bin) + } + + switch { + case *noRestart || !service.Installed(): + fmt.Printf("Restart the agent to run the new version: %s\n", service.RestartHint()) + case runtime.GOOS == "linux": + // From the timer, leave a stopped (or failed) agent stopped. + if err := service.Restart(*ifNewer); err != nil { + return fmt.Errorf("installed, but restarting failed: %w", err) + } + fmt.Println("Restarted spectate-agent.") + default: + fmt.Printf("Restart the agent to run the new version: %s\n", service.RestartHint()) + } return nil } diff --git a/cmd/spectate-agent/update_e2e_test.go b/cmd/spectate-agent/update_e2e_test.go new file mode 100644 index 0000000..a6b988e --- /dev/null +++ b/cmd/spectate-agent/update_e2e_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/spectate/agent/v2/internal/update" +) + +// buildAgent builds this command with the given version and update key. +func buildAgent(t *testing.T, dir, ver, pubKey string) string { + t.Helper() + out := filepath.Join(dir, "spectate-agent-"+ver) + ldflags := "-X github.com/spectate/agent/v2/internal/version.Version=" + ver + if pubKey != "" { + ldflags += " -X github.com/spectate/agent/v2/internal/version.UpdatePublicKey=" + pubKey + } + cmd := exec.Command("go", "build", "-trimpath", "-ldflags", ldflags, "-o", out, ".") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", ver, err, b) + } + return out +} + +// fakeReleases serves a GitHub-like releases API and asset downloads. +type fakeReleases struct { + mu sync.Mutex + srv *httptest.Server + rels []update.Release + files map[string][]byte +} + +func (f *fakeReleases) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.URL.Path == "/repos/spectate/agent/releases": + _ = json.NewEncoder(w).Encode(f.rels) + case strings.HasPrefix(r.URL.Path, "/download/"): + http.Redirect(w, r, "/cdn/"+strings.TrimPrefix(r.URL.Path, "/download/"), http.StatusFound) + case strings.HasPrefix(r.URL.Path, "/cdn/"): + if data, ok := f.files[strings.TrimPrefix(r.URL.Path, "/cdn/")]; ok { + _, _ = w.Write(data) + return + } + http.NotFound(w, r) + default: + http.NotFound(w, r) + } +} + +func (f *fakeReleases) publish(tag string, bin []byte, signer ed25519.PrivateKey) { + f.mu.Lock() + defer f.mu.Unlock() + name := update.BinaryName(runtime.GOOS, runtime.GOARCH) + sums := []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(bin), name)) + sig := []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(signer, sums)) + "\n") + rel := update.Release{TagName: tag} + for file, data := range map[string][]byte{name: bin, update.ChecksumsName: sums, update.SignatureName: sig} { + f.files[tag+"/"+file] = data + rel.Assets = append(rel.Assets, update.Asset{Name: file, URL: f.srv.URL + "/download/" + tag + "/" + file}) + } + f.rels = []update.Release{rel} +} + +func (f *fakeReleases) tamper(path string, data []byte) { + f.mu.Lock() + defer f.mu.Unlock() + f.files[path] = data +} + +// TestSelfUpdateEndToEnd builds an "installed" 2.0.0-dev binary and a 2.1.0 +// release with a throwaway signing key, then runs `update` against a fake +// GitHub: it must reject tampered releases and atomically install a good one. +func TestSelfUpdateEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("builds binaries") + } + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("update supports linux and darwin") + } + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + _, otherPriv, _ := ed25519.GenerateKey(rand.Reader) + key := base64.StdEncoding.EncodeToString(pub) + + build := t.TempDir() + oldBin := buildAgent(t, build, "2.0.0-dev", key) + newBin := buildAgent(t, build, "2.1.0", key) + newData, err := os.ReadFile(newBin) + if err != nil { + t.Fatal(err) + } + + gh := &fakeReleases{files: map[string][]byte{}} + gh.srv = httptest.NewServer(gh) + defer gh.srv.Close() + + installDir := t.TempDir() + installed := filepath.Join(installDir, "spectate-agent") + reset := func() { + data, _ := os.ReadFile(oldBin) + if err := os.WriteFile(installed, data, 0o755); err != nil { + t.Fatal(err) + } + } + run := func(args ...string) (string, error) { + cmd := exec.Command(installed, append([]string{"update", "--no-restart"}, args...)...) + cmd.Env = append(os.Environ(), "SPECTATE_UPDATE_API_URL="+gh.srv.URL) + out, err := cmd.CombinedOutput() + return string(out), err + } + installedVersion := func() string { + out, _ := exec.Command(installed, "version").Output() + return string(out) + } + reset() + + // --check only reports. + gh.publish("v2.1.0", newData, priv) + out, err := run("--check") + if err != nil || !strings.Contains(out, "current: 2.0.0-dev") || !strings.Contains(out, "latest: 2.1.0") { + t.Fatalf("--check: %v\n%s", err, out) + } + if !strings.HasPrefix(installedVersion(), "spectate-agent 2.0.0-dev ") { + t.Fatal("--check must not install") + } + + // Signed by another key. + gh.publish("v2.1.0", newData, otherPriv) + if out, err := run(); err == nil || !strings.Contains(out, "signature verification failed") { + t.Fatalf("foreign signature accepted: %v\n%s", err, out) + } + // Tampered checksum: the binary no longer matches the signed checksums.txt. + gh.publish("v2.1.0", newData, priv) + gh.tamper("v2.1.0/"+update.BinaryName(runtime.GOOS, runtime.GOARCH), append([]byte(nil), oldBinData(t, oldBin)...)) + if out, err := run(); err == nil || !strings.Contains(out, "checksum mismatch") { + t.Fatalf("tampered binary accepted: %v\n%s", err, out) + } + // Tampered checksums.txt (consistent with the swapped binary, but unsigned by the key). + gh.tamper("v2.1.0/"+update.ChecksumsName, []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(oldBinData(t, oldBin)), update.BinaryName(runtime.GOOS, runtime.GOARCH)))) + if out, err := run(); err == nil || !strings.Contains(out, "signature verification failed") { + t.Fatalf("tampered checksums accepted: %v\n%s", err, out) + } + if !strings.HasPrefix(installedVersion(), "spectate-agent 2.0.0-dev ") { + t.Fatal("a rejected update modified the installed binary") + } + + // A good release is installed atomically. + gh.publish("v2.1.0", newData, priv) + out, err = run() + if err != nil || !strings.Contains(out, "signature and checksum verified") { + t.Fatalf("update: %v\n%s", err, out) + } + if !strings.HasPrefix(installedVersion(), "spectate-agent 2.1.0 ") { + t.Fatalf("installed binary reports %q", installedVersion()) + } + if entries, _ := os.ReadDir(installDir); len(entries) != 1 { + t.Fatalf("temp files left behind: %v", entries) + } + + // A newer build with --if-newer is quiet and leaves the binary alone. + newer := buildAgent(t, build, "2.2.0-dev", key) + cmd := exec.Command(newer, "update", "--if-newer", "--no-restart") + cmd.Env = append(os.Environ(), "SPECTATE_UPDATE_API_URL="+gh.srv.URL) + if out, err := cmd.CombinedOutput(); err != nil || len(out) != 0 { + t.Fatalf("--if-newer when current: %v %q", err, out) + } + + // Release builds ignore the API override; keyless builds refuse to update. + cmd = exec.Command(installed, "update", "--no-restart") + cmd.Env = append(os.Environ(), "SPECTATE_UPDATE_API_URL="+gh.srv.URL) + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "only honoured by development builds") { + t.Fatalf("release build accepted the API override: %v\n%s", err, out) + } + keyless := buildAgent(t, build, "2.0.0-dev.nokey", "") + cmd = exec.Command(keyless, "update", "--no-restart") + cmd.Env = append(os.Environ(), "SPECTATE_UPDATE_API_URL="+gh.srv.URL) + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "no release signing key") { + t.Fatalf("keyless build updated without --insecure-skip-signature: %v\n%s", err, out) + } +} + +func oldBinData(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/go.mod b/go.mod index f50887e..95226e1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/shirou/gopsutil/v4 v4.26.9 + golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a google.golang.org/protobuf v1.36.12 ) diff --git a/go.sum b/go.sum index 7b1964a..454017c 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= diff --git a/install.sh b/install.sh index feb9559..93bff75 100755 --- a/install.sh +++ b/install.sh @@ -11,16 +11,25 @@ # --binary PATH install a local binary instead of downloading # --import-legacy reuse the v1 agent's (spectated) host token instead of registering # --upgrade only replace the binary and restart the service +# --auto-update also install a daily timer that runs `spectate-agent update --if-newer` # # Creates the unprivileged `spectate-agent` user, installs the binary to # /usr/local/bin, writes /etc/spectate-agent/agent.env, registers the host and # enables the hardened systemd unit. State lives in /var/lib/spectate-agent. +# +# Downloads are verified against the release's checksums.txt. The release +# assets' copy of this script also verifies the ed25519 signature of +# checksums.txt (checksums.txt.sig) when OpenSSL 3 is available; +# `spectate-agent update` always verifies it. set -eu REPO="spectate/agent" # Set to the release version in the install.sh asset of each GitHub release; # empty in the repository, which installs the newest v2 release. DEFAULT_VERSION="" +# Release signing public key (base64 ed25519), set in the install.sh asset of +# each GitHub release; empty in the repository (checksums only). +UPDATE_PUBLIC_KEY="" BIN="/usr/local/bin/spectate-agent" USER_NAME="spectate-agent" ETC_DIR="/etc/spectate-agent" @@ -32,6 +41,7 @@ VERSION="${DEFAULT_VERSION:-latest}" LOCAL_BINARY="" IMPORT_LEGACY=0 UPGRADE=0 +AUTO_UPDATE=0 while [ $# -gt 0 ]; do case "$1" in @@ -41,13 +51,44 @@ while [ $# -gt 0 ]; do --binary) LOCAL_BINARY="$2"; shift 2 ;; --import-legacy) IMPORT_LEGACY=1; shift ;; --upgrade) UPGRADE=1; shift ;; - -h|--help) sed -n '2,17p' "$0"; exit 0 ;; + --auto-update) AUTO_UPDATE=1; shift ;; + -h|--help) sed -n '2,23p' "$0"; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done fail() { echo "error: $*" >&2; exit 1; } +# ed25519 verification needs OpenSSL 3 (`pkeyutl -rawin`); LibreSSL and +# OpenSSL 1.1 cannot do it. +have_openssl3() { + command -v openssl >/dev/null 2>&1 || return 1 + case "$(openssl version 2>/dev/null)" in + "OpenSSL 3."* | "OpenSSL 4."*) return 0 ;; + esac + return 1 +} + +# verify_signature DIR BASE_URL: checks DIR/checksums.txt against the +# release's checksums.txt.sig with UPDATE_PUBLIC_KEY. +verify_signature() { + [ -n "$UPDATE_PUBLIC_KEY" ] || return 0 + if ! have_openssl3; then + echo "warning: OpenSSL 3 not found: verifying checksums only, not the release signature" >&2 + return 0 + fi + curl -fsSL "$2/checksums.txt.sig" -o "$1/checksums.txt.sig" \ + || fail "the release has no checksums.txt.sig (unsigned release?)" + # SubjectPublicKeyInfo of a raw ed25519 key: a fixed 12-byte DER prefix. + printf '%s\n' '-----BEGIN PUBLIC KEY-----' "MCowBQYDK2VwAyEA$UPDATE_PUBLIC_KEY" '-----END PUBLIC KEY-----' > "$1/release.pem" + tr -d ' \r\n' < "$1/checksums.txt.sig" | openssl base64 -d -A > "$1/checksums.sig.bin" \ + || fail "malformed checksums.txt.sig" + openssl pkeyutl -verify -pubin -inkey "$1/release.pem" -rawin \ + -in "$1/checksums.txt" -sigfile "$1/checksums.sig.bin" >/dev/null 2>&1 \ + || fail "signature verification of checksums.txt failed" + echo "Verified the release signature." +} + [ "$(id -u)" -eq 0 ] || fail "please run as root (sudo)" [ "$(uname -s)" = "Linux" ] || fail "this installer supports Linux; on macOS run \`spectate-agent install\` as your user" command -v systemctl >/dev/null 2>&1 || fail "systemd is required" @@ -83,6 +124,7 @@ else echo "Downloading spectate-agent $VERSION (linux/$ARCH)..." curl -fsSL "$BASE/spectate-agent_linux_$ARCH" -o "$TMP/spectate-agent" curl -fsSL "$BASE/checksums.txt" -o "$TMP/checksums.txt" + verify_signature "$TMP" "$BASE" (cd "$TMP" && grep " spectate-agent_linux_$ARCH\$" checksums.txt | sed "s/spectate-agent_linux_$ARCH/spectate-agent/" | sha256sum -c -) \ || fail "checksum verification failed" fi @@ -90,6 +132,9 @@ install -m 0755 "$TMP/spectate-agent" "$BIN" echo "Installed $("$BIN" version)" if [ "$UPGRADE" -eq 1 ]; then + if [ "$AUTO_UPDATE" -eq 1 ]; then + "$BIN" install --user "$USER_NAME" --env-file "$ETC_DIR/agent.env" --auto-update + fi systemctl restart spectate-agent echo "Upgraded and restarted spectate-agent." exit 0 @@ -136,7 +181,11 @@ else SPECTATE_INSTALL_TOKEN="$TOKEN" as_agent "$BIN" register --api-url "$API_URL" fi -# 5. Service -"$BIN" install --user "$USER_NAME" --env-file "$ETC_DIR/agent.env" +# 5. Service (and the optional daily update timer) +if [ "$AUTO_UPDATE" -eq 1 ]; then + "$BIN" install --user "$USER_NAME" --env-file "$ETC_DIR/agent.env" --auto-update +else + "$BIN" install --user "$USER_NAME" --env-file "$ETC_DIR/agent.env" +fi echo echo "Done. Logs: journalctl -u spectate-agent -f Status: sudo -u $USER_NAME SPECTATE_STATE_DIR=$STATE_DIR $BIN status" diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d30003e..664d834 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -5,13 +5,20 @@ // └── spool non-empty ──► replay oldest first // // Failed sends back off exponentially with jitter (honouring Retry-After); -// batches the server rejects as malformed or too large are dropped. On +// batches the server rejects as malformed or too large are dropped. A +// rejected host token (401) keeps spooling but retries only hourly; a deleted +// host (410 HOST_DELETED) stops the agent for good (see ErrHostDeleted). On // shutdown the current window is flushed (or spooled when offline). +// +// Collectors run concurrently, each in its own goroutine, bounded by the +// round deadline: a collector stuck in a syscall (statfs on a dead NFS mount) +// delays nothing else, and is not started again until it returns. package agent import ( "context" "errors" + "fmt" "log/slog" "math/rand/v2" "runtime" @@ -33,6 +40,22 @@ import ( // maxWindowSamples bounds memory per shipping window (~50 bytes per sample). const maxWindowSamples = 50_000 +// HostDeletedMessage is logged when the API reports the host as deleted. +const HostDeletedMessage = "This host was removed from Spectate. Remove the agent: sudo spectate-agent uninstall" + +// ErrHostDeleted is returned by Run when the host was deleted in Spectate +// (now, or by a previous run: state.json carries the host_deleted marker). +// The process should exit without being restarted. +var ErrHostDeleted = errors.New("host deleted in Spectate") + +const ( + // clockWarnThreshold is the clock offset beyond which the agent warns. + clockWarnThreshold = 30 * time.Second + clockWarnEvery = time.Hour + authErrorLogEvery = time.Hour + updateLogEvery = 24 * time.Hour +) + // Options configures a Runner. Zero values select production defaults. type Options struct { Client *transport.Client @@ -48,6 +71,9 @@ type Options struct { HostInfoRefresh time.Duration // Backoff for failed sends (default 5s doubling up to 5m, jittered). Backoff *backoff.Backoff + // AuthRetry is the retry interval while the API rejects the host token + // (401), jittered down by up to a quarter (default 1h). + AuthRetry time.Duration // DrainPause between replayed spool batches (default 500ms). DrainPause time.Duration // ShutdownTimeout bounds the final flush (default 5s). @@ -67,6 +93,17 @@ type Runner struct { seq atomic.Uint64 acc *metrics.Accumulator + cancel context.CancelFunc + deleted atomic.Bool + limits *limiter + + stateMu sync.Mutex + state config.State // last persisted state + + collMu sync.Mutex + running map[string]time.Time // collector name -> start of a run that has not returned + reportedS map[string]bool // running collectors already reported as stuck + mu sync.Mutex remote config.Remote shipEvery time.Duration @@ -108,19 +145,35 @@ func New(opts Options) *Runner { if opts.ShutdownTimeout == 0 { opts.ShutdownTimeout = 5 * time.Second } + if opts.AuthRetry == 0 { + opts.AuthRetry = time.Hour + } r := &Runner{ opts: opts, log: opts.Logger, instance: config.NewInstanceID(), acc: metrics.NewAccumulator(maxWindowSamples), cfgChanged: make(chan struct{}, 1), + limits: newLimiter(), + state: opts.State, + running: make(map[string]time.Time), + reportedS: make(map[string]bool), } r.applyRemote(opts.State.Remote.Normalize(), 0, false) return r } -// Run collects and ships until ctx is cancelled, then flushes. +// Run collects and ships until ctx is cancelled, then flushes. It returns +// ErrHostDeleted, without collecting or shipping anything more, once the API +// reports the host as deleted. func (r *Runner) Run(ctx context.Context) error { + if r.opts.State.HostDeleted { + r.log.Error(HostDeletedMessage, "host_uuid", r.opts.State.HostUUID) + return ErrHostDeleted + } + ctx, r.cancel = context.WithCancel(ctx) + defer r.cancel() + r.refreshHostInfo(ctx) r.log.Info("agent started", "version", version.Version, "instance", r.instance, "api", r.opts.Client.BaseURL, "spooled_batches", r.opts.Spool.Len()) @@ -134,9 +187,15 @@ func (r *Runner) Run(ctx context.Context) error { stopCollect() wg.Wait() + if r.deleted.Load() { + return ErrHostDeleted + } flushCtx, cancel := context.WithTimeout(context.Background(), r.opts.ShutdownTimeout) defer cancel() r.ship(flushCtx, true) + if r.deleted.Load() { + return ErrHostDeleted + } r.log.Info("agent stopped", "spooled_batches", r.opts.Spool.Len()) return nil } @@ -172,24 +231,137 @@ func (r *Runner) collectLoop(ctx context.Context) { } } +// collectRound runs every enabled collector concurrently and waits for them +// until the round deadline (half the collection interval, at least 1s). +// Samples of collectors that have not returned by then are kept up to the +// deadline; later ones are discarded. A collector whose previous run is still +// going is not started again. func (r *Runner) collectRound(ctx context.Context, at time.Time) { remote := r.Remote() ts := at.UnixMilli() timeout := max(time.Duration(remote.CollectionInterval)*r.opts.IntervalUnit/2, time.Second) + rctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() - for _, c := range r.opts.Collectors { - if !remote.Enabled(c.Name()) { + bufs := make([]*roundBuffer, len(r.opts.Collectors)) + done := make(chan struct{}, len(r.opts.Collectors)) + started := 0 + for i, c := range r.opts.Collectors { + name := c.Name() + if !remote.Enabled(name) || !r.startCollector(name) { + continue + } + buf := &roundBuffer{} + bufs[i] = buf + started++ + go func() { + defer func() { done <- struct{}{} }() + defer r.finishCollector(name) + if err := c.Collect(rctx, at, buf.emit); err != nil && !errors.Is(err, context.Canceled) { + r.log.Debug("collector failed", "collector", name, "err", err) + } + }() + } + +wait: + for range started { + select { + case <-done: + case <-rctx.Done(): + break wait + } + } + + for _, buf := range bufs { + if buf == nil { continue } - cctx, cancel := context.WithTimeout(ctx, timeout) - err := c.Collect(cctx, at, func(metric string, labels []metrics.Label, value float64) { - r.acc.Add(metric, labels, ts, value) - }) - cancel() - if err != nil && !errors.Is(err, context.Canceled) { - r.log.Debug("collector failed", "collector", c.Name(), "err", err) + for _, s := range buf.close() { + r.acc.Add(s.metric, s.labels, ts, s.value) + } + } + r.reportStuck(timeout) +} + +// startCollector claims a collector for this round; false while a previous +// run has not returned. +func (r *Runner) startCollector(name string) bool { + r.collMu.Lock() + defer r.collMu.Unlock() + if _, busy := r.running[name]; busy { + r.log.Debug("collector still running from an earlier round, skipping", "collector", name) + return false + } + r.running[name] = time.Now() + return true +} + +func (r *Runner) finishCollector(name string) { + r.collMu.Lock() + defer r.collMu.Unlock() + if r.reportedS[name] { + r.log.Info("stuck collector returned, collecting it again", "collector", name, + "after", time.Since(r.running[name]).Round(time.Millisecond)) + delete(r.reportedS, name) + } + delete(r.running, name) +} + +// reportStuck logs, once per stuck run, collectors that missed the deadline. +func (r *Runner) reportStuck(timeout time.Duration) { + r.collMu.Lock() + defer r.collMu.Unlock() + for name := range r.running { + if !r.reportedS[name] { + r.reportedS[name] = true + r.log.Warn("collector did not finish within the round deadline; shipping the other collectors and skipping it until it returns", + "collector", name, "deadline", timeout) + } + } +} + +// Stuck returns the collectors whose last run has not returned. +func (r *Runner) Stuck() []string { + r.collMu.Lock() + defer r.collMu.Unlock() + var out []string + for _, c := range r.opts.Collectors { + if r.reportedS[c.Name()] { + out = append(out, c.Name()) } } + return out +} + +type sample struct { + metric string + labels []metrics.Label + value float64 +} + +// roundBuffer holds one collector's samples for one round. Emits after +// close (a collector returning after the round deadline) are discarded. +type roundBuffer struct { + mu sync.Mutex + closed bool + samples []sample +} + +func (b *roundBuffer) emit(metric string, labels []metrics.Label, value float64) { + b.mu.Lock() + defer b.mu.Unlock() + if !b.closed { + b.samples = append(b.samples, sample{metric, labels, value}) + } +} + +func (b *roundBuffer) close() []sample { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + out := b.samples + b.samples = nil + return out } func (r *Runner) shipLoop(ctx context.Context) { @@ -258,15 +430,22 @@ func (r *Runner) ship(ctx context.Context, final bool) { return } + if r.deleted.Load() { + return + } if r.opts.Spool.Len() == 0 && !r.backingOff() { err := r.send(ctx, body) if err == nil { return } - if transport.Classify(err) == transport.Drop { + switch transport.Classify(err) { + case transport.Drop: r.droppedBatches.Add(1) r.log.Error("batch rejected by server, dropping", "err", err) return + case transport.HostDeleted: + r.hostDeleted(err) + return } r.failed(err) } @@ -299,6 +478,9 @@ func (r *Runner) drain(ctx context.Context) { case transport.Classify(err) == transport.Drop: r.droppedBatches.Add(1) r.log.Error("spooled batch rejected by server, dropping", "batch", entry.Name, "err", err) + case transport.Classify(err) == transport.HostDeleted: + r.hostDeleted(err) + return default: r.failed(err) return @@ -336,12 +518,23 @@ func (r *Runner) send(ctx context.Context, body []byte) error { r.mu.Unlock() r.applyRemote(resp.Config, resp.NextIngestIn, true) + r.checkClock(resp.ClockOffsetMs) + r.noteUpdate(resp.AgentUpdate) return nil } func (r *Runner) failed(err error) { + unauthorized := transport.Classify(err) == transport.Unauthorized + r.mu.Lock() - delay := r.opts.Backoff.Next() + var delay time.Duration + if unauthorized { + // Only a human can fix a revoked token: keep spooling, but do not + // knock on the API every few minutes. + delay = r.opts.AuthRetry - time.Duration(rand.Int64N(int64(r.opts.AuthRetry/4)+1)) + } else { + delay = r.opts.Backoff.Next() + } if ra := transport.RetryAfter(err); ra > delay { delay = ra } @@ -349,14 +542,95 @@ func (r *Runner) failed(err error) { attempts := r.opts.Backoff.Attempts() r.mu.Unlock() + if unauthorized { + level := slog.LevelDebug + if r.limits.allow("auth", authErrorLogEvery) { + level = slog.LevelError + } + r.log.Log(context.Background(), level, + "the API rejected the host token (revoked or invalid); spooling and retrying hourly. Re-register the host if it should keep reporting", + "err", err, "retry_in", delay.Round(time.Second), "spooled_batches", r.opts.Spool.Len()) + return + } + var apiErr *transport.Error level := slog.LevelWarn - if errors.As(err, &apiErr) && (apiErr.Status == 401 || apiErr.Status == 403) { - level = slog.LevelError // token revoked or host deleted: data spools until fixed + if errors.As(err, &apiErr) && apiErr.Status == 403 { + level = slog.LevelError // forbidden: data spools until fixed } r.log.Log(context.Background(), level, "ship failed, spooling", "err", err, "retry_in", delay.Round(time.Second), "attempt", attempts) } +// hostDeleted handles 410 HOST_DELETED: the server will never accept data +// for this host again. Drop the spool and the current window, persist the +// marker so a restart does not resume, log once and stop the runner. +func (r *Runner) hostDeleted(err error) { + if !r.deleted.CompareAndSwap(false, true) { + return + } + r.log.Error(HostDeletedMessage, "err", err, "host_uuid", r.opts.State.HostUUID) + if cleared, cerr := r.opts.Spool.Clear(); cerr != nil { + r.log.Warn("clear spool", "err", cerr) + } else if cleared > 0 { + r.log.Debug("cleared spool", "batches", cleared) + } + r.acc.Drain() + r.persist(func(st *config.State) { st.HostDeleted = true }) + if r.cancel != nil { + r.cancel() + } +} + +// checkClock warns (hourly) when the server reports the host clock off by +// more than 30s. The server corrects sample timestamps itself; the agent +// never shifts its own. +func (r *Runner) checkClock(offsetMs int64) { + offset := time.Duration(offsetMs) * time.Millisecond + if offset.Abs() <= clockWarnThreshold || !r.limits.allow("clock", clockWarnEvery) { + return + } + direction := "behind" + if offset < 0 { + direction = "ahead" + } + r.log.Warn(fmt.Sprintf("host clock is off by %ds; samples are corrected by the server, but fix NTP", int64(offset.Abs().Seconds())), + "clock_offset_ms", offsetMs, "host_clock", direction) +} + +// noteUpdate records a newer release announced by the API (shown by +// `spectate-agent status`) and logs it once a day. The agent cannot replace +// itself (it runs unprivileged on a read-only filesystem). +func (r *Runner) noteUpdate(u *transport.AgentUpdate) { + var avail *config.AvailableUpdate + if u != nil && version.Compare(u.LatestVersion, version.Version) > 0 { + avail = &config.AvailableUpdate{Version: u.LatestVersion, ReleaseURL: u.ReleaseURL} + } + + r.stateMu.Lock() + prev := r.state.AvailableUpdate + r.stateMu.Unlock() + if (prev == nil) != (avail == nil) || (prev != nil && *prev != *avail) { + r.persist(func(st *config.State) { st.AvailableUpdate = avail }) + } + if avail != nil && r.limits.allow("update:"+avail.Version, updateLogEvery) { + r.log.Info(fmt.Sprintf("spectate-agent %s is available: sudo spectate-agent update", avail.Version), + "current", version.Version, "release_url", avail.ReleaseURL) + } +} + +// persist applies fn to the persisted state and saves it. +func (r *Runner) persist(fn func(*config.State)) { + r.stateMu.Lock() + defer r.stateMu.Unlock() + fn(&r.state) + if r.opts.StateDir == "" { + return + } + if err := config.SaveState(r.opts.StateDir, r.state); err != nil { + r.log.Warn("persist state", "err", err) + } +} + // cut drains the window, appends agent health samples and encodes the batch. func (r *Runner) cut() ([]byte, error) { now := time.Now() @@ -452,12 +726,27 @@ func (r *Runner) applyRemote(remote config.Remote, nextIngestIn int, persist boo if persist { r.log.Info("applied server config", "collection_interval", remote.CollectionInterval, "ingest_interval", remote.IngestInterval, "collectors", remote.Collectors) - if r.opts.StateDir != "" { - st := r.opts.State - st.Remote = remote - if err := config.SaveState(r.opts.StateDir, st); err != nil { - r.log.Warn("persist server config", "err", err) - } - } + r.persist(func(st *config.State) { st.Remote = remote }) + } +} + +// limiter rate-limits repeated log lines per key. +type limiter struct { + mu sync.Mutex + last map[string]time.Time + now func() time.Time +} + +func newLimiter() *limiter { return &limiter{last: make(map[string]time.Time), now: time.Now} } + +// allow reports whether key was not allowed within the last `every`. +func (l *limiter) allow(key string, every time.Duration) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + if last, ok := l.last[key]; ok && now.Sub(last) < every { + return false } + l.last[key] = now + return true } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 7e16df0..8fe1bc8 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -45,8 +45,11 @@ type fakeAPI struct { batches []received failNext int // respond 503 to this many requests status int // when non-zero, respond with this status + errBody string // body of the status response (default INVALID_PAYLOAD) resend bool // ask for host info once config string // JSON config to return + extra map[string]any + requests int srv *httptest.Server } @@ -67,6 +70,7 @@ func (f *fakeAPI) handle(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() + f.requests++ if f.failNext > 0 { f.failNext-- w.Header().Set("Retry-After", "0") @@ -74,8 +78,12 @@ func (f *fakeAPI) handle(w http.ResponseWriter, r *http.Request) { return } if f.status != 0 { + body := f.errBody + if body == "" { + body = `{"error":{"code":"INVALID_PAYLOAD","message":"nope"}}` + } w.WriteHeader(f.status) - _, _ = w.Write([]byte(`{"error":{"code":"INVALID_PAYLOAD","message":"nope"}}`)) + _, _ = w.Write([]byte(body)) return } req, err := encode.Unmarshal(body) @@ -91,13 +99,17 @@ func (f *fakeAPI) handle(w http.ResponseWriter, r *http.Request) { f.batches = append(f.batches, received{req, series}) resend := f.resend f.resend = false - _ = json.NewEncoder(w).Encode(map[string]any{ + resp := map[string]any{ "accepted_samples": 1, "duplicate": false, "resend_host_info": resend, "config": json.RawMessage(f.config), "next_ingest_in": 60, - }) + } + for k, v := range f.extra { + resp[k] = v + } + _ = json.NewEncoder(w).Encode(resp) } func (f *fakeAPI) snapshot() []received { @@ -106,6 +118,12 @@ func (f *fakeAPI) snapshot() []received { return append([]received(nil), f.batches...) } +func (f *fakeAPI) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.requests +} + func (f *fakeAPI) set(fn func(*fakeAPI)) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/agent/lifecycle_test.go b/internal/agent/lifecycle_test.go new file mode 100644 index 0000000..be45c2f --- /dev/null +++ b/internal/agent/lifecycle_test.go @@ -0,0 +1,392 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/spectate/agent/v2/internal/backoff" + "github.com/spectate/agent/v2/internal/collector" + "github.com/spectate/agent/v2/internal/config" + "github.com/spectate/agent/v2/internal/metrics" + pb "github.com/spectate/agent/v2/internal/proto/agentv2" + "github.com/spectate/agent/v2/internal/spool" + "github.com/spectate/agent/v2/internal/transport" +) + +// logBuffer is a concurrency-safe slog sink. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// count returns how many log lines contain all of parts. +func (b *logBuffer) count(parts ...string) int { + n := 0 + for line := range strings.SplitSeq(b.String(), "\n") { + all := line != "" + for _, p := range parts { + all = all && strings.Contains(line, p) + } + if all { + n++ + } + } + return n +} + +func logTo(r *Runner, level slog.Level) *logBuffer { + b := &logBuffer{} + r.log = slog.New(slog.NewTextHandler(b, &slog.HandlerOptions{Level: level})) + return b +} + +const hostDeletedBody = `{"error":{"code":"HOST_DELETED","message":"This host was deleted."}}` + +func TestHostDeletedStopsShippingClearsSpoolAndPersistsMarker(t *testing.T) { + api := newFakeAPI(t) + api.status = http.StatusGone + api.errBody = hostDeletedBody + stateDir := t.TempDir() + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + for i := range 3 { // left over from an outage + _ = sp.Push([]byte("old"), time.Now().Add(time.Duration(i)*time.Millisecond)) + } + st := config.State{APIURL: api.srv.URL, HostToken: "hst_test", HostUUID: "u-1", Remote: config.DefaultRemote()} + if err := config.SaveState(stateDir, st); err != nil { + t.Fatal(err) + } + c := &counter{} + r := newRunner(t, api, sp, c) + r.opts.StateDir = stateDir + r.opts.State = st + r.state = st + logs := logTo(r, slog.LevelDebug) + + done := make(chan error, 1) + go func() { done <- r.Run(context.Background()) }() + select { + case err := <-done: + if !errors.Is(err, ErrHostDeleted) { + t.Fatalf("Run = %v, want ErrHostDeleted", err) + } + case <-time.After(5 * time.Second): + t.Fatal("runner kept going after 410 HOST_DELETED") + } + + requests := api.count() + if requests != 1 { + t.Fatalf("requests = %d: the first 410 must stop shipping (no replay of the remaining spool, no final flush)", requests) + } + if sp.Len() != 0 { + t.Fatalf("spool not cleared: %d batches", sp.Len()) + } + if n := logs.count("level=ERROR", HostDeletedMessage); n != 1 { + t.Fatalf("want exactly one ERROR %q, got %d:\n%s", HostDeletedMessage, n, logs) + } + saved, err := config.LoadState(stateDir) + if err != nil || !saved.HostDeleted || saved.HostToken != "hst_test" { + t.Fatalf("marker not persisted: %+v, %v", saved, err) + } + collected := c.n.Load() + time.Sleep(50 * time.Millisecond) + if c.n.Load() != collected { + t.Fatal("collection continued after the host was deleted") + } + + // Restart: the marker stops the agent before it contacts the API. + r2 := newRunner(t, api, sp, &counter{}) + r2.opts.State = saved + logs2 := logTo(r2, slog.LevelDebug) + if err := r2.Run(context.Background()); !errors.Is(err, ErrHostDeleted) { + t.Fatalf("restart with marker: %v", err) + } + if api.count() != requests { + t.Fatal("restart with the host_deleted marker must not call the API") + } + if logs2.count("level=ERROR", HostDeletedMessage) != 1 { + t.Fatalf("restart must log the message once:\n%s", logs2) + } +} + +func TestOther410IsRetried(t *testing.T) { + api := newFakeAPI(t) + api.status = http.StatusGone + api.errBody = `{"message":"gone"}` + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + r := newRunner(t, api, sp, &counter{}) + r.ship(context.Background(), false) + if r.deleted.Load() || sp.Len() != 1 { + t.Fatalf("a 410 without HOST_DELETED is a transient error: deleted=%v spool=%d", r.deleted.Load(), sp.Len()) + } +} + +func TestRevokedTokenSpoolsAndBacksOffLong(t *testing.T) { + api := newFakeAPI(t) + api.status = http.StatusUnauthorized + api.errBody = `{"error":{"code":"INVALID_HOST_TOKEN","message":"Invalid host token."}}` + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + r := newRunner(t, api, sp, &counter{}) + r.opts.AuthRetry = time.Hour + logs := logTo(r, slog.LevelDebug) + ctx := context.Background() + + r.collectRound(ctx, time.Now()) + r.ship(ctx, false) + if sp.Len() != 1 { + t.Fatalf("401 must keep the batch: spool = %d", sp.Len()) + } + r.mu.Lock() + wait := time.Until(r.retryAt) + r.mu.Unlock() + if wait < 44*time.Minute || wait > time.Hour { + t.Fatalf("401 retry in %v, want ~1h (not the normal 5m cap)", wait) + } + + // While backing off, new windows are spooled without contacting the API. + r.ship(ctx, false) + if requests := api.count(); sp.Len() != 2 || requests != 1 { + t.Fatalf("spool = %d, requests = %d", sp.Len(), requests) + } + + // The next retry fails again: no second ERROR within the hour. + r.drain(ctx) + if n := logs.count("level=ERROR"); n != 1 { + t.Fatalf("ERROR lines = %d, want 1:\n%s", n, logs) + } + if r.deleted.Load() { + t.Fatal("401 must not be treated as a deleted host") + } + + // An hour later it is reported again. + r.limits.now = func() time.Time { return time.Now().Add(61 * time.Minute) } + r.drain(ctx) + if n := logs.count("level=ERROR"); n != 2 { + t.Fatalf("ERROR lines = %d, want 2 after an hour", n) + } +} + +func TestRevokedTokenHonoursLongerRetryAfter(t *testing.T) { + r := newRunner(t, newFakeAPI(t), nil, &counter{}) + r.opts.AuthRetry = time.Hour + r.log = slog.New(slog.NewTextHandler(io.Discard, nil)) + r.failed(&transport.Error{Status: 401, RetryAfter: 3 * time.Hour}) + r.mu.Lock() + defer r.mu.Unlock() + if wait := time.Until(r.retryAt); wait < 179*time.Minute { + t.Fatalf("retry in %v, want Retry-After (3h)", wait) + } +} + +func TestClockOffsetWarningIsRateLimited(t *testing.T) { + api := newFakeAPI(t) + api.extra = map[string]any{"clock_offset_ms": 95_000} + r := newRunner(t, api, nil, &counter{}) + logs := logTo(r, slog.LevelInfo) + now := time.Now() + r.limits.now = func() time.Time { return now } + ctx := context.Background() + + for range 3 { + r.ship(ctx, false) + } + msg := "host clock is off by 95s; samples are corrected by the server, but fix NTP" + if n := logs.count("level=WARN", msg); n != 1 { + t.Fatalf("clock warnings = %d, want 1:\n%s", n, logs) + } + + now = now.Add(61 * time.Minute) + r.ship(ctx, false) + if n := logs.count("level=WARN", msg); n != 2 { + t.Fatalf("clock warnings after an hour = %d, want 2", n) + } + + // Within the threshold (either direction): silent. + now = now.Add(2 * time.Hour) + api.set(func(f *fakeAPI) { f.extra = map[string]any{"clock_offset_ms": -30_000} }) + r.ship(ctx, false) + if logs.count("host clock") != 2 { + t.Fatal("an offset of 30s must not warn") + } + api.set(func(f *fakeAPI) { f.extra = map[string]any{"clock_offset_ms": -45_500} }) + r.ship(ctx, false) + if logs.count("level=WARN", "host clock is off by 45s", "host_clock=ahead") != 1 { + t.Fatalf("negative offsets warn too:\n%s", logs) + } +} + +func TestAgentUpdateIsPersistedAndLoggedDaily(t *testing.T) { + api := newFakeAPI(t) + api.extra = map[string]any{"agent_update": map[string]string{ + "latest_version": "99.1.0", "release_url": "https://github.com/spectate/agent/releases/tag/v99.1.0", + }} + stateDir := t.TempDir() + r := newRunner(t, api, nil, &counter{}) + r.opts.StateDir = stateDir + logs := logTo(r, slog.LevelInfo) + now := time.Now() + r.limits.now = func() time.Time { return now } + ctx := context.Background() + + r.ship(ctx, false) + r.ship(ctx, false) + msg := "spectate-agent 99.1.0 is available: sudo spectate-agent update" + if n := logs.count("level=INFO", msg); n != 1 { + t.Fatalf("update hints = %d, want 1:\n%s", n, logs) + } + st, err := config.LoadState(stateDir) + if err != nil || st.AvailableUpdate == nil || st.AvailableUpdate.Version != "99.1.0" { + t.Fatalf("available update not persisted: %+v, %v", st.AvailableUpdate, err) + } + now = now.Add(25 * time.Hour) + r.ship(ctx, false) + if logs.count(msg) != 2 { + t.Fatal("the hint repeats daily") + } + + // Older / equal / absent announcements clear it. + for _, extra := range []map[string]any{ + {"agent_update": map[string]string{"latest_version": "1.0.0"}}, + {"agent_update": nil}, + } { + api.set(func(f *fakeAPI) { f.extra = extra }) + r.ship(ctx, false) + if st, _ := config.LoadState(stateDir); st.AvailableUpdate != nil { + t.Fatalf("stale update hint kept after %v", extra) + } + } +} + +// blocker is a collector stuck in an uninterruptible call until released. +type blocker struct { + name string + release chan struct{} + starts atomic.Int32 +} + +func (b *blocker) Name() string { return b.name } + +func (b *blocker) Collect(_ context.Context, _ time.Time, emit collector.Emit) error { + b.starts.Add(1) + emit("test.blocker.partial", nil, 1) // emitted before the deadline: kept + <-b.release // ignores ctx, like statfs on a dead NFS mount + emit("test.blocker.late", nil, 1) // after the deadline: discarded + return nil +} + +func TestStuckCollectorDoesNotStallOthersOrPileUp(t *testing.T) { + api := newFakeAPI(t) + stuck := &blocker{name: "disk", release: make(chan struct{})} + t.Cleanup(func() { + select { + case <-stuck.release: + default: + close(stuck.release) + } + }) + c := &counter{} + sp, _ := spool.Open(t.TempDir(), 1<<20, time.Hour) + r := New(Options{ + Client: transport.New(api.srv.URL, "hst_test"), + Spool: sp, + State: config.State{HostToken: "hst_test", Remote: config.DefaultRemote()}, + Collectors: []collector.Collector{stuck, c}, + HostInfo: func(context.Context) (*pb.HostInfo, error) { + return &pb.HostInfo{}, nil + }, + Backoff: backoff.New(time.Millisecond, time.Millisecond), + IntervalUnit: 100 * time.Millisecond, // 15 "s" interval: round deadline 750ms, clamped to 1s + }) + logs := logTo(r, slog.LevelInfo) + ctx := context.Background() + + start := time.Now() + r.collectRound(ctx, time.Now()) + if took := time.Since(start); took > 3*time.Second { + t.Fatalf("round took %v: the stuck collector blocked it", took) + } + r.collectRound(ctx, time.Now()) + r.collectRound(ctx, time.Now()) + + if got := stuck.starts.Load(); got != 1 { + t.Fatalf("stuck collector started %d times, want 1 (no goroutine pile-up)", got) + } + if got := c.n.Load(); got != 3 { + t.Fatalf("healthy collector ran %d times, want 3", got) + } + if n := logs.count("level=WARN", "collector=disk"); n != 1 { + t.Fatalf("stuck collector warnings = %d, want 1:\n%s", n, logs) + } + if got := r.Stuck(); len(got) != 1 || got[0] != "disk" { + t.Fatalf("Stuck() = %v", got) + } + + // Release it: the late sample is discarded and the next round runs it again. + close(stuck.release) + waitFor(t, "stuck collector to return", func() bool { return len(r.Stuck()) == 0 }) + r.collectRound(ctx, time.Now()) + if got := stuck.starts.Load(); got != 2 { + t.Fatalf("recovered collector started %d times, want 2", got) + } + + counts := map[string]int{} + for _, s := range r.acc.Drain() { + counts[s.Metric] += len(s.Values) + } + if counts["test.counter"] != 4 || counts["test.blocker.partial"] != 2 || counts["test.blocker.late"] != 1 { + // late: only the second (unblocked) run's emit, the first run's came after its round closed + t.Fatalf("samples = %v", counts) + } + if logs.count("stuck collector returned") != 1 { + t.Fatalf("recovery not logged:\n%s", logs) + } +} + +// Samples carry the round's timestamp regardless of which goroutine emitted them. +func TestConcurrentCollectorsShareRoundTimestamp(t *testing.T) { + r := newRunner(t, newFakeAPI(t), nil, &counter{}) + r.opts.Collectors = append(r.opts.Collectors, &emitter{name: "memory"}, &emitter{name: "load"}) + at := time.UnixMilli(1_700_000_000_000) + r.collectRound(context.Background(), at) + n := 0 + for _, s := range r.acc.Drain() { + for _, ts := range s.Timestamps { + n++ + if ts != at.UnixMilli() { + t.Fatalf("%s ts = %d", s.Metric, ts) + } + } + } + if n != 3 { + t.Fatalf("samples = %d, want 3", n) + } +} + +type emitter struct{ name string } + +func (e *emitter) Name() string { return e.name } + +func (e *emitter) Collect(_ context.Context, _ time.Time, emit collector.Emit) error { + emit("test."+e.name, []metrics.Label{{Key: "k", Value: e.name}}, 1) + return nil +} diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 151d3ad..3fee62d 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -2,6 +2,7 @@ package collector import ( "context" + "sync" "testing" "time" @@ -144,3 +145,93 @@ func TestSystemCollectorsRunOnThisHost(t *testing.T) { t.Fatalf("host info: %v", err) } } + +func TestDefaultsExcludeNetworkFilesystems(t *testing.T) { + d := &Disk{} + d.Configure(config.DefaultRemote()) + for _, fs := range []string{"nfs", "nfs4", "cifs", "smbfs", "smb3", "afpfs", "webdav", "autofs", "fuse.sshfs", "ceph", "glusterfs", "fuse.glusterfs", "9p"} { + if !d.Excluded(disk.PartitionStat{Mountpoint: "/mnt/x", Fstype: fs}) { + t.Errorf("%s should be excluded by default", fs) + } + } + for _, fs := range []string{"ext4", "xfs", "btrfs", "zfs", "apfs"} { + if d.Excluded(disk.PartitionStat{Mountpoint: "/data", Fstype: fs}) { + t.Errorf("%s should be collected", fs) + } + } +} + +// A mountpoint whose statfs hangs (dead NFS server) must not hide the other +// disks, and must not be queried again while the first call is stuck. +func TestDiskSkipsHungMountpoint(t *testing.T) { + release := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + var mu sync.Mutex + calls := map[string]int{} + + d := &Disk{UsageTimeout: 50 * time.Millisecond} + d.Configure(config.DefaultRemote()) + d.partitions = func(context.Context, bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Mountpoint: "/", Device: "/dev/sda1", Fstype: "ext4"}, + {Mountpoint: "/mnt/dead", Device: "/dev/sdc1", Fstype: "ext4"}, + {Mountpoint: "/data", Device: "/dev/sdb1", Fstype: "xfs"}, + }, nil + } + d.usage = func(_ context.Context, path string) (*disk.UsageStat, error) { + mu.Lock() + calls[path]++ + mu.Unlock() + if path == "/mnt/dead" { + <-release // uninterruptible, ignores ctx + } + return &disk.UsageStat{Path: path, Total: 100, Used: 40, Free: 60, UsedPercent: 40}, nil + } + + for round := range 3 { + emit, got := record() + start := time.Now() + if err := d.Collect(context.Background(), time.Now(), emit); err != nil { + t.Fatal(err) + } + took := time.Since(start) + if round == 0 && took > time.Second { + t.Fatalf("collect took %v", took) + } + if round > 0 && took > 40*time.Millisecond { + t.Fatalf("round %d waited %v on the hung mount again", round, took) + } + var mounts []string + for _, s := range got["system.disk_usage.total"] { + mounts = append(mounts, s.labels[0].Value) + } + if len(mounts) != 2 || mounts[0] != "/" || mounts[1] != "/data" { + t.Fatalf("round %d: mounts = %v, want [/ /data]", round, mounts) + } + } + mu.Lock() + if calls["/mnt/dead"] != 1 || calls["/"] != 3 { + t.Fatalf("calls = %v: the hung mount must be queried once", calls) + } + mu.Unlock() + if hung := d.Hung(); len(hung) != 1 || hung[0] != "/mnt/dead" { + t.Fatalf("Hung() = %v", hung) + } + + // Once the call returns, the mountpoint is collected again. + unblock() + for deadline := time.Now().Add(5 * time.Second); len(d.Hung()) > 0; time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("hung call never cleared") + } + } + emit, got := record() + if err := d.Collect(context.Background(), time.Now(), emit); err != nil { + t.Fatal(err) + } + if n := len(got["system.disk_usage.total"]); n != 3 { + t.Fatalf("after recovery: %d mounts, want 3", n) + } +} diff --git a/internal/collector/system.go b/internal/collector/system.go index 999a216..2990343 100644 --- a/internal/collector/system.go +++ b/internal/collector/system.go @@ -3,7 +3,10 @@ package collector import ( "context" "errors" + "log/slog" + "sort" "sync" + "sync/atomic" "time" "github.com/shirou/gopsutil/v4/cpu" @@ -124,13 +127,32 @@ func (Uptime) Collect(ctx context.Context, _ time.Time, emit Emit) error { return nil } +// DefaultUsageTimeout bounds one mountpoint's statfs call. +const DefaultUsageTimeout = 2 * time.Second + // Disk reports usage per mounted filesystem. +// +// statfs on a hung mount (dead NFS server, stuck FUSE daemon) blocks in the +// kernel and cannot be interrupted, so each mountpoint is queried in its own +// goroutine with its own deadline. A mountpoint whose call is still hanging +// is skipped (not queried again) until that call returns, so one dead mount +// neither hides the other disks nor piles up goroutines. type Disk struct { + // UsageTimeout bounds each mountpoint's usage call (default 2s). + UsageTimeout time.Duration + mu sync.RWMutex includeMountpoints []string excludeMountpoints []string excludeDevices []string excludeFilesystems []string + + // Test seams (default gopsutil). + partitions func(ctx context.Context, all bool) ([]disk.PartitionStat, error) + usage func(ctx context.Context, path string) (*disk.UsageStat, error) + + hungMu sync.Mutex + hung map[string]time.Time // mountpoint -> start of the call still running } func (*Disk) Name() string { return "disk" } @@ -157,7 +179,11 @@ func (d *Disk) Excluded(p disk.PartitionStat) bool { } func (d *Disk) Collect(ctx context.Context, _ time.Time, emit Emit) error { - parts, err := disk.PartitionsWithContext(ctx, false) + partitions := d.partitions + if partitions == nil { + partitions = disk.PartitionsWithContext + } + parts, err := partitions(ctx, false) if err != nil && len(parts) == 0 { return err } @@ -167,8 +193,11 @@ func (d *Disk) Collect(ctx context.Context, _ time.Time, emit Emit) error { continue } seen[p.Mountpoint] = true + if ctx.Err() != nil { + return ctx.Err() + } - u, err := disk.UsageWithContext(ctx, p.Mountpoint) + u, err := d.usageOf(ctx, p.Mountpoint) if err != nil || u.Total == 0 { continue } @@ -185,6 +214,81 @@ func (d *Disk) Collect(ctx context.Context, _ time.Time, emit Emit) error { return nil } +// Hung returns the mountpoints whose usage call has not returned yet. +func (d *Disk) Hung() []string { + d.hungMu.Lock() + defer d.hungMu.Unlock() + out := make([]string, 0, len(d.hung)) + for mp := range d.hung { + out = append(out, mp) + } + sort.Strings(out) + return out +} + +var errMountHung = errors.New("usage call still hanging") + +// usageOf runs one usage call with its own deadline. On timeout the call is +// left running (it cannot be cancelled) and the mountpoint is skipped until +// it returns. +func (d *Disk) usageOf(ctx context.Context, mountpoint string) (*disk.UsageStat, error) { + d.hungMu.Lock() + if _, ok := d.hung[mountpoint]; ok { + d.hungMu.Unlock() + return nil, errMountHung + } + if d.hung == nil { + d.hung = make(map[string]time.Time) + } + start := time.Now() + d.hung[mountpoint] = start // cleared below unless the call hangs + d.hungMu.Unlock() + + usage := d.usage + if usage == nil { + usage = disk.UsageWithContext + } + timeout := d.UsageTimeout + if timeout <= 0 { + timeout = DefaultUsageTimeout + } + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + type result struct { + u *disk.UsageStat + err error + } + done := make(chan result, 1) + var timedOut atomic.Bool + go func() { + u, err := usage(cctx, mountpoint) + d.hungMu.Lock() + delete(d.hung, mountpoint) + d.hungMu.Unlock() + if timedOut.Load() { + slog.Info("disk usage call returned, collecting the mountpoint again", + "mountpoint", mountpoint, "after", time.Since(start).Round(time.Millisecond)) + } + done <- result{u, err} + }() + + select { + case r := <-done: + return r.u, r.err + case <-cctx.Done(): + timedOut.Store(true) + select { + case r := <-done: // returned at the deadline + return r.u, r.err + default: + } + slog.Warn("disk usage call hangs (stuck mount?); skipping the mountpoint until it returns", + "mountpoint", mountpoint, "timeout", timeout) + return nil, errMountHung + } +} + // Net reports per-interface traffic since the previous round, normalised to // amounts per 60 seconds. type Net struct { diff --git a/internal/config/config.go b/internal/config/config.go index 4d0106d..e251103 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,7 +45,15 @@ func DefaultRemote() Remote { r.CollectionInterval = 15 r.IngestInterval = 60 r.Collectors = []string{"cpu", "memory", "load", "disk", "net", "uptime", "agent"} - r.Disk.ExcludeFilesystems = []string{"squashfs", "vfat"} + // Network and automount filesystems: statfs on a dead server blocks + // (uninterruptibly), and their usage belongs to the server that exports + // them. On Linux gopsutil already skips "nodev" filesystems, which covers + // these; macOS lists every mount, so the list is what keeps them out there. + r.Disk.ExcludeFilesystems = []string{ + "squashfs", "vfat", + "nfs", "nfs4", "cifs", "smbfs", "smb3", "afpfs", "webdav", "autofs", + "fuse.sshfs", "ceph", "glusterfs", "fuse.glusterfs", "9p", + } r.Net.IncludeInterfaces = []string{"en0", "eth0", "lo0"} r.Net.ExcludeInterfaces = []string{"lo", "br*", "bridge*", "docker*", "veth*", "virbr*", "cni*", "flannel*", "cali*", "utun*", "llw*", "ap*", "anpi*", "awdl*", "gif*", "stf*"} return r @@ -128,11 +136,22 @@ func env(key, fallback string) string { // State is persisted in /state.json (mode 0600). type State struct { - APIURL string `json:"api_url"` - HostToken string `json:"host_token"` - HostUUID string `json:"host_uuid,omitempty"` - Remote Remote `json:"config"` - UpdatedAt time.Time `json:"updated_at"` + APIURL string `json:"api_url"` + HostToken string `json:"host_token"` + HostUUID string `json:"host_uuid,omitempty"` + Remote Remote `json:"config"` + // HostDeleted is set when the API answered 410 HOST_DELETED: the agent + // refuses to run until `register --force` replaces the state. + HostDeleted bool `json:"host_deleted,omitempty"` + // AvailableUpdate is the newer release the API last announced. + AvailableUpdate *AvailableUpdate `json:"available_update,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AvailableUpdate is a newer agent release announced by the API. +type AvailableUpdate struct { + Version string `json:"version"` + ReleaseURL string `json:"release_url,omitempty"` } // ErrNotRegistered is returned when no host token is stored. diff --git a/internal/service/service.go b/internal/service/service.go index 88071ba..43dad61 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,15 +13,27 @@ import ( // Name is the service/unit name. const Name = "spectate-agent" +// ExitHostDeleted is the exit status of `run` when the host was deleted in +// Spectate. The unit does not restart on it (RestartPreventExitStatus). +const ExitHostDeleted = 78 // EX_CONFIG + // Options for Install. type Options struct { - Binary string // absolute path of the agent binary - User string // service user (Linux) - EnvFile string // optional EnvironmentFile (Linux) + Binary string // absolute path of the agent binary + User string // service user (Linux) + EnvFile string // optional EnvironmentFile (Linux) + AutoUpdate bool // also install the daily update timer (Linux) } -// UnitPath is where the systemd unit is written. -const UnitPath = "/etc/systemd/system/" + Name + ".service" +const ( + // UnitPath is where the systemd unit is written. + UnitPath = "/etc/systemd/system/" + Name + ".service" + // UpdateName is the oneshot unit that runs `update --if-newer`. + UpdateName = Name + "-update" + // UpdateServicePath and UpdateTimerPath hold the auto-update units. + UpdateServicePath = "/etc/systemd/system/" + UpdateName + ".service" + UpdateTimerPath = "/etc/systemd/system/" + UpdateName + ".timer" +) var unitTemplate = template.Must(template.New("unit").Parse(`[Unit] Description=Spectate host monitoring agent @@ -37,6 +49,8 @@ ExecStart={{.Binary}} run EnvironmentFile=-{{.EnvFile}} Restart=always RestartSec=5 +# Exit status {{.ExitHostDeleted}}: the host was deleted in Spectate; restarting cannot help. +RestartPreventExitStatus={{.ExitHostDeleted}} TimeoutStopSec=15 KillSignal=SIGTERM @@ -70,6 +84,34 @@ Nice=10 WantedBy=multi-user.target `)) +// The agent runs unprivileged on a read-only filesystem and cannot replace +// itself; this root oneshot does, at most daily. +var updateServiceTemplate = template.Must(template.New("update-service").Parse(`[Unit] +Description=Update the Spectate host monitoring agent +Documentation=https://github.com/spectate/agent#updating +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart={{.Binary}} update --if-newer +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +`)) + +const updateTimer = `[Unit] +Description=Daily update check for the Spectate host monitoring agent + +[Timer] +OnCalendar=daily +RandomizedDelaySec=6h +Persistent=true + +[Install] +WantedBy=timers.target +` + // Unit renders the systemd unit. func Unit(o Options) (string, error) { if o.User == "" { @@ -79,12 +121,24 @@ func Unit(o Options) (string, error) { o.EnvFile = "/etc/spectate-agent/agent.env" } var b bytes.Buffer - if err := unitTemplate.Execute(&b, o); err != nil { + if err := unitTemplate.Execute(&b, struct { + Options + ExitHostDeleted int + }{o, ExitHostDeleted}); err != nil { return "", err } return b.String(), nil } +// UpdateUnits renders the auto-update service and timer. +func UpdateUnits(o Options) (service, timer string, err error) { + var b bytes.Buffer + if err := updateServiceTemplate.Execute(&b, o); err != nil { + return "", "", err + } + return b.String(), updateTimer, nil +} + func run(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout diff --git a/internal/service/service_darwin.go b/internal/service/service_darwin.go index ed754b5..e7e2934 100644 --- a/internal/service/service_darwin.go +++ b/internal/service/service_darwin.go @@ -69,3 +69,23 @@ func Uninstall() error { } return nil } + +// Installed reports whether the launchd agent is installed. +func Installed() bool { + path, err := plistPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +// Restart is not automated on macOS: the launchd agent belongs to a user +// session. RestartHint says how. +func Restart(bool) error { return errors.New("restart the launchd agent manually") } + +// RestartHint returns the commands that restart the launchd agent. +func RestartHint() string { + path, _ := plistPath() + return fmt.Sprintf("launchctl unload %s && launchctl load -w %s", path, path) +} diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index d203d1d..222b3c8 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -25,20 +25,62 @@ func Install(o Options) error { if err := os.WriteFile(UnitPath, []byte(unit), 0o644); err != nil { return fmt.Errorf("write unit: %w", err) } + if o.AutoUpdate { + svc, timer, err := UpdateUnits(o) + if err != nil { + return err + } + if err := os.WriteFile(UpdateServicePath, []byte(svc), 0o644); err != nil { + return fmt.Errorf("write update unit: %w", err) + } + if err := os.WriteFile(UpdateTimerPath, []byte(timer), 0o644); err != nil { + return fmt.Errorf("write update timer: %w", err) + } + } if err := run("systemctl", "daemon-reload"); err != nil { return err } - return run("systemctl", "enable", "--now", Name) + if err := run("systemctl", "enable", "--now", Name); err != nil { + return err + } + if o.AutoUpdate { + return run("systemctl", "enable", "--now", UpdateName+".timer") + } + return nil } -// Uninstall stops and removes the unit (state in /var/lib is kept). +// Uninstall stops and removes the units, including the auto-update timer +// (state in /var/lib is kept). func Uninstall() error { if os.Geteuid() != 0 { return errors.New("uninstall must run as root") } + if _, err := os.Stat(UpdateTimerPath); err == nil { + _ = run("systemctl", "disable", "--now", UpdateName+".timer") + } _ = run("systemctl", "disable", "--now", Name) - if err := os.Remove(UnitPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err + for _, path := range []string{UnitPath, UpdateServicePath, UpdateTimerPath} { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } } return run("systemctl", "daemon-reload") } + +// Installed reports whether the systemd unit is installed. +func Installed() bool { + _, err := os.Stat(UnitPath) + return err == nil +} + +// Restart restarts the installed service. With onlyIfRunning a stopped (or +// failed) service is left alone (systemctl try-restart). +func Restart(onlyIfRunning bool) error { + if onlyIfRunning { + return run("systemctl", "try-restart", Name) + } + return run("systemctl", "restart", Name) +} + +// RestartHint returns the command that restarts the service. +func RestartHint() string { return "sudo systemctl restart " + Name } diff --git a/internal/service/service_other.go b/internal/service/service_other.go index 1c9cba3..71b5466 100644 --- a/internal/service/service_other.go +++ b/internal/service/service_other.go @@ -11,3 +11,12 @@ func Install(Options) error { // Uninstall is not supported on this platform. func Uninstall() error { return errors.New("service uninstall is only supported on Linux and macOS") } + +// Installed reports whether the service is installed (never, here). +func Installed() bool { return false } + +// Restart is not supported on this platform. +func Restart(bool) error { return errors.New("service restart is only supported on Linux") } + +// RestartHint says how to restart the agent here. +func RestartHint() string { return "restart spectate-agent under your init system" } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 7618c32..dadef13 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -13,9 +13,30 @@ func TestUnitRunsUnprivilegedAndHardened(t *testing.T) { for _, want := range []string{ "User=spectate-agent", "ExecStart=/usr/local/bin/spectate-agent run", "StateDirectory=spectate-agent", "EnvironmentFile=-/etc/spectate-agent/agent.env", "NoNewPrivileges=yes", "ProtectSystem=strict", "CapabilityBoundingSet=\n", + "Restart=always", "RestartPreventExitStatus=78\n", } { if !strings.Contains(unit, want) { t.Errorf("unit missing %q", want) } } } + +func TestUpdateUnits(t *testing.T) { + svc, timer, err := UpdateUnits(Options{Binary: "/usr/local/bin/spectate-agent"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Type=oneshot", "ExecStart=/usr/local/bin/spectate-agent update --if-newer"} { + if !strings.Contains(svc, want) { + t.Errorf("update service missing %q", want) + } + } + if strings.Contains(svc, "User=") { + t.Error("the updater must run as root (it replaces the binary)") + } + for _, want := range []string{"OnCalendar=daily", "RandomizedDelaySec=6h", "Persistent=true", "WantedBy=timers.target"} { + if !strings.Contains(timer, want) { + t.Errorf("timer missing %q", want) + } + } +} diff --git a/internal/spool/spool.go b/internal/spool/spool.go index c07d58f..bd04527 100644 --- a/internal/spool/spool.go +++ b/internal/spool/spool.go @@ -155,6 +155,23 @@ func (s *Spool) Remove(e Entry) error { return err } +// Clear deletes every spooled batch and returns how many there were. +func (s *Spool) Clear() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + n := len(s.entries) + var firstErr error + for _, e := range s.entries { + if err := os.Remove(filepath.Join(s.dir, e.Name)); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil { + firstErr = err + } + } + s.entries = nil + s.bytes = 0 + return n, firstErr +} + // Len returns the number of spooled batches. func (s *Spool) Len() int { s.mu.Lock() diff --git a/internal/spool/spool_test.go b/internal/spool/spool_test.go index ea5c6dd..f965329 100644 --- a/internal/spool/spool_test.go +++ b/internal/spool/spool_test.go @@ -111,3 +111,20 @@ func TestInterruptedWritesAreCleanedUp(t *testing.T) { t.Fatal("tmp file should be removed") } } + +func TestClearRemovesEverything(t *testing.T) { + dir := t.TempDir() + s, _ := Open(dir, 1<<20, time.Hour) + for i := range 3 { + _ = s.Push([]byte("x"), time.Now().Add(time.Duration(i)*time.Second)) + } + if n, err := s.Clear(); err != nil || n != 3 || s.Len() != 0 || s.Bytes() != 0 { + t.Fatalf("clear = %d, %v (len %d)", n, err, s.Len()) + } + if items, _ := os.ReadDir(dir); len(items) != 0 { + t.Fatalf("files left: %v", items) + } + if s2, _ := Open(dir, 1<<20, time.Hour); s2.Len() != 0 { + t.Fatal("cleared spool reappeared on reopen") + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 84fe608..b6bf627 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -21,6 +21,9 @@ const ( registerPath = "/agent-api/v2/register" ingestPath = "/agent-api/v2/ingest" maxResponse = 1 << 20 + + // AgentTimeHeader carries the agent's clock (unix ms) at send time. + AgentTimeHeader = "X-Agent-Time" ) // Client is an HTTP client for one API base URL. @@ -67,12 +70,21 @@ func (e *Error) Error() string { type Disposition int const ( - // Retry later (network error, 5xx, 408, 429, auth problems). + // Retry later (network error, 5xx, 403, 408, 429). Retry Disposition = iota // Drop: the server will never accept this batch (400, 413, 422). Drop + // Unauthorized: the host token is invalid or revoked (401). Keep the + // batch, but retry rarely: only a human can fix this. + Unauthorized + // HostDeleted: the host was removed from Spectate (410 HOST_DELETED). + // Nothing will ever be accepted again; stop shipping. + HostDeleted ) +// CodeHostDeleted is the error code of a 410 for a deleted host. +const CodeHostDeleted = "HOST_DELETED" + // Classify maps an ingest error to a disposition. func Classify(err error) Disposition { var apiErr *Error @@ -80,6 +92,12 @@ func Classify(err error) Disposition { switch apiErr.Status { case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity: return Drop + case http.StatusUnauthorized: + return Unauthorized + case http.StatusGone: + if apiErr.Code == CodeHostDeleted { + return HostDeleted + } } } return Retry @@ -110,6 +128,8 @@ type RegisterResponse struct { HostToken string `json:"host_token"` Config config.Remote `json:"config"` NextIngestIn int `json:"next_ingest_in"` + // ClockOffsetMs is server time minus the request's X-Agent-Time. + ClockOffsetMs int64 `json:"clock_offset_ms"` } // IngestResponse is returned by ingest. @@ -119,6 +139,17 @@ type IngestResponse struct { ResendHostInfo bool `json:"resend_host_info"` Config config.Remote `json:"config"` NextIngestIn int `json:"next_ingest_in"` + // ClockOffsetMs is server time minus the request's X-Agent-Time. The + // server corrects sample timestamps itself; the agent only warns. + ClockOffsetMs int64 `json:"clock_offset_ms"` + // AgentUpdate announces a newer agent release (nil when current). + AgentUpdate *AgentUpdate `json:"agent_update"` +} + +// AgentUpdate is the newest agent release the server knows of. +type AgentUpdate struct { + LatestVersion string `json:"latest_version"` + ReleaseURL string `json:"release_url"` } // Register exchanges an install token for a host token. @@ -160,6 +191,9 @@ func (c *Client) Ingest(ctx context.Context, gzBody []byte) (*IngestResponse, er func (c *Client) do(req *http.Request, out any) error { req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", version.UserAgent()) + // The send time, not the batch time: the server derives the host's clock + // offset from it (returned as clock_offset_ms). + req.Header.Set(AgentTimeHeader, strconv.FormatInt(time.Now().UnixMilli(), 10)) resp, err := c.HTTP.Do(req) if err != nil { diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go index b20f8c3..f2611fa 100644 --- a/internal/transport/transport_test.go +++ b/internal/transport/transport_test.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" + "sync" "testing" "time" ) @@ -58,14 +60,88 @@ func TestIngestErrorsAreClassified(t *testing.T) { t.Fatalf("%d should drop", s) } } - for _, s := range []int{401, 403, 500, 502, 503} { + for _, s := range []int{403, 410, 500, 502, 503} { status = s if _, err := c.Ingest(context.Background(), nil); Classify(err) != Retry { t.Fatalf("%d should retry", s) } } + status = 401 + if _, err := c.Ingest(context.Background(), nil); Classify(err) != Unauthorized { + t.Fatal("401 should be classified as unauthorized") + } srv.Close() if _, err := c.Ingest(context.Background(), nil); Classify(err) != Retry { t.Fatal("network errors should retry") } } + +func TestHostDeletedIsItsOwnDisposition(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusGone) + _, _ = w.Write([]byte(`{"error":{"code":"HOST_DELETED","message":"This host was deleted."}}`)) + })) + defer srv.Close() + + _, err := New(srv.URL, "hst_t").Ingest(context.Background(), nil) + if Classify(err) != HostDeleted { + t.Fatalf("410 HOST_DELETED: %v -> %v", err, Classify(err)) + } +} + +// Every request carries the agent's clock at send time, and the server's +// clock offset and update hint are parsed from responses. +func TestAgentTimeHeaderAndClockOffset(t *testing.T) { + var mu sync.Mutex + var stamps []int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ms, err := strconv.ParseInt(r.Header.Get(AgentTimeHeader), 10, 64) + if err != nil { + t.Errorf("%s: bad %s header %q", r.URL.Path, AgentTimeHeader, r.Header.Get(AgentTimeHeader)) + } + mu.Lock() + stamps = append(stamps, ms) + mu.Unlock() + if r.URL.Path == "/agent-api/v2/register" { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"host_uuid":"u","host_token":"hst_x","clock_offset_ms":-42000}`)) + return + } + _, _ = w.Write([]byte(`{"accepted_samples":1,"clock_offset_ms":31000,` + + `"agent_update":{"latest_version":"2.1.0","release_url":"https://github.com/spectate/agent/releases/tag/v2.1.0"}}`)) + })) + defer srv.Close() + c := New(srv.URL, "hst_t") + + before := time.Now().UnixMilli() + reg, err := c.Register(context.Background(), RegisterRequest{Token: "t"}) + if err != nil || reg.ClockOffsetMs != -42000 { + t.Fatalf("register = %+v, %v", reg, err) + } + time.Sleep(20 * time.Millisecond) // a second request is stamped at its own send time + resp, err := c.Ingest(context.Background(), nil) + if err != nil || resp.ClockOffsetMs != 31000 || resp.AgentUpdate == nil || resp.AgentUpdate.LatestVersion != "2.1.0" { + t.Fatalf("ingest = %+v, %v", resp, err) + } + after := time.Now().UnixMilli() + + mu.Lock() + defer mu.Unlock() + if len(stamps) != 2 { + t.Fatalf("stamps = %v", stamps) + } + for _, ms := range stamps { + if ms < before || ms > after { + t.Fatalf("%s = %d, want within [%d, %d]", AgentTimeHeader, ms, before, after) + } + } + if stamps[1]-stamps[0] < 20 { + t.Fatalf("header must be set per request at send time: %v", stamps) + } + + // Absent fields stay zero. + var plain IngestResponse + if err := json.Unmarshal([]byte(`{"accepted_samples":1,"agent_update":null}`), &plain); err != nil || plain.AgentUpdate != nil || plain.ClockOffsetMs != 0 { + t.Fatalf("plain = %+v, %v", plain, err) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..d193678 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,378 @@ +// Package update finds, verifies and installs agent releases from GitHub. +// +// A release is trusted when checksums.txt carries a valid ed25519 signature +// (checksums.txt.sig, made by cmd/release-sign with the release key) and the +// binary matches its sha256 in checksums.txt. The binary is then swapped in +// atomically: written next to the running one, fsynced, test-run, renamed. +package update + +import ( + "bufio" + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/spectate/agent/v2/internal/version" +) + +const ( + // Repo is the GitHub repository releases come from. + Repo = "spectate/agent" + // DefaultAPIURL is the GitHub API. + DefaultAPIURL = "https://api.github.com" + // ChecksumsName and SignatureName are the release's checksum assets. + ChecksumsName = "checksums.txt" + SignatureName = ChecksumsName + ".sig" + + maxBinary = 256 << 20 + maxSmall = 1 << 20 +) + +// allowedHosts are the only hosts the updater talks to: the API, release +// download URLs and the CDN hosts GitHub redirects asset downloads to. +var allowedHosts = []string{ + "api.github.com", + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +} + +// stableTag matches v2 release tags; pre-releases (v2.1.0-rc.1) do not match. +var stableTag = regexp.MustCompile(`^v2\.[0-9]+\.[0-9]+$`) + +// Release is a GitHub release. +type Release struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + HTMLURL string `json:"html_url"` + Assets []Asset `json:"assets"` +} + +// Version is the tag without its "v". +func (r *Release) Version() string { return strings.TrimPrefix(r.TagName, "v") } + +func (r *Release) asset(name string) (Asset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// Asset is one release file. +type Asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// Client fetches releases. +type Client struct { + apiURL string + // PublicKey verifies checksums.txt.sig; nil skips the signature check + // (the caller must have been told to, explicitly). + PublicKey ed25519.PublicKey + http *http.Client + // testHost is allowed in addition to allowedHosts (any scheme) when the + // API URL is overridden, which only development builds permit. + testHost string +} + +// New returns a client for apiURL (DefaultAPIURL in production). A different +// URL also becomes an allowed download host; callers only pass one for +// development builds. +func New(apiURL string, pub ed25519.PublicKey) (*Client, error) { + apiURL = strings.TrimRight(apiURL, "/") + c := &Client{apiURL: apiURL, PublicKey: pub} + if apiURL != DefaultAPIURL { + u, err := url.Parse(apiURL) + if err != nil || u.Host == "" { + return nil, fmt.Errorf("invalid update API URL %q", apiURL) + } + c.testHost = u.Host + } + c.http = &http.Client{ + Timeout: 5 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + return c.allowed(req.URL) + }, + } + return c, nil +} + +func (c *Client) allowed(u *url.URL) error { + if c.testHost != "" && u.Host == c.testHost { + return nil + } + if u.Scheme != "https" || !slices.Contains(allowedHosts, u.Hostname()) || u.Port() != "" { + return fmt.Errorf("refusing to download from %s://%s (not a GitHub release host)", u.Scheme, u.Host) + } + return nil +} + +// ParsePublicKey decodes a base64 ed25519 public key. +func ParsePublicKey(s string) (ed25519.PublicKey, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil || len(b) != ed25519.PublicKeySize { + return nil, errors.New("invalid release public key (want base64 of a 32-byte ed25519 key)") + } + return ed25519.PublicKey(b), nil +} + +// Latest returns the newest stable v2 release. The repository's +// releases/latest stays on the v1 agent, so releases are listed instead. +func (c *Client) Latest(ctx context.Context) (*Release, error) { + var releases []Release + if err := c.getJSON(ctx, c.apiURL+"/repos/"+Repo+"/releases?per_page=30", &releases); err != nil { + return nil, err + } + var best *Release + for i := range releases { + r := &releases[i] + if r.Draft || r.Prerelease || !stableTag.MatchString(r.TagName) { + continue + } + if best == nil || version.Compare(r.Version(), best.Version()) > 0 { + best = r + } + } + if best == nil { + return nil, errors.New("no v2 release found") + } + return best, nil +} + +// ByVersion returns the release tagged v (pre-releases included). +func (c *Client) ByVersion(ctx context.Context, ver string) (*Release, error) { + ver = strings.TrimPrefix(strings.TrimSpace(ver), "v") + if v, ok := version.Parse(ver); !ok || v.Major != 2 { + return nil, fmt.Errorf("invalid version %q (want 2.x.y)", ver) + } + var r Release + if err := c.getJSON(ctx, c.apiURL+"/repos/"+Repo+"/releases/tags/v"+url.PathEscape(ver), &r); err != nil { + return nil, err + } + if r.Draft || r.TagName != "v"+ver { + return nil, fmt.Errorf("release v%s not found", ver) + } + return &r, nil +} + +// BinaryName is the release asset for a platform. +func BinaryName(goos, goarch string) string { return "spectate-agent_" + goos + "_" + goarch } + +// Download fetches and verifies the release binary for goos/goarch: +// checksums.txt must be signed by c.PublicKey (unless nil) and the binary +// must match its sha256 there. +func (c *Client) Download(ctx context.Context, r *Release, goos, goarch string) ([]byte, error) { + name := BinaryName(goos, goarch) + binAsset, ok := r.asset(name) + if !ok { + return nil, fmt.Errorf("release %s has no %s", r.TagName, name) + } + sumsAsset, ok := r.asset(ChecksumsName) + if !ok { + return nil, fmt.Errorf("release %s has no %s", r.TagName, ChecksumsName) + } + + sums, err := c.fetch(ctx, sumsAsset.URL, maxSmall) + if err != nil { + return nil, fmt.Errorf("download %s: %w", ChecksumsName, err) + } + if c.PublicKey != nil { + sigAsset, ok := r.asset(SignatureName) + if !ok { + return nil, fmt.Errorf("release %s has no %s: refusing an unsigned release", r.TagName, SignatureName) + } + sig, err := c.fetch(ctx, sigAsset.URL, maxSmall) + if err != nil { + return nil, fmt.Errorf("download %s: %w", SignatureName, err) + } + if err := VerifySignature(c.PublicKey, sums, sig); err != nil { + return nil, err + } + } + want, err := Checksum(sums, name) + if err != nil { + return nil, err + } + + bin, err := c.fetch(ctx, binAsset.URL, maxBinary) + if err != nil { + return nil, fmt.Errorf("download %s: %w", name, err) + } + got := sha256.Sum256(bin) + if !bytes.Equal(got[:], want) { + return nil, fmt.Errorf("checksum mismatch for %s: got %x, checksums.txt says %x", name, got, want) + } + return bin, nil +} + +// VerifySignature checks a base64 ed25519 signature (checksums.txt.sig). +func VerifySignature(pub ed25519.PublicKey, data, sigFile []byte) error { + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigFile))) + if err != nil || len(sig) != ed25519.SignatureSize { + return fmt.Errorf("malformed %s", SignatureName) + } + if !ed25519.Verify(pub, data, sig) { + return fmt.Errorf("%s: signature verification failed (not signed by the release key)", ChecksumsName) + } + return nil +} + +// Checksum returns the sha256 listed for name in a checksums.txt +// (" " lines, as written by sha256sum and goreleaser). +func Checksum(sums []byte, name string) ([]byte, error) { + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { + continue + } + sum, err := hex.DecodeString(fields[0]) + if err != nil || len(sum) != sha256.Size { + return nil, fmt.Errorf("malformed checksum for %s", name) + } + return sum, nil + } + return nil, fmt.Errorf("%s has no checksum for %s", ChecksumsName, name) +} + +// Install atomically replaces target with bin: it writes a temporary file in +// target's directory, fsyncs it, checks that it runs and reports +// wantVersion, then renames it over target. +func Install(bin []byte, target, wantVersion string) (err error) { + dir := filepath.Dir(target) + mode := fs.FileMode(0o755) + if info, err := os.Stat(target); err == nil { + mode = info.Mode().Perm() | 0o111 + } + tmp, err := os.CreateTemp(dir, ".spectate-agent-update-*") + if err != nil { + if errors.Is(err, fs.ErrPermission) { + return fmt.Errorf("cannot write to %s: run as root (sudo spectate-agent update)", dir) + } + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + if _, err := tmp.Write(bin); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + if err := selfTest(tmp.Name(), wantVersion); err != nil { + return err + } + if err := os.Rename(tmp.Name(), target); err != nil { + return err + } + if d, err := os.Open(dir); err == nil { + _ = d.Sync() // persist the rename + _ = d.Close() + } + return nil +} + +// selfTest runs ` version` and expects "spectate-agent ". +func selfTest(bin, wantVersion string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "version").Output() + if err != nil { + return fmt.Errorf("new binary does not run: %w", err) + } + if !strings.HasPrefix(string(out), "spectate-agent "+wantVersion+" ") { + return fmt.Errorf("new binary reports %q, want version %s", strings.TrimSpace(string(out)), wantVersion) + } + return nil +} + +func (c *Client) getJSON(ctx context.Context, rawURL string, out any) error { + data, err := c.get(ctx, rawURL, maxSmall*4, "application/vnd.github+json") + if err != nil { + return err + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode %s: %w", rawURL, err) + } + return nil +} + +func (c *Client) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) { + return c.get(ctx, rawURL, limit, "application/octet-stream") +} + +func (c *Client) get(ctx context.Context, rawURL string, limit int64, accept string) ([]byte, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + if err := c.allowed(u); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("User-Agent", version.UserAgent()) + if strings.HasPrefix(accept, "application/vnd.github") { + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("GET %s: not found", rawURL) + } + return nil, fmt.Errorf("GET %s: HTTP %d", rawURL, resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("GET %s: response larger than %d bytes", rawURL, limit) + } + return data, nil +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..bbe4265 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,265 @@ +package update + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type fakeGitHub struct { + srv *httptest.Server + releases []Release + files map[string][]byte +} + +func newFakeGitHub(t *testing.T) *fakeGitHub { + f := &fakeGitHub{files: map[string][]byte{}} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/spectate/agent/releases": + _ = json.NewEncoder(w).Encode(f.releases) + case strings.HasPrefix(r.URL.Path, "/repos/spectate/agent/releases/tags/"): + tag := strings.TrimPrefix(r.URL.Path, "/repos/spectate/agent/releases/tags/") + for _, rel := range f.releases { + if rel.TagName == tag { + _ = json.NewEncoder(w).Encode(rel) + return + } + } + http.NotFound(w, r) + case strings.HasPrefix(r.URL.Path, "/download/"): + // Like github.com: release downloads redirect to a CDN. + http.Redirect(w, r, "/cdn/"+strings.TrimPrefix(r.URL.Path, "/download/"), http.StatusFound) + case strings.HasPrefix(r.URL.Path, "/cdn/"): + data, ok := f.files[strings.TrimPrefix(r.URL.Path, "/cdn/")] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(data) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(f.srv.Close) + return f +} + +// publish adds a release with a binary, checksums.txt and (when priv is set) +// checksums.txt.sig. +func (f *fakeGitHub) publish(tag string, bin []byte, priv ed25519.PrivateKey) { + name := BinaryName(runtime.GOOS, runtime.GOARCH) + sum := sha256.Sum256(bin) + sums := []byte(fmt.Sprintf("%x %s\n%x install.sh\n", sum, name, sha256.Sum256([]byte("x")))) + rel := Release{TagName: tag} + add := func(file string, data []byte) { + f.files[tag+"/"+file] = data + rel.Assets = append(rel.Assets, Asset{Name: file, URL: f.srv.URL + "/download/" + tag + "/" + file}) + } + add(name, bin) + add(ChecksumsName, sums) + if priv != nil { + add(SignatureName, []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(priv, sums))+"\n")) + } + f.releases = append(f.releases, rel) +} + +func keypair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func TestLatestPicksHighestStableV2(t *testing.T) { + gh := newFakeGitHub(t) + gh.releases = []Release{ + {TagName: "v2.9.0"}, + {TagName: "v2.10.0-rc.1", Prerelease: true}, + {TagName: "v2.11.0", Draft: true}, + {TagName: "v2.10.0"}, + {TagName: "v2.10.1", Prerelease: true}, // flagged as pre-release on GitHub + {TagName: "v1.5.0"}, + {TagName: "v3.0.0"}, + } + c, _ := New(gh.srv.URL, nil) + rel, err := c.Latest(context.Background()) + if err != nil || rel.TagName != "v2.10.0" { + t.Fatalf("latest = %+v, %v", rel, err) + } + + rel, err = c.ByVersion(context.Background(), "2.10.0-rc.1") + if err != nil || rel.TagName != "v2.10.0-rc.1" { + t.Fatalf("by version = %+v, %v", rel, err) + } + if _, err := c.ByVersion(context.Background(), "1.5.0"); err == nil { + t.Fatal("v1 releases must not be installable") + } + if _, err := c.ByVersion(context.Background(), "2.11.0"); err == nil { + t.Fatal("drafts must not be installable") + } + + gh.releases = []Release{{TagName: "v1.5.0"}} + if _, err := c.Latest(context.Background()); err == nil { + t.Fatal("no v2 release must be an error") + } +} + +func TestDownloadVerifiesSignatureAndChecksum(t *testing.T) { + pub, priv := keypair(t) + _, otherPriv := keypair(t) + gh := newFakeGitHub(t) + bin := []byte("new agent binary") + gh.publish("v2.1.0", bin, priv) + gh.publish("v2.2.0", bin, otherPriv) // signed by a different key + gh.publish("v2.3.0", bin, nil) // unsigned + ctx := context.Background() + c, _ := New(gh.srv.URL, pub) + + rel, _ := c.ByVersion(ctx, "2.1.0") + got, err := c.Download(ctx, rel, runtime.GOOS, runtime.GOARCH) + if err != nil || string(got) != string(bin) { + t.Fatalf("download = %q, %v", got, err) + } + + rel, _ = c.ByVersion(ctx, "2.2.0") + if _, err := c.Download(ctx, rel, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("foreign signature: %v", err) + } + rel, _ = c.ByVersion(ctx, "2.3.0") + if _, err := c.Download(ctx, rel, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "unsigned") { + t.Fatalf("unsigned release: %v", err) + } + + // Tampered binary (checksums.txt still validly signed). + gh.files["v2.1.0/"+BinaryName(runtime.GOOS, runtime.GOARCH)] = []byte("evil binary") + rel, _ = c.ByVersion(ctx, "2.1.0") + if _, err := c.Download(ctx, rel, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("tampered binary: %v", err) + } + // Tampered checksums.txt (matches the evil binary, signature no longer does). + sum := sha256.Sum256([]byte("evil binary")) + gh.files["v2.1.0/"+ChecksumsName] = []byte(fmt.Sprintf("%x %s\n", sum, BinaryName(runtime.GOOS, runtime.GOARCH))) + if _, err := c.Download(ctx, rel, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "signature verification failed") { + t.Fatalf("tampered checksums: %v", err) + } + + // Without a key (explicit --insecure-skip-signature) only the checksum protects. + insecure, _ := New(gh.srv.URL, nil) + if _, err := insecure.Download(ctx, rel, runtime.GOOS, runtime.GOARCH); err != nil { + t.Fatalf("insecure download of a consistent release: %v", err) + } + if _, err := insecure.Download(ctx, rel, "plan9", "mips"); err == nil { + t.Fatal("missing platform binary must be an error") + } +} + +func TestOnlyGitHubHostsAreContacted(t *testing.T) { + c, _ := New(DefaultAPIURL, nil) + for raw, ok := range map[string]bool{ + "https://api.github.com/repos/spectate/agent/releases": true, + "https://github.com/spectate/agent/releases/download/v2.1.0/checksums.txt": true, + "https://objects.githubusercontent.com/github-production-release-asset/x": true, + "https://release-assets.githubusercontent.com/github-production-release-asset": true, + "http://github.com/spectate/agent/releases/download/v2.1.0/checksums.txt": false, + "https://github.com:8443/x": false, + "https://evil.example.com/x": false, + "https://github.com.evil.example.com/": false, + "https://user@githubusercontent.com/x": false, + } { + u, _ := url.Parse(raw) + if err := c.allowed(u); (err == nil) != ok { + t.Errorf("allowed(%s) = %v, want ok=%v", raw, err, ok) + } + } + + // A redirect off the allowlist is not followed. + evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://evil.example.com/binary", http.StatusFound) + })) + defer evil.Close() + tc, _ := New(evil.URL, nil) + if _, err := tc.fetch(context.Background(), evil.URL+"/download/x", 10); err == nil || !strings.Contains(err.Error(), "refusing") { + t.Fatalf("redirect to a foreign host: %v", err) + } +} + +func TestChecksumParsing(t *testing.T) { + sums := []byte("" + + "0000000000000000000000000000000000000000000000000000000000000001 spectate-agent_linux_amd64.tar.gz\n" + + "00000000000000000000000000000000000000000000000000000000000000ff spectate-agent_linux_amd64\n") + got, err := Checksum(sums, "spectate-agent_linux_amd64") + if err != nil || got[31] != 0xff { + t.Fatalf("checksum = %x, %v", got, err) + } + if _, err := Checksum(sums, "spectate-agent_linux_arm64"); err == nil { + t.Fatal("missing entry must be an error") + } + if _, err := Checksum([]byte("zz spectate-agent_linux_amd64\n"), "spectate-agent_linux_amd64"); err == nil { + t.Fatal("malformed entry must be an error") + } +} + +func TestInstallReplacesAtomicallyAfterSelfTest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script fixture") + } + dir := t.TempDir() + target := filepath.Join(dir, "spectate-agent") + if err := os.WriteFile(target, []byte("#!/bin/sh\necho 'spectate-agent 2.0.0 (commit a, built b)'\n"), 0o755); err != nil { + t.Fatal(err) + } + good := []byte("#!/bin/sh\necho 'spectate-agent 2.1.0 (commit c, built d)'\n") + + // Wrong version reported: target untouched, temp file removed. + if err := Install(good, target, "2.2.0"); err == nil || !strings.Contains(err.Error(), "want version 2.2.0") { + t.Fatalf("version mismatch: %v", err) + } + // Not runnable at all. + if err := Install([]byte("\x7fELF garbage"), target, "2.1.0"); err == nil { + t.Fatal("a binary that does not run must not be installed") + } + if data, _ := os.ReadFile(target); !strings.Contains(string(data), "2.0.0") { + t.Fatal("target replaced by a failed install") + } + + if err := Install(good, target, "2.1.0"); err != nil { + t.Fatal(err) + } + if data, _ := os.ReadFile(target); string(data) != string(good) { + t.Fatal("target not replaced") + } + if info, _ := os.Stat(target); info.Mode().Perm()&0o111 == 0 { + t.Fatal("installed binary is not executable") + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("temp files left behind: %v", entries) + } +} + +func TestParsePublicKey(t *testing.T) { + pub, _ := keypair(t) + got, err := ParsePublicKey(" " + base64.StdEncoding.EncodeToString(pub) + "\n") + if err != nil || !got.Equal(pub) { + t.Fatalf("parse = %v, %v", got, err) + } + for _, bad := range []string{"", "not base64!", base64.StdEncoding.EncodeToString([]byte("short"))} { + if _, err := ParsePublicKey(bad); err == nil { + t.Errorf("ParsePublicKey(%q) should fail", bad) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index cb04027..2e6a16d 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,7 +1,11 @@ // Package version carries build metadata, set via -ldflags at release time. package version -import "runtime" +import ( + "runtime" + "strconv" + "strings" +) var ( // Version is the agent release (e.g. "2.0.0"). @@ -12,9 +16,116 @@ var ( BuildDate = "unknown" // DefaultAPIURL is the API base URL used when none is configured. DefaultAPIURL = "https://api.spectate.net" + // UpdatePublicKey is the base64 ed25519 public key that signs release + // checksums (`spectate-agent update` verifies checksums.txt.sig with it). + // Empty in development builds. + UpdatePublicKey = "" ) // UserAgent identifies the agent to the API. func UserAgent() string { return "spectate-agent/" + Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")" } + +// IsDev reports whether this is a development build (version "x.y.z-dev…"). +// Release builds never carry "-dev". +func IsDev() bool { return strings.Contains(Version, "-dev") } + +// Semver is a parsed MAJOR.MINOR.PATCH[-PRERELEASE] version (build metadata +// is ignored). +type Semver struct { + Major, Minor, Patch int + Pre string +} + +// Parse parses "2.1.0", "v2.1.0" or "2.1.0-rc.1". +func Parse(s string) (Semver, bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if i := strings.IndexByte(s, '+'); i >= 0 { + s = s[:i] + } + var v Semver + core, pre, hasPre := strings.Cut(s, "-") + if hasPre { + if pre == "" { + return Semver{}, false + } + v.Pre = pre + } + parts := strings.Split(core, ".") + if len(parts) != 3 { + return Semver{}, false + } + nums := [3]*int{&v.Major, &v.Minor, &v.Patch} + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 || p == "" || (len(p) > 1 && p[0] == '0') { + return Semver{}, false + } + *nums[i] = n + } + return v, true +} + +// Compare returns -1, 0 or +1 as a is older than, equal to or newer than b +// (semver precedence; a pre-release is older than its release). Unparsable +// versions sort before parsable ones. +func Compare(a, b string) int { + va, okA := Parse(a) + vb, okB := Parse(b) + switch { + case !okA && !okB: + return 0 + case !okA: + return -1 + case !okB: + return 1 + } + for _, d := range [3]int{va.Major - vb.Major, va.Minor - vb.Minor, va.Patch - vb.Patch} { + if d != 0 { + return sign(d) + } + } + return comparePre(va.Pre, vb.Pre) +} + +func comparePre(a, b string) int { + switch { + case a == b: + return 0 + case a == "": + return 1 + case b == "": + return -1 + } + pa, pb := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + na, errA := strconv.Atoi(pa[i]) + nb, errB := strconv.Atoi(pb[i]) + switch { + case errA == nil && errB == nil: + if na != nb { + return sign(na - nb) + } + case errA == nil: // numeric identifiers sort before alphanumeric ones + return -1 + case errB == nil: + return 1 + default: + if c := strings.Compare(pa[i], pb[i]); c != 0 { + return c + } + } + } + return sign(len(pa) - len(pb)) +} + +func sign(n int) int { + switch { + case n < 0: + return -1 + case n > 0: + return 1 + } + return 0 +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..100e714 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,30 @@ +package version + +import "testing" + +func TestCompare(t *testing.T) { + for _, c := range []struct { + a, b string + want int + }{ + {"2.1.0", "2.0.9", 1}, + {"v2.0.10", "2.0.9", 1}, + {"2.0.0", "2.0.0", 0}, + {"2.0.0-dev", "2.0.0", -1}, + {"2.0.0-rc.2", "2.0.0-rc.10", -1}, + {"2.0.0-rc.1", "2.0.0-beta", 1}, + {"2.0.0-alpha", "2.0.0-alpha.1", -1}, + {"3.0.0", "2.99.99", 1}, + {"2.0.0+build.1", "2.0.0", 0}, + {"garbage", "2.0.0", -1}, + {"2.0", "2.0.0", -1}, + {"02.0.0", "1.0.0", -1}, + } { + if got := Compare(c.a, c.b); got != c.want { + t.Errorf("Compare(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + if got := Compare(c.b, c.a); got != -c.want { + t.Errorf("Compare(%q, %q) = %d, want %d", c.b, c.a, got, -c.want) + } + } +} From 443745c5445d58d0a5f755f19d9aad38f5aa8013 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Mon, 26 Oct 2026 10:50:13 +0100 Subject: [PATCH 3/3] chore: retire v1: stable v2 releases become latest v1 (spectated) is no longer supported, so releases are no longer kept off "latest": install.sh downloads from releases/latest instead of looking up the newest v2 tag through the GitHub API, and releases/latest/download/install.sh is a stable installer URL. install-latest.sh (v1) stays until the dashboard's add-host command uses the v2 installer; it stops working once v2.0.0 is released. --- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 6 ++---- README.md | 25 +++++++++++++------------ install.sh | 18 +++++++----------- internal/update/update.go | 5 +++-- 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb1854f..0b2c5cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ # Publishes a GitHub release for every v2.x.y tag (git tag -s v2.0.0 && git push origin v2.0.0). -# Releases are not marked "latest": that stays on v1 (spectated) for its installers. +# Stable releases become "latest"; v2.x.y-rc.N tags are pre-releases. name: Release on: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8069938..f38fe83 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -74,10 +74,8 @@ release: github: owner: spectate name: agent - # v1 (spectated) installers download releases/latest/download/spectated_*, - # so v1.5.0 must stay the "latest" release until v1 is retired. install.sh - # finds the newest v2 release itself. - make_latest: false + # Stable v2 releases become "latest" (pre-releases never do), so + # releases/latest/download/install.sh always installs the newest agent. prerelease: auto extra_files: - glob: build/install.sh diff --git a/README.md b/README.md index d65aee5..b30a01a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # spectate-agent The Spectate host monitoring agent, protocol v2. It replaces the v1 agent -(`spectated`), which keeps working against the frozen `/agent-api/host/*` -endpoints; see [v1 (spectated)](#v1-spectated). The agent registers with +(`spectated`), which is no longer supported; see [v1 (spectated)](#v1-spectated). +The agent registers with `POST /agent-api/v2/register` and ships metrics to `POST /agent-api/v2/ingest` (gzip + protobuf, schema in [`proto/spectate/agent/v2/ingest.proto`](proto/spectate/agent/v2/ingest.proto)). @@ -74,8 +74,9 @@ Other modes: | `--version 2.0.1` | Installs a specific release | Every release publishes `install.sh` as an asset with that release as its -default version; the copy in this repository installs the newest `v2.x.y` -release (found through the GitHub API, because `releases/latest` stays on v1). +default version; the copy in this repository installs the latest release. +`https://github.com/spectate/agent/releases/latest/download/install.sh` is +therefore always the newest installer. To remove the agent: `sudo spectate-agent uninstall` (also removes the update timer). State in `/var/lib/spectate-agent` is kept. @@ -123,8 +124,8 @@ sudo spectate-agent update # install the newest v2 release and restart sudo spectate-agent update --version 2.1.0 # a specific release (also older or pre-release) ``` -`update` lists the repository's releases through the GitHub API (not -`releases/latest`, which stays on v1) and picks the highest stable `v2.x.y`. It +`update` lists the repository's releases through the GitHub API and picks the +highest stable `v2.x.y`, so a pre-release or an old tag is never installed. It downloads `spectate-agent_linux_`, `checksums.txt` and `checksums.txt.sig`; it verifies the ed25519 signature of `checksums.txt` with the public key compiled into the binary, then the binary's sha256. It only @@ -235,12 +236,12 @@ test suite, which keeps the Go and PHP implementations in lockstep: after ## v1 (spectated) -The v1 agent is frozen and still runs at customers. Its source is the -`v1.5.0` tag (and the earlier `v1.x.y` tags), its releases stay as they are, -and v1.5.0 stays the "latest" release: the v1 installer, `install-latest.sh` -in this repository, downloads -`releases/latest/download/spectated__.tar.gz`. Keep that file -unchanged and do not mark a v2 release as latest until v1 is retired. +The v1 agent is no longer supported. Its source is the `v1.5.0` tag (and the +earlier `v1.x.y` tags); its releases stay on GitHub. v2 releases are marked +"latest", so the v1 installer, `install-latest.sh`, stops working with the first +v2 release: it downloads `releases/latest/download/spectated__.tar.gz`. +It stays in this repository only until the dashboard's add-host command uses +the v2 installer; then delete it. Move a v1 host to v2 with `install.sh --import-legacy`: the host keeps its token, identity and history. diff --git a/install.sh b/install.sh index 93bff75..8eacd82 100755 --- a/install.sh +++ b/install.sh @@ -110,18 +110,14 @@ if [ -n "$LOCAL_BINARY" ]; then cp "$LOCAL_BINARY" "$TMP/spectate-agent" else if [ "$VERSION" = "latest" ]; then - # Not releases/latest: that stays on the v1 agent (spectated) while v1 - # installs are supported. Releases are listed newest first; pre-releases - # (v2.1.0-rc.1) do not match. - VERSION="$(curl -fsSL -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/$REPO/releases?per_page=30" \ - | grep -o '"tag_name": *"v2\.[0-9][0-9]*\.[0-9][0-9]*"' | head -n 1 | sed 's/.*"v\(.*\)"/\1/')" \ - || VERSION="" - [ -n "$VERSION" ] || fail "could not find the latest release; pass --version (see https://github.com/$REPO/releases)" + # The newest stable release (pre-releases are never "latest") + BASE="https://github.com/$REPO/releases/latest/download" + echo "Downloading the latest spectate-agent (linux/$ARCH)..." + else + VERSION="${VERSION#v}" + BASE="https://github.com/$REPO/releases/download/v$VERSION" + echo "Downloading spectate-agent $VERSION (linux/$ARCH)..." fi - VERSION="${VERSION#v}" - BASE="https://github.com/$REPO/releases/download/v$VERSION" - echo "Downloading spectate-agent $VERSION (linux/$ARCH)..." curl -fsSL "$BASE/spectate-agent_linux_$ARCH" -o "$TMP/spectate-agent" curl -fsSL "$BASE/checksums.txt" -o "$TMP/checksums.txt" verify_signature "$TMP" "$BASE" diff --git a/internal/update/update.go b/internal/update/update.go index d193678..bcbd67e 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -141,8 +141,9 @@ func ParsePublicKey(s string) (ed25519.PublicKey, error) { return ed25519.PublicKey(b), nil } -// Latest returns the newest stable v2 release. The repository's -// releases/latest stays on the v1 agent, so releases are listed instead. +// Latest returns the newest stable v2 release. Releases are listed rather than +// asking for releases/latest, so only stable v2.x.y tags are ever installed, +// whatever is marked latest. func (c *Client) Latest(ctx context.Context) (*Release, error) { var releases []Release if err := c.getJSON(ctx, c.apiURL+"/repos/"+Repo+"/releases?per_page=30", &releases); err != nil {