mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-04 13:23:28 +00:00
feat: Pulse v6 release
This commit is contained in:
parent
2fe22c3308
commit
778a2577b6
3155 changed files with 594553 additions and 173975 deletions
|
|
@ -1,4 +1,4 @@
|
|||
FROM golang:1.24
|
||||
FROM golang:1.25.7
|
||||
|
||||
# Set bash as default shell for features
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/README.md
vendored
4
.github/workflows/README.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
77
.github/workflows/build-and-test.yml
vendored
77
.github/workflows/build-and-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
145
.github/workflows/canonical-governance.yml
vendored
Normal file
145
.github/workflows/canonical-governance.yml
vendored
Normal 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
|
||||
210
.github/workflows/create-release.yml
vendored
210
.github/workflows/create-release.yml
vendored
|
|
@ -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!"
|
||||
|
|
|
|||
37
.github/workflows/deploy-demo-server.yml
vendored
37
.github/workflows/deploy-demo-server.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/issue-version-triage.yml
vendored
2
.github/workflows/issue-version-triage.yml
vendored
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
94
.github/workflows/promote-floating-tags.yml
vendored
94
.github/workflows/promote-floating-tags.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
78
.github/workflows/publish-docker.yml
vendored
78
.github/workflows/publish-docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
38
.github/workflows/publish-helm-chart.yml
vendored
38
.github/workflows/publish-helm-chart.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
203
.github/workflows/release-dry-run.yml
vendored
203
.github/workflows/release-dry-run.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
40
.github/workflows/repo-boundary-audit.yml
vendored
Normal file
40
.github/workflows/repo-boundary-audit.yml
vendored
Normal 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
|
||||
5
.github/workflows/test-e2e.yml
vendored
5
.github/workflows/test-e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
36
.github/workflows/update-demo-server.yml
vendored
36
.github/workflows/update-demo-server.yml
vendored
|
|
@ -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
83
.gitignore
vendored
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
190
ARCHITECTURE.md
190
ARCHITECTURE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/`
|
||||
|
|
|
|||
127
Dockerfile
127
Dockerfile
|
|
@ -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/
|
||||
|
|
|
|||
38
Makefile
38
Makefile
|
|
@ -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"
|
||||
|
|
|
|||
88
README.md
88
README.md
|
|
@ -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>
|
||||
|
||||
[](https://github.com/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.
|
||||
|
||||

|
||||
|
||||
## 🧭 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
|
||||
|
||||
|
|
|
|||
63
SECURITY.md
63
SECURITY.md
|
|
@ -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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.1.24
|
||||
6.0.0-rc.1
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
cmd/pulse-agent/main_bench_test.go
Normal file
55
cmd/pulse-agent/main_bench_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
|||
49
cmd/pulse-control-plane/main.go
Normal file
49
cmd/pulse-control-plane/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
298
cmd/pulse/commands_integration_test.go
Normal file
298
cmd/pulse/commands_integration_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
94
cmd/pulse/config_security_test.go
Normal file
94
cmd/pulse/config_security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
57
cmd/pulse/test_helpers_test.go
Normal file
57
cmd/pulse/test_helpers_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# pulse
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
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` | |
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
69
deploy/helm/pulse/templates/prometheusrule.yaml
Normal file
69
deploy/helm/pulse/templates/prometheusrule.yaml
Normal 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 }}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
44
docs/AI.md
44
docs/AI.md
|
|
@ -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
184
docs/AI_AUTONOMY.md
Normal 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 | 5–30 | Maximum agentic turns per investigation |
|
||||
| `patrol_investigation_timeout_sec` | 600 | 60–1800 | 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
|
||||
446
docs/API.md
446
docs/API.md
|
|
@ -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
169
docs/AUDIT_LOGGING.md
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
337
docs/CANONICAL_ALERT_ENGINE_MIGRATION_2026-03-10.md
Normal file
337
docs/CANONICAL_ALERT_ENGINE_MIGRATION_2026-03-10.md
Normal 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.
|
||||
|
|
@ -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
121
docs/CLOUD.md
Normal 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)
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
165
docs/EVAL.md
165
docs/EVAL.md
|
|
@ -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
|
||||
```
|
||||
53
docs/FAQ.md
53
docs/FAQ.md
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|--------|-------------|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
63
docs/MIGRATION_UNIFIED_NAV.md
Normal file
63
docs/MIGRATION_UNIFIED_NAV.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
docs/OIDC.md
12
docs/OIDC.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
18
docs/PBS.md
18
docs/PBS.md
|
|
@ -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
77
docs/PRIVACY.md
Normal 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**
|
||||
|
|
@ -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
191
docs/RBAC.md
Normal 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
|
||||
|
|
@ -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 doesn’t).
|
||||
- **[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
136
docs/RECOVERY.md
Normal 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
120
docs/RELAY.md
Normal 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
|
||||
|
|
@ -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
92
docs/REPO_BOUNDARY.md
Normal 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.
|
||||
|
|
@ -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)
|
||||

|
||||
*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
|
||||

|
||||
*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
|
||||

|
||||
*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
|
||||

|
||||
*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
|
||||

|
||||
*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
|
||||

|
||||
|
|
@ -18,12 +24,12 @@
|
|||
|
||||
## Alert History & Analytics
|
||||

|
||||
*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
|
||||

|
||||
*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
|
||||

|
||||
*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.*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
471
docs/STORAGE_ARCHITECTURE.md
Normal file
471
docs/STORAGE_ARCHITECTURE.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
126
docs/TRUENAS.md
Normal 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
|
||||
|
|
@ -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
125
docs/UNIFIED_RESOURCES.md
Normal 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
|
||||
|
|
@ -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
102
docs/UPGRADE_v6.md
Normal 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.
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue