feat: Pulse v6 release

This commit is contained in:
rcourtman 2026-03-18 16:06:30 +00:00
parent 2fe22c3308
commit 778a2577b6
3155 changed files with 594553 additions and 173975 deletions

View file

@ -1,4 +1,4 @@
FROM golang:1.24
FROM golang:1.25.7
# Set bash as default shell for features
SHELL ["/bin/bash", "-c"]

View file

@ -5,7 +5,7 @@ This dev container provides a complete, reproducible development environment for
## What's Included
### Development Tools
- **Go 1.24** - Backend development
- **Go 1.25.7** - Backend development
- **Node.js 20** - Frontend development
- **gopls v0.17.0** - Go language server
- **Delve** - Go debugger
@ -225,7 +225,7 @@ Custom overrides: Create `.env.devcontainer` (gitignored)
## Resources
- **VM Specs**: 8GB RAM, 30GB disk, 2 CPU cores
- **Base Image**: `golang:1.24` (Ubuntu-based)
- **Base Image**: `golang:1.25.7` (Ubuntu-based)
- **Caches**: ~2-3GB for Go modules and build artifacts
## Tips & Tricks
@ -247,7 +247,7 @@ MacBook (VS Code)
dev-containers VM (Proxmox)
↓ Docker
Dev Container
├── Go 1.24 + tools
├── Go 1.25.7 + tools
├── Node 20 + npm
├── Your code (/workspaces/pulse)
├── Hot reload watchers

View file

@ -10,15 +10,12 @@ dev-docs/
# Binaries and build artifacts
pulse
backend
/pulse-host-agent
/pulse-host-agent-*
/pulse-docker-agent
/pulse-agent
/pulse-server
bin/
dist/
frontend/
frontend-modern/public/download/
frontend-modern/public/pulse-host-agent*
frontend-modern/dist/
scripts/macos/dist/
*.exe

View file

@ -46,7 +46,7 @@ body:
attributes:
label: Pulse version
description: Exact version shown in the UI or logs.
placeholder: v5.1.5
placeholder: v6.0.0
validations:
required: true
@ -55,7 +55,7 @@ body:
attributes:
label: Agent version
description: Exact agent version (or "none" if no agents are involved).
placeholder: v5.1.5
placeholder: v6.0.0
validations:
required: true
@ -64,7 +64,7 @@ body:
attributes:
label: Image tag or digest
description: Exact image reference used by the running container.
placeholder: rcourtman/pulse:5.1.5 or rcourtman/pulse@sha256:...
placeholder: rcourtman/pulse:v6.0.0 or rcourtman/pulse@sha256:...
validations:
required: true

View file

@ -17,11 +17,9 @@ Add these secrets to your GitHub repository settings (`Settings` → `Secrets an
2. **DEMO_SERVER_HOST**
- The hostname or IP of the demo server
- Value: `174.138.72.137` (or hostname if using DNS)
3. **DEMO_SERVER_USER**
- The SSH username for the demo server
- Value: `root` (or the appropriate user with sudo access)
- The SSH username for the demo server (e.g. `root` or a deploy user with sudo access)
### How It Works

View file

@ -49,6 +49,14 @@ jobs:
working-directory: frontend-modern
run: npm run lint
- name: Audit header composition
working-directory: frontend-modern
run: npm run lint:headers
- name: Check frontend copy-paste duplication
working-directory: frontend-modern
run: npm run lint:cpd
- name: Frontend unit tests
working-directory: frontend-modern
run: npm run test
@ -60,16 +68,83 @@ jobs:
- name: Build frontend bundle (with embed copy)
run: make frontend
- name: Check frontend bundle size budget
working-directory: frontend-modern
run: npm run check:bundlesize
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Script smoke tests
run: scripts/tests/run.sh
- name: Go unit tests
env:
PULSE_DATA_DIR: /tmp/pulse-test-data
run: go test ./...
run: go test -race -timeout 10m ./...
- name: Go benchmarks
env:
PULSE_DATA_DIR: /tmp/pulse-bench-data
run: |
set -o pipefail
go test -bench=. -benchmem -count=5 -run=^$ -timeout=10m \
./pkg/metrics/ \
./pkg/auth/ \
./internal/api/ \
./internal/monitoring/ \
./internal/unifiedresources/ \
./internal/dockeragent/ \
./cmd/pulse-agent/ \
./internal/hostagent/ \
./internal/hostmetrics/ \
| tee bench-results.txt
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: bench-results
path: bench-results.txt
retention-days: 90
- name: Install benchstat
run: go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260211190930-8161c38c6cdc
- name: Save benchmark baseline (main branch)
if: github.ref == 'refs/heads/main'
run: cp bench-results.txt bench-baseline.txt
- name: Cache benchmark baseline (main branch)
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: bench-baseline.txt
key: go-bench-baseline-${{ github.sha }}
- name: Restore benchmark baseline (PRs)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@v4
with:
path: bench-baseline.txt
key: go-bench-baseline-
restore-keys: go-bench-baseline-
- name: Compare benchmarks against baseline
if: github.event_name == 'pull_request'
run: |
set -eo pipefail
if [ ! -f bench-baseline.txt ]; then
echo "No benchmark baseline found (first PR against main?). Skipping comparison."
exit 0
fi
echo "=== Benchmark comparison (baseline vs current) ==="
benchstat bench-baseline.txt bench-results.txt | tee bench-comparison.txt
echo ""
bash scripts/check-bench-regression.sh bench-comparison.txt
- name: Build Pulse backend
run: go build ./cmd/pulse

View file

@ -0,0 +1,145 @@
name: Canonical Governance
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
governance:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout pulse-pro evidence repo
uses: actions/checkout@v4
with:
repository: rcourtman/pulse-pro
fetch-depth: 1
path: .governance/repos/pulse-pro
- name: Checkout pulse-enterprise evidence repo
uses: actions/checkout@v4
with:
repository: rcourtman/pulse-enterprise
fetch-depth: 1
path: .governance/repos/pulse-enterprise
- name: Checkout pulse-mobile evidence repo
uses: actions/checkout@v4
with:
repository: rcourtman/pulse-mobile
fetch-depth: 1
path: .governance/repos/pulse-mobile
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Determine governance diff range
id: diff
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
range="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
elif [ "${{ github.event_name }}" = "push" ]; then
range="${{ github.event.before }}...${{ github.sha }}"
elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then
range="HEAD^...HEAD"
else
range=""
fi
echo "range=${range}" >> "$GITHUB_OUTPUT"
- name: Run canonical completion guard against changed files
shell: bash
run: |
set -euo pipefail
if [ -n "${{ steps.diff.outputs.range }}" ]; then
git diff --name-only "${{ steps.diff.outputs.range }}" \
| python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin
else
printf '' | python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin
fi
- name: Run status audit
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
run: python3 scripts/release_control/status_audit.py --check
- name: Run control plane audit
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
run: python3 scripts/release_control/control_plane_audit.py --check
- name: Run registry audit
run: python3 scripts/release_control/registry_audit.py --check
- name: Run contract audit
run: python3 scripts/release_control/contract_audit.py --check
- name: Run canonical completion guard unit tests
run: python3 scripts/release_control/canonical_completion_guard_test.py
- name: Run control plane audit unit tests
run: python3 scripts/release_control/control_plane_audit_test.py
- name: Run contract audit unit tests
run: python3 scripts/release_control/contract_audit_test.py
- name: Run staged Go formatter unit tests
run: python3 scripts/release_control/format_staged_go_test.py
- name: Run governance stage guard unit tests
run: python3 scripts/release_control/governance_stage_guard_test.py
- name: Run registry audit unit tests
run: python3 scripts/release_control/registry_audit_test.py
- name: Run repo file IO unit tests
run: python3 scripts/release_control/repo_file_io_test.py
- name: Run release promotion policy unit tests
run: python3 scripts/release_control/release_promotion_policy_test.py
- name: Run status audit unit tests
run: python3 scripts/release_control/status_audit_test.py
- name: Run subsystem contract helper unit tests
run: python3 scripts/release_control/subsystem_contracts_test.py
- name: Run subsystem lookup unit tests
run: python3 scripts/release_control/subsystem_lookup_test.py
- name: Run repo governance guardrail tests
env:
PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}
PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/.governance/repos/pulse-pro
PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/.governance/repos/pulse-enterprise
PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/.governance/repos/pulse-mobile
run: go test ./internal/repoctl -count=1
- name: Run active-target automated readiness assertion proofs
run: python3 scripts/release_control/readiness_assertion_guard.py --active-target --proof-type automated
- name: Run active-target hybrid readiness assertion proofs
run: python3 scripts/release_control/readiness_assertion_guard.py --active-target --proof-type hybrid
- name: Run readiness assertion guard unit tests
run: python3 scripts/release_control/readiness_assertion_guard_test.py

View file

@ -12,6 +12,31 @@ on:
description: 'Release notes (markdown) - generated by Claude'
required: true
type: string
promoted_from_tag:
description: 'Stable only: RC tag being promoted (for example 6.0.0-rc.2)'
required: false
type: string
rollback_version:
description: 'Required: prior stable version to pin for rollback (for example 5.1.14 or v5.1.14)'
required: false
type: string
ga_date:
description: 'First stable v6.0.0 GA only: exact GA publish date (YYYY-MM-DD)'
required: false
type: string
v5_eos_date:
description: 'First stable v6.0.0 GA only: Pulse v5 end-of-support date (YYYY-MM-DD)'
required: false
type: string
hotfix_exception:
description: 'Stable only: bypass the 72-hour RC soak for urgent customer harm'
required: false
type: boolean
default: false
hotfix_reason:
description: 'Stable only: reason for hotfix soak exception'
required: false
type: string
draft_only:
description: 'Create draft release only (do not publish)'
required: false
@ -31,21 +56,25 @@ jobs:
version: ${{ steps.extract.outputs.version }}
tag: ${{ steps.extract.outputs.tag }}
is_prerelease: ${{ steps.extract.outputs.is_prerelease }}
source_branch: ${{ steps.extract.outputs.source_branch }}
required_branch: ${{ steps.branch_policy.outputs.required_branch }}
promoted_from_tag: ${{ steps.promotion.outputs.promoted_from_tag }}
rollback_tag: ${{ steps.promotion.outputs.rollback_tag }}
rollback_command: ${{ steps.promotion.outputs.rollback_command }}
ga_date: ${{ steps.promotion.outputs.ga_date }}
v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }}
hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }}
hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }}
steps:
- name: Extract version
id: extract
run: |
if [ "${{ github.event_name }}" = "push" ]; then
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
else
VERSION=$(jq -r '.inputs.version // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "")
if [ -z "$VERSION" ]; then
echo "::error::workflow_dispatch must include a version input"
exit 1
fi
TAG="v${VERSION}"
VERSION=$(jq -r '.inputs.version // ""' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "")
if [ -z "$VERSION" ]; then
echo "::error::workflow_dispatch must include a version input"
exit 1
fi
TAG="v${VERSION}"
IS_PRERELEASE="false"
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
@ -53,16 +82,38 @@ jobs:
echo "Detected prerelease version: ${VERSION}"
fi
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
echo "::error::Release workflow must be dispatched from a branch ref (current ref: ${GITHUB_REF})."
exit 1
fi
SOURCE_BRANCH="${GITHUB_REF_NAME}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}"
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
sparse-checkout: |
VERSION
docs/release-control/control_plane.json
scripts/release_control/control_plane.py
scripts/release_control/repo_file_io.py
- name: Resolve required release branch
id: branch_policy
run: |
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${{ steps.extract.outputs.version }}")"
if [ "${{ steps.extract.outputs.source_branch }}" != "$REQUIRED_BRANCH" ]; then
echo "::error::Invalid release line. Version ${{ steps.extract.outputs.version }} must run from ${REQUIRED_BRANCH}, but workflow ref is ${{ steps.extract.outputs.source_branch }}."
exit 1
fi
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}"
- name: Validate VERSION file
run: |
@ -75,6 +126,49 @@ jobs:
fi
echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)"
- name: Validate promotion policy
id: promotion
env:
VERSION: ${{ steps.extract.outputs.version }}
TAG: ${{ steps.extract.outputs.tag }}
IS_PRERELEASE: ${{ steps.extract.outputs.is_prerelease }}
PROMOTED_FROM_TAG_INPUT: ${{ github.event.inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ github.event.inputs.rollback_version }}
GA_DATE_INPUT: ${{ github.event.inputs.ga_date }}
V5_EOS_DATE_INPUT: ${{ github.event.inputs.v5_eos_date }}
HOTFIX_EXCEPTION_INPUT: ${{ github.event.inputs.hotfix_exception }}
HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }}
run: |
set -euo pipefail
git fetch --prune origin main pulse/v6 --tags
RELEASE_NOTES_INPUT="$(jq -r '.inputs.release_notes // ""' "$GITHUB_EVENT_PATH")"
NOTES_FILE="$(mktemp)"
printf '%s\n' "$RELEASE_NOTES_INPUT" > "$NOTES_FILE"
HELPER_ARGS=(
--version "${VERSION}"
--promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}"
--rollback-version "${ROLLBACK_VERSION_INPUT:-}"
--ga-date "${GA_DATE_INPUT:-}"
--v5-eos-date "${V5_EOS_DATE_INPUT:-}"
--hotfix-reason "${HOTFIX_REASON_INPUT:-}"
--release-notes-file "$NOTES_FILE"
)
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(--hotfix-exception)
fi
python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out"
rm -f "$NOTES_FILE"
{
cat "$RUNNER_TEMP/promotion-metadata.out"
} >> "$GITHUB_OUTPUT"
echo "[OK] Promotion policy validated for ${TAG}"
# Frontend checks run in parallel with backend tests
frontend_checks:
needs: prepare
@ -97,6 +191,12 @@ jobs:
- name: Lint frontend
run: npm --prefix frontend-modern run lint
- name: Audit header composition
run: npm --prefix frontend-modern run lint:headers
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
# Backend tests run in parallel with frontend checks
backend_tests:
needs: prepare
@ -137,7 +237,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25.7'
cache: true
- name: Run backend tests
@ -186,7 +286,7 @@ jobs:
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
VERSION=${{ needs.prepare.outputs.tag }}
- name: Build Docker agent image (verify only)
- name: Build Pulse agent image (verify only)
uses: docker/build-push-action@v6
with:
context: .
@ -195,8 +295,8 @@ jobs:
platforms: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: false
provenance: false
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max
build-args: |
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
VERSION=${{ needs.prepare.outputs.tag }}
@ -208,7 +308,7 @@ jobs:
- backend_tests
if: ${{ needs.prepare.outputs.is_prerelease != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 45
env:
FRONTEND_DIST: frontend-modern/dist
steps:
@ -237,7 +337,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25.7'
cache: true
- name: Build Pulse Docker image for integration tests
@ -246,6 +346,12 @@ jobs:
- name: Build mock GitHub server
run: docker build -t pulse-mock-github:test tests/integration/mock-github-server
- name: Install integration test dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Run integration tests
working-directory: tests/integration
env:
@ -253,6 +359,9 @@ jobs:
MOCK_NETWORK_ERROR: "false"
MOCK_RATE_LIMIT: "false"
MOCK_STALE_RELEASE: "false"
PULSE_MULTI_TENANT_ENABLED: "true"
PULSE_E2E_ENTITLEMENT_PROFILE: "multi-tenant"
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
run: |
docker compose -f docker-compose.test.yml up -d
@ -271,7 +380,24 @@ jobs:
sleep 2
done
UPDATE_API_BASE_URL=http://localhost:7655 go test ../../tests/integration/api -run TestUpdateFlowIntegration -count=1
node scripts/apply-entitlement-profile.mjs
echo "Running update API route smoke check..."
STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true)
echo "Update status endpoint returned HTTP ${STATUS}"
case "${STATUS}" in
200|401|403)
;;
*)
echo "Unexpected response from /api/updates/status"
cat /tmp/update-status.json || true
exit 1
;;
esac
echo "Running multi-tenant E2E suite..."
npx playwright test tests/03-multi-tenant.spec.ts --project=chromium --reporter=list
docker compose -f docker-compose.test.yml down -v
- name: Cleanup
@ -307,7 +433,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25.7'
cache: true
- name: Set up Node.js
@ -371,6 +497,28 @@ jobs:
echo "Update your \`docker-compose.yml\` to use \`rcourtman/pulse:${VERSION}\`"
echo ""
echo "See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions."
echo ""
echo "## Promotion Metadata"
echo ""
echo "- Promotion channel: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}"
echo "- Candidate stable tag: ${{ needs.prepare.outputs.tag }}"
if [ -n "${{ needs.prepare.outputs.promoted_from_tag }}" ]; then
echo "- Promoted RC tag: ${{ needs.prepare.outputs.promoted_from_tag }}"
else
echo "- Promoted RC tag: n/a"
fi
echo "- Rollback target: ${{ needs.prepare.outputs.rollback_tag }}"
echo "- Rollback command: \`${{ needs.prepare.outputs.rollback_command }}\`"
if [ -n "${{ needs.prepare.outputs.ga_date }}" ]; then
echo "- Planned GA date: ${{ needs.prepare.outputs.ga_date }}"
fi
if [ -n "${{ needs.prepare.outputs.v5_eos_date }}" ]; then
echo "- Planned v5 end-of-support date: ${{ needs.prepare.outputs.v5_eos_date }}"
fi
echo "- Hotfix exception: ${{ needs.prepare.outputs.hotfix_exception }}"
if [ -n "${{ needs.prepare.outputs.hotfix_reason }}" ]; then
echo "- Hotfix reason: ${{ needs.prepare.outputs.hotfix_reason }}"
fi
} >> "$NOTES_FILE"
echo "notes_file=${NOTES_FILE}" >> $GITHUB_OUTPUT
@ -466,7 +614,25 @@ jobs:
if ls release/*.tgz 1> /dev/null 2>&1; then
gh release upload "${TAG}" release/*.tgz --clobber
fi
for bare_agent in \
release/pulse-agent-linux-amd64 \
release/pulse-agent-linux-arm64 \
release/pulse-agent-linux-armv7 \
release/pulse-agent-linux-armv6 \
release/pulse-agent-linux-386 \
release/pulse-agent-freebsd-amd64 \
release/pulse-agent-freebsd-arm64 \
release/pulse-agent-windows-amd64.exe \
release/pulse-agent-windows-arm64.exe \
release/pulse-agent-windows-386.exe; do
if [ -f "${bare_agent}" ]; then
gh release upload "${TAG}" "${bare_agent}" --clobber
fi
done
gh release upload "${TAG}" release/install.sh --clobber
if [ -f release/install.ps1 ]; then
gh release upload "${TAG}" release/install.ps1 --clobber
fi
gh release upload "${TAG}" release/install-docker.sh --clobber
gh release upload "${TAG}" release/pulse-auto-update.sh --clobber
@ -503,7 +669,7 @@ jobs:
echo "[OK] Docker publish workflow dispatched"
- name: Trigger demo server update
if: ${{ github.event.inputs.draft_only != 'true' }}
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
@ -511,6 +677,10 @@ jobs:
gh workflow run update-demo-server.yml -f tag="${{ needs.prepare.outputs.tag }}"
echo "[OK] Demo server update dispatched"
- name: Skip demo server update for prerelease
if: ${{ github.event.inputs.draft_only != 'true' && needs.prepare.outputs.is_prerelease == 'true' }}
run: echo "Skipping demo server update for prerelease tag ${{ needs.prepare.outputs.tag }}"
- name: Summary
run: |
echo "[SUCCESS] Release published!"

View file

@ -14,7 +14,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25.7'
cache: true
- name: Set up Node.js
@ -47,7 +47,17 @@ jobs:
cp -r frontend-modern/dist internal/api/frontend-modern/
- name: Build static binary
run: CGO_ENABLED=0 go build -ldflags="-s -w" -o pulse ./cmd/pulse/
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
run: |
VERSION="v$(cat VERSION | tr -d '\n')"
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
SERVER_LDFLAGS="$(if [ -n "${PULSE_LICENSE_PUBLIC_KEY:-}" ]; then ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${PULSE_LICENSE_PUBLIC_KEY}"; else ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"; fi)"
CGO_ENABLED=0 go build \
-ldflags="${SERVER_LDFLAGS}" \
-trimpath \
-o pulse ./cmd/pulse/
- name: Tailscale
uses: tailscale/github-action@v2
@ -57,24 +67,33 @@ jobs:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.DEMO_SERVER_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.DEMO_SERVER_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
ssh-keyscan -H ${{ secrets.DEMO_SERVER_HOST }} >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Deploy to server
env:
DEPLOY_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEPLOY_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
SSH_OPTS=(
-i ~/.ssh/id_ed25519
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile=~/.ssh/known_hosts
)
# Clean up any stale files from previous runs
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} "rm -f /tmp/pulse-new; rm -rf /tmp/pulse-demo-test" || true
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "rm -f /tmp/pulse-new; rm -rf /tmp/pulse-demo-test" || true
# Upload new binary
scp -o StrictHostKeyChecking=no pulse ${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/pulse-new
scp "${SSH_OPTS[@]}" pulse "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/pulse-new"
# Health check: Run on test port 8082 and verify
# We use a subshell to background the process and then kill it after the check
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} "
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "
chmod +x /tmp/pulse-new &&
PULSE_DATA_DIR=/tmp/pulse-demo-test \
FRONTEND_PORT=8082 \
@ -99,7 +118,7 @@ jobs:
"
# Swap and restart production service
ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} "
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "
sudo systemctl stop pulse &&
sudo mv /tmp/pulse-new /opt/pulse/bin/pulse &&
sudo systemctl start pulse
@ -109,3 +128,7 @@ jobs:
run: |
sleep 5
curl -f https://demo.pulserelay.pro/api/health
- name: Cleanup SSH material
if: always()
run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts

View file

@ -64,7 +64,7 @@ jobs:
const headingMatch = body.match(/#+\s*Pulse version[\s\S]{0,80}?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i);
if (headingMatch) return normalizeVersion(headingMatch[1]);
// Final fallback for older templates with "Pulse | Version: [5.1.2]".
// Final fallback for older templates with "Pulse | Version: [6.0.0]".
const legacyMatch = body.match(/pulse\s*\|?\s*version[^\n]*?(\bv?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b)/i);
if (legacyMatch) return normalizeVersion(legacyMatch[1]);

View file

@ -9,7 +9,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., v5.0.0)'
description: 'Release tag (e.g., v6.0.0)'
required: true
type: string
prerelease:
@ -71,6 +71,66 @@ jobs:
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
echo "Promoting floating tags for ${TAG} (prerelease: ${PRERELEASE})"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Validate release line policy
env:
TAG: ${{ steps.extract.outputs.tag }}
PRERELEASE: ${{ steps.extract.outputs.prerelease }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} does not exist in repository tags."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing floating-tag promotion."
exit 1
fi
if [ "$PRERELEASE" != "true" ]; then
BASE_VERSION="${TAG#v}"
BASE_VERSION="${BASE_VERSION%%-*}"
mapfile -t RC_TAGS < <(git tag -l "v${BASE_VERSION}-rc.*" --sort=-version:refname)
if [ "${#RC_TAGS[@]}" -eq 0 ]; then
echo "::error::Stable tag ${TAG} has no matching RC tags for base version ${BASE_VERSION}."
exit 1
fi
MATCHED_RC=""
for rc_tag in "${RC_TAGS[@]}"; do
rc_commit="$(git rev-list -n1 "refs/tags/${rc_tag}")"
if git merge-base --is-ancestor "$rc_commit" "$TAG_COMMIT"; then
MATCHED_RC="$rc_tag"
break
fi
done
if [ -z "$MATCHED_RC" ]; then
echo "::error::Stable tag ${TAG} does not descend from any matching RC tag for base version ${BASE_VERSION}."
exit 1
fi
echo "[OK] ${TAG} descends from prerelease ${MATCHED_RC}"
fi
echo "[OK] ${TAG} validated against release line ${REQUIRED_BRANCH}"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
@ -108,11 +168,11 @@ jobs:
exit 1
fi
# Also wait for agent image
# Also wait for unified agent image
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
if docker manifest inspect rcourtman/pulse-docker-agent:${TAG} > /dev/null 2>&1; then
echo "Image rcourtman/pulse-docker-agent:${TAG} is available!"
if docker manifest inspect rcourtman/pulse-agent:${TAG} > /dev/null 2>&1; then
echo "Image rcourtman/pulse-agent:${TAG} is available!"
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
@ -158,7 +218,7 @@ jobs:
ghcr.io/${OWNER}/pulse:${TAG}
fi
- name: Promote Docker agent image tags
- name: Promote Pulse agent image tags
env:
TAG: ${{ steps.extract.outputs.tag }}
PRERELEASE: ${{ steps.extract.outputs.prerelease }}
@ -173,22 +233,22 @@ jobs:
if [ "$PRERELEASE" = "true" ]; then
docker buildx imagetools create \
-t rcourtman/pulse-docker-agent:rc \
rcourtman/pulse-docker-agent:${TAG}
-t rcourtman/pulse-agent:rc \
rcourtman/pulse-agent:${TAG}
docker buildx imagetools create \
-t ghcr.io/${OWNER}/pulse-docker-agent:rc \
ghcr.io/${OWNER}/pulse-docker-agent:${TAG}
-t ghcr.io/${OWNER}/pulse-agent:rc \
ghcr.io/${OWNER}/pulse-agent:${TAG}
else
docker buildx imagetools create \
-t rcourtman/pulse-docker-agent:latest \
-t rcourtman/pulse-docker-agent:${MAJOR_MINOR} \
-t rcourtman/pulse-docker-agent:${MAJOR} \
rcourtman/pulse-docker-agent:${TAG}
-t rcourtman/pulse-agent:latest \
-t rcourtman/pulse-agent:${MAJOR_MINOR} \
-t rcourtman/pulse-agent:${MAJOR} \
rcourtman/pulse-agent:${TAG}
docker buildx imagetools create \
-t ghcr.io/${OWNER}/pulse-docker-agent:latest \
-t ghcr.io/${OWNER}/pulse-docker-agent:${MAJOR_MINOR} \
-t ghcr.io/${OWNER}/pulse-docker-agent:${MAJOR} \
ghcr.io/${OWNER}/pulse-docker-agent:${TAG}
-t ghcr.io/${OWNER}/pulse-agent:latest \
-t ghcr.io/${OWNER}/pulse-agent:${MAJOR_MINOR} \
-t ghcr.io/${OWNER}/pulse-agent:${MAJOR} \
ghcr.io/${OWNER}/pulse-agent:${TAG}
fi
- name: Promotion summary

View file

@ -28,6 +28,8 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Extract version from release tag
id: version
@ -47,6 +49,60 @@ jobs:
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
echo "Publishing Docker images for ${TAG} (prerelease: ${IS_PRERELEASE})"
- name: Validate release line policy
env:
TAG: ${{ steps.version.outputs.tag }}
IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} does not exist in repository tags."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Docker publish."
exit 1
fi
if [ "$IS_PRERELEASE" != "true" ]; then
BASE_VERSION="${TAG#v}"
BASE_VERSION="${BASE_VERSION%%-*}"
mapfile -t RC_TAGS < <(git tag -l "v${BASE_VERSION}-rc.*" --sort=-version:refname)
if [ "${#RC_TAGS[@]}" -eq 0 ]; then
echo "::error::Stable tag ${TAG} has no matching RC tags for base version ${BASE_VERSION}."
exit 1
fi
MATCHED_RC=""
for rc_tag in "${RC_TAGS[@]}"; do
rc_commit="$(git rev-list -n1 "refs/tags/${rc_tag}")"
if git merge-base --is-ancestor "$rc_commit" "$TAG_COMMIT"; then
MATCHED_RC="$rc_tag"
break
fi
done
if [ -z "$MATCHED_RC" ]; then
echo "::error::Stable tag ${TAG} does not descend from any matching RC tag for base version ${BASE_VERSION}."
exit 1
fi
echo "[OK] ${TAG} descends from prerelease ${MATCHED_RC}"
fi
echo "[OK] ${TAG} validated against release line ${REQUIRED_BRANCH}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -86,7 +142,7 @@ jobs:
ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.version }}
${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse:latest', github.repository_owner) || '' }}
- name: Build and push Pulse Docker agent image (multi-arch)
- name: Build and push Pulse agent image (multi-arch)
uses: docker/build-push-action@v6
with:
context: .
@ -95,17 +151,17 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
provenance: false
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache
build-args: |
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
VERSION=${{ steps.version.outputs.tag }}
tags: |
rcourtman/pulse-docker-agent:${{ steps.version.outputs.tag }}
rcourtman/pulse-docker-agent:${{ steps.version.outputs.version }}
${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse-docker-agent:latest' || '' }}
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:${{ steps.version.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:${{ steps.version.outputs.version }}
${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse-docker-agent:latest', github.repository_owner) || '' }}
rcourtman/pulse-agent:${{ steps.version.outputs.tag }}
rcourtman/pulse-agent:${{ steps.version.outputs.version }}
${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse-agent:latest' || '' }}
ghcr.io/${{ github.repository_owner }}/pulse-agent:${{ steps.version.outputs.tag }}
ghcr.io/${{ github.repository_owner }}/pulse-agent:${{ steps.version.outputs.version }}
${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse-agent:latest', github.repository_owner) || '' }}
- name: Output image information
run: |
@ -120,10 +176,10 @@ jobs:
fi
echo ""
echo "Agent images (linux/amd64, linux/arm64):"
echo " - rcourtman/pulse-docker-agent:${{ steps.version.outputs.tag }}"
echo " - rcourtman/pulse-docker-agent:${{ steps.version.outputs.version }}"
echo " - rcourtman/pulse-agent:${{ steps.version.outputs.tag }}"
echo " - rcourtman/pulse-agent:${{ steps.version.outputs.version }}"
if [ "$IS_PRERELEASE" != "true" ]; then
echo " - rcourtman/pulse-docker-agent:latest"
echo " - rcourtman/pulse-agent:latest"
fi
echo ""
if [ "$IS_PRERELEASE" = "true" ]; then

View file

@ -22,6 +22,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Helm
uses: azure/setup-helm@v4
@ -41,7 +44,7 @@ jobs:
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$CHART_VERSION"
fi
RELEASE_TAG="$CHART_VERSION"
RELEASE_TAG="v${CHART_VERSION}"
else
RELEASE_TAG="${{ github.event.release.tag_name }}"
if [ -z "$RELEASE_TAG" ]; then
@ -52,9 +55,42 @@ jobs:
APP_VERSION="$CHART_VERSION"
fi
IS_PRERELEASE="false"
if [[ "$APP_VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$APP_VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$APP_VERSION" =~ -beta\.[0-9]+$ ]]; then
IS_PRERELEASE="true"
fi
echo "chart_version=$CHART_VERSION" >> "$GITHUB_OUTPUT"
echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
- name: Validate release line policy
env:
RELEASE_TAG: ${{ steps.versions.outputs.release_tag }}
APP_VERSION: ${{ steps.versions.outputs.app_version }}
run: |
set -euo pipefail
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${APP_VERSION}")"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then
echo "::error::Tag ${RELEASE_TAG} does not exist. Helm publish must map to a real Git tag."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${RELEASE_TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${RELEASE_TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Helm publish."
exit 1
fi
echo "[OK] ${RELEASE_TAG} validated against release line ${REQUIRED_BRANCH}"
- name: Helm lint (strict)
run: helm lint deploy/helm/pulse --strict

View file

@ -3,6 +3,35 @@ name: Release Dry Run
on:
workflow_dispatch:
inputs:
version:
description: 'Optional version under rehearsal (e.g. 6.0.0-rc.2 or 6.0.0)'
required: false
type: string
promoted_from_tag:
description: 'Stable rehearsal only: RC tag being promoted (for example v6.0.0-rc.2)'
required: false
type: string
rollback_version:
description: 'Optional rollback stable version to rehearse (for example 5.1.14 or v5.1.14)'
required: false
type: string
ga_date:
description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)'
required: false
type: string
v5_eos_date:
description: 'Stable v6.0.0 rehearsal only: Pulse v5 end-of-support date (YYYY-MM-DD)'
required: false
type: string
hotfix_exception:
description: 'Stable rehearsal only: bypass 72-hour RC soak for urgent customer harm'
required: false
type: boolean
default: false
hotfix_reason:
description: 'Stable rehearsal only: reason for hotfix soak exception'
required: false
type: string
note:
description: 'Optional note/reason for the dry run'
required: false
@ -16,10 +45,108 @@ jobs:
permissions:
contents: read
packages: read
outputs:
version: ${{ steps.rehearsal.outputs.version }}
tag: ${{ steps.rehearsal.outputs.tag }}
is_prerelease: ${{ steps.rehearsal.outputs.is_prerelease }}
promoted_from_tag: ${{ steps.rehearsal.outputs.promoted_from_tag }}
rollback_tag: ${{ steps.rehearsal.outputs.rollback_tag }}
rollback_command: ${{ steps.rehearsal.outputs.rollback_command }}
ga_date: ${{ steps.rehearsal.outputs.ga_date }}
v5_eos_date: ${{ steps.rehearsal.outputs.v5_eos_date }}
soak_hours: ${{ steps.rehearsal.outputs.soak_hours }}
hotfix_exception: ${{ steps.rehearsal.outputs.hotfix_exception }}
hotfix_reason: ${{ steps.rehearsal.outputs.hotfix_reason }}
steps:
- name: Validate release ref
run: |
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
echo "::error::Release dry run must be executed from a branch ref. Current ref: ${GITHUB_REF}"
exit 1
fi
echo "[OK] Dry run executing on branch ${GITHUB_REF_NAME}"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve required release branch
id: branch_policy
env:
VERSION_INPUT: ${{ inputs.version }}
run: |
VERSION="${VERSION_INPUT:-}"
if [ -z "$VERSION" ]; then
VERSION="$(tr -d '\r\n' < VERSION)"
fi
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
echo "[OK] Governed release branch for ${VERSION} is ${REQUIRED_BRANCH}"
- name: Resolve rehearsal metadata
id: rehearsal
env:
VERSION_INPUT: ${{ inputs.version }}
PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }}
GA_DATE_INPUT: ${{ inputs.ga_date }}
V5_EOS_DATE_INPUT: ${{ inputs.v5_eos_date }}
HOTFIX_EXCEPTION_INPUT: ${{ inputs.hotfix_exception }}
HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }}
run: |
set -euo pipefail
VERSION="${VERSION_INPUT:-}"
if [ -z "$VERSION" ]; then
VERSION="$(tr -d '\r\n' < VERSION)"
fi
TAG="v${VERSION}"
IS_PRERELEASE="false"
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
IS_PRERELEASE="true"
fi
REQUIRED_BRANCH="${{ steps.branch_policy.outputs.required_branch }}"
if [ "${GITHUB_REF_NAME}" != "$REQUIRED_BRANCH" ]; then
echo "::error::Rehearsal version ${VERSION} requires branch ${REQUIRED_BRANCH}, but workflow ran on ${GITHUB_REF_NAME}."
exit 1
fi
FILE_VERSION="$(tr -d '\r\n' < VERSION)"
if [ "$FILE_VERSION" != "$VERSION" ]; then
echo "::error::VERSION file (${FILE_VERSION}) does not match rehearsal version (${VERSION})."
exit 1
fi
git fetch --prune origin main pulse/v6 --tags
HELPER_ARGS=(
--version "${VERSION}"
--promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}"
--rollback-version "${ROLLBACK_VERSION_INPUT:-}"
--ga-date "${GA_DATE_INPUT:-}"
--v5-eos-date "${V5_EOS_DATE_INPUT:-}"
--hotfix-reason "${HOTFIX_REASON_INPUT:-}"
)
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(--hotfix-exception)
fi
python3 scripts/release_control/resolve_release_promotion.py \
"${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out"
{
echo "version=${VERSION}"
echo "tag=${TAG}"
echo "is_prerelease=${IS_PRERELEASE}"
cat "$RUNNER_TEMP/rehearsal-metadata.out"
} >> "$GITHUB_OUTPUT"
echo "[OK] Rehearsal metadata validated for ${TAG}"
- name: Set up Node.js
uses: actions/setup-node@v4
@ -41,6 +168,9 @@ jobs:
- name: Lint frontend
run: npm --prefix frontend-modern run lint
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
- name: Install docker-compose
run: |
sudo apt-get update
@ -76,7 +206,7 @@ jobs:
- name: Build Docker images for integration tests
run: |
VERSION="v$(cat VERSION | tr -d '\n')"
docker build -t pulse:test --target runtime .
docker build -t pulse-mock-github:test tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
@ -97,8 +227,18 @@ jobs:
echo "Running Playwright diagnostics..."
npx playwright test tests/00-diagnostic.spec.ts --reporter=list
echo "Running API-level update integration test..."
UPDATE_API_BASE_URL=http://localhost:7655 go test ../../tests/integration/api -run TestUpdateFlowIntegration -count=1
echo "Running update API route smoke check..."
STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true)
echo "Update status endpoint returned HTTP ${STATUS}"
case "${STATUS}" in
200|401|403)
;;
*)
echo "Unexpected response from /api/updates/status"
cat /tmp/update-status.json || true
exit 1
;;
esac
docker compose -f docker-compose.test.yml down -v
@ -106,3 +246,60 @@ jobs:
if: always()
working-directory: tests/integration
run: docker compose -f docker-compose.test.yml down -v || true
- name: Write rehearsal summary
if: always()
env:
NOTE: ${{ inputs.note }}
run: |
mkdir -p release-dry-run
SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md"
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
{
echo "# RC-to-GA Rehearsal Summary"
echo ""
echo "- Workflow run: ${RUN_URL}"
echo "- Branch: ${GITHUB_REF_NAME}"
echo "- Version: ${{ steps.rehearsal.outputs.version }}"
echo "- Candidate stable tag: ${{ steps.rehearsal.outputs.tag }}"
echo "- Promotion channel: ${{ steps.rehearsal.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}"
if [ -n "${{ steps.rehearsal.outputs.promoted_from_tag }}" ]; then
echo "- Promoted RC tag: ${{ steps.rehearsal.outputs.promoted_from_tag }}"
fi
if [ -n "${{ steps.rehearsal.outputs.rollback_tag }}" ]; then
echo "- Rollback target: ${{ steps.rehearsal.outputs.rollback_tag }}"
fi
if [ -n "${{ steps.rehearsal.outputs.rollback_command }}" ]; then
echo "- Rollback command: \`${{ steps.rehearsal.outputs.rollback_command }}\`"
fi
if [ -n "${{ steps.rehearsal.outputs.soak_hours }}" ]; then
echo "- RC soak hours at rehearsal time: ${{ steps.rehearsal.outputs.soak_hours }}"
fi
if [ -n "${{ steps.rehearsal.outputs.ga_date }}" ]; then
echo "- Planned GA date: ${{ steps.rehearsal.outputs.ga_date }}"
fi
if [ -n "${{ steps.rehearsal.outputs.v5_eos_date }}" ]; then
echo "- Planned v5 end-of-support date: ${{ steps.rehearsal.outputs.v5_eos_date }}"
fi
echo "- Hotfix exception: ${{ steps.rehearsal.outputs.hotfix_exception }}"
if [ -n "${{ steps.rehearsal.outputs.hotfix_reason }}" ]; then
echo "- Hotfix reason: ${{ steps.rehearsal.outputs.hotfix_reason }}"
fi
if [ -n "${NOTE}" ]; then
echo "- Operator note: ${NOTE}"
fi
echo ""
echo "## Result"
echo ""
echo "This run exercised the non-publish release path and validated the current promotion contract on the selected branch."
echo "Record this run URL in the release ticket when clearing \`rc-to-ga-promotion-readiness\`."
} > "$SUMMARY_FILE"
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
- name: Upload rehearsal summary artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: rc-to-ga-rehearsal-summary
path: release-dry-run/rc-to-ga-rehearsal-summary.md

View file

@ -0,0 +1,40 @@
name: Repo Boundary Audit
on:
pull_request:
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Boundary Audit (report mode)
run: |
./scripts/audit-private-boundary.sh | tee repo-boundary-audit.txt
- name: Enforce API Import Boundary
run: ./scripts/audit-private-boundary.sh --enforce-api-imports
- name: Enforce API Root Import Boundary
run: ./scripts/audit-private-boundary.sh --enforce-api-root-imports
- name: Enforce Non-API Import Boundary
run: ./scripts/audit-private-boundary.sh --enforce-nonapi-imports
- name: Enforce API pkg/licensing Bridge Boundary
run: ./scripts/audit-private-boundary.sh --enforce-api-pkg-licensing-imports
- name: Enforce Paid Surface Allowlist Integrity
run: ./scripts/audit-private-boundary.sh --enforce-paid-surface-allowlist
- name: Enforce No Paid-Domain Leaks Outside Allowlist
run: ./scripts/audit-private-boundary.sh --enforce
- name: Upload Audit Report
uses: actions/upload-artifact@v4
with:
name: repo-boundary-audit
path: repo-boundary-audit.txt

View file

@ -29,9 +29,6 @@ jobs:
name: Playwright Core E2E
runs-on: ubuntu-latest
timeout-minutes: 45
# E2E tests are smoke tests - they run but don't block merges
# This reduces friction from flaky tests while maintaining visibility
continue-on-error: true
steps:
- name: Checkout code
@ -61,6 +58,7 @@ jobs:
env:
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true"
PULSE_MULTI_TENANT_ENABLED: "true"
run: node scripts/pretest.mjs
- name: Run E2E suite
@ -69,6 +67,7 @@ jobs:
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
PULSE_E2E_SKIP_DOCKER: "true"
PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL: "true"
PULSE_E2E_PERF: "1"
run: npm test
- name: Collect container logs

View file

@ -27,6 +27,42 @@ jobs:
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Validate stable release line policy
run: |
set -euo pipefail
TAG="${{ steps.target.outputs.tag }}"
VERSION="${TAG#v}"
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
echo "::error::Demo server updates only support stable releases. Refusing prerelease tag ${TAG}."
exit 1
fi
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} does not exist in repository tags."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing demo deployment."
exit 1
fi
echo "[OK] ${TAG} validated for stable demo deployment on ${REQUIRED_BRANCH}"
- name: Skip if not latest published release
id: gate
if: github.event_name == 'release'

83
.gitignore vendored
View file

@ -1,14 +1,13 @@
# Binaries
/bin/
/pulse
/pulse-docker-agent
/pulse-sensor-proxy
/pulse-server
/pulse-test
/pulse-host-agent
/pulse-host-agent-*
/pulse-agent
/pulse-agent-*
/pulse-linux-amd64
/pulse-control-plane-linux-amd64
# Logs
*.log
@ -67,7 +66,6 @@ dist/
build/
*.tar.gz
pulse-fixes*.tar.gz
release/pulse-host-agent-*.tar.gz
scripts/macos/dist/
# Frontend copy for embedding (generated during build)
@ -87,7 +85,6 @@ AI_DEVELOPMENT.md
scripts/pulse-watchdog.sh
pulse-watchdog.log
.mcp-servers/
.mcp.json
.codex/
# Release process files
@ -104,10 +101,15 @@ DOCKER_PUSH_INSTRUCTIONS.md
# Testing and temporary files
testing-tools/*
!testing-tools/run_adaptive_soak.sh
manual-test*.md
verify-*.md
test-*.md
test-results/
frontend-modern/test-results/
playwright-report/
frontend-modern/playwright-report/
tests/integration/eval-results/
tests/integration/debug-stripe.png
*.test.js
*.test.md
@ -115,7 +117,8 @@ screenshots/
.devdata/
test-*.js
test-*.sh
!scripts/tests/test-sensor-proxy-http.sh
!scripts/tests/test-*.sh
!scripts/tests/integration/test-*.sh
test-*.html
*.backup.*
.env.dev
@ -127,10 +130,13 @@ tmp/
# Master plan documents (local only)
PULSE_V4_ISSUES_MASTER_PLAN.md
MONETIZATION.md
FIX_SUMMARY_*.md
docs/*_PLAN.md
docs/*_ROADMAP.md
docs/*IMPLEMENTATION*.md
docs/settings-audit-*.md
docs/settings-content-audit-*.md
# Development documentation
TYPING_*.md
@ -149,11 +155,15 @@ mock.env.backup
# Claude Code Safety Hooks (local only)
.claudecode-settings.json
.claudecode-hooks/
.validation_marker
.session_dirty_*
.session_validated_*
# Sensitive files - DO NOT COMMIT
secrets.env
*secret*.env
docs/PULSE_PRO_IMPLEMENTATION.md
.encryption.key
# Browser/session artifacts
**/cookies.txt
@ -173,9 +183,57 @@ docs/development/
tmp_*.py
tmp_*.sh
# Local agent directories
# Local agent directories and internal governance (not for public release)
scripts/agent/
docs/internal/
docs/architecture/
docs/release-control/
!docs/release-control/
docs/release-control/*
!docs/release-control/control_plane.json
!docs/release-control/control_plane.schema.json
!docs/release-control/v6/
docs/release-control/v6/*
!docs/release-control/v6/README.md
!docs/release-control/v6/status.schema.json
# Release control scripts — keep public tooling; internal/ subdir is excluded by the wildcard above
scripts/release_control/
!scripts/release_control/
scripts/release_control/*
!scripts/release_control/canonical_completion_guard.py
!scripts/release_control/canonical_completion_guard_test.py
!scripts/release_control/contract_audit.py
!scripts/release_control/contract_audit_test.py
!scripts/release_control/control_plane.py
!scripts/release_control/control_plane_audit.py
!scripts/release_control/control_plane_audit_test.py
!scripts/release_control/documentation_currentness_test.py
!scripts/release_control/format_staged_go.py
!scripts/release_control/format_staged_go_test.py
!scripts/release_control/governance_stage_guard.py
!scripts/release_control/governance_stage_guard_test.py
!scripts/release_control/readiness_assertion_guard.py
!scripts/release_control/readiness_assertion_guard_test.py
!scripts/release_control/record_rc_to_ga_blocked.py
!scripts/release_control/registry_audit.py
!scripts/release_control/registry_audit_test.py
!scripts/release_control/release_promotion_policy_support.py
!scripts/release_control/release_promotion_policy_support_test.py
!scripts/release_control/release_promotion_policy_test.py
!scripts/release_control/resolve_release_promotion.py
!scripts/release_control/resolve_release_promotion_test.py
!scripts/release_control/repo_file_io.py
!scripts/release_control/repo_file_io_test.py
!scripts/release_control/staged_commit_shape_guard.py
!scripts/release_control/staged_commit_shape_guard_test.py
!scripts/release_control/status_audit.py
!scripts/release_control/status_audit_test.py
!scripts/release_control/status_lookup.py
!scripts/release_control/status_lookup_test.py
!scripts/release_control/subsystem_contracts.py
!scripts/release_control/subsystem_contracts_test.py
!scripts/release_control/subsystem_lookup.py
!scripts/release_control/subsystem_lookup_test.py
.agent/
# Pulse Pro landing page (private)
@ -184,6 +242,9 @@ landing-page/
# Local Gemini config
.gemini/
deployment/
# Hosted/cloud infrastructure (Pulse SaaS internals — not for public release)
deploy/cloud/
start-pulse-agent.sh
# Dev container extension auth
@ -205,9 +266,15 @@ scripts/watch-snapshot.sh
scripts/auto-backup.sh
scripts/safe-checkout.sh
BACKUP_SYSTEM.md
scripts/com.pulse.hot-dev.plist
# Overnight refactor scripts and artifacts (local only)
scripts/overnight-refactor/
scripts/lint-fixer/
# Generated artifacts
/eval
test_output.txt
coverage_summary.txt
CHANGELOG-DRAFT.md
.aider*

View file

@ -8,9 +8,17 @@ linters:
- gofmt
- goimports
- errcheck
- dupl
issues:
max-same-issues: 0
max-issues-per-linter: 0
exclude-rules:
# Exclude dupl findings in test files and mock generators — test boilerplate
# is inherently repetitive and mock data generators share patterns by design.
- linters: [dupl]
path: "_test\\.go$"
- linters: [dupl]
path: "internal/mock/"
linters-settings:
gofmt:
simplify: true
@ -18,3 +26,5 @@ linters-settings:
exclude-functions:
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
dupl:
threshold: 150

View file

@ -1,5 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
#!/usr/bin/env bash
set -euo pipefail
# Pre-commit hook to prevent committing restricted or sensitive data
RESTRICTED_FILES="active_subs.json charges.json customers.json subscriptions.json"
@ -60,10 +61,63 @@ else
fi
echo "Sensitivity check passed."
# Governance checks — only run when governance files are present (skipped on fresh clones).
# Governance files live in docs/release-control/v6/internal/ which is not tracked in the
# public repo; contributors without that directory get standard linting only.
if [ -f "docs/release-control/v6/internal/status.json" ]; then
echo "Running governance stage guard..."
python3 scripts/release_control/governance_stage_guard.py
echo "Running staged commit shape guard..."
python3 scripts/release_control/staged_commit_shape_guard.py
echo "Running control plane audit..."
python3 scripts/release_control/control_plane_audit.py --check --staged
echo "Running canonical completion guard..."
python3 scripts/release_control/canonical_completion_guard.py
echo "Running status audit..."
python3 scripts/release_control/status_audit.py --check --staged
echo "Running registry audit..."
python3 scripts/release_control/registry_audit.py --check --staged
echo "Running contract audit..."
python3 scripts/release_control/contract_audit.py --check --staged
echo "Running governance guardrail tests..."
export PULSE_READ_STAGED_GOVERNANCE=1
go test ./internal/repoctl -count=1
echo "Running readiness assertion guard..."
python3 scripts/release_control/readiness_assertion_guard.py --staged --active-target --proof-type automated
echo "Running release-control helper unit tests..."
python3 scripts/release_control/canonical_completion_guard_test.py
python3 scripts/release_control/control_plane_audit_test.py
python3 scripts/release_control/contract_audit_test.py
python3 scripts/release_control/format_staged_go_test.py
python3 scripts/release_control/governance_stage_guard_test.py
python3 scripts/release_control/release_promotion_policy_support_test.py
python3 scripts/release_control/registry_audit_test.py
python3 scripts/release_control/readiness_assertion_guard_test.py
(cd scripts/release_control && git -C ../.. show :scripts/release_control/release_promotion_policy_test.py | python3 -)
python3 scripts/release_control/repo_file_io_test.py
python3 scripts/release_control/staged_commit_shape_guard_test.py
python3 scripts/release_control/status_audit_test.py
python3 scripts/release_control/subsystem_contracts_test.py
python3 scripts/release_control/subsystem_lookup_test.py
unset PULSE_READ_STAGED_GOVERNANCE
else
echo "Governance files not present — skipping governance checks."
fi
# Run Go formatting
echo "Running Go formatter..."
gofmt -w -s .
python3 scripts/release_control/format_staged_go.py
# Run Go linting (if golangci-lint is available)
if command -v golangci-lint >/dev/null 2>&1; then
@ -73,14 +127,14 @@ fi
# Run frontend linting (if package.json has lint script)
if [ -f frontend-modern/package.json ]; then
echo "Running frontend linter..."
cd frontend-modern
npm run lint --if-present || true
cd ..
if git diff --cached --name-only | grep -q "^frontend-modern/"; then
echo "Running frontend linter..."
cd frontend-modern
npm run lint
cd ..
else
echo "Skipping frontend lint (no staged frontend changes)."
fi
fi
# Re-stage only files that were already staged (to pick up formatter changes)
# This avoids accidentally committing unrelated work-in-progress files
git diff --cached --name-only -z | xargs -0 -r git add
echo "Pre-commit checks passed!"

View file

@ -16,5 +16,11 @@ while read local_ref local_sha remote_ref remote_sha; do
fi
done
# TypeScript type-check before push to prevent CI failures
cd frontend-modern && npm run type-check
# Frontend quality gates before push to prevent regressions in solo workflow.
# This includes ESLint + theme audit + TypeScript checks.
if [ -f frontend-modern/package.json ]; then
cd frontend-modern
npm run lint
npm run type-check
cd ..
fi

View file

@ -1,94 +1,186 @@
# Pulse Architecture
Pulse is a real-time monitoring system designed for Proxmox VE, Proxmox Backup Server, and Docker/Host infrastructure. It is built with a **Go** backend and a **SolidJS** frontend, focusing on low latency, high concurrency, and a premium user experience.
Pulse is a real-time infrastructure monitoring platform for **Proxmox VE**, **Proxmox Backup Server**, **Proxmox Mail Gateway**, **Docker**, **Host** systems, **Kubernetes**, and **TrueNAS**. It is built with a **Go 1.25+** backend and a **SolidJS / TypeScript** frontend, focusing on low latency, high concurrency, and a premium user experience.
## 🏗 High-Level Overview
The system operates as a single binary that serves both the API and the static frontend assets. It connects to Proxmox infrastructure via their REST APIs (using API tokens or password auth), while Docker/Host metrics are collected by lightweight agents that push data to Pulse.
The system runs as a single binary that serves both the API and the embedded frontend assets. It connects to infrastructure via platform-specific REST APIs and lightweight push-based agents, normalises everything into a **Unified Resource model**, and delivers real-time updates to clients over WebSocket.
```mermaid
graph TD
User[User Browser] <-->|WebSocket / HTTP| Pulse[Pulse Server]
User[Browser / Mobile] <-->|WebSocket + HTTP| Pulse[Pulse Server]
Mobile[Mobile App] <-->|E2E Encrypted| Relay[Relay Server]
Relay <-->|WebSocket| Pulse
subgraph "Pulse Server (Go)"
API[REST API]
API[Decomposed API Router]
WS[WebSocket Hub]
Monitor[Monitoring Engine]
Monitor[Reloadable Monitor]
UR[Unified Resource Registry]
AI[AI Subsystem]
Config[Config Manager]
License[Entitlements Engine]
Audit[Audit Logger]
end
Pulse -->|HTTPS API :8006| PVE[Proxmox VE Node]
Pulse -->|HTTPS API :8007| PBS[Proxmox Backup Server]
Agent[Pulse Agent] -->|HTTPS POST| API
Agent -.->|Collects from| DockerHost[Docker / Host]
Pulse -->|HTTPS :8006| PVE[Proxmox VE]
Pulse -->|HTTPS :8007| PBS[Proxmox Backup Server]
Pulse -->|HTTPS| PMG[Proxmox Mail Gateway]
Pulse -->|HTTPS| TrueNAS[TrueNAS SCALE/CORE]
DockerAgent[Docker Agent] -->|HTTPS POST| API
HostAgent[Host Agent] -->|HTTPS POST| API
K8sAgent[Kubernetes Agent] -->|HTTPS POST| API
Monitor --> WS
Monitor --> API
Monitor --> UR
UR --> WS
UR --> API
```
## 🔌 Backend Architecture (Go)
The backend is a high-performance Go application designed for concurrent monitoring.
All backend code lives under `cmd/`, `internal/`, and `pkg/`. The binary is assembled from `cmd/pulse/main.go` which delegates to `pkg/server.Run()`.
### Core Components
1. **Entry Point (`cmd/pulse/main.go`)**:
* Initializes the configuration, logger, and persistence layer.
* Starts the `ReloadableMonitor` which manages the lifecycle of monitoring routines.
* Launches the HTTP server and WebSocket hub.
1. **Entry Point (`cmd/pulse/main.go``pkg/server/server.go`)**
- Loads unified configuration via `internal/config.Load()`.
- Initialises the RBAC manager (`pkg/auth`), audit logger (`pkg/audit`), and crypto layer (`internal/crypto`).
- Creates a `ReloadableMonitor` that manages the lifecycle of all monitoring goroutines.
- Starts the HTTP/HTTPS server, WebSocket hub, AI services (Patrol + Chat), and the Relay client.
- Supports graceful hot-reload via `SIGHUP` and `.env` file watching.
2. **Monitoring Engine (`internal/monitoring`)**:
* **Polymorphic Monitors**: Uses interfaces to treat PVE and PBS hosts uniformly where possible.
* **Goroutines**: Each host is monitored in its own lightweight goroutine to ensure non-blocking operations.
* **API Clients**: Communicates with Proxmox VE/PBS via their REST APIs using API tokens or password-based tickets.
2. **Unified Resource Registry (`internal/unifiedresources`)**
- Central data model that normalises resources from **7 data sources** (`proxmox`, `pbs`, `pmg`, `docker`, `agent`, `kubernetes`, `truenas`) into a single `Resource` struct.
- **Canonical v6 resource types**: `agent`, `vm`, `system-container`, `app-container`, `docker-host`, `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `storage`, `pbs`, `pmg`, `ceph`, `physical_disk`.
- Identity-matching engine: merges resources across sources using machine IDs, DMI UUIDs, hostnames, IPs, and MAC addresses.
- Provides typed **views** (`NodeView`, `K8sClusterView`, etc.) for consumer-specific queries.
- Canonical API endpoint: `GET /api/resources`.
3. **Agent Receivers (`internal/api/agents`)**:
* Receives metrics from `pulse-agent` instances via HTTP POST.
* Agents collect Docker container stats, host metrics, and temperatures locally.
* Push-based model: agents initiate connections to Pulse, not vice versa.
3. **Monitoring Engine (`internal/monitoring`)**
- **Polymorphic monitors**: Each Proxmox VE/PBS/PMG node runs in its own goroutine, polling via the platform REST API.
- **Agent receivers** (`internal/api`): Docker, Host, and Kubernetes agents push metrics via HTTP POST to `/api/agents/{type}/report`.
- **TrueNAS provider** (`internal/truenas`): Polls TrueNAS REST API for system info, pools, datasets, disks, alerts, ZFS snapshots, and replication tasks.
- Multi-tenant aware: when `PULSE_MULTI_TENANT_ENABLED=true`, each organisation gets an isolated monitor instance with its own configuration.
4. **WebSocket Hub (`internal/websocket`)**:
* Manages active client connections.
* Broadcasts metric updates in real-time.
* Handles "commands" from the frontend (e.g., requesting immediate updates).
4. **WebSocket Hub (`internal/websocket`)**
- Manages active browser connections with per-message compression (deflate).
- Broadcasts state diffs to all subscribed clients; supports per-tenant broadcasts for multi-org setups.
- Enforces origin validation, org-level authorization, and multi-tenant license gating.
5. **API Layer (`internal/api`)**:
* RESTful endpoints for configuration (adding nodes, setting thresholds).
* Handles authentication and secure token management.
5. **Decomposed API Router (`internal/api`)**
- The router is split into focused registration files for maintainability:
- `router_routes_auth_security.go` — Auth, OIDC, SAML, SSO, security tokens, recovery, agent install scripts.
- `router_routes_monitoring.go` — Unified resources, metrics history, charts, recovery points, alerts, notifications, discovery.
- `router_routes_ai_relay.go` — AI settings, Patrol, Intelligence, Chat sessions, Relay config, and the approval workflow.
- `router_routes_org_license.go` — Organisations, RBAC, audit logs, license/entitlements, billing.
- `router_routes_registration.go` — Config CRUD, TrueNAS connections, update management, agent profiles, system settings.
- `router_routes_hosted.go` — Hosted/SaaS-specific signup and org admin routes.
- Every endpoint enforces **scoped access control** (e.g., `monitoring:read`, `settings:write`, `ai:execute`, `ai:chat`).
6. **Relay Client (`internal/relay`)**
- Maintains a persistent WebSocket tunnel to a managed relay server for **mobile remote access**.
- Uses an ECDH key-exchange for per-channel **end-to-end encryption**.
- Multiplexes multiple mobile sessions over a single connection with back-pressure (data limiter) and per-channel authentication.
- Supports push notifications through the relay.
- Gated by the `relay` license feature.
7. **AI Subsystem (`internal/ai`)**
- **Pulse Assistant (Chat)**: Interactive LLM-powered chat with infrastructure context. Supports bring-your-own-key (BYOK) providers (OpenAI, Anthropic, Ollama, etc.) plus Claude OAuth.
- **Pulse Patrol**: Scheduled background analysis that produces findings, predictions, and remediation plans. Autonomy levels: `monitor`, `approval`, `assisted`, `full`.
- **Intelligence Services**: Patterns, correlations, anomalies, baselines, forecasts, and incident recording. All surfaced via `/api/ai/intelligence/*`.
- **Safety gates**: Command execution disabled by default (`--enable-commands` opt-in); circuit breakers and scoped permissions at every layer.
8. **Entitlements & Licensing (`internal/license`)**
- Capability-key based gating: `ai_autofix`, `rbac`, `multi_tenant`, `relay`, `agent_profiles`, `kubernetes_ai`, `ai_alerts`, etc.
- Three tiers: **Community** (free), **Pro** (license key), **Cloud** (subscription).
- Trial lifecycle with activation, renewal, and expiry. All state exposed via `/api/license/*`.
9. **Recovery Engine (`internal/recovery`)**
- Aggregates backup data (PBS snapshots, ZFS snapshots, replication tasks) into a unified recovery point timeline.
- Provides faceted queries: filter by type, source, status. Rollup summaries for dashboards.
- API: `/api/recovery/points`, `/api/recovery/series`, `/api/recovery/facets`, `/api/recovery/rollups`.
10. **Audit Logging (`pkg/audit`)**
- Defence-in-depth: every mutation is logged to SQLite, optionally signed with per-tenant encryption keys.
- Async logging mode (`PULSE_AUDIT_ASYNC`) for high-throughput environments.
- Tenant-aware logger manager for isolated per-org audit trails.
### Data Flow
1. **Collection**:
* **Proxmox**: The Monitoring Engine polls PVE/PBS REST APIs (default: 2s interval).
* **Agents**: Docker/Host agents push metrics to Pulse at their configured interval (default: 30s).
2. **Normalization**: API responses and agent reports are parsed into standardized Go structs (`HostMetrics`, `ContainerMetrics`).
3. **Broadcast**: Normalized data is sent to the `WebSocket Hub`.
4. **Delivery**: The Hub serializes the data to JSON and pushes it to all subscribed frontend clients.
1. **Collection**:
- **Proxmox VE / PBS / PMG**: Monitoring engine polls platform REST APIs (configurable interval, default 2 s for PVE).
- **Docker / Host / Kubernetes**: Lightweight agents push metrics via HTTP POST on their configured interval.
- **TrueNAS**: Provider polls the TrueNAS REST API for system, pool, dataset, disk, alert, and replication data.
2. **Normalisation**: Platform-specific responses are mapped into `unifiedresources.Resource` structs by adapters in `internal/unifiedresources/adapters.go`.
3. **Registration**: Resources are inserted into the in-memory registry, which handles deduplication, identity matching, and status computation.
4. **Broadcast**: The latest state snapshot is serialised to JSON and pushed to all connected WebSocket clients by the Hub.
5. **Persistence**: Metrics history is stored in a SQLite-backed metrics store (`pkg/metrics`) with configurable retention.
## 🎨 Frontend Architecture (SolidJS)
The frontend is a modern Single Page Application (SPA) built with **SolidJS** and **TypeScript**. It prioritizes performance by using fine-grained reactivity instead of a Virtual DOM.
The frontend is a modern SPA in `frontend-modern/`, built with **SolidJS** and **TypeScript**. It uses fine-grained reactivity (no Virtual DOM) for maximum performance.
### Key Technologies
* **SolidJS**: For reactive UI components.
* **TailwindCSS**: For styling and theming (Dark/Light mode).
* **Vite**: For fast development and optimized builds.
- **SolidJS**: Reactive UI framework.
- **TailwindCSS** with a **semantic design token layer**: All structural colours use CSS custom-property tokens (`bg-base`, `bg-surface`, `text-base-content`, `border-border`, etc.) defined in `index.css`— components never hardcode hex values. Light/dark mode switches automatically via class-based theme resolution.
- **Vite**: Build tooling (dev server + production bundling).
- **Lucide Icons**: Icon library (imported as solid components).
### Routing & Navigation
Navigation is organised by **task**, not by platform:
| Route | Page | Purpose |
|---|---|---|
| `/infrastructure` | Infrastructure | Hosts, nodes, clusters across all platforms |
| `/workloads` | Workloads | VMs, LXCs, containers, K8s pods |
| `/storage` | Storage | Proxmox storage, ZFS pools, Ceph |
| `/recovery` | Recovery | Backups, snapshots, replication |
| `/ceph` | Ceph | Detailed Ceph cluster view |
| `/dashboard` | Dashboard | Summary panels and metrics |
| `/alerts/*` | Alerts | Alert rules, active alerts, history |
| `/ai/*` | AI Intelligence | Patrol findings, investigations, forecasts |
| `/settings/*` | Settings | Configuration, security, AI, relay |
| `/operations/*` | Operations | Operational tools |
Legacy route aliases have been removed; canonical v6 routes are the only supported navigation surface.
### State Management
* **Stores (`frontend-modern/src/stores`)**: `websocket.ts` manages the WS connection and reactive updates; `metricsHistory.ts` buffers metrics for sparklines.
- **WebSocket store** (`stores/websocket.ts`): Manages the live connection, reactive `State` object, reconnection logic, and per-org switching.
- **Metrics collector** (`stores/metricsCollector.ts`): Buffers time-series data for sparklines and historical charts.
- **AI stores** (`stores/aiChat.ts`, `stores/aiIntelligence.ts`): Manage chat sessions and patrol findings.
- **License store** (`stores/license.ts`): Tracks plan tier, feature flags, and multi-tenant state.
- **System settings store** (`stores/systemSettings.ts`): Caches server-side preferences (for example theme and feature toggles).
### Component Design
* **Atomic Design**: Small, reusable components (`MetricBar`, `StatusBadge`) compose into larger views (`NodeSummaryTable`).
* **Visualizations**: Custom SVG-based charts (Sparklines) are used instead of heavy charting libraries to keep the bundle size small and rendering fast.
- **Shared primitives** in `components/shared/`: `Card`, `Button`, `Toggle`, `FilterButtonGroup`, `Table` — all mapped to the semantic design tokens.
- **Lazy-loaded pages**: All top-level pages are loaded via `lazy()` with optional preloading after initial render.
- **Virtual table windowing**: Large resource lists use virtualised rendering for smooth scrolling at scale.
- **Command Palette** (`Cmd/Ctrl+K`): Quick-access command launcher.
- **Keyboard shortcuts**: `g i` → Infrastructure, `g w` → Workloads, `g s` → Storage, `g b` → Recovery, `g a` → Alerts, `g t` → Settings, `/` → Search.
### Mobile Experience
- **MobileNavBar** component: Bottom tab bar for touch navigation.
- **Relay integration**: The mobile app connects through the relay protocol for encrypted remote access.
## 🔒 Security
* **Encryption at Rest**: Sensitive configuration (passwords, API keys) is encrypted on disk using `AES-GCM` with a user-provided passphrase.
* **Transport Security**: All communications can be secured via TLS.
* **Authentication**: Session-based auth for API access.
- **Encryption at Rest**: Sensitive config (passwords, API keys) encrypted with AES-GCM via `internal/crypto`. Each tenant can have its own encryption key.
- **Scoped API Tokens**: Tokens carry explicit scopes (`monitoring:read`, `settings:write`, `ai:chat`, `ai:execute`, `docker:report`, etc.). Endpoints enforce scope checks before processing.
- **RBAC**: Role-based access control via `pkg/auth` with file-backed persistence. Resources: `users`, `settings`, `monitoring`.
- **SSO**: OIDC and SAML providers with per-provider configuration and multi-provider support.
- **Agent Commands**: Disabled by default for security. Operators must opt in with `--enable-commands`.
- **Audit Trail**: Every mutation logged to per-tenant SQLite databases with optional cryptographic signatures.
- **Rate Limiting**: Per-endpoint and per-tenant rate limiting with configurable thresholds.
- **CSRF Protection**: Token-based CSRF prevention on mutation endpoints.
- **Recovery Mode**: Localhost-only endpoint for emergency auth recovery with time-limited tokens.
## 🚀 Deployment
Pulse is distributed as:
1. **Docker Container**: Multi-stage build resulting in a scratch-based or alpine-based image containing just the binary and frontend assets.
2. **Single Binary**: The frontend is embedded into the Go binary using `embed`, allowing for a single-file deployment.
1. **Docker Container**: Multi-stage build producing a minimal image with the Go binary + embedded frontend.
2. **Single Binary**: The frontend is compiled into the Go binary using `go:embed` (`internal/api/frontend_embed.go`), enabling single-file deployment.
3. **Kubernetes Helm Chart**: For cluster deployments with configurable replicas and persistence.
4. **Systemd Service**: For bare-metal or VM installations with `.env`-based configuration and `SIGHUP` reload support.
5. **Proxmox LXC Helper Script**: One-line install inside a Proxmox helper-scripts container.

View file

@ -1,17 +1,17 @@
# Contributing to Pulse
Thanks for investing time in Pulse! This document collects the essentials you
need to be productive across the Go backend, React/TypeScript frontend, and the
need to be productive across the Go backend, SolidJS/TypeScript frontend, and the
installer tooling.
---
## Project Overview
- **Backend (`cmd/`, `internal/`, `pkg/`)** Go 1.23+ web server that embeds
- **Backend (`cmd/`, `internal/`, `pkg/`)** Go 1.25+ web server that embeds
the built frontend and exposes REST + WebSocket APIs.
- **Architecture (`ARCHITECTURE.md`)** High-level system design diagrams and explanations.
- **Frontend (`frontend-modern/`)** Vite + React app built with TypeScript.
- **Frontend (`frontend-modern/`)** Vite + SolidJS app built with TypeScript.
- **Agents (`cmd/pulse-*-agent`)** Go binaries distributed alongside Pulse for
host and Docker telemetry.
- **Documentation (`docs/`)** Markdown-based guides published to users and
@ -78,18 +78,22 @@ examples where possible.
- Tests: `npm run test`
- Lint: `npm run lint`
- Format: `npm run format`
- Production build: `npm run build` (copied into `internal/api/frontend-modern`
via the Makefile).
- Production build: `npm run build` (syncs the Go embed copy in
`internal/api/frontend-modern/dist` automatically).
Use modern React patterns (hooks, function components) and prefer TanStack Query
for data fetching. Add Storybook stories or screenshots when introducing new
Use SolidJS patterns (signals, memos, createEffect) and the shared design-system
components in `components/shared/`. Add screenshots when introducing new
UI-heavy features.
Design-system lint rules are enforced as CI blockers. Avoid hardcoded structural
light/dark classes and broken utility chains; use semantic tokens from
`frontend-modern/DESIGN_SYSTEM.md`.
---
## Installers & Scripts
- Centralised guidance: `docs/script-library-guide.md`
- Centralised guidance: `docs/internal/SCRIPT_LIBRARY.md`
- Bundling: `make bundle-scripts`
- Tests: `scripts/tests/run.sh` plus integration suites under
`scripts/tests/integration/`

View file

@ -15,6 +15,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
# Copy frontend source
COPY frontend-modern/ ./
COPY scripts/exclusive-lock.mjs /app/scripts/exclusive-lock.mjs
# Build frontend
RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
@ -23,7 +24,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
# Build stage for Go backend
# Force amd64 platform - Go cross-compiles for all targets anyway,
# and this avoids slow QEMU emulation during multi-arch builds
FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
FROM --platform=linux/amd64 golang:1.25.7-alpine AS backend-builder
ARG BUILD_AGENT
ARG PULSE_LICENSE_PUBLIC_KEY
@ -43,11 +44,11 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY pkg/ ./pkg/
COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh
COPY VERSION ./
# Copy built frontend from frontend-builder stage for embedding
# Must be at internal/api/frontend-modern for Go embed
COPY --from=frontend-builder /app/frontend-modern/dist ./internal/api/frontend-modern/dist
# Copy the synced embed artifact from the frontend builder stage.
COPY --from=frontend-builder /app/internal/api/frontend-modern/dist ./internal/api/frontend-modern/dist
# Build the main pulse binary for all target architectures
RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
@ -55,124 +56,71 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
VERSION="${VERSION:-v$(cat VERSION | tr -d '\n')}" && \
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
LICENSE_LDFLAGS="" && \
if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then \
LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"; \
fi && \
SERVER_LDFLAGS="$(if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${PULSE_LICENSE_PUBLIC_KEY}"; else ./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"; fi)" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION} ${LICENSE_LDFLAGS}" \
-tags release \
-ldflags="${SERVER_LDFLAGS}" \
-trimpath \
-o pulse-linux-amd64 ./cmd/pulse && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION} ${LICENSE_LDFLAGS}" \
-tags release \
-ldflags="${SERVER_LDFLAGS}" \
-trimpath \
-o pulse-linux-arm64 ./cmd/pulse
# Build host-agent binaries for all platforms (for download endpoint)
RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \
VERSION="${VERSION:-v$(cat VERSION | tr -d '\n')}" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-linux-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-linux-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-linux-armv6 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-linux-386 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-windows-arm64.exe ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-windows-386.exe ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-freebsd-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build \
-ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \
-trimpath \
-o pulse-host-agent-freebsd-arm64 ./cmd/pulse-host-agent
# Build unified agent binaries for all platforms (for download endpoint)
RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \
VERSION="${VERSION:-v$(cat VERSION | tr -d '\n')}" && \
AGENT_LDFLAGS="$(./scripts/release_ldflags.sh agent --version "${VERSION}")" && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-linux-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-linux-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-linux-armv7 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-linux-armv6 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=386 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-linux-386 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-darwin-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-darwin-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-windows-amd64.exe ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-windows-arm64.exe ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=386 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-windows-386.exe ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-freebsd-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-ldflags="${AGENT_LDFLAGS}" \
-trimpath \
-o pulse-agent-freebsd-arm64 ./cmd/pulse-agent
@ -203,16 +151,11 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \
chmod +x /usr/local/bin/pulse-agent && \
rm -rf /tmp/pulse-agent-*
# Create shim for pulse-docker-agent to maintain backward compatibility
RUN echo '#!/bin/sh' > /usr/local/bin/pulse-docker-agent && \
echo 'exec /usr/local/bin/pulse-agent --enable-docker "$@"' >> /usr/local/bin/pulse-docker-agent && \
chmod +x /usr/local/bin/pulse-docker-agent
COPY --from=backend-builder /app/VERSION /VERSION
ENV PULSE_NO_AUTO_UPDATE=true
ENTRYPOINT ["/usr/local/bin/pulse-docker-agent"]
ENTRYPOINT ["/usr/local/bin/pulse-agent", "--enable-docker", "--enable-host=false"]
# Final stage (Pulse server runtime)
FROM alpine:3.20 AS runtime
@ -241,11 +184,7 @@ RUN chmod +x /docker-entrypoint.sh
# Provide installer scripts for HTTP download endpoints
RUN mkdir -p /opt/pulse/scripts
COPY scripts/install-docker-agent.sh /opt/pulse/scripts/install-docker-agent.sh
COPY scripts/install-container-agent.sh /opt/pulse/scripts/install-container-agent.sh
COPY scripts/install-host-agent.ps1 /opt/pulse/scripts/install-host-agent.ps1
COPY scripts/uninstall-host-agent.sh /opt/pulse/scripts/uninstall-host-agent.sh
COPY scripts/uninstall-host-agent.ps1 /opt/pulse/scripts/uninstall-host-agent.ps1
COPY scripts/install-docker.sh /opt/pulse/scripts/install-docker.sh
COPY scripts/install.sh /opt/pulse/scripts/install.sh
COPY scripts/install.ps1 /opt/pulse/scripts/install.ps1
@ -264,24 +203,6 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \
fi
# Host agent binaries (all platforms and architectures)
COPY --from=backend-builder /app/pulse-host-agent-linux-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-arm64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-armv7 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-armv6 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-linux-386 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-darwin-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-darwin-arm64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-windows-amd64.exe /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-windows-arm64.exe /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-windows-386.exe /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-freebsd-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-host-agent-freebsd-arm64 /opt/pulse/bin/
# Create symlinks for Windows without .exe extension
RUN ln -s pulse-host-agent-windows-amd64.exe /opt/pulse/bin/pulse-host-agent-windows-amd64 && \
ln -s pulse-host-agent-windows-arm64.exe /opt/pulse/bin/pulse-host-agent-windows-arm64 && \
ln -s pulse-host-agent-windows-386.exe /opt/pulse/bin/pulse-host-agent-windows-386
# Unified agent binaries (all platforms and architectures)
COPY --from=backend-builder /app/pulse-agent-linux-amd64 /opt/pulse/bin/
COPY --from=backend-builder /app/pulse-agent-linux-arm64 /opt/pulse/bin/

View file

@ -1,6 +1,6 @@
# Pulse Makefile for development
.PHONY: build run dev frontend backend all clean distclean dev-hot lint lint-backend lint-frontend format format-backend format-frontend build-agents
.PHONY: build run dev frontend backend all clean distclean dev-hot lint lint-backend lint-frontend format format-backend format-frontend build-agents control-plane handoff
FRONTEND_DIR := frontend-modern
FRONTEND_DIST := $(FRONTEND_DIR)/dist
@ -12,14 +12,7 @@ all: frontend backend build-agents
# Build frontend only
frontend:
npm --prefix $(FRONTEND_DIR) run build
@echo "================================================"
@echo "Copying frontend to internal/api/ for Go embed"
@echo "This is REQUIRED - Go cannot embed external paths"
@echo "================================================"
rm -rf $(FRONTEND_EMBED_DIR)
mkdir -p $(FRONTEND_EMBED_DIR)
cp -r $(FRONTEND_DIST) $(FRONTEND_EMBED_DIR)/
@echo "✓ Frontend copied for embedding"
@echo "✓ Frontend build synced to $(FRONTEND_EMBED_DIR)"
# Build backend only (includes embedded frontend)
backend:
@ -69,15 +62,20 @@ format-backend:
format-frontend:
npm --prefix $(FRONTEND_DIR) run format
# Build control plane binary
control-plane:
@VERSION=$$(cat VERSION | tr -d '\n') && \
go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o pulse-control-plane ./cmd/pulse-control-plane
test:
@./scripts/ensure_test_assets.sh
@echo "Running backend tests (excluding tmp tooling)..."
go test $$(go list ./... | grep -v '/tmp$$')
go test -race -timeout 10m $$(go list ./... | grep -v '/tmp$$')
# Run integration tests (requires Ollama at OLLAMA_URL or 192.168.0.124:11434)
# Run integration tests (requires Ollama at OLLAMA_URL or 127.0.0.1:11434)
test-integration:
@echo "Running AI integration tests against Ollama..."
@echo "Set OLLAMA_URL to override default (http://192.168.0.124:11434)"
@echo "Set OLLAMA_URL to override default (http://127.0.0.1:11434)"
go test -tags=integration -v ./internal/ai/providers/... -run "TestIntegration"
# Run both unit and integration tests
@ -88,19 +86,17 @@ build-agents:
@echo "Building agent binaries for all platforms..."
@mkdir -p bin
@VERSION=$$(cat VERSION | tr -d '\n') && \
echo "Building host agent binaries..." && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent && \
echo "Building unified agent binaries..." && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-darwin-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-darwin-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-windows-amd64.exe ./cmd/pulse-agent
@ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64
@echo "✓ All agent binaries built in bin/"
# Session handoff — merge a Claude session's handoff entry into MEMORY.md (flock-safe)
# Usage: make handoff SESSION_ID=<id>
# Expects handoff content at /tmp/handoff-$(SESSION_ID).md
handoff:
@if [ -z "$(SESSION_ID)" ]; then echo "Error: SESSION_ID required (make handoff SESSION_ID=xxx)" >&2; exit 1; fi
@./scripts/session-handoff.sh "$(SESSION_ID)" "/tmp/handoff-$(SESSION_ID).md"

View file

@ -2,7 +2,7 @@
<div align="center">
<img src="docs/images/pulse-logo.svg" alt="Pulse Logo" width="120" />
<p><strong>Real-time monitoring for Proxmox, Docker, and Kubernetes infrastructure.</strong></p>
<p><strong>Real-time monitoring for Proxmox, Docker, Kubernetes, and TrueNAS infrastructure.</strong></p>
[![GitHub Stars](https://img.shields.io/github/stars/rcourtman/Pulse?style=flat&logo=github)](https://github.com/rcourtman/Pulse)
[![GitHub release](https://img.shields.io/github/v/release/rcourtman/Pulse)](https://github.com/rcourtman/Pulse/releases/latest)
@ -16,38 +16,53 @@
## 🚀 Overview
Pulse is a modern, unified dashboard for monitoring your **infrastructure** across Proxmox, Docker, and Kubernetes. It consolidates metrics, alerts, and AI-powered insights from all your systems into a single, beautiful interface.
Pulse is a modern, unified dashboard for monitoring your **infrastructure** across Proxmox, Docker, Kubernetes, and TrueNAS. It consolidates metrics, alerts, and AI-powered insights from all your systems into a single, beautiful interface.
Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" without the complexity of enterprise monitoring stacks.
![Pulse Dashboard](docs/images/01-dashboard.jpg)
## 🧭 Unified Navigation
Pulse now groups everything by task instead of data source:
- **Infrastructure** for hosts and nodes
- **Workloads** for VMs, containers, and Kubernetes pods
- **Storage** and **Backups** as top-level views
- PMG now routes into **Infrastructure** (source filter), and Kubernetes routes into **Workloads** (K8s filter)
- Legacy URLs are no longer routed as compatibility aliases; use canonical v6 routes.
Power-user shortcuts:
- `g i` → Infrastructure, `g w` → Workloads, `?` → shortcuts help
- `/` or `Cmd/Ctrl+K` → global search
## ✨ Features
### Core Monitoring
- **Unified Monitoring**: View health and metrics for PVE, PBS, PMG, Docker, and Kubernetes in one place
- **Unified Monitoring**: View health and metrics for PVE, PBS, PMG, Docker, Kubernetes, and TrueNAS in one place
- **Smart Alerts**: Get notified via Discord, Slack, Telegram, Email, and more
- **Auto-Discovery**: Automatically finds Proxmox nodes on your network
- **Metrics History**: Persistent storage with configurable retention
- **Backup Explorer**: Visualize backup jobs and storage usage
- **Recovery Central**: Unified backup/snapshot/replication timeline across PBS and TrueNAS
### AI-Powered
- **Chat Assistant (BYOK)**: Ask questions about your infrastructure in natural language
- **Patrol (BYOK)**: Background health checks that generate findings on a schedule
- **Alert Analysis (Pro)**: Optional AI analysis when alerts fire
- **Alert Analysis (Pro/Pro+/Cloud)**: Optional AI analysis when alerts fire
- **Cost Tracking**: Track usage and costs per provider/model
### Multi-Platform
- **Proxmox VE/PBS/PMG**: Full monitoring and management
- **TrueNAS**: Pools, datasets, disks, ZFS snapshots, replication tasks, and alerts
- **Kubernetes**: Complete K8s cluster monitoring via agents
- **Docker/Podman**: Container and Swarm service monitoring
- **OCI Containers**: Proxmox 9.1+ native container support
### Security & Operations
- **Secure by Design**: Credentials encrypted at rest, strict API scoping
- **Secure by Design**: Credentials encrypted at rest, strict API scoping, agent commands disabled by default
- **One-Click Updates**: Easy upgrades for supported deployments
- **OIDC/SSO**: Single sign-on authentication
- **Privacy Focused**: No telemetry, all data stays on your server
- **OIDC/SSO/SAML**: Single sign-on with multi-provider support
- **Mobile Remote Access (Coming Soon)**: Relay protocol with end-to-end encryption is available now; public mobile app launch is in staged rollout (Relay and above)
- **Privacy Focused**: Anonymous telemetry is enabled by default and [fully documented](docs/PRIVACY.md) — no hostnames, credentials, or personal data is ever sent. Disable any time in Settings or via `PULSE_TELEMETRY=false`.
## ⚡ Quick Start
@ -58,7 +73,7 @@ Run this one-liner on your Proxmox host to create a lightweight LXC container:
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
Note: this installs the Pulse **server**. Agent installs use the command generated in **Settings → Agents → Installation commands** (served from `/install.sh` on your Pulse server).
Note: this installs the Pulse **server**. Agent installs use the command generated in **Settings → Unified Agents → Installation commands** (served from `/install.sh` on your Pulse server).
### Option 2: Docker
```bash
@ -75,13 +90,17 @@ Access the dashboard at `http://<your-ip>:7655`.
## 📚 Documentation
- **[Installation Guide](docs/INSTALL.md)**: Detailed instructions for Docker, Kubernetes, and bare metal.
- **[Upgrade to v6](docs/UPGRADE_v6.md)**: Migration guide for upgrading from v5 to v6.
- **[Configuration](docs/CONFIGURATION.md)**: Setup authentication, notifications, and advanced settings.
- **[Security](SECURITY.md)**: Learn about Pulse's security model and best practices.
- **[API Reference](docs/API.md)**: Integrate Pulse with your own tools.
- **[Architecture](ARCHITECTURE.md)**: High-level system design and data flow.
- **[AI Features](docs/AI.md)**: Pulse Assistant (Chat) and Pulse Patrol documentation.
- **[Multi-Tenant](docs/MULTI_TENANT.md)**: Enterprise multi-tenant setup and configuration.
- **[Troubleshooting](docs/TROUBLESHOOTING.md)**: Solutions to common issues.
- **[Agent Security](docs/AGENT_SECURITY.md)**: Details on checksum-verified updates and verification.
- **[Docker Monitoring](docs/DOCKER.md)**: Setup and management of Docker agents.
- **[Unified Navigation](docs/MIGRATION_UNIFIED_NAV.md)**: Guide to the new task-based navigation.
## 🌐 Community Integrations
@ -89,23 +108,38 @@ Community-maintained integrations and addons:
- **[Home Assistant Addons](https://github.com/Kosztyk/homeassistant-addons)** - Run Pulse Agent and Pulse Server as Home Assistant addons.
## 🚀 Pulse Pro
## 💳 Plans (Community / Relay / Pro / Pro+ / Cloud)
**[Pulse Pro](https://pulserelay.pro)** unlocks **Auto-Fix and advanced AI analysis****Pulse Patrol is available to everyone with BYOK**.
Pulse is full-featured for core monitoring in every tier. Self-hosted pricing now sells monitored coverage by monitored system, not by installed agent. Cloud and MSP pricing are unchanged.
| Feature | Free | Pro |
|---------|------|-----|
| Real-time dashboard | ✅ | ✅ |
| Threshold alerts | ✅ | ✅ |
| AI Chat (BYOK) | ✅ | ✅ |
| **Pulse Patrol (BYOK)** | ✅ | ✅ |
| Alert-triggered AI analysis | — | ✅ |
| Kubernetes AI analysis | — | ✅ |
| Auto-fix + autonomous mode | — | ✅ |
| Centralized agent profiles | — | ✅ |
| **Advanced Reporting (PDF/CSV)** | — | ✅ |
| **Audit Webhooks (SIEM integration)** | — | ✅ |
| Priority support | — | ✅ |
Self-hosted tiers:
| Plan | Price | Included monitored systems | Metric history | Key upgrade |
|---|---:|---:|---:|---|
| Community | Free | 5 | 7 days | Core monitoring for one real small lab |
| Relay | $4.99/mo or $39/yr | 8 | 14 days | Remote access, mobile, and push notifications |
| Pro | $8.99/mo or $79/yr | 15 | 90 days | AI investigation, auto-fix, and operations tooling |
| Pro+ | $14.99/mo or $129/yr | 50 | 90 days | More room for larger self-hosted labs |
Pulse counts top-level monitored systems once no matter how they are collected. VMs, containers, pods, disks, backups, and other child resources under that system are included rather than counted separately.
Runtime-aligned capability summary:
| Capability | Community | Relay | Pro | Pro+ | Cloud |
|---|:---:|:---:|:---:|:---:|:---:|
| Pulse Patrol (Background Health Checks) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Remote Access / Mobile / Push | — | ✅ | ✅ | ✅ | ✅ |
| Alert Analysis | — | — | ✅ | ✅ | ✅ |
| Pulse Patrol Auto-Fix | — | — | ✅ | ✅ | ✅ |
| Kubernetes Analysis | — | — | ✅ | ✅ | ✅ |
| Centralized Agent Profiles | — | — | ✅ | ✅ | ✅ |
| Update Alerts (Container/Package Updates) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic SSO (OIDC) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Advanced SSO (SAML/Multi-Provider) | — | — | ✅ | ✅ | ✅ |
| Role-Based Access Control (RBAC) | — | — | ✅ | ✅ | ✅ |
| Enterprise Audit Logging | — | — | ✅ | ✅ | ✅ |
| Advanced Infrastructure Reporting (PDF/CSV) | — | — | ✅ | ✅ | ✅ |
| Extended Metric History | 7 days | 14 days | 90 days | 90 days | Hosted entitlements |
Pulse Patrol runs on your schedule (every 10 minutes to every 7 days, default 6 hours) and finds:
- ZFS pools approaching capacity
@ -114,17 +148,17 @@ Pulse Patrol runs on your schedule (every 10 minutes to every 7 days, default 6
- Clock drift across cluster nodes
- Container health check failures
Pulse Patrol uses your configured provider (BYOK) and runs entirely on your server.
On self-hosted installs, Pulse Patrol uses your configured provider (BYOK) and runs entirely on your server.
Technical highlights:
- Cross-system context (nodes, VMs, backups, containers, and metrics history)
- LLM analysis with your provider + alert-triggered deep dives (Pro)
- LLM analysis with your provider + alert-triggered deep dives (Pro/Pro+/Cloud)
- Optional auto-fix with command safety policies and audit trail
- Centralized agent profiles for consistent fleet settings
**[Try the live demo →](https://demo.pulserelay.pro)** or **[learn more at pulserelay.pro](https://pulserelay.pro)**
Pulse Pro technical details: [docs/PULSE_PRO.md](docs/PULSE_PRO.md)
Pulse plan technical details: [docs/PULSE_PRO.md](docs/PULSE_PRO.md)
## ❤️ Support Pulse Development

View file

@ -213,14 +213,14 @@ Legacy environment seeding:
sudo systemctl edit pulse
# Add:
[Service]
Environment="API_TOKENS=ansible-token,docker-agent-token"
Environment="API_TOKENS=ansible-token,agent-token"
Environment="API_TOKEN=legacy-token"
# Then restart:
sudo systemctl restart pulse
# Docker
docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest
docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest
```
### Option 2: Allow Unprotected Export (Homelab)
@ -317,6 +317,17 @@ together.
> **Note**: `DISABLE_AUTH` is deprecated and no longer disables authentication. Remove it from your environment and restart if it's still present.
### SSO / Single Sign-On
Pulse supports **OIDC** and **SAML** SSO providers with multi-provider configuration:
- **OIDC**: Google, Authentik, Keycloak, Auth0, or any compliant provider.
- **SAML**: For enterprise IdPs that use SAML assertions.
- Multiple providers can be enabled simultaneously; the login page shows all available SSO buttons.
- Configure via **Settings → Security → SSO Providers** (admin required).
See `docs/PROXY_AUTH.md` for proxy-based auth (Authentik, Authelia, Cloudflare).
### Password Authentication
#### Quick Security Setup (Recommended)
@ -376,10 +387,10 @@ The Quick Security Setup automatically:
sudo systemctl edit pulse
# Add:
[Service]
Environment="API_TOKENS=ansible-token,docker-agent-token"
Environment="API_TOKENS=ansible-token,agent-token"
# Docker
docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest
docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest
# To provide pre-hashed tokens instead, list the SHA3-256 hashes
# Environment="API_TOKENS=83c8...,b1de..."
@ -409,6 +420,27 @@ curl -X POST \
Most API endpoints also accept `Authorization: Bearer <token>`, but export/import uses the `X-API-Token` header.
### Scoped API Tokens
API tokens can be scoped to limit access. Available scopes:
| Scope | Purpose |
|---|---|
| `monitoring:read` | Read resource data, metrics, charts |
| `monitoring:write` | Update metadata, trigger discovery |
| `settings:read` | Read configuration, export |
| `settings:write` | Modify settings, import, manage nodes |
| `ai:chat` | Use the AI chat assistant |
| `ai:execute` | Run AI commands, view patrol findings |
| `docker:report` | Docker agent metric reporting |
| `kubernetes:report` | Kubernetes agent metric reporting |
| `agent:report` | Agent metric reporting |
| `docker:manage` | Docker host management actions |
| `kubernetes:manage` | Kubernetes cluster management actions |
| `agent:manage` | Agent configuration updates |
Endpoints enforce scope checks before processing. A token without the required scope receives `403 Forbidden`.
### Auto-Registration Security
#### Default Mode
@ -520,7 +552,26 @@ curl -s http://localhost:7655/api/monitoring/scheduler/health | jq
- **Backoff Delays**: Increased backoff may indicate rate limiting or errors
- **Error Rates**: Track failed API calls and authentication attempts
There is currently no dedicated scheduler-health UI in v5. Use the API endpoint above (or export diagnostics from **Settings → Diagnostics**) when troubleshooting.
Use the API endpoint above or export diagnostics from **Settings → Diagnostics** when troubleshooting.
### Relay Security (Pro)
The relay protocol provides mobile remote access with end-to-end encryption:
- **ECDH key exchange**: Per-channel encryption keys are derived via Elliptic Curve Diffie-Hellman, meaning the relay server never sees plaintext data.
- **Per-channel authentication**: Each mobile session authenticates independently.
- **Back-pressure**: Data limiters prevent channel flooding.
- **License-gated**: Relay functionality requires a Pro or Cloud license.
- **Configurable**: Enable/disable via **Settings → Relay** (admin only).
### Agent Command Security
**Agent commands are disabled by default.** This prevents the AI subsystem from executing arbitrary commands on monitored hosts.
- Operators must explicitly opt in with `--enable-commands` on the agent.
- Even when enabled, commands require `ai:execute` scope and admin privileges.
- All command executions are logged to the audit trail.
- Circuit breakers automatically halt execution when error thresholds are exceeded.
## Security Best Practices
@ -575,7 +626,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
curl -X POST http://localhost:7655/api/security/reset-lockout \
-H "X-API-Token: your-api-token" \
-H "Content-Type: application/json" \
-d '{"identifier":"192.168.1.100"}'
-d '{"identifier":"198.51.100.100"}'
```
## Troubleshooting

View file

@ -1 +1 @@
5.1.24
6.0.0-rc.1

View file

@ -9,7 +9,7 @@
//
// Options:
//
// -scenario string Scenario to run: smoke, readonly, enforce, routing, routing-recovery, logs, readonly-recovery, search-id, disambiguate, context-target, discovery, writeverify, strict, strict-block, strict-recovery, readonly-guardrails, noninteractive, approval, approval-approve, approval-deny, approval-combo, patrol, patrol-basic, patrol-investigation, patrol-finding-quality, patrol-signal-coverage, matrix, all (default "smoke")
// -scenario string Scenario to run: smoke, readonly, enforce, routing, routing-recovery, logs, readonly-recovery, search-id, disambiguate, context-target, discovery, writeverify, explore, explore-followup, explore-readonly, explore-missing, explore-suite, strict, strict-block, strict-recovery, readonly-guardrails, noninteractive, approval, approval-approve, approval-deny, approval-combo, patrol, patrol-basic, patrol-investigation, patrol-finding-quality, patrol-signal-coverage, matrix, all (default "smoke")
// -url string Pulse API base URL (default "http://127.0.0.1:7655")
// -user string Username for auth (default "admin")
// -pass string Password for auth (default "admin")
@ -22,6 +22,7 @@ package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@ -35,7 +36,7 @@ import (
)
func main() {
scenario := flag.String("scenario", "smoke", "Scenario to run: smoke, readonly, enforce, routing, routing-recovery, logs, readonly-recovery, search-id, disambiguate, context-target, discovery, writeverify, guest-control, guest-idempotent, guest-discovery, guest-natural, guest-multi, readonly-filtering, read-loop-recovery, ambiguous-intent, strict, strict-block, strict-recovery, readonly-guardrails, noninteractive, approval, approval-approve, approval-deny, approval-combo, patrol, patrol-basic, patrol-investigation, patrol-finding-quality, patrol-signal-coverage, matrix, all")
scenario := flag.String("scenario", "smoke", "Scenario to run: smoke, readonly, enforce, routing, routing-recovery, logs, readonly-recovery, search-id, disambiguate, context-target, discovery, writeverify, guest-control, guest-idempotent, guest-discovery, guest-natural, guest-multi, readonly-filtering, read-loop-recovery, explore, explore-followup, explore-readonly, explore-missing, explore-suite, ambiguous-intent, strict, strict-block, strict-recovery, readonly-guardrails, noninteractive, approval, approval-approve, approval-deny, approval-combo, patrol, patrol-basic, patrol-investigation, patrol-finding-quality, patrol-signal-coverage, matrix, all")
url := flag.String("url", "http://127.0.0.1:7655", "Pulse API base URL")
user := flag.String("user", "admin", "Username for auth")
pass := flag.String("pass", "admin", "Password for auth")
@ -198,6 +199,11 @@ func listScenarios() {
fmt.Println(" Safety & Filtering:")
fmt.Println(" readonly-filtering - Control tools excluded from read-only queries (3 steps)")
fmt.Println(" read-loop-recovery - Model produces text after budget blocks (2 steps)")
fmt.Println(" explore - Explore pre-pass status lifecycle baseline (1 step)")
fmt.Println(" explore-followup - Explore lifecycle across follow-up turns (2 steps)")
fmt.Println(" explore-readonly - Explore + read-only safety checks (1 step)")
fmt.Println(" explore-missing - Explore behavior when resource lookup misses (1 step)")
fmt.Println(" explore-suite - Run all explore-focused scenarios")
fmt.Println(" ambiguous-intent - Ambiguous requests default to read-only (3 steps)")
fmt.Println()
fmt.Println(" Advanced:")
@ -297,6 +303,21 @@ func getScenarios(name string) []eval.Scenario {
return []eval.Scenario{eval.ReadOnlyToolFilteringScenario()}
case "read-loop-recovery":
return []eval.Scenario{eval.ReadLoopRecoveryScenario()}
case "explore":
return []eval.Scenario{eval.ExploreStatusLifecycleScenario()}
case "explore-followup":
return []eval.Scenario{eval.ExploreFollowupScenario()}
case "explore-readonly":
return []eval.Scenario{eval.ExploreReadOnlySafetyScenario()}
case "explore-missing":
return []eval.Scenario{eval.ExploreMissingTargetScenario()}
case "explore-suite":
return []eval.Scenario{
eval.ExploreStatusLifecycleScenario(),
eval.ExploreFollowupScenario(),
eval.ExploreReadOnlySafetyScenario(),
eval.ExploreMissingTargetScenario(),
}
case "ambiguous-intent":
return []eval.Scenario{eval.AmbiguousIntentScenario()}
@ -373,6 +394,10 @@ func getScenarios(name string) []eval.Scenario {
eval.GuestControlMultiMentionScenario(),
eval.ReadOnlyToolFilteringScenario(),
eval.ReadLoopRecoveryScenario(),
eval.ExploreStatusLifecycleScenario(),
eval.ExploreFollowupScenario(),
eval.ExploreReadOnlySafetyScenario(),
eval.ExploreMissingTargetScenario(),
eval.AmbiguousIntentScenario(),
eval.StrictResolutionScenario(),
eval.StrictResolutionBlockScenario(),
@ -409,6 +434,10 @@ func getScenarios(name string) []eval.Scenario {
eval.GuestControlMultiMentionScenario(),
eval.ReadOnlyToolFilteringScenario(),
eval.ReadLoopRecoveryScenario(),
eval.ExploreStatusLifecycleScenario(),
eval.ExploreFollowupScenario(),
eval.ExploreReadOnlySafetyScenario(),
eval.ExploreMissingTargetScenario(),
eval.AmbiguousIntentScenario(),
eval.StrictResolutionScenario(),
eval.StrictResolutionBlockScenario(),
@ -509,19 +538,36 @@ func fetchAutoModels(baseURL, user, pass string) ([]string, []autoSelectionDetai
if err != nil {
return nil, nil, nil, fmt.Errorf("models request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if readErr != nil {
readBodyErr := fmt.Errorf("failed to read models error response body: %w", readErr)
if closeErr != nil {
return nil, nil, nil, errors.Join(readBodyErr, fmt.Errorf("failed to close models response body: %w", closeErr))
}
return nil, nil, nil, readBodyErr
}
if closeErr != nil {
return nil, nil, nil, fmt.Errorf("failed to close models response body: %w", closeErr)
}
return nil, nil, nil, fmt.Errorf("models request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload apiModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, nil, nil, fmt.Errorf("failed to decode models response: %w", err)
decodeErr := fmt.Errorf("failed to decode models response: %w", err)
if closeErr := resp.Body.Close(); closeErr != nil {
return nil, nil, nil, errors.Join(decodeErr, fmt.Errorf("failed to close models response body: %w", closeErr))
}
return nil, nil, nil, decodeErr
}
if closeErr := resp.Body.Close(); closeErr != nil {
return nil, nil, nil, fmt.Errorf("failed to close models response body: %w", closeErr)
}
if payload.Error != "" {
return nil, nil, nil, fmt.Errorf("%s", payload.Error)
return nil, nil, nil, fmt.Errorf("models API returned error: %s", payload.Error)
}
providerFilter := parseProviderFilterWithDefault(os.Getenv("EVAL_MODEL_PROVIDERS"))
@ -630,11 +676,12 @@ func parseProviderFilterWithDefault(raw string) map[string]bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return map[string]bool{
"openai": true,
"anthropic": true,
"deepseek": true,
"gemini": true,
"ollama": true,
"openai": true,
"openrouter": true,
"anthropic": true,
"deepseek": true,
"gemini": true,
"ollama": true,
}
}
return parseProviderFilter(raw)

View file

@ -5,8 +5,6 @@ import (
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/rs/zerolog"
)
func TestHealthHandler_HealthzAlwaysOK(t *testing.T) {
@ -41,13 +39,3 @@ func TestHealthHandler_ReadyzDependsOnReadyFlag(t *testing.T) {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestRunAsWindowsService_Stub(t *testing.T) {
ran, err := runAsWindowsService(Config{}, zerolog.Nop())
if err != nil {
t.Fatalf("err = %v", err)
}
if ran {
t.Fatalf("expected ran=false on non-windows")
}
}

View file

@ -4,10 +4,12 @@ import (
"context"
"flag"
"fmt"
"math"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"reflect"
"strconv"
"strings"
@ -102,7 +104,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
// 1. Parse Configuration
cfg, err := loadConfig(args, getenv)
if err != nil {
return err
return fmt.Errorf("failed to load unified agent configuration: %w", err)
}
// 2. Setup Logging
@ -111,7 +113,10 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
cfg.Logger = &logger
if cfg.InsecureSkipVerify {
logger.Warn().Msg("TLS verification disabled for agent connections (self-signed cert mode)")
logger.Warn().
Str("component", "startup").
Str("action", "tls_skip_verify_enabled").
Msg("TLS verification disabled for agent connections (self-signed cert mode)")
}
// 2a. Handle Self-Test
@ -140,7 +145,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
cfg.AgentID = lookupHostname
}
} else {
logger.Warn().Err(err).Msg("Failed to fetch host info for Agent ID generation")
logger.Warn().
Err(err).
Str("component", "startup").
Str("action", "agent_id_host_info_failed").
Msg("Failed to fetch host info for Agent ID generation")
}
}
if lookupHostname == "" {
@ -162,8 +171,10 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
AgentID: cfg.AgentID,
Hostname: lookupHostname,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CACertPath: cfg.CACertPath,
Logger: logger,
})
defer rc.Close()
// Use a short timeout for config fetch so we don't block startup too long
rcCtx, rcCancel := context.WithTimeout(ctx, 10*time.Second)
@ -172,12 +183,23 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if err != nil {
// Just log warning and proceed with local config
logger.Warn().Err(err).Msg("Failed to fetch remote config - using local (or previously cached) defaults")
logger.Warn().
Err(err).
Str("component", "remote_config").
Str("action", "fetch_failed").
Msg("Failed to fetch remote config - using local (or previously cached) defaults")
} else {
logger.Info().Msg("Successfully fetched remote configuration")
logger.Info().
Str("component", "remote_config").
Str("action", "fetch_succeeded").
Msg("Successfully fetched remote configuration")
if commandsEnabled != nil {
cfg.EnableCommands = *commandsEnabled
logger.Info().Bool("enabled", cfg.EnableCommands).Msg("Applied remote command execution setting")
logger.Info().
Str("component", "remote_config").
Str("action", "apply_enable_commands").
Bool("enabled", cfg.EnableCommands).
Msg("Applied remote command execution setting")
}
if len(settings) > 0 {
applyRemoteSettings(&cfg, settings, &logger)
@ -199,9 +221,9 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
logger.Info().
Str("version", Version).
Str("pulse_url", cfg.PulseURL).
Bool("host_agent", cfg.EnableHost).
Bool("docker_agent", cfg.EnableDocker).
Bool("kubernetes_agent", cfg.EnableKubernetes).
Bool("host_enabled", cfg.EnableHost).
Bool("docker_enabled", cfg.EnableDocker).
Bool("kubernetes_enabled", cfg.EnableKubernetes).
Bool("proxmox_mode", cfg.EnableProxmox).
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse Unified Agent")
@ -229,6 +251,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
CurrentVersion: Version,
CheckInterval: 1 * time.Hour,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CACertPath: cfg.CACertPath,
Logger: &logger,
Disabled: cfg.DisableAutoUpdate,
})
@ -250,13 +273,17 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
AgentVersion: Version,
Tags: cfg.Tags,
InsecureSkipVerify: cfg.InsecureSkipVerify,
CACertPath: cfg.CACertPath,
LogLevel: cfg.LogLevel,
Logger: &logger,
EnableProxmox: cfg.EnableProxmox,
ProxmoxType: cfg.ProxmoxType,
EnableCommands: cfg.EnableCommands,
Enroll: cfg.Enroll,
DiskExclude: cfg.DiskExclude,
StateDir: cfg.StateDir,
ReportIP: cfg.ReportIP,
DisableCeph: cfg.DisableCeph,
}
agent, err := newHostAgent(hostCfg)
@ -312,7 +339,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
dockerAgent, err = newDockerAgent(dockerCfg)
if err != nil {
// Docker isn't available yet - start retry loop in background
logger.Warn().Err(err).Msg("Docker not available, will retry with exponential backoff")
logger.Warn().
Err(err).
Str("component", "docker_agent").
Str("action", "initialization_failed_retry_scheduled").
Msg("Docker not available, will retry with exponential backoff")
g.Go(func() error {
agent := initDockerWithRetry(ctx, dockerCfg, &logger)
@ -355,7 +386,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
agent, err := newKubeAgent(kubeCfg)
if err != nil {
logger.Warn().Err(err).Msg("Kubernetes not available, will retry with exponential backoff")
logger.Warn().
Err(err).
Str("component", "kubernetes_agent").
Str("action", "initialization_failed_retry_scheduled").
Msg("Kubernetes not available, will retry with exponential backoff")
g.Go(func() error {
retried := initKubernetesWithRetry(ctx, kubeCfg, &logger)
@ -381,7 +416,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
logger.Error().Err(err).Msg("Agent terminated with error")
agentUp.Set(0)
cleanupDockerAgent(dockerAgent, &logger)
return err
return fmt.Errorf("unified agent runtime failed: %w", err)
}
// 12. Cleanup
@ -397,7 +432,11 @@ func cleanupDockerAgent(agent RunnableCloser, logger *zerolog.Logger) {
return
}
if err := agent.Close(); err != nil {
logger.Warn().Err(err).Msg("Failed to close docker agent")
logger.Warn().
Err(err).
Str("component", "docker_agent").
Str("action", "shutdown_failed").
Msg("Failed to close docker agent")
}
}
@ -440,14 +479,28 @@ func startHealthServer(ctx context.Context, addr string, ready *atomic.Bool, log
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil && err != http.ErrServerClosed {
logger.Warn().Err(err).Msg("Failed to shut down health server")
logger.Warn().
Err(err).
Str("component", "health_server").
Str("action", "shutdown_failed").
Str("addr", addr).
Msg("Failed to shut down health server")
}
}()
go func() {
logger.Info().Str("addr", addr).Msg("Health/metrics server listening")
logger.Info().
Str("component", "health_server").
Str("action", "listening").
Str("addr", addr).
Msg("Health/metrics server listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Warn().Err(err).Msg("Health server stopped unexpectedly")
logger.Warn().
Err(err).
Str("component", "health_server").
Str("action", "stopped_unexpectedly").
Str("addr", addr).
Msg("Health server stopped unexpectedly")
}
}()
}
@ -460,6 +513,7 @@ type Config struct {
AgentID string
Tags []string
InsecureSkipVerify bool
CACertPath string
LogLevel zerolog.Level
Logger *zerolog.Logger
@ -479,6 +533,12 @@ type Config struct {
// Security
EnableCommands bool // Enable command execution for AI auto-fix (disabled by default)
// Enrollment
Enroll bool // Exchange bootstrap token for runtime token on startup
// State directory
StateDir string // Persistent state directory for agent-id, proxmox registration, etc.
// Disk filtering
DiskExclude []string // Mount points or patterns to exclude from disk monitoring
@ -508,6 +568,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
envHostname := strings.TrimSpace(getenv("PULSE_HOSTNAME"))
envAgentID := strings.TrimSpace(getenv("PULSE_AGENT_ID"))
envInsecure := strings.TrimSpace(getenv("PULSE_INSECURE_SKIP_VERIFY"))
envCACertPath := strings.TrimSpace(getenv("PULSE_CACERT"))
envTags := strings.TrimSpace(getenv("PULSE_TAGS"))
envLogLevel := strings.TrimSpace(getenv("LOG_LEVEL"))
envEnableHost := strings.TrimSpace(getenv("PULSE_ENABLE_HOST"))
@ -532,6 +593,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
}
envKubeIncludeAllDeployments := strings.TrimSpace(getenv("PULSE_KUBE_INCLUDE_ALL_DEPLOYMENTS"))
envKubeMaxPods := strings.TrimSpace(getenv("PULSE_KUBE_MAX_PODS"))
envStateDir := strings.TrimSpace(getenv("PULSE_STATE_DIR"))
envDiskExclude := strings.TrimSpace(getenv("PULSE_DISK_EXCLUDE"))
envReportIP := strings.TrimSpace(getenv("PULSE_REPORT_IP"))
envDisableCeph := strings.TrimSpace(getenv("PULSE_DISABLE_CEPH"))
@ -578,6 +640,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
hostnameFlag := fs.String("hostname", envHostname, "Override hostname")
agentIDFlag := fs.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := fs.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS verification")
caCertFlag := fs.String("cacert", envCACertPath, "Path to custom CA bundle for agent HTTPS transport")
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level")
enableHostFlag := fs.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
@ -590,12 +653,14 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
dockerRuntimeFlag := fs.String("docker-runtime", envDockerRuntime, "Container runtime: auto, docker, or podman (default: auto)")
enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable command execution for AI auto-fix (disabled by default)")
disableCommandsFlag := fs.Bool("disable-commands", false, "[DEPRECATED] Commands are now disabled by default; use --enable-commands to enable")
enrollFlag := fs.Bool("enroll", false, "Exchange bootstrap token for runtime token (used by deploy wizard)")
healthAddrFlag := fs.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)")
kubeconfigFlag := fs.String("kubeconfig", envKubeconfig, "Path to kubeconfig (optional; uses in-cluster config if available)")
kubeContextFlag := fs.String("kube-context", envKubeContext, "Kubeconfig context (optional)")
kubeIncludeAllPodsFlag := fs.Bool("kube-include-all-pods", utils.ParseBool(envKubeIncludeAllPods), "Include all non-succeeded pods (may be large)")
kubeIncludeAllDeploymentsFlag := fs.Bool("kube-include-all-deployments", utils.ParseBool(envKubeIncludeAllDeployments), "Include all deployments, not just problem ones")
kubeMaxPodsFlag := fs.Int("kube-max-pods", defaultInt(envKubeMaxPods, 200), "Max pods included in report")
stateDirFlag := fs.String("state-dir", envStateDir, "Persistent state directory (default: /var/lib/pulse-agent)")
reportIPFlag := fs.String("report-ip", envReportIP, "IP address to report (for multi-NIC systems)")
disableCephFlag := fs.Bool("disable-ceph", utils.ParseBool(envDisableCeph), "Disable local Ceph status polling")
showVersion := fs.Bool("version", false, "Print the agent version and exit")
@ -627,13 +692,43 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
// Resolve token with priority: --token > --token-file > env > default file
token := resolveToken(*tokenFlag, *tokenFileFlag, envToken)
// When --enroll is set and a runtime token already exists from a previous
// enrollment, use it instead of the bootstrap token embedded in the service
// config. This ensures the agent survives restarts after enrollment.
stateDir := strings.TrimSpace(*stateDirFlag)
if *enrollFlag {
enrollStateDir := stateDir
if enrollStateDir == "" {
enrollStateDir = "/var/lib/pulse-agent"
}
runtimeTokenPath := filepath.Join(enrollStateDir, "runtime.token")
if content, err := os.ReadFile(runtimeTokenPath); err == nil {
if t := strings.TrimSpace(string(content)); t != "" {
token = t
}
}
}
if token == "" && !*selfTest {
return Config{}, fmt.Errorf("Pulse API token is required (use --token, --token-file, PULSE_TOKEN env, or /var/lib/pulse-agent/token)")
}
logLevel, err := parseLogLevel(*logLevelFlag)
if err != nil {
logLevel = zerolog.InfoLevel
return Config{}, fmt.Errorf("invalid log level %q: %w", strings.TrimSpace(*logLevelFlag), err)
}
interval := *intervalFlag
if interval <= 0 {
return Config{}, fmt.Errorf("interval must be greater than 0 (got %s)", interval)
}
kubeMaxPods := *kubeMaxPodsFlag
if kubeMaxPods <= 0 {
return Config{}, fmt.Errorf("kube-max-pods must be greater than 0 (got %d)", kubeMaxPods)
}
dockerRuntime, err := normalizeDockerRuntime(*dockerRuntimeFlag)
if err != nil {
return Config{}, err
}
tags := gatherTags(envTags, tagFlags)
@ -654,11 +749,12 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
return Config{
PulseURL: pulseURL,
APIToken: token,
Interval: *intervalFlag,
Interval: interval,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
CACertPath: strings.TrimSpace(*caCertFlag),
LogLevel: logLevel,
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
@ -668,8 +764,9 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag),
DisableAutoUpdate: *disableAutoUpdateFlag,
DisableDockerUpdateChecks: *disableDockerUpdateChecksFlag,
DockerRuntime: strings.TrimSpace(*dockerRuntimeFlag),
DockerRuntime: dockerRuntime,
EnableCommands: resolveEnableCommands(*enableCommandsFlag, *disableCommandsFlag, envEnableCommands, envDisableCommands),
Enroll: *enrollFlag,
HealthAddr: strings.TrimSpace(*healthAddrFlag),
KubeconfigPath: strings.TrimSpace(*kubeconfigFlag),
KubeContext: strings.TrimSpace(*kubeContextFlag),
@ -677,7 +774,8 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
KubeExcludeNamespaces: kubeExcludeNamespaces,
KubeIncludeAllPods: *kubeIncludeAllPodsFlag,
KubeIncludeAllDeployments: *kubeIncludeAllDeploymentsFlag,
KubeMaxPods: *kubeMaxPodsFlag,
KubeMaxPods: kubeMaxPods,
StateDir: strings.TrimSpace(*stateDirFlag),
DiskExclude: diskExclude,
ReportIP: strings.TrimSpace(*reportIPFlag),
DisableCeph: *disableCephFlag,
@ -735,6 +833,18 @@ func defaultInt(value string, fallback int) int {
return parsed
}
func normalizeDockerRuntime(value string) (string, error) {
runtime := strings.ToLower(strings.TrimSpace(value))
switch runtime {
case "", "auto", "default":
return "", nil
case "docker", "podman":
return runtime, nil
default:
return "", fmt.Errorf("invalid docker runtime %q: must be auto, docker, or podman", value)
}
}
func parseLogLevel(value string) (zerolog.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
@ -835,26 +945,36 @@ func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *ze
attempt := 0
for {
if err := ctx.Err(); err != nil {
logger.Info().Msg("Docker retry cancelled, context done")
return nil
}
agent, err := newDockerAgent(cfg)
if err == nil {
logger.Info().
Str("component", "docker_agent").
Str("action", "retry_connect_succeeded").
Int("attempts", attempt+1).
Msg("Successfully connected to Docker after retry")
return agent
}
attempt++
logger.Warn().
retryLogEvent(logger, attempt).
Err(err).
Str("component", "docker_agent").
Str("action", "retry_connect_failed").
Int("attempt", attempt).
Str("next_retry", delay.String()).
Msg("Docker not available, will retry")
select {
case <-ctx.Done():
logger.Info().Msg("Docker retry cancelled, context done")
if !waitForRetryDelay(ctx, delay) {
logger.Info().
Str("component", "docker_agent").
Str("action", "retry_cancelled").
Msg("Docker retry cancelled, context done")
return nil
case <-time.After(delay):
}
// Calculate next delay with exponential backoff, capped at retryMaxDelay
@ -875,26 +995,36 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo
attempt := 0
for {
if err := ctx.Err(); err != nil {
logger.Info().Msg("Kubernetes retry cancelled, context done")
return nil
}
agent, err := newKubeAgent(cfg)
if err == nil {
logger.Info().
Str("component", "kubernetes_agent").
Str("action", "retry_connect_succeeded").
Int("attempts", attempt+1).
Msg("Successfully connected to Kubernetes after retry")
return agent
}
attempt++
logger.Warn().
retryLogEvent(logger, attempt).
Err(err).
Str("component", "kubernetes_agent").
Str("action", "retry_connect_failed").
Int("attempt", attempt).
Str("next_retry", delay.String()).
Msg("Kubernetes still not available, will retry")
select {
case <-ctx.Done():
logger.Info().Msg("Kubernetes retry cancelled, context done")
if !waitForRetryDelay(ctx, delay) {
logger.Info().
Str("component", "kubernetes_agent").
Str("action", "retry_cancelled").
Msg("Kubernetes retry cancelled, context done")
return nil
case <-time.After(delay):
}
// Calculate next delay with exponential backoff, capped at retryMaxDelay
@ -905,6 +1035,41 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo
}
}
func waitForRetryDelay(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer func() {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// retryLogEvent returns a zerolog event at a level that decreases with attempt count
// to avoid flooding logs on misconfigured systems with unbounded retries.
// - Attempts 1-10: Warn (initial visibility)
// - Attempts 11-50: Info (still visible, less noisy)
// - Attempts 51+: Debug (effectively silent unless debug logging enabled)
func retryLogEvent(logger *zerolog.Logger, attempt int) *zerolog.Event {
switch {
case attempt <= 10:
return logger.Warn()
case attempt <= 50:
return logger.Info()
default:
return logger.Debug()
}
}
// applyRemoteSettings merges remote settings into the local configuration.
// Supported keys:
// - enable_host (bool)
@ -956,7 +1121,12 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
}
case "docker_runtime":
if s, ok := v.(string); ok {
cfg.DockerRuntime = strings.TrimSpace(strings.ToLower(s))
runtime, err := normalizeDockerRuntime(s)
if err != nil {
logger.Warn().Str("val", s).Msg("Remote config: ignoring invalid docker_runtime value")
continue
}
cfg.DockerRuntime = runtime
logger.Info().Str("val", s).Msg("Remote config: docker_runtime")
}
case "log_level":
@ -972,13 +1142,19 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
}
case "interval":
if s, ok := v.(string); ok {
if d, err := time.ParseDuration(s); err == nil {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
cfg.Interval = d
logger.Info().Str("val", s).Msg("Remote config: interval")
} else {
logger.Warn().Str("val", s).Msg("Remote config: ignoring invalid interval value")
}
} else if f, ok := v.(float64); ok {
// JSON numbers are floats, assume seconds
cfg.Interval = time.Duration(f) * time.Second
if math.IsNaN(f) || math.IsInf(f, 0) || f <= 0 {
logger.Warn().Float64("val", f).Msg("Remote config: ignoring invalid interval value")
continue
}
// JSON numbers are floats, assume seconds.
cfg.Interval = time.Duration(f * float64(time.Second))
logger.Info().Float64("val", f).Msg("Remote config: interval (s)")
}
case "disable_auto_update":
@ -1013,4 +1189,74 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
}
}
}
if d, ok := remoteDurationSetting(settings, "interval"); ok && d > 0 {
cfg.Interval = d
logger.Info().Dur("val", d).Msg("Remote config: interval")
}
if b, ok := remoteBoolSetting(settings, "disable_auto_update"); ok {
cfg.DisableAutoUpdate = b
logger.Info().Bool("val", b).Msg("Remote config: disable_auto_update")
}
if b, ok := remoteBoolSetting(settings, "disable_docker_update_checks"); ok {
cfg.DisableDockerUpdateChecks = b
logger.Info().Bool("val", b).Msg("Remote config: disable_docker_update_checks")
}
if b, ok := remoteBoolSetting(settings, "kube_include_all_pods"); ok {
cfg.KubeIncludeAllPods = b
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_pods")
}
if b, ok := remoteBoolSetting(settings, "kube_include_all_deployments"); ok {
cfg.KubeIncludeAllDeployments = b
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_deployments")
}
if s, ok := remoteStringSetting(settings, "report_ip"); ok {
cfg.ReportIP = s
logger.Info().Str("val", s).Msg("Remote config: report_ip")
}
if b, ok := remoteBoolSetting(settings, "disable_ceph"); ok {
cfg.DisableCeph = b
logger.Info().Bool("val", b).Msg("Remote config: disable_ceph")
}
}
func remoteBoolSetting(settings map[string]interface{}, key string) (bool, bool) {
value, ok := settings[key]
if !ok {
return false, false
}
parsed, ok := value.(bool)
return parsed, ok
}
func remoteStringSetting(settings map[string]interface{}, key string) (string, bool) {
value, ok := settings[key]
if !ok {
return "", false
}
parsed, ok := value.(string)
return parsed, ok
}
func remoteDurationSetting(settings map[string]interface{}, key string) (time.Duration, bool) {
value, ok := settings[key]
if !ok {
return 0, false
}
switch typed := value.(type) {
case string:
parsed, err := time.ParseDuration(typed)
if err != nil {
return 0, false
}
return parsed, true
case float64:
return time.Duration(typed) * time.Second, true
case int:
return time.Duration(typed) * time.Second, true
case int64:
return time.Duration(typed) * time.Second, true
default:
return 0, false
}
}

View file

@ -0,0 +1,55 @@
package main
import (
"testing"
"github.com/rs/zerolog"
)
func BenchmarkLoadConfig(b *testing.B) {
args := []string{"-token", "test-token", "-url", "http://localhost:7655"}
env := func(key string) string {
switch key {
case "PULSE_URL":
return "http://localhost:7655"
case "PULSE_TOKEN":
return "test-token"
default:
return ""
}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = loadConfig(args, env)
}
}
func BenchmarkParseLogLevel(b *testing.B) {
levels := []string{"debug", "info", "warn", "error", "INFO", " debug ", ""}
for _, level := range levels {
b.Run(level, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = parseLogLevel(level)
}
})
}
}
func BenchmarkRetryLogEvent(b *testing.B) {
logger := zerolog.New(zerolog.NewConsoleWriter()).Level(zerolog.DebugLevel)
attempts := []int{1, 10, 11, 50, 51, 100}
for _, attempt := range attempts {
b.Run("", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
event := retryLogEvent(&logger, attempt)
event.Discard()
}
})
}
}

View file

@ -256,6 +256,34 @@ func TestApplyRemoteSettingsIntervalFloat(t *testing.T) {
}
}
func TestApplyRemoteSettingsIgnoresInvalidValues(t *testing.T) {
logger := zerolog.New(io.Discard)
cfg := &Config{
Interval: 30 * time.Second,
DockerRuntime: "docker",
}
applyRemoteSettings(cfg, map[string]interface{}{
"interval": "invalid",
"docker_runtime": "not-a-runtime",
}, &logger)
if cfg.Interval != 30*time.Second {
t.Fatalf("expected interval to remain unchanged, got %v", cfg.Interval)
}
if cfg.DockerRuntime != "docker" {
t.Fatalf("expected docker runtime to remain unchanged, got %q", cfg.DockerRuntime)
}
applyRemoteSettings(cfg, map[string]interface{}{
"interval": float64(0),
}, &logger)
if cfg.Interval != 30*time.Second {
t.Fatalf("expected non-positive numeric interval to be ignored, got %v", cfg.Interval)
}
}
func TestDefaultInt(t *testing.T) {
tests := []struct {
name string
@ -775,6 +803,7 @@ func TestLoadConfig(t *testing.T) {
"PULSE_TOKEN": "my-token",
"PULSE_ENABLE_HOST": "false",
"PULSE_ENABLE_DOCKER": "true",
"PULSE_CACERT": "/etc/pulse/ca.pem",
}
cfg, err := loadConfig([]string{}, func(s string) string { return env[s] })
if err != nil {
@ -792,10 +821,13 @@ func TestLoadConfig(t *testing.T) {
if cfg.EnableDocker != true {
t.Errorf("expected docker enabled by env")
}
if cfg.CACertPath != "/etc/pulse/ca.pem" {
t.Errorf("expected CA cert path from env, got %s", cfg.CACertPath)
}
})
t.Run("flag overrides", func(t *testing.T) {
cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false"}, func(s string) string { return "" })
cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false", "-cacert", "/tmp/custom-ca.pem"}, func(s string) string { return "" })
if err != nil {
t.Fatal(err)
}
@ -808,6 +840,9 @@ func TestLoadConfig(t *testing.T) {
if cfg.EnableHost != false {
t.Errorf("expected host disabled by flag")
}
if cfg.CACertPath != "/tmp/custom-ca.pem" {
t.Errorf("expected CA cert path from flag, got %s", cfg.CACertPath)
}
})
t.Run("invalid interval flag", func(t *testing.T) {
@ -817,6 +852,34 @@ func TestLoadConfig(t *testing.T) {
}
})
t.Run("non-positive interval returns error", func(t *testing.T) {
_, err := loadConfig([]string{"-token", "test-token", "-interval", "0s"}, func(s string) string { return "" })
if err == nil {
t.Fatal("expected error for non-positive interval")
}
})
t.Run("invalid kube max pods returns error", func(t *testing.T) {
_, err := loadConfig([]string{"-token", "test-token", "-kube-max-pods", "0"}, func(s string) string { return "" })
if err == nil {
t.Fatal("expected error for non-positive kube-max-pods")
}
})
t.Run("invalid docker runtime returns error", func(t *testing.T) {
_, err := loadConfig([]string{"-token", "test-token", "-docker-runtime", "containerd"}, func(s string) string { return "" })
if err == nil {
t.Fatal("expected error for invalid docker runtime")
}
})
t.Run("invalid log level returns error", func(t *testing.T) {
_, err := loadConfig([]string{"-token", "test-token", "-log-level", "invalid"}, func(s string) string { return "" })
if err == nil {
t.Fatal("expected error for invalid log level")
}
})
t.Run("show version", func(t *testing.T) {
_, err := loadConfig([]string{"-version"}, func(s string) string { return "" })
if err != flag.ErrHelp {
@ -874,6 +937,39 @@ func TestInitDockerWithRetry_Cancel(t *testing.T) {
}
}
func TestInitDockerWithRetry_CancelDuringBackoff(t *testing.T) {
origAgent := newDockerAgent
origInitial := retryInitialDelay
origMax := retryMaxDelay
defer func() {
newDockerAgent = origAgent
retryInitialDelay = origInitial
retryMaxDelay = origMax
}()
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
return nil, errors.New("not available")
}
retryInitialDelay = 5 * time.Second
retryMaxDelay = 5 * time.Second
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
start := time.Now()
logger := zerolog.New(os.Stdout)
agent := initDockerWithRetry(ctx, dockeragent.Config{}, &logger)
if agent != nil {
t.Fatalf("expected nil agent when cancelled")
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("expected prompt cancellation during backoff, took %v", elapsed)
}
}
func TestInitDockerWithRetry_Success(t *testing.T) {
orig := newDockerAgent
defer func() { newDockerAgent = orig }()
@ -925,6 +1021,39 @@ func TestInitKubernetesWithRetry_Cancel(t *testing.T) {
}
}
func TestInitKubernetesWithRetry_CancelDuringBackoff(t *testing.T) {
origAgent := newKubeAgent
origInitial := retryInitialDelay
origMax := retryMaxDelay
defer func() {
newKubeAgent = origAgent
retryInitialDelay = origInitial
retryMaxDelay = origMax
}()
newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) {
return nil, errors.New("not available")
}
retryInitialDelay = 5 * time.Second
retryMaxDelay = 5 * time.Second
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
start := time.Now()
logger := zerolog.New(os.Stdout)
agent := initKubernetesWithRetry(ctx, kubernetesagent.Config{}, &logger)
if agent != nil {
t.Fatalf("expected nil agent when cancelled")
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("expected prompt cancellation during backoff, took %v", elapsed)
}
}
func TestInitKubernetesWithRetry_Success(t *testing.T) {
orig := newKubeAgent
defer func() { newKubeAgent = orig }()
@ -1217,6 +1346,53 @@ func TestRun_AgentFailure(t *testing.T) {
}
}
func TestRun_PropagatesDisableCephToHostAgent(t *testing.T) {
origDocker := newDockerAgent
origKube := newKubeAgent
origHost := newHostAgent
defer func() {
newDockerAgent = origDocker
newKubeAgent = origKube
newHostAgent = origHost
}()
hostCfgCh := make(chan hostagent.Config, 1)
newHostAgent = func(cfg hostagent.Config) (Runnable, error) {
hostCfgCh <- cfg
return &mockRunnable{}, nil
}
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
return &mockRunnableCloser{}, nil
}
newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) {
return &mockRunnable{}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := run(ctx, []string{
"-token", "T",
"-enable-host=true",
"-enable-docker=false",
"-enable-kubernetes=false",
"-disable-ceph=true",
"-health-addr", "127.0.0.1:0",
}, func(string) string { return "" })
if err != nil && err != context.Canceled {
t.Fatalf("run returned unexpected error: %v", err)
}
select {
case hostCfg := <-hostCfgCh:
if !hostCfg.DisableCeph {
t.Fatalf("expected DisableCeph=true on host agent config")
}
default:
t.Fatalf("host agent was not initialized")
}
}
func TestLoadConfig_Comprehensive(t *testing.T) {
tests := []struct {
name string
@ -1521,3 +1697,36 @@ func TestRun_KubeRetry(t *testing.T) {
t.Errorf("expected at least 2 calls to newKubeAgent, got %d", calls)
}
}
func TestRetryLogEvent_LevelThrottling(t *testing.T) {
// Ensure debug events are not filtered by the global level
prev := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(prev) })
tests := []struct {
attempt int
wantLevel string
}{
{1, "warn"},
{5, "warn"},
{10, "warn"},
{11, "info"},
{25, "info"},
{50, "info"},
{51, "debug"},
{100, "debug"},
}
for _, tt := range tests {
var buf strings.Builder
logger := zerolog.New(&buf).Level(zerolog.DebugLevel)
event := retryLogEvent(&logger, tt.attempt)
event.Msg("test")
output := buf.String()
if !strings.Contains(output, `"level":"`+tt.wantLevel+`"`) {
t.Errorf("attempt %d: expected level %q in output, got: %s", tt.attempt, tt.wantLevel, output)
}
}
}

View file

@ -47,6 +47,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
CurrentVersion: Version,
CheckInterval: 1 * time.Hour,
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
CACertPath: ws.cfg.CACertPath,
Logger: &ws.logger,
Disabled: ws.cfg.DisableAutoUpdate,
})
@ -68,6 +69,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
AgentVersion: Version,
Tags: ws.cfg.Tags,
InsecureSkipVerify: ws.cfg.InsecureSkipVerify,
CACertPath: ws.cfg.CACertPath,
LogLevel: ws.cfg.LogLevel,
Logger: &ws.logger,
}
@ -129,8 +131,8 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
ws.logger.Info().
Str("version", Version).
Str("pulse_url", ws.cfg.PulseURL).
Bool("host_agent", ws.cfg.EnableHost).
Bool("docker_agent", ws.cfg.EnableDocker).
Bool("host_enabled", ws.cfg.EnableHost).
Bool("docker_enabled", ws.cfg.EnableDocker).
Msg("Pulse Agent service is running")
if ws.eventLog != nil {
ws.eventLog.Info(1, fmt.Sprintf("Pulse Agent started (URL: %s, Host: %v, Docker: %v)", ws.cfg.PulseURL, ws.cfg.EnableHost, ws.cfg.EnableDocker))
@ -141,6 +143,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
go func() {
doneChan <- g.Wait()
}()
doneReceived := false
// Service control loop
loop:
@ -162,6 +165,7 @@ loop:
ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command")
}
case err := <-doneChan:
doneReceived = true
if err != nil && err != context.Canceled {
ws.logger.Error().Err(err).Msg("Agent error")
if ws.eventLog != nil {
@ -175,19 +179,34 @@ loop:
}
// Wait for agents to stop gracefully (with timeout)
shutdownTimeout := time.NewTimer(10 * time.Second)
defer shutdownTimeout.Stop()
select {
case <-doneChan:
if doneReceived {
ws.logger.Info().Msg("Agents stopped gracefully")
if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Agent stopped gracefully")
}
case <-shutdownTimeout.C:
ws.logger.Warn().Msg("Agent shutdown timeout, forcing stop")
if ws.eventLog != nil {
ws.eventLog.Warning(1, "Pulse Agent shutdown timeout")
} else {
shutdownTimeout := time.NewTimer(10 * time.Second)
defer shutdownTimeout.Stop()
select {
case err := <-doneChan:
if err != nil && err != context.Canceled {
ws.logger.Error().Err(err).Msg("Agent error during shutdown")
if ws.eventLog != nil {
ws.eventLog.Error(1, fmt.Sprintf("Pulse Agent shutdown error: %v", err))
}
changes <- svc.Status{State: svc.Stopped}
return true, 1
}
ws.logger.Info().Msg("Agents stopped gracefully")
if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Agent stopped gracefully")
}
case <-shutdownTimeout.C:
ws.logger.Warn().Msg("Agent shutdown timeout, forcing stop")
if ws.eventLog != nil {
ws.eventLog.Warning(1, "Pulse Agent shutdown timeout")
}
}
}
@ -219,7 +238,9 @@ func runAsWindowsService(cfg Config, logger zerolog.Logger) (ranAsService bool,
}
defer func() {
if elog != nil {
elog.Close()
if closeErr := elog.Close(); closeErr != nil {
logger.Warn().Err(closeErr).Msg("Failed to close Windows Event Log handle")
}
}
}()

View file

@ -0,0 +1,49 @@
package main
import (
"context"
"fmt"
"os"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
"github.com/spf13/cobra"
)
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
var rootCmd = &cobra.Command{
Use: "pulse-control-plane",
Short: "Pulse Cloud Control Plane",
Long: `Control plane for Pulse Cloud — manages tenant lifecycle, containers, and billing.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cloudcp.Run(context.Background(), Version)
},
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Pulse Control Plane %s\n", Version)
if BuildTime != "unknown" {
fmt.Printf("Built: %s\n", BuildTime)
}
if GitCommit != "unknown" {
fmt.Printf("Commit: %s\n", GitCommit)
}
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

View file

@ -1,268 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
)
var (
// Version is the semantic version of the agent, set at build time via ldflags
Version = "dev"
osExit = os.Exit
runAsWindowsServiceFunc = runAsWindowsService
runFunc = run
)
// Config holds the configuration for the standalone host agent
type Config struct {
HostConfig hostagent.Config
DisableAutoUpdate bool
}
type multiValue []string
func (m *multiValue) String() string {
return strings.Join(*m, ",")
}
func (m *multiValue) Set(value string) error {
*m = append(*m, value)
return nil
}
func main() {
cfg, showVersion, err := parseConfig(os.Args[0], os.Args[1:], os.Getenv)
if err != nil {
if err == flag.ErrHelp {
osExit(0)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
osExit(1)
}
if showVersion {
fmt.Println(Version)
osExit(0)
}
if err := runFunc(context.Background(), cfg); err != nil {
// Log error and exit - logger is set up in run() but we might not have it here
// Actually, run() handles its own fatal errors for now to match original behavior
// but we return error for testing.
fmt.Fprintf(os.Stderr, "error: %v\n", err)
osExit(1)
}
}
func run(ctx context.Context, cfg Config) error {
hostCfg := cfg.HostConfig
zerolog.SetGlobalLevel(hostCfg.LogLevel)
logger := zerolog.New(os.Stdout).Level(hostCfg.LogLevel).With().Timestamp().Logger()
hostCfg.Logger = &logger
// Check if we should run as a Windows service
if err := runAsWindowsServiceFunc(cfg, logger); err != nil {
return fmt.Errorf("Windows service failed: %w", err)
}
// If runAsWindowsService returns nil without error, we're not running as a service
// or we're on a non-Windows platform, so run normally
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
// Deprecation warning
logger.Warn().Msg("pulse-host-agent is DEPRECATED and will be removed in a future release")
logger.Warn().Msg("Please migrate to the unified 'pulse-agent' with --enable-host flag")
logger.Warn().Msg("Example: pulse-agent --url <URL> --token <TOKEN> --enable-host")
logger.Warn().Msg("")
logger.Info().
Str("version", Version).
Str("pulse_url", hostCfg.PulseURL).
Str("agent_id", hostCfg.AgentID).
Dur("interval", hostCfg.Interval).
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse host agent")
// Start Auto-Updater
updater := agentupdate.New(agentupdate.Config{
PulseURL: hostCfg.PulseURL,
APIToken: hostCfg.APIToken,
AgentName: "pulse-host-agent",
CurrentVersion: Version,
CheckInterval: 1 * time.Hour,
InsecureSkipVerify: hostCfg.InsecureSkipVerify,
Logger: &logger,
Disabled: cfg.DisableAutoUpdate,
})
g.Go(func() error {
updater.RunLoop(ctx)
return nil
})
// Start the host agent
agent, err := hostagent.New(hostCfg)
if err != nil {
return fmt.Errorf("failed to initialise host agent: %w", err)
}
g.Go(func() error {
return agent.Run(ctx)
})
if err := g.Wait(); err != nil && err != context.Canceled {
return fmt.Errorf("host agent terminated with error: %w", err)
}
logger.Info().Msg("Host agent stopped")
return nil
}
func parseConfig(progName string, args []string, getenv func(string) string) (Config, bool, error) {
getenvTrim := func(k string) string {
return strings.TrimSpace(getenv(k))
}
envURL := getenvTrim("PULSE_URL")
envToken := getenvTrim("PULSE_TOKEN")
envInterval := getenvTrim("PULSE_INTERVAL")
envHostname := getenvTrim("PULSE_HOSTNAME")
envAgentID := getenvTrim("PULSE_AGENT_ID")
envInsecure := getenvTrim("PULSE_INSECURE_SKIP_VERIFY")
envTags := getenvTrim("PULSE_TAGS")
envRunOnce := getenvTrim("PULSE_ONCE")
envLogLevel := getenvTrim("LOG_LEVEL")
envNoAutoUpdate := getenvTrim("PULSE_NO_AUTO_UPDATE")
defaultInterval := 30 * time.Second
if envInterval != "" {
if parsed, err := time.ParseDuration(envInterval); err == nil {
defaultInterval = parsed
}
}
fs := flag.NewFlagSet(progName, flag.ContinueOnError)
urlFlag := fs.String("url", envURL, "Pulse server URL (e.g. https://pulse.example.com)")
tokenFlag := fs.String("token", envToken, "Pulse API token (required)")
intervalFlag := fs.Duration("interval", defaultInterval, "Reporting interval (e.g. 30s, 1m)")
hostnameFlag := fs.String("hostname", envHostname, "Override hostname reported to Pulse")
agentIDFlag := fs.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := fs.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS certificate verification")
runOnceFlag := fs.Bool("once", utils.ParseBool(envRunOnce), "Collect and send a single report, then exit")
noAutoUpdateFlag := fs.Bool("no-auto-update", utils.ParseBool(envNoAutoUpdate), "Disable automatic updates")
showVersion := fs.Bool("version", false, "Print the agent version and exit")
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level: debug, info, warn, error")
var tagFlags multiValue
fs.Var(&tagFlags, "tag", "Tag to apply to this host (repeatable)")
if err := fs.Parse(args); err != nil {
return Config{}, false, err
}
if *showVersion {
return Config{}, true, nil
}
pulseURL := strings.TrimSpace(*urlFlag)
if pulseURL == "" {
pulseURL = "http://localhost:7655"
}
token := strings.TrimSpace(*tokenFlag)
if token == "" {
return Config{}, false, fmt.Errorf("Pulse API token is required (via --token or PULSE_TOKEN)")
}
interval := *intervalFlag
if interval <= 0 {
interval = 30 * time.Second
}
logLevel, err := parseLogLevel(*logLevelFlag)
if err != nil {
return Config{}, false, err
}
tags := gatherTags(envTags, tagFlags)
return Config{
HostConfig: hostagent.Config{
PulseURL: pulseURL,
APIToken: token,
Interval: interval,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
RunOnce: *runOnceFlag,
LogLevel: logLevel,
},
DisableAutoUpdate: *noAutoUpdateFlag,
}, false, nil
}
func gatherTags(env string, flags []string) []string {
tags := make([]string, 0)
if env != "" {
for _, tag := range strings.Split(env, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
for _, tag := range flags {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
return tags
}
func parseLogLevel(value string) (zerolog.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return zerolog.InfoLevel, nil
}
level, err := zerolog.ParseLevel(normalized)
if err != nil {
return zerolog.InfoLevel, fmt.Errorf("invalid log level %q: must be debug, info, warn, or error", value)
}
if level < zerolog.DebugLevel || level > zerolog.ErrorLevel {
return zerolog.InfoLevel, fmt.Errorf("invalid log level %q: must be debug, info, warn, or error", value)
}
return level, nil
}
func defaultLogLevel(envValue string) string {
if strings.TrimSpace(envValue) == "" {
return "info"
}
return envValue
}

View file

@ -1,791 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rs/zerolog"
)
func TestGatherTags(t *testing.T) {
tests := []struct {
name string
env string
flags []string
expected []string
}{
// Empty inputs
{
name: "empty env and flags returns empty slice",
env: "",
flags: nil,
expected: []string{},
},
{
name: "empty env and empty flags returns empty slice",
env: "",
flags: []string{},
expected: []string{},
},
// Environment only
{
name: "single env tag",
env: "prod",
flags: nil,
expected: []string{"prod"},
},
{
name: "multiple env tags comma separated",
env: "prod,us-west",
flags: nil,
expected: []string{"prod", "us-west"},
},
{
name: "env tags with whitespace trimmed",
env: " prod , us-west ",
flags: nil,
expected: []string{"prod", "us-west"},
},
{
name: "env empty items filtered",
env: "prod,,us-west,",
flags: nil,
expected: []string{"prod", "us-west"},
},
{
name: "env whitespace-only items filtered",
env: "prod, ,us-west",
flags: nil,
expected: []string{"prod", "us-west"},
},
// Flags only
{
name: "single flag tag",
env: "",
flags: []string{"staging"},
expected: []string{"staging"},
},
{
name: "multiple flag tags",
env: "",
flags: []string{"staging", "eu-central"},
expected: []string{"staging", "eu-central"},
},
{
name: "flag tags with whitespace trimmed",
env: "",
flags: []string{" staging ", " eu-central "},
expected: []string{"staging", "eu-central"},
},
{
name: "flag empty items filtered",
env: "",
flags: []string{"staging", "", "eu-central"},
expected: []string{"staging", "eu-central"},
},
{
name: "flag whitespace-only items filtered",
env: "",
flags: []string{"staging", " ", "eu-central"},
expected: []string{"staging", "eu-central"},
},
// Both env and flags (env first, then flags)
{
name: "env tags come before flags",
env: "prod",
flags: []string{"app1"},
expected: []string{"prod", "app1"},
},
{
name: "multiple env and multiple flags",
env: "prod,us-west",
flags: []string{"app1", "critical"},
expected: []string{"prod", "us-west", "app1", "critical"},
},
{
name: "duplicates preserved (no dedup)",
env: "prod,prod",
flags: []string{"prod"},
expected: []string{"prod", "prod", "prod"},
},
// Edge cases
{
name: "only commas in env",
env: ",,,",
flags: nil,
expected: []string{},
},
{
name: "single comma",
env: ",",
flags: nil,
expected: []string{},
},
{
name: "env with tabs",
env: "\tprod\t,\tstaging\t",
flags: nil,
expected: []string{"prod", "staging"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := gatherTags(tt.env, tt.flags)
if !reflect.DeepEqual(got, tt.expected) {
t.Fatalf("expected %v, got %v", tt.expected, got)
}
})
}
}
func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
input string
wantLevel zerolog.Level
wantErr bool
errSubstr string
}{
// Valid levels
{
name: "debug level",
input: "debug",
wantLevel: zerolog.DebugLevel,
},
{
name: "info level",
input: "info",
wantLevel: zerolog.InfoLevel,
},
{
name: "warn level",
input: "warn",
wantLevel: zerolog.WarnLevel,
},
{
name: "error level",
input: "error",
wantLevel: zerolog.ErrorLevel,
},
// Case insensitivity
{
name: "uppercase DEBUG",
input: "DEBUG",
wantLevel: zerolog.DebugLevel,
},
{
name: "mixed case Info",
input: "Info",
wantLevel: zerolog.InfoLevel,
},
{
name: "uppercase WARN",
input: "WARN",
wantLevel: zerolog.WarnLevel,
},
{
name: "uppercase ERROR",
input: "ERROR",
wantLevel: zerolog.ErrorLevel,
},
// Whitespace handling
{
name: "leading whitespace",
input: " debug",
wantLevel: zerolog.DebugLevel,
},
{
name: "trailing whitespace",
input: "warn ",
wantLevel: zerolog.WarnLevel,
},
{
name: "both whitespace",
input: " error ",
wantLevel: zerolog.ErrorLevel,
},
{
name: "tabs",
input: "\tinfo\t",
wantLevel: zerolog.InfoLevel,
},
// Empty string defaults to info
{
name: "empty string defaults to info",
input: "",
wantLevel: zerolog.InfoLevel,
},
{
name: "whitespace only defaults to info",
input: " ",
wantLevel: zerolog.InfoLevel,
},
{
name: "tabs only defaults to info",
input: "\t\t",
wantLevel: zerolog.InfoLevel,
},
// Invalid levels
{
name: "invalid level returns error",
input: "invalid",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "invalid log level",
},
{
name: "typo returns error",
input: "debuf",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "must be debug, info, warn, or error",
},
{
name: "verbose returns error",
input: "verbose",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "invalid log level",
},
// Trace level is outside allowed range (DebugLevel to ErrorLevel)
{
name: "trace level rejected (outside allowed range)",
input: "trace",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "must be debug, info, warn, or error",
},
// Fatal/panic levels are outside allowed range
{
name: "fatal level rejected (outside allowed range)",
input: "fatal",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "must be debug, info, warn, or error",
},
{
name: "panic level rejected (outside allowed range)",
input: "panic",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "must be debug, info, warn, or error",
},
// Numeric values (zerolog supports these)
{
name: "numeric 0 maps to debug level",
input: "0",
wantLevel: zerolog.DebugLevel,
},
{
name: "numeric 1 maps to info level",
input: "1",
wantLevel: zerolog.InfoLevel,
},
{
name: "numeric 2 maps to warn level",
input: "2",
wantLevel: zerolog.WarnLevel,
},
{
name: "numeric 3 maps to error level",
input: "3",
wantLevel: zerolog.ErrorLevel,
},
{
name: "numeric -1 is trace (rejected - outside range)",
input: "-1",
wantLevel: zerolog.InfoLevel,
wantErr: true,
errSubstr: "must be debug, info, warn, or error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
level, err := parseLogLevel(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, err.Error())
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
if level != tt.wantLevel {
t.Fatalf("expected level %v, got %v", tt.wantLevel, level)
}
})
}
}
func TestDefaultLogLevel(t *testing.T) {
tests := []struct {
name string
envValue string
expected string
}{
// Empty returns "info"
{
name: "empty string returns info",
envValue: "",
expected: "info",
},
{
name: "whitespace only returns info",
envValue: " ",
expected: "info",
},
{
name: "tabs only returns info",
envValue: "\t\t",
expected: "info",
},
{
name: "newline only returns info",
envValue: "\n",
expected: "info",
},
// Non-empty returns as-is (no validation)
{
name: "debug returns debug",
envValue: "debug",
expected: "debug",
},
{
name: "error returns error",
envValue: "error",
expected: "error",
},
{
name: "invalid value passed through",
envValue: "invalid",
expected: "invalid",
},
{
name: "mixed case passed through",
envValue: "DEBUG",
expected: "DEBUG",
},
{
name: "value with surrounding whitespace NOT trimmed",
envValue: " debug ",
expected: " debug ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := defaultLogLevel(tt.envValue)
if got != tt.expected {
t.Fatalf("expected %q, got %q", tt.expected, got)
}
})
}
}
func TestMultiValue(t *testing.T) {
t.Run("String joins with comma", func(t *testing.T) {
mv := multiValue{"a", "b", "c"}
if got := mv.String(); got != "a,b,c" {
t.Fatalf("expected %q, got %q", "a,b,c", got)
}
})
t.Run("String empty slice returns empty string", func(t *testing.T) {
mv := multiValue{}
if got := mv.String(); got != "" {
t.Fatalf("expected %q, got %q", "", got)
}
})
t.Run("String single item no comma", func(t *testing.T) {
mv := multiValue{"single"}
if got := mv.String(); got != "single" {
t.Fatalf("expected %q, got %q", "single", got)
}
})
t.Run("Set appends values", func(t *testing.T) {
mv := multiValue{}
if err := mv.Set("first"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := mv.Set("second"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := mv.Set("third"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := multiValue{"first", "second", "third"}
if !reflect.DeepEqual(mv, expected) {
t.Fatalf("expected %v, got %v", expected, mv)
}
})
t.Run("Set preserves empty strings", func(t *testing.T) {
mv := multiValue{}
_ = mv.Set("")
_ = mv.Set("value")
_ = mv.Set("")
if len(mv) != 3 {
t.Fatalf("expected 3 items, got %d", len(mv))
}
})
t.Run("Set always returns nil error", func(t *testing.T) {
mv := multiValue{}
// Set always returns nil, testing various inputs
inputs := []string{"", "normal", "with spaces", "special!@#$%", "unicode日本語"}
for _, input := range inputs {
if err := mv.Set(input); err != nil {
t.Fatalf("expected nil error for input %q, got %v", input, err)
}
}
})
}
func TestRunAsWindowsServiceStub(t *testing.T) {
// This tests the non-windows stub
cfg := Config{}
logger := zerolog.Nop()
err := runAsWindowsService(cfg, logger)
if err != nil {
t.Fatalf("expected nil error from stub, got %v", err)
}
}
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
args []string
env map[string]string
wantURL string
wantToken string
wantLevel zerolog.Level
wantVersion bool
wantErr bool
}{
{
name: "defaults with token",
args: []string{"--token", "test-token"},
env: nil,
wantURL: "http://localhost:7655",
wantToken: "test-token",
wantLevel: zerolog.InfoLevel,
},
{
name: "env vars",
args: []string{},
env: map[string]string{
"PULSE_TOKEN": "env-token",
"PULSE_URL": "http://env-url",
"LOG_LEVEL": "debug",
},
wantURL: "http://env-url",
wantToken: "env-token",
wantLevel: zerolog.DebugLevel,
},
{
name: "flags override env",
args: []string{"--url", "http://flag-url", "--log-level", "error"},
env: map[string]string{"PULSE_TOKEN": "token", "PULSE_URL": "http://env-url"},
wantURL: "http://flag-url",
wantToken: "token",
wantLevel: zerolog.ErrorLevel,
},
{
name: "show version",
args: []string{"--version"},
wantVersion: true,
},
{
name: "missing token returns error",
args: []string{"--url", "http://localhost"},
wantErr: true,
},
{
name: "invalid log level returns error",
args: []string{"--token", "t", "--log-level", "invalid"},
wantErr: true,
},
{
name: "invalid interval returns error",
args: []string{"--token", "t", "--interval", "invalid"},
wantErr: true,
},
{
name: "invalid flag returns error",
args: []string{"--invalid"},
wantErr: true,
},
{
name: "help flag",
args: []string{"--help"},
wantErr: true,
},
{
name: "env interval",
args: []string{"--token", "t"},
env: map[string]string{
"PULSE_INTERVAL": "10s",
},
wantURL: "http://localhost:7655",
wantToken: "t",
wantLevel: zerolog.InfoLevel,
},
{
name: "invalid env interval defaults to 30s",
args: []string{"--token", "t"},
env: map[string]string{
"PULSE_INTERVAL": "invalid",
},
wantURL: "http://localhost:7655",
wantToken: "t",
wantLevel: zerolog.InfoLevel,
},
{
name: "negative interval defaults to 30s",
args: []string{"--token", "t", "--interval", "-10s"},
wantURL: "http://localhost:7655",
wantToken: "t",
wantLevel: zerolog.InfoLevel,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getenv := func(k string) string {
if tt.env == nil {
return ""
}
return tt.env[k]
}
cfg, showVersion, err := parseConfig("pulse-host-agent", tt.args, getenv)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if showVersion != tt.wantVersion {
t.Fatalf("expected showVersion %v, got %v", tt.wantVersion, showVersion)
}
if !showVersion {
if cfg.HostConfig.PulseURL != tt.wantURL {
t.Fatalf("expected URL %q, got %q", tt.wantURL, cfg.HostConfig.PulseURL)
}
if cfg.HostConfig.APIToken != tt.wantToken {
t.Fatalf("expected Token %q, got %q", tt.wantToken, cfg.HostConfig.APIToken)
}
if cfg.HostConfig.LogLevel != tt.wantLevel {
t.Fatalf("expected Level %v, got %v", tt.wantLevel, cfg.HostConfig.LogLevel)
}
}
})
}
}
func TestMain(m *testing.M) {
// Custom TestMain if needed, but we can just use regular tests
m.Run()
}
func TestMainFunc(t *testing.T) {
origArgs := os.Args
origExit := osExit
origRun := runFunc
defer func() {
os.Args = origArgs
osExit = origExit
runFunc = origRun
}()
tests := []struct {
name string
args []string
runErr error
wantExit int
}{
{
name: "help exits 0",
args: []string{"pulse-host-agent", "--help"},
wantExit: 0,
},
{
name: "version exits 0",
args: []string{"pulse-host-agent", "--version"},
wantExit: 0,
},
{
name: "invalid flag exits 1",
args: []string{"pulse-host-agent", "--invalid-flag"},
wantExit: 1,
},
{
name: "missing token exits 1",
args: []string{"pulse-host-agent"},
wantExit: 1,
},
{
name: "run success exits normally",
args: []string{"pulse-host-agent", "--token", "test"},
runErr: nil,
wantExit: 100, // custom exit to signal success of main and return
},
{
name: "run failure exits 1",
args: []string{"pulse-host-agent", "--token", "test"},
runErr: fmt.Errorf("run failed"),
wantExit: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Args = tt.args
runFunc = func(ctx context.Context, cfg Config) error {
return tt.runErr
}
var exitCode int
var exited bool
osExit = func(code int) {
exitCode = code
exited = true
panic("exited")
}
// For the "success" case, we want to see it reach the end of main
// Actually, main doesn't call osExit(0) at the very end, it just returns.
// But if runFunc returns nil, main returns.
if tt.wantExit == 100 {
// Special case: we expect it NOT to exit via osExit
defer func() {
_ = recover()
if exited {
t.Errorf("expected main not to call osExit, but it called with %d", exitCode)
}
}()
main()
return
}
defer func() {
_ = recover()
if !exited {
t.Errorf("expected osExit to be called")
}
if exitCode != tt.wantExit {
t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode)
}
}()
main()
})
}
}
func TestRunFunc(t *testing.T) {
origService := runAsWindowsServiceFunc
defer func() {
runAsWindowsServiceFunc = origService
}()
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
t.Run("windows service failure", func(t *testing.T) {
runAsWindowsServiceFunc = func(cfg Config, logger zerolog.Logger) error {
return fmt.Errorf("service failed")
}
err := run(ctx, Config{})
if err == nil || !strings.Contains(err.Error(), "Windows service failed") {
t.Fatalf("expected service failure error, got %v", err)
}
})
t.Run("invalid config fails hostagent.New", func(t *testing.T) {
runAsWindowsServiceFunc = origService
cfg := Config{
HostConfig: hostagent.Config{
PulseURL: "http://localhost",
APIToken: "", // Empty token fails New
},
}
err := run(ctx, cfg)
if err == nil || !strings.Contains(err.Error(), "failed to initialise host agent") {
t.Fatalf("expected hostagent init error, got %v", err)
}
})
t.Run("run once finishes", func(t *testing.T) {
runAsWindowsServiceFunc = origService
cfg := Config{
HostConfig: hostagent.Config{
PulseURL: "http://localhost:1",
APIToken: "test",
RunOnce: true,
},
DisableAutoUpdate: true,
}
shortCtx, shortCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer shortCancel()
_ = run(shortCtx, cfg)
})
t.Run("host agent terminated with error (deadline)", func(t *testing.T) {
runAsWindowsServiceFunc = origService
cfg := Config{
HostConfig: hostagent.Config{
PulseURL: "http://localhost:1",
APIToken: "test",
},
DisableAutoUpdate: true,
}
// Using a short timeout that will definitely trigger before the agent finishes (which is never in this config)
timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer timeoutCancel()
err := run(timeoutCtx, cfg)
if err == nil || !strings.Contains(err.Error(), "host agent terminated with error") {
t.Fatalf("expected termination error, got %v", err)
}
})
}

View file

@ -1,12 +0,0 @@
//go:build !windows
package main
import (
"github.com/rs/zerolog"
)
// runAsWindowsService is a no-op on non-Windows platforms
func runAsWindowsService(_ Config, _ zerolog.Logger) error {
return nil
}

View file

@ -1,188 +0,0 @@
//go:build windows
package main
import (
"context"
"fmt"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
)
type windowsService struct {
cfg Config
logger zerolog.Logger
eventLog *eventlog.Log
}
func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
// Log to Windows Event Log
if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Host Agent service starting")
}
hostCfg := ws.cfg.HostConfig
hostCfg.Logger = &ws.logger
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
g, ctx := errgroup.WithContext(ctx)
// Start Auto-Updater
updater := agentupdate.New(agentupdate.Config{
PulseURL: hostCfg.PulseURL,
APIToken: hostCfg.APIToken,
AgentName: "pulse-host-agent",
CurrentVersion: Version,
CheckInterval: 1 * time.Hour,
InsecureSkipVerify: hostCfg.InsecureSkipVerify,
Logger: &ws.logger,
Disabled: ws.cfg.DisableAutoUpdate,
})
g.Go(func() error {
updater.RunLoop(ctx)
return nil
})
// Start the host agent
agent, err := hostagent.New(hostCfg)
if err != nil {
ws.logger.Error().Err(err).Msg("Failed to create host agent")
if ws.eventLog != nil {
ws.eventLog.Error(1, fmt.Sprintf("Failed to create host agent: %v", err))
}
changes <- svc.Status{State: svc.Stopped}
return true, 1
}
g.Go(func() error {
ws.logger.Info().
Str("version", Version).
Str("pulse_url", hostCfg.PulseURL).
Str("agent_id", hostCfg.AgentID).
Dur("interval", hostCfg.Interval).
Bool("auto_update", !ws.cfg.DisableAutoUpdate).
Msg("Starting Pulse host agent as Windows service")
return agent.Run(ctx)
})
// Channel to receive errgroup completion
doneChan := make(chan error, 1)
go func() {
doneChan <- g.Wait()
}()
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
ws.logger.Info().Msg("Host agent service is running")
if ws.eventLog != nil {
ws.eventLog.Info(1, fmt.Sprintf("Pulse Host Agent started successfully (URL: %s, Interval: %s)", hostCfg.PulseURL, hostCfg.Interval))
}
// Service control loop
loop:
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
ws.logger.Info().Uint32("command", uint32(c.Cmd)).Msg("Received service control command")
if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Host Agent received stop command")
}
changes <- svc.Status{State: svc.StopPending}
cancel()
break loop
default:
ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command")
}
case err := <-doneChan:
if err != nil && err != context.Canceled {
ws.logger.Error().Err(err).Msg("Agent error")
if ws.eventLog != nil {
ws.eventLog.Error(1, fmt.Sprintf("Pulse Host Agent error: %v", err))
}
changes <- svc.Status{State: svc.Stopped}
return true, 1
}
break loop
}
}
// Wait for agent to stop gracefully (with timeout)
shutdownTimeout := time.NewTimer(10 * time.Second)
defer shutdownTimeout.Stop()
select {
case <-doneChan:
ws.logger.Info().Msg("Agent stopped gracefully")
if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Host Agent stopped gracefully")
}
case <-shutdownTimeout.C:
ws.logger.Warn().Msg("Agent shutdown timeout, forcing stop")
if ws.eventLog != nil {
ws.eventLog.Warning(1, "Pulse Host Agent shutdown timeout")
}
}
changes <- svc.Status{State: svc.Stopped}
return false, 0
}
func runAsWindowsService(cfg Config, logger zerolog.Logger) error {
// Check if we're running as a Windows service
isService, err := svc.IsWindowsService()
if err != nil {
return fmt.Errorf("failed to determine if running as service: %w", err)
}
if !isService {
// Not running as a service, run normally
return nil
}
logger.Info().Msg("Running as Windows service")
// Open Windows Event Log (best effort - don't fail if it doesn't work)
elog, err := eventlog.Open("PulseHostAgent")
if err != nil {
logger.Warn().Err(err).Msg("Could not open Windows Event Log, continuing without it")
elog = nil
}
defer func() {
if elog != nil {
elog.Close()
}
}()
ws := &windowsService{
cfg: cfg,
logger: logger,
eventLog: elog,
}
// Run as a Windows service
err = svc.Run("PulseHostAgent", ws)
if err != nil {
if elog != nil {
elog.Error(1, fmt.Sprintf("Failed to run service: %v", err))
}
return fmt.Errorf("failed to run Windows service: %w", err)
}
return nil
}

View file

@ -0,0 +1,298 @@
//go:build integration
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunServer(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("PULSE_FRONTEND_PORT", "0")
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
// Test case: AllowedOrigins = "*"
t.Setenv("PULSE_ALLOWED_ORIGINS", "*")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
captureOutput(func() {
_ = runServer(ctx)
})
// Test case: Specific AllowedOrigins
t.Setenv("PULSE_ALLOWED_ORIGINS", "http://localhost:3000")
ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel2()
captureOutput(func() {
_ = runServer(ctx2)
})
}
func TestSIGHUP(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("PULSE_FRONTEND_PORT", "0")
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
time.Sleep(200 * time.Millisecond)
cancel()
}()
captureOutput(func() {
_ = runServer(ctx)
})
}
func TestMainActual(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
env := newTestCLIEnv()
process := newTestCLIProcess()
mockFS := newTestMockFS()
exitCode := 0
process.Exit = func(code int) { exitCode = code }
newProgram(env, process, mockFS).Run(context.Background(), []string{"version"})
assert.Equal(t, 0, exitCode)
newProgram(env, process, mockFS).Run(context.Background(), []string{"--invalid-flag"})
assert.Equal(t, 1, exitCode)
}
func TestRunServer_HTTPS(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
t.Setenv("PULSE_HTTPS_ENABLED", "true")
t.Setenv("PULSE_TLS_CERT_FILE", "nonexistent.crt")
t.Setenv("PULSE_TLS_KEY_FILE", "nonexistent.key")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
captureOutput(func() {
_ = runServer(ctx)
})
}
func TestRunServer_ConfigReload(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("PULSE_FRONTEND_PORT", "0")
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
oldMetricsPort := metricsPort
metricsPort = 0 // Use random port for metrics
defer func() { metricsPort = oldMetricsPort }()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errChan := make(chan error, 1)
go func() {
errChan <- runServer(ctx)
}()
// Wait for server to start.
time.Sleep(500 * time.Millisecond)
// Trigger reload via SIGHUP.
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
time.Sleep(200 * time.Millisecond)
// Trigger mock reload if possible.
mockEnv := filepath.Join(tempDir, "mock.env")
require.NoError(t, os.WriteFile(mockEnv, []byte("PULSE_MOCK_MODE=true\n"), 0644))
time.Sleep(200 * time.Millisecond)
cancel()
err := <-errChan
assert.NoError(t, err)
// Give time for pending file watcher events to complete before cleanup.
time.Sleep(100 * time.Millisecond)
}
func TestMainCmd(t *testing.T) {
// Root command without args should run runServer.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
cmd := newProgram(newTestCLIEnv(), newTestCLIProcess(), newTestMockFS()).RootCommand()
oldRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runServer(ctx)
}
defer func() { cmd.RunE = oldRunE }()
cmd.SetArgs([]string{})
err := cmd.Execute()
assert.NoError(t, err)
}
func TestRunServer_AutoImportFail(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
// Setup auto-import env vars with invalid data that causes normalize error.
t.Setenv("PULSE_INIT_CONFIG_DATA", " ")
t.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "pass")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
output := captureOutput(func() {
_ = runServer(ctx)
})
assert.NotEmpty(t, output)
}
func TestRunServer_WebSocket(t *testing.T) {
l, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
port := l.Addr().(*net.TCPAddr).Port
require.NoError(t, l.Close())
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", port))
t.Setenv("PULSE_AUTH_USER", "testuser")
t.Setenv("PULSE_AUTH_PASS", "testpass")
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
sysConfig := map[string]any{
"allowedOrigins": "*",
}
sysData, _ := json.Marshal(sysConfig)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "system.json"), sysData, 0644))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
_ = runServer(ctx)
}()
deadline := time.Now().Add(3 * time.Second)
for {
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
if err == nil {
_ = conn.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("server did not start listening on port %d", port)
}
time.Sleep(50 * time.Millisecond)
}
url := fmt.Sprintf("ws://localhost:%d/ws", port)
dialer := websocket.Dialer{}
auth := base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))
header := http.Header{}
header.Add("Authorization", "Basic "+auth)
conn, _, err := dialer.Dial(url, header)
require.NoError(t, err)
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err = conn.ReadMessage()
if err != nil {
t.Fatalf("expected first websocket read to succeed, got: %v", err)
}
}
func TestRunServer_AllowedOrigins(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
sysConfig := map[string]any{
"allowedOrigins": "example.com,foo.com",
}
sysData, _ := json.Marshal(sysConfig)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "system.json"), sysData, 0644))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
captureOutput(func() {
_ = runServer(ctx)
})
}
func TestRunServer_FrontendFail(t *testing.T) {
oldMetricsPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldMetricsPort }()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := l.Addr().(*net.TCPAddr).Port
defer l.Close()
t.Setenv("BIND_ADDRESS", "127.0.0.1")
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", port))
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644))
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
output := captureOutput(func() {
_ = runServer(ctx)
})
assert.Contains(t, output, "Failed to start HTTP server")
}

View file

@ -2,50 +2,13 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/pkg/server"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
// createTestEncryptionKey creates a valid base64-encoded encryption key in the temp directory.
// Required before creating .enc files to avoid crypto initialization failures.
func createTestEncryptionKey(t *testing.T, dir string) {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(filepath.Join(dir, ".encryption.key"), []byte(encoded), 0600); err != nil {
t.Fatalf("failed to create test encryption key: %v", err)
}
}
func getFreeTCPPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to allocate free tcp port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func TestVersionCmd(t *testing.T) {
oldVersion := Version
oldBuildTime := BuildTime
@ -56,914 +19,65 @@ func TestVersionCmd(t *testing.T) {
GitCommit = oldGitCommit
}()
// Test 1: Full version info
Version = "1.2.3"
BuildTime = "2023-01-01"
GitCommit = "abcdef"
output := captureOutput(func() {
rootCmd.SetArgs([]string{"version"})
rootCmd.Execute()
cmd := newProgram(newTestCLIEnv(), newTestCLIProcess(), newTestMockFS()).RootCommand()
cmd.SetArgs([]string{"version"})
_ = cmd.Execute()
})
assert.Contains(t, output, "Pulse 1.2.3")
assert.Contains(t, output, "Built: 2023-01-01")
assert.Contains(t, output, "Commit: abcdef")
// Test 2: Only version
BuildTime = "unknown"
GitCommit = "unknown"
output = captureOutput(func() {
rootCmd.SetArgs([]string{"version"})
rootCmd.Execute()
cmd := newProgram(newTestCLIEnv(), newTestCLIProcess(), newTestMockFS()).RootCommand()
cmd.SetArgs([]string{"version"})
_ = cmd.Execute()
})
assert.Contains(t, output, "Pulse 1.2.3")
assert.NotContains(t, output, "Built:")
assert.NotContains(t, output, "Commit:")
}
func TestConfigInfoCmd(t *testing.T) {
output := captureOutput(func() {
rootCmd.SetArgs([]string{"config", "info"})
rootCmd.Execute()
})
assert.Contains(t, output, "Pulse Configuration Information")
assert.Contains(t, output, "Configuration is managed through the web UI")
}
func TestConfigExportCmd(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
createTestEncryptionKey(t, tempDir)
// Set PULSE_PASSPHRASE for non-interactive test
os.Setenv("PULSE_PASSPHRASE", "testpass")
defer os.Unsetenv("PULSE_PASSPHRASE")
outputFile := filepath.Join(tempDir, "export.enc")
rootCmd.SetArgs([]string{"config", "export", "-o", outputFile})
err := rootCmd.Execute()
assert.NoError(t, err)
_, err = os.Stat(outputFile)
assert.NoError(t, err)
// Test without output file (prints to stdout)
output := captureOutput(func() {
exportFile = "" // Reset again
rootCmd.SetArgs([]string{"config", "export"})
rootCmd.Execute()
})
assert.NotEmpty(t, output)
}
func TestConfigImportCmd(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
createTestEncryptionKey(t, tempDir)
os.Setenv("PULSE_PASSPHRASE", "testpass")
defer os.Unsetenv("PULSE_PASSPHRASE")
// First export some config to have something to import
exportFile = filepath.Join(tempDir, "export.enc")
rootCmd.SetArgs([]string{"config", "export", "-o", exportFile})
rootCmd.Execute()
// Now import it
importFile = exportFile
forceImport = true
rootCmd.SetArgs([]string{"config", "import", "-i", exportFile, "--force"})
err := rootCmd.Execute()
assert.NoError(t, err)
// Test missing input file error
importFile = "" // Reset to trigger error
rootCmd.SetArgs([]string{"config", "import", "--force"})
err = rootCmd.Execute()
assert.Error(t, err)
if err != nil {
assert.Contains(t, err.Error(), "import file is required")
}
}
func TestBootstrapTokenCmd(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
tokenFile := filepath.Join(tempDir, ".bootstrap_token")
err := os.WriteFile(tokenFile, []byte("test-token"), 0644)
assert.NoError(t, err)
output := captureOutput(func() {
rootCmd.SetArgs([]string{"bootstrap-token"})
rootCmd.Execute()
})
assert.Contains(t, output, "test-token")
assert.Contains(t, output, tokenFile)
}
func TestBootstrapTokenEdgeCases(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
oldExit := osExit
defer func() { osExit = oldExit }()
exitCode := 0
osExit = func(code int) { exitCode = code }
// 1. Token file not found
captureOutput(func() {
showBootstrapToken()
})
assert.Equal(t, 1, exitCode)
// 2. Token file empty
tokenFile := filepath.Join(tempDir, ".bootstrap_token")
os.WriteFile(tokenFile, []byte(""), 0644)
captureOutput(func() {
showBootstrapToken()
})
assert.Equal(t, 1, exitCode)
// 3. Other read error (e.g. is a directory)
dirToken := filepath.Join(tempDir, "is_a_dir")
os.Mkdir(dirToken, 0755)
os.Setenv("PULSE_DATA_DIR", tempDir)
// We need to trick it to use this path
// showBootstrapToken uses filepath.Join(dataPath, ".bootstrap_token")
// So we make .bootstrap_token a directory
os.Remove(tokenFile)
os.Mkdir(tokenFile, 0755)
captureOutput(func() {
showBootstrapToken()
})
assert.Equal(t, 1, exitCode)
os.RemoveAll(tokenFile)
// 4. Test data paths
os.Setenv("PULSE_DOCKER", "true")
os.Unsetenv("PULSE_DATA_DIR")
captureOutput(func() {
showBootstrapToken()
})
assert.Equal(t, 1, exitCode)
os.Unsetenv("PULSE_DOCKER")
// 5. Test default data path (/etc/pulse)
os.Unsetenv("PULSE_DATA_DIR")
captureOutput(func() {
showBootstrapToken()
})
assert.Equal(t, 1, exitCode)
}
func TestStartMetricsServer_Error(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Bind a port first
l, err := net.Listen("tcp", "127.0.0.1:0")
assert.NoError(t, err)
defer l.Close()
addr := l.Addr().String()
// Try to start on the same port
startMetricsServer(ctx, addr)
// Give it enough time to fail and log
time.Sleep(500 * time.Millisecond)
}
func TestMockCmds(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// Test status (disabled initially)
output := captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "status"})
rootCmd.Execute()
})
assert.Contains(t, output, "Mock mode: DISABLED")
// Create a mock.env with extra keys
envPath := filepath.Join(tempDir, "mock.env")
os.WriteFile(envPath, []byte("PULSE_MOCK_MODE=true\nEXTRA_KEY=value\n"), 0644)
// Test status (enabled)
output = captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "status"})
rootCmd.Execute()
})
assert.Contains(t, output, "Mock mode: ENABLED")
// Test enable (should preserve EXTRA_KEY)
output = captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "enable"})
rootCmd.Execute()
})
assert.Contains(t, output, "Mock mode enabled")
content, _ := os.ReadFile(envPath)
assert.Contains(t, string(content), "EXTRA_KEY=value")
// Test disable
output = captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "disable"})
rootCmd.Execute()
})
assert.Contains(t, output, "Mock mode disabled")
// Test getMockEnvPath branch (no env var)
os.Unsetenv("PULSE_DATA_DIR")
path := getMockEnvPath()
assert.NotEmpty(t, path)
// Test getMockEnvPath branch (/opt/pulse/mock.env fallback)
os.Unsetenv("PULSE_DATA_DIR")
// Ensure it exists
mockPath := "/opt/pulse/mock.env"
errWrite := os.WriteFile(mockPath, []byte("PULSE_MOCK_MODE=false\n"), 0644)
if errWrite == nil {
path = getMockEnvPath()
assert.Equal(t, mockPath, path)
// Don't remove it yet, or remove it carefully
}
}
func TestGetMockEnvPath_DefaultFallback(t *testing.T) {
// Cover line 104: dataDir = "/opt/pulse"
os.Unsetenv("PULSE_DATA_DIR")
// Ensure /opt/pulse/mock.env does NOT exist
os.Remove("/opt/pulse/mock.env")
path := getMockEnvPath()
assert.Equal(t, "/opt/pulse/mock.env", path)
}
func TestMockEnable_Error(t *testing.T) {
resetFlags()
// Force setMockMode to fail by using a read-only directory
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// Make directory read-only so file creation fails?
// Or make the mock.env a directory?
os.Mkdir(filepath.Join(tempDir, "mock.env"), 0755)
oldExit := osExit
defer func() { osExit = oldExit }()
exitCode := 0
osExit = func(code int) { exitCode = code }
captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "enable"})
rootCmd.Execute()
})
assert.Equal(t, 1, exitCode)
}
func TestMockDisable_Error(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// Make mock.env a directory
os.Mkdir(filepath.Join(tempDir, "mock.env"), 0755)
oldExit := osExit
defer func() { osExit = oldExit }()
exitCode := 0
osExit = func(code int) { exitCode = code }
captureOutput(func() {
rootCmd.SetArgs([]string{"mock", "disable"})
rootCmd.Execute()
})
assert.Equal(t, 1, exitCode)
}
func TestGetPassphrase(t *testing.T) {
oldRead := readPassword
defer func() { readPassword = oldRead }()
// 1. Flag
passphrase = "flag-pass"
assert.Equal(t, "flag-pass", getPassphrase("test", false))
passphrase = ""
// 2. Interactive
os.Unsetenv("PULSE_PASSPHRASE")
readPassword = func(fd int) ([]byte, error) {
return []byte("inter-pass"), nil
}
assert.Equal(t, "inter-pass", getPassphrase("test", false))
// 3. Confirmation match
callCount := 0
readPassword = func(fd int) ([]byte, error) {
callCount++
return []byte("match"), nil
}
assert.Equal(t, "match", getPassphrase("test", true))
assert.Equal(t, 2, callCount)
// 4. Confirmation mismatch
callCount = 0
readPassword = func(fd int) ([]byte, error) {
callCount++
if callCount == 1 {
return []byte("pass1"), nil
}
return []byte("pass2"), nil
}
assert.Equal(t, "", getPassphrase("test", true))
// 5. Error
readPassword = func(fd int) ([]byte, error) {
return nil, fmt.Errorf("error")
}
assert.Equal(t, "", getPassphrase("test", false))
// 6. Error in confirm
callCount = 0
readPassword = func(fd int) ([]byte, error) {
callCount++
if callCount == 1 {
return []byte("pass1"), nil
}
return nil, fmt.Errorf("error")
}
assert.Equal(t, "", getPassphrase("test", true))
}
func TestConfigAutoImportCmd(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
createTestEncryptionKey(t, tempDir)
os.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "testpass")
defer os.Unsetenv("PULSE_INIT_CONFIG_PASSPHRASE")
// Test with data
os.Setenv("PULSE_INIT_CONFIG_DATA", "testdata")
defer os.Unsetenv("PULSE_INIT_CONFIG_DATA")
// This might fail because 'testdata' is not a valid encrypted config,
// but we want to see it try. ImportConfig will probably fail.
rootCmd.SetArgs([]string{"config", "auto-import"})
err := rootCmd.Execute()
// It should fail because "testdata" is not valid encrypted config
assert.Error(t, err)
// Test with URL
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "url-test-data")
}))
defer server.Close()
os.Setenv("PULSE_INIT_CONFIG_URL", server.URL)
defer os.Unsetenv("PULSE_INIT_CONFIG_URL")
os.Unsetenv("PULSE_INIT_CONFIG_DATA")
rootCmd.SetArgs([]string{"config", "auto-import"})
err = rootCmd.Execute()
assert.Error(t, err) // Still invalid data, but covered the URL path
}
func TestRunServer(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t)))
// Create a dummy .env to avoid config load error
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
// Test case: AllowedOrigins = "*"
t.Setenv("PULSE_ALLOWED_ORIGINS", "*")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
captureOutput(func() {
runServer(ctx)
})
// Test case: Specific AllowedOrigins
os.Setenv("PULSE_ALLOWED_ORIGINS", "http://localhost:3000")
ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel2()
captureOutput(func() {
runServer(ctx2)
})
}
func TestSIGHUP(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t)))
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
syscall.Kill(os.Getpid(), syscall.SIGHUP)
time.Sleep(200 * time.Millisecond)
cancel()
}()
captureOutput(func() {
runServer(ctx)
})
}
func TestMainActual(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
// Root command which will return immediately because we've already set its args in previously tests?
// or we set it to something that fails quickly.
rootCmd.SetArgs([]string{"version"})
main()
// Test main error path
oldExit := osExit
defer func() { osExit = oldExit }()
exitCode := 0
osExit = func(code int) { exitCode = code }
rootCmd.SetArgs([]string{"--invalid-flag"})
captureOutput(func() {
main()
})
assert.Equal(t, 1, exitCode)
}
func TestConfigAutoImport_Errors(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
os.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "testpass")
defer os.Unsetenv("PULSE_INIT_CONFIG_PASSPHRASE")
// 1. Invalid URL scheme
os.Setenv("PULSE_INIT_CONFIG_URL", "ftp://host/file")
rootCmd.SetArgs([]string{"config", "auto-import"})
err := rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported URL scheme")
// 2. Invalid URL
os.Setenv("PULSE_INIT_CONFIG_URL", "http:// invalid")
err = rootCmd.Execute()
assert.Error(t, err)
// 3. 404 from URL
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
os.Setenv("PULSE_INIT_CONFIG_URL", server.URL)
err = rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch configuration")
// 4. Empty body from URL
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server2.Close()
os.Setenv("PULSE_INIT_CONFIG_URL", server2.URL)
err = rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration response from URL was empty")
}
func TestNormalizeImportPayload(t *testing.T) {
// Empty case
_, err := server.NormalizeImportPayload([]byte(" "))
assert.Error(t, err)
assert.Contains(t, err.Error(), "configuration payload is empty")
// Base64 case (where decoded doesn't look like base64)
// base64("!!") = "ISE="
s, err := server.NormalizeImportPayload([]byte(" ISE= "))
assert.NoError(t, err)
assert.Equal(t, "ISE=", s)
// Base64-of-Base64 case (unwraps)
// base64("test") = "dGVzdA=="
// test also looks like base64 (4 chars, alphanumeric)
s, err = server.NormalizeImportPayload([]byte(" dGVzdA== "))
assert.NoError(t, err)
assert.Equal(t, "test", s)
// Plain case (not base64)
s, err = server.NormalizeImportPayload([]byte("!!"))
assert.NoError(t, err)
// Should be base64 encoded
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("!!")), s)
}
func TestRunServer_HTTPS(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
func TestResolveMetricsPortFromEnvPrefersPrefixedOverride(t *testing.T) {
t.Setenv("PULSE_METRICS_PORT", "0")
t.Setenv("METRICS_PORT", "9091")
t.Setenv("PULSE_HTTPS_ENABLED", "true")
t.Setenv("PULSE_TLS_CERT_FILE", "nonexistent.crt")
t.Setenv("PULSE_TLS_KEY_FILE", "nonexistent.key")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
captureOutput(func() {
runServer(ctx)
})
}
func TestRunServer_ConfigReload(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t)))
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
metricsPort = 0 // Use random port for metrics
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Run server in background
errChan := make(chan error, 1)
go func() {
errChan <- runServer(ctx)
}()
// Wait for server to start
time.Sleep(500 * time.Millisecond)
// Send SIGHUP to trigger reload
syscall.Kill(os.Getpid(), syscall.SIGHUP)
time.Sleep(200 * time.Millisecond)
// Trigger mock reload if possible
mockEnv := filepath.Join(tempDir, "mock.env")
os.WriteFile(mockEnv, []byte("PULSE_MOCK_MODE=true\n"), 0644)
time.Sleep(200 * time.Millisecond)
cancel()
err := <-errChan
assert.NoError(t, err)
// Give time for any pending file watcher events to complete before cleanup
time.Sleep(100 * time.Millisecond)
}
func TestMainCmd(t *testing.T) {
oldPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldPort }()
// Root command without args should run runServer
// But we don't want it to block forever
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t)))
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
// Override rootCmd RunE
oldRunE := rootCmd.RunE
rootCmd.RunE = func(cmd *cobra.Command, args []string) error {
return runServer(ctx)
}
defer func() { rootCmd.RunE = oldRunE }()
rootCmd.SetArgs([]string{})
err := rootCmd.Execute()
assert.NoError(t, err)
}
func TestConfigExport_ErrorPaths(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// 1. Passphrase required error
// Set passphrase to empty by making getPassphrase return ""
// getPassphrase returns "" if terminal read fails
oldRead := readPassword
readPassword = func(fd int) ([]byte, error) { return nil, fmt.Errorf("read error") }
defer func() { readPassword = oldRead }()
rootCmd.SetArgs([]string{"config", "export"})
err := rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "passphrase is required")
// 2. Default data dir branch - only test if /etc/pulse exists
if _, err := os.Stat("/etc/pulse"); err == nil {
os.Unsetenv("PULSE_DATA_DIR")
rootCmd.SetArgs([]string{"config", "export", "--passphrase", "test"})
// This will try to read from /etc/pulse/nodes.enc which might not exist or be accessible
rootCmd.Execute()
if got := resolveMetricsPortFromEnv(nil, 7655); got != 0 {
t.Fatalf("resolveMetricsPortFromEnv() = %d, want 0", got)
}
}
func TestConfigImport_NoDataDir(t *testing.T) {
// Skip in CI where /etc/pulse doesn't exist
if _, err := os.Stat("/etc/pulse"); os.IsNotExist(err) {
t.Skip("Skipping test: /etc/pulse does not exist (likely CI environment)")
func TestResolveMetricsPortFromEnvFallsBackOnInvalidValue(t *testing.T) {
t.Setenv("PULSE_METRICS_PORT", "not-a-port")
var stderr bytes.Buffer
if got := resolveMetricsPortFromEnv(&stderr, 9091); got != 9091 {
t.Fatalf("resolveMetricsPortFromEnv() = %d, want fallback 9091", got)
}
resetFlags()
os.Unsetenv("PULSE_DATA_DIR")
rootCmd.SetArgs([]string{"config", "import", "--passphrase", "test", "-i", "nonexistent"})
rootCmd.Execute()
}
func TestConfigExport_WriteError(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// Create a directory where the output file should be, to cause write error
outputFile := filepath.Join(tempDir, "is_dir")
os.Mkdir(outputFile, 0755)
rootCmd.SetArgs([]string{"config", "export", "--passphrase", "test", "-o", outputFile})
err := rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to write export file")
}
func TestConfigImport_Errors(t *testing.T) {
resetFlags()
resetReadPassword := readPassword
defer func() { readPassword = resetReadPassword }()
tempDir := t.TempDir()
os.Setenv("PULSE_DATA_DIR", tempDir)
defer os.Unsetenv("PULSE_DATA_DIR")
// Create dummy import file
importFile := filepath.Join(tempDir, "import.enc")
os.WriteFile(importFile, []byte("data"), 0644)
// 1. Passphrase required error
readPassword = func(fd int) ([]byte, error) { return nil, fmt.Errorf("read error") }
rootCmd.SetArgs([]string{"config", "import", "-i", importFile})
err := rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "passphrase is required")
// 2. Import cancelled
readPassword = func(fd int) ([]byte, error) { return []byte("pass"), nil }
// Mock stdin for confirmation "no"
oldStdin := os.Stdin
r, w, _ := os.Pipe()
os.Stdin = r
w.Write([]byte("no\n"))
w.Close()
rootCmd.SetArgs([]string{"config", "import", "-i", importFile})
captureOutput(func() {
err = rootCmd.Execute()
})
assert.NoError(t, err)
os.Stdin = oldStdin
// 3. Failed to import configuration (invalid data)
// We need to force import to skip confirmation
rootCmd.SetArgs([]string{"config", "import", "-i", importFile, "--force", "--passphrase", "pass"})
err = rootCmd.Execute()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to import configuration")
}
func TestRunServer_AutoImportFail(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
// Setup auto-import env vars with invalid data that causes normalize error
t.Setenv("PULSE_INIT_CONFIG_DATA", " ")
t.Setenv("PULSE_INIT_CONFIG_PASSPHRASE", "pass")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
// Should log error but continue
output := captureOutput(func() {
runServer(ctx)
})
// Just check that we got some output, exact buffering might be tricky with logs
// assert.Contains(t, output, "Auto-import failed")
// If assert fails it might be due to race or logger init.
// We mainly want to cover the code path.
// But let's check if output is not empty
assert.NotEmpty(t, output)
}
func TestCaptureOutput(t *testing.T) {
output := captureOutput(func() {
fmt.Print("hello")
fmt.Fprint(os.Stderr, "world")
})
assert.Equal(t, "helloworld", output)
}
func TestRunServer_WebSocket(t *testing.T) {
resetFlags()
// Pick random port for frontend
l, _ := net.Listen("tcp", "localhost:0")
port := l.Addr().(*net.TCPAddr).Port
l.Close()
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", port))
// Set up auth for test
t.Setenv("PULSE_AUTH_USER", "testuser")
t.Setenv("PULSE_AUTH_PASS", "testpass")
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
// Need valid node config to proceed
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
// Need system.json to set AllowedOrigins to * for test (relaxed)
sysConfig := map[string]interface{}{
"allowedOrigins": "*",
}
sysData, _ := json.Marshal(sysConfig)
os.WriteFile(filepath.Join(tempDir, "system.json"), sysData, 0644)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start server in background
go func() {
runServer(ctx)
}()
// Wait for server to be ready
// Polling is better than sleep
ready := false
for i := 0; i < 20; i++ {
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
if err == nil {
conn.Close()
ready = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
t.Skip("Server failed to start")
}
// Connect WS with Basic Auth
url := fmt.Sprintf("ws://localhost:%d/api/state", port) // This connects to handleState which returns JSON, NOT WS
// ERROR: handleState is JSON endpoint.
// WebSocket endpoint is /ws (line 1325).
// And handleWebSocket (3968) calls CheckAuth.
// So target /ws
url = fmt.Sprintf("ws://localhost:%d/ws", port)
dialer := websocket.Dialer{}
auth := base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))
header := http.Header{}
header.Add("Authorization", "Basic "+auth)
conn, _, err := dialer.Dial(url, header)
if assert.NoError(t, err) {
defer conn.Close()
// Wait for state message - this triggers the SetStateGetter callback
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err := conn.ReadMessage()
// We don't care about message content, just that we got something (or not error)
if err != nil {
t.Logf("WS Read Error: %v", err)
}
}
// Explicitly cancel and wait for server shutdown before test cleanup
// to avoid race condition where server writes files during temp dir removal
cancel()
time.Sleep(200 * time.Millisecond)
}
func TestRunServer_AllowedOrigins(t *testing.T) {
resetFlags()
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
// Write system.json with specific allowed origins
sysConfig := map[string]interface{}{
"allowedOrigins": "example.com,foo.com",
}
sysData, _ := json.Marshal(sysConfig)
os.WriteFile(filepath.Join(tempDir, "system.json"), sysData, 0644)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
captureOutput(func() {
runServer(ctx)
})
// Coverage should show hit on AllowedOrigins parsing logic
}
func TestRunServer_FrontendFail(t *testing.T) {
resetFlags()
// Use a random port for metrics to avoid conflict
oldMetricsPort := metricsPort
metricsPort = 0
defer func() { metricsPort = oldMetricsPort }()
// Find free port, bind it to make busy
l, _ := net.Listen("tcp", "127.0.0.1:0")
port := l.Addr().(*net.TCPAddr).Port
// Keep l open
defer l.Close()
t.Setenv("BIND_ADDRESS", "127.0.0.1")
// Set frontend port to busy port
t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", port))
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
createTestEncryptionKey(t, tempDir)
os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err := runServer(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to bind frontend listener")
}
// Helper to capture stdout and stderr
func captureOutput(f func()) string {
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stdout = w
os.Stderr = w
f()
w.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, r)
return buf.String()
}
func resetFlags() {
exportFile = ""
importFile = ""
passphrase = ""
forceImport = false
assert.Contains(t, stderr.String(), "Ignoring invalid PULSE_METRICS_PORT value")
}

View file

@ -1,303 +0,0 @@
package main
import (
"bufio"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/server"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
exportFile string
importFile string
passphrase string
forceImport bool
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configuration management commands",
Long: `Manage Pulse configuration settings`,
}
var configInfoCmd = &cobra.Command{
Use: "info",
Short: "Show configuration information",
Long: `Display information about Pulse configuration`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Pulse Configuration Information")
fmt.Println("==============================")
fmt.Println()
fmt.Println("Configuration is managed through the web UI.")
fmt.Println("Settings are stored in encrypted files at /etc/pulse/")
fmt.Println()
fmt.Println("Configuration files:")
fmt.Println(" - nodes.enc : Encrypted Proxmox node configurations")
fmt.Println(" - email.enc : Encrypted email settings")
fmt.Println(" - system.json : System settings (polling interval, etc)")
fmt.Println(" - alerts.json : Alert rules and thresholds")
fmt.Println(" - webhooks.enc : Webhook configurations")
fmt.Println()
fmt.Println("To configure Pulse, use the Settings tab in the web UI.")
return nil
},
}
var configExportCmd = &cobra.Command{
Use: "export",
Short: "Export configuration with encryption",
Long: `Export all Pulse configuration to an encrypted file`,
Example: ` # Export with interactive passphrase prompt
pulse config export -o pulse-config.enc
# Export with passphrase from environment variable
PULSE_PASSPHRASE=mysecret pulse config export -o pulse-config.enc`,
RunE: func(cmd *cobra.Command, args []string) error {
// Get passphrase
pass := getPassphrase("Enter passphrase for encryption: ", false)
if pass == "" {
return fmt.Errorf("passphrase is required")
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Export configuration
exportedData, err := persistence.ExportConfig(pass)
if err != nil {
return fmt.Errorf("failed to export configuration: %w", err)
}
// Write to file or stdout
if exportFile != "" {
if err := os.WriteFile(exportFile, []byte(exportedData), 0600); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Configuration exported to %s\n", exportFile)
} else {
fmt.Println(exportedData)
}
return nil
},
}
var configImportCmd = &cobra.Command{
Use: "import",
Short: "Import configuration from encrypted export",
Long: `Import Pulse configuration from an encrypted export file`,
Example: ` # Import with interactive passphrase prompt
pulse config import -i pulse-config.enc
# Import with passphrase from environment variable
PULSE_PASSPHRASE=mysecret pulse config import -i pulse-config.enc
# Force import without confirmation
pulse config import -i pulse-config.enc --force`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check if import file is specified
if importFile == "" {
return fmt.Errorf("import file is required (use -i flag)")
}
// Read import file
data, err := os.ReadFile(importFile)
if err != nil {
return fmt.Errorf("failed to read import file: %w", err)
}
// Get passphrase
pass := getPassphrase("Enter passphrase for decryption: ", false)
if pass == "" {
return fmt.Errorf("passphrase is required")
}
// Confirm import unless forced
if !forceImport {
fmt.Println("WARNING: This will overwrite all existing configuration!")
fmt.Print("Continue? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "yes" && response != "y" {
fmt.Println("Import cancelled")
return nil
}
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(string(data), pass); err != nil {
return fmt.Errorf("failed to import configuration: %w", err)
}
fmt.Println("Configuration imported successfully")
fmt.Println("Please restart Pulse for changes to take effect:")
fmt.Println(" sudo systemctl restart pulse")
return nil
},
}
var readPassword = term.ReadPassword
func getPassphrase(prompt string, confirm bool) string {
// Check environment variable first
if pass := os.Getenv("PULSE_PASSPHRASE"); pass != "" {
return pass
}
// Check if passphrase flag was set
if passphrase != "" {
return passphrase
}
// Interactive prompt
fmt.Print(prompt)
bytePassword, err := readPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return ""
}
pass := string(bytePassword)
// Confirm if requested
if confirm {
fmt.Print("Confirm passphrase: ")
bytePassword2, err := readPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return ""
}
if string(bytePassword2) != pass {
fmt.Println("Passphrases do not match")
return ""
}
}
return pass
}
// Environment variable support for initial setup
var configAutoImportCmd = &cobra.Command{
Use: "auto-import",
Hidden: true, // Hidden command for automated setup
Short: "Auto-import configuration on startup",
Long: `Automatically import configuration from URL or file on first startup`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check for auto-import environment variables
configURL := os.Getenv("PULSE_INIT_CONFIG_URL")
configData := os.Getenv("PULSE_INIT_CONFIG_DATA")
configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE")
if configURL == "" && configData == "" {
return nil // Nothing to import
}
if configPass == "" {
return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import")
}
var encryptedData string
// Get data from URL or direct data
if configURL != "" {
parsedURL, err := url.Parse(configURL)
if err != nil {
return fmt.Errorf("invalid PULSE_INIT_CONFIG_URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q for PULSE_INIT_CONFIG_URL", parsedURL.Scheme)
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(configURL)
if err != nil {
return fmt.Errorf("failed to fetch configuration from URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("failed to fetch configuration from URL: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read configuration response: %w", err)
}
if len(body) == 0 {
return fmt.Errorf("configuration response from URL was empty")
}
payload, err := server.NormalizeImportPayload(body)
if err != nil {
return err
}
encryptedData = payload
} else if configData != "" {
payload, err := server.NormalizeImportPayload([]byte(configData))
if err != nil {
return err
}
encryptedData = payload
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(encryptedData, configPass); err != nil {
return fmt.Errorf("failed to auto-import configuration: %w", err)
}
fmt.Println("Configuration auto-imported successfully")
return nil
},
}
func init() {
configCmd.AddCommand(configInfoCmd)
configCmd.AddCommand(configExportCmd)
configCmd.AddCommand(configImportCmd)
configCmd.AddCommand(configAutoImportCmd)
// Export flags
configExportCmd.Flags().StringVarP(&exportFile, "output", "o", "", "Output file for encrypted configuration")
configExportCmd.Flags().StringVarP(&passphrase, "passphrase", "p", "", "Passphrase for encryption (or use PULSE_PASSPHRASE env var)")
// Import flags
configImportCmd.Flags().StringVarP(&importFile, "input", "i", "", "Input file with encrypted configuration")
configImportCmd.Flags().StringVarP(&passphrase, "passphrase", "p", "", "Passphrase for decryption (or use PULSE_PASSPHRASE env var)")
configImportCmd.Flags().BoolVarP(&forceImport, "force", "f", false, "Force import without confirmation")
}

View file

@ -0,0 +1,94 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/pkg/pulsecli"
)
func TestReadBoundedRegularFileSuccess(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.enc")
want := []byte("encrypted-config")
if err := os.WriteFile(path, want, 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got, err := pulsecli.ReadBoundedRegularFile(path, int64(len(want)))
if err != nil {
t.Fatalf("readBoundedRegularFile: %v", err)
}
if string(got) != string(want) {
t.Fatalf("got %q, want %q", string(got), string(want))
}
}
func TestReadBoundedRegularFileRejectsOversized(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.enc")
if err := os.WriteFile(path, []byte("0123456789"), 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := pulsecli.ReadBoundedRegularFile(path, 8)
if err == nil {
t.Fatal("expected oversized file error")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadBoundedRegularFileRejectsSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.enc")
if err := os.WriteFile(target, []byte("ok"), 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
link := filepath.Join(dir, "config.enc")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink not supported: %v", err)
}
_, err := pulsecli.ReadBoundedRegularFile(link, 1024)
if err == nil {
t.Fatal("expected non-regular file error")
}
if !strings.Contains(err.Error(), "regular file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadBoundedHTTPBodySuccess(t *testing.T) {
data := []byte("payload")
got, err := pulsecli.ReadBoundedHTTPBody(bytes.NewReader(data), int64(len(data)), int64(len(data)), "configuration response")
if err != nil {
t.Fatalf("readBoundedHTTPBody: %v", err)
}
if string(got) != string(data) {
t.Fatalf("got %q, want %q", string(got), string(data))
}
}
func TestReadBoundedHTTPBodyRejectsOversizedContentLength(t *testing.T) {
_, err := pulsecli.ReadBoundedHTTPBody(bytes.NewReader([]byte("ok")), 9, 8, "configuration response")
if err == nil {
t.Fatal("expected oversized content-length error")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadBoundedHTTPBodyRejectsOversizedStream(t *testing.T) {
_, err := pulsecli.ReadBoundedHTTPBody(bytes.NewReader([]byte("0123456789")), -1, 8, "configuration response")
if err == nil {
t.Fatal("expected oversized stream error")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("unexpected error: %v", err)
}
}

View file

@ -2,25 +2,29 @@ package main
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/pkg/pulsecli"
)
func TestGetPassphrase_FromEnv(t *testing.T) {
env := newTestCLIEnv()
process := newTestCLIProcess()
t.Setenv("PULSE_PASSPHRASE", "from-env")
passphrase = ""
t.Cleanup(func() { passphrase = "" })
env.Passphrase = ""
got := getPassphrase("ignored", false)
got := pulsecli.GetPassphrase(env.ConfigDeps(process), "ignored", false)
if got != "from-env" {
t.Fatalf("got %q", got)
}
}
func TestGetPassphrase_FromFlag(t *testing.T) {
env := newTestCLIEnv()
process := newTestCLIProcess()
t.Setenv("PULSE_PASSPHRASE", "")
passphrase = "from-flag"
t.Cleanup(func() { passphrase = "" })
env.Passphrase = "from-flag"
got := getPassphrase("ignored", false)
got := pulsecli.GetPassphrase(env.ConfigDeps(process), "ignored", false)
if got != "from-flag" {
t.Fatalf("got %q", got)
}

View file

@ -3,9 +3,14 @@ package main
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/pkg/pulsecli"
"github.com/rcourtman/pulse-go-rewrite/pkg/server"
"github.com/spf13/cobra"
)
// Version information (set at build time with -ldflags)
@ -16,49 +21,65 @@ var (
metricsPort = 9091
)
var rootCmd = &cobra.Command{
Use: "pulse",
Short: "Pulse - Proxmox VE and PBS monitoring system",
Long: `Pulse is a real-time monitoring system for Proxmox Virtual Environment (PVE) and Proxmox Backup Server (PBS)`,
Version: Version,
RunE: func(cmd *cobra.Command, args []string) error {
return runServer(context.Background())
},
func resolveMetricsPortFromEnv(stderr io.Writer, fallback int) int {
for _, envName := range []string{"PULSE_METRICS_PORT", "METRICS_PORT"} {
raw := strings.TrimSpace(os.Getenv(envName))
if raw == "" {
continue
}
port, err := strconv.Atoi(raw)
if err != nil || port < 0 || port > 65535 {
if stderr != nil {
fmt.Fprintf(stderr, "Ignoring invalid %s value %q; using metrics port %d\n", envName, raw, fallback)
}
return fallback
}
return port
}
return fallback
}
func runServer(ctx context.Context) error {
server.MetricsPort = metricsPort
updates.BuildVersion = Version
server.MetricsPort = resolveMetricsPortFromEnv(os.Stderr, metricsPort)
return server.Run(ctx, Version)
}
func init() {
// Add config command
rootCmd.AddCommand(configCmd)
// Add version command
rootCmd.AddCommand(versionCmd)
// Add bootstrap-token command
rootCmd.AddCommand(bootstrapTokenCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Pulse %s\n", Version)
if BuildTime != "unknown" {
fmt.Printf("Built: %s\n", BuildTime)
}
if GitCommit != "unknown" {
fmt.Printf("Commit: %s\n", GitCommit)
}
},
}
func main() {
if err := rootCmd.Execute(); err != nil {
// osExit is defined in bootstrap.go
osExit(1)
func newProgram(env *pulsecli.Env, process pulsecli.ProcessIO, mockFS pulsecli.MockFS) *pulsecli.Program {
if env == nil {
env = pulsecli.NewEnv()
}
return &pulsecli.Program{
Command: pulsecli.CommandSpec{
Use: "pulse",
Short: "Pulse - Proxmox VE and PBS monitoring system",
Long: `Pulse is a real-time monitoring system for Proxmox Virtual Environment (PVE) and Proxmox Backup Server (PBS)`,
Version: Version,
VersionTemplate: "Pulse {{.Version}}\n",
VersionPrinter: printVersion,
},
Runtime: pulsecli.RuntimeSpec{
Run: runServer,
},
Deps: env.CommandDeps(process, mockFS),
Exit: process.Exit,
}
}
func printVersion(w io.Writer) {
fmt.Fprintf(w, "Pulse %s\n", Version)
if BuildTime != "unknown" {
fmt.Fprintf(w, "Built: %s\n", BuildTime)
}
if GitCommit != "unknown" {
fmt.Fprintf(w, "Commit: %s\n", GitCommit)
}
}
func main() {
newProgram(pulsecli.NewEnv(), pulsecli.NewProcessIO(), pulsecli.NewMockFS()).Run(context.Background(), os.Args[1:])
}
// Force rebuild 1769525035

View file

@ -1,43 +0,0 @@
package main
import (
"context"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog/log"
)
var (
metricsShutdownTimeout = 5 * time.Second
)
func startMetricsServer(ctx context.Context, addr string) {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), metricsShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil && err != http.ErrServerClosed {
log.Warn().Err(err).Msg("Failed to shut down metrics server cleanly")
}
}()
go func() {
log.Info().Str("addr", addr).Msg("Metrics endpoint listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Warn().Err(err).Msg("Metrics server stopped unexpectedly")
}
}()
}

View file

@ -1,244 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
var mockCmd = &cobra.Command{
Use: "mock",
Short: "Manage mock/demo mode for development and demos",
Long: `Enable or disable mock mode to run Pulse with simulated data instead of real infrastructure.`,
}
var mockEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable mock mode with simulated infrastructure data",
Long: `Enable mock mode to run Pulse with simulated data.
This creates/updates the mock.env file and requires a service restart.
Mock mode is useful for:
- Demos without real infrastructure
- Development and testing
- Showcasing AI patrol features
Example:
pulse mock enable
sudo systemctl restart pulse`,
Run: func(cmd *cobra.Command, args []string) {
if err := setMockMode(true); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
osExit(1)
return
}
fmt.Println("✓ Mock mode enabled")
fmt.Println("")
fmt.Println("Restart Pulse to apply changes:")
fmt.Println(" sudo systemctl restart pulse")
},
}
var mockDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable mock mode and use real infrastructure",
Long: `Disable mock mode to reconnect to real infrastructure.
This updates the mock.env file and requires a service restart.
Example:
pulse mock disable
sudo systemctl restart pulse`,
Run: func(cmd *cobra.Command, args []string) {
if err := setMockMode(false); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
osExit(1)
return
}
fmt.Println("✓ Mock mode disabled")
fmt.Println("")
fmt.Println("Restart Pulse to apply changes:")
fmt.Println(" sudo systemctl restart pulse")
},
}
var mockStatusCmd = &cobra.Command{
Use: "status",
Short: "Show current mock mode status",
Run: func(cmd *cobra.Command, args []string) {
enabled, config := getMockStatus()
if enabled {
fmt.Println("Mock mode: ENABLED")
fmt.Println("")
fmt.Println("Configuration:")
for _, line := range config {
fmt.Printf(" %s\n", line)
}
} else {
fmt.Println("Mock mode: DISABLED")
fmt.Println("")
fmt.Println("Run 'pulse mock enable' to enable mock mode")
}
},
}
func init() {
mockCmd.AddCommand(mockEnableCmd)
mockCmd.AddCommand(mockDisableCmd)
mockCmd.AddCommand(mockStatusCmd)
rootCmd.AddCommand(mockCmd)
}
// getMockEnvPath returns the path to mock.env
func getMockEnvPath() string {
// Check PULSE_DATA_DIR first, then fall back to /opt/pulse
dataDir := os.Getenv("PULSE_DATA_DIR")
if dataDir == "" {
// Check if we're in development (running from /opt/pulse)
if _, err := os.Stat("/opt/pulse/mock.env"); err == nil {
return "/opt/pulse/mock.env"
}
dataDir = "/opt/pulse"
}
return filepath.Join(dataDir, "mock.env")
}
// setMockMode enables or disables mock mode by updating mock.env
func setMockMode(enable bool) error {
envPath := getMockEnvPath()
// Read existing config or create default
config := getDefaultMockConfig()
// Try to preserve existing config
if data, err := os.ReadFile(envPath); err == nil {
existing := parseMockEnv(string(data))
for k, v := range existing {
if k != "PULSE_MOCK_MODE" {
config[k] = v
}
}
}
// Set the mode
if enable {
config["PULSE_MOCK_MODE"] = "true"
} else {
config["PULSE_MOCK_MODE"] = "false"
}
// Write the file
return writeMockEnv(envPath, config)
}
// getDefaultMockConfig returns the default mock configuration
func getDefaultMockConfig() map[string]string {
return map[string]string{
"PULSE_MOCK_MODE": "false",
"PULSE_MOCK_NODES": "7",
"PULSE_MOCK_VMS_PER_NODE": "5",
"PULSE_MOCK_LXCS_PER_NODE": "8",
"PULSE_MOCK_DOCKER_HOSTS": "3",
"PULSE_MOCK_DOCKER_CONTAINERS": "12",
"PULSE_MOCK_GENERIC_HOSTS": "4",
"PULSE_MOCK_K8S_CLUSTERS": "2",
"PULSE_MOCK_K8S_NODES": "4",
"PULSE_MOCK_K8S_PODS": "30",
"PULSE_MOCK_K8S_DEPLOYMENTS": "12",
"PULSE_MOCK_RANDOM_METRICS": "true",
"PULSE_MOCK_STOPPED_PERCENT": "20",
}
}
// parseMockEnv parses a mock.env file into a map
func parseMockEnv(content string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
}
}
return result
}
// writeMockEnv writes the mock.env file
func writeMockEnv(path string, config map[string]string) error {
// Order keys for consistent output
keys := []string{
"PULSE_MOCK_MODE",
"PULSE_MOCK_NODES",
"PULSE_MOCK_VMS_PER_NODE",
"PULSE_MOCK_LXCS_PER_NODE",
"PULSE_MOCK_DOCKER_HOSTS",
"PULSE_MOCK_DOCKER_CONTAINERS",
"PULSE_MOCK_GENERIC_HOSTS",
"PULSE_MOCK_K8S_CLUSTERS",
"PULSE_MOCK_K8S_NODES",
"PULSE_MOCK_K8S_PODS",
"PULSE_MOCK_K8S_DEPLOYMENTS",
"PULSE_MOCK_RANDOM_METRICS",
"PULSE_MOCK_STOPPED_PERCENT",
}
var lines []string
lines = append(lines, "# Pulse Mock Mode Configuration")
lines = append(lines, "# Enable with: pulse mock enable")
lines = append(lines, "# Disable with: pulse mock disable")
lines = append(lines, "")
for _, key := range keys {
if val, ok := config[key]; ok {
lines = append(lines, fmt.Sprintf("%s=%s", key, val))
}
}
// Add any extra keys not in our ordered list
for k, v := range config {
found := false
for _, key := range keys {
if k == key {
found = true
break
}
}
if !found {
lines = append(lines, fmt.Sprintf("%s=%s", k, v))
}
}
content := strings.Join(lines, "\n") + "\n"
return os.WriteFile(path, []byte(content), 0644)
}
// getMockStatus returns the current mock mode status and config
func getMockStatus() (enabled bool, config []string) {
envPath := getMockEnvPath()
data, err := os.ReadFile(envPath)
if err != nil {
return false, nil
}
parsed := parseMockEnv(string(data))
enabled = parsed["PULSE_MOCK_MODE"] == "true"
if enabled {
config = []string{
fmt.Sprintf("Nodes: %s", parsed["PULSE_MOCK_NODES"]),
fmt.Sprintf("VMs per node: %s", parsed["PULSE_MOCK_VMS_PER_NODE"]),
fmt.Sprintf("Containers per node: %s", parsed["PULSE_MOCK_LXCS_PER_NODE"]),
fmt.Sprintf("Docker hosts: %s", parsed["PULSE_MOCK_DOCKER_HOSTS"]),
fmt.Sprintf("K8s clusters: %s", parsed["PULSE_MOCK_K8S_CLUSTERS"]),
}
}
return enabled, config
}

View file

@ -0,0 +1,57 @@
package main
import (
"bytes"
"encoding/base64"
"io"
"os"
"path/filepath"
"testing"
"github.com/rcourtman/pulse-go-rewrite/pkg/pulsecli"
)
func newTestCLIEnv() *pulsecli.Env {
return pulsecli.NewEnv()
}
func newTestCLIProcess() pulsecli.ProcessIO {
process := pulsecli.NewProcessIO()
process.Exit = func(int) {}
return process
}
func newTestMockFS() pulsecli.MockFS {
return pulsecli.NewMockFS()
}
func createTestEncryptionKey(t *testing.T, dir string) {
t.Helper()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(filepath.Join(dir, ".encryption.key"), []byte(encoded), 0o600); err != nil {
t.Fatalf("failed to create test encryption key: %v", err)
}
}
func captureOutput(f func()) string {
oldStdout := os.Stdout
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stdout = w
os.Stderr = w
f()
_ = w.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
return buf.String()
}

View file

@ -2,8 +2,8 @@ apiVersion: v2
name: pulse
description: Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
type: application
version: 5.1.24
appVersion: "5.1.24"
version: 5.1.7
appVersion: "5.1.7"
icon: https://raw.githubusercontent.com/rcourtman/Pulse/main/docs/images/pulse-logo.svg
keywords:
- monitoring

View file

@ -1,6 +1,6 @@
# pulse
![Version: 5.1.23](https://img.shields.io/badge/Version-5.1.23-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 5.1.23](https://img.shields.io/badge/AppVersion-5.1.23-informational?style=flat-square)
![Version: 5.1.6](https://img.shields.io/badge/Version-5.1.6-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 5.1.6](https://img.shields.io/badge/AppVersion-5.1.6-informational?style=flat-square)
Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
@ -16,6 +16,23 @@ Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
* <https://github.com/rcourtman/Pulse>
## Explore Monitoring
Enable Explore recording rules and alerts with:
```yaml
monitoring:
prometheusRule:
enabled: true
```
This creates recording rules for:
- `pulse_ai_explore_failure_rate`
- `pulse_ai_explore_p95_duration_seconds`
- `pulse_ai_explore_skipped_no_model_total`
and optional alerts for sustained failure rate, latency, and missing model configuration.
## Values
| Key | Type | Default | Description |
@ -35,7 +52,7 @@ Helm chart for deploying the Pulse hub and optional Docker monitoring agent.
| agent.extraVolumes | list | `[]` | |
| agent.healthPort | int | `9191` | |
| agent.image.pullPolicy | string | `"IfNotPresent"` | |
| agent.image.repository | string | `"ghcr.io/rcourtman/pulse-docker-agent"` | |
| agent.image.repository | string | `"ghcr.io/rcourtman/pulse-agent"` | |
| agent.image.tag | string | `""` | |
| agent.kind | string | `"DaemonSet"` | |
| agent.livenessProbe.enabled | bool | `true` | |

View file

@ -41,7 +41,7 @@ spec:
{{- toYaml $podSecurityContext | nindent 8 }}
{{- end }}
containers:
- name: pulse-docker-agent
- name: pulse-agent
image: "{{ .Values.agent.image.repository }}:{{ default .Chart.AppVersion .Values.agent.image.tag }}"
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
{{- if .Values.agent.args }}

View file

@ -0,0 +1,69 @@
{{- if .Values.monitoring.prometheusRule.enabled -}}
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: {{ include "pulse.fullname" . }}-ai-explore
namespace: {{ .Release.Namespace }}
labels:
{{- include "pulse.labels" . | nindent 4 }}
{{- with .Values.monitoring.prometheusRule.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.monitoring.prometheusRule.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
groups:
- name: {{ include "pulse.fullname" . }}.ai.explore
rules:
- record: pulse_ai_explore_failure_rate
expr: |
(
sum(rate(pulse_ai_explore_runs_total{outcome="failed"}[{{ .Values.monitoring.prometheusRule.failureRate.window }}]))
/
clamp_min(sum(rate(pulse_ai_explore_runs_total[{{ .Values.monitoring.prometheusRule.failureRate.window }}])), 1)
)
- record: pulse_ai_explore_p95_duration_seconds
expr: |
histogram_quantile(
0.95,
sum by (le) (rate(pulse_ai_explore_duration_seconds_bucket{outcome="success"}[{{ .Values.monitoring.prometheusRule.p95Duration.window }}]))
)
- record: pulse_ai_explore_skipped_no_model_total
expr: |
increase(pulse_ai_explore_runs_total{outcome="skipped_no_model"}[{{ .Values.monitoring.prometheusRule.skippedNoModel.window }}])
{{- if .Values.monitoring.prometheusRule.failureRate.enabled }}
- alert: PulseAIExploreFailureRateHigh
expr: |
pulse_ai_explore_failure_rate > {{ .Values.monitoring.prometheusRule.failureRate.threshold }}
and
sum(increase(pulse_ai_explore_runs_total[{{ .Values.monitoring.prometheusRule.failureRate.window }}])) >= {{ .Values.monitoring.prometheusRule.failureRate.minRuns }}
for: {{ .Values.monitoring.prometheusRule.failureRate.for }}
labels:
severity: {{ .Values.monitoring.prometheusRule.failureRate.severity | quote }}
annotations:
summary: Explore pre-pass failure rate is high
description: Explore pre-pass failures exceeded threshold for sustained traffic.
{{- end }}
{{- if .Values.monitoring.prometheusRule.p95Duration.enabled }}
- alert: PulseAIExploreP95DurationHigh
expr: pulse_ai_explore_p95_duration_seconds > {{ .Values.monitoring.prometheusRule.p95Duration.thresholdSeconds }}
for: {{ .Values.monitoring.prometheusRule.p95Duration.for }}
labels:
severity: {{ .Values.monitoring.prometheusRule.p95Duration.severity | quote }}
annotations:
summary: Explore pre-pass latency p95 is high
description: Explore pre-pass p95 duration is above the configured threshold.
{{- end }}
{{- if .Values.monitoring.prometheusRule.skippedNoModel.enabled }}
- alert: PulseAIExploreSkippedNoModel
expr: pulse_ai_explore_skipped_no_model_total > {{ .Values.monitoring.prometheusRule.skippedNoModel.threshold }}
for: {{ .Values.monitoring.prometheusRule.skippedNoModel.for }}
labels:
severity: {{ .Values.monitoring.prometheusRule.skippedNoModel.severity | quote }}
annotations:
summary: Explore pre-pass is skipping due to missing explicit model
description: Explore pre-pass skipped because no explicit model was configured.
{{- end }}
{{- end }}

View file

@ -153,7 +153,7 @@
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable legacy pulse-docker-agent workload (deprecated)"
"description": "Enable optional unified pulse-agent workload"
},
"kind": {
"type": "string",
@ -238,6 +238,113 @@
"description": "Metrics endpoint path on the main HTTP service (metrics listener is separate)"
}
}
},
"prometheusRule": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Create PrometheusRule for Explore recording rules and alerts"
},
"labels": {
"type": "object",
"description": "Additional labels for PrometheusRule"
},
"annotations": {
"type": "object",
"description": "Additional annotations for PrometheusRule"
},
"failureRate": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable high Explore failure-rate alert"
},
"window": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "PromQL range window for failure-rate calculations"
},
"threshold": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Failure rate threshold (0-1)"
},
"minRuns": {
"type": "integer",
"minimum": 1,
"description": "Minimum Explore runs in the window before alerting"
},
"for": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "Required alert duration before firing"
},
"severity": {
"type": "string",
"description": "Severity label for the alert"
}
}
},
"p95Duration": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable high Explore p95 latency alert"
},
"window": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "PromQL range window for latency calculations"
},
"thresholdSeconds": {
"type": "number",
"minimum": 0,
"description": "p95 latency threshold in seconds"
},
"for": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "Required alert duration before firing"
},
"severity": {
"type": "string",
"description": "Severity label for the alert"
}
}
},
"skippedNoModel": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable alert when Explore skips due to missing explicit model"
},
"window": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "PromQL range window for skipped-no-model counts"
},
"threshold": {
"type": "number",
"minimum": 0,
"description": "Skipped-no-model threshold over the window"
},
"for": {
"type": "string",
"pattern": "^[0-9]+[smhd]$",
"description": "Required alert duration before firing"
},
"severity": {
"type": "string",
"description": "Severity label for the alert"
}
}
}
}
}
}
}

View file

@ -103,8 +103,8 @@ server:
failureThreshold: 3
agent:
# Legacy: this deploys the deprecated `pulse-docker-agent`.
# For new deployments, prefer installing the unified agent (`pulse-agent`) on the hosts you want to monitor.
# Optional in-cluster agent workload.
# This deploys the unified `pulse-agent` in container-monitoring mode.
enabled: false
kind: DaemonSet # Supported: DaemonSet | Deployment
replicaCount: 1
@ -113,7 +113,7 @@ agent:
name: ""
annotations: {}
image:
repository: ghcr.io/rcourtman/pulse-docker-agent
repository: ghcr.io/rcourtman/pulse-agent
tag: ""
pullPolicy: IfNotPresent
env:
@ -127,7 +127,9 @@ agent:
name: ""
data: {}
keys: []
args: []
args:
- --enable-docker
- --enable-host=false
resources: {}
podAnnotations: {}
podLabels: {}
@ -173,3 +175,26 @@ monitoring:
labels: {}
relabelings: []
metricRelabelings: []
prometheusRule:
enabled: false
labels: {}
annotations: {}
failureRate:
enabled: true
window: 10m
threshold: 0.25
minRuns: 20
for: 10m
severity: warning
p95Duration:
enabled: true
window: 10m
thresholdSeconds: 8
for: 15m
severity: warning
skippedNoModel:
enabled: true
window: 30m
threshold: 5
for: 15m
severity: warning

View file

@ -18,9 +18,9 @@ staticClients:
name: Pulse Dev
secret: pulse-secret
redirectURIs:
- http://127.0.0.1:5173/api/oidc/callback
- http://127.0.0.1:7655/api/oidc/callback
- http://127.0.0.1:8765/api/oidc/callback
- http://127.0.0.1:5173/api/oidc/legacy-oidc/callback
- http://127.0.0.1:7655/api/oidc/legacy-oidc/callback
- http://127.0.0.1:8765/api/oidc/legacy-oidc/callback
staticPasswords:
- email: admin@example.com
hash: "$2a$10$uo8fC/3BtvIULFvS7/NuRe6Bn3NmidSXHHiAchpdZEiBBV3IcJKfy"

View file

@ -18,7 +18,7 @@ services:
environment:
- TZ=${TZ:-UTC}
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:7655/api/health"]
test: ["CMD-SHELL", "[ -x /docker-healthcheck.sh ] && /docker-healthcheck.sh || wget --quiet --tries=1 --spider http://localhost:7655/api/health"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -4,6 +4,44 @@ set -e
# Default UID/GID if not provided
PUID=${PUID:-1000}
PGID=${PGID:-1000}
PULSE_IMMUTABLE_OWNERSHIP_PATHS=${PULSE_IMMUTABLE_OWNERSHIP_PATHS:-}
is_immutable_ownership_path() {
target="$1"
[ -n "$target" ] || return 1
case ":$PULSE_IMMUTABLE_OWNERSHIP_PATHS:" in
*:"$target":*)
return 0
;;
esac
return 1
}
chown_tree_skipping_immutable_paths() {
owner="$1"
root="$2"
if [ ! -e "$root" ]; then
return 0
fi
if [ -z "$PULSE_IMMUTABLE_OWNERSHIP_PATHS" ]; then
chown -R "$owner" "$root"
return 0
fi
find "$root" -mindepth 1 | while IFS= read -r path; do
if is_immutable_ownership_path "$path"; then
echo "Skipping immutable ownership path: $path"
continue
fi
chown "$owner" "$path"
done
chown "$owner" "$root"
}
# Only adjust permissions if running as root
if [ "$(id -u)" = "0" ]; then
@ -13,7 +51,8 @@ if [ "$(id -u)" = "0" ]; then
if [ "$PUID" = "0" ]; then
echo "Running as root user"
# Fix ownership to root
chown -R root:root /data /app /etc/pulse /opt/pulse
chown -R root:root /data /app /opt/pulse
chown_tree_skipping_immutable_paths root:root /etc/pulse
exec "$@"
fi
@ -33,7 +72,8 @@ if [ "$(id -u)" = "0" ]; then
fi
# Fix ownership of data directory
chown -R pulse:pulse /data /app /etc/pulse /opt/pulse
chown -R pulse:pulse /data /app /opt/pulse
chown_tree_skipping_immutable_paths pulse:pulse /etc/pulse
# Switch to pulse user
exec su-exec pulse "$@"

View file

@ -4,7 +4,7 @@
if [ "$HTTPS_ENABLED" = "true" ] || [ "$HTTPS_ENABLED" = "1" ]; then
# Use HTTPS with --no-check-certificate to handle self-signed certs
wget --no-verbose --tries=1 --spider --no-check-certificate https://localhost:7655 || exit 1
wget --no-verbose --tries=1 --spider --no-check-certificate https://localhost:7655/api/health || exit 1
else
wget --no-verbose --tries=1 --spider http://localhost:7655 || exit 1
wget --no-verbose --tries=1 --spider http://localhost:7655/api/health || exit 1
fi

View file

@ -11,7 +11,7 @@ The agent verifies a SHA-256 checksum of the downloaded binary. The server must
`X-Checksum-Sha256`; updates are rejected if the header is missing or mismatched.
### 2. Signature Verification (Optional)
The legacy Docker agent supports optional Ed25519 signature verification when the server provides `X-Signature-Ed25519`. The unified agent relies on checksum verification only. Missing signatures are logged as a warning where supported.
When present, Ed25519 signatures (`X-Signature-Ed25519`) add an extra validation layer on top of checksums.
### 3. Pre-Flight Checks
To prevent "brick-updates"—bad updates that crash immediately and require manual recovery—agents perform pre-flight validation before replacing the running executable.
@ -22,13 +22,6 @@ Unified agent (`pulse-agent`):
3. Validate binary magic (ELF/Mach-O/PE) and size limits (100MB max).
4. Make executable and swap atomically.
Legacy Docker agent (`pulse-docker-agent`):
1. Download new binary.
2. Verify checksum (required).
3. Make executable.
4. **Execute with `--self-test`** to validate startup.
5. If the self-test fails, the update is aborted.
## API Security
- **Token Authentication**: All agent-to-server communication requires a valid API token.

View file

@ -1,6 +1,6 @@
# Pulse AI
Pulse Patrol is available to everyone with BYOK (your own AI provider). Pulse Pro unlocks auto-fix and advanced analysis. Learn more at <https://pulserelay.pro> or see the technical overview in [PULSE_PRO.md](PULSE_PRO.md).
Pulse Patrol is available to everyone on the Community plan with BYOK (your own AI provider). Pro, Pro+, and Cloud unlock auto-fix and advanced analysis. Learn more at <https://pulserelay.pro> or see [PULSE_PRO.md](PULSE_PRO.md).
---
@ -81,6 +81,7 @@ Every patrol run passes the LLM comprehensive context about your environment:
| **Storage Pools** | Usage %, capacity predictions, type (ZFS/LVM/Ceph), growth rates |
| **Docker/Podman** | Container counts, health states, unhealthy container lists |
| **Kubernetes** | Nodes, pods, deployments, services, DaemonSets, StatefulSets, namespaces |
| **TrueNAS** | Pools, datasets, disk health, SMART status, replication, alerts |
| **PBS/PMG** | Datastore status, backup jobs, job failures, verification status |
| **Ceph** | Cluster health, OSD states, PG status |
| **Agent Hosts** | Load averages, memory, disk, RAID status, temperatures |
@ -163,11 +164,11 @@ Dismissed and resolved findings persist across Pulse restarts.
Patrol supports three autonomy modes that control how much action it can take:
| Mode | Behavior | License |
|------|----------|---------|
| **Monitor** | Detect issues only. No investigation or fixes. | Free (BYOK) |
| **Investigate** | Investigates findings and proposes fixes. All fixes require approval before execution. | Free (BYOK) |
| **Auto-fix** | Automatically fixes issues and verifies results. Critical findings still require approval by default. | Pro |
| Mode | Behavior | Plan |
|------|----------|------|
| **Monitor** | Detect issues only. No investigation or fixes. | Community (BYOK) |
| **Investigate** | Investigates findings and proposes fixes. All fixes require approval before execution. | Community (BYOK) |
| **Auto-fix** | Automatically fixes issues and verifies results. Critical findings still require approval by default. | Pro / Pro+ / Cloud |
### Investigation Flow
@ -244,8 +245,8 @@ Pulse Assistant is a **tool-driven** chat interface. It does not "guess" system
|------|---------------|---------|
| `pulse_query`, `pulse_discovery` | Resolve | Resource discovery and query |
| `pulse_read` | Read | Read-only operations: exec, file, find, tail, logs |
| `pulse_metrics` | Read | Performance metrics |
| `pulse_storage` | Read | Storage information |
| `pulse_metrics` | Read | Performance metrics and baselines |
| `pulse_storage` | Read | Storage pools, backups, snapshots, Ceph, RAID, disk health |
| `pulse_kubernetes` | Read | Kubernetes cluster info |
| `pulse_pmg` | Read | Proxmox Mail Gateway stats |
| `pulse_alerts` | Read/Write | Alert management (resolve/dismiss are writes) |
@ -253,7 +254,9 @@ Pulse Assistant is a **tool-driven** chat interface. It does not "guess" system
| `pulse_knowledge` | Read/Write | Knowledge persistence (remember/note/save are writes) |
| `pulse_file_edit` | Read/Write | File operations (write/append are writes) |
| `pulse_control` | Write | Guest control, service management |
| `pulse_patrol` | Read | Patrol findings and status |
| `patrol_report_finding` | Patrol | Report a new finding (patrol runs only) |
| `patrol_resolve_finding` | Patrol | Resolve an active finding (patrol runs only) |
| `patrol_get_findings` | Patrol | List active findings (patrol runs only) |
### Safety Gates
@ -268,11 +271,11 @@ The assistant enforces multiple safety gates:
### Control Levels
| Level | Behavior | License |
| Level | Behavior | Plan |
|-------|----------|---------|
| **Read-only** | AI can observe and query data only | Free |
| **Controlled** | AI asks for approval before executing commands | Free |
| **Autonomous** | AI executes actions without prompting | Pro |
| **Read-only** | AI can observe and query data only | Community |
| **Controlled** | AI asks for approval before executing commands | Community |
| **Autonomous** | AI executes actions without prompting | Pro / Pro+ / Cloud |
### Using Approvals (Controlled Mode)
@ -294,6 +297,7 @@ Configure in the UI: **Settings → System → AI Assistant**
- **Anthropic** (API key or OAuth)
- **OpenAI**
- **OpenRouter**
- **DeepSeek**
- **Google Gemini**
- **Ollama** (self-hosted, with tool/function calling support)
@ -343,7 +347,7 @@ Patrol runs on a configurable schedule:
Patrol can also be triggered by:
- **Manual run**: Click "Run Patrol" in the UI
- **Alert-triggered analysis (Pro)**: Runs when an alert fires
- **Alert-triggered analysis (Pro and above)**: Runs when an alert fires
- **API call**: `POST /api/ai/patrol/run`
---
@ -405,10 +409,10 @@ scripts/eval/run_model_matrix.sh
Pulse includes settings that control how "active" AI features are:
- **Autonomous mode (Pro)**: When enabled, AI may execute safe commands without approval
- **Patrol auto-fix (Pro)**: Allows patrol to attempt automatic remediation
- **Alert-triggered analysis (Pro)**: Limits AI to analyzing specific events when alerts occur
- **Full autonomy unlock (Pro)**: Enables auto-fix for critical findings without approval (requires explicit toggle)
- **Autonomous mode (Pro and above)**: When enabled, AI may execute safe commands without approval
- **Patrol auto-fix (Pro and above)**: Allows patrol to attempt automatic remediation
- **Alert-triggered analysis (Pro and above)**: Limits AI to analyzing specific events when alerts occur
- **Full autonomy unlock (Pro and above)**: Enables auto-fix for critical findings without approval (requires explicit toggle)
If you enable execution features, ensure agent tokens and scopes are appropriately restricted.
@ -424,7 +428,7 @@ Use this only in trusted environments.
## Privacy
Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). No telemetry is sent to Pulse by default.
Patrol runs on your server and only sends the minimal context needed for analysis to the configured provider (when AI is enabled). Anonymous telemetry (counts and feature flags only — no hostnames or credentials) is enabled by default and can be disabled any time — see [Privacy](PRIVACY.md) for details.
---
@ -469,4 +473,4 @@ Pulse tracks token usage and costs:
- [Architecture: Pulse Assistant (Safety Gates)](architecture/pulse-assistant.md) — Detailed FSM states, tool protocol, and invariants
- [API Reference](API.md) — Complete API endpoint documentation
- [Pulse Pro](PULSE_PRO.md) — Pro features and licensing
- [Plans and entitlements](PULSE_PRO.md) — Community/Relay/Pro/Pro+/Cloud features and licensing

184
docs/AI_AUTONOMY.md Normal file
View file

@ -0,0 +1,184 @@
# AI Autonomy and Safety Configuration
This guide covers how to configure and manage Pulse's AI autonomy levels, control levels, and safety guardrails.
For a general overview of Pulse AI, see [AI.md](AI.md). For plan-level feature availability, see [PULSE_PRO.md](PULSE_PRO.md).
---
## Two Axes of Control
Pulse separates AI permissions into two independent axes:
1. **Patrol Autonomy Level** — How much Patrol can do on its own (detect, investigate, fix).
2. **Assistant Control Level** — Whether the interactive chat assistant can execute commands.
These are configured independently in **Settings → System → AI Assistant**.
---
## Patrol Autonomy Levels
Patrol autonomy controls how aggressively Patrol responds to findings.
| Level | Key | Detect | Investigate | Fix (Warning) | Fix (Critical) | Plan |
|-------|-----|:------:|:-----------:|:-------------:|:--------------:|------|
| **Monitor** | `monitor` | Yes | No | No | No | Community |
| **Approval** | `approval` | Yes | Yes | Approval required | Approval required | Pro / Pro+ / Cloud |
| **Assisted** | `assisted` | Yes | Yes | Auto-fix | Approval required | Pro / Pro+ / Cloud |
| **Full** | `full` | Yes | Yes | Auto-fix | Auto-fix | Pro / Pro+ / Cloud |
- **Monitor** (default): Patrol creates findings but takes no action. This is the only level available on the Community plan. Suitable for learning what Patrol detects before enabling automation.
- **Approval** (Pro and above): Patrol investigates every finding and proposes fixes. All fixes queue for manual approval before execution.
- **Assisted** (Pro and above): Warning-level findings are auto-fixed. Critical findings still require approval. This is the recommended starting point for most Pro and Pro+ users.
- **Full** (Pro and above): All findings are auto-fixed without approval. Requires an explicit toggle and a Pro, Pro+, or Cloud license. Recommended only for environments with thorough alert coverage.
### Configuration
**UI:** Settings → System → AI Assistant → Patrol Autonomy Level
**API:**
```bash
# Get current patrol autonomy settings
curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy
# Update autonomy level
curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}'
```
### License Requirements
- `monitor`: Available on all plans (Community with BYOK).
- `approval`, `assisted`, and `full`: Require the `ai_autofix` capability (Pro, Pro+, or Cloud license).
Without the `ai_autofix` capability, the effective autonomy level is clamped to `monitor` at runtime, regardless of the saved configuration. If you previously had a Pro license and downgraded, your saved setting is preserved but enforcement reverts to `monitor`.
---
## Assistant Control Levels
Control levels govern what the interactive Pulse Assistant can do during chat sessions.
| Level | Key | Query | Execute Commands | Plan |
|-------|-----|:-----:|:----------------:|------|
| **Read-only** | `read_only` | Yes | No | Community |
| **Controlled** | `controlled` | Yes | With approval | Community |
| **Autonomous** | `autonomous` | Yes | Yes | Pro / Pro+ / Cloud |
- **Read-only** (default): The assistant can query metrics, storage, and resource status but cannot execute any control actions.
- **Controlled**: The assistant can propose commands but pauses for your explicit approval before execution. Each command shows a detailed approval card in the chat UI.
- **Autonomous**: The assistant executes commands without prompting. Requires a Pro, Pro+, or Cloud license.
### Configuration
**UI:** Settings → System → AI Assistant → Control Level
**API:**
```bash
curl -X PUT http://localhost:7655/api/settings/ai/update \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{"control_level": "controlled"}'
```
### Approval Flow (Controlled Mode)
When control level is `controlled`, write operations follow this flow:
1. The assistant proposes a command (e.g., `qm start 100`).
2. An `APPROVAL_REQUIRED` response is emitted with an `approval_id`.
3. The UI displays an approval card showing the exact command.
4. You click **Approve** or **Deny**.
5. On approval, the command executes and the assistant verifies the result.
Approvals expire after 5 minutes if not acted upon.
---
## Investigation Configuration
When autonomy is `approval`, `assisted`, or `full`, Patrol investigates findings. These parameters tune investigation behavior:
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| `patrol_investigation_budget` | 15 | 530 | Maximum agentic turns per investigation |
| `patrol_investigation_timeout_sec` | 600 | 601800 | Maximum seconds per investigation |
| `max_concurrent_investigations` | 3 | — | Parallel investigation limit |
| `max_attempts_per_finding` | 3 | — | Retries before marking as `needs_attention` |
| `investigation_cooldown_sec` | 3600 | — | Cooldown before re-investigating a finding |
| `timeout_cooldown_sec` | 600 | — | Shorter cooldown after timeout failures |
---
## Safety Guardrails
Regardless of autonomy level, Pulse enforces multiple safety layers:
### Blocked Commands
Certain destructive commands are always blocked (defined in `pkg/aicontracts/safety.go`):
- Disk format/partition operations
- Cluster-wide destructive operations
- Commands that could cause data loss
### Risk Classification
Proposed fixes are classified by risk level in the approval system. Risk classification is surfaced in approval requests so operators can make informed decisions.
### Circuit Breaker
If the AI provider experiences consecutive failures, the circuit breaker (`internal/ai/circuit/breaker.go`) trips and temporarily disables AI operations. It auto-resets after a cooldown period.
### Discovery-Before-Action
The assistant cannot operate on resources it hasn't first discovered. This prevents hallucinated resource IDs from reaching infrastructure commands.
### Verification-After-Write
After executing any control action, the assistant must verify the result with a read operation before reporting success. This is enforced by the FSM — the assistant cannot return to idle state without verification.
---
## Recommended Progression
For new deployments, we recommend gradually increasing autonomy:
1. **Start with Monitor** — Run Patrol for a few cycles to see what it detects. Dismiss false positives.
2. **Move to Approval** — Enable investigation. Review proposed fixes to build confidence.
3. **Upgrade to Assisted** — Let Patrol auto-fix warnings while you approve critical fixes.
4. **Consider Full** — Only if your environment has comprehensive alerting and you trust the fix patterns.
---
## Monitoring AI Activity
### Patrol Metrics
Prometheus counters (prefix `pulse_patrol_*`) track:
- Patrol runs, findings, investigations, fixes
- Fix outcomes (success, failure, verification status)
- Circuit breaker trips
### Cost Tracking
Token usage and estimated costs are tracked per provider:
- **UI:** Settings → System → AI Assistant → Usage
- **API:** `GET /api/ai/cost/summary`
- Set monthly budget limits to cap spending
### Investigation Status
- **API:** `GET /api/ai/patrol/findings` — List all findings with investigation status
- **API:** `GET /api/ai/circuit/status` — Check circuit breaker state
---
## Related Documentation
- [Pulse AI Overview](AI.md) — Full AI system documentation
- [Plans and Entitlements](PULSE_PRO.md) — Feature availability by plan
- [API Reference](API.md) — Complete API documentation
- [Pulse Patrol Deep Dive](architecture/pulse-patrol-deep-dive.md) — Technical architecture details

View file

@ -43,8 +43,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include:
- `monitoring:read`
- `settings:read`
- `settings:write`
- `host-agent:config:read`
- `host-agent:manage`
- `agent:config:read`
- `agent:manage`
Endpoints that require admin access are noted below.
@ -74,21 +74,76 @@ Lightweight HTML status page for quick checks.
### Unified Resources
`GET /api/resources`
Returns a unified, flattened resource list. Requires `monitoring:read`.
Returns the unified resource list with pagination + aggregations. Requires `monitoring:read`.
Query params:
- `type`: comma-separated list (e.g., `agent`, `vm`, `system-container`, `container`, `docker-service`, `storage`, `pbs`, `pmg`, `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `physical_disk`, `ceph`)
- `source`: comma-separated list (e.g., `proxmox`, `agent`, `docker`, `pbs`, `pmg`, `kubernetes`)
- `status`: comma-separated list (`online`, `offline`, `warning`, `unknown`)
- `parent`: parent resource ID
- `cluster`: cluster name
- `namespace`: Kubernetes namespace (filters Kubernetes resources only)
- `q`: name search (contains match)
- `tags`: comma-separated tags
- `page`: page number (default `1`)
- `limit`: page size (default `50`, max `100`)
- `sort`: `name` (default), `status`, `type`, `lastSeen`
- `order`: `asc` (default) or `desc`
Note: `GET /api/resources` is optimized for list views. Some large, platform-specific fields may be omitted from the list response and are only returned by `GET /api/resources/{id}`.
`GET /api/resources/stats`
Summary counts and health rollups.
Returns aggregations (counts + health rollups).
`GET /api/resources/k8s/namespaces?cluster=<clusterName>`
Returns namespace-level rollups (pods + deployments) for a Kubernetes cluster. Requires `monitoring:read`.
```json
{
"cluster": "prod-k8s",
"data": [
{
"namespace": "default",
"pods": { "total": 12, "online": 10, "warning": 2, "offline": 0, "unknown": 0 },
"deployments": { "total": 3, "online": 3, "warning": 0, "offline": 0, "unknown": 0 }
}
]
}
```
`GET /api/resources/{id}`
Fetch a single resource by ID.
`GET /api/resources/{id}/children`
Returns child resources for the parent ID.
`GET /api/resources/{id}/metrics`
Returns the resource metrics payload.
`POST /api/resources/{id}/link`
Manually link two resources.
```json
{ "targetId": "resource-id", "reason": "optional note" }
```
`POST /api/resources/{id}/unlink`
Manually unlink two resources.
```json
{ "targetId": "resource-id", "reason": "optional note" }
```
`POST /api/resources/{id}/report-merge`
Report an incorrect merge (creates exclusions).
```json
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
```
### Resource Metadata
User notes, tags, and custom URLs for resources.
- `GET /api/hosts/metadata` (admin or `monitoring:read`)
- `GET /api/hosts/metadata/{hostId}` (admin or `monitoring:read`)
- `PUT /api/hosts/metadata/{hostId}` (admin or `monitoring:write`)
- `DELETE /api/hosts/metadata/{hostId}` (admin or `monitoring:write`)
- `GET /api/agents/metadata` (admin or `monitoring:read`)
- `GET /api/agents/metadata/{agentId}` (admin or `monitoring:read`)
- `PUT /api/agents/metadata/{agentId}` (admin or `monitoring:write`)
- `DELETE /api/agents/metadata/{agentId}` (admin or `monitoring:write`)
- `GET /api/guests/metadata` (admin or `monitoring:read`)
- `GET /api/guests/metadata/{guestId}` (admin or `monitoring:read`)
@ -100,10 +155,10 @@ User notes, tags, and custom URLs for resources.
- `PUT /api/docker/metadata/{containerId}` (admin or `monitoring:write`)
- `DELETE /api/docker/metadata/{containerId}` (admin or `monitoring:write`)
- `GET /api/docker/hosts/metadata` (admin or `monitoring:read`)
- `GET /api/docker/hosts/metadata/{hostId}` (admin or `monitoring:read`)
- `PUT /api/docker/hosts/metadata/{hostId}` (admin or `monitoring:write`)
- `DELETE /api/docker/hosts/metadata/{hostId}` (admin or `monitoring:write`)
- `GET /api/docker/runtimes/metadata` (admin or `monitoring:read`)
- `GET /api/docker/runtimes/metadata/{runtimeId}` (admin or `monitoring:read`)
- `PUT /api/docker/runtimes/metadata/{runtimeId}` (admin or `monitoring:write`)
- `DELETE /api/docker/runtimes/metadata/{runtimeId}` (admin or `monitoring:write`)
### Version Info
`GET /api/version`
@ -111,12 +166,12 @@ Returns version, build time, and update status.
Example response:
```json
{
"version": "5.0.16",
"buildTime": "2026-01-19T22:20:18Z",
"version": "6.0.0",
"buildTime": "2026-02-21T00:00:00Z",
"channel": "stable",
"deploymentType": "systemd",
"updateAvailable": true,
"latestVersion": "5.0.17"
"updateAvailable": false,
"latestVersion": "6.0.0"
}
```
Version fields are returned as plain semantic versions (no leading `v`).
@ -138,7 +193,7 @@ Returns a small public config payload (update channel, auto-update enabled).
{
"type": "pve",
"name": "Proxmox 1",
"host": "https://192.168.1.10:8006",
"host": "https://198.51.100.10:8006",
"user": "root@pam",
"password": "password"
}
@ -188,15 +243,69 @@ Request body:
### Setup Script (Public)
`GET /api/setup-script`
Returns the Proxmox/PBS setup script. Requires a temporary setup token (`auth_token`) in the query.
Returns the Proxmox/PBS setup script as a shell-script download. Accepts an
optional temporary setup token in the `setup_token` query for embedded
non-interactive bootstrap; otherwise the script prompts for the one-time setup
token at runtime. Canonical callers must send a supported `type` of `pve` or
`pbs` plus non-empty `host` and `pulse_url`; the route no longer generates
placeholder-host scripts for later repair or reconstructs Pulse identity from
the request origin. The route now shares the same canonical type boundary as
`/api/setup-script-url`, rejecting unsupported node types instead of treating
unknown values as PBS, and it normalizes the supplied `host` before script
generation so downloaded artifacts and rerun URLs preserve the same canonical
node identity as the bootstrap response. The optional `backup_perms=true`
query is supported only for `type=pve`.
### Setup Script URL
`POST /api/setup-script-url` (auth)
Generates a one-time setup token and URL for `/api/setup-script`.
Canonical callers must send a supported `type` of `pve` or `pbs` plus a
non-empty `host`; the backend normalizes that host before minting the setup
token and now returns the canonical bootstrap identity back in the response as
`type`, `host`, `url`, `downloadURL`, `scriptFileName`, `command`, `commandWithEnv`,
`commandWithoutEnv`, `setupToken`, `tokenHint`, and `expires`. The returned
commands are canonical root-or-sudo `curl -fsSL` bootstrap commands for the
generated setup script, while `url`, `downloadURL`, and `scriptFileName` are
the runtime-owned artifact metadata used by copy and manual download surfaces.
The request body is a single canonical JSON object only; unknown fields and trailing JSON are
rejected as invalid request shape, and `backupPerms:true` is supported only for
`type:"pve"`. This route stays on the normal
authenticated bootstrap boundary: when Pulse auth is already configured it
requires a real authenticated session or API token, and setup tokens do not
authorize the request itself. Pulse-managed Proxmox monitor-token names on the
setup/bootstrap path derive from the canonical Pulse endpoint, not request-local
host fallbacks, so setup-script and turnkey node-add flows stay on one
deterministic `pulse-<canonical-scope-slug>` identity per Pulse instance.
`setupToken` remains bootstrap transport data for `/api/setup-script` and
`/api/auto-register`, while `tokenHint` is the operator-facing display field
for quick-setup surfaces and must stay masked instead of exposing the full
one-time token in UI copy. Shared frontend consumers may validate
`setupToken`, but they should not retain or display it once the returned
bootstrap artifact and `tokenHint` are available; visible quick-setup previews
should use the non-secret `commandWithoutEnv` form while copy actions keep
using the token-bearing `commandWithEnv` artifact, and manual download flows
should use the token-bearing `downloadURL` artifact instead of rebuilding a
plain setup-script URL from non-secret preview state. Non-frontend bootstrap
consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that
same full artifact contract too, rejecting missing or mismatched
`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a
reduced setup-token-only response shape.
### Auto-Register (Public)
`POST /api/auto-register`
Auto-registers a node using the temporary setup token.
Registers a node through the canonical `/api/auto-register` contract using a temporary
setup token carried in the JSON `authToken` field. Canonical callers must send
a supported `type` of `pve` or `pbs`, an explicit `source` marker of `agent` or
`script`, a canonical Pulse-managed `tokenId` in the form
`pulse-monitor@{pve|pbs}!pulse-<canonical-scope-slug>` matching the requested
type, an explicit `serverName`, and missing-token requests now fail with
`Pulse setup token required`. Incomplete token completion requests now fail
with `tokenId and tokenValue must be provided together`, and other missing
canonical request fields fail with explicit `Missing required canonical
auto-register fields: ...` guidance.
Success responses now carry the canonical stored identity and caller boundary
back to the installer or runtime-side Unified Agent:
`{"status":"success","action":"use_token","type":"pve|pbs","source":"agent|script","host":"https://...","nodeId":"<stored-name>","nodeName":"<stored-name>",...}`.
### Agent Install Command
`POST /api/agent-install-command` (auth)
@ -213,12 +322,12 @@ Service discovery is used by Pulse Assistant and the UI to inventory web service
- `GET /api/discovery/status`
- `PUT /api/discovery/settings` (admin, `settings:write`)
- `GET /api/discovery/type/{type}`
- `GET /api/discovery/host/{host}`
- `GET /api/discovery/{type}/{host}/{id}`
- `POST /api/discovery/{type}/{host}/{id}` (trigger discovery, optional `force`)
- `DELETE /api/discovery/{type}/{host}/{id}`
- `GET /api/discovery/{type}/{host}/{id}/progress`
- `PUT /api/discovery/{type}/{host}/{id}/notes`
- `GET /api/discovery/agent/{agentId}`
- `GET /api/discovery/{type}/{targetId}/{resourceId}`
- `POST /api/discovery/{type}/{targetId}/{resourceId}` (trigger discovery, optional `force`)
- `DELETE /api/discovery/{type}/{targetId}/{resourceId}`
- `GET /api/discovery/{type}/{targetId}/{resourceId}/progress`
- `PUT /api/discovery/{type}/{targetId}/{resourceId}/notes`
### Test Notification
`POST /api/test-notification` (auth)
@ -241,18 +350,24 @@ Returns storage chart data.
`GET /api/storage/`
Detailed storage usage per node and pool.
### Backup History
`GET /api/backups/unified`
Combined view of PVE and PBS backups.
### Recovery (formerly Backups / Snapshots)
Pulse v6 uses the recovery API to provide a platform-agnostic view of backup and snapshot artifacts.
See `docs/architecture/RECOVERY_CONTRACT.md` for the provider-neutral contract (subjects, points, rollups, and filter semantics).
Other backup endpoints:
- `GET /api/backups`
- `GET /api/backups/pve`
- `GET /api/backups/pbs`
### Snapshots
`GET /api/snapshots`
Returns guest snapshot history for the current tenant.
- `GET /api/recovery/points`
- Query params:
- Core filters: `provider`, `kind`, `mode`, `outcome`, `subjectResourceId`, `rollupId`
- Time window: `from` (RFC3339), `to` (RFC3339)
- Paging: `page`, `limit`
- Normalized filters: `q`, `cluster`, `node`, `namespace`, `scope=workload`, `verification` (`verified` | `unverified` | `unknown`)
- `GET /api/recovery/rollups`
- Query params: `provider`, `kind`, `mode`, `outcome`, `subjectResourceId`, `rollupId`, `from` (RFC3339), `to` (RFC3339), `page`, `limit`
- `GET /api/recovery/series`
- Returns per-day counts for the activity chart.
- Query params: same filters as `/api/recovery/points` (except paging), plus `tzOffsetMinutes` (integer; UTC offset minutes for day bucketing)
- `GET /api/recovery/facets`
- Returns distinct filter values (clusters/nodes/namespaces) and capability flags (size/verification/entity id present).
- Query params: same filters as `/api/recovery/points` (except paging)
---
@ -280,10 +395,31 @@ Triggers a test alert to all configured channels.
### Audit Webhooks (Pro)
- `GET /api/admin/webhooks/audit` (admin, `settings:read`)
- `POST /api/admin/webhooks/audit` (admin, `settings:write`)
- Body: `{ "urls": ["https://..."] }`
- Strict JSON contract: unknown fields and trailing payload are rejected.
- Maximum `20` webhook URLs per update request.
- URLs are normalized (trimmed) and duplicate entries are ignored.
- Endpoint fails closed if URL validation runtime is unavailable.
### Advanced Reporting (Pro)
- `GET /api/admin/reports/generate` (admin, `settings:read`)
- Query params: `format` (pdf/csv, default `pdf`), `resourceType`, `resourceId`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
- `POST /api/admin/reports/generate-multi` (admin, `settings:read`)
- Body fields: `resources` (1-50 entries of `{resourceType,resourceId}`), `format`, `metricType` (optional), `start`/`end` (RFC3339, optional; defaults to last 24h), `title` (optional)
Validation and limits:
- `start` and `end` must be RFC3339 when provided.
- `start` must be before `end`.
- Maximum report window is 366 days.
- `metricType` must match `[a-zA-Z0-9._:-]+` and be <= 64 chars.
- `title` must be <= 256 chars.
- Multi-report body max size is 1MB and rejects trailing payload or unknown JSON fields.
Common reporting error codes:
- `invalid_format`, `missing_params`, `invalid_resource_type`, `invalid_resource_id`
- `invalid_metric_type`, `invalid_title`
- `invalid_start`, `invalid_end`, `invalid_range`, `range_too_large`
- `no_resources`, `too_many_resources`, `body_too_large`, `invalid_body`
### Queue and Dead-Letter Tools
- `GET /api/notifications/queue/stats` (admin)
@ -310,7 +446,6 @@ Alert configuration and history (requires `monitoring:read`/`monitoring:write`).
- `POST /api/alerts/acknowledge` (body: `{ "id": "alert-id" }`)
- `POST /api/alerts/unacknowledge` (body: `{ "id": "alert-id" }`)
- `POST /api/alerts/clear` (body: `{ "id": "alert-id" }`)
- Legacy path-based endpoints: `POST /api/alerts/{id}/acknowledge`, `/unacknowledge`, `/clear`
---
@ -372,7 +507,7 @@ Returns a new raw token (shown once) and updates stored hashes:
## 🧾 Audit Log (Pro)
These endpoints require admin access and the `settings:read` scope. In OSS builds, the list endpoint returns an empty set and `persistentLogging: false`.
These endpoints require admin access and the `settings:read` scope. On Community, the list endpoint returns an empty set and `persistentLogging: false`.
### List Audit Events
`GET /api/audit?limit=100&event=login&user=admin&success=true&startTime=2024-01-01T00:00:00Z&endTime=2024-01-31T23:59:59Z`
@ -386,7 +521,7 @@ Response:
"timestamp": "2024-01-12T10:15:30Z",
"event": "login",
"user": "admin",
"ip": "10.0.0.10",
"ip": "198.51.100.10",
"path": "/api/login",
"success": true,
"details": "Successful login",
@ -498,7 +633,7 @@ Returns scheduler health, DLQ, and breaker status. Requires `monitoring:read`.
- `GET /api/infra-updates` (requires `monitoring:read`)
- `GET /api/infra-updates/summary` (requires `monitoring:read`)
- `POST /api/infra-updates/check` (requires `monitoring:write`)
- `GET /api/infra-updates/host/{hostId}` (requires `monitoring:read`)
- `GET /api/infra-updates/agent/{agentId}` (requires `monitoring:read`)
- `GET /api/infra-updates/{resourceId}` (requires `monitoring:read`)
### Diagnostics
@ -519,21 +654,37 @@ Returns minimal server info for installer scripts.
## 🔑 OIDC / SSO
### Get OIDC Config
`GET /api/security/oidc`
Retrieve current OIDC provider settings.
### Provider Login
- `GET /api/oidc/{providerID}/login`
- `GET /api/oidc/{providerID}/callback`
### Update OIDC Config
`POST /api/security/oidc`
Configure OIDC provider details (Issuer, Client ID, etc).
OIDC and SAML configuration is managed via SSO providers.
### Login
`GET /api/oidc/login`
Initiate OIDC login flow.
### SSO Provider Management (Pro)
- `GET /api/security/sso/providers` (admin)
- `POST /api/security/sso/providers` (admin)
- `GET /api/security/sso/providers/{id}` (admin)
- `PUT /api/security/sso/providers/{id}` (admin)
- `DELETE /api/security/sso/providers/{id}` (admin)
Provider mutation request contract:
- Max request body: 1MB.
- Strict JSON contract: unknown fields and trailing payload are rejected.
- Provider IDs must match server validation.
- SAML providers require `advanced_sso` feature entitlement.
### SSO Test and Metadata Preview (Pro)
- `POST /api/security/sso/providers/test` (admin)
- `POST /api/security/sso/providers/metadata/preview` (admin)
Test/preview request contract:
- Max request body: 32KB.
- Strict JSON contract: unknown fields and trailing payload are rejected.
- Common errors include `invalid_json`, `validation_error`, `rate_limited`, `body_too_large`.
---
## 💳 License (Pulse Pro)
## 💳 License (Relay / Pro / Pro+ / Cloud)
### License Status (Admin)
`GET /api/license/status`
@ -598,9 +749,83 @@ Returns all users with their role assignments.
---
## 🤖 Pulse AI *(v5)*
## 🏢 Organizations (Enterprise)
**Pro gating:** endpoints labeled "(Pro)" require a Pulse Pro license and return `402 Payment Required` if the feature is not licensed.
Multi-tenant organization management. Requires `PULSE_MULTI_TENANT_ENABLED=true` and an Enterprise license with the `multi_tenant` feature. All endpoints require authentication.
See [MULTI_TENANT.md](MULTI_TENANT.md) for setup and architecture details.
### List Organizations
`GET /api/orgs` (requires `settings:read`)
Returns organizations accessible to the authenticated user.
### Create Organization
`POST /api/orgs` (requires `settings:write`, session auth only)
```json
{ "id": "acme-corp", "displayName": "Acme Corporation" }
```
The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters.
### Get Organization
`GET /api/orgs/{id}` (requires `settings:read`)
Returns organization details. User must be a member.
### Update Organization
`PUT /api/orgs/{id}` (requires `settings:write`, session auth only)
```json
{ "displayName": "Updated Name" }
```
Admin or owner role required. The default organization cannot be updated.
### Delete Organization
`DELETE /api/orgs/{id}` (requires `settings:write`, session auth only)
Admin or owner role required. The default organization cannot be deleted.
### List Members
`GET /api/orgs/{id}/members` (requires `settings:read`)
Returns all members with their roles. User must be a member of the org.
### Add or Update Member
`POST /api/orgs/{id}/members` (requires `settings:write`, session auth only)
```json
{ "userId": "jane", "role": "editor" }
```
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
### Remove Member
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
Admin or owner role required. The organization owner cannot be removed.
### List Outgoing Shares
`GET /api/orgs/{id}/shares` (requires `settings:read`)
Returns resources shared outbound from this organization to others.
### List Incoming Shares
`GET /api/orgs/{id}/shares/incoming` (requires `settings:read`)
Returns resources shared inbound to this organization from other organizations.
### Create Share
`POST /api/orgs/{id}/shares` (requires `settings:write`, session auth only)
```json
{
"targetOrgId": "partner-org",
"resourceType": "vm",
"resourceId": "vm-101",
"resourceName": "Web Server",
"accessRole": "viewer"
}
```
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
### Delete Share
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)
Revoke a resource share. Admin or owner role required.
---
## 🤖 Pulse AI
**Paid gating:** endpoints labeled with a paid plan require the relevant Relay, Pro, Pro+, or Cloud capability and return `402 Payment Required` if the feature is not licensed.
### Get AI Settings
`GET /api/settings/ai`
@ -646,12 +871,6 @@ Streaming variant of execute (used by the UI for incremental responses).
- `POST /api/ai/sessions/{id}/revert`
- `POST /api/ai/sessions/{id}/unrevert`
### Legacy Chat Sessions (UI Sync)
- `GET /api/ai/chat/sessions`
- `GET /api/ai/chat/sessions/{id}`
- `PUT /api/ai/chat/sessions/{id}`
- `DELETE /api/ai/chat/sessions/{id}`
### Question Answers
- `POST /api/ai/question/{id}/answer`
@ -660,7 +879,7 @@ Streaming variant of execute (used by the UI for incremental responses).
```json
{ "cluster_id": "cluster-id" }
```
Requires a Pulse Pro license with the `kubernetes_ai` feature enabled.
Requires Pro, Pro+, or Cloud with the `kubernetes_ai` feature enabled.
### Alert Investigation (Pro)
`POST /api/ai/investigate-alert`
@ -749,7 +968,7 @@ Request bodies:
- `POST /api/ai/cost/reset` (admin)
- `GET /api/ai/cost/export` (admin)
## 📈 Metrics Store (v5)
## 📈 Metrics Store
Auth required: `monitoring:read`.
@ -768,7 +987,7 @@ Query params:
- `range` (optional): `1h`, `6h`, `12h`, `24h`, `1d`, `7d`, `30d`, `90d` (default `24h`; duration strings also accepted)
- `maxPoints` (optional): Downsample to a target number of points
> **License**: Requests beyond `7d` require the Pulse Pro `long_term_metrics` feature. Unlicensed requests return `402 Payment Required`.
> **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and Pro+ unlock `90d`, and requests beyond the active tier's limit return `402 Payment Required`.
> **Aliases**: `guest` (VM/LXC) and `docker` (Docker container) are accepted, but persistent store data uses the canonical types above.
---
@ -796,73 +1015,49 @@ Returns the current server version for agent update checks.
`GET /install.sh`
Serves the universal `install.sh` used to install `pulse-agent` on target machines.
`GET /api/install/install.sh`
API-prefixed alias for the unified agent installer script.
### Unified Agent Installer (Windows)
`GET /install.ps1`
Serves the PowerShell installer for Windows.
`GET /api/install/install.ps1`
API-prefixed alias for the unified agent PowerShell installer.
### Docker Server Installer Script
`GET /api/install/install-docker.sh`
Serves the turnkey Docker installer script that generates a `docker-compose.yml` and `.env`.
### Legacy Agents (Deprecated)
`GET /download/pulse-host-agent` - *Deprecated, use pulse-agent*
`GET /download/pulse-docker-agent` - *Deprecated, use pulse-agent --enable-docker*
Host-agent downloads accept `?platform=<os>&arch=<arch>` and expose a checksum endpoint:
- `/download/pulse-host-agent.sha256?platform=linux&arch=amd64`
Legacy install/uninstall scripts:
- `GET /install-docker-agent.sh`
- `GET /install-container-agent.sh`
- `GET /install-host-agent.sh`
- `GET /install-host-agent.ps1`
- `GET /uninstall-host-agent.sh`
- `GET /uninstall-host-agent.ps1`
### Submit Reports
`POST /api/agents/host/report` - Host metrics
`POST /api/agents/agent/report` - Agent metrics
`POST /api/agents/docker/report` - Docker container metrics
`POST /api/agents/kubernetes/report` - Kubernetes cluster metrics
### Host Agent Management
`GET /api/agents/host/lookup?id=<host_id>`
`GET /api/agents/host/lookup?hostname=<hostname>`
Looks up a host by ID or hostname/display name. Requires `host-agent:report`.
### Agent Management
`GET /api/agents/agent/lookup?id=<agent_id>`
`GET /api/agents/agent/lookup?hostname=<hostname>`
Looks up an agent by ID or hostname/display name. Requires `agent:report`.
`POST /api/agents/host/uninstall`
Host agent self-unregister during uninstall. Requires `host-agent:report`.
`POST /api/agents/agent/uninstall`
Agent self-unregister during uninstall. Requires `agent:report`.
`POST /api/agents/host/unlink` (admin, `host-agent:manage`)
Unlinks a host agent from a node.
`POST /api/agents/agent/unlink` (admin, `agent:manage`)
Unlinks an agent from a node.
`DELETE /api/agents/host/{host_id}` (admin, `host-agent:manage`)
Removes a host agent from state.
`DELETE /api/agents/agent/{agent_id}` (admin, `agent:manage`)
Removes an agent from state.
### Host Agent Linking (Admin)
- `POST /api/agents/host/link` (admin, `host-agent:manage`)
- `POST /api/agents/host/unlink` (admin, `host-agent:manage`)
### Agent Linking (Admin)
- `POST /api/agents/agent/link` (admin, `agent:manage`)
- `POST /api/agents/agent/unlink` (admin, `agent:manage`)
### Agent Remote Config
`GET /api/agents/host/{agent_id}/config`
Returns the server-side config payload for an agent (used by remote config and debugging). Requires `host-agent:config:read`.
`GET /api/agents/agent/{agent_id}/config`
Returns the server-side config payload for an agent (used by remote config and debugging). Requires `agent:config:read`.
`PATCH /api/agents/host/{agent_id}/config` (admin, `host-agent:manage`)
`PATCH /api/agents/agent/{agent_id}/config` (admin, `agent:manage`)
Updates server-side config for an agent (e.g., `commandsEnabled`).
### Docker Agent Management (Admin)
- `POST /api/agents/docker/commands/{commandId}/ack` (`docker:report`)
- `DELETE /api/agents/docker/hosts/{hostId}` (`docker:manage`, supports `?hide=true` or `?force=true`)
- `POST /api/agents/docker/hosts/{hostId}/allow-reenroll` (`docker:manage`)
- `PUT /api/agents/docker/hosts/{hostId}/unhide` (`docker:manage`)
- `PUT /api/agents/docker/hosts/{hostId}/pending-uninstall` (`docker:manage`)
- `PUT /api/agents/docker/hosts/{hostId}/display-name` (`docker:manage`)
- `POST /api/agents/docker/hosts/{hostId}/check-updates` (`docker:manage`)
- `DELETE /api/agents/docker/runtimes/{agentId}` (`docker:manage`, supports `?hide=true` or `?force=true`)
- `POST /api/agents/docker/runtimes/{agentId}/allow-reenroll` (`docker:manage`)
- `PUT /api/agents/docker/runtimes/{agentId}/unhide` (`docker:manage`)
- `PUT /api/agents/docker/runtimes/{agentId}/pending-uninstall` (`docker:manage`)
- `PUT /api/agents/docker/runtimes/{agentId}/display-name` (`docker:manage`)
- `POST /api/agents/docker/runtimes/{agentId}/check-updates` (`docker:manage`)
- `POST /api/agents/docker/runtimes/{agentId}/update-all` (`docker:manage`)
- `POST /api/agents/docker/containers/update` (`docker:manage`)
### Kubernetes Agent Management (Admin)
@ -892,10 +1087,41 @@ Updates server-side config for an agent (e.g., `commandsEnabled`).
---
## 🐟 TrueNAS
TrueNAS connection management endpoints for adding, testing, and removing TrueNAS SCALE/CORE instances.
### Connection Management (Admin)
- `GET /api/truenas/connections` (admin, `settings:read`) — List configured TrueNAS connections.
- `POST /api/truenas/connections` (admin, `settings:write`) — Add a new TrueNAS connection.
- `POST /api/truenas/connections/test` (admin, `settings:write`) — Test a TrueNAS connection before saving.
- `DELETE /api/truenas/connections/{id}` (admin, `settings:write`) — Remove a TrueNAS connection.
TrueNAS resources (pools, datasets, disks, ZFS snapshots, replication tasks, alerts) are surfaced through the unified `/api/resources` endpoint with `source=truenas`.
---
## 📱 Relay / Mobile Remote Access (Relay and Above)
End-to-end encrypted relay protocol for mobile connectivity.
> Mobile app status: public rollout is coming soon; current pairing endpoints are primarily used for staged beta onboarding.
### Relay Configuration (Admin, Relay and Above)
- `GET /api/settings/relay` (admin, `settings:read`, Relay+) — Get current relay configuration.
- `PUT /api/settings/relay` (admin, `settings:write`, Relay+) — Update relay configuration.
- `GET /api/settings/relay/status` (admin, `settings:read`, Relay+) — Get relay connection status.
### Mobile Onboarding
- `GET /api/onboarding/qr` (`settings:read`) — Generate QR code for mobile app pairing.
- `POST /api/onboarding/validate` (`settings:read`) — Validate a mobile onboarding connection.
- `GET /api/onboarding/deep-link` (`settings:read`) — Generate deep-link URL for mobile app.
---
## 🔌 WebSocket Endpoints
- `GET /ws` Primary UI WebSocket (browser sessions).
- `GET /socket.io/` Legacy Socket.IO compatibility endpoint.
- `GET /api/agent/ws` Agent WebSocket used for AI command execution.
---

169
docs/AUDIT_LOGGING.md Normal file
View file

@ -0,0 +1,169 @@
# Audit Logging
Pulse's audit log records security-relevant events with tamper-evident signatures. Use it for compliance, incident investigation, and tracking who did what.
**Requires:** Pro, Pro+, or Cloud license with the `audit_logging` capability to query, export, and verify events via the API. Events are recorded on all plans, but the API endpoints are license-gated.
For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-audit-log-pro).
---
## What Gets Logged
Pulse automatically captures the following events:
| Event Type | Description | Example |
|------------|-------------|---------|
| `login` | Successful and failed login attempts | User `admin` logged in from 198.51.100.5 |
| `logout` | User logouts | User `admin` logged out |
| `password_change` | Password modifications | Password changed (Docker/systemd) |
| `csrf_failure` | Blocked cross-site request forgery attempts | Invalid CSRF token |
| `lockout_reset` | Account lockout resets | Admin reset lockout for user `bob` |
| `oidc_login` | OIDC SSO login attempts (success/failure at each stage) | OIDC login success |
| `oidc_token_refresh` | OIDC token refresh success/failure (global, not tenant-scoped) | Token refreshed successfully |
| `oidc_role_assignment` | Automatic role assignment from OIDC groups | Auto-assigned roles: operator, viewer |
| `saml_login` | SAML SSO login attempts | SAML login success via provider-id |
| `saml_role_assignment` | Automatic role assignment from SAML groups | Auto-assigned roles: admin |
| `sso_provider_created` | SSO provider configuration created | Created provider: Authentik |
| `sso_provider_updated` | SSO provider configuration modified | Updated provider: Authentik |
| `sso_provider_deleted` | SSO provider configuration removed | Deleted provider: Authentik |
| `ai_settings_updated` | AI configuration changes | AI settings updated |
| `agent_profile_assigned` | Agent profile assignments | Profile `production` assigned to agent |
| `agent_profile_unassigned` | Agent profile removals | Profile removed from agent |
| `user_roles_updated` | RBAC role assignments changed | Updated roles for user jane: [operator] |
| `agent_config_fetch` | Agent configuration retrieval attempts | Agent config fetched successfully |
Each event includes:
- **Timestamp** (UTC)
- **Event type**
- **User** who triggered the event
- **Client IP** address
- **Request path**
- **Success/failure** flag
- **Details** (human-readable description)
- **Cryptographic signature** (tamper detection)
---
## Viewing Audit Events
### UI
**Settings → Security → Audit Log**
The audit log panel shows events in reverse chronological order with filtering by event type, user, date range, and success/failure.
### API
```bash
# List recent events
curl http://localhost:7655/api/audit?limit=50 \
-H "Authorization: Bearer $TOKEN"
# Filter by event type and date range
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
-H "Authorization: Bearer $TOKEN"
# Get audit summary
curl http://localhost:7655/api/audit/summary \
-H "Authorization: Bearer $TOKEN"
```
### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | integer | Maximum events to return (default: 100) |
| `event` | string | Filter by event type (e.g., `login`, `password_change`) |
| `user` | string | Filter by username |
| `success` | boolean | Filter by success (`true`) or failure (`false`) |
| `startTime` | ISO 8601 | Start of date range |
| `endTime` | ISO 8601 | End of date range |
---
## Exporting Audit Data
Export the audit log for external analysis or compliance archival:
```bash
curl http://localhost:7655/api/audit/export \
-H "Authorization: Bearer $TOKEN" \
-o audit-export.json
```
The export includes all events matching the current filter criteria.
---
## Tamper Detection
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
```bash
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
-H "Authorization: Bearer $TOKEN"
```
Response:
```json
{
"available": true,
"verified": true,
"message": "Event signature verified"
}
```
If `verified` is `false`, the event data has been tampered with since it was recorded.
---
## Multi-Tenant Audit Isolation
In multi-tenant deployments, most events are scoped to the active organization:
- Tenant-aware events (logins, role changes, config updates) are stored per-organization.
- Some auth lifecycle events (e.g., `oidc_token_refresh`) are global and not tenant-scoped.
- Switching organizations shows only that organization's tenant-scoped events.
- The tenant context is determined by `X-Pulse-Org-ID` header or session cookie.
See [Multi-Tenant Organizations](MULTI_TENANT.md) for details.
---
## Community vs Pro Behavior
| Capability | Community | Pro / Pro+ / Cloud |
|------------|-----------|-------------|
| Events captured | Yes | Yes |
| Persistent storage (SQLite) | Yes | Yes |
| Query/filter API | License-gated (402) | Full access |
| Signature verification | License-gated (402) | Available |
| Export | License-gated (402) | Available |
| `persistentLogging` API flag | `false` | `true` |
On all plans, audit events are written to the SQLite database. However, the query, verify, and export API endpoints require the `audit_logging` license feature and return `402 Payment Required` without it. The `persistentLogging` flag in API responses indicates whether the licensed query capabilities are available.
---
## Storage
Audit events are stored in a SQLite database in the Pulse data directory:
- **Single-tenant:** `{data-dir}/audit/audit.db`
- **Multi-tenant:** `{data-dir}/orgs/{org-id}/audit/audit.db`
Data directory locations:
- systemd: `/etc/pulse/`
- Docker/Kubernetes: `/data/`
- Development: `tmp/dev-config/`
---
## Related Documentation
- [Plans and Entitlements](PULSE_PRO.md) — Audit logging availability by plan
- [RBAC](RBAC.md) — Role-based access control (role changes are audit logged)
- [OIDC / SSO](OIDC.md) — SSO login events are audit logged
- [Security Policy](../SECURITY.md) — Core security model
- [Multi-Tenant Organizations](MULTI_TENANT.md) — Per-tenant audit isolation
- [API Reference](API.md#-audit-log-pro) — Audit log API endpoints

View file

@ -1,6 +1,6 @@
# Automatic Updates
Pulse 5.0 introduces one-click updates for supported deployment types, making it easy to keep your monitoring system up to date.
Pulse supports one-click updates for supported deployment types, making it easy to keep your monitoring system up to date.
## Supported Deployment Types
@ -48,7 +48,7 @@ In **Settings → System → Updates**:
| Setting | Description |
|---------|-------------|
| **Update Channel** | Stable (recommended) or Release Candidate |
| **Update Channel** | Stable (recommended for production) or Release Candidate (opt-in preview) |
| **Auto-Check** | Background update check interval (hours); `0` disables |
### Stored Settings (system.json)
@ -66,6 +66,8 @@ Auto-update preferences are stored in `system.json` and edited via the UI.
**Note:** `autoUpdateTime` is stored for UI reference. The systemd timer still runs on its own schedule (02:00 + jitter). Background update checks follow `autoUpdateCheckInterval`.
**Channel policy note:** `stable` is the default and only recommended channel for paid or production environments. `rc` is an explicit preview channel. In v6, unattended systemd auto-updates remain `stable`-only even if `updateChannel` is set to `rc`.
## Manual Update Methods
### Docker
@ -86,7 +88,7 @@ If you use the legacy `docker-compose` binary, replace `docker compose` with `do
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Unified Agents → Installation commands**.
### Systemd Service (Manual)
@ -94,7 +96,7 @@ This script installs/updates the **Pulse server**. Agent updates use the `/insta
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Unified Agents → Installation commands**.
### Source Build
@ -116,7 +118,7 @@ Pulse creates a backup before updating. If the update fails:
3. Error details are logged
### Manual Rollback
Backups created by in-app updates are stored as `backup-<timestamp>/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory is not writable, Pulse falls back to `/tmp/pulse-backup-<timestamp>`.
Update backups created by in-app updates are stored as `backup-<timestamp>/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory is not writable, Pulse falls back to `/tmp/pulse-backup-<timestamp>`.
There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart.
Example (systemd/LXC):

View file

@ -0,0 +1,337 @@
## v6 Canonical Alert-Engine Migration
Status: Draft
Date: 2026-03-10
Scope: `pulse` only
## Purpose
This document defines the canonical v6 alert-engine target for this repo and the migration path inside the current codebase.
It is intentionally not a rewrite plan. The current system already has the pieces that matter:
- `internal/unifiedresources` already owns canonical resource identity, parent-child relationships, metrics targets, and provider-native incidents.
- `internal/alerts/unified_eval.go` already centralizes threshold-based metric evaluation for unified resources.
- `internal/alerts/unified_incidents.go` already mirrors canonical resource incidents into the live alert manager.
- `internal/monitoring/monitor_alert_sync.go` already syncs unified incidents into shared alert state.
- `internal/monitoring/monitor_alerts.go` already owns the downstream fan-out to tenant websocket broadcast, notifications, incident recording, and AI callbacks.
What is still legacy is responsibility placement: most `Check*` methods in `internal/alerts/alerts.go` still mix collection, threshold resolution, transition rules, alert construction, and notification entry.
## Canonical Target Model
The target model keeps one alert engine and splits it into explicit layers.
### 1. Unified Resource Identity
Source of truth: `internal/unifiedresources`
Canonical resource identity comes from `unifiedresources.Resource`:
- `Resource.ID` is the long-term alert `resource_id`.
- `Resource.Type` is the long-term alert `resource_type`.
- `Resource.Canonical`, `ParentID`, `ParentName`, `MetricsTarget`, and `DiscoveryTarget` are the only allowed inputs for display naming, grouping, and source lookup.
- `Resource.Incidents` is the only canonical provider-native incident feed.
Important migration bridge:
- Today, alert `resource_id` and threshold override key are not always the same key.
- Example: host alerts emit `agent:<hostID>` resource IDs, while override lookup still resolves on raw `host.ID`.
- The migration must therefore carry both a `ResourceID` and a `ThresholdKey` until config storage is migrated safely.
### 2. Canonical Alert Specs
Add a spec layer inside `internal/alerts` and make it the only input to transition logic.
Minimum spec families:
- `MetricSpec`
- `StateSpec`
- `IncidentSpec`
Minimum fields every spec must carry:
- stable `AlertID`
- canonical `ResourceID`
- explicit `ThresholdKey`
- canonical `ResourceType`
- `Node`, `Instance`, `ResourceName`
- `AlertType`
- current value or incident payload
- resolved message and metadata
- transition policy fields such as `RequiredConfirmations`, `MonitorOnly`, and `DisableConnectivity`
This is the contract that replaces ad hoc alert assembly inside each `Check*` method.
### 3. Evaluator Engine
Source of truth: `internal/alerts`
The evaluator owns only:
- threshold resolution
- time-threshold delay handling
- hysteresis trigger/clear behavior
- active/resolved transition rules
- acknowledgement and escalation preservation
- rate limiting
- quiet-hours suppression
- flapping suppression
`checkMetric`, `preserveAlertState`, `dispatchAlert`, and the resolved-alert flow already do most of this. The migration should reuse those mechanics, not replace them.
### 4. Transition Persistence
Transition state remains in the existing manager until the migration is complete:
- `activeAlerts`
- `recentlyResolved`
- `recentAlerts`
- `pendingAlerts`
- `offlineConfirmations`
- `ackState`
- flapping and rate-limit maps
- `historyManager`
Do not create a second persistence path. The migration succeeds by moving callers onto the same state machine.
### 5. Notification Fan-Out
`internal/monitoring/monitor_alerts.go` remains the notification boundary:
- tenant websocket broadcasts
- notification manager sends/cancels
- incident timeline recording
- AI alert callbacks
The evaluator emits alert transitions. Monitoring owns delivery.
## Why a Full Rewrite Is the Wrong Move
A full rewrite is the wrong move here for four concrete reasons.
1. The hard part already exists.
The current manager already preserves ack state, start times, quiet-hours behavior, cooldown re-notify logic, rate limiting, flapping suppression, and resolved notification behavior. Rebuilding that from scratch would duplicate the highest-risk code path.
2. The codebase already has a working canonical seam.
`unified_eval.go` and `unified_incidents.go` prove the repo can migrate incrementally by moving input builders first and leaving transition state alone.
3. Pollers are still source-shaped.
The monitor layer still fans out typed models from Proxmox, agents, Docker, PBS, PMG, storage, backups, and snapshots. Replacing the engine outright would force every poller and every alert family to move at once.
4. Downstream contracts already depend on the current manager.
`monitor_alerts.go` assumes one live alert manager feeds websocket, notifications, incident recording, and AI. Swapping the engine wholesale would multiply migration surfaces instead of reducing them.
The correct move is to keep one manager and migrate input assembly until legacy `Check*` methods are thin builders only.
## Stable Behaviors That Must Not Change Mid-Migration
These behaviors are already relied on by tests, persisted state, or user workflow and must remain stable until a dedicated migration phase says otherwise.
- Existing alert ID formats stay stable for migrated families. Do not change IDs while `preserveAlertState`, ack carry-over, and history continuity still depend on them.
- Existing threshold override keys stay stable until config migration is implemented explicitly.
- `checkMetric` hysteresis, per-type time thresholds, per-metric time thresholds, cooldown re-notify, and critical escalation-on-level-change stay unchanged.
- Ack, start time, escalation metadata, and last notification timestamps continue to survive alert rebuilds through `preserveAlertState`.
- Notification suppression rules stay unchanged: quiet hours, rate limiting, flapping, monitor-only, activation state, and resolved-notification rules.
- Current confirmation semantics stay unchanged:
- node offline: 3 polls
- PBS offline: 3 polls
- PMG offline: 3 polls
- storage offline: 2 polls
- guest powered-off: 2 polls
- Unified incident suppression semantics stay unchanged, including parent-child de-dup in `internal/alerts/unified_incidents.go`.
- Tenant broadcast routing stays in monitoring and remains tenant-scoped.
## Migration Phases
### Phase 1: Introduce Canonical Builder Contracts
Goal:
Create explicit canonical alert-spec builders without changing downstream alert behavior.
Code moves:
- Add internal spec types in `internal/alerts` for metric, state, and incident evaluation.
- Give every spec both `ResourceID` and `ThresholdKey`.
- Add builders that project `unifiedresources.Resource` into evaluator input.
- Add builder helpers for typed source models where unified resources are not yet the direct caller.
- Keep `CheckUnifiedResource` and `SyncUnifiedResourceIncidents` as the first-class entry points for canonical metric and incident inputs.
What stays:
- `checkMetric`
- `preserveAlertState`
- existing notification callbacks
- existing manager maps and history persistence
Delete at end of phase:
- Nothing. This phase is structure-only.
Exit criteria:
- New evaluator helpers accept specs rather than raw source models.
- New alert logic is forbidden from being added directly inside typed `Check*` methods.
### Phase 2: Convert Metric Families First
Goal:
Make typed metric checks collectors/builders only.
Code moves:
- Convert `CheckGuest`, `CheckNode`, `CheckHost`, `CheckPBS`, and `CheckStorage` into:
- source-specific collection and suppression
- canonical spec building
- shared evaluator calls
- Reuse `evaluateUnifiedMetrics` and `checkMetric` for the final transition decision.
- Keep source-specific metadata assembly in the builders.
- For host-agent metrics, explicitly bridge the current mismatch between raw host override keys and emitted `agent:<id>` alert resource IDs.
Required parity gates:
- extend `internal/alerts/unified_eval_parity_test.go`
- keep `internal/alerts/threshold_resolution_shared_test.go` green
- keep `internal/alerts/override_normalization_test.go` green
Delete at end of phase:
- Inline metric trigger/clear code inside the migrated `Check*` methods.
- Duplicated threshold-resolution branches that now live in shared builder/evaluator helpers.
Exit criteria:
- Those five `Check*` methods no longer create metric alerts directly.
- Metric alert construction happens only through shared spec-to-evaluator code.
### Phase 3: Move State and Event Families Onto the Same Engine
Goal:
Stop building `Alert` structs directly inside typed methods for non-metric resource alerts.
Code moves:
- Convert offline and power-state handling to `StateSpec` with per-family confirmation policy.
- Convert provider-specific event families to builders:
- host SMART risk and wearout
- RAID degradation/rebuild state
- ZFS pool and device health
- Docker container state, health, restart-loop, OOM, memory-limit, and image-update events
- PMG queue depth, oldest message, backlog, anomaly, and node queue events
- Keep the current confirmation counts, alert IDs, messages, and metadata fields stable.
- Keep `SyncUnifiedResourceIncidents` as the canonical incident path, but make it emit `IncidentSpec` into shared transition helpers instead of assembling `Alert` objects directly.
Delete at end of phase:
- Direct `Alert{...}` construction in migrated event branches of `internal/alerts/alerts.go`.
- Resource-family-specific state transition duplication that now exists only to manage confirmations and resolved handling.
Exit criteria:
- Typed `Check*` methods act as collectors/builders for both metric and event families.
- Shared transition helpers own all create/update/resolve behavior for migrated families.
### Phase 4: Make Monitoring Call the Canonical Path First
Goal:
Make unified resources the primary alert input surface without breaking domains that still need typed collection.
Code moves:
- After unified resource refresh, evaluate canonical resources first.
- Expand `internal/monitoring/monitor_alert_sync.go` from incident-only sync into the canonical unified alert-sync entry point.
- Keep typed poller calls only for domains not yet represented well enough in unified resources.
- Current expected holdouts:
- backups from `internal/monitoring/monitor_backups.go`
- snapshots from `internal/monitoring/monitor_backups.go`
- For those holdouts, move them to spec builders even if they are not yet unified-resource-backed.
Delete at end of phase:
- Direct typed metric fan-out from monitor loops for resource families already covered by unified resources.
- Compatibility wrappers in monitoring that only exist to feed old evaluator entry points.
Exit criteria:
- The primary evaluation path for guest, node, host, PBS, storage, and unified incidents starts from unified resource state.
- Typed monitor calls remain only for documented exceptions.
### Phase 5: Collapse the Identity Bridge and Remove Legacy Entrypoints
Goal:
Finish the migration by making unified identity the only alert identity contract.
Code moves:
- Migrate threshold override/config storage from legacy keys to canonical unified resource IDs.
- Remove explicit `ThresholdKey` bridging once config and alert state agree on the same ID contract.
- Convert backups and snapshots into canonical builder specs with stable resource references.
- Retire compatibility wrappers once no monitor path depends on them.
Delete at end of phase:
- legacy key-translation helpers that only exist for override compatibility
- typed `Check*` evaluator logic that survived as wrappers
- any duplicated incident or metric entrypoints made obsolete by the canonical path
Exit criteria:
- `unifiedresources.Resource.ID` is the single alert identity contract for evaluation and config.
- Remaining `Check*` methods are either deleted or are thin adapters used only at external boundaries.
## How Legacy `Check*` Methods Should Look After Migration
After Phase 3, a legacy `Check*` method should do only this:
1. Read source-specific models.
2. Apply source-specific suppression or shaping that cannot live in the generic evaluator.
3. Build canonical specs with explicit resource identity, threshold key, metadata, and transition policy.
4. Hand the specs to shared evaluator helpers.
It should not:
- resolve trigger/clear transitions inline
- mutate `activeAlerts` directly
- construct resolved-alert flows directly
- send notifications directly
- own history or acknowledgement persistence
## Deletion Rules By Area
Delete code only when the replacement path is already live and parity-tested.
- Metric-family code can be deleted once the shared metric builder/evaluator path produces identical IDs, thresholds, and metadata.
- Event-family code can be deleted once the shared state/incident helpers preserve confirmation counts, ack carry-over, and resolution behavior.
- Monitor fan-out code can be deleted only after unified resource evaluation is the first caller in the poll loop.
- Key-translation helpers can be deleted only after config override storage is migrated to canonical unified IDs.
## Highest-Risk Migration Edges
These are the places most likely to create regressions if the migration is done sloppily.
1. Identity drift between `ResourceID` and override key.
This is already visible in host-agent alerts. Do not collapse those keys until config migration is real.
2. Alert ID churn.
Changing IDs too early will break ack continuity, history continuity, re-notify cooldown state, and flapping history.
3. Partial transition rewrites.
If a builder starts creating `Alert` structs directly again, transition policy will drift across families.
4. Backup and snapshot exceptions.
Those flows still come from recovery/backups rather than unified resources. They should join the canonical spec layer before any attempt to delete their typed paths.
5. Incident duplication.
`SyncUnifiedResourceIncidents` already suppresses redundant parent/child incidents. Any replacement path must preserve that exact behavior.
## Execution Order
Do the work in this order:
1. builder/spec layer
2. metric-family migration
3. state/event-family migration
4. monitor unified-first routing
5. config identity migration and final deletions
Any attempt to start with Phase 4 or Phase 5 first will create a temporary second alert engine, which this repo does not need.

View file

@ -1,8 +1,8 @@
# Centralized Agent Management (Pulse Pro)
# Centralized Agent Management (Pro/Pro+/Cloud)
Pulse Pro supports centralized management of agent configurations, allowing administrators to define "Configuration Profiles" and assign them to specific agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host.
Pro, Pro+, and Cloud support centralized management of agent configurations, allowing administrators to define "Configuration Profiles" and assign them to specific agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host.
Profiles are managed in the UI: **Settings → Agents → Agent Profiles**.
Profiles are managed in the UI: **Settings → Unified Agents → Agent Profiles**.
## Concepts
@ -33,12 +33,12 @@ The following settings can be controlled remotely via profiles:
Notes:
- `interval` accepts a duration string. If you send a JSON number, it is interpreted as seconds.
- Docker auto-detection can still enable Docker monitoring if the agent is not explicitly configured. To force-disable Docker, set `PULSE_ENABLE_DOCKER=false` or install with `--disable-docker`.
- `commandsEnabled` (AI command execution) is controlled separately per agent in **Settings → Agents → Unified Agents** and is applied live on report. It is not part of profile settings.
- Docker auto-detection can still enable Docker monitoring if the agent is not explicitly configured. To force-disable Docker, set `PULSE_ENABLE_DOCKER=false` or install with `--enable-docker=false` on the host.
- `commandsEnabled` (AI command execution) is controlled separately per agent in **Settings → Unified Agents** and is applied live on report. It is not part of profile settings.
## API Usage
All endpoints require **Admin** authentication and a **Pulse Pro** license.
All endpoints require **Admin** authentication and a Pro, Pro+, or Cloud license.
### 1. Create a Profile
@ -98,11 +98,11 @@ Authorization: Bearer <admin-token>
To see what configuration an agent receives:
```http
GET /api/agents/host/{agent_id}/config
GET /api/agents/agent/{agent_id}/config
Authorization: Bearer <agent-or-admin-token>
```
Requires `host-agent:config:read` (or admin tokens with management scopes).
Requires `agent:config:read` (or admin tokens with management scopes).
### 7. Schema, Validation, and Suggestions

121
docs/CLOUD.md Normal file
View file

@ -0,0 +1,121 @@
# Pulse Cloud (Hosted)
Pulse Cloud is the hosted version of Pulse — a fully managed monitoring instance that runs in the cloud so you don't have to self-host.
## How It Works
1. **Sign up** at the Pulse Cloud portal.
2. **Connect your agents** — install the Pulse agent on your infrastructure pointing to your cloud URL.
3. **Monitor** — access your dashboard from any browser; mobile app rollout is coming soon.
Each Cloud account gets a dedicated, isolated Pulse instance with its own subdomain (e.g., `yourname.cloud.pulserelay.pro`).
## Features
Pulse Cloud includes everything in the **Pro** plan, plus:
| Feature | Description |
|---|---|
| **Fully managed hosting** | No server to manage, no updates to apply |
| **Automatic updates** | Your instance is always on the latest version |
| **Automatic backups** | Daily encrypted backups with 7-day retention |
| **Dedicated instance** | Your data runs in an isolated container — not shared with other tenants |
| **Wildcard TLS** | HTTPS with auto-renewing certificates |
| **Mobile ready** | Relay is pre-configured now; mobile app rollout is coming soon |
### Cloud Enterprise (Add-On)
For organisations that need multi-tenant management:
| Feature | Capability Key |
|---|---|
| Multi-Tenant Mode | `multi_tenant` |
| Multi-User Mode | `multi_user` |
| Unlimited Instances | `unlimited` |
| White-Label Branding | `white_label` (coming soon) |
See [Plans & Entitlements](PULSE_PRO.md) for the full feature matrix.
## Getting Started
### 1. Create Your Account
Sign up via the Pulse Cloud portal. Your instance is provisioned automatically after checkout.
### 2. Connect Agents
Once your instance is running, install agents on your infrastructure:
```bash
curl -fsSL https://yourname.cloud.pulserelay.pro/install.sh | \
bash -s -- --url https://yourname.cloud.pulserelay.pro --token <api-token>
```
Generate installation commands from **Settings → Unified Agents → Installation commands** in your cloud dashboard.
### 3. Add Proxmox / TrueNAS Connections
Add your Proxmox VE, PBS, PMG, or TrueNAS systems via **Settings → Infrastructure** or **Settings → TrueNAS**.
### 4. Set Up Mobile Access
Relay is enabled by default on Cloud instances. Open **Settings → Relay** to prepare pairing and connect once mobile beta/public access is enabled.
## Data & Privacy
- Your monitoring data runs in an **isolated container** — no shared databases.
- Data is stored encrypted at rest.
- Backups are automated and encrypted.
- You can **export** your configuration at any time via **Settings → System → Recovery** and migrate to self-hosted if needed.
- See [Privacy](PRIVACY.md) for full details.
## Billing
Pulse Cloud billing is handled by Stripe. You can manage your subscription from the Cloud portal:
- View current plan and usage
- Update payment method
- Cancel or change plans
## Migrating To/From Cloud
### Self-Hosted → Cloud
1. **Export** from your self-hosted instance: **Settings → System → Recovery → Create Backup**.
2. **Import** into your Cloud instance: **Settings → System → Recovery → Restore Configuration**.
3. Update agent `--url` flags to point to your cloud URL.
### Cloud → Self-Hosted
1. **Export** from Cloud: **Settings → System → Recovery → Create Backup**.
2. Install Pulse on your own server (see [Install Guide](INSTALL.md)).
3. **Import** the backup.
4. Re-activate your license key (if switching to Pro self-hosted).
5. Update agent `--url` flags.
See [Migration Guide](MIGRATION.md) for detailed steps.
## FAQ
### Can I use my own domain?
Custom domain support is planned for a future release. Currently, instances use `*.cloud.pulserelay.pro` subdomains.
### Is my data shared with other users?
No. Each Cloud account runs in a dedicated, isolated container with its own data directory.
### What happens if I cancel?
Your data is retained for 30 days after cancellation. You can export your configuration at any time before deletion.
### Can I switch between Cloud and self-hosted?
Yes. Use the export/import workflow described above. Your monitoring configuration is fully portable.
## See Also
- [Plans & Entitlements](PULSE_PRO.md) — feature comparison across Community, Relay, Pro, Pro+, and Cloud
- [Installation (Self-Hosted)](INSTALL.md) — self-hosted installation guide
- [Relay / Mobile Access](RELAY.md) — relay setup and mobile rollout status (pre-configured on Cloud)
- [Multi-Tenant](MULTI_TENANT.md) — multi-tenant mode (Cloud Enterprise)

View file

@ -6,7 +6,7 @@ Pulse uses a split-configuration model to ensure security and flexibility.
| ------ | --------- | ---------------- |
| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) |
| `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** |
| `.audit-signing.key` | Audit log signing key (Pulse Pro, encrypted) | 🔒 **Sensitive** |
| `.audit-signing.key` | Audit log signing key (Pro/Pro+/Cloud, encrypted) | 🔒 **Sensitive** |
| `system.json` | General Settings | 📝 Standard |
| `nodes.enc` | Node Credentials | 🔒 **Encrypted** (AES-256-GCM) |
| `alerts.json` | Alert Rules | 📝 Standard |
@ -16,26 +16,25 @@ Pulse uses a split-configuration model to ensure security and flexibility.
| `oidc.enc` | OIDC provider config | 🔒 **Encrypted** |
| `sso.enc` | SAML/SSO provider config | 🔒 **Encrypted** |
| `api_tokens.json` | API token records (hashed) | 🔒 **Sensitive** |
| `env_token_suppressions.json` | Suppressed legacy env tokens (migration aid) | 📝 Standard |
| `ai.enc` | AI settings and credentials | 🔒 **Encrypted** |
| `ai_findings.json` | AI Patrol findings | 📝 Standard |
| `ai_patrol_runs.json` | AI Patrol run history | 📝 Standard |
| `ai_usage_history.json` | AI usage history | 📝 Standard |
| `ai_chat_sessions.json` | Legacy AI chat sessions (UI sync) | 📝 Standard |
| `license.enc` | Pulse Pro license key | 🔒 **Encrypted** |
| `license.enc` | Relay/Pro/Pro+/Cloud license key | 🔒 **Encrypted** |
| `host_metadata.json` | Host notes, tags, and AI command overrides | 📝 Standard |
| `docker_metadata.json` | Docker metadata cache | 📝 Standard |
| `guest_metadata.json` | Guest notes and metadata | 📝 Standard |
| `agent_profiles.json` | Agent configuration profiles (Pulse Pro) | 📝 Standard |
| `agent_profile_assignments.json` | Agent profile assignments (Pulse Pro) | 📝 Standard |
| `profile-versions.json` | Agent profile version history (Pulse Pro) | 📝 Standard |
| `profile-deployments.json` | Agent profile deployment status (Pulse Pro) | 📝 Standard |
| `profile-changelog.json` | Agent profile change log (Pulse Pro) | 📝 Standard |
| `agent_profiles.json` | Agent configuration profiles (Pro/Pro+/Cloud) | 📝 Standard |
| `agent_profile_assignments.json` | Agent profile assignments (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-versions.json` | Agent profile version history (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-deployments.json` | Agent profile deployment status (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-changelog.json` | Agent profile change log (Pro/Pro+/Cloud) | 📝 Standard |
| `recovery_tokens.json` | Recovery tokens (short-lived) | 🔒 **Sensitive** |
| `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | 🔒 **Sensitive** |
| `update-history.jsonl` | Update history log (in-app updates) | 📝 Standard |
| `metrics.db` | Persistent metrics history (SQLite) | 📝 Standard |
| `audit.db` | Audit log database (Pulse Pro, SQLite) | 🔒 **Sensitive** |
| `audit.db` | Audit log database (Pro/Pro+/Cloud, SQLite) | 🔒 **Sensitive** |
| `baselines.json` | AI baseline data for anomaly detection | 📝 Standard |
| `ai_correlations.json` | AI correlation analysis cache | 📝 Standard |
| `ai_patterns.json` | AI pattern detection data | 📝 Standard |
@ -67,10 +66,6 @@ This file controls access to Pulse. It is **never** exposed to the UI.
# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup)
PULSE_AUTH_USER='admin'
PULSE_AUTH_PASS='$2a$12$...'
# Legacy API tokens (deprecated, auto-migrated to api_tokens.json)
API_TOKEN='token1'
API_TOKENS='token2,token3'
```
<details>
@ -83,7 +78,6 @@ You can pre-configure Pulse by setting environment variables. Plain text credent
docker run -d \
-e PULSE_AUTH_USER=admin \
-e PULSE_AUTH_PASS=secret123 \
-e API_TOKENS=ci-token,agent-token \
rcourtman/pulse:latest
```
</details>
@ -103,7 +97,7 @@ Environment overrides (lock the corresponding UI fields):
| `OIDC_ISSUER_URL` | Issuer URL from your IdP |
| `OIDC_CLIENT_ID` | Client ID |
| `OIDC_CLIENT_SECRET` | Client secret |
| `OIDC_REDIRECT_URL` | Override redirect URL (defaults to `<public-url>/api/oidc/callback`) |
| `OIDC_REDIRECT_URL` | Override redirect URL (defaults to `<public-url>/api/oidc/<provider-id>/callback`) |
| `OIDC_LOGOUT_URL` | Optional logout URL |
| `OIDC_SCOPES` | Space or comma-separated scopes |
| `OIDC_USERNAME_CLAIM` | Claim for username (default: `preferred_username`) |
@ -112,19 +106,13 @@ Environment overrides (lock the corresponding UI fields):
| `OIDC_ALLOWED_GROUPS` | Allowed groups (space or comma-separated) |
| `OIDC_ALLOWED_DOMAINS` | Allowed email domains (space or comma-separated) |
| `OIDC_ALLOWED_EMAILS` | Allowed emails (space or comma-separated) |
| `OIDC_GROUP_ROLE_MAPPINGS` | Comma-separated group=role mappings (Pulse Pro) |
| `OIDC_GROUP_ROLE_MAPPINGS` | Comma-separated group=role mappings (Pro/Pro+/Cloud) |
| `OIDC_CA_BUNDLE` | Custom CA bundle path |
</details>
Legacy token flag (backwards compatibility):
| Variable | Description |
| ---------- | ------------- |
| `API_TOKEN_ENABLED` | Legacy toggle for API token auth (defaults to enabled when tokens exist) |
> **Note**: `API_TOKEN` / `API_TOKENS` are legacy and will be migrated into `api_tokens.json` on startup.
> Manage API tokens in the UI for long-term support.
> **Note**: `API_TOKEN` / `API_TOKENS` in `.env` are legacy and ignored at runtime in v6.
> Manage API tokens in the UI (`api_tokens.json`) for supported behavior.
---
@ -194,13 +182,15 @@ Numeric intervals are **seconds** unless noted otherwise.
| `metricsRetentionHourlyDays` | Hourly metrics retention (days) |
| `metricsRetentionDailyDays` | Daily metrics retention (days) |
| `disableDockerUpdateActions` | Hide Docker update actions in UI |
| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) |
| `disableLocalUpgradeMetrics` | Disable local-only upgrade metrics collection |
| `backendPort` | Legacy (unused) |
| `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) |
`discoveryConfig` supports:
- `environment_override`, `subnet_allowlist`, `subnet_blocklist`, `ip_blocklist`
- `max_hosts_per_scan`, `max_concurrent`, `enable_reverse_dns`, `scan_gateways`
- `dial_timeout_ms`, `http_timeout_ms`
- `environmentOverride`, `subnetAllowlist`, `subnetBlocklist`
- `maxHostsPerScan`, `maxConcurrent`, `enableReverseDns`, `scanGateways`
- `dialTimeoutMs`, `httpTimeoutMs`
### Common Overrides (Environment Variables)
Environment variables take precedence over `system.json`.
@ -208,7 +198,6 @@ Environment variables take precedence over `system.json`.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `FRONTEND_PORT` | Public listening port | `7655` |
| `PORT` | Legacy alias for `FRONTEND_PORT` | *(unset)* |
| `LOG_LEVEL` | Log verbosity (see below) | `info` |
| `LOG_FORMAT` | Log output format (`auto`, `json`, `console`) | `auto` |
| `LOG_FILE` | Log file path (enables file logging) | *(unset)* |
@ -230,6 +219,7 @@ Environment variables take precedence over `system.json`.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* |
| `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* |
| `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* |
@ -237,7 +227,7 @@ Environment variables take precedence over `system.json`.
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `DISCOVERY_ENABLED` | Auto-discover nodes | `false` |
| `DISCOVERY_SUBNET` | CIDR or `auto` | `auto` |
| `DISCOVERY_ENVIRONMENT_OVERRIDE` | Force discovery environment (`auto`, `native`, `docker_host`, `docker_bridge`, `lxc_privileged`, `lxc_unprivileged`) | `auto` |
| `DISCOVERY_ENVIRONMENT_OVERRIDE` | Force discovery environment (`auto`, `native`, `docker-host`, `docker-bridge`, `lxc-privileged`, `lxc-unprivileged`) | `auto` |
| `DISCOVERY_SUBNET_ALLOWLIST` | Comma-separated CIDRs allowed for discovery | *(empty)* |
| `DISCOVERY_SUBNET_BLOCKLIST` | Comma-separated CIDRs excluded from discovery | `169.254.0.0/16` |
| `DISCOVERY_MAX_HOSTS_PER_SCAN` | Max hosts to scan per run | `1024` |
@ -285,6 +275,8 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame
| `DNS_CACHE_TIMEOUT` | Cache TTL for DNS lookups | `5m` |
| `MAX_POLL_TIMEOUT` | Maximum time per polling cycle | `3m` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade metrics collection | `false` |
| `PULSE_TELEMETRY` | Anonymous usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
### Logging Overrides
@ -308,6 +300,10 @@ These are stored in `system.json` and managed via the UI.
| `autoUpdateTime` | Stored UI preference (systemd timer has its own schedule) | `03:00` |
> **Note**: Update settings are stored in `system.json`. Legacy `.env` entries (`UPDATE_CHANNEL`, `AUTO_UPDATE_ENABLED`, `AUTO_UPDATE_CHECK_INTERVAL`, `AUTO_UPDATE_TIME`) are kept in sync for backwards compatibility but are not read at runtime.
>
> `stable` is the default and recommended production channel. `rc` is an
> opt-in preview channel. In v6, unattended systemd auto-updates remain
> `stable`-only even when `updateChannel` is set to `rc`.
### Auto-Import (Bootstrap)
@ -400,7 +396,7 @@ docker run --init -e HTTPS_ENABLED=true \
## 🛡️ Security Best Practices
1. **Permissions**: Ensure `.env` and `nodes.enc` are `600` (read/write by owner only).
2. **Backups**: Back up `.env` separately from `system.json`.
2. **Backup hygiene**: Back up `.env` separately from `system.json`.
3. **Tokens**: Use scoped API tokens for agents instead of the admin password.
---
@ -420,9 +416,9 @@ API tokens provide scoped, revocable access to Pulse. Manage tokens in **Setting
| `docker:manage` | Container lifecycle actions (restart, stop) |
| `kubernetes:report` | Kubernetes agent telemetry submission |
| `kubernetes:manage` | Kubernetes cluster management |
| `host-agent:report` | Host agent metrics submission |
| `host-agent:config:read` | Read host-agent config payloads |
| `host-agent:manage` | Manage host agents (unlink/delete/config) |
| `agent:report` | Agent host telemetry submission |
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Manage registered agents (unlink/delete/config) |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration |
@ -433,7 +429,7 @@ The UI offers quick presets for common use cases:
| Preset | Scopes | Use Case |
| -------- | -------- | ---------- |
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
| **Host agent** | `host-agent:report` | Host agent authentication |
| **Agent host** | `agent:report` | Agent host telemetry authentication |
| **Container report** | `docker:report` | Container agent (read-only) |
| **Container manage** | `docker:report`, `docker:manage` | Container agent with actions |
| **Settings read** | `settings:read` | Read-only config access |
@ -458,3 +454,66 @@ For unattended displays (wall monitors, dashboards), use a kiosk token to avoid
- Can be revoked anytime from the UI
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
---
## TrueNAS Integration {#truenas}
Pulse v6 supports first-class TrueNAS SCALE and CORE monitoring.
### Adding a TrueNAS Instance
1. Go to **Settings → TrueNAS**.
2. Click **Add Connection**.
3. Enter the URL (e.g., `https://truenas.local`) and an API key.
4. Click **Test Connection** to verify, then **Save**.
### Creating a TrueNAS API Key
On your TrueNAS system:
1. Navigate to the TrueNAS UI → **Settings → API Keys**.
2. Click **Add** and create a new read-only key.
3. Copy the key value and paste it into Pulse.
### What Gets Monitored
| Data | Where it appears |
|---|---|
| System info (CPU, memory, uptime) | Infrastructure page |
| ZFS Pools & datasets | Storage page |
| Physical disks | Storage page |
| ZFS Snapshots | Recovery page |
| Replication tasks | Recovery page |
| TrueNAS alerts | Alerts page |
TrueNAS connections are stored encrypted in `truenas.enc`.
---
## Relay / Mobile Remote Access (Relay and Above) {#relay}
The relay protocol provides end-to-end encrypted remote access foundations for Pulse mobile connectivity.
> Mobile app availability: staged rollout (coming soon). Relay configuration is available now for early-access/beta onboarding and future readiness.
### Configuration
1. Go to **Settings → Relay**.
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** when your mobile beta access is enabled.
### Environment Overrides
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay | `false` |
| `PULSE_RELAY_SERVER` | Override relay server URL | `relay.pulserelay.pro` |
### Security
- All data is encrypted end-to-end using ECDH key exchange.
- The relay server never sees plaintext monitoring data.
- Each mobile session has its own encryption channel.
- Requires a valid Relay, Pro, Pro+, or Cloud license (gated by the `relay` feature key).
Relay config is stored encrypted in `relay.enc`.

View file

@ -24,14 +24,13 @@ Pulse uses a split config model:
- **Local auth and secrets**: `.env` (managed by Quick Security Setup or environment overrides, not shown in the UI)
- **Encryption key**: `.encryption.key` (required to decrypt `.enc` files)
- **Audit signing key**: `.audit-signing.key` (Pulse Pro, encrypted)
- **Audit signing key**: `.audit-signing.key` (Pro/Pro+/Cloud, encrypted)
- **System settings**: `system.json` (editable in the UI unless locked by env)
- **Nodes and credentials**: `nodes.enc` (encrypted)
- **Notification config**: `email.enc`, `webhooks.enc`, `apprise.enc` (encrypted)
- **OIDC config**: `oidc.enc` (encrypted)
- **SSO config**: `sso.enc` (encrypted)
- **API tokens**: `api_tokens.json`
- **Legacy token suppressions**: `env_token_suppressions.json`
- **AI config**: `ai.enc` (encrypted)
- **AI patrol data**: `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`
- **AI chat sessions**: `ai_chat_sessions.json` (legacy UI sync)
@ -40,8 +39,8 @@ Pulse uses a split config model:
- **AI pattern data**: `ai_patterns.json`
- **AI remediation data**: `ai_remediations.json`
- **AI incident tracking**: `ai_incidents.json`
- **Audit log database**: `audit.db` (Pulse Pro, SQLite)
- **Pulse Pro license**: `license.enc` (encrypted)
- **Audit log database**: `audit.db` (Pro/Pro+/Cloud, SQLite)
- **Relay/Pro/Pro+/Cloud license**: `license.enc` (encrypted)
- **Host metadata**: `host_metadata.json`
- **Docker metadata**: `docker_metadata.json`
- **Guest metadata**: `guest_metadata.json`
@ -55,6 +54,9 @@ Pulse uses a split config model:
- **Update history**: `update-history.jsonl`
- **Metrics history**: `metrics.db` (SQLite)
- **Organization metadata**: `org.json` (multi-tenant)
- **TrueNAS connections**: `truenas.enc` (encrypted)
- **Relay config**: `relay.enc` (encrypted, Relay and above)
- **RBAC roles**: `rbac_roles.json` (Pro/Pro+/Cloud)
Path mapping:

View file

@ -54,14 +54,13 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
| `TZ` | Timezone | `UTC` |
| `PULSE_AUTH_USER` | Admin Username | *(unset)* |
| `PULSE_AUTH_PASS` | Admin Password | *(unset)* |
| `API_TOKENS` | Comma-separated API tokens (**legacy**) | *(unset)* |
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
> **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events.
> **Note**: `API_TOKEN` / `API_TOKENS` are legacy. Prefer managing API tokens in the UI after initial setup.
> **Note**: API tokens are managed in the UI and stored in `api_tokens.json`.
> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup.
<details>
@ -117,7 +116,7 @@ Pulse can detect and apply updates to your Docker containers directly from the U
### Updating a Container
1. Navigate to the **Docker** tab
1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**)
2. Look for containers with a blue update arrow (⬆️)
3. Click the update button → Click **Confirm**
4. Pulse will:
@ -176,19 +175,7 @@ services:
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI: **Settings → Agents** (Docker Settings card).
---
## 🍎 macOS Docker Desktop Socket
On macOS, Docker Desktop exposes a socket at `~/.docker/run/docker.sock`. Pulse auto-detects this path when `--enable-docker` is used with `RuntimeDocker` or `RuntimeAuto`, so no extra configuration is needed.
To override, set `DOCKER_HOST`:
```bash
export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock
```
You can also toggle "Hide Docker Update Buttons" from the UI: **Settings → Unified Agents** (Docker Settings card).
---

View file

@ -1,165 +0,0 @@
# Pulse Assistant Eval Harness
This is a live, end-to-end eval harness that exercises the AI chat API, tool calls, and safety gates.
It requires a running Pulse instance and valid credentials.
## Quickstart
List scenarios:
```
go run ./cmd/eval -list
```
Run the full suite:
```
go run ./cmd/eval -scenario full
```
Run a single scenario:
```
go run ./cmd/eval -scenario readonly
```
Run the model matrix quick set:
```
go run ./cmd/eval -scenario matrix
```
Auto-select models (latest per provider):
```
go run ./cmd/eval -scenario matrix -auto-models
```
## Environment Overrides
These env vars let you align the evals with your infrastructure naming:
```
EVAL_NODE
EVAL_NODE_CONTAINER
EVAL_DOCKER_HOST
EVAL_HOMEPAGE_CONTAINER
EVAL_JELLYFIN_CONTAINER
EVAL_GRAFANA_CONTAINER
EVAL_HOMEASSISTANT_CONTAINER
EVAL_MQTT_CONTAINER
EVAL_ZIGBEE_CONTAINER
EVAL_FRIGATE_CONTAINER
EVAL_MODEL (optional model override)
EVAL_MODEL_PROVIDERS (optional comma-separated provider filter for auto selection; defaults to openai,anthropic,deepseek,gemini,ollama)
EVAL_MODEL_LIMIT (optional per-provider limit for auto selection, default 2)
EVAL_MODEL_EXCLUDE_KEYWORDS (optional comma-separated keywords to skip models; default filters image/video/audio, codex, and specific pre-release IDs like openai:gpt-5.2-pro until chat support is live; set to "none" to disable)
```
Write/verify and strict-resolution controls:
```
EVAL_WRITE_HOST (defaults to EVAL_NODE)
EVAL_WRITE_COMMAND (defaults to "true")
EVAL_REQUIRE_WRITE_VERIFY (set to 1 to assert pulse_control -> pulse_read)
EVAL_STRICT_RESOLUTION (set to 1 to expect STRICT_RESOLUTION block)
EVAL_REQUIRE_STRICT_RECOVERY (set to 1 to require pulse_query -> pulse_control)
EVAL_EXPECT_APPROVAL (set to 1 to assert approval_needed event)
```
Retry controls and reports:
```
EVAL_HTTP_TIMEOUT (seconds, default 300)
EVAL_STEP_RETRIES (default 2)
EVAL_RETRY_ON_PHANTOM (default 1)
EVAL_RETRY_ON_EXPLICIT_TOOL (default 1)
EVAL_RETRY_ON_STREAM_FAILURE (default 1)
EVAL_RETRY_ON_EMPTY_RESPONSE (default 1)
EVAL_RETRY_ON_TOOL_ERRORS (default 1)
EVAL_RETRY_ON_RATE_LIMIT (default 0)
EVAL_RATE_LIMIT_COOLDOWN (seconds, optional backoff before retry)
EVAL_PREFLIGHT (set to 1 to run a quick chat preflight)
EVAL_PREFLIGHT_TIMEOUT (seconds, default 15)
EVAL_REPORT_DIR (write JSON report per scenario)
```
## Recommended Runs
Full suite with custom resource names:
```
EVAL_NODE=delly EVAL_DOCKER_HOST=homepage-docker \
go run ./cmd/eval -scenario full
```
Strict-resolution block + recovery (requires server with PULSE_STRICT_RESOLUTION=true):
```
EVAL_STRICT_RESOLUTION=1 EVAL_REQUIRE_STRICT_RECOVERY=1 \
go run ./cmd/eval -scenario strict
```
Strict-resolution block only (no recovery):
```
EVAL_STRICT_RESOLUTION=1 \
go run ./cmd/eval -scenario strict-block
```
Strict-resolution recovery in a single step:
```
EVAL_STRICT_RESOLUTION=1 EVAL_REQUIRE_STRICT_RECOVERY=1 \
go run ./cmd/eval -scenario strict-recovery
```
Approval flow (requires Control Level = Controlled):
```
EVAL_EXPECT_APPROVAL=1 \
go run ./cmd/eval -scenario approval
```
Approval approve flow (auto-approves approvals during the step):
```
EVAL_EXPECT_APPROVAL=1 \
go run ./cmd/eval -scenario approval-approve
```
Approval deny flow (auto-denies approvals during the step):
```
EVAL_EXPECT_APPROVAL=1 \
go run ./cmd/eval -scenario approval-deny
```
Approval combo flow (approve + deny in one session):
```
EVAL_EXPECT_APPROVAL=1 \
go run ./cmd/eval -scenario approval-combo
```
Write then verify (safe no-op command by default):
```
EVAL_REQUIRE_WRITE_VERIFY=1 \
go run ./cmd/eval -scenario writeverify
```
## Model Matrix Workflow
Run the matrix and update the docs table in one step:
```
scripts/eval/run_model_matrix.sh
```
Key overrides:
```
PULSE_BASE_URL=http://127.0.0.1:7655
PULSE_EVAL_USER=admin
PULSE_EVAL_PASS=admin
EVAL_MODEL_PROVIDERS=openai,anthropic,gemini
EVAL_MODEL_LIMIT=2
EVAL_MODELS=anthropic:claude-haiku-4-5-20251001
EVAL_SCENARIO=matrix
EVAL_REPORT_DIR=tmp/eval-reports
EVAL_WRITE_DOC=1
```
## Notes
- The evals run against live infrastructure. Use safe commands or keep the default `EVAL_WRITE_COMMAND=true`.
- Scenario assertions are intentionally coarse; use stricter env flags to enforce write/verify or strict-recovery sequences.
- Live tests via `go test`:
```
go test -v ./internal/ai/eval -run TestQuickSmokeTest -live
```

View file

@ -9,7 +9,7 @@ If you run Proxmox VE, use the official LXC installer (recommended):
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
Note: this installs the Pulse **server**. Agent installs use the command from **Settings → Agents → Installation commands** (served from `/install.sh` on your Pulse server).
Note: this installs the Pulse **server**. Agent installs use the command from **Settings → Unified Agents → Installation commands** (served from `/install.sh` on your Pulse server).
If you prefer Docker:
@ -20,35 +20,12 @@ docker run -d --name pulse -p 7655:7655 -v pulse_data:/data rcourtman/pulse:late
See [INSTALL.md](INSTALL.md) for all options (Docker Compose, Kubernetes, systemd).
### How do I add a node?
Go to **Settings → Proxmox**.
Go to **Settings → Unified Agents**.
- **Recommended (Agent setup)**: select **Agent Install** and run the generated install command on the Proxmox host.
- **Manual**: use **Username & Password**, or select the **Manual** tab and enter API token credentials.
- **Manual/API-only**: open **Advanced** in the add-node modal and use **API Only** or **Manual**.
If you want Pulse to find servers automatically, enable discovery in **Settings → System → Network** and then return to **Settings → Proxmox** to review discovered servers.
### How do I access the LXC console to run commands?
If you installed Pulse using the LXC installer, the container is created *without a root password*. The LXC container uses Proxmox host-level authentication via **pct enter** rather than traditional password login. This is the recommended approach for Proxmox-managed containers.
To access the console and run commands (like `update` or `journalctl -u pulse` or `pulse bootstrap-token`), use the Proxmox host shell:
**From Proxmox Host Shell** (SSH into your Proxmox host first):
```bash
pct enter <VMID> # e.g., pct enter 100
```
This gives you direct root access without requiring a password.
**Find your container ID**:
```bash
pct list | grep -i pulse
```
**Note**: The Proxmox web console will show a `login:` prompt, but you cannot log in without first setting a password. To set a password for web console or SSH access:
```bash
pct enter <VMID>
passwd # Set root password
```
If you want Pulse to find servers automatically, enable discovery in **Settings → System → Network** and then review discovered servers in **Settings → Infrastructure**.
### How do I change the port?
- **Systemd**: `sudo systemctl edit pulse`, add `Environment="FRONTEND_PORT=8080"`, restart.
@ -61,11 +38,11 @@ If a setting is disabled with an amber warning, it's being overridden by an envi
## 🔍 Monitoring & Metrics
### What is Pulse Pro, and what does it actually do?
Pulse Pro unlocks **Auto-Fix and advanced AI analysis**. Pulse Patrol is available to everyone with BYOK and provides scheduled, cross-system analysis that correlates real-time state, recent metrics history, and diagnostics to surface actionable findings.
### What do Relay, Pro, and Cloud unlock?
Relay adds remote access, mobile access, push notifications, and a little more monitored-system headroom. Pro, Pro+, and Cloud unlock **Auto-Fix and advanced AI analysis**. Pulse Patrol is available to everyone on Community with BYOK and provides scheduled, cross-system analysis that correlates real-time state, recent metrics history, and diagnostics to surface actionable findings.
Example output includes trend-based capacity warnings, backup regressions, Kubernetes AI cluster analysis, and correlated container failures that simple threshold alerts miss.
See [AI Patrol](AI.md), [Pulse Pro technical overview](PULSE_PRO.md), and <https://pulserelay.pro>.
See [Pulse AI](AI.md), [Plans and entitlements](PULSE_PRO.md), and <https://pulserelay.pro>.
### Why do VMs show "-" for disk usage?
Proxmox API returns `0` for VM disk usage by default. You must install the **QEMU Guest Agent** inside the VM and enable it in Proxmox (VM → Options → QEMU Guest Agent).
@ -74,6 +51,18 @@ See [VM Disk Monitoring](VM_DISK_MONITORING.md) for details.
### Does Pulse monitor Ceph?
Yes! If Pulse detects Ceph storage, it automatically queries cluster health, OSD status, and pool usage. No extra config needed.
### Does Pulse monitor TrueNAS?
Yes! Pulse v6 includes first-class TrueNAS SCALE/CORE integration. Add your TrueNAS server in **Settings → TrueNAS** with the URL and API key. Pulse monitors pools, datasets, disks, ZFS snapshots, replication tasks, and alerts. TrueNAS resources appear in the unified Infrastructure, Storage, and Recovery views.
### Where did my pages go? (Unified Navigation)
Pulse v6 organises the UI by **task** instead of **platform**:
- **Infrastructure** → all hosts (Proxmox, Docker, K8s, TrueNAS)
- **Workloads** → VMs, LXCs, containers, pods
- **Storage** → all storage pools
- **Recovery** → backups, snapshots, replication
Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) redirect automatically. See [Migration Guide](MIGRATION_UNIFIED_NAV.md) for the full mapping.
### Can I disable alerts for specific metrics?
Yes. Go to **Alerts → Thresholds** and set any value to `-1` to disable it. You can do this globally or per-resource (VM/Node).
@ -109,7 +98,7 @@ sudo pulse bootstrap-token
Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#https--tls).
### Can I use Single Sign-On (SSO)?
Yes. Pulse supports OIDC in **Settings → Security → Single Sign-On** and Proxy Auth (Authentik, Authelia). See [Proxy Auth Guide](PROXY_AUTH.md) and [OIDC](OIDC.md).
Yes. Pulse supports **OIDC** and **SAML** SSO providers, with multi-provider support (multiple IdPs active simultaneously). Configure in **Settings → Security → SSO Providers**. Pulse also supports Proxy Auth (Authentik, Authelia, Cloudflare). See [Proxy Auth Guide](PROXY_AUTH.md).
---
@ -117,7 +106,7 @@ Yes. Pulse supports OIDC in **Settings → Security → Single Sign-On** and Pro
### No data showing?
- Check Proxmox API is reachable (port 8006).
- Verify credentials in **Settings → Proxmox**.
- Verify credentials in **Settings → Infrastructure**.
- Check logs: `journalctl -u pulse -f` or `docker logs -f pulse`.
### Connection refused?

View file

@ -13,7 +13,7 @@ Run this on your Proxmox host:
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
> **Note**: The GitHub `install.sh` is the **server** installer. The agent installer is served from your Pulse server at `/install.sh` (see **Settings → Agents → Installation commands**).
> **Note**: The GitHub `install.sh` is the **server** installer. The agent installer is served from your Pulse server at `/install.sh` (see **Settings → Unified Agents → Installation commands**).
### Docker
Ideal for containerized environments or testing.
@ -119,7 +119,6 @@ Pulse is secure by default. On first launch, you must retrieve a **Bootstrap Tok
| Platform | Command |
|----------|---------|
| **LXC** | From Proxmox host: `pct enter <VMID>` then `cat /etc/pulse/.bootstrap_token` or `pulse bootstrap-token` |
| **Docker** | `docker exec pulse cat /data/.bootstrap_token` or `docker exec pulse /app/pulse bootstrap-token` |
| **Kubernetes** | `kubectl exec -it <pod> -- cat /data/.bootstrap_token` or `kubectl exec -it <pod> -- /app/pulse bootstrap-token` |
| **Systemd** | `sudo cat /etc/pulse/.bootstrap_token` or `sudo pulse bootstrap-token` |
@ -131,8 +130,9 @@ Pulse is secure by default. On first launch, you must retrieve a **Bootstrap Tok
- Set your **Admin Username** and **Password** (or let Pulse generate one).
- Pulse generates an **API token** for agents and automations.
- Copy the credentials before leaving the page.
4. Open **Settings → Unified Agents** and install the unified agent on each host you want monitored.
> **Note**: If you configure authentication via environment variables (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS` and/or legacy `API_TOKENS`), the bootstrap token is automatically removed and this step is skipped.
> **Note**: If you configure authentication via environment variables (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS`), the bootstrap token is automatically removed and this step is skipped.
---

View file

@ -2,6 +2,8 @@
This guide explains how to deploy the Pulse Server (Hub) and Pulse Agents on Kubernetes clusters, including immutable distributions like Talos Linux.
> **Navigation note (v6):** Kubernetes cluster and node resources appear on the **Infrastructure** page, while pods appear on the **Workloads** page. The legacy `/kubernetes` URL redirects to `/workloads?type=k8s`.
## Prerequisites
- A Kubernetes cluster (v1.19+)
@ -52,11 +54,12 @@ kubectl apply -f pulse-server.yaml
## 2. Deploying the Pulse Agent
### Important: Helm Chart Agent Is Legacy Docker-Only
### Helm Chart Agent Mode
The Helm chart includes an `agent` section, but it deploys the **deprecated** `pulse-docker-agent` (Docker socket metrics only). It does **not** deploy the unified `pulse-agent`.
The Helm chart includes an optional `agent` section that deploys the unified `pulse-agent`.
By default, this workload runs in container-monitoring mode (`--enable-docker --enable-host=false`).
If you need the unified agent on Kubernetes, use a custom DaemonSet as shown below.
For Kubernetes monitoring, use a custom DaemonSet as shown below.
### Unified Agent on Kubernetes (DaemonSet)
@ -122,7 +125,7 @@ spec:
Use a token scoped for the agent:
- `kubernetes:report` for Kubernetes reporting
- `host-agent:report` if you enable host metrics
- `agent:report` if you enable host metrics
#### Important DaemonSet Configuration
@ -221,6 +224,15 @@ rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch"]
# Optional (Recovery): VolumeSnapshots and Velero backups.
# These rules are safe to include even if the APIs are not installed; the agent will
# feature-detect and ignore 404/403 responses.
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots"]
verbs: ["get", "list", "watch"]
- apiGroups: ["velero.io"]
resources: ["backups"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding

View file

@ -1,6 +1,6 @@
# Proxmox Mail Gateway (PMG) Monitoring
Pulse 5.0 adds support for monitoring Proxmox Mail Gateway instances alongside your PVE and PBS infrastructure.
Pulse monitors Proxmox Mail Gateway instances alongside your PVE, PBS, and other infrastructure.
## Features
@ -13,7 +13,7 @@ Pulse 5.0 adds support for monitoring Proxmox Mail Gateway instances alongside y
### Via Settings UI
1. Navigate to **Settings → Proxmox**
1. Navigate to **Settings → Infrastructure**
2. Click **Add Node**
3. Select **Proxmox Mail Gateway** as the type
4. Enter connection details:
@ -27,7 +27,7 @@ Pulse 5.0 adds support for monitoring Proxmox Mail Gateway instances alongside y
Pulse can automatically discover PMG instances on your network:
1. Enable discovery in **Settings → System → Network**
2. Go to **Settings → Proxmox**
2. Go to **Settings → Infrastructure**
3. PMG instances on port 8006 are detected and shown in the Proxmox discovery panels
4. Click a discovered PMG server to add it
@ -41,7 +41,7 @@ PMG does not support API tokens. Use a dedicated PMG user with read-only access
## Dashboard
The Mail Gateway tab shows:
In the v6 unified navigation, PMG data appears on the **Infrastructure** page (filter by **PMG** source):
| Metric | Description |
|--------|-------------|

View file

@ -71,7 +71,7 @@ curl -H "X-API-Token: $TOKEN" \
"http://localhost:7655/api/metrics-store/history?resourceType=vm&resourceId=pve1:node1:100&range=7d&metric=cpu"
```
> **License**: Requests beyond `7d` require the Pulse Pro `long_term_metrics` feature. Unlicensed requests return `402 Payment Required`.
> **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and Pro+ unlock `90d`, and requests beyond the active tier's limit return `402 Payment Required`.
> **Aliases**: `guest` (VM/LXC) and `docker` (Docker container) are accepted, but persistent store data uses the canonical types above.
## Troubleshooting

View file

@ -10,13 +10,13 @@ Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption k
### ✅ DO: Use Export/Import
#### 1. Export (Old Server)
1. Go to **Settings → System → Backups**.
1. Go to **Settings → System → Recovery**.
2. Click **Create Backup**.
3. Enter a strong passphrase and download the encrypted backup.
#### 2. Import (New Server)
1. Install a fresh Pulse instance.
2. Go to **Settings → System → Backups**.
2. Go to **Settings → System → Recovery**.
3. Click **Restore Configuration** and upload your file.
4. Enter the passphrase.
@ -30,12 +30,16 @@ Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption k
| System settings (`system.json`) | Update history/backup folders |
| API token records | — |
| OIDC config | — |
| SSO / SAML config | — |
| TrueNAS connections (`truenas.enc`) | — |
| Guest metadata/notes | — |
| — | Relay config (`relay.enc`) — re-enable in Settings |
| — | Host metadata (notes/tags/AI command overrides) |
| — | Docker metadata cache |
| — | Agent profiles and assignments |
| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
| — | Pulse Pro license (`license.enc`) |
| — | RBAC roles (`rbac_roles.json`) — re-create after import |
| — | Relay/Pro/Pro+/Cloud license (`license.enc`) |
| — | Server sessions (`sessions.json`) |
| — | Update history (`update-history.jsonl`) |
@ -59,12 +63,11 @@ Because local login credentials are stored in `.env` (not part of exports), you
1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
2. **Confirm API access**:
* If you created API tokens in the UI, those token records are included in the export and should continue working.
* If you used `.env`-based `API_TOKENS`/`API_TOKEN` (legacy), reconfigure them on the new host or re-create tokens in the UI.
3. **Update Agents**:
* **Unified Agent**: Update the `--token` flag in your service definition.
* **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
* *Tip: Use **Settings → Agents → Installation commands** to generate updated install commands.*
4. **Pulse Pro**: Re-activate your license key after migration (license files are not included in exports).
* *Tip: Use **Settings → Unified Agents → Installation commands** to generate updated install commands.*
4. **Relay/Pro/Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
## 🔒 Security

View file

@ -0,0 +1,63 @@
# Migration Guide: Unified Navigation
This guide explains what changed in unified navigation and where legacy pages moved in v6.
## What Changed
- Navigation is now organized by **task** (Infrastructure, Workloads, Storage, Recovery) instead of by platform.
- Legacy pages (Proxmox Overview, Hosts, Docker, Services, Kubernetes) were replaced by unified views.
- Global search and keyboard shortcuts make navigation faster across all resources.
- Kubernetes is now split by intent:
- **Infrastructure** shows Kubernetes clusters and nodes.
- **Workloads** shows Kubernetes pods with the same filters/grouping as VMs and containers.
## Why This Change
- A unified resource model enables one inventory and one search across platforms.
- Filters, drawers, and workflows stay consistent, instead of being re-implemented per platform page.
- New integrations can be added without expanding the top-level navigation indefinitely.
## Legacy Aliases and Redirects
- Legacy aliases have been fully removed; update bookmarks and runbooks to canonical routes.
- Optional migration aid: enable the **Classic shortcuts** bar in the main navigation (Settings → System → General).
- Plan automation/bookmarks to use canonical routes now:
- `/infrastructure?source=pmg`
- `/workloads?type=k8s`
## Where Old Pages Moved
| Legacy Page | New Location |
|------------|--------------|
| Proxmox Overview | `/infrastructure` |
| Hosts | `/infrastructure` |
| Docker | `/workloads` (containers) + `/infrastructure` (hosts) |
| Proxmox Storage | `/storage` |
| Proxmox Backups | `/recovery` |
| Proxmox Replication | `/recovery?view=events&mode=remote` |
| Proxmox Ceph | `/ceph` (summary also visible in Storage) |
| Proxmox Mail Gateway | `/infrastructure?source=pmg` |
| Services | `/infrastructure?source=pmg` |
| Kubernetes | `/workloads?type=k8s` |
## New Features to Know
### Global Search
- Press `/` to focus search.
- Search by name, node, type, tags, or status.
- Results navigate directly to the relevant view.
- Use `Cmd/Ctrl+K` for the command palette.
### Keyboard Shortcuts
- `g i` → Infrastructure
- `g w` → Workloads
- `g s` → Storage
- `g b` → Recovery
- `g a` → Alerts
- `g t` → Settings
- `?` → Shortcut help
### Debug Drawer (Optional)
- Enable with localStorage key `pulse_debug_mode` for raw JSON in resource drawer.
## Tips
- If you used Docker and Hosts pages before, start with **Infrastructure** (hosts) and **Workloads** (containers).
- If you used the Kubernetes page before, use **Infrastructure** for cluster/node health and **Workloads** for pod-level operations.
- The new pages support unified filters, tags, and search across all sources.

View file

@ -1,296 +1,191 @@
# Multi-Tenant Feature Documentation
# Multi-Tenant Organizations (Cloud Enterprise)
## Status: Disabled by Default
Pulse supports isolated, multi-tenant organizations for MSPs, homelabs with multiple environments, and multi-datacenter deployments. Each organization gets its own infrastructure, resources, alerts, and audit log — fully isolated from other organizations on the same Pulse instance.
This feature is gated behind a feature flag and license check. It will not affect existing users unless explicitly enabled.
## Requirements
---
| Requirement | Detail |
|---|---|
| **Feature flag** | `PULSE_MULTI_TENANT_ENABLED=true` |
| **License** | Enterprise license with `multi_tenant` capability |
## How to Enable
Without these, all API calls return `501 Not Implemented` (flag off) or `402 Payment Required` (no license). The **default** organization always works regardless.
### Requirements
## Quick Start
1. **Feature flag**: Set environment variable
```bash
PULSE_MULTI_TENANT_ENABLED=true
```
1. Set `PULSE_MULTI_TENANT_ENABLED=true` in your environment and restart Pulse.
2. Activate your Enterprise license in **Settings → License**.
3. Go to **Settings → Organization** and click **Create Organization**.
4. Name your organization and assign infrastructure to it.
5. Use the **Org Switcher** in the header bar to switch between organizations.
2. **License**: Enterprise license with `multi_tenant` feature enabled
## Concepts
### Behavior Without Enablement
### Organizations
| Condition | HTTP Response | WebSocket Response |
|-----------|---------------|-------------------|
| Feature flag disabled | 501 Not Implemented | 501 Not Implemented |
| Flag enabled, no license | 402 Payment Required | 402 Payment Required |
| Flag enabled + licensed | Normal operation | Normal operation |
An organization is a fully isolated monitoring environment:
The "default" organization always works regardless of feature flag or license status.
- Its own set of monitored nodes and resources.
- Its own alerts, thresholds, and notifications.
- Its own audit log.
- Its own configuration directory on disk.
---
The **default** organization always exists and is used when multi-tenant is disabled. It cannot be deleted or renamed.
## What's Implemented
### Roles
### Tenant Isolation
Each member has a role within an organization:
| Component | Status | Details |
|-----------|--------|---------|
| State/Monitor | ✅ | Each org gets its own `Monitor` instance via `MultiTenantMonitor` |
| WebSocket | ✅ | Clients bound to tenant, broadcasts filtered by org |
| Audit Logs | ✅ | `LogAuditEventForTenant()` writes to per-org audit DB |
| Resources | ✅ | Per-tenant resource stores with `PopulateFromSnapshotForTenant()` |
| Persistence | ✅ | `MultiTenantPersistence` provides per-org config directories |
| Role | Permissions |
|---|---|
| **Owner** | Full control. Can transfer ownership, delete the org. |
| **Admin** | Manage members, shares, and org settings. Cannot transfer ownership. |
| **Editor** | Read/write access to org resources. Cannot manage members or shares. |
| **Viewer** | Read-only access to all org data. |
### Gating & Authorization
### Resource Sharing
| Component | Status | Details |
|-----------|--------|---------|
| Feature flag | ✅ | `PULSE_MULTI_TENANT_ENABLED` env var (default: false) |
| License check | ✅ | Requires `multi_tenant` feature in Enterprise license |
| HTTP middleware | ✅ | `TenantMiddleware` extracts org ID, validates access |
| WebSocket gating | ✅ | `MultiTenantChecker` validates before upgrade |
| Token authorization | ✅ | `AuthorizationChecker.TokenCanAccessOrg()` |
| User authorization | ✅ | `AuthorizationChecker.UserCanAccessOrg()` via org membership |
Organizations can share specific resources with other organizations:
### Tenant-Aware Endpoints
- Share a VM, container, host, or storage resource with another org.
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
- The receiving org sees shared resources alongside their own, with a share badge.
All user-facing data endpoints use `getTenantMonitor(ctx)`:
## Managing Organizations
- `/api/state`
- `/api/charts`
- `/api/storage/{id}`
- `/api/backups`, `/api/backups/pve`, `/api/backups/pbs`
- `/api/snapshots`
- `/api/resources/*`
- `/api/metrics/*`
### Creating an Organization
---
**UI:** Settings → Organization → Create Organization
## Storage Layout and Migration
**API:**
```bash
curl -X POST http://localhost:7655/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
```
- The **default** org uses the root data dir for backward compatibility.
- Non-default orgs store data in `/orgs/<org-id>/`.
- When multi-tenant is enabled, legacy single-tenant data is migrated into `/orgs/default/` and symlinks are created in the root data dir for compatibility.
### Switching Organizations
Use the **Org Switcher** dropdown in the header. When you switch:
- All pages reload with the new organization's data.
- AI chat history is reset (each org has its own context).
- Caches are invalidated and re-fetched.
### Managing Members
**UI:** Settings → Organization → Access
**API:**
```bash
# List members
curl http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN"
# Add a member
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id", "role": "editor"}'
# Update role
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
```
### Sharing Resources
**UI:** Settings → Organization → Sharing
**API:**
```bash
# Create a share
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetOrgId": "other-org-id",
"resourceType": "host",
"resourceId": "resource-id",
"role": "viewer"
}'
# View incoming shares
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
```
## Settings Panels
When multi-tenant is enabled, **Settings → Organization** shows:
| Panel | Description |
|---|---|
| **Overview** | Organization name, description, creation date |
| **Access** | Member list, invite/remove members, change roles |
| **Sharing** | Outgoing and incoming resource shares |
| **Billing & Plan** | Organization-level plan and license info |
## API Reference
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/orgs` | List organizations the current user can access |
| `POST` | `/api/orgs` | Create a new organization |
| `GET` | `/api/orgs/{id}` | Get organization details |
| `PATCH` | `/api/orgs/{id}` | Update organization |
| `DELETE` | `/api/orgs/{id}` | Delete organization |
| `GET` | `/api/orgs/{id}/members` | List members |
| `POST` | `/api/orgs/{id}/members` | Add a member |
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
| `POST` | `/api/orgs/{id}/shares` | Create a share |
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
### Tenant Context
All data-fetching endpoints respect the active organization context. The active org is determined by:
1. `X-Pulse-Org-ID` header (API clients)
2. Session cookie (browser)
3. Falls back to the `default` organization
## Storage
- The **default** org uses the root data directory (backward compatible).
- Non-default orgs store data in `{data-dir}/orgs/{org-id}/`.
- Organization metadata is stored in `org.json` inside each org directory.
- When multi-tenant is first enabled, legacy single-tenant data is migrated into `orgs/default/` with symlinks for compatibility.
---
## Troubleshooting
## Intentionally Global (Admin-Level)
### "Multi-tenant is not enabled on this server" (501)
These endpoints show system-wide data regardless of tenant context:
Set `PULSE_MULTI_TENANT_ENABLED=true` in your environment and restart Pulse.
| Endpoint | Rationale |
|----------|-----------|
| `/api/health` | System uptime, not tenant-specific |
| `/api/scheduler/health` | Process-level scheduler status |
| `/api/diagnostics/*` | Admin diagnostics for full system |
### "Multi-tenant requires an Enterprise license" (402)
Also global:
- `security_setup_fix.go` - Clears unauthenticated agents on default monitor
Activate an Enterprise license with the `multi_tenant` capability in **Settings → License**.
---
### Organization data not loading after switch
## Architecture
1. Hard-refresh the browser (`Ctrl+Shift+R`).
2. Check the Org Switcher dropdown — ensure the correct org is selected.
3. Check Pulse logs for tenant middleware errors.
### Key Files
### Shared resources not appearing
| File | Purpose |
|------|---------|
| `internal/api/middleware_tenant.go` | Extracts org ID, validates access, injects context |
| `internal/api/middleware_license.go` | Feature flag, license check, 501/402 responses |
| `internal/api/authorization.go` | `AuthorizationChecker` interface, token/user access checks |
| `internal/monitoring/multi_tenant_monitor.go` | Per-org monitor instances |
| `internal/config/multi_tenant.go` | Per-org persistence (config directories) |
| `internal/websocket/hub.go` | Tenant-aware client tracking, `MultiTenantChecker` |
| `pkg/server/server.go` | Wires up org loader, multi-tenant checker |
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
2. Confirm the share role grants sufficient access.
3. Check that the source org's resources are online.
### Request Flow
## See Also
```
Request
├─► TenantMiddleware
│ ├─► Extract org ID (header/cookie/default)
│ ├─► Feature flag check (501 if disabled)
│ ├─► License check (402 if unlicensed)
│ ├─► Authorization check (403 if denied)
│ └─► Inject org ID into context
├─► Handler
│ └─► getTenantMonitor(ctx) → org-specific Monitor
└─► Response (org-scoped data)
```
### Org ID Sources (Priority Order)
1. `X-Pulse-Org-ID` header (API clients/agents)
2. `pulse_org_id` cookie (browser sessions)
3. Fallback: `"default"`
---
## Data Model
### Organization
```go
type Organization struct {
ID string
DisplayName string
OwnerUserID string // Creator/owner
Members []OrganizationMember // User membership
}
type OrganizationMember struct {
UserID string
Role string // "owner", "admin", "member"
AddedAt time.Time
AddedBy string
}
```
### API Token Binding
```go
type APITokenRecord struct {
// ... existing fields ...
OrgID string // Single org binding
OrgIDs []string // Multi-org access (MSP tokens)
}
```
Legacy tokens (empty `OrgID`) have wildcard access during migration period.
---
## TODO / Deferred Items
### High Priority (Before GA)
- [ ] **UI integration**: Org switcher, org management screens
### Medium Priority
- [ ] **Org CRUD endpoints**: Create/update/delete organizations via API
- [ ] **Member management**: Add/remove users from organizations
### Low Priority / Policy Decisions
- [ ] Decide if diagnostics should be org-scoped or super-admin only
- [ ] Decide if `security_setup_fix.go` agent cleanup should be org-scoped
---
## Testing Checklist
### Unit Tests
```bash
# Tenant middleware tests
go test ./internal/api -run TestTenantMiddleware
# WebSocket multi-tenant tests
go test ./internal/websocket -run TestHandleWebSocket_MultiTenant
```
### Manual Testing
1. **Default behavior (flag disabled)**
- Start Pulse without `PULSE_MULTI_TENANT_ENABLED`
- Verify normal operation
- Attempt `X-Pulse-Org-ID: test-org` header → expect 501
2. **Flag enabled, no license**
- Set `PULSE_MULTI_TENANT_ENABLED=true`
- No Enterprise license
- Attempt non-default org → expect 402
3. **Full multi-tenant**
- Enable flag + Enterprise license
- Create org "test-a" with PVE node A
- Create org "test-b" with PVE node B
- Open browser tabs for each org
- Verify each sees only their nodes
- Verify WebSocket updates are isolated
- Attempt header spoofing with wrong token → expect 403
### Integration Test Script
```bash
# 1. Verify default org works without flag
curl -u admin:admin http://localhost:7655/api/state
# → 200 OK
# 2. Verify non-default org blocked without flag
curl -u admin:admin -H "X-Pulse-Org-ID: test-org" http://localhost:7655/api/state
# → 501 Not Implemented
# 3. With flag enabled but no license
export PULSE_MULTI_TENANT_ENABLED=true
curl -u admin:admin -H "X-Pulse-Org-ID: test-org" http://localhost:7655/api/state
# → 402 Payment Required
```
---
## Rollout
### Verification Status
| Component | Status | Method | Notes |
|-----------|--------|--------|-------|
| **Feature Flag** | ✅ Verified | Unit Test | Flag disables/enables multi-tenant access correctly |
| **Licensing** | ✅ Verified | Unit Test | Unlicensed access blocked with 402 Payment Required |
| **Migration** | ✅ Verified | Unit Test | Legacy data moves to default org; symlinks created |
| **Isolation** | ✅ Verified | Unit Test | API State, WebSockets, and Resources respect tenant context |
| **Security** | ✅ Verified | Code Audit | API Tokens and Audit Logs enforce tenant binding |
### Readiness Checklist
- [ ] Enterprise license loaded for the orgs that will access multi-tenant features
- [ ] `PULSE_MULTI_TENANT_ENABLED=true` configured in the runtime environment
- [ ] Config migration has run on startup (verify tenant layout exists in data dir)
- [ ] Org membership loader is available for session users
- [ ] API tokens for non-default orgs are bound to the org(s)
- [ ] Per-tenant audit logging is enabled (tenant audit DBs present and writable)
- [ ] Tenant config loading uses per-org nodes and credentials (no shared secrets)
### Rollout Steps
1. Enable the feature flag in staging
2. Confirm enterprise license activation for a test org
3. Create a non-default org and bind a test API token to it
4. Validate:
- `501`/`402` behavior for disabled/unlicensed org access
- Success for licensed access (HTTP + WebSocket)
- Data isolation across orgs
5. Roll out to production with monitoring for 4xx/5xx spikes
### Rollback
Disable `PULSE_MULTI_TENANT_ENABLED` to revert non-default org access (default org unaffected).
---
## Response Codes Reference
| Code | Meaning | When |
|------|---------|------|
| 200 | Success | Valid org access |
| 400 | Bad Request | Invalid org ID format |
| 402 | Payment Required | Feature enabled but not licensed |
| 403 | Forbidden | Token/user not authorized for org |
| 501 | Not Implemented | Feature flag disabled |
---
## Changelog
- **2024-01**: Initial implementation
- Feature flag gating
- License enforcement
- Per-tenant state isolation
- WebSocket tenant binding
- Audit log isolation
- Authorization framework
- [Plans & Entitlements](PULSE_PRO.md) — multi-tenant availability by plan
- [Pulse Cloud](CLOUD.md) — hosted multi-tenant environment
- [Security](../SECURITY.md) — authentication and authorization model

View file

@ -5,13 +5,13 @@ Enable Single Sign-On (SSO) with providers like Authentik, Keycloak, Okta, and A
## 🚀 Quick Start
1. **Configure Provider**: Create an OIDC application in your IdP.
- **Redirect URI**: `https://<your-pulse-domain>/api/oidc/callback`
- **Redirect URI**: `https://<your-pulse-domain>/api/oidc/<provider-id>/callback`
- **Scopes**: `openid`, `profile`, `email`
2. **Enable in Pulse**: Go to **Settings → Security → Single Sign-On**.
3. **Enter Details**:
- **Issuer URL**: The base URL of your IdP (e.g., `https://auth.example.com/application/o/pulse/`).
- **Client ID & Secret**: From your IdP.
4. **Save**: The login page will now show a "Continue with Single Sign-On" button.
4. **Save**: The login page will now show your configured SSO provider button(s).
> **Tip**: To hide the username/password form and only show the SSO button, set `PULSE_AUTH_HIDE_LOCAL_LOGIN=true` in your environment. You can still access the local login by appending `?show_local=true` to the URL (e.g., `https://your-pulse-instance/?show_local=true`).
@ -34,7 +34,7 @@ Restrict access to specific users or groups:
- **Allowed Domains**: Restrict to specific email domains (e.g., `example.com`).
- **Allowed Emails**: Allow specific email addresses.
### Group-to-Role Mapping (Pro)
### Group-to-Role Mapping (Pro and Above)
Automatically assign Pulse roles based on OIDC group membership. When a user logs in, Pulse checks their groups claim and assigns the corresponding roles.
@ -87,18 +87,18 @@ For persistent sessions that don't require frequent re-authentication:
### Authentik
- **Type**: OAuth2/OpenID (Confidential)
- **Redirect URI**: `https://pulse.example.com/api/oidc/callback`
- **Redirect URI**: `https://pulse.example.com/api/oidc/<provider-id>/callback`
- **Signing Key**: Must use **RS256** (create a certificate/key pair if needed).
- **Issuer URL**: `https://auth.example.com/application/o/pulse/`
### Keycloak
- **Client ID**: `pulse`
- **Access Type**: Confidential
- **Valid Redirect URIs**: `https://pulse.example.com/api/oidc/callback`
- **Valid Redirect URIs**: `https://pulse.example.com/api/oidc/<provider-id>/callback`
- **Issuer URL**: `https://keycloak.example.com/realms/myrealm`
### Azure AD
- **Redirect URI**: `https://pulse.example.com/api/oidc/callback` (Web)
- **Redirect URI**: `https://pulse.example.com/api/oidc/<provider-id>/callback` (Web)
- **Issuer URL**: `https://login.microsoftonline.com/<tenant-id>/v2.0`
- **Note**: Enable "ID tokens" in Authentication settings.

View file

@ -29,7 +29,7 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
- ❌ Can be slow for encrypted PBS storage
- ❌ Limited metadata per backup
**Recommendation:** If you see a banner in the Backups page suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
---
@ -56,22 +56,26 @@ The agent will:
Use this when you can run a command on the PBS host but do not want to install the agent.
From Pulse's Settings page:
1. Go to **Settings → Proxmox**
1. Go to **Settings → Unified Agents**
2. Click **Add Node**
3. Select **API Only** tab
3. Open **Advanced** and select **API Only**
4. Enter your PBS server's URL
5. Click copy to get the setup command
6. Run the command on your PBS server
Example (what the UI generates):
```bash
curl -sSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | bash
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
```
The script creates a `pulse-monitor@pbs` user, generates a scoped API token, and registers the server with Pulse.
Pulse generates that full command for you from **Settings → Nodes**, including
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
generates a scoped API token, and registers the server with Pulse.
> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality.
> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding.
### Method 3: Manual Token Creation
If you prefer manual setup:
@ -120,7 +124,7 @@ It does **not** allow:
If you have multiple PBS servers, add each one separately in Settings. Pulse will:
- Monitor each server independently
- Show backups from all servers in the unified Backups view
- Show backups from all servers in the unified Recovery view
- Deduplicate if the same backup appears via both PVE passthrough and direct PBS
---
@ -160,7 +164,7 @@ If you see the same backup twice:
## Data Source Indicator
In the Backups view, PBS backups show a data source indicator:
In the Recovery view, PBS backups show a data source indicator:
- **"PBS"** badge alone = Direct PBS connection (full data)
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)

77
docs/PRIVACY.md Normal file
View file

@ -0,0 +1,77 @@
# Privacy
Pulse is designed to run locally. By default, your monitoring data stays on your server.
## Anonymous Telemetry
Pulse includes anonymous telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help the developer understand how many active installations exist and which features are in use.
No hostnames, credentials, IP addresses, or personally identifiable information is ever sent. See the full field list below.
### How to disable
- **Settings → System → General → Anonymous telemetry** (toggle off), or
- Set the environment variable `PULSE_TELEMETRY=false`
### Exactly what is sent
Every field is listed below — nothing else leaves your server:
| Field | Example | Purpose |
|-------|---------|---------|
| Install ID | `a1b2c3d4-...` | Random UUID generated locally, not tied to any account |
| Version | `6.0.0` | Pulse version |
| Platform | `docker` or `binary` | Deployment method |
| OS | `linux` | Operating system |
| Arch | `amd64` | CPU architecture |
| Event | `startup` or `heartbeat` | Whether this is a startup or daily ping |
| PVE nodes | `3` | Number of Proxmox VE nodes connected |
| PBS instances | `1` | Number of Proxmox Backup Server instances |
| PMG instances | `0` | Number of Proxmox Mail Gateway instances |
| VMs | `25` | Total VM count |
| Containers | `12` | Total LXC container count |
| Docker hosts | `2` | Number of Docker hosts monitored |
| Kubernetes clusters | `0` | Number of Kubernetes clusters |
| AI enabled | `true`/`false` | Whether AI features are turned on |
| Active alerts | `4` | Number of active alerts |
| Relay enabled | `true`/`false` | Whether remote access is enabled |
| SSO enabled | `true`/`false` | Whether OIDC/SSO is configured |
| Multi-tenant | `true`/`false` | Whether multi-tenant mode is on |
| License tier | `free`, `pro`, etc. | Current license tier |
| API tokens | `3` | Number of API tokens configured |
### What is NOT sent
- No IP addresses are stored server-side
- No hostnames, node names, VM names, or any infrastructure identifiers
- No Proxmox credentials, API tokens, or passwords
- No alert content, AI prompts, or chat messages
- No personally identifiable information of any kind
### Source code
The telemetry implementation is in [`internal/telemetry/telemetry.go`](../internal/telemetry/telemetry.go). You can read the `Ping` struct to see every field that is transmitted.
## No Third-Party Analytics
- There is no third-party analytics SDK in the frontend.
- Telemetry pings go only to the Pulse license server (`license.pulserelay.pro`), not to any third-party service.
## Optional Outbound Connections (Explicitly Enabled)
Pulse can make outbound connections when you enable specific features:
- **AI (BYOK)**: when AI features are enabled, Pulse sends only the context required for your request to the provider you configured (OpenAI, Anthropic, etc.). See `docs/AI.md`.
- **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access.
- **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration.
## Local Upgrade Metrics (Can Be Disabled)
Pulse can record local-only events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows.
- These events are stored locally and are not exported to third parties.
- Disable via **Settings → System → General → Disable local upgrade metrics** or set:
- `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true`
If you prefer fewer upgrade prompts, you can also enable:
- **Settings → System → General → Reduce Pro prompts**

View file

@ -1,183 +1,159 @@
# 🚀 Pulse Pro (Technical Overview)
# Pulse Plans and Entitlements (Community / Relay / Pro / Pro+ / Cloud)
Pulse Pro unlocks advanced AI automation features on top of the free Pulse platform. Pulse Patrol is available to all users with BYOK, while Pro adds auto-fix, autonomy, and deeper analysis.
This document explains Pulse's user-facing plan structure, the locked self-hosted commercial model, and how those plans map to runtime feature gates.
## What You Get
For the canonical, code-aligned entitlement table (including internal tier names), see:
- `docs/architecture/ENTITLEMENT_MATRIX.md`
### Audit Log
- Persistent audit trail with SQLite storage and HMAC signing.
- Queryable via `/api/audit` and verified per event in the Security → Audit Log UI.
- Supports filtering, verification badges, and signature checks for tamper detection.
- Signing uses an auto-generated HMAC key stored (encrypted) at `.audit-signing.key` in the Pulse data directory.
- Retention defaults to 90 days (not currently configurable via environment variables).
- API reference: `docs/API.md`.
- If signing is disabled (for example, encryption is unavailable), events are stored without signatures and verification will fail.
## Plan Mapping (User-Facing -> Code Tiers)
### Audit Webhooks
- real-time delivery of audit events to external endpoints (SIEM, ELK, etc.).
- Asynchronous dispatch to ensure zero impact on system latency.
- Signature verification on ingest for secure integration.
- Configurable via **Settings → Security → Webhooks**.
Pulse uses capability keys (for example, `ai_autofix`) to gate features at runtime. Those capabilities are bundled into internal tiers in `internal/license/features.go`.
### Advanced Reporting
- Generate comprehensive PDF/CSV reports for nodes, VMs, containers, and storage.
- Includes key statistics, trends, and capacity projections.
- Customizable time ranges and metric aggregation.
- Access via **Settings → System → Reporting**.
User-facing plans map to internal tiers as follows:
- **Community**: `free`
- **Relay**: `relay`
- **Pro**: `pro`, `pro_annual`, `lifetime`
- **Pro+**: `pro_plus`
- **Cloud**: `msp` or `enterprise`
### Pulse Patrol (BYOK)
Scheduled background analysis that correlates live state + metrics history to produce actionable findings.
Notes:
- `lifetime` keeps the same runtime entitlements as Pro.
- Items marked **Cloud*** require the `enterprise` tier rather than the base `msp` tier.
- If you are self-hosting, you can use capability keys and `GET /api/license/features` to discover exactly what is active in your instance.
**Inputs:**
- Nodes, guests, storages, backups, containers, and Kubernetes resources.
- Metrics history trends and anomaly scores.
- Alert state and diagnostics.
## Self-Hosted Commercial Model
**Outputs:**
- Findings with severity, category, and remediation hints.
- Trend-aware capacity warnings (e.g., "storage pool will be full in 10 days").
- Cross-system correlation (e.g., backups failing because a datastore is full).
Pulse sells monitored coverage. The counted unit is a monitored system, not an installed agent.
### Pro-Only Automations
- **Alert-triggered analysis**: on-demand deep analysis when alerts fire.
- **Auto-fix mode**: automatic remediation with verification loops (see Autonomy Levels below).
- **Full autonomy unlock**: auto-fix for critical findings without requiring approval.
- **Kubernetes AI analysis**: deep cluster analysis beyond basic monitoring.
- **Audit-triggered webhooks**: real-time delivery of security events to external systems.
- **Advanced Reporting**: scheduled or on-demand PDF/CSV infrastructure health reports.
- **Agent Profiles**: centralized configuration profiles for fleets of agents.
Self-hosted pricing is locked to:
### Autonomy Levels
| Plan | Price | Included monitored systems | Metric history | Purpose |
|---|---:|---:|---:|---|
| Community | Free | 5 | 7 days | One real small lab end to end |
| Relay | $4.99/mo or $39/yr | 8 | 14 days | Cheap headroom plus remote access |
| Pro | $8.99/mo or $79/yr | 15 | 90 days | Automation and operations tier |
| Pro+ | $14.99/mo or $129/yr | 50 | 90 days | Larger self-hosted labs |
Counted examples:
- Proxmox PVE node
- PBS or PMG server
- Standalone Linux, Windows, or macOS host
- Docker host
- TrueNAS or Unraid system
- Kubernetes cluster
Not counted separately:
- VMs
- containers
- pods
- disks
- pools
- datastores
- backup jobs
- other child resources under a counted top-level system
Runtime rules:
- API-backed monitoring and agent-backed monitoring consume the same cap.
- If the same system is seen through both paths, it counts once.
- Deduplication follows canonical unified-resource identity rather than transport-specific state.
Migration policy:
- Existing paid v5 customers keep their grandfathered recurring continuity until cancellation.
- Existing free users above the new Community cap are not hard-broken on rollout day.
- During grace, existing monitoring keeps working.
- During grace, only new counted-system additions are blocked until the user removes systems or upgrades.
## Feature Matrix
Legend:
- Included: `Y` / `N`
- `Y*`: Cloud Enterprise only (`enterprise` tier)
This matrix is derived from the canonical table in `docs/architecture/ENTITLEMENT_MATRIX.md` plus runtime history/limit semantics exposed through entitlements.
| Constant | Capability Key | Display Name | Community | Relay | Pro | Pro+ | Cloud | Primary Gating Mechanism / Notes |
|---|---|---|:---:|:---:|:---:|:---:|:---:|---|
| `FeatureAIPatrol` | `ai_patrol` | Pulse Patrol (Background Health Checks) | Y | Y | Y | Y | Y | Patrol itself is available on Community with BYOK. Higher-autonomy outcomes and fix execution are separately gated. |
| `FeatureRelay` | `relay` | Remote Access (Mobile Relay) | N | Y | Y | Y | Y | API route gating via `RequireLicenseFeature(..., relay, ...)` for relay settings and onboarding endpoints. |
| `FeatureAIAlerts` | `ai_alerts` | Alert Analysis | N | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., ai_alerts, ...)`. |
| `FeatureAIAutoFix` | `ai_autofix` | Pulse Patrol Auto-Fix | N | N | Y | Y | Y | Required for fix execution and higher-autonomy actions. |
| `FeatureKubernetesAI` | `kubernetes_ai` | Kubernetes Analysis | N | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., kubernetes_ai, ...)`. |
| `FeatureAgentProfiles` | `agent_profiles` | Centralized Agent Profiles | N | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., agent_profiles, ...)`. |
| `FeatureUpdateAlerts` | `update_alerts` | Update Alerts (Container/Package Updates) | Y | Y | Y | Y | Y | Included in Community tier per `TierFeatures[TierFree]`. |
| `FeatureSSO` | `sso` | Basic SSO (OIDC) | Y | Y | Y | Y | Y | Basic SSO is included in Community tier. |
| `FeatureAdvancedSSO` | `advanced_sso` | Advanced SSO (SAML/Multi-Provider) | N | N | Y | Y | Y | Used to gate advanced SSO capabilities such as SAML and multi-provider flows. |
| `FeatureRBAC` | `rbac` | Role-Based Access Control (RBAC) | N | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., rbac, ...)`. |
| `FeatureAuditLogging` | `audit_logging` | Audit Logging | N | N | Y | Y | Y | API route gating for audit query, verify, and export endpoints. |
| `FeatureAdvancedReporting` | `advanced_reporting` | PDF/CSV Reporting | N | N | Y | Y | Y | API route gating via `RequireLicenseFeature(..., advanced_reporting, ...)`. |
| `FeatureLongTermMetrics` | `long_term_metrics` | Extended Metric History | N | Y | Y | Y | Y | Runtime history limits are tier-aware through `max_history_days`: Community `7`, Relay `14`, Pro/Pro+ `90`. |
| `FeatureMultiUser` | `multi_user` | Multi-User Mode | N | N | N | N | Y* | Cloud Enterprise only. |
| `FeatureWhiteLabel` | `white_label` | White-Label Branding | N | N | N | N | Y* | Capability key exists but is still marked not implemented in `internal/license/features.go`. |
| `FeatureMultiTenant` | `multi_tenant` | Multi-Tenant Mode | N | N | N | N | Y* | Requires both `PULSE_MULTI_TENANT_ENABLED=true` and the `multi_tenant` capability for non-default orgs. |
| `FeatureUnlimited` | `unlimited` | Unlimited Instances | N | N | N | N | Y | Used for hosted volume and instance limit removal. |
## Autonomy Levels (AI Safety)
Patrol and the Assistant support tiered autonomy:
| Mode | Behavior | License |
|------|----------|--------|
| **Monitor** | Detect issues only. No investigation or fixes. | Free (BYOK) |
| **Investigate** | Investigates findings and proposes fixes. All fixes require approval. | Free (BYOK) |
| **Auto-fix** | Automatically fixes issues and verifies. Critical findings require approval by default. | **Pro** |
| **Full autonomy** | Auto-fix for all findings including critical, without approval. | **Pro** (explicit toggle) |
| Mode | Behavior | Plan |
|---|---|---|
| **Monitor** | Detect issues only. No investigation or fixes. | Community / Relay |
| **Investigate** | Investigates findings and proposes fixes. All fixes require approval. | Community / Relay |
| **Auto-fix** | Automatically fixes issues and verifies. Critical findings require approval by default. | Pro / Pro+ / Cloud |
| **Full autonomy** | Auto-fix for all findings including critical, without approval (explicit toggle). | Pro / Pro+ / Cloud |
### Investigation Orchestration
## What You Get (By Plan)
When Patrol creates a finding, the investigation orchestrator can:
### Community
- Core monitoring for up to 5 monitored systems.
- 7-day history.
- Pulse Patrol with BYOK.
- Basic SSO and update alerts.
1. **Create a chat session** dedicated to the finding.
2. **AI analyzes** the issue using available tools (metrics, logs, storage, etc.).
3. **Propose a fix** with risk assessment (low/medium/high/critical).
4. **Queue for approval** or **auto-execute** based on autonomy level.
5. **Verify the fix** with a follow-up read after execution.
### Relay
- Everything in Community, plus:
- 8 monitored systems.
- 14-day history.
- Remote access via Relay.
- Mobile app access and push notifications.
Investigation outcomes include:
- `resolved` — Issue resolved during investigation
- `fix_queued` — Fix proposed, awaiting approval
- `fix_executed` — Fix auto-executed successfully
- `fix_verified` — Fix worked, issue confirmed resolved
- `needs_attention` — Requires human intervention
- `cannot_fix` — Issue cannot be automatically fixed
### Pro
- Everything in Relay, plus:
- 15 monitored systems.
- AI alert analysis.
- Auto-fix and higher autonomy.
- Kubernetes AI analysis.
- Centralized agent profiles.
- Advanced SSO, RBAC, audit logging, and advanced reporting.
- 90-day history.
### What Free Users Still Get
- **Pulse Patrol (BYOK)**: background findings and investigation proposals with your own provider.
- **AI Chat (BYOK)**: interactive troubleshooting with your own API keys.
- **Update alerts**: container/package update signals remain available in the free tier.
### Pro+
- Everything in Pro, with 50 monitored systems for larger self-hosted labs.
### What You See In The UI
- **Patrol findings**: a prioritized list with severity, evidence, and recommended fixes.
- **Investigation status**: progress indicators showing investigation state and outcome.
- **Approval cards**: pending fixes await your review with one-click approve/deny.
- **Alert timelines**: AI analysis events attached to the alert history for auditability.
- **Remediation controls**: explicit toggles for autonomy mode in Patrol settings.
- **Agent profiles**: create, edit, and assign profiles in **Settings → Agents → Agent Profiles**.
### Cloud
- Hosted Pulse with Pro-level capabilities and hosted lifecycle management.
- Cloud Enterprise adds multi-tenant orgs, multi-user mode, and future white-labeling where licensed.
## Pro Feature Gates (License-Enforced)
## License Activation and Introspection
Pulse Pro licenses enable specific server-side features. These are enforced at the API layer and in the UI:
Pulse plan upgrades are activated locally with a license key.
- `ai_alerts`: alert-triggered analysis runs.
- `ai_autofix`: autonomous mode and auto-fix workflows.
- `kubernetes_ai`: AI analysis for Kubernetes clusters (not basic monitoring).
- `agent_profiles`: centralized agent configuration profiles.
- `advanced_reporting`: infrastructure health report generation (PDF/CSV).
- `audit_logging`: persistent audit trail and real-time webhook delivery.
- `long_term_metrics`: 30-day and 90-day metrics history (7-day history is free).
## Why It Matters (Technical Value)
- **Cross-system correlation**: Patrol combines PVE, PBS, PMG, Docker, and Kubernetes signals into a single model context instead of isolated checks.
- **Trend-aware analysis**: Uses metrics history to detect slow-burn issues that static thresholds miss.
- **Noise control**: Suppression and dismissal memory prevent alert fatigue.
- **Actionable findings**: Each finding includes root-cause clues and next steps.
- **Auditability**: AI analysis is attached to alerts and stored with finding history, so decisions are traceable.
- **Fleet consistency**: Agent Profiles keep monitoring settings consistent across large deployments.
## Scheduling and Controls
- **Interval**: 10 minutes to 7 days (default 6 hours). Set to 0 to disable Patrol.
- **Scope**: Patrol only analyzes resources Pulse is already monitoring.
- **Safety**: Command execution and auto-fix are disabled by default and require explicit enablement.
## How Licensing Works
Pulse Pro is activated locally with a license key.
1. Go to **Settings → System → Pulse Pro**.
2. Paste your license key and click **Activate License**.
3. The key is validated locally (no license server required).
License status, expiry, and feature availability are visible in the same panel.
The license key is stored encrypted in `license.enc` under the Pulse config directory. It is not included in export/import backups, so re-activate after migrations.
- License key storage: `license.enc` under the Pulse config directory (encrypted; requires `.encryption.key` to decrypt).
- Export/import note: license files are not included in exports, so you typically re-activate after migrations.
- Pulse v6 prefers v6 activation keys, but it can migrate valid Pulse v5 Pro or Lifetime JWT-style licenses into the v6 activation model.
- If a v5 license is already persisted on disk during upgrade and no v6 activation state exists yet, Pulse will try to auto-exchange it on startup.
- If you are activating manually in v6, paste the v6 activation key shown on the hosted checkout success page. A backup copy is also sent by email. You can also paste a valid v5 Pro or Lifetime license key and Pulse will try to exchange it automatically.
- If the exchange cannot complete, retry from the v6 license panel or use the self-serve retrieval flow to fetch the current v6 activation key.
### Feature Status API
You can inspect license feature gates via:
You can inspect active feature gates via:
- `GET /api/license/features` (authenticated)
This returns a feature map like `ai_alerts`, `ai_autofix`, and `kubernetes_ai` so you can automate Pro-only workflows safely.
This returns a feature map including keys like `relay`, `ai_alerts`, `ai_autofix`, `kubernetes_ai`, and `multi_tenant` so you can conditionally enable paid workflows safely.
## Under The Hood (Technical)
## Deep Dives
- **Patrol context**: patrol runs build a unified snapshot from live state + `metrics.db` history, then correlate alerts, diagnostics, and resource topology.
- **Findings storage**: findings persist in `ai_findings.json` with run history in `ai_patrol_runs.json`.
- **Alert-triggered analysis**: runs per alert event and writes analysis into the alert timeline for auditability.
- **Auto-fix safety**: requires explicit toggles and uses the same agent command scopes you configure for manual runs.
📖 **For complete technical details on the AI subsystems:**
- [Pulse Patrol Deep Dive](architecture/pulse-patrol-deep-dive.md) — Baseline learning, pattern detection, forecasting, correlation analysis, incident memory
- [Pulse Assistant Deep Dive](architecture/pulse-assistant-deep-dive.md) — Context prefetching, FSM enforcement, knowledge accumulation, safety gates
## Example Finding Payload (API)
`GET /api/ai/patrol/findings` returns structured findings you can integrate with external tooling:
```json
{
"id": "finding-9f7c2f5e",
"key": "storage-high-usage",
"severity": "warning",
"category": "capacity",
"resource_id": "storage:local-lvm",
"resource_name": "local-lvm",
"resource_type": "storage",
"node": "pve-1",
"title": "Storage nearing capacity",
"description": "local-lvm is at 87% and growing ~4%/day.",
"recommendation": "Review VM disks on local-lvm or expand the volume within 7 days.",
"evidence": "Used 1.74TB of 2.0TB; +4.1%/day over 7d.",
"source": "ai-analysis",
"detected_at": "2025-03-04T09:11:12Z",
"last_seen_at": "2025-03-04T15:11:12Z",
"alert_id": "alert-storage-usage-local-lvm",
"times_raised": 2,
"suppressed": false
}
```
Findings include `source: "ai-analysis"` when AI is enabled (BYOK).
## Privacy and Data Handling
Patrol runs on your Pulse server. When AI is enabled, only the minimal context needed for analysis is sent to the configured AI provider. No telemetry is sent to Pulse by default.
For a deeper AI walkthrough, see [AI.md](AI.md).
- [Pulse Patrol Deep Dive](architecture/pulse-patrol-deep-dive.md)
- [Pulse Assistant Deep Dive](architecture/pulse-assistant-deep-dive.md)
- [Pulse AI overview](AI.md)

191
docs/RBAC.md Normal file
View file

@ -0,0 +1,191 @@
# Role-Based Access Control (RBAC)
RBAC lets you define custom roles with granular permissions and assign them to users. This restricts what each user can see and do in Pulse.
**Requires:** Pro, Pro+, or Cloud license with the `rbac` capability.
For plan details, see [PULSE_PRO.md](PULSE_PRO.md). For API endpoints, see [API Reference](API.md#-rbac--role-management-pro).
---
## Concepts
### Roles
A role is a named set of permissions. Each permission is an `(action, resource)` pair:
- **action**: `read`, `write`, `delete`, or `admin`
- **resource**: A Pulse resource type (e.g., `alerts`, `settings`, `nodes`, `ai`)
Pulse ships with built-in roles: `admin` (full access), `operator` (manage alerts and resources), `viewer` (read-only), and `auditor` (audit log access). You can create additional custom roles for more granular control.
### Role Assignment
Users can hold multiple roles. Their effective permissions are combined across all assigned roles. Explicit `deny` rules take precedence over `allow` grants.
### OIDC Group Mapping
When using OIDC/SSO, roles can be automatically assigned based on group membership. See [OIDC Group-to-Role Mapping](OIDC.md#group-to-role-mapping-pro) for configuration.
---
## Quick Start
1. Activate a Pro, Pro+, or Cloud license in **Settings → License**.
2. Go to **Settings → Security → Access Control**.
3. Create roles with the permissions you need.
4. Assign roles to users.
---
## Managing Roles
### Creating a Role
**UI:** Settings → Security → Access Control → Create Role
**API:**
```bash
curl -X POST http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "operator",
"name": "Operator",
"description": "Can view and manage alerts",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}
]
}'
```
### Listing Roles
```bash
curl http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN"
```
### Updating a Role
```bash
curl -X PUT http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Operator",
"description": "Updated description",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}
]
}'
```
### Deleting a Role
```bash
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN"
```
---
## Managing User Assignments
### Listing Users and Their Roles
```bash
curl http://localhost:7655/api/admin/users \
-H "Authorization: Bearer $TOKEN"
```
### Setting Roles for a User
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": ["operator", "viewer"]}'
```
To remove all custom roles from a user, send an empty list:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": []}'
```
Note: Users cannot modify their own role assignments (self-escalation prevention).
---
## Automatic Role Assignment via OIDC
If you use an OIDC identity provider, Pulse can automatically assign roles based on group membership on each login.
**UI:** Settings → Security → Single Sign-On → Group Role Mappings
**Environment variable:**
```bash
# Format: group1=role1,group2=role2
OIDC_GROUP_ROLE_MAPPINGS="oidc-admins=admin,oidc-operators=operator,oidc-viewers=viewer"
```
How it works:
- On each login, Pulse reads the user's groups from the OIDC groups claim.
- Matching groups are mapped to Pulse roles.
- A user can receive multiple roles from multiple group mappings.
- When at least one mapped role is found, role assignments are updated to match. Note: logins with zero matching groups do not clear existing role assignments.
- Role changes are logged to the [audit log](AUDIT_LOGGING.md) as `oidc_role_assignment` events.
See [OIDC documentation](OIDC.md#group-to-role-mapping-pro) for full configuration details.
---
## Organization Roles (Multi-Tenant)
In multi-tenant deployments (Cloud Enterprise), each organization has its own role hierarchy:
| Role | Permissions |
|------|------------|
| **Owner** | Full control. Can transfer ownership and delete the org. |
| **Admin** | Manage members, shares, and org settings. Cannot transfer ownership. |
| **Editor** | Read/write access to org resources. Cannot manage members. |
| **Viewer** | Read-only access to all org data. |
These organization roles are separate from the RBAC custom roles described above. Organization roles control access within a specific tenant, while RBAC roles control access to Pulse features globally.
See [Multi-Tenant Organizations](MULTI_TENANT.md) for details.
---
## Example: Team Setup
A typical team configuration:
| User | Role | Access |
|------|------|--------|
| alice | `admin` | Full access to everything |
| bob | `operator` | Can view nodes/VMs and manage alerts |
| carol | `viewer` | Read-only access to dashboards and metrics |
| monitoring-bot | API token with `alerts:read` scope | Automated alert polling |
---
## Related Documentation
- [Plans and Entitlements](PULSE_PRO.md) — RBAC availability by plan
- [OIDC / SSO](OIDC.md) — Automatic role assignment from identity providers
- [Audit Logging](AUDIT_LOGGING.md) — Track role changes and access events
- [Multi-Tenant Organizations](MULTI_TENANT.md) — Organization-level roles
- [API Reference](API.md#-rbac--role-management-pro) — RBAC API endpoints
- [Security Policy](../SECURITY.md) — Core security model

View file

@ -4,6 +4,31 @@ Welcome to the Pulse documentation portal. Here you'll find everything you need
---
## v6 Execution Canonical Source
For Pulse v6 build/release execution work, do not start from this broad docs index.
Use:
1. `docs/release-control/v6/SOURCE_OF_TRUTH.md` for stable human governance and locked decisions
2. `docs/release-control/v6/status.json` for live lane state, lane-to-subsystem ownership, structured evidence references, typed lane/subsystem decision records, and canonical ordered lists
3. `docs/release-control/v6/status.schema.json` for the machine-readable status contract
4. `docs/release-control/v6/subsystems/registry.json` and `docs/release-control/v6/subsystems/registry.schema.json` for subsystem ownership, explicit shared-ownership exceptions, and proof-routing rules
5. `python3 scripts/release_control/status_audit.py --check` if you need a machine-derived evidence health audit
6. `python3 scripts/release_control/registry_audit.py --check` if you need a machine-derived subsystem registry audit
7. `python3 scripts/release_control/contract_audit.py --check` if you need a machine-derived subsystem contract audit, including explicit cross-subsystem dependency checks and exact registry-derived shared-boundary wording
Local pre-commit runs the v6 machine audits against staged control-file content so partial staging cannot hide governance drift.
Local pre-commit also blocks partial staging for hook-sensitive governance files under `docs/release-control/v6/`, `scripts/release_control/`, `internal/repoctl/`, `.husky/pre-commit`, and `.github/workflows/canonical-governance.yml`, because those checks still execute or structurally read the working-tree versions locally.
8. `python3 scripts/release_control/subsystem_lookup.py <path> [<path> ...]` if you need subsystem ownership, proof routing, lane context, relevant decision records, and dependent contract-update obligations for a change
For governed runtime changes, a staged subsystem contract only counts if its
diff updates a substantive contract section such as `Purpose`, `Canonical Files`,
`Shared Boundaries`, `Extension Points`, `Forbidden Paths`,
`Completion Obligations`, or `Current State`, rather than metadata alone.
All other documents are supporting references unless explicitly required for evidence.
---
## 🚀 Getting Started
- **[Installation Guide](INSTALL.md)**
@ -14,9 +39,9 @@ Welcome to the Pulse documentation portal. Here you'll find everything you need
Where config lives, how updates work, and what differs per deployment.
- **[Migration Guide](MIGRATION.md)**
Moving to a new server? Here's how to export and import your data safely.
- **[Upgrade to v5](UPGRADE_v5.md)**
Practical upgrade guidance and post-upgrade checks.
- **[FAQ](FAQ.md)**
- **[Upgrade to v6](UPGRADE_v6.md)**
Practical upgrade guidance and post-upgrade checks for Pulse v6.
- **[FAQ](FAQ.md)**
Common questions and quick answers.
## 🛠️ Deployment & Operations
@ -29,33 +54,55 @@ Welcome to the Pulse documentation portal. Here you'll find everything you need
## 🔐 Security
- **[Security Policy](../SECURITY.md)** The core security model (Encryption, Auth, API Scopes).
- **[Privacy](PRIVACY.md)** What leaves your network (and what doesnt).
- **[OIDC / SSO](OIDC.md)** OIDC Single Sign-On configuration (Authentik, Keycloak, Azure AD, etc.).
- **[Proxy Auth](PROXY_AUTH.md)** Authentik/Authelia/Cloudflare proxy authentication configuration.
- **[Agent Security](AGENT_SECURITY.md)** Agent self-update verification and API security.
## ✨ New in 5.0
## 📖 Advanced Topics (Relay / Pro / Pro+ / Cloud)
- **[Pulse AI](AI.md)** Optional assistant for chat, patrol findings, alert analysis, and execution workflows.
- **[AI Autonomy & Safety](AI_AUTONOMY.md)** Configure patrol autonomy levels, assistant control levels, investigation tuning, and safety guardrails.
- **[Role-Based Access Control (RBAC)](RBAC.md)** Define custom roles, assign permissions, and integrate with OIDC group mapping.
- **[Audit Logging](AUDIT_LOGGING.md)** Tamper-evident event logging for compliance, with query, export, and signature verification.
## ✨ New in 6.0
- **[Unified Resource Model](UNIFIED_RESOURCES.md)** How all platforms merge into one model with task-based navigation.
- **[Unified Navigation Migration](MIGRATION_UNIFIED_NAV.md)** Upgrading from platform-specific tabs to v6 navigation.
- **[TrueNAS Integration](TRUENAS.md)** First-class TrueNAS SCALE/CORE monitoring (pools, datasets, disks, snapshots, replication).
- **[Relay / Mobile Remote Access](RELAY.md)** End-to-end encrypted relay (mobile app public rollout is coming soon; Relay and above).
- **[Recovery Central](RECOVERY.md)** Unified backup, snapshot, and replication view across all providers.
- **[Pulse Cloud (Hosted)](CLOUD.md)** Fully managed hosting with automatic updates and backups.
- **[Pulse AI](AI.md)** Chat assistant, patrol findings, alert analysis, intelligence, and forecasts.
- **[Metrics History](METRICS_HISTORY.md)** Persistent metrics storage with configurable retention.
- **[Mail Gateway](MAIL_GATEWAY.md)** Proxmox Mail Gateway (PMG) monitoring.
- **[Auto Updates](AUTO_UPDATE.md)** One-click updates for supported deployments.
- **[Kubernetes](KUBERNETES.md)** Helm deployment (ingress, persistence, HA patterns).
- **[Multi-Tenant Organizations](MULTI_TENANT.md)** Isolate infrastructure by organization (Enterprise, opt-in).
- **[Entitlements Overhaul](PULSE_PRO.md)** Capability-key-based feature gating across Community/Relay/Pro/Pro+/Cloud.
## 🚀 Pulse Pro
## 💳 Plans (Community / Relay / Pro / Pro+ / Cloud)
Pulse Pro unlocks **Auto-Fix and advanced AI analysis****Pulse Patrol is available to all with BYOK**.
Pulse is available in four self-hosted tiers plus hosted Cloud:
- **Community**: Free self-hosted monitoring for up to 5 monitored systems with 7-day history.
- **Relay**: Adds remote access, mobile, push notifications, 14-day history, and raises the monitored-system limit to 8.
- **Pro**: Adds AI investigation, auto-fix, operations tooling, and raises the monitored-system limit to 15 with 90-day history.
- **Pro+**: Everything in Pro with room for up to 50 monitored systems.
- **Cloud**: Hosted Pulse with Pro-level capabilities; hosted pricing is unchanged by the self-hosted model lock.
- **[Learn more at pulserelay.pro](https://pulserelay.pro)**
- **[AI Patrol deep dive](AI.md)**
- **[Pulse Pro technical overview](PULSE_PRO.md)**
- **What you actually get**: Auto-fix + autonomous mode, alert-triggered deep dives, Kubernetes AI analysis, reporting, and agent profiles.
- **Technical highlights**: correlation across nodes/VMs/backups/containers, trend-based capacity predictions, and findings you can resolve/suppress.
- **Scheduling**: 10 minutes to 7 days (default 6 hours).
- **Agent Profiles (Pro)**: centralized agent configuration profiles. See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md).
- **[Plans and entitlements](PULSE_PRO.md)** (includes the Community/Relay/Pro/Pro+/Cloud matrix)
- **[AI deep dive](AI.md)**
- **[Multi-Tenant Organizations (Enterprise)](MULTI_TENANT.md)** — Isolate infrastructure by organization for MSPs and multi-datacenter deployments.
## 📡 Monitoring & Agents
- **[Unified Agent](UNIFIED_AGENT.md)** Single binary for host, Docker, and Kubernetes monitoring.
- **[Centralized Agent Management (Pro)](CENTRALIZED_MANAGEMENT.md)** Agent profiles and remote config.
- **[Centralized Agent Management (Pro/Pro+/Cloud)](CENTRALIZED_MANAGEMENT.md)** Agent profiles and remote config.
- **[Proxmox Backup Server](PBS.md)** PBS integration, direct API vs PVE passthrough, token setup.
- **[TrueNAS](TRUENAS.md)** TrueNAS SCALE/CORE integration.
- **[ZFS Monitoring](ZFS_MONITORING.md)** Proxmox-native ZFS pool monitoring.
- **[Storage Architecture](STORAGE_ARCHITECTURE.md)** Proposed canonical storage, disk, S.M.A.R.T., and topology model for making storage genuinely operator-useful.
- **[VM Disk Monitoring](VM_DISK_MONITORING.md)** Enabling QEMU Guest Agent for disk stats.
- **[Temperature Monitoring](TEMPERATURE_MONITORING.md)** Agent-based temperature monitoring (`pulse-agent --enable-proxmox`). Sensor proxy has been removed.
- **[Webhooks](WEBHOOKS.md)** Custom notification payloads.
@ -66,6 +113,12 @@ Pulse Pro unlocks **Auto-Fix and advanced AI analysis** — **Pulse Patrol is av
- **[Architecture](../ARCHITECTURE.md)** System design and component interaction.
- **[Contributing](../CONTRIBUTING.md)** How to contribute to Pulse.
## 📁 Previous Versions
- **[Upgrade to v5](UPGRADE_v5.md)** Upgrade guidance for v4 → v5 migrations.
- **[v6 Release Promotion Policy](release-control/v6/RELEASE_PROMOTION_POLICY.md)** Canonical stable-vs-RC release rules and rollback expectations.
- **[v6 Prerelease Runbook](releases/V6_PRERELEASE_RUNBOOK.md)** Internal release operations used during the v6 RC period.
---
Found a bug or have a suggestion?

136
docs/RECOVERY.md Normal file
View file

@ -0,0 +1,136 @@
# Recovery
Pulse v6 includes a **provider-neutral recovery view** that aggregates backup, snapshot, and replication artifacts across all connected platforms into a single interface.
## Overview
Recovery answers two questions:
1. **"What is protected?"** → The **Protected Items** table shows a rollup per subject with its latest backup/snapshot status.
2. **"What happened?"** → The **Events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes.
## Supported Providers
| Provider | Recovery Point Types |
|---|---|
| **Proxmox Backup Server (PBS)** | Full and incremental backups, sync jobs, verify tasks |
| **Proxmox VE (PVE)** | Local dump-style backups (`vzdump`) |
| **TrueNAS** | ZFS snapshots, replication tasks |
| **Kubernetes** | VolumeSnapshots, Velero backups (when available) |
## Concepts
### Subject (What Was Protected)
A subject is the thing being protected:
- A Proxmox VM or container
- A TrueNAS dataset (e.g., `tank/apps/postgres`)
- A Kubernetes PVC (e.g., `monitoring/prometheus-pvc`)
Subjects link to unified resources via `subjectResourceId` when possible.
### Recovery Point (An Artifact / Event)
A recovery point is a single concrete artifact:
- A PBS backup snapshot
- A local `vzdump` backup file
- A ZFS snapshot
- A replication run result
### Rollup (A Subject Summary)
A rollup groups recovery points for a subject to show:
- **Protection status** — is this subject actively protected?
- **Latest point** — when was the most recent successful backup/snapshot?
- **Health** — are there recent failures or warnings?
## Navigating Recovery
### Protected Items Tab
Shows one row per protected subject (or per subject + method when multiple backup methods exist). Key columns:
| Column | Description |
|---|---|
| Subject | The protected resource (VM name, dataset path, etc.) |
| Method | Backup method (PBS backup, local dump, ZFS snapshot, replication) |
| Last Point | Most recent recovery point timestamp |
| Outcome | Success / Warning / Failed |
| Source | Which provider created this point (pve, pbs, truenas, k8s) |
### Events Tab
Shows individual recovery points. Key columns:
| Column | Description |
|---|---|
| Time | When the point was created (started/completed) |
| Subject | What was backed up |
| Method | Kind + mode of the backup |
| Outcome | success / warning / failed / running |
| Size | Size of the artifact (when available) |
| Verified | Whether the backup has been verified (tri-state) |
### Filtering
Both tabs support:
- **Source filter** — show only points from a specific provider
- **Outcome filter** — show only failed, successful, or running points
- **Time range** — filter to a specific time window
- **Search** — full-text search across subjects and details
## API Reference
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/recovery/points` | List individual recovery points |
| `GET` | `/api/recovery/rollups` | List subject rollups (protected items) |
| `GET` | `/api/recovery/series` | Time-series data for recovery charts |
| `GET` | `/api/recovery/facets` | Available filter facets (providers, kinds, outcomes) |
### Query Parameters
All recovery endpoints support:
| Parameter | Description |
|---|---|
| `provider` | Filter by provider (`pve`, `pbs`, `truenas`, `k8s`) |
| `kind` | Filter by kind (`backup`, `snapshot`, `replication`) |
| `outcome` | Filter by outcome (`success`, `failed`, `warning`, `running`) |
| `since` | ISO 8601 timestamp — only points after this time |
| `until` | ISO 8601 timestamp — only points before this time |
| `subject` | Filter by subject reference |
| `limit` | Max results (default: 500) |
## Troubleshooting
### No recovery data showing
1. Verify at least one data source provides backup/snapshot data:
- **PBS**: Ensure a PBS connection exists in Settings → Infrastructure.
- **TrueNAS**: Ensure a TrueNAS connection exists in Settings → TrueNAS.
- **PVE**: Local backups from PVE are included automatically.
2. Wait one polling cycle (~30 seconds) for data to appear.
3. Check the source filter — make sure you're not filtering to an empty source.
### PBS backups showing but not TrueNAS snapshots (or vice versa)
Check the **Source** filter on the Recovery page. Each provider surfaces its recovery points independently. Clear all filters to see everything.
### Recovery points showing as "failed"
Click the row to expand the details drawer, which shows the provider-specific error message. Common causes:
- **PBS**: Datastore unreachable, verification failed, prune job errors
- **TrueNAS**: Replication target unreachable, dataset locked, insufficient space
- **PVE**: Backup storage full, vzdump process error
## See Also
- [PBS Integration](PBS.md) — Proxmox Backup Server monitoring
- [TrueNAS Integration](TRUENAS.md) — TrueNAS snapshot and replication monitoring
- [Unified Resource Model](UNIFIED_RESOURCES.md) — how recovery integrates with the unified model

120
docs/RELAY.md Normal file
View file

@ -0,0 +1,120 @@
# Relay / Mobile Remote Access (Relay and Above)
Pulse Relay provides **end-to-end encrypted remote access** foundations for Pulse instances. It allows secure remote connectivity without exposing your Pulse server to the public internet.
> Mobile app status: public rollout is coming soon. Relay remains available now for early-access/beta onboarding.
## How It Works
```text
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Mobile │◄──E2E──►│ Relay │◄──WSS──►│ Pulse │
│ App │ ECDH │ Server │ │ Server │
└──────────┘ └──────────────┘ └──────────┘
```
1. Your Pulse server maintains a persistent WebSocket connection to the relay server.
2. A mobile client connects to the relay server and authenticates.
3. An ECDH key exchange creates a per-channel encryption key.
4. All monitoring data is encrypted end-to-end — the relay server **never sees plaintext data**.
## Quick Start
1. Go to **Settings → Relay**.
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** when mobile beta access is enabled.
4. Your paired mobile client connects through relay.
## Requirements
- **Relay, Pro, Pro+, or Cloud license** — relay is gated by the `relay` feature key.
- **Outbound WebSocket** — Pulse must be able to reach `relay.pulserelay.pro` (port 443).
- **No inbound ports** — you do not need to open any ports on your firewall.
## Security
Relay was designed with a zero-trust model:
| Property | Detail |
|---|---|
| **Encryption** | End-to-end ECDH key exchange per channel |
| **Plaintext** | Relay server never sees your monitoring data |
| **Authentication** | Per-session mobile authentication |
| **Back-pressure** | Data limiters prevent channel flooding |
| **License-gated** | Requires an active Relay-or-higher license |
| **Configurable** | Can be enabled/disabled at any time via Settings |
| **Audit** | Relay connection events are logged to the audit trail |
## Configuration
### UI
**Settings → Relay** — toggle on/off, view QR code, and manage relay pairing sessions.
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay | `false` |
| `PULSE_RELAY_SERVER` | Override relay server URL | `relay.pulserelay.pro` |
### Storage
Relay configuration is stored encrypted in `relay.enc` in the Pulse data directory.
## API Reference
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| `GET` | `/api/settings/relay` | `settings:read` | Get relay status and config |
| `PUT` | `/api/settings/relay` | `settings:write` | Update relay settings |
| `POST` | `/api/onboarding/qr` | `settings:read` | Generate mobile onboarding QR code |
| `POST` | `/api/onboarding/deep-link` | `settings:read` | Generate mobile deep link |
## Mobile App Setup
### iOS / Android
1. Join mobile early access when available.
2. Open the app and tap **Connect to Server**.
3. Scan the QR code from **Settings → Relay** in your Pulse web UI.
4. The app connects via the relay and begins showing live data.
### Multiple Servers
The mobile app supports connecting to multiple Pulse instances. Each connection has its own encrypted channel.
## Troubleshooting
### Relay showing "Disconnected"
1. Confirm your Relay, Pro, Pro+, or Cloud license is active (**Settings → License**).
2. Verify the Pulse server can reach the relay server:
```bash
curl -s https://relay.pulserelay.pro/healthz
```
3. Check Pulse logs for relay errors:
```bash
journalctl -u pulse | grep -i relay
# or
docker logs pulse | grep -i relay
```
### Mobile app can't connect
1. Verify relay is enabled in **Settings → Relay**.
2. Confirm your mobile account has beta access.
3. Re-scan the QR code — sessions can expire.
4. Ensure your mobile device has internet access.
### Data not updating on mobile
1. Check the relay connection status in **Settings → Relay**.
2. Look for WebSocket reconnection messages in Pulse logs.
3. Restart the mobile app.
## See Also
- [Configuration Guide](CONFIGURATION.md#relay) — environment variables
- [Security](../SECURITY.md#relay-security-pro) — relay security details
- [Plans & Entitlements](PULSE_PRO.md) — feature availability by plan

View file

@ -5,3 +5,6 @@ Pulse release notes live on GitHub:
For historical v4 notes that previously lived in this repo, see:
`docs/releases/RELEASE_NOTES_v4.md`
For the in-repo v6 draft notes (work in progress), see:
`docs/releases/RELEASE_NOTES_v6.md`

92
docs/REPO_BOUNDARY.md Normal file
View file

@ -0,0 +1,92 @@
# Repo Boundary (Pulse v6)
This document defines where code should live as Pulse v6 is finalized.
## Ownership map
1. `pulse` (public): community/core product runtime and OSS-safe docs.
2. `pulse-enterprise` (private): paid in-app implementations (enterprise modules behind interfaces).
3. `pulse-pro` (private): backend/business infrastructure (`license-server`, `relay-server`, billing/support ops).
## Critical migration constraint
`pulse-enterprise` cannot import `pulse/internal/...` packages because of Go `internal` package visibility rules.
To move paid in-app logic out of this public repo, shared contracts must be exposed through `pkg/...` interfaces first, then implemented privately.
Current promoted contract surface:
- `pkg/licensing` (feature/tier constants, entitlement state enums, upgrade URL resolver, upgrade reason matrix, feature-gate interfaces, shared request/response types)
- `pkg/licensing` evaluator + entitlement source contracts (for capability/limit checks outside `internal/...`)
- `pkg/licensing` core license model types (`Claims`, `License`, `LicenseState`, `LicenseStatus`) for cross-repo contract stability
- `pkg/extensions` endpoint binding/runtime contracts for private enterprise handler ownership (`RBACAdminEndpoints`/`RBACAdminRuntime`, `AuditAdminEndpoints`/`AuditAdminRuntime`, `SSOAdminEndpoints`/`SSOAdminRuntime` including provider-test + metadata-preview DTO/runtime callbacks, SSO config snapshot/public URL callbacks, SSO mutation callbacks, and SSO CRUD runtime dispatch hooks, `ReportingAdminEndpoints`/`ReportingAdminRuntime` including engine/org/error + filename callbacks and DTO-based state/backup runtime callbacks for private enrichment ownership)
## Current boundary audit
Run:
```bash
./scripts/audit-private-boundary.sh
```
Enforce full boundary (non-zero exit on production paid-domain private implementation leakage):
```bash
./scripts/audit-private-boundary.sh --enforce
```
Enforce API import boundary only (non-zero exit if any non-test `internal/api/*.go` imports `internal/license/*`):
```bash
./scripts/audit-private-boundary.sh --enforce-api-imports
```
Enforce API root import boundary (non-zero exit if any non-test `internal/api/*.go` imports `internal/license`):
```bash
./scripts/audit-private-boundary.sh --enforce-api-root-imports
```
Enforce non-API runtime import boundary (non-zero exit if non-test runtime files import `internal/license`):
```bash
./scripts/audit-private-boundary.sh --enforce-nonapi-imports
```
Enforce API `pkg/licensing` bridge boundary (non-zero exit if non-test `internal/api/*.go` imports `pkg/licensing` outside `internal/api/licensing_bridge.go`):
```bash
./scripts/audit-private-boundary.sh --enforce-api-pkg-licensing-imports
```
Enforce paid-surface allowlist integrity (non-zero exit if allowlist references missing files):
```bash
./scripts/audit-private-boundary.sh --enforce-paid-surface-allowlist
```
The script reports paid-domain files in two categories:
- private implementation leakage (must be zero to pass `--enforce`)
- allowlisted paid-surface adapters (tracked in `scripts/repo-boundary-paid-surface.allowlist`)
Broad paid-domain discovery currently covers:
- `internal/license/...`
- paid-focused handlers in `internal/api/...` (`license`, `entitlement`, `billing`, `stripe`, `hosted`, `rbac`, `audit`, `reporting`, `sso`, `conversion`)
This is expected until extraction phases complete.
Current milestone:
- Non-test `internal/api/*.go` imports of `internal/license/*`: **0**
- API root imports of `internal/license`: **0**
- Non-API runtime imports of `internal/license`: **0**
- Production paid-domain private implementation leakage: **0**
## Safety requirements during extraction
1. Keep API contracts stable for current v5/v6 users.
2. Do not break `license.pulserelay.pro` behavior.
3. Keep JWT claim schema compatibility (`lid`, `email`, `tier`, `iat`, `exp` when applicable).
4. Move code in phases with tests green at every step.

View file

@ -1,16 +1,22 @@
# Pulse Screenshots
> **Note:** Screenshots below show the Pulse v6 interface with unified, task-based navigation. Your dashboard may look different from these examples depending on your infrastructure and configuration.
## Dashboard Overview (Dark Mode)
![Dashboard Overview](images/01-dashboard.jpg)
*Real-time monitoring dashboard showing 7 Proxmox nodes with 35 VMs and 56 containers. Color-coded resource usage (CPU, RAM, storage) with quick status indicators for running/stopped guests. Automatic layout adapts to cluster size - compact cards for 5-9 nodes. Professional dark theme optimized for 24/7 monitoring setups.*
*Real-time monitoring dashboard with summary panels showing aggregate CPU, memory, and storage usage across your entire fleet. The dashboard adapts its layout to the number of connected nodes and resources. Professional dark theme optimized for 24/7 monitoring setups.*
## Infrastructure
![Infrastructure View](images/02-storage.png)
*Unified Infrastructure page showing all hosts across Proxmox VE, Docker, Kubernetes, and TrueNAS in a single table. Hosts from multiple data sources are identity-matched and merged automatically. Filter by source, status, or search by name.*
## Storage Management
![Storage Management](images/02-storage.png)
*Comprehensive storage view displaying all storage pools across nodes with usage percentages, allocated vs used space, and visual indicators. Monitors local, ZFS, LVM, and network storage types in a unified interface.*
*Comprehensive storage view displaying all storage pools across Proxmox nodes and TrueNAS systems with usage percentages, allocated vs used space, and visual indicators. Monitors local, ZFS, LVM, Ceph, and network storage types in a unified interface.*
## Backup Central
![Unified Backup View](images/03-backups.png)
*Centralized backup management showing PBS backups, PVE backup tasks, and VM snapshots in one place. Track backup status, sizes, retention, and quickly identify failed or missing backups across your entire infrastructure. Time-range buttons (24h/7d/30d/custom) and the synchronized bar chart make it easy to focus on exactly the window you care about.*
## Recovery Central
![Unified Recovery View](images/03-backups.png)
*Centralized recovery management showing backups, snapshots, and replication points from PBS and TrueNAS in one place. Track outcomes, sizes, retention, and quickly identify failed or missing recovery points across your entire infrastructure. Time-range buttons (24h/7d/30d/custom) and the synchronized bar chart make it easy to focus on exactly the window you care about.*
## Alerts & Configuration
![Alerts and Configuration](images/04-alerts.png)
@ -18,12 +24,12 @@
## Alert History & Analytics
![Alert History](images/05-alert-history.png)
*Comprehensive alert history with frequency visualization showing 77 alerts over 28 time periods. Filter by severity (warnings, critical, info), search specific resources, and track resolution times. Visual timeline helps identify patterns and recurring issues.*
*Comprehensive alert history with frequency visualization. Filter by severity (warnings, critical, info), search specific resources, and track resolution times. Visual timeline helps identify patterns and recurring issues.*
## Settings & Node Management
![Node Configuration](images/06-settings.png)
*Manage Proxmox nodes and PBS instances through the UI. Add/remove nodes, configure credentials securely (encrypted at rest), set polling intervals, and manage authentication settings. Quick health check shows connection status for all nodes.*
*Manage Proxmox nodes, agent connections, and TrueNAS instances through the UI. Add/remove nodes, configure credentials securely (encrypted at rest), set polling intervals, and manage authentication settings. Quick health check shows connection status for all nodes.*
## Mobile Responsive Design
![Mobile View](images/08-mobile.png)
*Fully responsive mobile interface for monitoring on the go. Touch-optimized controls, collapsible navigation, and adaptive layouts ensure full functionality on smartphones and tablets without compromising usability.*
*Fully responsive mobile interface with a dedicated bottom tab bar for touch navigation. Supports mobile remote access via the relay protocol (Pro feature) for secure monitoring on the go. Touch-optimized controls, adaptive layouts, and the command palette ensure full functionality on smartphones and tablets.*

View file

@ -1,62 +0,0 @@
# 📜 Script Library Guide
This guide explains the shared Bash modules in `scripts/lib/` used for building installer scripts.
## 📂 Structure
| File | Purpose |
| :--- | :--- |
| `common.sh` | Logging, error handling, retry helpers, temp dirs. |
| `http.sh` | Curl/wget wrappers, GitHub release helpers. |
| `systemd.sh` | Systemd unit management helpers. |
**Conventions:**
* **Namespaces:** Functions are exported as `module::function` (e.g., `common::run`).
* **Bundling:** `./scripts/bundle.sh` inlines modules for distribution.
* **Compatibility:** Targets Bash 5 on Debian 11+ and Ubuntu LTS.
## 🦴 Script Skeleton
```bash
#!/usr/bin/env bash
set -euo pipefail
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" && pwd)"
# shellcheck source=../../scripts/lib/common.sh
source "${LIB_DIR}/common.sh"
# shellcheck source=../../scripts/lib/systemd.sh
source "${LIB_DIR}/systemd.sh"
common::init "$@"
common::require_command curl tar
main() {
common::log_info "Starting installer..."
common::temp_dir WORKDIR --prefix pulse-
http::download --url "${URL}" --output "${WORKDIR}/pulse.tar.gz"
systemd::create_service /etc/systemd/system/pulse.service <<'UNIT'
[Unit]
Description=Pulse Monitoring
UNIT
systemd::enable_and_start pulse.service
}
main "$@"
```
## 🛠️ Best Practices
* **Logging:** Use `common::log_info`, `common::log_warn`, etc. They respect `PULSE_LOG_LEVEL`.
* **Dry Run:** Wrap mutating commands in `common::run` to support `--dry-run`.
* **Testing:** Use `scripts/tests/run.sh` for linting and `scripts/tests/integration/` for scenarios.
## 📦 Bundling
1. Update `scripts/bundle.manifest`.
2. Run `./scripts/bundle.sh`.
3. Verify `dist/` artifacts.
**Note:** Never edit bundled artifacts manually. Always rebuild from source.

View file

@ -0,0 +1,471 @@
# Storage Architecture Proposal
This document defines the intended storage model for Pulse beyond the current "show storage resources and raw S.M.A.R.T. fields" behavior.
The goal is to make storage genuinely useful for operators, not merely visible.
## Problem
Today Pulse can surface storage-adjacent data from several sources:
- Proxmox storage pools
- Proxmox physical disks
- Ceph
- host-agent disk inventories
- host-agent S.M.A.R.T. data
- TrueNAS pools/datasets/disks
That is useful, but it is not yet a coherent storage product.
The current gaps are:
- disk data is source-shaped instead of operator-shaped
- S.M.A.R.T. attributes are visible, but risk is not modeled
- topology is weak: disk -> pool/array/host/workload impact is incomplete
- agent-only hosts need first-class storage treatment, not second-class fallback behavior
- storage alerting is mostly threshold-oriented rather than consequence-oriented
## Product Principle
Operators do not want "S.M.A.R.T. monitoring."
They want answers to:
- Which disks are at risk?
- Which pools/arrays are at risk because of those disks?
- Is redundancy still intact?
- Is this getting worse?
- What needs action now?
Pulse should therefore treat S.M.A.R.T. as one input signal inside a broader storage health model.
## Primary User Jobs
### Homelab / power users
- Identify failing disks before data loss
- See parity/cache/array issues clearly
- Map a bad disk to a specific device/serial/path
- Understand whether replacement is urgent or watch-only
### SMB / business operators
- See storage risk by host, cluster, site, and business impact
- Know whether backup targets and primary storage remain healthy
- Detect degraded redundancy, not just degraded disks
- Track long-term degradation trends and maintenance windows
## Canonical Storage Model
Pulse should model storage in four layers.
### 1. Physical Disk
This is the actual block device.
Canonical resource type:
- `physical_disk`
Identity signals, strongest first:
- serial
- WWN / EUI
- controller-specific stable disk ID
- source-scoped fallback `(host, device path)`
Core fields:
- serial, WWN, device path
- model, vendor, firmware
- transport / type (`sata`, `sas`, `nvme`, `usb`, etc.)
- size
- health / risk / confidence
- temperature
- wear indicators
- media / pending / reallocated / CRC / unsafe-shutdown style counters
- telemetry freshness
### 2. Storage Membership
This is the topology layer.
A disk is often only meaningful in context:
- member of mdraid array
- member of ZFS vdev/pool
- Unraid parity/data/cache assignment
- Ceph OSD backing device
- PBS datastore backing disk set
Pulse should model storage membership as first-class relationships, not implicit text fields.
Examples:
- disk -> host
- disk -> array
- disk -> pool
- disk -> OSD
- pool -> workloads
- datastore -> backup jobs / recovery points
### 3. Logical Storage Object
These are the operator-facing objects:
- pool
- datastore
- filesystem
- dataset
- share
- Ceph cluster / pool
- backup repository
Canonical resource types already mostly exist:
- `storage`
- `datastore`
- `ceph`
These resources should carry:
- capacity
- health
- redundancy state
- rebuild/resilver/scrub state
- impacted children
### 4. Consumer Impact
This is the "why should I care" layer.
Storage objects should be traceable to:
- VMs
- LXCs
- app containers / pods
- backup jobs
- recovery points
This allows Pulse to answer:
- a degraded mirror affects these VMs
- this backup datastore is filling and will affect these protection jobs
- this failed disk left this array with no redundancy
## S.M.A.R.T. Model
### Raw telemetry
Pulse should ingest raw S.M.A.R.T. data when available, including vendor-specific subsets.
Raw attributes remain important in the detail view, but they should not be the primary UX.
### Derived model
Pulse should derive a normalized disk health model from raw telemetry:
- `health_state`
- healthy
- watch
- degraded
- critical
- unknown
- `risk_score`
- 0-100
- `confidence`
- low / medium / high
- `reason_codes`
- `pending_sectors_nonzero`
- `reallocated_sectors_rising`
- `nvme_spare_low`
- `temperature_sustained_high`
- `smart_failed`
- `telemetry_missing`
### Trend model
Current values are not enough.
Pulse should preserve time series for:
- temperature
- reallocated sectors
- pending sectors
- media errors
- NVMe percentage used
- available spare
- unsafe shutdowns
Trend direction matters:
- stable
- improving
- slowly worsening
- sharply worsening
## Source Strategy
### Proxmox
Use Proxmox for:
- storage pools
- physical disks when available
- Ceph
- host/node topology
Use agent linkage to enrich Proxmox disks with:
- better temperature coverage
- richer S.M.A.R.T. attributes
- better device identity
### Unified host agent
The host agent must be a first-class storage source, not only an enrichment source.
For agent-backed hosts, Pulse should directly create:
- `physical_disk` resources from agent S.M.A.R.T.
- logical storage resources when the agent can report them
- storage topology when the platform supports it
This matters for:
- Unraid
- generic Linux servers
- bare-metal NAS boxes
- non-Proxmox storage hosts
### Unraid
Unraid deserves explicit treatment, not generic-Linux treatment forever.
Pulse should ultimately understand:
- array state
- parity devices
- cache pools
- disk disabled / missing / emulated state
- rebuild progress
- filesystem status
- share impact
Initial fallback can still be generic host-agent disk ingestion, but the end state should be Unraid-aware topology.
### ZFS / TrueNAS
Pulse should normalize:
- pool health
- vdev health
- read/write/checksum errors
- scrub status and age
- resilver status and age
- per-disk membership
### Generic Linux
Even without a rich platform API, Pulse should still provide value:
- agent physical disks
- mdraid state if available
- mount/device correlation
- filesystem usage
- telemetry coverage warnings
## Alerts
Storage alerts should be layered.
### Disk alerts
Examples:
- S.M.A.R.T. failed
- pending sectors non-zero
- reallocated sectors rising
- NVMe spare below threshold
- sustained high temperature
### Redundancy alerts
Examples:
- pool degraded but still redundant
- array has lost redundancy
- parity invalid / parity missing
- OSD count below safe threshold
### Capacity alerts
Examples:
- pool nearing full
- backup datastore nearing full
- cache pool under pressure
### Telemetry coverage alerts
Examples:
- disk telemetry missing for previously known disk
- controller blocks S.M.A.R.T. visibility
- host stopped reporting disk inventory
This category is important because silent storage blind spots are dangerous.
## UX Proposal
The storage surface should be organized around three questions.
### 1. What is at risk?
Top-level storage page should prioritize:
- disks needing attention
- degraded pools/arrays
- rebuilds/resilvers in progress
- backup repositories at risk
### 2. Where is the risk?
Every disk or pool should show context:
- host
- platform
- array / pool / vdev / parity role
- impacted workloads / backups
### 3. What should I do?
Each finding should have a recommended action:
- replace now
- schedule maintenance
- monitor trend
- investigate controller / cable / cooling
- improve telemetry coverage
## Recommended Page Structure
### Fleet summary
- disks at risk
- degraded storage objects
- active rebuild/resilver operations
- storage capacity hotspots
### Disk view
Grouped and filterable by:
- host
- pool / array
- risk state
- platform
- disk type
Columns:
- device / serial
- host
- role
- health
- risk
- temperature
- wear
- trend
- last seen
### Topology view
For a selected disk:
- parent host
- array / pool / vdev membership
- redundancy state
- affected storage objects
- affected workloads / backups
### Detail drawer
Include:
- normalized summary
- risk reasons
- trend charts
- raw S.M.A.R.T. attributes
- source provenance
- telemetry freshness
## Data Model Requirements
The canonical unified resource model should support:
- `physical_disk` from every valid source
- disk identity merge across sources
- parent/child relationships between host, disk, pool, workload
- source provenance per disk field when signals disagree
- storage topology edges, not just flat metadata blobs
- freshness per source and per sub-signal
## Rollout Plan
### Phase 1: Canonical disk coverage
- ensure every agent-backed host can emit `physical_disk`
- unify disk identity across agent / Proxmox / TrueNAS sources
- show agent-only disks in storage
- attach disk metrics targets consistently
### Phase 2: Disk health model
- add derived S.M.A.R.T. health / risk / confidence
- add reason codes
- add telemetry freshness semantics
- improve disk alerts
### Phase 3: Topology
- model disk -> pool/array/vdev membership
- model redundancy state
- propagate impact to workloads / backups
### Phase 4: Platform specialization
- Unraid-aware storage model
- deeper ZFS / TrueNAS topology
- mdraid normalization
- controller-specific enrichments where feasible
### Phase 5: Operator UX
- risk-first storage landing page
- action-oriented recommendations
- maintenance-friendly detail workflows
## Near-Term Priority
If I were sequencing this immediately, I would prioritize:
1. agent-only physical disk coverage
2. canonical disk identity merge by serial / WWN
3. disk metrics and S.M.A.R.T. trend persistence for agent-backed disks
4. derived disk risk model
5. topology edges for arrays/pools/parity
That gives Pulse a strong storage foundation before investing in more UI complexity.
## Definition of "Useful"
Pulse storage is useful when an operator can answer, in under a minute:
- what is unhealthy
- what is merely noisy
- what is losing redundancy
- what will impact workloads or backups
- what needs action now
If the user still has to mentally decode raw S.M.A.R.T. tables to get there, the storage model is not finished.

View file

@ -33,7 +33,7 @@ Pulse can also collect temperatures by SSHing into each host and running `sensor
### Setup
1. Generate the node setup command from the UI:
**Settings -> Proxmox -> Add Node**
**Settings -> Infrastructure -> Add Node**
2. Run the command on each Proxmox host. The setup script can:
- Create the required API user and permissions
- Add a restricted SSH key entry for temperature collection
@ -103,21 +103,3 @@ sudo sed -i '/# pulse-managed-key$/d;/# pulse-proxy-key$/d' /root/.ssh/authorize
```
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.
### LXC Container Config Cleanup
The v4 installer also added mount entries for `/run/pulse-sensor-proxy` to the LXC container config (`/etc/pve/lxc/<ctid>.conf`). After a host reboot, `/run` is cleared and the mount source no longer exists, which prevents the container from starting. To check for and remove stale entries:
```bash
# Check for stale sensor-proxy mount entries
grep -n 'pulse-sensor-proxy' /etc/pve/lxc/*.conf
# Remove mp<N> entries (container must be stopped)
# Replace mp0 with the actual key shown in the grep output (mp0, mp1, etc.)
pct set <ctid> -delete mp0
# Remove lxc.mount.entry lines
sed -i '/lxc\.mount\.entry:.*pulse-sensor-proxy/d' /etc/pve/lxc/<ctid>.conf
```
Re-running the Pulse installer on the Proxmox host also performs this cleanup automatically.

View file

@ -46,12 +46,12 @@ sudo pulse bootstrap-token
#### Audit Log verification shows unsigned events
- **Symptom**: Audit Log entries show “Unsigned” or verification fails in the UI.
- **Root cause**: Audit signing is disabled (crypto manager unavailable), so events are stored without signatures.
- **Fix**: Ensure `.encryption.key` is present and Pulse Pro audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned.
- **Fix**: Ensure `.encryption.key` is present and Pro/Pro+/Cloud audit logging is enabled, then restart Pulse to regenerate `.audit-signing.key`. Newly created events will be signed; existing unsigned events remain unsigned.
#### Audit Log is empty
- **Symptom**: Audit Log shows zero events or "Console Logging Only."
- **Root cause**: OSS build uses console logging only, or Pulse Pro audit logging is not enabled.
- **Fix**: Use Pulse Pro with audit logging enabled, then generate new audit events (logins, token creation, password changes).
- **Root cause**: Community plan uses console logging only, or Pro/Pro+/Cloud audit logging is not enabled.
- **Fix**: Use Pro, Pro+, or Cloud with audit logging enabled, then generate new audit events (logins, token creation, password changes).
#### Audit Log verification fails for older events
- **Symptom**: Older events fail verification while newer events pass.
@ -88,6 +88,33 @@ sudo pulse bootstrap-token
- If targeting private IPs, allow them in **Settings → System → Network → Webhook Security**.
- Check Pulse logs for HTTP status codes and response bodies.
### TrueNAS
#### "TrueNAS service unavailable"
- Ensure TrueNAS was added in **Settings → TrueNAS** with a valid URL and API key.
- Check that the TrueNAS system is reachable from the Pulse server (default HTTPS port).
- Verify the API key has read access. Test with:
```bash
curl -sk -H "Authorization: Bearer <api-key>" https://<truenas-ip>/api/v2.0/system/info
```
#### TrueNAS pools/datasets not appearing
- TrueNAS data appears in the unified resource model and may take one polling cycle (30s) to appear.
- Check **Infrastructure** (TrueNAS host), **Storage** (pools/datasets), and **Recovery** (snapshots/replication).
### Navigation (v6)
#### Old bookmarks don't work
- Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) are not supported in v6.
- Update bookmarks to canonical routes. See [Migration Guide](MIGRATION_UNIFIED_NAV.md).
### Relay / Mobile
#### Relay showing "Disconnected"
- Confirm a valid Relay, Pro, Pro+, or Cloud license is active (**Settings → License**).
- Check Pulse server can reach the relay server (outbound WebSocket to `relay.pulserelay.pro`).
- Review logs: `journalctl -u pulse | grep relay` or `docker logs pulse | grep relay`.
---
## 🛠️ Advanced Diagnostics
@ -113,7 +140,14 @@ At minimum, ensure the user/token has read access for inventory and metrics:
- `VM.Monitor`
- `Datastore.Audit`
For VM disk usage via QEMU guest agent, also ensure `VM.GuestAgent.Audit` (PVE 9+).
For VM guest agent features on PVE 9+, also ensure:
- `VM.GuestAgent.Audit` — required for disk usage and guest info
- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache)
Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token.
**Rocky Linux / RHEL VMs**: The default qemu-guest-agent configuration may block file-read RPCs (`guest-file-open`, `guest-file-read`, `guest-file-close`). If memory or disk data is missing for these VMs, check `/etc/sysconfig/qemu-ga` and ensure those operations are not blocked, then restart the agent. Refer to your distro's qemu-guest-agent documentation for the exact config syntax.
### Recovery Mode
If you are completely locked out, you can trigger a recovery token from localhost:

126
docs/TRUENAS.md Normal file
View file

@ -0,0 +1,126 @@
# TrueNAS Integration
Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS CORE** systems. TrueNAS data flows through the unified resource model, appearing alongside Proxmox, Docker, Kubernetes, and host agent data throughout the UI.
## Quick Start
1. Go to **Settings → TrueNAS**.
2. Click **Add Connection**.
3. Enter the TrueNAS URL (e.g., `https://truenas.local`) and an API key.
4. Click **Test Connection****Save**.
5. Data appears within one polling cycle (~30 seconds).
## Creating a TrueNAS API Key
On your TrueNAS system:
1. Navigate to **Settings → API Keys** (SCALE) or **System → API Keys** (CORE).
2. Click **Add** and create a new key.
3. Copy the key value and paste it into Pulse.
> **Tip**: A read-only key is sufficient for monitoring. Pulse does not write to TrueNAS.
## What Gets Monitored
| Data | Unified Page | Details |
|---|---|---|
| System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status |
| ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) |
| ZFS Datasets | Storage | Used/available space, mount status, read-only flag |
| Physical Disks | Storage | Model, serial, size, transport type, rotational flag |
| ZFS Snapshots | Recovery | Dataset, creation time, size, referenced data |
| Replication Tasks | Recovery | Source/target datasets, direction, last run status |
| TrueNAS Alerts | Alerts | Native TrueNAS alert messages and severity levels |
## Unified Resource Mapping
TrueNAS resources are mapped into the unified resource model:
- **TrueNAS host** → appears as a resource with `source: truenas` on the **Infrastructure** page.
- **ZFS pools and datasets** → appear on the **Storage** page.
- **ZFS snapshots and replication** → appear on the **Recovery** page as recovery points.
- **TrueNAS alerts** → surfaced on the **Alerts** page alongside Proxmox and other platform alerts.
Resources from TrueNAS can be filtered using the **source** filter on any page.
## Multiple TrueNAS Systems
Add as many TrueNAS connections as needed. Each connection is polled independently. Resources from all connected systems are merged into the unified view.
## Configuration
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `PULSE_ENABLE_TRUENAS` | Enable/disable TrueNAS integration | `true` |
### Storage
TrueNAS connection credentials are stored encrypted in `truenas.enc` in the Pulse data directory (`/etc/pulse` or `/data`).
## API Reference
All endpoints require admin authentication.
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/truenas/connections` | List all configured TrueNAS connections |
| `POST` | `/api/truenas/connections` | Add a new TrueNAS connection |
| `DELETE` | `/api/truenas/connections/{id}` | Remove a TrueNAS connection |
| `POST` | `/api/truenas/connections/test` | Test a connection before saving |
### Adding a connection (API)
```bash
curl -X POST http://localhost:7655/api/truenas/connections \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"nas-1","host":"https://truenas.local","api_key":"your-api-key"}'
```
### Testing a connection (API)
```bash
curl -X POST http://localhost:7655/api/truenas/connections/test \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"nas-1","host":"https://truenas.local","api_key":"your-api-key"}'
```
## Troubleshooting
### "TrueNAS service unavailable"
- Check that the TrueNAS system is reachable from the Pulse server.
- Verify the URL includes the protocol (`https://`).
- Test connectivity manually:
```bash
curl -sk -H "Authorization: Bearer <api-key>" https://<truenas-ip>/api/v2.0/system/info
```
### No data appearing after adding connection
- Wait at least 30 seconds for the first poll cycle.
- Check Pulse logs for TrueNAS-related errors:
```bash
journalctl -u pulse | grep -i truenas
# or
docker logs pulse | grep -i truenas
```
### Stale TrueNAS data
- If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds.
- Check TrueNAS connectivity and API key validity.
- Verify with the API:
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:7655/api/resources \
| jq '.resources[] | select(.platformType == "truenas")'
```
### Disabling TrueNAS integration
Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is preserved but polling stops.
## See Also
- [Configuration Guide](CONFIGURATION.md#truenas) — environment variables and setup
- [ZFS Monitoring](ZFS_MONITORING.md) — Proxmox-native ZFS pool monitoring
- [Recovery](RECOVERY.md) — TrueNAS snapshots in the recovery view

View file

@ -1,13 +1,16 @@
# Pulse Unified Agent
The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management.
The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces older split-agent installs with one deployment and one service for simpler operations.
Install it on each host you want Pulse to monitor. This is the primary monitoring path for infrastructure onboarding.
> Note: For temperature monitoring, use `pulse-agent --enable-proxmox` (recommended) or SSH-based collection. The legacy sensor proxy has been removed. See `docs/TEMPERATURE_MONITORING.md`.
## Quick Start
Generate an installation command in the UI:
**Settings → Agents → Installation commands**
**Settings → Unified Agents → Installation commands**
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
### Linux (systemd)
```bash
@ -56,50 +59,33 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
## Configuration
### Installer flags (`install.sh`)
These flags are accepted by the install script (i.e. `curl ... | bash -s -- <flags>`). The installer passes the relevant options through to the agent's service definition.
| Flag | Env Var | Description | Default |
|------|---------|-------------|---------|
| `--url` | `PULSE_URL` | Pulse server URL | `http://localhost:7655` |
| `--token` | `PULSE_TOKEN` | API token | *(required)* |
| `--token-file` | - | Read API token from file | *(unset)* |
| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` |
| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` |
| `--disable-host` | - | Disable host metrics | - |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | auto-detect |
| `--disable-docker` | - | Disable Docker monitoring even if detected | - |
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | auto-detect |
| `--disable-kubernetes` | - | Disable Kubernetes monitoring even if detected | - |
| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (also enables Kubernetes) | *(auto)* |
| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` (auto-detect if not configured) |
| `--docker-runtime` | `PULSE_DOCKER_RUNTIME` | Force container runtime: `auto`, `docker`, or `podman` | `auto` |
| `--enable-kubernetes` | `PULSE_ENABLE_KUBERNETES` | Enable Kubernetes metrics | `false` (installer auto-detect if not configured) |
| `--enable-proxmox` | `PULSE_ENABLE_PROXMOX` | Enable Proxmox integration | `false` |
| `--proxmox-type` | `PULSE_PROXMOX_TYPE` | Proxmox type: `pve` or `pbs` | *(auto-detect)* |
| `--enable-commands` | `PULSE_ENABLE_COMMANDS` | Enable AI command execution (disabled by default) | `false` |
| `--disable-commands` | `PULSE_DISABLE_COMMANDS` | **Deprecated** (commands are disabled by default) | - |
| `--disk-exclude` | `PULSE_DISK_EXCLUDE` | Mount point patterns to exclude from disk monitoring (repeatable or CSV) | *(none)* |
| `--kubeconfig` | `PULSE_KUBECONFIG` | Kubeconfig path (optional) | *(auto)* |
| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context (optional) | *(auto)* |
| `--kube-include-namespace` | `PULSE_KUBE_INCLUDE_NAMESPACES` | Limit namespaces (repeatable or CSV, wildcards supported) | *(all)* |
| `--kube-exclude-namespace` | `PULSE_KUBE_EXCLUDE_NAMESPACES` | Exclude namespaces (repeatable or CSV, wildcards supported) | *(none)* |
| `--kube-include-all-pods` | `PULSE_KUBE_INCLUDE_ALL_PODS` | Include all non-succeeded pods | `false` |
| `--kube-include-all-deployments` | `PULSE_KUBE_INCLUDE_ALL_DEPLOYMENTS` | Include all deployments, not just problems | `false` |
| `--enable-proxmox` | `PULSE_ENABLE_PROXMOX` | Enable Proxmox integration | auto-detect |
| `--disable-proxmox` | - | Disable Proxmox integration even if detected | - |
| `--proxmox-type` | `PULSE_PROXMOX_TYPE` | Proxmox type: `pve` or `pbs` | *(auto-detect)* |
| `--enable-commands` | `PULSE_ENABLE_COMMANDS` | Enable AI command execution | `false` |
| `--disk-exclude` | `PULSE_DISK_EXCLUDE` | Mount point patterns to exclude (repeatable) | *(none)* |
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--cacert` | - | Custom CA certificate path for TLS | *(none)* |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
| `--agent-id` | `PULSE_AGENT_ID` | Unique agent identifier | *(machine-id)* |
| `--env` | - | Set custom env var in the service file (repeatable) | *(none)* |
| `--uninstall` | - | Remove the agent | - |
### Agent-only flags
These flags are accepted by the `pulse-agent` binary directly but are **not** available via the install script. Set them via environment variables in the service file, or pass them when running the agent manually.
| Flag | Env Var | Description | Default |
|------|---------|-------------|---------|
| `--token-file` | - | Read API token from file | *(unset)* |
| `--docker-runtime` | `PULSE_DOCKER_RUNTIME` | Force container runtime: `auto`, `docker`, or `podman` | `auto` |
| `--kube-context` | `PULSE_KUBE_CONTEXT` | Kubeconfig context | *(auto)* |
| `--kube-include-namespace` | `PULSE_KUBE_INCLUDE_NAMESPACES` | Limit namespaces (repeatable or CSV, wildcards) | *(all)* |
| `--kube-exclude-namespace` | `PULSE_KUBE_EXCLUDE_NAMESPACES` | Exclude namespaces (repeatable or CSV, wildcards) | *(none)* |
| `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` |
| `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` |
| `--disable-docker-update-checks` | `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker image update detection | `false` |
| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` |
| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* |
| `--agent-id` | `PULSE_AGENT_ID` | Unique agent identifier | *(machine-id)* |
| `--report-ip` | `PULSE_REPORT_IP` | Override reported IP (multi-NIC) | *(auto)* |
| `--disable-ceph` | `PULSE_DISABLE_CEPH` | Disable local Ceph status polling | `false` |
| `--tag` | `PULSE_TAGS` | Apply tags (repeatable or CSV) | *(none)* |
@ -124,9 +110,9 @@ Auto-detection behavior:
To disable auto-detection, explicitly set the relevant flags or env vars, for example:
- `--disable-docker` or `PULSE_ENABLE_DOCKER=false`
- `--disable-kubernetes` or `PULSE_ENABLE_KUBERNETES=false`
- `--disable-proxmox` or `PULSE_ENABLE_PROXMOX=false`
- `--enable-docker=false` or `PULSE_ENABLE_DOCKER=false`
- `--enable-kubernetes=false` or `PULSE_ENABLE_KUBERNETES=false`
- `--enable-proxmox=false` or `PULSE_ENABLE_PROXMOX=false`
## Installation Options
@ -136,6 +122,18 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token>
```
### Proxmox VE Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pve
```
### Proxmox Backup Server Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pbs
```
### Force Enable Docker (if auto-detection fails)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
@ -145,7 +143,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
### Disable Docker (even if detected)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-docker
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
```
### Host + Kubernetes Monitoring
@ -157,7 +155,7 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-host --enable-docker
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
```
### Exclude Specific Disks from Monitoring
@ -216,23 +214,23 @@ The unified agent automatically checks for updates every hour. When a new versio
To disable auto-updates:
```bash
# During installation (inject the env var into the service file)
# During installation
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --env PULSE_DISABLE_AUTO_UPDATE=true
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
# Or when running the agent directly
pulse-agent --disable-auto-update
# Or set environment variable
PULSE_DISABLE_AUTO_UPDATE=true
```
## Remote Configuration (Agent Profiles, Pro)
## Remote Configuration (Agent Profiles, Pro/Pro+/Cloud)
Pulse Pro can push centralized settings to agents via Agent Profiles.
Pro, Pro+, and Cloud can push centralized settings to agents via Agent Profiles.
Behavior:
- The agent fetches remote config on startup from `/api/agents/host/{agent_id}/config`.
- The agent fetches remote config on startup from `/api/agents/agent/{agent_id}/config`.
- Profile settings override local flags/env for supported keys.
- Profile changes take effect on the next agent restart.
- Command execution (`commandsEnabled`) is controlled per agent in **Settings → Agents → Unified Agents** and can change live.
- Command execution (`commandsEnabled`) is controlled per agent in **Settings → Unified Agents** and can change live.
- Remote config responses can be signed with `PULSE_AGENT_CONFIG_SIGNING_KEY` (base64 Ed25519 private key).
- To require signed payloads, set `PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true` on Pulse and agents.
- If you use a custom signing key, set `PULSE_AGENT_CONFIG_PUBLIC_KEYS` on agents to trust the matching public key.
@ -248,16 +246,10 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | bash -s -- --uninstall
This removes:
- The agent binary
- The systemd/launchd service
- Any legacy agents (pulse-host-agent, pulse-docker-agent)
## Migration from Legacy Agents
## Migration Notes
The install script automatically removes legacy agents when installing the unified agent:
- `pulse-host-agent` service is stopped and removed
- `pulse-docker-agent` service is stopped and removed
- Binaries are deleted from `/usr/local/bin/`
No manual cleanup is required.
Use the unified installer (`install.sh`) for all new and existing deployments.
## Health Checks & Metrics
@ -307,15 +299,15 @@ Set `--health-addr=""` or `PULSE_HEALTH_ADDR=""` to disable the health/metrics s
- Verify network connectivity to Pulse server
- Ensure auto-update is not disabled
### Duplicate Hosts
If cloned VMs appear as the same host:
### Duplicate Agents
If cloned VMs appear as the same agent:
```bash
sudo rm /etc/machine-id && sudo systemd-machine-id-setup
```
Or set a unique agent ID:
```bash
--agent-id my-unique-host-id
--agent-id my-unique-agent-id
```
### Permission Denied (Docker)
@ -365,16 +357,16 @@ If your Docker Swarm cluster isn't being detected:
LOG_LEVEL=debug journalctl -u pulse-agent -f
```
### PVE Backups Not Showing
### PVE Backups Not Showing (Recovery)
If local PVE backups aren't appearing in Pulse after setting up via `--enable-proxmox`:
1. **Check permissions**: The API token needs `PVEDatastoreAdmin` on `/storage`:
```bash
pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin
```
2. **Re-run setup** (v5.1.x or later): Delete the node in Pulse Settings and re-run the agent with `--enable-proxmox`. Newer versions grant this permission automatically.
2. **Re-run setup**: Delete the node in Pulse Settings and re-run the agent with `--enable-proxmox`. Recent versions grant this permission automatically.
3. **Check state file**: If re-running doesn't trigger setup, remove the state file:
```bash

125
docs/UNIFIED_RESOURCES.md Normal file
View file

@ -0,0 +1,125 @@
# Unified Resource Model
Pulse v6 introduces a **unified resource model** that normalizes all monitored infrastructure — Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, Docker, host agents, Kubernetes, and TrueNAS — into a single, consistent data structure.
## Why Unified Resources?
In earlier versions, each platform had its own data model, API endpoints, and frontend pages. This created:
- Duplicate UI code for each platform
- Inconsistent filtering and search
- No cross-platform comparison
- Separate alert logic per platform
The unified model eliminates this by representing **every resource** as a single `Resource` struct with a common set of fields plus optional platform-specific extensions.
## Core Concepts
### Resource
Every monitored entity is a `Resource` with:
| Field | Description |
|---|---|
| `id` | Globally unique identifier |
| `name` | Display name |
| `type` | `host`, `vm`, `container`, `storage`, `pool`, `dataset`, `disk`, `service`, `cluster`, `pod`, `deployment` |
| `status` | `online`, `warning`, `critical`, `offline`, `unknown` |
| `sources` | Array of contributing data sources (e.g., `["pve", "agent"]`) |
| `sourceStatus` | Per-source health status |
| `metrics` | CPU, memory, disk, network (when available) |
| Platform extensions | `.kubernetes`, `.truenas`, `.docker`, etc. |
### Sources
A single resource can be reported by **multiple sources**. For example, a Proxmox node might have data from both the PVE API and a host agent:
```
sources: ["pve", "agent"]
sourceStatus:
pve: { status: "online", lastSeen: "..." }
agent: { status: "online", lastSeen: "..." }
```
The aggregate `status` is computed from all contributing sources.
### Data Sources
| Source | What it feeds |
|---|---|
| `pve` | Proxmox VE API — nodes, VMs, containers, storage |
| `pbs` | Proxmox Backup Server — datastores, backups, sync jobs |
| `pmg` | Proxmox Mail Gateway — mail stats, cluster health |
| `agent` | Unified agent — host metrics, temperatures, S.M.A.R.T. |
| `docker` | Docker/Podman — containers, images, networks |
| `kubernetes` | Kubernetes — clusters, nodes, pods, deployments |
| `truenas` | TrueNAS — system info, ZFS pools, datasets, snapshots, replication |
## Unified Navigation
The v6 UI organises pages by **task** instead of **platform**:
| Page | What it shows |
|---|---|
| **Dashboard** | Overview panels aggregating all sources |
| **Infrastructure** | All hosts: Proxmox nodes, Docker hosts, K8s nodes, TrueNAS systems, agent-only hosts |
| **Workloads** | All workloads: VMs, LXC containers, Docker containers, Kubernetes pods |
| **Storage** | All storage: Proxmox storage, PBS datastores, ZFS pools/datasets, Ceph |
| **Recovery** | All backup/snapshot artifacts: PBS backups, PVE local dumps, ZFS snapshots, replication |
| **Alerts** | Unified alert view across all platforms |
Every page supports **source filtering** — click a source badge to see only resources from that platform.
### Legacy Route Compatibility
Legacy URLs redirect automatically with toast notifications:
| Legacy Route | Redirects To |
|---|---|
| `/proxmox/overview` | `/infrastructure` |
| `/hosts` | `/infrastructure?source=agent` |
| `/docker` | `/workloads?source=docker` |
| `/kubernetes` | `/infrastructure?source=kubernetes` |
| `/services`, `/mail` | `/infrastructure?source=pmg` |
See [Migration Guide](MIGRATION_UNIFIED_NAV.md) for the full mapping.
## API
### Primary Endpoint
```
GET /api/resources
```
Returns all unified resources. Supports query parameters:
| Parameter | Description |
|---|---|
| `type` | Filter by resource type (`host`, `vm`, `container`, etc.) |
| `source` | Filter by data source (`pve`, `docker`, `kubernetes`, etc.) |
| `status` | Filter by status (`online`, `warning`, `critical`, `offline`) |
| `search` | Full-text search across name, ID, tags |
### Resource Details
Individual resource details are available via the unified state WebSocket connection, which pushes real-time updates to the frontend.
## Frontend Architecture
The frontend uses SolidJS reactive selectors to derive views from the unified store:
- `useResources()` — access the full unified resource list
- `useInfrastructureResources()` — hosts filtered for the Infrastructure page
- `useWorkloadResources()` — VMs/containers/pods for the Workloads page
- `useStorageResources()` — storage pools/datasets for the Storage page
- `useRecoveryResources()` — backup/snapshot data for the Recovery page
These selectors read from a single SolidJS store that is updated in real-time via WebSocket.
## See Also
- [Architecture](../ARCHITECTURE.md) — system architecture overview
- [Migration Guide](MIGRATION_UNIFIED_NAV.md) — upgrading from platform-specific navigation
- [API Reference](API.md) — full API documentation
- [TrueNAS Integration](TRUENAS.md) — TrueNAS-specific details

View file

@ -4,7 +4,7 @@ This is a practical guide for upgrading an existing Pulse install to v5.
## Before You Upgrade
- Create an encrypted config backup: **Settings → System → Backups → Create Backup**
- Create an encrypted config backup: **Settings → System → Recovery → Create Backup** (older versions labeled this **Backups**)
- Confirm you can access the host/container console (for rollback and bootstrap token retrieval)
- Review the v5 release notes on GitHub before upgrading
@ -23,7 +23,7 @@ curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.s
sudo bash -s -- --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Agents → Installation commands**.
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Unified Agents → Installation commands**.
### Docker
@ -62,35 +62,6 @@ The `pulse-sensor-proxy` from v4 is no longer needed — temperature monitoring
Skipping this step will leave a selfheal timer running on the host that generates recurring `TASK ERROR` entries in the Proxmox task log.
#### LXC mount entry cleanup
If your Pulse LXC container fails to start after a host reboot with:
```
Failed to mount "/run/pulse-sensor-proxy" onto ".../mnt/pulse-proxy"
TASK ERROR: startup for container '<ctid>' failed
```
This means the v4 installer added a mount entry for `/run/pulse-sensor-proxy` to the container config. After reboot, `/run` (tmpfs) is cleared and the mount source no longer exists.
**Automatic fix:** Re-run the Pulse installer on the Proxmox host. It detects and removes stale sensor-proxy mount entries from all LXC container configs before proceeding.
**Manual fix:**
```bash
# Check which containers have stale entries
grep -n 'pulse-sensor-proxy' /etc/pve/lxc/*.conf
# Remove mp<N> entries via pct (container must be stopped)
# Replace mp0 with the actual key shown in the grep output (mp0, mp1, etc.)
pct set <ctid> -delete mp0
# Or remove lxc.mount.entry lines directly
sed -i '/lxc\.mount\.entry:.*pulse-sensor-proxy/d' /etc/pve/lxc/<ctid>.conf
```
After removing the stale entry, start the container with `pct start <ctid>`.
### Temperature monitoring in containers
If Pulse runs in a container and you are relying on SSH-based temperature collection, move to the agent or run Pulse on the host. SSH-based collection from containers is intended for dev/test only (use `PULSE_DEV_ALLOW_CONTAINER_SSH=true` if you must).
@ -114,7 +85,7 @@ This can happen if:
**Quick fix** (run on each Proxmox host):
```bash
pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin
```
**Alternative** (re-run setup):

102
docs/UPGRADE_v6.md Normal file
View file

@ -0,0 +1,102 @@
# Upgrade to Pulse v6
This guide covers practical upgrade steps for existing Pulse installs moving to v6.
## Before You Upgrade
- Create an encrypted config backup: **Settings → System → Recovery → Create Backup** (older versions labeled this **Backups**)
- Confirm you can access the host/container console (for rollback and bootstrap token retrieval)
- If you have any external integrations or scripts: review the **API Changes** section below
## Upgrade Paths
### systemd and Proxmox LXC installs
Preferred path:
- **Settings → System → Updates**
If you prefer CLI, use the official installer for the target version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
sudo bash -s -- --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Unified Agents → Installation commands**.
### Docker
```bash
docker pull rcourtman/pulse:latest
docker compose up -d
```
### Kubernetes (Helm)
```bash
helm repo update
helm upgrade pulse pulse/pulse -n pulse
```
## Post-Upgrade Checklist
- Confirm version: `GET /api/version`
- Confirm scheduler health: `GET /api/monitoring/scheduler/health`
- Confirm unified resources API is responding: `GET /api/resources`
- Confirm nodes are polling and no breakers are stuck open
- Confirm notifications still send (send a test)
- Confirm agents are connected (if used)
## Migration Notes (v6)
### Unified Navigation (Bookmarks and Deep Links)
Legacy page aliases have been removed. Use canonical unified routes only.
- Reference: `docs/MIGRATION_UNIFIED_NAV.md`
- Optional migration aid: enable the "Classic platform shortcuts" bar (Settings → System → General).
- Optional preference: switch to **Classic** navigation style (Settings → System → General). This is stored per browser.
### API Changes
Unified Resources is now the canonical model and endpoint family:
- Canonical: `/api/resources`
### License, Trial, and Entitlements
Pulse v6 feature gating is driven by the entitlements endpoint:
- `GET /api/license/entitlements`
For self-hosted v6, Pulse now sells monitored coverage by monitored system rather than by installed agent. Community includes 5 monitored systems, Relay includes 8, Pro includes 15, and Pro+ includes 50. Relay also raises history to 14 days, while Pro and Pro+ raise it to 90 days.
For self-hosted v6, `POST /api/license/trial/start` initiates hosted signup rather than minting a local trial directly. Pulse only reflects trial lifecycle entitlements after the hosted control plane returns a signed activation token to `/auth/trial-activate`.
If you are upgrading an existing free instance that already exceeds the new Community cap, Pulse should not hard-break monitoring on rollout day. During grace, existing monitoring continues and only newly added counted systems are blocked until you remove systems or upgrade.
#### v5 License Migration
Pulse v6 uses the activation/grant model for active licensing, but it can migrate valid Pulse v5 Pro and Lifetime JWT-style licenses.
- If you upgrade an existing v5 instance and Pulse finds a persisted v5 license with no v6 activation state yet, v6 will try to auto-exchange it on startup.
- If auto-exchange cannot complete, your old key is left in place and the instance will prompt you to retry activation manually.
- In the v6 license panel, you can paste either:
- a Pulse v6 activation key, or
- a valid Pulse v5 Pro/Lifetime license key, which Pulse will try to exchange automatically
- If the exchange service cannot complete the migration, retry from the v6 license panel or use the self-serve retrieval flow to fetch the current v6 activation key. Email is only a backup copy of that key.
- Existing paid v5 customers keep their grandfathered recurring continuity until cancellation. If they cancel and later return, current v6 pricing applies.
Practical recommendation:
- Before upgrading, keep console access available so you can retry activation from the v6 license panel if the exchange service is temporarily unavailable.
### Multi-Tenant (Opt-In)
Multi-tenant mode is opt-in and additionally license-gated:
- Enablement flag: `PULSE_MULTI_TENANT_ENABLED=true`
- Capability gate: `multi_tenant`
See any multi-tenant operational docs under `docs/architecture/` if you plan to run this mode.

View file

@ -17,14 +17,15 @@ Monitor actual disk usage inside your VMs using the QEMU Guest Agent.
## ⚙️ Requirements
* **QEMU Guest Agent**: Must be installed and running inside the VM.
* **Proxmox Permissions**: `VM.Monitor` (Proxmox 8) or `VM.GuestAgent.Audit` (Proxmox 9+).
* **Proxmox Permissions**: `VM.Monitor` (Proxmox 8) or `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (Proxmox 9+). Note: `PVEAuditor` is a built-in read-only role that cannot be modified — create a custom role instead.
## 🔧 Troubleshooting
| Issue | Solution |
| :--- | :--- |
| **Disk shows "-"** | Hover over the dash for details. Common causes: Agent not running, disabled in config, or permission denied. |
| **Permission Denied** | Ensure your Proxmox token/user has `VM.GuestAgent.Audit` (PVE 9+) or `VM.Monitor` (PVE 8). |
| **Permission Denied** | Ensure your Proxmox token/user has `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (PVE 9+) or `VM.Monitor` (PVE 8). |
| **Rocky Linux / RHEL: memory or disk data missing** | The default qemu-guest-agent config may block file-read RPCs. Check `/etc/sysconfig/qemu-ga` and ensure `guest-file-open`, `guest-file-read`, and `guest-file-close` are not blocked, then restart the agent. See your distro's qemu-guest-agent docs for exact syntax. |
| **Agent Timeout** | Increase timeouts via env vars if network is slow: `GUEST_AGENT_FSINFO_TIMEOUT=10s`. |
| **Windows VMs** | Ensure the **QEMU Guest Agent** service is running in Windows Services. |

Some files were not shown because too many files have changed in this diff Show more