From 04429cb42c9163493ca97b3d09366789944561a5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:26:10 +0100 Subject: [PATCH] Keep setup and metrics guidance credential-safe Replace token arguments, an ignored API_TOKENS bootstrap and plaintext DaemonSet credentials with scoped UI-created tokens, private files and a pre-created Secret. Route hosted installation to the existing private-file flow and retain HTTP errors and encoded resource identities in metrics queries. Exercise copied recipes, failure stops and actual curl requests with synthetic credentials. Contract-Neutral: Documentation-only private-file setup and metrics access guidance; no runtime, API, entitlement or deployment contract delta. Change-source: pulse-maintainer --- docs/CLOUD.md | 20 +- docs/KUBERNETES.md | 81 +++++-- docs/METRICS_HISTORY.md | 22 +- frontend-modern/public/docs/CLOUD.md | 20 +- frontend-modern/public/docs/KUBERNETES.md | 81 +++++-- .../public/docs/METRICS_HISTORY.md | 22 +- scripts/tests/test_setup_access_docs.py | 217 ++++++++++++++++++ 7 files changed, 415 insertions(+), 48 deletions(-) create mode 100644 scripts/tests/test_setup_access_docs.py diff --git a/docs/CLOUD.md b/docs/CLOUD.md index c64b1e8ece..74b0cacbc5 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -47,14 +47,20 @@ Sign up via the Pulse Cloud portal. Your instance is provisioned automatically a ### 2. Connect Agents -Once your instance is running, install agents on your infrastructure: +Once your instance is running, open **Settings → Infrastructure → Install on a +host** in your cloud dashboard and choose the target platform and collector +profile. Run the installer on the host being monitored, not inside the hosted +Pulse server. -```bash -curl -fsSL https://yourname.cloud.pulserelay.pro/install.sh | \ - bash -s -- --url https://yourname.cloud.pulserelay.pro --token -``` - -Generate installation commands from **Settings → Infrastructure → Install on a host** in your cloud dashboard. +For manual setup, follow the [private-file installation steps](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas) +(or the Windows section in that guide), replacing `https://pulse.example.com` +with your instance's HTTPS address in both the download and install commands. +Save the agent token in a private file and pass only its path with +`--token-file`; never paste a token into command arguments, a URL or a report. +Download the agent installer from your own instance's `/install.sh`, stop if +the download fails, and inspect the saved script before running it. Keep TLS +verification enabled; do not pipe an unchecked response into a privileged shell +or substitute the top-level GitHub server installer. ### 3. Add Proxmox / TrueNAS Connections diff --git a/docs/KUBERNETES.md b/docs/KUBERNETES.md index 9455942397..d8df2d813d 100644 --- a/docs/KUBERNETES.md +++ b/docs/KUBERNETES.md @@ -61,36 +61,84 @@ By default, this workload runs in container-monitoring mode (`--enable-docker -- For Kubernetes monitoring, use a custom DaemonSet as shown below. +### Prepare an agent Secret + +Complete setup on the running Pulse server, then create a token in **API Access** +with `kubernetes:report`. Add `agent:report` only if you also enable host metrics. +Do not reuse an administrator token. Pulse v6 does not provision API tokens from +the legacy `API_TOKENS` server environment variable. + +On your kubectl workstation, save only the token in a private, regular file +outside your GitOps repository. Do not paste it into a shell assignment, a +`--from-literal` argument, a Helm value or the DaemonSet YAML: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/kubernetes-agent-token" +chmod 600 "$HOME/.config/pulse/kubernetes-agent-token" +vi "$HOME/.config/pulse/kubernetes-agent-token" +``` + +After saving the token in the editor, create the Secret in the same namespace +as the agent. The generated Secret goes directly to kubectl, not to your +terminal or a checked-in manifest. The subshell stops on either pipeline's +failure; do not continue to the agent deployment if it fails: + +```bash +( + set -euo pipefail + test -s "$HOME/.config/pulse/kubernetes-agent-token" + kubectl create namespace pulse --dry-run=client -o yaml | \ + kubectl apply --server-side --field-manager=pulse-agent-setup -f - + kubectl -n pulse create secret generic pulse-agent-env \ + --from-file=PULSE_TOKEN="$HOME/.config/pulse/kubernetes-agent-token" \ + --dry-run=client -o yaml | \ + kubectl apply --server-side --field-manager=pulse-agent-setup -f - +) +``` + +Server-side apply avoids copying the token into a last-applied annotation. If +another manager owns the Secret fields, reconcile that ownership instead of +forcing the conflict. Kubernetes Secrets are base64-encoded, **not inherently +encrypted**: restrict Secret access with RBAC and configure cluster encryption +at rest. Never share Secret YAML, Helm secret values or the local token file in +diagnostics. Keep the file private or remove it after provisioning; revoke a +token in Pulse when it is no longer needed. Secret rotation requires restarting +the agent pods because these examples read it into the environment at startup. + ### OpenShift profile (Helm) The chart has an SCC-compatible OpenShift profile for the Pulse server and an optional cluster-level Kubernetes collector: +For a new server, deploy the server first and complete its browser setup before +creating the scoped agent token: + ```bash -export PULSE_TOKEN='replace-with-a-kubernetes-report-token' +helm upgrade --install pulse pulse/pulse \ + --namespace pulse --create-namespace \ + --set openShift.enabled=true +``` -kubectl create namespace pulse --dry-run=client -o yaml | kubectl apply -f - -kubectl -n pulse create secret generic pulse-server-env \ - --from-literal=API_TOKENS="${PULSE_TOKEN}" \ - --dry-run=client -o yaml | kubectl apply -f - -kubectl -n pulse create secret generic pulse-agent-env \ - --from-literal=PULSE_TOKEN="${PULSE_TOKEN}" \ - --dry-run=client -o yaml | kubectl apply -f - +Create `pulse-agent-env` using the private-file steps above, then enable the +collector: +```bash helm upgrade --install pulse pulse/pulse \ --namespace pulse \ --set openShift.enabled=true \ --set openShift.kubernetesAgent.enabled=true \ --set openShift.kubernetesAgent.clusterID=my-openshift-cluster \ - --set server.secretEnv.name=pulse-server-env \ - --set 'server.secretEnv.keys[0]=API_TOKENS' \ --set agent.secretEnv.name=pulse-agent-env \ --set 'agent.secretEnv.keys[0]=PULSE_TOKEN' ``` -Using pre-created Secrets keeps the token out of Helm release values. For an -agent reporting to an existing external Pulse server, omit the server Secret -and override `agent.env[0].value` with that server's reachable `PULSE_URL`. +Using a pre-created Secret keeps the token out of Helm release values. For an +agent reporting to an existing external Pulse server, create the token there +and override `agent.env[0].value` with that server's reachable HTTPS `PULSE_URL`. +Keep certificate verification enabled. The profile deliberately: @@ -127,6 +175,8 @@ To monitor Kubernetes resources, run the unified agent as a DaemonSet and enable #### Minimal DaemonSet Example This uses the main `rcourtman/pulse` image but runs the `pulse-agent` binary directly. +Create the `pulse-agent-env` Secret above and the RBAC resources below before +applying the DaemonSet. The YAML contains only the Secret reference, not the token. ```yaml apiVersion: apps/v1 @@ -157,7 +207,10 @@ spec: - name: PULSE_URL value: "http://pulse-server.pulse.svc.cluster.local:7655" - name: PULSE_TOKEN - value: "YOUR_API_TOKEN_HERE" + valueFrom: + secretKeyRef: + name: pulse-agent-env + key: PULSE_TOKEN - name: PULSE_AGENT_ID value: "my-k8s-cluster" - name: PULSE_ENABLE_HOST diff --git a/docs/METRICS_HISTORY.md b/docs/METRICS_HISTORY.md index 883bf2db53..43ee4e4c35 100644 --- a/docs/METRICS_HISTORY.md +++ b/docs/METRICS_HISTORY.md @@ -129,13 +129,29 @@ These endpoints require authentication with the `monitoring:read` scope. - `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 -Example: +For a one-off read, open `/api/metrics-store/stats` in your signed-in Pulse +browser; do not extract its session cookie. For curl, prepare the private +header file described in [API authentication](API.md#-authentication), using a +token with only `monitoring:read`. The example requires curl 7.76 or later and +runs **on the Pulse host**: ```bash -curl -H "X-API-Token: $TOKEN" \ - "http://localhost:7655/api/metrics-store/history?resourceType=vm&resourceId=pve1:node1:100&range=7d&metric=cpu" +curl --disable --fail-with-body --connect-timeout 10 --max-time 30 \ + --header "@$HOME/.config/pulse/api-header" --get \ + --data-urlencode 'resourceType=vm' \ + --data-urlencode 'resourceId=pve1:node1:100' \ + --data-urlencode 'range=7d' --data-urlencode 'metric=cpu' \ + http://127.0.0.1:7655/api/metrics-store/history ``` +Replace the example resource ID with the ID reported by your instance. For a +remote request, use your Pulse HTTPS address and keep certificate verification +enabled. Keep `--disable` first to ignore local curl defaults that could enable +credential-bearing traces. HTTP errors, including 401, 402 and 403, return a +non-zero exit; do not treat the error body as history. Never put a token in a +command, URL or report, and share only the relevant redacted error rather than +the whole infrastructure response. + > **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and legacy 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. diff --git a/frontend-modern/public/docs/CLOUD.md b/frontend-modern/public/docs/CLOUD.md index c64b1e8ece..74b0cacbc5 100644 --- a/frontend-modern/public/docs/CLOUD.md +++ b/frontend-modern/public/docs/CLOUD.md @@ -47,14 +47,20 @@ Sign up via the Pulse Cloud portal. Your instance is provisioned automatically a ### 2. Connect Agents -Once your instance is running, install agents on your infrastructure: +Once your instance is running, open **Settings → Infrastructure → Install on a +host** in your cloud dashboard and choose the target platform and collector +profile. Run the installer on the host being monitored, not inside the hosted +Pulse server. -```bash -curl -fsSL https://yourname.cloud.pulserelay.pro/install.sh | \ - bash -s -- --url https://yourname.cloud.pulserelay.pro --token -``` - -Generate installation commands from **Settings → Infrastructure → Install on a host** in your cloud dashboard. +For manual setup, follow the [private-file installation steps](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas) +(or the Windows section in that guide), replacing `https://pulse.example.com` +with your instance's HTTPS address in both the download and install commands. +Save the agent token in a private file and pass only its path with +`--token-file`; never paste a token into command arguments, a URL or a report. +Download the agent installer from your own instance's `/install.sh`, stop if +the download fails, and inspect the saved script before running it. Keep TLS +verification enabled; do not pipe an unchecked response into a privileged shell +or substitute the top-level GitHub server installer. ### 3. Add Proxmox / TrueNAS Connections diff --git a/frontend-modern/public/docs/KUBERNETES.md b/frontend-modern/public/docs/KUBERNETES.md index 9455942397..d8df2d813d 100644 --- a/frontend-modern/public/docs/KUBERNETES.md +++ b/frontend-modern/public/docs/KUBERNETES.md @@ -61,36 +61,84 @@ By default, this workload runs in container-monitoring mode (`--enable-docker -- For Kubernetes monitoring, use a custom DaemonSet as shown below. +### Prepare an agent Secret + +Complete setup on the running Pulse server, then create a token in **API Access** +with `kubernetes:report`. Add `agent:report` only if you also enable host metrics. +Do not reuse an administrator token. Pulse v6 does not provision API tokens from +the legacy `API_TOKENS` server environment variable. + +On your kubectl workstation, save only the token in a private, regular file +outside your GitOps repository. Do not paste it into a shell assignment, a +`--from-literal` argument, a Helm value or the DaemonSet YAML: + +```bash +umask 077 +mkdir -p "$HOME/.config/pulse" +chmod 700 "$HOME/.config/pulse" +touch "$HOME/.config/pulse/kubernetes-agent-token" +chmod 600 "$HOME/.config/pulse/kubernetes-agent-token" +vi "$HOME/.config/pulse/kubernetes-agent-token" +``` + +After saving the token in the editor, create the Secret in the same namespace +as the agent. The generated Secret goes directly to kubectl, not to your +terminal or a checked-in manifest. The subshell stops on either pipeline's +failure; do not continue to the agent deployment if it fails: + +```bash +( + set -euo pipefail + test -s "$HOME/.config/pulse/kubernetes-agent-token" + kubectl create namespace pulse --dry-run=client -o yaml | \ + kubectl apply --server-side --field-manager=pulse-agent-setup -f - + kubectl -n pulse create secret generic pulse-agent-env \ + --from-file=PULSE_TOKEN="$HOME/.config/pulse/kubernetes-agent-token" \ + --dry-run=client -o yaml | \ + kubectl apply --server-side --field-manager=pulse-agent-setup -f - +) +``` + +Server-side apply avoids copying the token into a last-applied annotation. If +another manager owns the Secret fields, reconcile that ownership instead of +forcing the conflict. Kubernetes Secrets are base64-encoded, **not inherently +encrypted**: restrict Secret access with RBAC and configure cluster encryption +at rest. Never share Secret YAML, Helm secret values or the local token file in +diagnostics. Keep the file private or remove it after provisioning; revoke a +token in Pulse when it is no longer needed. Secret rotation requires restarting +the agent pods because these examples read it into the environment at startup. + ### OpenShift profile (Helm) The chart has an SCC-compatible OpenShift profile for the Pulse server and an optional cluster-level Kubernetes collector: +For a new server, deploy the server first and complete its browser setup before +creating the scoped agent token: + ```bash -export PULSE_TOKEN='replace-with-a-kubernetes-report-token' +helm upgrade --install pulse pulse/pulse \ + --namespace pulse --create-namespace \ + --set openShift.enabled=true +``` -kubectl create namespace pulse --dry-run=client -o yaml | kubectl apply -f - -kubectl -n pulse create secret generic pulse-server-env \ - --from-literal=API_TOKENS="${PULSE_TOKEN}" \ - --dry-run=client -o yaml | kubectl apply -f - -kubectl -n pulse create secret generic pulse-agent-env \ - --from-literal=PULSE_TOKEN="${PULSE_TOKEN}" \ - --dry-run=client -o yaml | kubectl apply -f - +Create `pulse-agent-env` using the private-file steps above, then enable the +collector: +```bash helm upgrade --install pulse pulse/pulse \ --namespace pulse \ --set openShift.enabled=true \ --set openShift.kubernetesAgent.enabled=true \ --set openShift.kubernetesAgent.clusterID=my-openshift-cluster \ - --set server.secretEnv.name=pulse-server-env \ - --set 'server.secretEnv.keys[0]=API_TOKENS' \ --set agent.secretEnv.name=pulse-agent-env \ --set 'agent.secretEnv.keys[0]=PULSE_TOKEN' ``` -Using pre-created Secrets keeps the token out of Helm release values. For an -agent reporting to an existing external Pulse server, omit the server Secret -and override `agent.env[0].value` with that server's reachable `PULSE_URL`. +Using a pre-created Secret keeps the token out of Helm release values. For an +agent reporting to an existing external Pulse server, create the token there +and override `agent.env[0].value` with that server's reachable HTTPS `PULSE_URL`. +Keep certificate verification enabled. The profile deliberately: @@ -127,6 +175,8 @@ To monitor Kubernetes resources, run the unified agent as a DaemonSet and enable #### Minimal DaemonSet Example This uses the main `rcourtman/pulse` image but runs the `pulse-agent` binary directly. +Create the `pulse-agent-env` Secret above and the RBAC resources below before +applying the DaemonSet. The YAML contains only the Secret reference, not the token. ```yaml apiVersion: apps/v1 @@ -157,7 +207,10 @@ spec: - name: PULSE_URL value: "http://pulse-server.pulse.svc.cluster.local:7655" - name: PULSE_TOKEN - value: "YOUR_API_TOKEN_HERE" + valueFrom: + secretKeyRef: + name: pulse-agent-env + key: PULSE_TOKEN - name: PULSE_AGENT_ID value: "my-k8s-cluster" - name: PULSE_ENABLE_HOST diff --git a/frontend-modern/public/docs/METRICS_HISTORY.md b/frontend-modern/public/docs/METRICS_HISTORY.md index 883bf2db53..43ee4e4c35 100644 --- a/frontend-modern/public/docs/METRICS_HISTORY.md +++ b/frontend-modern/public/docs/METRICS_HISTORY.md @@ -129,13 +129,29 @@ These endpoints require authentication with the `monitoring:read` scope. - `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 -Example: +For a one-off read, open `/api/metrics-store/stats` in your signed-in Pulse +browser; do not extract its session cookie. For curl, prepare the private +header file described in [API authentication](API.md#-authentication), using a +token with only `monitoring:read`. The example requires curl 7.76 or later and +runs **on the Pulse host**: ```bash -curl -H "X-API-Token: $TOKEN" \ - "http://localhost:7655/api/metrics-store/history?resourceType=vm&resourceId=pve1:node1:100&range=7d&metric=cpu" +curl --disable --fail-with-body --connect-timeout 10 --max-time 30 \ + --header "@$HOME/.config/pulse/api-header" --get \ + --data-urlencode 'resourceType=vm' \ + --data-urlencode 'resourceId=pve1:node1:100' \ + --data-urlencode 'range=7d' --data-urlencode 'metric=cpu' \ + http://127.0.0.1:7655/api/metrics-store/history ``` +Replace the example resource ID with the ID reported by your instance. For a +remote request, use your Pulse HTTPS address and keep certificate verification +enabled. Keep `--disable` first to ignore local curl defaults that could enable +credential-bearing traces. HTTP errors, including 401, 402 and 403, return a +non-zero exit; do not treat the error body as history. Never put a token in a +command, URL or report, and share only the relevant redacted error rather than +the whole infrastructure response. + > **License**: Requests beyond Community's `7d` floor require the paid `long_term_metrics` entitlement. Relay unlocks `14d`, Pro and legacy 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. diff --git a/scripts/tests/test_setup_access_docs.py b/scripts/tests/test_setup_access_docs.py new file mode 100644 index 0000000000..d43f2dba75 --- /dev/null +++ b/scripts/tests/test_setup_access_docs.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Exercise copied setup/metrics recipes with synthetic credentials. + +Run with pulse-worker-source-proof --workspace scripts. The kubectl recorder +checks local argv, pipe payloads and stop behaviour, not a real cluster. Curl +uses guest-local listeners; no real Pulse instance or token is involved. +""" + +from __future__ import annotations + +import base64 +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest +from urllib.parse import parse_qs, urlsplit + +from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server + + +GUIDES = ("KUBERNETES", "METRICS_HISTORY", "CLOUD") + + +def guide(name: str) -> str: + return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8") + + +def blocks(name: str, language: str = "bash") -> list[str]: + return re.findall(r"```" + language + r"\n(.*?)```", guide(name), re.DOTALL) + + +def recipe(name: str, needle: str) -> str: + matches = [block for block in blocks(name) if needle in block] + if len(matches) != 1: + raise AssertionError(f"expected one {name} recipe containing {needle!r}") + return matches[0] + + +KUBECTL_RECORDER = '''#!/usr/bin/env python3 +import base64, json, os, sys +from pathlib import Path +args = sys.argv[1:] +assert os.environ['FIXTURE_TOKEN'] not in ' '.join(args) +assert not any(k in os.environ for k in ('PULSE_TOKEN', 'API_TOKENS')) +if args[:2] == ['create', 'namespace']: + phase = 'namespace-create' + assert args == ['create', 'namespace', 'pulse', '--dry-run=client', '-o', 'yaml'] + document = {'kind': 'Namespace', 'metadata': {'name': 'pulse'}} +elif args[:5] == ['-n', 'pulse', 'create', 'secret', 'generic']: + phase = 'secret-create' + assert args[5] == 'pulse-agent-env' + assert args[7:] == ['--dry-run=client', '-o', 'yaml'] + assert args[6].startswith('--from-file=PULSE_TOKEN=') + path = Path(args[6].split('=', 2)[2]) + assert path.read_text().strip() == os.environ['FIXTURE_TOKEN'] + assert path.stat().st_mode & 0o777 == 0o600 + document = {'apiVersion': 'v1', 'kind': 'Secret', + 'metadata': {'name': 'pulse-agent-env', 'namespace': 'pulse'}, + 'data': {'PULSE_TOKEN': base64.b64encode(path.read_bytes()).decode()}} +else: + assert args == ['apply', '--server-side', '--field-manager=pulse-agent-setup', '-f', '-'] + payload = sys.stdin.read() + document = json.loads(payload) if payload else None + phase = 'secret-apply' if document and document['kind'] == 'Secret' else 'namespace-apply' +with Path(os.environ['KUBECTL_RECEIPT']).open('a') as output: + output.write(json.dumps({'phase': phase, 'argv': args, 'document': document}) + '\\n') +if phase == os.environ.get('FAIL_PHASE'): + sys.exit(23) +if args[0] == 'apply': + if document is None: + sys.exit(24) + print(document['kind'].lower() + '/' + document['metadata']['name'] + ' serverside-applied') +else: + print(json.dumps(document)) +''' + + +class SetupAccessDocsTest(unittest.TestCase): + def test_mirrors_and_secret_free_shell_recipes(self): + for name in GUIDES: + with self.subTest(guide=name): + self.assertEqual((ROOT / "docs" / f"{name}.md").read_bytes(), + (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes()) + shell = "\n".join(blocks(name)) + self.assertNotRegex(shell, r"(?:export\s+PULSE_TOKEN|--from-literal=\w*TOKEN|--token(?:\s|=)|X-API-Token:|Bearer\s)") + self.assertNotRegex(shell, r"(?:--insecure|--verbose|--trace\S*|\s-k\b|curl[^`]*\|\s*bash)") + for block in blocks(name): + subprocess.run(["bash", "-n", "-c", block], check=True, capture_output=True) + + def test_private_file_preparation_preserves_existing_token(self): + command = recipe("KUBERNETES", 'vi "$HOME/.config/pulse/kubernetes-agent-token"') + with tempfile.TemporaryDirectory(prefix="setup docs ") as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n') + editor.chmod(0o700) + env = {**os.environ, "HOME": str(home), "PATH": f"{tools}:{os.environ['PATH']}"} + token = home / ".config/pulse/kubernetes-agent-token" + for existing in (False, True): + if existing: + token.write_text(TEST_TOKEN + "\n") + token.chmod(0o644) + token.parent.chmod(0o755) + result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(stat.S_IMODE(token.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(token.parent.stat().st_mode), 0o700) + self.assertEqual(token.read_text(), TEST_TOKEN + "\n" if existing else "") + self.assertNotIn(TEST_TOKEN.encode(), result.stdout + result.stderr) + + def run_secret(self, home: Path, fail: str = "", empty: bool = False): + private = home / ".config/pulse" + private.mkdir(parents=True, mode=0o700) + token = private / "kubernetes-agent-token" + token.write_text("" if empty else TEST_TOKEN + "\n") + token.chmod(0o600) + tools = home / "tools" + tools.mkdir() + recorder = tools / "kubectl" + recorder.write_text(KUBECTL_RECORDER) + recorder.chmod(0o700) + receipt = home / "kubectl.jsonl" + env = {**os.environ, "HOME": str(home), "PATH": f"{tools}:{os.environ['PATH']}", + "FIXTURE_TOKEN": TEST_TOKEN, "KUBECTL_RECEIPT": str(receipt), "FAIL_PHASE": fail} + for key in ("PULSE_TOKEN", "API_TOKENS"): + env.pop(key, None) + result = subprocess.run(["bash", "-c", recipe("KUBERNETES", "--from-file=PULSE_TOKEN=")], + env=env, capture_output=True, timeout=10) + records = [json.loads(line) for line in receipt.read_text().splitlines()] if receipt.exists() else [] + self.assertNotIn(TEST_TOKEN.encode(), result.stdout + result.stderr) + self.assertNotIn(base64.b64encode((TEST_TOKEN + "\n").encode()), result.stdout + result.stderr) + return result, records + + def test_secret_passes_only_file_path_in_argv_and_data_over_stdin(self): + with tempfile.TemporaryDirectory(prefix="setup docs ") as temporary: + result, records = self.run_secret(Path(temporary)) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(sorted(r["phase"] for r in records), + sorted(("namespace-create", "namespace-apply", "secret-create", "secret-apply"))) + created = next(r["document"] for r in records if r["phase"] == "secret-create") + applied = next(r["document"] for r in records if r["phase"] == "secret-apply") + self.assertEqual(created, applied) + self.assertEqual(base64.b64decode(applied["data"]["PULSE_TOKEN"]), (TEST_TOKEN + "\n").encode()) + self.assertNotIn("annotations", applied["metadata"]) + self.assertNotIn(TEST_TOKEN, json.dumps([r["argv"] for r in records])) + + def test_pipeline_failure_and_empty_file_stop_provisioning(self): + for phase in ("namespace-create", "namespace-apply", "secret-create", "secret-apply", ""): + with self.subTest(phase=phase), tempfile.TemporaryDirectory() as temporary: + result, records = self.run_secret(Path(temporary), fail=phase, empty=not phase) + self.assertNotEqual(result.returncode, 0) + if phase.startswith("namespace"): + self.assertFalse(any(r["phase"].startswith("secret") for r in records)) + if not phase: + self.assertEqual(records, []) + + def test_daemonset_and_helm_reference_the_same_scoped_secret(self): + manifest = next(block for block in blocks("KUBERNETES", "yaml") if "kind: DaemonSet" in block) + self.assertRegex(manifest, r"name: PULSE_TOKEN\n\s+valueFrom:\n\s+secretKeyRef:\n\s+name: pulse-agent-env\n\s+key: PULSE_TOKEN") + self.assertNotIn("YOUR_API_TOKEN", manifest) + collector = recipe("KUBERNETES", "openShift.kubernetesAgent.enabled=true") + self.assertIn("agent.secretEnv.name=pulse-agent-env", collector) + self.assertIn("agent.secretEnv.keys[0]=PULSE_TOKEN", collector) + self.assertNotIn("server.secretEnv", collector) + chart = (ROOT / "deploy/helm/pulse/templates/agent.yaml").read_text() + for key in (".Values.agent.secretEnv", '"valueFrom"', '"secretKeyRef"', '"pulse.agentSecretName"'): + self.assertIn(key, chart) + content = " ".join(guide("KUBERNETES").split()) + for boundary in ("kubernetes:report", "agent:report", "Do not reuse an administrator token", + "does not provision API tokens", "not inherently encrypted", "RBAC", + "restarting the agent pods", "forcing the conflict"): + self.assertIn(boundary, content) + + def test_cloud_uses_existing_private_file_installation_not_server_installer(self): + content = " ".join(guide("CLOUD").split()) + for boundary in ("UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas", "--token-file", + "host being monitored", "Windows", "HTTPS", "stop if the download fails", + "top-level GitHub server installer", "Keep TLS verification enabled"): + self.assertIn(boundary, content) + + +class MetricsAccessDocsHTTPTest(unittest.TestCase): + def test_history_query_with_both_headers_and_hostile_curl_defaults(self): + request = recipe("METRICS_HISTORY", "curl ") + expected_id = "pve 1:node/1:100&other=1" + request = request.replace("pve1:node1:100", expected_id) + with recording_server() as (port, records): + for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)): + with self.subTest(header=key), tempfile.TemporaryDirectory() as temporary: + result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, request) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + path, headers, method, body = records[-1] + self.assertEqual(urlsplit(path).path, "/api/metrics-store/history") + self.assertEqual(parse_qs(urlsplit(path).query), { + "resourceType": ["vm"], "resourceId": [expected_id], "range": ["7d"], "metric": ["cpu"]}) + self.assertEqual((method, body), ("GET", b"")) + self.assertEqual(headers[key], value) + self.assertNotIn("X-Curlrc-Injected", headers) + + def test_http_auth_license_and_server_errors_fail(self): + for status in (401, 402, 403, 500): + with self.subTest(status=status), recording_server(status) as (port, records), tempfile.TemporaryDirectory() as temporary: + result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, + recipe("METRICS_HISTORY", "curl ")) + self.assertEqual(result.returncode, 22, result.stderr.decode()) + self.assertEqual(len(records), 1) + self.assertIn(b'{"fixture":true}', result.stdout) + + +if __name__ == "__main__": + unittest.main()