Merge upstream main with provider Plan verification

Preserve the reviewed provider Plan return history and incorporate current upstream changes. Rebuild the portal bundle and manifest from the combined source.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-24 16:04:28 +01:00
69 changed files with 2434 additions and 1076 deletions
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -265,6 +266,12 @@ func runProviderMSPInstallProofWithDependencies(ctx context.Context, cfg *cloudc
proofTenantIDs = providerMSPInstallProofTenantIDs(proof.Workspaces)
}
if err != nil {
// A proof that fails part-way returns no report, but it may already
// have created workspaces; remove those too.
var partial *providerMSPProofPartialError
if errors.As(err, &partial) && len(proofTenantIDs) == 0 {
proofTenantIDs = partial.CreatedTenantIDs
}
addFailure("workspace proof: %v", err)
return failAfterProof(err)
}
@@ -407,3 +407,54 @@ func (f *fakeProviderMSPInstallProofRuntime) CleanupProviderMSPProofTenants(_ co
func (f *fakeProviderMSPInstallProofRuntime) Close() {
f.closed = true
}
// A proof that fails part-way returns no report, so the workspace list the
// install proof used to clean up from was empty and the workspaces it had
// already created stayed behind in the provider's client slots.
func TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof(t *testing.T) {
cfg := testProviderMSPProofConfig(t)
fakeRuntime := &fakeProviderMSPInstallProofRuntime{
proofErr: &providerMSPProofPartialError{
err: errors.New("create proof workspace 2: workspace limit reached"),
CreatedTenantIDs: []string{"ws-proof-01"},
},
}
report, err := runProviderMSPInstallProofWithDependencies(context.Background(), cfg, providerMSPInstallProofOptions{
AccountName: "Example MSP",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: 2,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
BackupOutput: "/tmp/provider-msp-install-proof.tar.gz",
RestoreTargetDataDir: "/tmp/provider-msp-restore-drill",
Cleanup: true,
}, providerMSPInstallProofDependencies{
Bootstrap: func(context.Context, *cloudcp.CPConfig, cloudcp.ProviderMSPBootstrapOptions) (*cloudcp.ProviderMSPBootstrapResult, error) {
return healthyProviderMSPInstallProofBootstrap(), nil
},
RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) {
return healthyProviderMSPStatusPreflightReport(), nil
},
RunStatus: func(context.Context, *cloudcp.CPConfig, providerMSPStatusOptions) (*providerMSPStatusReport, error) {
return &providerMSPStatusReport{OK: true}, nil
},
NewProofRuntime: func(*cloudcp.CPConfig) (providerMSPInstallProofRuntime, error) {
return fakeRuntime, nil
},
CreateBackup: func(context.Context, *cloudcp.CPConfig, string) (*cloudcp.ProviderMSPBackupCreateResult, error) {
t.Fatal("backup should not run after a failed proof")
return nil, nil
},
})
if err == nil || !strings.Contains(err.Error(), "workspace limit reached") {
t.Fatalf("install proof error = %v, want the proof failure", err)
}
if report == nil || report.OK || !report.CleanupOK {
t.Fatalf("report = %#v, want failed with cleanup done", report)
}
if !reflect.DeepEqual(fakeRuntime.cleanupTenantIDs, []string{"ws-proof-01"}) {
t.Fatalf("cleanup tenant ids = %#v, want the workspace the partial proof created", fakeRuntime.cleanupTenantIDs)
}
}
+47 -5
View File
@@ -260,7 +260,26 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
return nil, fmt.Errorf("bootstrap provider MSP account: %w", err)
}
// The proof creates its workspaces on the provider's own account, so they
// count against the same client limit real clients do. Check for room
// before creating anything: on a two-client evaluation that already has a
// client, the second proof workspace would otherwise be refused after the
// first was created, leaving it behind in one of the evaluation's slots.
if bootstrap.WorkspaceLimit > 0 {
inUse, err := rt.registry.CountActiveByAccountID(bootstrap.AccountID)
if err != nil {
return nil, fmt.Errorf("count client workspaces: %w", err)
}
if free := bootstrap.WorkspaceLimit - inUse; free < opts.WorkspaceCount {
return nil, fmt.Errorf("the proof creates %d temporary client workspaces but this account has %d free (%d of %d in use); run it on a fresh platform, before adding clients",
opts.WorkspaceCount, max(free, 0), inUse, bootstrap.WorkspaceLimit)
}
}
createdTenants := make([]*registry.Tenant, 0, opts.WorkspaceCount)
partial := func(err error) error {
return &providerMSPProofPartialError{err: err, CreatedTenantIDs: providerMSPProofTenantIDs(createdTenants)}
}
if opts.Cleanup {
defer func() {
_ = rt.cleanupProviderMSPProofTenants(context.Background(), createdTenants)
@@ -292,13 +311,13 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
displayName := fmt.Sprintf("%s %02d", opts.WorkspacePrefix, idx+1)
tenant, err := rt.createProviderMSPProofWorkspace(ctx, bootstrap.AccountID, displayName, bootstrap.OwnerEmail)
if err != nil {
return nil, fmt.Errorf("create proof workspace %d: %w", idx+1, err)
return nil, partial(fmt.Errorf("create proof workspace %d: %w", idx+1, err))
}
createdTenants = append(createdTenants, tenant)
workspace, err := rt.proveProviderMSPWorkspace(ctx, tenant, bootstrap.OwnerUserID, opts.InstallType, opts.TargetPath)
if err != nil {
return nil, fmt.Errorf("prove workspace %s: %w", tenant.ID, err)
return nil, partial(fmt.Errorf("prove workspace %s: %w", tenant.ID, err))
}
if workspace.ContainerID == "" {
report.RuntimeContainerVerified = false
@@ -327,13 +346,35 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
boundaryOK, err := rt.verifyProviderMSPInstallTokenIsolation(report.Workspaces)
if err != nil {
return nil, err
return nil, partial(err)
}
report.InstallTokenBoundaryOK = boundaryOK
report.WorkspaceCount = len(report.Workspaces)
return report, nil
}
// providerMSPProofPartialError is a proof that failed after creating some of
// its workspaces. It carries their IDs so the caller can remove them: without
// that, a failure part-way through left proof workspaces occupying client
// slots, because no report (and so no workspace list) comes back on error.
type providerMSPProofPartialError struct {
err error
CreatedTenantIDs []string
}
func (e *providerMSPProofPartialError) Error() string { return e.err.Error() }
func (e *providerMSPProofPartialError) Unwrap() error { return e.err }
func providerMSPProofTenantIDs(tenants []*registry.Tenant) []string {
ids := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if tenant != nil && strings.TrimSpace(tenant.ID) != "" {
ids = append(ids, tenant.ID)
}
}
return ids
}
func normalizeProviderMSPProofOptions(opts providerMSPProofOptions) (providerMSPProofOptions, error) {
opts.AccountName = strings.TrimSpace(opts.AccountName)
opts.OwnerEmail = strings.ToLower(strings.TrimSpace(opts.OwnerEmail))
@@ -395,8 +436,9 @@ func (rt *providerMSPProofRuntime) createProviderMSPProofWorkspace(ctx context.C
rt.registry,
rt.provisioner,
account.WorkspaceLimitPolicy{
ProviderHostedMSP: true,
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
ProviderHostedMSP: true,
ProviderMSPPlanVersion: providerMSPProofPlanVersion(rt.cfg),
ProviderMSPLicenseLapsed: func() bool { return rt.cfg.ProviderMSPLicenseLapsed(time.Now()) },
},
)
mux := http.NewServeMux()
@@ -6,12 +6,14 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
@@ -430,3 +432,48 @@ func writeProviderMSPProofLicenseForTest(t *testing.T, licenseID, email, planVer
}
return path
}
// The proof's workspaces count against the provider's own client limit. When
// there is no room for all of them it must refuse before creating any: on a
// two-client evaluation with a client already added, it used to create the
// first proof workspace, get refused on the second, and leave the first
// occupying a slot.
func TestProviderMSPProofRefusesWithoutFreeSlotsAndCreatesNothing(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock")
t.Setenv("DOCKER_TLS_VERIFY", "")
t.Setenv("DOCKER_CERT_PATH", "")
cfg := testProviderMSPProofConfig(t)
rt, err := newProviderMSPProofRuntimeFromConfig(cfg)
if err != nil {
t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err)
}
defer rt.close()
bootstrap, err := cloudcp.BootstrapProviderMSP(context.Background(), cfg, cloudcp.ProviderMSPBootstrapOptions{
AccountName: "Acme Provider",
OwnerEmail: "owner@example.com",
})
if err != nil {
t.Fatalf("BootstrapProviderMSP: %v", err)
}
if err := rt.registry.Create(&registry.Tenant{ID: "t-realclient", AccountID: bootstrap.AccountID, DisplayName: "Real Client", State: registry.TenantStateActive}); err != nil {
t.Fatalf("create existing client: %v", err)
}
_, err = rt.runProviderMSPProof(context.Background(), providerMSPProofOptions{
AccountName: "Acme Provider",
OwnerEmail: "owner@example.com",
WorkspacePrefix: "Provider MSP Proof",
WorkspaceCount: bootstrap.WorkspaceLimit,
InstallType: "pve",
TargetPath: "/settings/infrastructure?add=linux-host",
})
want := fmt.Sprintf("has %d free (1 of %d in use)", bootstrap.WorkspaceLimit-1, bootstrap.WorkspaceLimit)
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("runProviderMSPProof error = %v, want refusal naming %q", err, want)
}
if count, err := rt.registry.CountActiveByAccountID(bootstrap.AccountID); err != nil || count != 1 {
t.Fatalf("active workspaces after refusal = %d, %v; want only the existing client", count, err)
}
}
+14 -9
View File
@@ -23,15 +23,18 @@ type providerMSPStatusOptions struct {
}
type providerMSPStatusReport struct {
OK bool
Environment string
ControlMode string
BaseURL string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
OK bool
Environment string
ControlMode string
BaseURL string
PlanVersion string
PlanSource string
LicenseID string
LicenseEmail string
WorkspaceLimit int
// LicenseLapsed is informational, not a failure: a lapsed install must
// still upgrade and keep its portal up so the provider can renew there.
LicenseLapsed bool
RegistryReady bool
TotalTenants int
HealthyTenants int
@@ -117,6 +120,7 @@ func runProviderMSPStatusWithDependencies(ctx context.Context, cfg *cloudcp.CPCo
LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID),
LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)),
WorkspaceLimit: workspaceLimit,
LicenseLapsed: cfg.ProviderMSPLicenseLapsed(deps.Now()),
CountsByState: map[registry.TenantState]int{},
}
addFailure := func(format string, args ...any) {
@@ -399,6 +403,7 @@ func printProviderMSPStatusReport(report *providerMSPStatusReport) {
fmt.Printf("license_id=%s\n", report.LicenseID)
fmt.Printf("license_email=%s\n", report.LicenseEmail)
fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit)
fmt.Printf("license_lapsed=%t\n", report.LicenseLapsed)
fmt.Printf("registry_ready=%t\n", report.RegistryReady)
fmt.Printf("total_tenants=%d\n", report.TotalTenants)
fmt.Printf("healthy_tenants=%d\n", report.HealthyTenants)
@@ -73,6 +73,30 @@ func TestProviderMSPStatusReportsHealthyOperatorSurface(t *testing.T) {
}
}
// A lapsed licence is reported, not failed: upgrade.sh gates on status, and a
// lapsed install must still upgrade and keep its portal up to renew.
func TestProviderMSPStatusReportsALapsedLicenceWithoutFailing(t *testing.T) {
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
cfg.ProviderMSPLicenseExpiresAt = now.Add(-30 * 24 * time.Hour)
report, err := runProviderMSPStatusWithDependencies(context.Background(), cfg, providerMSPStatusOptions{}, providerMSPStatusDependencies{
RunPreflight: func(context.Context, *cloudcp.CPConfig, providerMSPPreflightOptions) (*providerMSPPreflightReport, error) {
return healthyProviderMSPStatusPreflightReport(), nil
},
NewDocker: healthyProviderMSPStatusDocker(map[string]bool{}),
CheckBackup: func(context.Context, *cloudcp.CPConfig) (*providerMSPBackupStatus, error) {
return healthyProviderMSPBackupStatus(now), nil
},
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("runProviderMSPStatusWithDependencies: %v", err)
}
if !report.OK || !report.LicenseLapsed {
t.Fatalf("report OK=%v LicenseLapsed=%v failures=%v, want OK and lapsed", report.OK, report.LicenseLapsed, report.Failures)
}
}
func TestProviderMSPStatusFailsOnFailedUnhealthyAndStuckWorkspaces(t *testing.T) {
cfg := testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile)
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
+10 -4
View File
@@ -113,10 +113,16 @@ PULSE_EMAIL_REPLY_TO=support@example.com
# images:
# ./upgrade.sh --dry-run
#
# Apply a provider control-plane upgrade after updating the image pins in this
# file. The runner creates and verifies a fresh backup, dry-runs restore into a
# separate target data dir, pulls provider images, starts Traefik/control-plane,
# and prints the tenant runtime rollout plan for CP_PULSE_IMAGE:
# Move to a new release: download and verify its provider bundle as for a fresh
# install, then run upgrade.sh from the extracted bundle directory. It installs
# the bundle files here, keeps this file (and a pre-upgrade copy), re-pins
# CONTROL_PLANE_IMAGE and CP_PULSE_IMAGE to that release, creates and verifies a
# fresh backup, dry-runs restore into a separate target data dir, pulls provider
# images, starts Traefik/control-plane, and prints the tenant runtime rollout
# plan for CP_PULSE_IMAGE (--keep-image-pins keeps pins you set by hand):
# sudo -E bash ./upgrade.sh
#
# Run from this directory, upgrade.sh applies the image pins already here:
# ./upgrade.sh
#
# Also roll all client runtimes to the configured CP_PULSE_IMAGE line after the
+131 -2
View File
@@ -7,6 +7,14 @@ usage() {
Usage:
./upgrade.sh [options]
To move to a new release, download and verify its provider bundle exactly as
for a fresh install, then run ./upgrade.sh from the extracted bundle directory
(sudo -E bash ./upgrade.sh). It installs the bundle's files into the existing
install (PULSE_PROVIDER_MSP_INSTALL_DIR, default /opt/pulse-provider-msp),
keeps .env and a pre-upgrade copy of it, re-pins CONTROL_PLANE_IMAGE and
CP_PULSE_IMAGE to that release, and then runs the flow below. Run from the
install directory, it applies the image pins already in .env.
Runs the provider-hosted MSP pre-upgrade and upgrade flow:
1. validates .env and docker-compose.yml
2. checks provider status and install preflight
@@ -26,6 +34,7 @@ Options:
--restore-target DIR Restore dry-run target data dir (default: <data-dir>/upgrade-restore-drill)
--run-id ID Operator-visible tenant reconcile run id
--health-timeout DURATION Tenant rollout health timeout (default: 90s)
--keep-image-pins From a bundle: install its files but keep the image pins in .env
-h, --help Show this help
Environment equivalents:
@@ -75,6 +84,98 @@ run_control() {
docker compose run --rm --no-deps control-plane "$@"
}
set_env_value() {
local key="$1" value="$2" env_path="$3" tmp
tmp="$(mktemp)"
if grep -q -E "^${key}=" "${env_path}"; then
awk -v key="${key}" -v value="${value}" 'BEGIN{done=0} $0 ~ "^" key "=" && done==0 { print key "=" value; done=1; next } { print }' "${env_path}" >"${tmp}"
else
cat "${env_path}" >"${tmp}"
printf '%s=%s\n' "${key}" "${value}" >>"${tmp}"
fi
cat "${tmp}" >"${env_path}"
rm -f "${tmp}"
}
# resolve_image_digest turns a tag into an immutable digest ref, as setup.sh
# does, reading the registry without pulling the image.
resolve_image_digest() {
local ref="$1" manifest_json digest
manifest_json="$(docker buildx imagetools inspect "${ref}" --format '{{json .Manifest}}' 2>/dev/null || true)"
digest="$(printf '%s' "${manifest_json}" | jq -r 'if type == "object" then .digest // empty else empty end' 2>/dev/null || true)"
if [[ "${digest}" != sha256:* ]]; then
digest="$(docker buildx imagetools inspect "${ref}" 2>/dev/null | awk '$1 == "Digest:" {print $2; exit}' || true)"
fi
[[ "${digest}" == sha256:* ]] || return 1
printf '%s@%s\n' "${ref%:*}" "${digest}"
}
# The files setup.sh installs from a bundle. A bundle upgrade installs the same
# set, so an upgraded install matches a fresh one.
bundle_install_files=(docker-compose.yml traefik.yml traefik-dynamic.yml .env.example run-install-proof.sh upgrade.sh)
# A bundle upgrade brings an existing install up to the release bundle this
# script was extracted with. Setup resolves the image pins in .env to digests
# once, so an upgrade used to re-pull only the release the provider first
# installed; moving on meant finding four digests by hand. Nothing on disk
# changes until the platform has been checked and backed up: those steps run
# the new release's control plane as a one-off through a shell override of the
# pins (compose prefers the shell over .env), which also carries fixes to the
# checks themselves. Only then are the bundle files installed and .env
# re-pinned, just before the new images start.
bundle_dir=""
bundle_version=""
bundle_pin_keys=()
bundle_pin_values=()
plan_bundle_upgrade() {
local install_dir="$1" env_path key target current resolved
bundle_dir="$2"
bundle_version="$(tr -d '[:space:]' <"${bundle_dir}/VERSION")"
env_path="${install_dir}/.env"
[[ -f "${env_path}" ]] || die "no provider MSP install at ${install_dir} (missing .env); run ./setup.sh for a fresh install"
[[ -w "${install_dir}" && -w "${env_path}" ]] || die "cannot write ${install_dir}; run: sudo -E bash ./upgrade.sh"
echo "provider_msp_upgrade_bundle_version=${bundle_version}"
echo "provider_msp_upgrade_install_dir=${install_dir}"
if truthy "${keep_image_pins}"; then
echo "provider_msp_upgrade_image_pins=kept"
return 0
fi
for key in CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do
target="$(env_value "${key}" "${bundle_dir}/.env.example")"
[[ -n "${target}" ]] || die "bundle .env.example has no ${key}; download the release bundle, not the source tree"
current="$(env_value "${key}" "${env_path}")"
if [[ "${target}" == *@sha256:* ]]; then
resolved="${target}"
elif ! resolved="$(resolve_image_digest "${target}")"; then
die "could not resolve a digest for ${target}; check this host can reach the registry"
fi
echo "provider_msp_upgrade_image_pin ${key} current=${current} target=${resolved}"
bundle_pin_keys+=("${key}")
bundle_pin_values+=("${resolved}")
export "${key}=${resolved}"
done
}
apply_bundle_upgrade() {
local install_dir="$1" env_path stamp file i
env_path="${install_dir}/.env"
stamp="$(date -u +'%Y%m%dT%H%M%SZ')"
cp -p "${env_path}" "${env_path}.pre-upgrade-${stamp}"
echo "provider_msp_upgrade_env_backup=${env_path}.pre-upgrade-${stamp}"
for file in "${bundle_install_files[@]}"; do
[[ -f "${bundle_dir}/${file}" ]] || die "bundle is missing ${file}"
case "${file}" in
*.sh) install -m 0755 "${bundle_dir}/${file}" "${install_dir}/${file}" ;;
*) install -m 0644 "${bundle_dir}/${file}" "${install_dir}/${file}" ;;
esac
done
for ((i = 0; i < ${#bundle_pin_keys[@]}; i++)); do
set_env_value "${bundle_pin_keys[$i]}" "${bundle_pin_values[$i]}" "${env_path}"
done
echo "provider_msp_upgrade_bundle_installed=true"
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${script_dir}"
@@ -87,7 +188,7 @@ backup_output="${PROVIDER_MSP_UPGRADE_BACKUP_OUTPUT:-}"
restore_target="${PROVIDER_MSP_UPGRADE_RESTORE_TARGET:-}"
run_id="${PROVIDER_MSP_UPGRADE_RUN_ID:-provider-msp-upgrade-$(date -u +'%Y%m%dT%H%M%SZ')}"
health_timeout="${PROVIDER_MSP_UPGRADE_HEALTH_TIMEOUT:-90s}"
keep_image_pins="${PROVIDER_MSP_UPGRADE_KEEP_IMAGE_PINS:-0}"
while (($# > 0)); do
case "$1" in
--dry-run)
@@ -126,6 +227,10 @@ while (($# > 0)); do
health_timeout="${2:-}"
shift 2
;;
--keep-image-pins)
keep_image_pins=1
shift
;;
-h|--help)
usage
exit 0
@@ -138,13 +243,22 @@ while (($# > 0)); do
esac
done
# Run from an extracted release bundle (it carries VERSION and no .env), work
# on the existing install and move it to that release.
install_dir="${script_dir}"
if [[ -f "${script_dir}/VERSION" && ! -f "${script_dir}/.env" ]]; then
install_dir="${PULSE_PROVIDER_MSP_INSTALL_DIR:-/opt/pulse-provider-msp}"
plan_bundle_upgrade "${install_dir}" "${script_dir}"
cd "${install_dir}"
fi
if [[ ! -f .env ]]; then
die "deploy/provider-msp/.env is required; copy .env.example to .env and fill in the provider values"
fi
provider_data_dir="$(env_value PULSE_PROVIDER_MSP_DATA_DIR .env)"
provider_data_dir="${provider_data_dir:-/data}"
tenant_runtime_image="$(env_value CP_PULSE_IMAGE .env)"
tenant_runtime_image="${CP_PULSE_IMAGE:-$(env_value CP_PULSE_IMAGE .env)}"
if [[ -z "${tenant_runtime_image}" ]]; then
die "CP_PULSE_IMAGE is required"
fi
@@ -169,11 +283,21 @@ echo "provider_msp_upgrade_run_id=${run_id}"
echo "provider_msp_upgrade_restore_target=${restore_target}"
echo "provider_msp_upgrade_tenant_runtime_image=${tenant_runtime_image}"
# Status checks that the tenant runtime image is already on this host, and runs
# before anything else would pull it, so a new CP_PULSE_IMAGE failed the gate on
# every upgrade. Pulling changes nothing that is running.
if ! truthy "${skip_runtime_image_pull}"; then
docker pull "${tenant_runtime_image}" >/dev/null
fi
run_control provider-msp status
run_control "${preflight_args[@]}"
if truthy "${dry_run}"; then
run_control tenant-runtime rollout --all --image "${tenant_runtime_image}" --dry-run
if [[ -n "${bundle_dir}" ]]; then
echo "provider_msp_upgrade_bundle_installed=false"
fi
echo "tenant_runtime_rollout_applied=false"
echo "provider_msp_upgrade_plan_ok=true"
exit 0
@@ -195,6 +319,11 @@ run_control provider-msp backup verify "${archive_path}"
run_control provider-msp backup restore "${archive_path}" --target-data-dir "${restore_target}" --dry-run
run_control provider-msp status --require-backup
# The platform is checked and backed up; now install the bundle and re-pin.
if [[ -n "${bundle_dir}" ]]; then
apply_bundle_upgrade "${install_dir}"
fi
if ! truthy "${skip_compose_pull}"; then
docker compose pull traefik docker-socket-proxy control-plane
fi
+1 -1
View File
@@ -31,7 +31,7 @@ Pulse automatically captures the following events:
| `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 |
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; agents poll every minute, so repeat polls are not recorded | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
Each event includes:
- **Timestamp** (UTC)
@@ -53,6 +53,28 @@ is exposed as unavailable, rather than replaced with a synthetic healthy state.
Delivery-log diagnostic text is a notification-attempt projection, not an agent admission or liveness verdict. Masking embedded destination credentials preserves failure context without changing agent identities, credentials, session replacement or removal policy. Consumers must use the retained failureClass and outcome as delivery evidence only; a transport error does not establish that the monitored agent is offline.
### Accepted reports refresh the canonical store at most once per window — issue #2199
Every canonical store refresh is estate-wide, so refreshing on each accepted
report made total cost grow with agent count times estate size. Accepted host,
Docker and Kubernetes reports, including rejected-report liveness, therefore
refresh through `refreshUnifiedResourceStoreAfterAgentReport`, throttled to one
refresh per `agentReportRefreshInterval` (2 seconds, the same staleness the
read paths accept through `readPathRegistryFreshness`). The first report after
a quiet window is published before the handler returns. Reports inside the
window fold into one trailing refresh at the window's end, which reads the
latest state and broadcasts it, so a report reaches ReadState consumers within
the window rather than immediately. Agent removal and host-agent evaluation
still refresh immediately, so removed inventory retires at once. `Monitor.Stop`
cancels a pending trailing refresh and waits for a running one before closing
stores. Monitors built without `New` keep a zero window and refresh on every
report. `TestAgentReportRefreshFoldsReportsWithinWindow` and
`TestStopCancelsPendingAgentReportRefresh` pin the leading, trailing and
shutdown behaviour. The founder approved trading immediate visibility for this
bound on 24 September 2026.
### Canonical Patrol and Assistant continuation, 2026-09-07
### Canonical Patrol and Assistant continuation, 2026-09-07
Patrol planning uses the canonical action broker's plan-only boundary. Policy
@@ -582,6 +604,10 @@ acknowledgement stays a success rather than a rejection so a real agent does not
read a demo server as an outage and retry-storm it. Tests that assert report
admission or tenant isolation must therefore not enable mock mode, or the
assertion passes without exercising the boundary.
Report admission, config fetch, and continuity lookups read the live host list
through `GetLiveHostsSnapshot`, which copies only hosts (see the monitoring
contract); it must not route through a full state snapshot, which copied every
guest on each agent report.
Physical-disk evidence collected by a host agent must survive projection back
into monitoring's models. Absent evidence has to carry its declared sentinel
@@ -5212,6 +5238,11 @@ agent truth, effective enforcement, and bounded reason separately so lifecycle
surfaces can show desired-disabled/applied-enabled and
desired-enabled/applied-disabled as drift or attention, and no-report cases as
pending or unknown rather than in-sync.
Routine config polling is not a per-poll audit event:
`/api/agents/agent/{id}/config` audits every failed fetch, and a successful one
only on the agent's first delivery after start, a token or desired-config
change, or once a day (see the API contract).
That same canonical /api/auto-register path must also complete the live
post-registration contract after persistence: it must trigger discovery refresh
and emit the canonical `node_auto_registered` WebSocket payload instead of
@@ -6633,6 +6633,17 @@ The patrol-local `memory.ChangeDetector.GetChangesSummary` path now also
delegates to the shared memory recent-change presentation helper, so any
future fallback summary entry point inherits the same heading, resource
prefixing, and change-type labels without re-implementing the markdown shape.
That detector is a reader of legacy history, not a detector. Its on-disk
history (`ai_changes.json`) was written by releases that still ran change
detection, which patrol dropped when it moved to agentic execution. The
detector loads that file once at construction and never modifies it, so it
holds no lock and has no background save to join before a data directory is
removed. `DetectChanges`, its snapshot diffing and `ResourceSnapshot` were
reachable only from tests and are retired; new change history belongs in the
unified-resource timeline, not in this fallback. Tests seed the fallback
through a legacy `ai_changes.json`, the same path production reads, and
`TestChangeDetector_LegacyHistoryIsReadOnly` proves reads leave the file and
directory untouched.
Those unified-resource action and export audit records are now also exposed
through the enterprise audit read surface so operators can inspect the
execution trail without reaching into storage internals.
@@ -698,6 +698,20 @@ and slow-client eviction must use the same synchronized send/close ownership;
recovering a send-on-closed panic is not a substitute for a race-free channel
lifecycle.
Invalidation coalescing is a throttle, not a restart-on-signal debounce. The
first invalidation after a quiet period broadcasts after the 100 ms coalesce
window; later invalidations for the same audience (all clients, or one tenant)
replace the pending request without rescheduling it, and are broadcast no
sooner than `defaultStateBroadcastInterval` (2 seconds) after that audience's
previous broadcast. Every accepted agent report sends an invalidation and each
broadcast resolves and diffs the full frontend state (about 130 ms at 2,080
synthetic resources), so the debounce let broadcast work scale with agent
count, and signals closer together than the window could postpone broadcasts
indefinitely (#2199). `TestStateBroadcastsSpacedByInterval`,
`TestTenantStateBroadcastsSpacedByInterval`, and
`TestStateBroadcastNotStarvedBySteadySignals` pin the spacing, latest-state,
and no-starvation behaviour.
Browser clients declare their inbound state-frame ceiling on the WebSocket
upgrade URL before initial-state construction begins. If a complete snapshot
or a later delta exceeds that ceiling, the hub sends a compact
@@ -8694,6 +8708,16 @@ payload shape: `agentId`, `commandsEnabled`, `settings`, `issuedAt`, and
signed command decision and the signed settings payload, restricted to the
agent-applied settings key schema, rather than treating `desiredConfig` itself
as a direct member of that signature payload.
Its `agent_config_fetch` audit events record every failed fetch but a
successful fetch only when it is new audit information: the agent's first
delivery after Pulse starts, a different token or `desiredConfig` hash, or an
unchanged delivery last recorded 24 hours earlier. Agents poll every minute,
and auditing each poll made this event over 99.9% of audit rows. Recorded
successes carry `config=<desiredConfig hash>` and
`reason=first_since_start|token_changed|config_changed|daily` in their details.
`TestAgentConfigFetchAuditsNewDeliveriesAndEveryFailure` and
`TestAgentConfigFetchAuditTrackerRecordsOnlyNewDeliveries` pin the rule.
Agent profile delete and unassign clients must now also route canonical `204`
success handling through shared allowed-status helpers in
`frontend-modern/src/api/responseUtils.ts` instead of open-coding local
@@ -3455,6 +3455,40 @@ Regression coverage:
`TestEnsureTenantNetworkRejectsUnownedExistingNetwork` in
`internal/cloudcp/docker/manager_test.go`.
### A provider control plane keeps its portal up on a lapsed licence
A provider-hosted control plane used to refuse to start once its licence was
past expiry and the 7-day grace: `ValidateLicense` rejected it, so `LoadConfig`
failed and the portal went down at the next restart. That is exactly when the
provider needs the portal, because Plan is the only place to buy or renew;
the same happened to a paying provider whose subscription lapsed. Startup now
resolves an authentic, key-bound licence with
`pkglicensing.ValidateLicenseAllowingLapse`, preferring a current licence
(renewed first) and otherwise the lapsed one that expired later, and logs the
lapse. Nothing is unlocked by this: client runtimes still verify the provider
licence carried in each lease with `ValidateLicense` and drop MSP capabilities
once it lapses, workspace creation refuses with `provider_msp_license_lapsed`,
and the refresher still refuses to adopt a lapsed licence from the licence
server. The Plan panel reads `lapsed` from the plan state, says the evaluation
or plan ended and what that means for clients, and offers the plans, including
the same plan again after a paid plan ends; `provider-msp status` prints
`license_lapsed` without failing, so a lapsed install can still upgrade. The
portal also shows a control plane error's `message` in preference to its
machine `error` code, which had been surfacing codes such as
`provider_msp_license_lapsed` and `already_subscribed` as the whole message.
Verified on 2026-09-24 against the walkthrough lab: the control plane started
on an evaluation that lapsed 20 days earlier, the Plan panel offered Solo and
Starter, adding a client showed the lapse sentence, and with a live
subscription the refresher restored the paid licence on its own. Regression
coverage: `TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence`
in `pkg/licensing/service_lapsed_test.go`,
`TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence` in
`internal/cloudcp/config_test.go`,
`TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence` in
`internal/cloudcp/account/tenant_handlers_lapsed_test.go`, and
`TestProviderMSPLicenseRefreshRefusesALapsedLicence` in
`internal/cloudcp/provider_msp_license_refresh_test.go`.
### Provider-hosted MSP platforms buy and renew their own licence
A provider-hosted control plane now buys and renews its licence through the
@@ -2438,6 +2438,65 @@ artifact-selection behaviour.
## Current State
### Provider MSP upgrades move an install to a new release bundle
`setup.sh` resolves the image pins in `.env` to digests once, so `upgrade.sh`
only ever re-pulled the release a provider first installed; moving to a new
release meant finding the new digests by hand, and none of that release's
fixes reached the install. Run from a newly extracted and verified release
bundle (it carries `VERSION` and no `.env`), `upgrade.sh` now works on the
existing install (`PULSE_PROVIDER_MSP_INSTALL_DIR`, default
`/opt/pulse-provider-msp`): it resolves the bundle's `CONTROL_PLANE_IMAGE` and
`CP_PULSE_IMAGE` tags (stamped by `scripts/build-release.sh`) to digests and
prints current and target pins. It runs the status gate, preflight and the
verified backup with the new release's control plane through a shell override,
which compose prefers over `.env`, so those checks carry the new release's
fixes while nothing on disk changes. Only then does it keep a
`.env.pre-upgrade-<time>` copy, install the same bundle files `setup.sh`
installs, write the new pins and start the new images. `--keep-image-pins`
keeps hand-set pins. The runner also pulls the tenant runtime image before the
first status check, which otherwise failed every upgrade to a new
`CP_PULSE_IMAGE` with "not present locally". Verified on 2026-09-24 against
the walkthrough lab with a v6.4.5-rc.2 bundle: the pins resolved from the real
registry, and when that release's own status check failed the upgrade stopped
with `.env`, the compose file and `upgrade.sh` byte-identical and the control
plane unchanged. Regression coverage:
`TestProviderMSPUpgradeFromBundleRepinsToTheBundleRelease` in
`scripts/installtests/provider_msp_deploy_test.go`.
### Provider MSP install proof never strands a client slot
`run-install-proof.sh`, which setup's summary recommends before the first real
client, creates its temporary proof workspaces on the provider's own account,
so they count against the same client limit. It now checks for room before
creating any and refuses with the counts ("the proof creates 2 temporary client
workspaces but this account has 0 free (3 of 3 in use)"). Previously, on a
two-client evaluation that already had a client, it created the first proof
workspace, was refused the second, and returned no report, so its cleanup had
no workspace list and the first proof workspace stayed behind in one of the
evaluation's two slots. A proof that fails part-way now returns the IDs it
created, and `install-proof` removes them. Verified on 2026-09-24 against the
walkthrough lab: refusal before any workspace was created, client count
unchanged. Regression coverage:
`TestProviderMSPProofRefusesWithoutFreeSlotsAndCreatesNothing` in
`cmd/pulse-control-plane/provider_msp_proof_test.go` and
`TestProviderMSPInstallProofCleansUpWorkspacesFromAPartialProof` in
`cmd/pulse-control-plane/provider_msp_install_proof_test.go`.
### A lapsed provider MSP install still starts and upgrades
A provider-hosted install whose licence is past expiry and grace (an
evaluation that ran out, or a paid plan that was not renewed) now starts its
control plane instead of crash-looping on licence validation, so the portal
and its Plan tab stay reachable to buy or renew. `provider-msp status`, which
`upgrade.sh` gates on, prints `license_lapsed=true` as information rather than
a failure, so such an install can still be upgraded. Client runtimes keep
enforcing the lapse themselves and new clients are refused until a current
licence is in place. Verified on 2026-09-24 on the walkthrough lab with an
evaluation that lapsed 20 days earlier. Regression coverage:
`TestProviderMSPStatusReportsALapsedLicenceWithoutFailing` in
`cmd/pulse-control-plane/provider_msp_status_test.go`.
### Provider MSP setup points buyers at the portal
`deploy/provider-msp/setup.sh` no longer tells an evaluating provider to
@@ -30,6 +30,23 @@ of an unchanged snapshot must not append duplicate change records.
cover the publish and blocked-persistence boundaries. Synthetic performance
improvement alone does not establish field CPU relief.
### One registry snapshot per store refresh pass — issue #2199
Each store refresh pass (`updateResourceStore`, its read-path twin, and so
every accepted agent report) ran five metric syncs and the alert sync, and each
cloned the whole registry through `GetAll`. A pass now wraps the store in
`resourceSnapshotStore`, which clones once and forwards metrics-target
resolution, so every consumer of the pass reads one generation. Stores without
metrics-target resolution pass through unwrapped, keeping the syncs' capability
checks unchanged. `TestAgentReportRefreshClonesRegistryOnce` requires one clone
per agent-report refresh and `TestResourceSnapshotStoreClonesOncePerPass` pins
the wrapper. With node-indexed guest parents, a synthetic agent-report refresh
at 2,080 resources moved from 188 to 82 ms; the pass still covers the whole
estate, and report-driven passes run at most once per 2-second window (see
the agent-lifecycle contract).
### Linked Pulse agent memory for Proxmox LXC — issue #2148 (22 September 2026)
### Linked Pulse agent memory for Proxmox LXC — issue #2148 (22 September 2026)
Both LXC memory paths (the efficient `cluster/resources` builder and the
@@ -2410,6 +2427,14 @@ must apply the same continuity overlay when `HostsSnapshot()` resolves its
canonical read state, so settings and other host-list consumers do not blank
previously admitted Pulse Agent rows during a config-driven monitor swap while
fresh reports are still in flight.
Continuity lookups must stay cheap because every canonical read-state lookup
makes one. `readStateWithStandaloneHostContinuity` takes the live host list
once per call, and `GetLiveHostsSnapshot` copies only hosts through
`State.GetHosts` rather than deep-copying every guest through `GetSnapshot`;
agent reports and config fetches share that accessor. With one offline
standalone agent, a lookup at 2,080 synthetic resources moved from 136 ms to
74 microseconds. `TestStandaloneHostContinuityReadStateReusedAcrossLookups` and
`TestGetLiveHostsSnapshotCopiesOnlyHosts` pin both.
That same mock-runtime boundary also owns freshness while demos are running.
The mock update loop must keep provider-backed TrueNAS and VMware records plus
legacy PBS and PMG summaries on current `LastSeen` and health state each tick,
@@ -2674,6 +2674,17 @@ turning inactive rows into recovery state or preloading their inventories. The
surface contract pins the route query map, the inactive recovery null query,
and the direct Protection query parameters.
### Agent config fetch auditing leaves recovery evidence unchanged
`/api/agents/agent/{id}/config` now records a successful fetch in the security
audit log only when it is new audit information (the agent's first delivery
after start, a token or desired-config change, or once a day), while every
failed fetch is still recorded. This changes no storage or recovery path:
recovery actions keep their own action audits, dispatch receipts, and
verification records, and only routine successful config polls stop producing
one security-audit row each. The rule is pinned by
`internal/api/host_agent_removal_lifecycle_integration_test.go`.
### Fresh agent command policy preserves the existing recovery authority boundary
New self-hosted install-command tokens project their explicit command-policy
@@ -38,6 +38,41 @@ compares the old and new paths, while
boundary. The 1,000-resource benchmark is component evidence, not installed
fleet CPU attribution or a release acceptance claim.
### Node-indexed guest parent resolution during ingest — issue #2199
Guest ingest resolves each Proxmox guest's parent node, and that lookup can
fall back to scanning for agent candidates by node name. Walking every resource
for every guest made each snapshot rebuild quadratic in the estate. Every
agent-producing source is ingested before the VM and container loops, so the
registry buckets agents by lowercased node name for those loops, reusing
`agentNodeScanIndex` from `buildChildCounts`. `ingestRecord` rebuilds a live
index whenever an ingest yields an agent, and the indexed walk skips entries
that are no longer the registry's current object for their ID, so resolution
matches the full scan exactly. `TestGuestIngestParentsMatchFullAgentScan`
compares every guest's ingest-time parent with the full scan, including a
cluster whose guests are reachable only through the scan, and
`TestAgentNodeScanIndexTracksAgentsIngestedWhileLive` pins the rebuild. On a
synthetic estate of 8-node clusters the rebuild moved from 74 to 42 ms at 2,080
resources and now grows linearly; that is component evidence, not installed
fleet CPU attribution.
### Host continuity overlays reuse one build per registry generation
`ReadStateWithHostContinuity` overlays standalone agents that are offline
across a restart onto the live read state by building a registry clone. The
monitor requests that overlay on every canonical read-state lookup, which
alert evaluation makes once per resource per poll, so a single offline agent
made every poll rebuild the registry once per resource for up to 72 hours. The
adapter now keeps the last overlay keyed by registry pointer, `LastRebuiltAt`,
and a fingerprint of the requested records (excluding the build-time
`UpdatedAt` stamp), and reuses it for at most `overlayReadStateMaxAge` (2
seconds), since the overlay evaluates source staleness when built. The live
adapter is never mutated.
`TestHostContinuityOverlayReusedWithinRegistryGeneration` pins reuse, rebuilds
on a new generation or different records, and the age bound.
### Bounded incident-history selection
Canonical frontend memory withdrawal is explicit: when a resource snapshot omits the canonical memory metric and its Proxmox memory facet marks usageUnavailable, the display merge must clear any previous metric. Plain partial omission remains compatible with richer REST state, and an incoming canonical metric (including measured zero) takes precedence over unavailable raw evidence. The adapter transition tests pin withdrawal and recovery; the hybrid-memory Chromium fixture exercises the rendered table and drawer at 1280px and 390px. Workload details remain canonical-only: withdrawal shows N/A in the table and removes the Memory section rather than manufacturing a raw-facet total; measured-zero recovery restores Total and Free, with screenshot positioning above fixed navigation. This does not change agent-only or arbitrary field-deletion semantics.
**VM-linked agent memory read — issue #1962 (7 September 2026)**
+13 -23
View File
@@ -1,23 +1,16 @@
{
"version": 1,
"base_sha": "4f11deddc728a69f937dcb04777c9e44276c3f95",
"verified_at": "2026-09-23T11:26:00Z",
"base_sha": "3b1b43293e21d06d1286124f96f78b1247443dea",
"verified_at": "2026-09-24T09:02:03Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/types/resource.ts"
"frontend-modern/src/components/Settings/BrandingSettingsCard.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "f6b50e71870ca4a3e0dcfa3e47413542e8444296bc68481c60e15d1a5d921f9f",
"frontend-modern/src/types/resource.ts": "db2509b73b2ed550ea1890aaef34e2a3dfe7d500aa193586562f9e20b95283ab"
"frontend-modern/src/components/Settings/BrandingSettingsCard.tsx": "6516a05aff71b1d96660f81189b6df8bceccf7e94d15d6c3213556e794f2ae40"
},
"routes": [
"/browser-tests/pbs-host-history-correlation.html?topology=guest&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=guest&order=reordered (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=pbs-only&order=reordered (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=stable (Proxmox Backups server drawer)",
"/browser-tests/pbs-host-history-correlation.html?topology=side-by-side&order=reordered (Proxmox Backups server drawer)"
"/settings/system-general (Application branding card, Header preview)"
],
"viewports": [
{
@@ -30,18 +23,15 @@
}
],
"states": [
"PBS connection configured by 10.0.0.5 and linked to the host agent only by the reported pbs.nodeName, across pbs-only, side-by-side and PVE-guest topologies",
"Drawer History tab selected with a populated correlated host series",
"Timer-driven snapshot replacing the datastore inventory in stable and reversed order",
"Snapshot that briefly omits the correlated host row while the PBS server row remains",
"Snapshot that restores the correlated host row on the same selection"
"No custom logo and no custom name: the preview falls back to the default Pulse lockup",
"Light colour scheme at 1280x900 and 390x844",
"Dark colour scheme at 1280x900"
],
"interactions": [
"Expand the Proxmox Backups server row, switch between datastore rows and select History; assert the chart paths render against the host metrics target even though the configured endpoint is not a machine-identity token",
"Apply timer-driven stable and reordered snapshots; assert the drawer is not remounted, the History selection is retained, the chart series survive and the Discovery/Metrics Target rows stay byte-identical",
"Apply a snapshot that omits the correlated host row; assert the Identity rows and History chart stay on the retained host target instead of falling back to the PBS service key",
"Restore the correlated host row; assert the same target is confirmed, and repeat the complete matrix at 1280x900 and 390x844"
"Open Settings > General with the white_label capability present; assert the Header preview renders exactly one svg.pulse-brand-logo (blue disc, white ring, white centre dot) and no \"\u25cf\" text glyph",
"Compare the preview mark with the page header lockup: both render PulseBrandMark at 20px",
"Check the page does not scroll horizontally at 390px wide"
],
"command": "pulse-worker-browser scripts/check-pbs-host-history-correlation.cjs",
"notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Production ProxmoxBackupServersTable mounted through the real browser fixture with synthetic API responses and an intercepted metrics-history request. Proves the reported-node-name host correlation and its retention across a transient host-row omission, not backend persistence or installed release behaviour. The recorded history requests target agent/agent-proxback and vm/proxmox:100 and never the PBS service target agent/pbs-1. The same check fails on the pre-fix source: the drawer falls back to the PBS service key when the endpoint is not a machine-identity token, and omitting the host row flips the Discovery and Metrics Target rows to agent:pbs-1."
"command": "node verify.mjs (Playwright against a private mock-mode backend on :7681 and Vite on :5199)",
"notes": "Chromium via Playwright 1.56.1 from repos/pulse/node_modules. Real app and backend in PULSE_MOCK_MODE with ALLOW_ADMIN_BYPASS; the only stub is /api/license/runtime-capabilities with white_label added, because the mock install has no white-label entitlement and the card is gated behind it. Before the fix the same preview drew a blue circle containing a \"\u25cf\" text character instead of the Pulse brand mark, as seen in a provider-hosted client workspace on the walkthrough lab. Console errors were only the mock environment's failing update check."
}
+1 -1
View File
@@ -31,7 +31,7 @@ Pulse automatically captures the following events:
| `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 |
| `agent_config_fetch` | Every failed agent configuration fetch. A successful fetch is recorded on the agent's first delivery after Pulse starts, when its token or delivered configuration changes, and otherwise once a day; agents poll every minute, so repeat polls are not recorded | `agent_id=… token_id=… config=sha256:… reason=config_changed` |
Each event includes:
- **Timestamp** (UTC)
@@ -1,6 +1,7 @@
import { Show, createSignal, type Accessor, type Component, type Setter } from 'solid-js';
import ImageIcon from 'lucide-solid/icons/image';
import Trash2 from 'lucide-solid/icons/trash-2';
import { PulseBrandMark } from '@/components/Brand/PulseBrandMark';
import { Button } from '@/components/shared/Button';
import { FeatureGateSection } from '@/components/shared/FeatureGateSection';
import { formControl, formHelpText, formLabel } from '@/components/shared/Form';
@@ -157,11 +158,7 @@ export const BrandingSettingsCard: Component<BrandingSettingsCardProps> = (props
<div class="mt-3 flex min-h-10 items-center justify-center gap-2 overflow-hidden rounded bg-surface px-3 py-2">
<Show
when={preview()}
fallback={
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
●
</span>
}
fallback={<PulseBrandMark class="h-5 w-5 shrink-0" decorative />}
>
{(logo) => (
<img
@@ -49,6 +49,27 @@ describe('BrandingSettingsCard', () => {
expect(brandingLogoPreview('YWJj', 'png')).toBe('data:image/png;base64,YWJj');
});
// Without a custom logo the preview must show what the header shows: the
// real Pulse brand mark, not a stand-in glyph.
it('previews the default header with the Pulse brand mark', () => {
const { container } = render(() => (
<BrandingSettingsCard
displayName={() => ''}
setDisplayName={() => ''}
logoBase64={() => ''}
setLogoBase64={() => ''}
logoFormat={() => ''}
setLogoFormat={() => ''}
setHasUnsavedChanges={() => false}
/>
));
const mark = container.querySelector('svg.pulse-brand-logo');
expect(mark).not.toBeNull();
expect(mark?.querySelectorAll('circle')).toHaveLength(3);
expect(container.textContent).not.toContain('●');
expect(screen.queryByTestId('branding-logo-preview')).toBeNull();
});
it('updates the application name and marks settings dirty', async () => {
const state = renderCard();
+3 -17
View File
@@ -338,18 +338,7 @@ func TestService_BuildEnrichedResourceContext(t *testing.T) {
s.mu.Unlock()
// Case 8: With Change Logic
cd := NewChangeDetector(memory.ChangeDetectorConfig{})
// Simulate a "created" change
cd.DetectChanges([]memory.ResourceSnapshot{
{
ID: "res1",
Name: "res1",
Type: "vm",
Status: "running",
SnapshotTime: now,
},
})
// Force persistence flush/processing if needed (DetectChanges does it async but returns changes immediately)
cd := newLegacyChangeDetector(t, legacyCreatedChange("res1", "res1", "vm"))
ps.SetChangeDetector(cd)
@@ -607,16 +596,13 @@ func TestService_BuildRelationshipContext_UsesCanonicalReadState(t *testing.T) {
func TestService_BuildRecentResourceChangesContext_FallsBackToMemoryFormatter(t *testing.T) {
s := &Service{}
ps := NewPatrolService(nil, nil)
cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
cd.DetectChanges([]ResourceSnapshot{
{ID: "res-fallback", Name: "fallback-resource", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
cd := newLegacyChangeDetector(t, legacyCreatedChange("res-fallback", "fallback-resource", "vm"))
ps.SetChangeDetector(cd)
s.patrolService = ps
got := s.buildRecentResourceChangesContext("res-fallback")
want := memory.FormatRecentChangesContext(cd.GetChangesForResource("res-fallback", 5), false, "###")
if got != want {
if got == "" || got != want {
t.Fatalf("expected fallback recent-resource-changes context to use shared memory formatter:\nwant %q\n got %q", want, got)
}
}
+2 -3
View File
@@ -740,8 +740,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) {
patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart})
patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart.Add(60 * time.Minute)})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{{ID: "vm-sum", Name: "vm-sum", Type: "vm", Status: "running", SnapshotTime: time.Now()}})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-sum", "vm-sum", "vm"))
remediations := memory.NewRemediationLog(memory.RemediationLogConfig{MaxRecords: 10})
_ = remediations.Log(memory.RemediationRecord{ResourceID: "vm-sum", Problem: "cpu", Action: "restart", Outcome: memory.OutcomeResolved})
@@ -762,7 +761,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) {
t.Error("expected recent changes in summary")
}
if len(summary.RecentChanges) == 0 {
t.Error("expected recent change entries in summary")
t.Fatal("expected recent change entries in summary")
}
if summary.RecentChanges[0].SourceType != ur.SourceHeuristic {
t.Fatalf("expected heuristic source type for fallback summary, got %s", summary.RecentChanges[0].SourceType)
+53 -25
View File
@@ -1,6 +1,9 @@
package ai
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -13,6 +16,49 @@ import (
ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// newLegacyChangeDetector loads changes the only way the detector still gets
// them in production: from a legacy ai_changes.json in its data directory.
func newLegacyChangeDetector(t *testing.T, changes ...memory.Change) *memory.ChangeDetector {
t.Helper()
dir := t.TempDir()
data, err := json.Marshal(changes)
if err != nil {
t.Fatalf("marshal legacy change history: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "ai_changes.json"), data, 0o600); err != nil {
t.Fatalf("write legacy change history: %v", err)
}
return memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10, DataDir: dir})
}
// legacyCreatedChange is a "created" entry as the retired detector recorded it.
func legacyCreatedChange(resourceID, name, resourceType string) memory.Change {
return memory.Change{
ID: "legacy-created-" + resourceID,
ResourceID: resourceID,
ResourceType: resourceType,
ResourceName: name,
ChangeType: memory.ChangeCreated,
DetectedAt: time.Now(),
Description: resourceType + " '" + name + "' created",
}
}
// legacyStatusChange is a status transition as the retired detector recorded it.
func legacyStatusChange(resourceID, name, resourceType, before, after string) memory.Change {
return memory.Change{
ID: "legacy-status-" + resourceID,
ResourceID: resourceID,
ResourceType: resourceType,
ResourceName: name,
ChangeType: memory.ChangeStatus,
Before: before,
After: after,
DetectedAt: time.Now(),
Description: "'" + name + "' status changed: " + before + " → " + after,
}
}
func TestNewIntelligence(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{DataDir: "/tmp/test"})
if intel == nil {
@@ -329,10 +375,7 @@ func TestIntelligence_GetResourceIntelligence_WithSubsystems(t *testing.T) {
func TestIntelligence_GetResourceIntelligence_FallsBackToChangeDetector(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "vm-rc", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-rc", "resource-vm", "vm"))
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
@@ -374,10 +417,7 @@ func TestIntelligence_GetRecentChanges_UsesCanonicalTimeline(t *testing.T) {
func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "vm-fallback", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-fallback", "resource-vm", "vm"))
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100)
@@ -483,10 +523,7 @@ func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) {
func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "vm-300", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-300", "fallback-vm", "vm"))
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
canonicalStore := ur.NewMemoryStore()
@@ -514,10 +551,7 @@ func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T)
func TestIntelligence_FormatContext_FallsBackToChangeDetector(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "vm-301", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-301", "fallback-vm", "vm"))
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
ctx := intel.FormatContext("vm-301")
@@ -559,10 +593,7 @@ func TestIntelligence_FormatGlobalContext_IncludesCanonicalRecentChanges(t *test
func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "node-fallback", Name: "fallback-node", Type: "node", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("node-fallback", "fallback-node", "node"))
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
ctx := intel.FormatGlobalContext()
@@ -603,14 +634,11 @@ func TestIntelligence_BuildRecentChangesContext_UsesCanonicalSectionFormatter(t
func TestIntelligence_BuildRecentChangesContext_FallsBackToMemoryFormatter(t *testing.T) {
intel := NewIntelligence(IntelligenceConfig{})
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
changes.DetectChanges([]memory.ResourceSnapshot{
{ID: "vm-302", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
})
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-302", "fallback-vm", "vm"))
ctx := intel.buildRecentChangesContext("vm-302", nil, changes, true, 5)
want := memory.FormatRecentChangesContext(changes.GetChangesForResource("vm-302", 5), true, "##")
if ctx != want {
if ctx == "" || ctx != want {
t.Fatalf("expected fallback recent-changes context to use shared memory formatter:\nwant %q\n got %q", want, ctx)
}
}
@@ -1,77 +0,0 @@
package memory
import (
"fmt"
"runtime"
"runtime/debug"
"testing"
"time"
)
func TestChangeDetectorMemoryStability(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory regression in short mode")
}
detector := NewChangeDetector(ChangeDetectorConfig{
MaxChanges: 50,
})
resourcesPerCycle := 120
warmupCycles := 4
measureCycles := 12
buildSnapshots := func(offset int, status string) []ResourceSnapshot {
now := time.Now()
snapshots := make([]ResourceSnapshot, 0, resourcesPerCycle)
for i := 0; i < resourcesPerCycle; i++ {
snapshots = append(snapshots, ResourceSnapshot{
ID: fmt.Sprintf("vm-%03d", i),
Name: fmt.Sprintf("vm-%03d", i),
Type: "vm",
Status: status,
Node: fmt.Sprintf("node-%d", i%3),
CPUCores: 2 + ((i + offset) % 4),
MemoryBytes: int64(2+(i+offset)%4) * 1024 * 1024 * 1024,
DiskBytes: int64(20+i%10) * 1024 * 1024 * 1024,
LastBackup: now.Add(-time.Duration((i+offset)%5) * time.Hour),
SnapshotTime: now,
})
}
return snapshots
}
for i := 0; i < warmupCycles; i++ {
status := "running"
if i%2 == 1 {
status = "stopped"
}
detector.DetectChanges(buildSnapshots(i*resourcesPerCycle, status))
}
runtime.GC()
debug.FreeOSMemory()
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
for i := 0; i < measureCycles; i++ {
status := "running"
if i%2 == 1 {
status = "stopped"
}
detector.DetectChanges(buildSnapshots(100000+i*resourcesPerCycle, status))
}
runtime.GC()
debug.FreeOSMemory()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if baseline.HeapAlloc > 0 {
allowed := baseline.HeapAlloc + 5*1024*1024
growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc)
if after.HeapAlloc > allowed && growthRatio > 1.25 {
t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio)
}
}
}
+17 -304
View File
@@ -1,13 +1,10 @@
// Package memory provides operational memory for AI context.
// It tracks changes to infrastructure, remediation actions, and their outcomes
// to enable the AI to provide more informed, experience-based recommendations.
// It keeps legacy infrastructure change history, remediation actions, and their
// outcomes to enable the AI to provide more informed, experience-based
// recommendations.
package memory
import (
"encoding/json"
"os"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
@@ -39,34 +36,19 @@ type Change struct {
Description string `json:"description"`
}
// ResourceSnapshot captures key attributes for change detection
type ResourceSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Node string `json:"node,omitempty"`
CPUCores int `json:"cpu_cores,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
DiskBytes int64 `json:"disk_bytes,omitempty"`
LastBackup time.Time `json:"last_backup,omitempty"`
SnapshotTime time.Time `json:"snapshot_time"`
}
// ChangeDetector tracks infrastructure changes over time
// ChangeDetector serves legacy infrastructure change history.
//
// Recent-change context comes from the unified-resource timeline; the detector
// is only its fallback. Its history (ai_changes.json) was written by releases
// that still ran change detection, which patrol dropped when it moved to
// agentic execution. The history is loaded once at construction and never
// modified afterwards, so reads need no lock.
type ChangeDetector struct {
mu sync.RWMutex
previousState map[string]ResourceSnapshot // resourceID -> snapshot
changes []Change
maxChanges int
changes []Change
maxChanges int
// Persistence
// dataDir holds the legacy history read by loadFromDisk.
dataDir string
// saveStateMu guards asynchronous save scheduling state.
saveStateMu sync.Mutex
saveRunning bool
saveRequested bool
}
// ChangeDetectorConfig configures the change detector
@@ -82,10 +64,9 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
}
d := &ChangeDetector{
previousState: make(map[string]ResourceSnapshot),
changes: make([]Change, 0),
maxChanges: cfg.MaxChanges,
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
changes: make([]Change, 0),
maxChanges: cfg.MaxChanges,
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
}
// Load existing changes from disk
@@ -100,174 +81,8 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
return d
}
// DetectChanges compares current snapshots to previous state and detects changes
func (d *ChangeDetector) DetectChanges(currentSnapshots []ResourceSnapshot) []Change {
d.mu.Lock()
defer d.mu.Unlock()
now := time.Now()
var newChanges []Change
// Track which resources we've seen in current snapshot
currentIDs := make(map[string]bool)
for _, current := range currentSnapshots {
currentIDs[current.ID] = true
prev, exists := d.previousState[current.ID]
if !exists {
// New resource
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeCreated,
After: current,
DetectedAt: now,
Description: formatCreateDescription(current),
}
newChanges = append(newChanges, change)
} else {
// Check for changes
changes := d.detectResourceChanges(prev, current, now)
newChanges = append(newChanges, changes...)
}
// Update previous state
d.previousState[current.ID] = current
}
// Check for deleted resources
for id, prev := range d.previousState {
if !currentIDs[id] {
change := Change{
ID: generateChangeID(),
ResourceID: id,
ResourceType: prev.Type,
ResourceName: prev.Name,
ChangeType: ChangeDeleted,
Before: prev,
DetectedAt: now,
Description: formatDeleteDescription(prev),
}
newChanges = append(newChanges, change)
delete(d.previousState, id)
}
}
// Store new changes
if len(newChanges) > 0 {
d.changes = append(d.changes, newChanges...)
d.trimChanges()
// Persist asynchronously
go func() {
if err := d.saveToDisk(); err != nil {
log.Warn().Err(err).Msg("failed to save change history")
}
}()
}
return newChanges
}
// detectResourceChanges checks for changes between two snapshots of the same resource
func (d *ChangeDetector) detectResourceChanges(prev, current ResourceSnapshot, now time.Time) []Change {
var changes []Change
// Status change
if prev.Status != current.Status {
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeStatus,
Before: prev.Status,
After: current.Status,
DetectedAt: now,
Description: formatStatusDescription(current.Name, prev.Status, current.Status),
}
changes = append(changes, change)
}
// Node change (migration)
if prev.Node != "" && current.Node != "" && prev.Node != current.Node {
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeMigrated,
Before: prev.Node,
After: current.Node,
DetectedAt: now,
Description: formatMigrationDescription(current.Name, prev.Node, current.Node),
}
changes = append(changes, change)
}
// CPU change
if prev.CPUCores > 0 && current.CPUCores > 0 && prev.CPUCores != current.CPUCores {
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeConfig,
Before: map[string]int{"cpu_cores": prev.CPUCores},
After: map[string]int{"cpu_cores": current.CPUCores},
DetectedAt: now,
Description: formatCPUChangeDescription(current.Name, prev.CPUCores, current.CPUCores),
}
changes = append(changes, change)
}
// Memory change (significant change > 5%)
if prev.MemoryBytes > 0 && current.MemoryBytes > 0 {
pctChange := float64(current.MemoryBytes-prev.MemoryBytes) / float64(prev.MemoryBytes)
if pctChange > 0.05 || pctChange < -0.05 {
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeConfig,
Before: map[string]int64{"memory_bytes": prev.MemoryBytes},
After: map[string]int64{"memory_bytes": current.MemoryBytes},
DetectedAt: now,
Description: formatMemoryChangeDescription(current.Name, prev.MemoryBytes, current.MemoryBytes),
}
changes = append(changes, change)
}
}
// Backup completed
if !prev.LastBackup.IsZero() && !current.LastBackup.IsZero() &&
current.LastBackup.After(prev.LastBackup) {
change := Change{
ID: generateChangeID(),
ResourceID: current.ID,
ResourceType: current.Type,
ResourceName: current.Name,
ChangeType: ChangeBackedUp,
Before: prev.LastBackup,
After: current.LastBackup,
DetectedAt: now,
Description: formatBackupDescription(current.Name, current.LastBackup),
}
changes = append(changes, change)
}
return changes
}
// GetChangesForResource returns recent changes for a specific resource
func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []Change {
d.mu.RLock()
defer d.mu.RUnlock()
var result []Change
// Iterate in reverse to get most recent first
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
@@ -280,9 +95,6 @@ func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []C
// GetRecentChanges returns the most recent changes across all resources
func (d *ChangeDetector) GetRecentChanges(limit int, since time.Time) []Change {
d.mu.RLock()
defer d.mu.RUnlock()
var result []Change
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
if d.changes[i].DetectedAt.After(since) {
@@ -306,35 +118,7 @@ func (d *ChangeDetector) trimChanges() {
}
}
// saveToDisk persists changes to JSON file
func (d *ChangeDetector) saveToDisk() error {
if d.dataDir == "" {
return nil
}
d.mu.RLock()
changes := make([]Change, len(d.changes))
copy(changes, d.changes)
d.mu.RUnlock()
path, err := memoryPersistencePath(d.dataDir, changeHistoryFileName)
if err != nil {
return err
}
data, err := json.MarshalIndent(changes, "", " ")
if err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
// loadFromDisk loads changes from JSON file
// loadFromDisk loads the legacy change history from its JSON file
func (d *ChangeDetector) loadFromDisk() error {
changes, ok, err := loadMemoryHistory(d.dataDir, changeHistoryFileName, "change history", func(a, b Change) bool {
return a.DetectedAt.Before(b.DetectedAt)
@@ -350,41 +134,6 @@ func (d *ChangeDetector) loadFromDisk() error {
// Helper functions
var changeCounter atomic.Uint64
func generateChangeID() string {
count := changeCounter.Add(1)
return time.Now().Format("20060102150405") + "-" + intToString(int(count%1000))
}
func formatBytes(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return formatFloat(float64(bytes)/GB) + " GB"
case bytes >= MB:
return formatFloat(float64(bytes)/MB) + " MB"
case bytes >= KB:
return formatFloat(float64(bytes)/KB) + " KB"
default:
return intToString(int(bytes)) + " B"
}
}
func formatFloat(v float64) string {
// Simple formatting without fmt dependency
whole := int(v)
frac := int((v - float64(whole)) * 10)
if frac == 0 {
return intToString(whole)
}
return intToString(whole) + "." + string(rune('0'+frac))
}
func intToString(n int) string {
if n == 0 {
return "0"
@@ -396,39 +145,3 @@ func intToString(n int) string {
}
return result
}
func formatCreateDescription(r ResourceSnapshot) string {
return r.Type + " '" + r.Name + "' created"
}
func formatDeleteDescription(r ResourceSnapshot) string {
return r.Type + " '" + r.Name + "' deleted"
}
func formatStatusDescription(name, before, after string) string {
return "'" + name + "' status changed: " + before + " → " + after
}
func formatMigrationDescription(name, fromNode, toNode string) string {
return "'" + name + "' migrated from " + fromNode + " to " + toNode
}
func formatCPUChangeDescription(name string, before, after int) string {
direction := "increased"
if after < before {
direction = "decreased"
}
return "'" + name + "' CPU " + direction + ": " + intToString(before) + " → " + intToString(after) + " cores"
}
func formatMemoryChangeDescription(name string, before, after int64) string {
direction := "increased"
if after < before {
direction = "decreased"
}
return "'" + name + "' memory " + direction + ": " + formatBytes(before) + " → " + formatBytes(after)
}
func formatBackupDescription(name string, backupTime time.Time) string {
return "'" + name + "' backup completed at " + backupTime.Format("2006-01-02 15:04")
}
@@ -1,40 +0,0 @@
package memory
import (
"sync"
"testing"
)
func TestGenerateChangeID_ConcurrentUnique(t *testing.T) {
changeCounter.Store(0)
const workers = 500
start := make(chan struct{})
ids := make(chan string, workers)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
<-start
ids <- generateChangeID()
}()
}
close(start)
wg.Wait()
close(ids)
seen := make(map[string]struct{}, workers)
for id := range ids {
if _, exists := seen[id]; exists {
t.Fatalf("duplicate change ID generated under concurrency: %s", id)
}
seen[id] = struct{}{}
}
if len(seen) != workers {
t.Fatalf("expected %d IDs, got %d", workers, len(seen))
}
}
+1 -91
View File
@@ -23,21 +23,6 @@ func TestChangeDetector_DefaultsAndHelpers(t *testing.T) {
if got := intToString(42); got != "42" {
t.Errorf("intToString(42) = %q", got)
}
if got := formatFloat(2.0); got != "2" {
t.Errorf("formatFloat(2.0) = %q", got)
}
if got := formatFloat(2.5); got != "2.5" {
t.Errorf("formatFloat(2.5) = %q", got)
}
cpu := formatCPUChangeDescription("vm-1", 4, 2)
if !strings.Contains(cpu, "decreased") {
t.Errorf("expected cpu decrease description, got %q", cpu)
}
mem := formatMemoryChangeDescription("vm-1", 8<<30, 4<<30)
if !strings.Contains(mem, "decreased") {
t.Errorf("expected memory decrease description, got %q", mem)
}
}
func TestNewChangeDetector_LoadsFromDisk(t *testing.T) {
@@ -79,67 +64,6 @@ func TestNewChangeDetector_LoadError(t *testing.T) {
}
}
func TestChangeDetector_SaveToDisk_Scenarios(t *testing.T) {
t.Run("NoDataDir", func(t *testing.T) {
d := &ChangeDetector{}
if err := d.saveToDisk(); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
t.Run("MissingDir", func(t *testing.T) {
tmpDir := t.TempDir()
missing := filepath.Join(tmpDir, "missing")
d := &ChangeDetector{
dataDir: missing,
changes: []Change{{ID: "c1"}},
}
if err := d.saveToDisk(); err == nil {
t.Fatal("expected error for missing directory")
}
})
t.Run("MarshalError", func(t *testing.T) {
tmpDir := t.TempDir()
d := &ChangeDetector{
dataDir: tmpDir,
changes: []Change{{ID: "c1", Before: func() {}}},
}
if err := d.saveToDisk(); err == nil {
t.Fatal("expected marshal error")
}
})
t.Run("RenameError", func(t *testing.T) {
tmpDir := t.TempDir()
destDir := filepath.Join(tmpDir, "ai_changes.json")
if err := os.MkdirAll(destDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
d := &ChangeDetector{
dataDir: tmpDir,
changes: []Change{{ID: "c1"}},
}
if err := d.saveToDisk(); err == nil {
t.Fatal("expected rename error")
}
})
t.Run("Success", func(t *testing.T) {
tmpDir := t.TempDir()
d := &ChangeDetector{
dataDir: tmpDir,
changes: []Change{{ID: "c1"}},
}
if err := d.saveToDisk(); err != nil {
t.Fatalf("saveToDisk error: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "ai_changes.json")); err != nil {
t.Fatalf("expected file to exist: %v", err)
}
})
}
func TestChangeDetector_LoadFromDisk_Scenarios(t *testing.T) {
t.Run("NoFile", func(t *testing.T) {
d := &ChangeDetector{dataDir: t.TempDir()}
@@ -232,20 +156,6 @@ func TestChangeDetector_LoadFromDisk_Scenarios(t *testing.T) {
})
}
func TestChangeDetector_DetectChanges_SaveError(t *testing.T) {
tmpDir := t.TempDir()
badDir := filepath.Join(tmpDir, "not-dir")
if err := os.WriteFile(badDir, []byte("x"), 0600); err != nil {
t.Fatalf("write file: %v", err)
}
detector := NewChangeDetector(ChangeDetectorConfig{DataDir: badDir})
detector.DetectChanges([]ResourceSnapshot{
{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "running"},
})
time.Sleep(20 * time.Millisecond)
}
func TestIncidentStore_DefaultsAndSummary(t *testing.T) {
store := NewIncidentStore(IncidentStoreConfig{})
if store.maxIncidents != defaultIncidentMaxIncidents {
@@ -913,10 +823,10 @@ func TestRemediationLog_Log_SaveError(t *testing.T) {
dataDir: badDir,
maxRecords: 1,
}
// Log persists before returning, so the failed save has already run here.
if err := log.Log(RemediationRecord{Problem: "p", Action: "a"}); err != nil {
t.Fatalf("log error: %v", err)
}
time.Sleep(20 * time.Millisecond)
}
func TestNewRemediationLog_LoadsFromDisk(t *testing.T) {
+40 -179
View File
@@ -1,6 +1,7 @@
package memory
import (
"bytes"
"os"
"path/filepath"
"testing"
@@ -11,104 +12,16 @@ import (
// Additional tests to improve coverage
func TestChangeDetector_ConfigChange(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state with memory
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024},
})
// Memory increased - should detect config change
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 8 * 1024 * 1024 * 1024},
})
if len(changes) != 1 {
t.Fatalf("Expected 1 config change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeConfig {
t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_DiskChange(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state with disk
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 100 * 1024 * 1024 * 1024},
})
// Disk increased significantly (>5%) - should detect config change
// Note: The implementation may not track disk changes, so this test documents behavior
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 200 * 1024 * 1024 * 1024},
})
// Disk changes may not be tracked by the implementation - adjust expectation
// This test documents the current behavior
_ = changes
}
func TestChangeDetector_CPUChange(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state with CPUCores
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 2},
})
// CPUCores increased - should detect config change
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 4},
})
if len(changes) != 1 {
t.Fatalf("Expected 1 config change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeConfig {
t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_BackupChange(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
oldBackup := time.Now().Add(-24 * time.Hour)
newBackup := time.Now()
// Initial state with old backup
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: oldBackup},
})
// New backup completed
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: newBackup},
})
if len(changes) != 1 {
t.Fatalf("Expected 1 backup change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeBackedUp {
t.Errorf("Expected ChangeBackedUp, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_GetChangesSummary(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Create some changes
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
})
d := &ChangeDetector{maxChanges: 100, changes: []Change{
{ID: "c1", ResourceID: "vm-100", ResourceType: "vm", ResourceName: "web-server", ChangeType: ChangeCreated, DetectedAt: time.Now(), Description: "vm 'web-server' created"},
}}
since := time.Now().Add(-1 * time.Hour)
summary := d.GetChangesSummary(since, 5)
want := FormatRecentChangesContext(d.GetRecentChanges(5, since), false, "##")
if summary != want {
if summary == "" || summary != want {
t.Fatalf("expected shared recent-changes formatter, got %q want %q", summary, want)
}
}
@@ -124,50 +37,41 @@ func TestChangeDetector_GetChangesSummary_NoChanges(t *testing.T) {
}
}
func TestChangeDetector_MultipleChangesAtOnce(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024, CPUCores: 2},
})
// Multiple changes at once: status, memory, CPUCores, and migration
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node2", MemoryBytes: 8 * 1024 * 1024 * 1024, CPUCores: 4},
})
// Should detect multiple changes
if len(changes) < 2 {
t.Errorf("Expected multiple changes, got %d", len(changes))
func TestChangeDetector_LegacyHistoryIsReadOnly(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "ai_changes.json")
legacy := []byte(`[{"id":"legacy-1","resource_id":"vm-100","resource_type":"vm","resource_name":"web-server","change_type":"created","detected_at":"2026-01-01T00:00:00Z","description":"vm 'web-server' created"}]`)
if err := os.WriteFile(path, legacy, 0600); err != nil {
t.Fatalf("write legacy history: %v", err)
}
}
func TestChangeDetector_Persistence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "changes-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create detector with persistence
d := NewChangeDetector(ChangeDetectorConfig{
MaxChanges: 100,
DataDir: tmpDir,
})
// Create some changes
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
})
// The legacy history is served through the read APIs.
if got := d.GetChangesForResource("vm-100", 10); len(got) != 1 || got[0].ID != "legacy-1" {
t.Fatalf("expected the legacy change, got %v", got)
}
if summary := d.GetChangesSummary(time.Time{}, 10); !containsStr(summary, "web-server") {
t.Fatalf("expected the legacy change in the summary, got %q", summary)
}
// Wait a bit for async save
time.Sleep(100 * time.Millisecond)
// Check if file was created (persistence is async, might not exist)
filePath := filepath.Join(tmpDir, "ai_changes.json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Log("Changes file not created - persistence may be async")
// Nothing rewrites the file or adds to the data directory.
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("read data dir: %v", err)
}
if len(entries) != 1 || entries[0].Name() != "ai_changes.json" {
t.Fatalf("data dir must hold only the legacy history, got %v", entries)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read legacy history: %v", err)
}
if !bytes.Equal(data, legacy) {
t.Fatalf("legacy history was rewritten: %s", data)
}
}
@@ -304,11 +208,7 @@ func TestRemediationLog_FormatForContext_NoRecords(t *testing.T) {
}
func TestRemediationLog_Persistence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "remediation-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
tmpDir := t.TempDir()
// Create log with persistence
r := NewRemediationLog(RemediationLogConfig{
@@ -316,20 +216,19 @@ func TestRemediationLog_Persistence(t *testing.T) {
DataDir: tmpDir,
})
_ = r.Log(RemediationRecord{
if err := r.Log(RemediationRecord{
ResourceID: "vm-100",
Problem: "Test problem",
Action: "Test action",
Outcome: OutcomeResolved,
})
}); err != nil {
t.Fatalf("Log: %v", err)
}
// Wait a bit for async save
time.Sleep(100 * time.Millisecond)
// Check if file was created (persistence may be async)
// Log persists before returning.
filePath := filepath.Join(tmpDir, "ai_remediations.json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Log("Remediation file not created - persistence may be async")
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("expected remediation file after Log: %v", err)
}
}
@@ -355,27 +254,6 @@ func TestFormatDuration(t *testing.T) {
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
input int64
contains string
}{
{500, "B"},
{1024, "KB"},
{1024 * 1024, "MB"},
{1024 * 1024 * 1024, "GB"},
// Note: formatBytes doesn't handle TB, it shows as GB
{1024 * 1024 * 1024 * 1024, "GB"},
}
for _, tt := range tests {
result := formatBytes(tt.input)
if !containsStr(result, tt.contains) {
t.Errorf("formatBytes(%d) = %q, expected to contain %q", tt.input, result, tt.contains)
}
}
}
func TestTruncateOutput(t *testing.T) {
tests := []struct {
input string
@@ -395,23 +273,6 @@ func TestTruncateOutput(t *testing.T) {
}
}
func TestChangeDetector_TrimChanges(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 3})
// Add 5 changes (exceeds max of 3)
for i := 0; i < 5; i++ {
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1", CPUCores: i + 1},
})
}
// Should only have 3 changes after trimming
changes := d.GetRecentChanges(100, time.Time{})
if len(changes) > 3 {
t.Errorf("Expected at most 3 changes after trimming, got %d", len(changes))
}
}
func TestRemediationLog_TrimRecords(t *testing.T) {
r := NewRemediationLog(RemediationLogConfig{MaxRecords: 3})
+24 -146
View File
@@ -5,123 +5,24 @@ import (
"time"
)
func TestChangeDetector_DetectNew(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// First detection - should see new resource
snapshots := []ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
}
changes := d.DetectChanges(snapshots)
if len(changes) != 1 {
t.Errorf("Expected 1 change (creation), got %d", len(changes))
}
if changes[0].ChangeType != ChangeCreated {
t.Errorf("Expected ChangeCreated, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_DetectStatusChange(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
})
// Status change
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node1"},
})
if len(changes) != 1 {
t.Errorf("Expected 1 status change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeStatus {
t.Errorf("Expected ChangeStatus, got %s", changes[0].ChangeType)
}
if changes[0].Before != "running" || changes[0].After != "stopped" {
t.Errorf("Expected running->stopped, got %v->%v", changes[0].Before, changes[0].After)
}
}
func TestChangeDetector_DetectMigration(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
})
// Migration
changes := d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node2"},
})
if len(changes) != 1 {
t.Errorf("Expected 1 migration change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeMigrated {
t.Errorf("Expected ChangeMigrated, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_DetectDeleted(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
// Initial state
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
})
// Delete (empty snapshot)
changes := d.DetectChanges([]ResourceSnapshot{})
if len(changes) != 1 {
t.Errorf("Expected 1 deletion change, got %d", len(changes))
}
if changes[0].ChangeType != ChangeDeleted {
t.Errorf("Expected ChangeDeleted, got %s", changes[0].ChangeType)
}
}
func TestChangeDetector_NoChanges(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
snapshot := []ResourceSnapshot{
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
}
// First time - creates
d.DetectChanges(snapshot)
// Second time - no changes
changes := d.DetectChanges(snapshot)
if len(changes) != 0 {
t.Errorf("Expected 0 changes, got %d", len(changes))
}
}
func TestChangeDetector_GetChangesForResource(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
now := time.Now()
d := &ChangeDetector{maxChanges: 100, changes: []Change{
{ID: "c1", ResourceID: "vm-100", ChangeType: ChangeCreated, DetectedAt: now.Add(-3 * time.Minute)},
{ID: "c2", ResourceID: "vm-200", ChangeType: ChangeCreated, DetectedAt: now.Add(-2 * time.Minute)},
{ID: "c3", ResourceID: "vm-100", ChangeType: ChangeStatus, DetectedAt: now.Add(-time.Minute)},
}}
// Create and change a few times
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"},
{ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"},
})
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
{ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"},
})
// Get changes for vm-100 only
// Get changes for vm-100 only, most recent first
changes := d.GetChangesForResource("vm-100", 10)
for _, c := range changes {
if c.ResourceID != "vm-100" {
t.Errorf("Got change for wrong resource: %s", c.ResourceID)
}
if len(changes) != 2 {
t.Fatalf("Expected 2 changes for vm-100, got %d", len(changes))
}
if changes[0].ID != "c3" || changes[1].ID != "c1" {
t.Errorf("Expected vm-100 changes newest first, got %s, %s", changes[0].ID, changes[1].ID)
}
if limited := d.GetChangesForResource("vm-100", 1); len(limited) != 1 || limited[0].ID != "c3" {
t.Errorf("Expected the limit to keep only the newest change, got %v", limited)
}
}
@@ -164,18 +65,16 @@ func TestRemediationLog_Stats(t *testing.T) {
}
func TestChangeDetector_GetRecentChanges(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
now := time.Now()
d := &ChangeDetector{maxChanges: 100, changes: []Change{
{ID: "old", ResourceID: "vm-100", DetectedAt: now.Add(-2 * time.Hour)},
{ID: "new", ResourceID: "vm-100", DetectedAt: now.Add(-10 * time.Minute)},
}}
// Create some changes
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
})
// Get recent changes
since := time.Now().Add(-1 * time.Hour)
changes := d.GetRecentChanges(10, since)
if len(changes) == 0 {
t.Error("Expected at least 1 recent change")
// Only changes inside the window are recent
changes := d.GetRecentChanges(10, now.Add(-1*time.Hour))
if len(changes) != 1 || changes[0].ID != "new" {
t.Errorf("Expected only the change inside the window, got %v", changes)
}
}
@@ -260,24 +159,3 @@ func TestRemediationLog_AutomaticVsManual(t *testing.T) {
t.Errorf("Expected 2 total, got %d", stats["total"])
}
}
func TestChangeDetector_Limit(t *testing.T) {
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 5})
// Create many changes to exceed limit
for i := 0; i < 10; i++ {
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
})
// Alternate status to create changes
d.DetectChanges([]ResourceSnapshot{
{ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"},
})
}
// Should have limited records
allChanges := d.GetRecentChanges(100, time.Time{})
if len(allChanges) > 5 {
t.Errorf("Expected max 5 changes due to limit, got %d", len(allChanges))
}
}
-3
View File
@@ -13,9 +13,6 @@ type ChangeDetectorConfig = memory.ChangeDetectorConfig
// Change is an alias for memory.Change
type Change = memory.Change
// ResourceSnapshot is an alias for memory.ResourceSnapshot
type ResourceSnapshot = memory.ResourceSnapshot
// ChangeType is an alias for memory.ChangeType
type ChangeType = memory.ChangeType
+8 -30
View File
@@ -193,21 +193,10 @@ func TestPatrolService_BuildScopedSetForRuntime_TypeOnlyScopeWithCorrelation(t *
func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) {
ps := NewPatrolService(nil, nil)
changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
changeDetector.DetectChanges([]ResourceSnapshot{{
ID: "qemu/101",
Name: "vm-1",
Type: "vm",
Status: "running",
SnapshotTime: time.Now().Add(-2 * time.Hour),
}})
changeDetector.DetectChanges([]ResourceSnapshot{{
ID: "qemu/101",
Name: "vm-1",
Type: "vm",
Status: "stopped",
SnapshotTime: time.Now().Add(-time.Hour),
}})
changeDetector := newLegacyChangeDetector(t,
legacyCreatedChange("qemu/101", "vm-1", "vm"),
legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"),
)
ps.SetChangeDetector(changeDetector)
node := ur.NewNodeView(&ur.Resource{ID: "node-1", Name: "node-1", Type: ur.ResourceTypeAgent})
@@ -230,21 +219,10 @@ func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) {
func TestBuildSeedContextState_TypeOnlyScopeIncludesCorrelatedRuntimeChange(t *testing.T) {
ps := NewPatrolService(nil, nil)
changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
changeDetector.DetectChanges([]ResourceSnapshot{{
ID: "qemu/101",
Name: "vm-1",
Type: "vm",
Status: "running",
SnapshotTime: time.Now().Add(-2 * time.Hour),
}})
changeDetector.DetectChanges([]ResourceSnapshot{{
ID: "qemu/101",
Name: "vm-1",
Type: "vm",
Status: "stopped",
SnapshotTime: time.Now().Add(-time.Hour),
}})
changeDetector := newLegacyChangeDetector(t,
legacyCreatedChange("qemu/101", "vm-1", "vm"),
legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"),
)
ps.SetChangeDetector(changeDetector)
cfg := correlation.DefaultConfig()
+4 -3
View File
@@ -143,9 +143,10 @@ func TestSeedPrecomputeIntelligence_PopulatesSignals(t *testing.T) {
pd.RecordEvent(HistoricalEvent{ResourceID: "vm-1", EventType: EventHighCPU, Timestamp: now.Add(-12 * time.Hour)})
ps.SetPatternDetector(pd)
cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "running", SnapshotTime: now.Add(-2 * time.Hour)}})
cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "stopped", SnapshotTime: now.Add(-1 * time.Hour)}})
cd := newLegacyChangeDetector(t,
legacyCreatedChange("vm-1", "vm-1", "vm"),
legacyStatusChange("vm-1", "vm-1", "vm", "running", "stopped"),
)
ps.SetChangeDetector(cd)
corrCfg := DefaultCorrelationConfig()
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"sync"
"time"
)
// agentConfigFetchAuditInterval re-records an unchanged successful config
// delivery at most this often per agent, so the audit trail still shows
// continued access.
const agentConfigFetchAuditInterval = 24 * time.Hour
// maxAgentConfigFetchAudits bounds remembered deliveries. Beyond it, entries
// older than agentConfigFetchAuditInterval are dropped; they would be
// re-recorded on their next fetch anyway.
const maxAgentConfigFetchAudits = 4096
// agentConfigFetchAuditTracker decides which successful agent config fetches
// are audit events. Agents poll their config every minute, and recording each
// poll made agent_config_fetch over 99.9% of audit rows (about 1,440 a day per
// agent), burying the logins and failures the log exists to show. Failed
// fetches are always audited and never pass through here.
type agentConfigFetchAuditTracker struct {
mu sync.Mutex
last map[agentConfigFetchAuditKey]agentConfigFetchAuditEntry
}
type agentConfigFetchAuditKey struct {
orgID string
agentID string
}
type agentConfigFetchAuditEntry struct {
tokenID string
configHash string
auditedAt time.Time
}
func newAgentConfigFetchAuditTracker() *agentConfigFetchAuditTracker {
return &agentConfigFetchAuditTracker{last: make(map[agentConfigFetchAuditKey]agentConfigFetchAuditEntry)}
}
// observe records a successful delivery and reports whether it is new audit
// information, with the reason: the agent's first delivery since startup, a
// different token or delivered config, or an unchanged delivery last audited
// at least agentConfigFetchAuditInterval ago. A nil tracker audits every
// delivery.
func (t *agentConfigFetchAuditTracker) observe(orgID, agentID, tokenID, configHash string, now time.Time) (string, bool) {
if t == nil {
return "", true
}
key := agentConfigFetchAuditKey{orgID: orgID, agentID: agentID}
entry := agentConfigFetchAuditEntry{tokenID: tokenID, configHash: configHash, auditedAt: now}
t.mu.Lock()
defer t.mu.Unlock()
previous, seen := t.last[key]
reason := ""
switch {
case !seen:
reason = "first_since_start"
case previous.tokenID != tokenID:
reason = "token_changed"
case previous.configHash != configHash:
reason = "config_changed"
case now.Sub(previous.auditedAt) >= agentConfigFetchAuditInterval:
reason = "daily"
default:
return "", false
}
if !seen && len(t.last) >= maxAgentConfigFetchAudits {
for staleKey, stale := range t.last {
if now.Sub(stale.auditedAt) >= agentConfigFetchAuditInterval {
delete(t.last, staleKey)
}
}
}
t.last[key] = entry
return reason, true
}
+18 -3
View File
@@ -43,6 +43,8 @@ type UnifiedAgentHandlers struct {
// server upgrade on their next report instead of their next hourly update
// check. Empty (the default) omits it from acks.
serverVersion string
configFetchAudits *agentConfigFetchAuditTracker
}
// SetServerVersion supplies the running server version to include on report
@@ -63,7 +65,10 @@ func trimUnifiedAgentRoutePath(path string) string {
// NewUnifiedAgentHandlers constructs a new handler set for Pulse Unified Agent ingest.
func NewUnifiedAgentHandlers(mtm *monitoring.MultiTenantMonitor, m *monitoring.Monitor, hub *websocket.Hub) *UnifiedAgentHandlers {
return &UnifiedAgentHandlers{baseAgentHandlers: newBaseAgentHandlers(mtm, m, hub)}
return &UnifiedAgentHandlers{
baseAgentHandlers: newBaseAgentHandlers(mtm, m, hub),
configFetchAudits: newAgentConfigFetchAuditTracker(),
}
}
// HandleReport ingests Pulse Unified Agent reports.
@@ -607,8 +612,18 @@ func (h *UnifiedAgentHandlers) handleGetConfig(w http.ResponseWriter, r *http.Re
return
}
LogAuditEventForTenant(GetOrgID(r.Context()), "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true,
fmt.Sprintf("agent_id=%s token_id=%s", agentID, tokenID(record)))
configHash := ""
if signedConfig.DesiredConfig != nil {
configHash = signedConfig.DesiredConfig.Hash
}
orgID := GetOrgID(r.Context())
if reason, audit := h.configFetchAudits.observe(orgID, agentID, tokenID(record), configHash, time.Now()); audit {
details := fmt.Sprintf("agent_id=%s token_id=%s config=%s", agentID, tokenID(record), configHash)
if reason != "" {
details += " reason=" + reason
}
LogAuditEventForTenant(orgID, "agent_config_fetch", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, details)
}
}
func tokenID(record *config.APITokenRecord) string {
@@ -16,6 +16,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
)
type hostRemovalLifecycleHTTPRuntime struct {
@@ -647,3 +648,73 @@ func TestHostAgentRenameKeepsCommandGateAlignedWithChannelAdmission(t *testing.T
t.Fatal("channel admission admitted a different agent ID on a matching hostname")
}
}
// Agents poll their config every minute. Only deliveries that tell an auditor
// something new are recorded, while every failed fetch still is.
func TestAgentConfigFetchAuditsNewDeliveriesAndEveryFailure(t *testing.T) {
capture := &auditCaptureLogger{}
prevLogger := audit.GetLogger()
prevManager := GetTenantAuditManager()
audit.SetLogger(capture)
SetTenantAuditManager(nil)
t.Cleanup(func() {
audit.SetLogger(prevLogger)
SetTenantAuditManager(prevManager)
})
handler, monitor := newUnifiedAgentHandlers(t, nil)
hostID := seedUnifiedAgentHost(t, monitor)
monitorState(t, monitor).UpsertHost(models.Host{ID: hostID, Hostname: "node-1", TokenID: "runtime-token"})
fetch := func(scopes ...string) int {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil)
attachAPITokenRecord(req, &config.APITokenRecord{ID: "runtime-token", Scopes: scopes})
rec := httptest.NewRecorder()
handler.HandleConfig(rec, req)
return rec.Code
}
for i := 0; i < 3; i++ {
if code := fetch(config.ScopeAgentConfigRead, config.ScopeAgentReport); code != http.StatusOK {
t.Fatalf("config fetch %d status = %d, want 200", i, code)
}
}
commandsEnabled := true
if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil {
t.Fatalf("UpdateHostAgentConfig: %v", err)
}
for i := 0; i < 2; i++ {
if code := fetch(config.ScopeAgentConfigRead, config.ScopeAgentReport); code != http.StatusOK {
t.Fatalf("config fetch after change %d status = %d, want 200", i, code)
}
}
for i := 0; i < 2; i++ {
if code := fetch(config.ScopeMonitoringRead); code == http.StatusOK {
t.Fatalf("config fetch with only %s succeeded", config.ScopeMonitoringRead)
}
}
capture.mu.Lock()
events := append([]audit.Event(nil), capture.events...)
capture.mu.Unlock()
var successes []string
failures := 0
for _, event := range events {
if event.EventType != "agent_config_fetch" {
continue
}
if event.Success {
successes = append(successes, event.Details)
} else {
failures++
}
}
if len(successes) != 2 ||
!strings.Contains(successes[0], "reason=first_since_start") ||
!strings.Contains(successes[1], "reason=config_changed") {
t.Fatalf("successful fetch audit events = %q, want the first delivery and the config change", successes)
}
if failures != 2 {
t.Fatalf("failed fetch audit events = %d, want every failure", failures)
}
}
+6 -1
View File
@@ -13,6 +13,12 @@ import (
// BenchmarkNormalizeRoute measures route normalization with representative
// API paths of varying complexity.
//
// The "/" fast path is deliberately not timed here. Paired CI runs measured it
// at either 2.18ns or 2.50ns, about one CPU cycle apart, and changes that never
// touched this code flipped it both ways by 13-30%.
// TestNormalizeRoute_RootFastPathDoesNotAllocate pins what matters about the
// path instead.
func BenchmarkNormalizeRoute(b *testing.B) {
paths := []struct {
name string
@@ -23,7 +29,6 @@ func BenchmarkNormalizeRoute(b *testing.B) {
{"with_uuid", "/api/nodes/550e8400-e29b-41d4-a716-446655440000"},
{"deep_path_truncated", "/api/v1/orgs/123/resources/456/metrics"},
{"with_query_params", "/api/metrics-store/history?resourceType=vm&resourceId=pve1:node1:100&metric=cpu&range=1h"},
{"root", "/"},
{"metrics_history", "/api/metrics-store/history"},
}
+10
View File
@@ -246,6 +246,16 @@ func TestNormalizeRoute(t *testing.T) {
}
}
// The root and empty paths must return before splitting. Without the fast
// path, "/" still normalizes to "/" but allocates a split slice on every call.
func TestNormalizeRoute_RootFastPathDoesNotAllocate(t *testing.T) {
for _, path := range []string{"/", ""} {
if allocs := testing.AllocsPerRun(100, func() { _ = normalizeRoute(path) }); allocs != 0 {
t.Errorf("normalizeRoute(%q) allocated %v times per call, want 0", path, allocs)
}
}
}
// ---------------------------------------------------------------------------
// recordAPIRequest integration tests — verify that the core recording
// function correctly populates all three Prometheus metric vectors.
@@ -98,23 +98,43 @@ func TestIssue1753SameNameProxmoxAgentsAuthenticateAndStayDistinctEndToEnd(t *te
t.Fatalf("provider links = %+v, want each site linked to its own authenticated agent", linkedByInstance)
}
resources, _ := monitor.UnifiedResourceSnapshot()
byInstance := make(map[string]unifiedresources.Resource)
for _, resource := range resources {
if resource.Proxmox != nil && resource.Proxmox.NodeName != "" {
byInstance[resource.Proxmox.Instance] = resource
}
}
if len(byInstance) != 2 {
t.Fatalf("presentation provider rows = %d, want 2: %+v", len(byInstance), resources)
}
for instance, want := range map[string]struct {
wantRows := map[string]struct {
agentID string
name string
}{
"staging": {agentID: "machine-staging", name: "Staging"},
"production": {agentID: "machine-production", name: "Production"},
} {
}
presentationRows := func() ([]unifiedresources.Resource, map[string]unifiedresources.Resource) {
resources, _ := monitor.UnifiedResourceSnapshot()
byInstance := make(map[string]unifiedresources.Resource)
for _, resource := range resources {
if resource.Proxmox != nil && resource.Proxmox.NodeName != "" {
byInstance[resource.Proxmox.Instance] = resource
}
}
return resources, byInstance
}
rowsLinked := func(byInstance map[string]unifiedresources.Resource) bool {
for instance, want := range wantRows {
resource := byInstance[instance]
if resource.Agent == nil || resource.Agent.AgentID != want.agentID {
return false
}
}
return true
}
// Reports inside one refresh window are published together by a trailing
// refresh (agent-lifecycle contract, #2199), so wait for the canonical rows.
resources, byInstance := presentationRows()
for deadline := time.Now().Add(5 * time.Second); !rowsLinked(byInstance) && time.Now().Before(deadline); {
time.Sleep(20 * time.Millisecond)
resources, byInstance = presentationRows()
}
if len(byInstance) != 2 {
t.Fatalf("presentation provider rows = %d, want 2: %+v", len(byInstance), resources)
}
for instance, want := range wantRows {
resource := byInstance[instance]
if resource.Agent == nil || resource.Agent.AgentID != want.agentID || resource.Name != want.name {
t.Fatalf("%s presentation row = %+v, want agent %q and name %q", instance, resource, want.agentID, want.name)
@@ -552,3 +552,36 @@ func TestUnifiedAgentHandlers_HandleLinkUnlink(t *testing.T) {
t.Fatalf("unlink status = %d, want 200: %s", unlinkRec.Code, unlinkRec.Body.String())
}
}
func TestAgentConfigFetchAuditTrackerRecordsOnlyNewDeliveries(t *testing.T) {
tracker := newAgentConfigFetchAuditTracker()
start := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
for _, step := range []struct {
name string
token string
hash string
at time.Duration
reason string
audit bool
}{
{"first delivery", "tok-a", "sha256:1", 0, "first_since_start", true},
{"unchanged poll", "tok-a", "sha256:1", time.Minute, "", false},
{"config change", "tok-a", "sha256:2", 2 * time.Minute, "config_changed", true},
{"unchanged after change", "tok-a", "sha256:2", 3 * time.Minute, "", false},
{"token rotation", "tok-b", "sha256:2", 4 * time.Minute, "token_changed", true},
{"just under a day", "tok-b", "sha256:2", 4*time.Minute + agentConfigFetchAuditInterval - time.Second, "", false},
{"a day since last audit", "tok-b", "sha256:2", 4*time.Minute + agentConfigFetchAuditInterval, "daily", true},
} {
reason, audit := tracker.observe("default", "agent-1", step.token, step.hash, start.Add(step.at))
if audit != step.audit || reason != step.reason {
t.Fatalf("%s: audit=%v reason=%q, want audit=%v reason=%q", step.name, audit, reason, step.audit, step.reason)
}
}
if reason, audit := tracker.observe("other-org", "agent-1", "tok-b", "sha256:2", start.Add(5*time.Minute)); !audit || reason != "first_since_start" {
t.Fatalf("same agent ID in another org: audit=%v reason=%q, want its own first delivery", audit, reason)
}
var unset *agentConfigFetchAuditTracker
if _, audit := unset.observe("default", "agent-1", "tok-a", "sha256:1", start); !audit {
t.Fatal("a handler without a tracker must audit every delivery")
}
}
@@ -36,6 +36,10 @@ type ownerAwareWorkspaceProvisioner interface {
type WorkspaceLimitPolicy struct {
ProviderHostedMSP bool
ProviderMSPPlanVersion string
// ProviderMSPLicenseLapsed reports whether the provider's licence is past
// its expiry and grace period. The portal keeps running then, so the
// provider can buy or renew, but no new client workspace may be created.
ProviderMSPLicenseLapsed func() bool
}
// HandleListTenants lists all tenants for an account.
@@ -271,6 +275,13 @@ func enforceWorkspaceLimit(reg *registry.TenantRegistry, account *registry.Accou
usingProviderHostedPlan := false
if sa == nil {
if policy.ProviderHostedMSP && account != nil && account.Kind == registry.AccountKindMSP {
if policy.ProviderMSPLicenseLapsed != nil && policy.ProviderMSPLicenseLapsed() {
return &workspaceLimitError{
reason: "provider_msp_license_lapsed",
message: "Your Pulse MSP plan has ended, so no new clients can be added. Buy a plan from Plan in the portal to add clients again.",
statusCode: http.StatusForbidden,
}
}
planVersion = pkglicensing.CanonicalizePlanVersion(policy.ProviderMSPPlanVersion)
usingProviderHostedPlan = true
if strings.TrimSpace(planVersion) == "" {
@@ -698,3 +698,42 @@ func TestCreateWorkspace_BlockedWhenSubscriptionCanceled(t *testing.T) {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
// A provider control plane keeps running on a lapsed licence so its portal can
// sell the renewal, but it must not add clients until the provider buys.
func TestCreateWorkspace_ProviderHostedMSPRefusesNewClientsOnALapsedLicence(t *testing.T) {
reg := newTestRegistry(t)
lapsed := true
mux, _ := newTestTenantMuxWithWorkspaceLimitPolicy(
t,
reg,
t.TempDir(),
WorkspaceLimitPolicy{ProviderHostedMSP: true, ProviderMSPPlanVersion: "msp_eval", ProviderMSPLicenseLapsed: func() bool { return lapsed }},
cpstripe.WithDefaultMSPPlanVersion("msp_eval"),
)
accountID, err := registry.GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&registry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Provider MSP"}); err != nil {
t.Fatal(err)
}
create := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/accounts/"+accountID+"/tenants", bytes.NewBufferString(`{"display_name":"Acme Dental"}`))
return doRequest(t, mux, req)
}
rec := create()
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Buy a plan from Plan") {
t.Fatalf("create on a lapsed licence = %d %q, want 403 pointing to Plan", rec.Code, rec.Body.String())
}
if count, _ := reg.CountActiveByAccountID(accountID); count != 0 {
t.Fatalf("lapsed licence created %d workspaces", count)
}
// Once the provider buys and the licence is current again, clients can be added.
lapsed = false
if rec := create(); rec.Code != http.StatusCreated {
t.Fatalf("create after renewal = %d %q, want 201", rec.Code, rec.Body.String())
}
}
+68 -14
View File
@@ -234,6 +234,13 @@ func LoadConfig() (*CPConfig, error) {
providerMSPLicenseEmail = resolved.LicenseEmail
providerMSPLicenseKey = resolved.LicenseKey
providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey
if resolved.lapsed(time.Now()) {
log.Warn().
Str("license_id", resolved.LicenseID).
Str("plan_version", resolved.PlanVersion).
Time("expired_at", resolved.ExpiresAt).
Msg("Provider MSP license has lapsed: the portal stays up to sell the renewal, no new clients can be added, and client workspaces drop MSP capabilities")
}
}
cfg := &CPConfig{
@@ -626,7 +633,14 @@ type providerMSPLicenseResolution struct {
ExpiresAt time.Time
}
// resolveProviderMSPPlanFromLicenseFile validates a licence the control plane
// is about to adopt, such as one the licence server just returned. It refuses
// a lapsed licence.
func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) {
return resolveProviderMSPLicenseFile(path, false)
}
func resolveProviderMSPLicenseFile(path string, allowLapsed bool) (*providerMSPLicenseResolution, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required")
@@ -636,7 +650,11 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso
return nil, err
}
pkglicensing.InitEmbeddedPublicKey()
license, err := pkglicensing.ValidateLicense(licenseKey)
validate := pkglicensing.ValidateLicense
if allowLapsed {
validate = pkglicensing.ValidateLicenseAllowingLapse
}
license, err := validate(licenseKey)
if err != nil {
return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
}
@@ -689,26 +707,62 @@ func ProviderMSPRenewedLicensePath(dataDir string) string {
return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt")
}
// resolveProviderMSPLicense prefers a renewed licence that still validates
// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is
// tried first because the host file is usually the self-issued evaluation,
// which expires; a paying provider must keep starting after it does. The
// lease signing key check in validate still ties either licence to this
// control plane's private key.
// lapsed reports whether the licence is past its expiry and grace period, the
// point at which client runtimes stop accepting leases chained to it.
func (r *providerMSPLicenseResolution) lapsed(now time.Time) bool {
return providerMSPLicenseLapsed(r.ExpiresAt, now)
}
func providerMSPLicenseLapsed(expiresAt, now time.Time) bool {
return !expiresAt.IsZero() && now.After(expiresAt.Add(pkglicensing.DefaultGracePeriod))
}
// ProviderMSPLicenseLapsed reports whether this platform's licence is past its
// expiry and grace period. The control plane keeps running so its portal can
// sell the renewal, but it adds no clients, and client runtimes drop MSP
// capabilities on their own when they verify the lapsed licence.
func (c *CPConfig) ProviderMSPLicenseLapsed(now time.Time) bool {
if c == nil {
return false
}
return providerMSPLicenseLapsed(c.ProviderMSPLicenseExpiresAt, now)
}
// resolveProviderMSPLicense picks the licence a provider-hosted control plane
// starts on: a current renewed licence first, then a current
// CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence wins because the host file
// is usually the self-issued evaluation, which expires; a paying provider must
// keep starting after it does. When neither is current it still starts, on
// whichever lapsed licence expires later, because refusing to start would take
// down the portal, which is the only place a provider can buy or renew. Both
// candidates must be authentic Pulse licences, and validate still ties the
// chosen one to this control plane's lease signing key.
func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) {
now := time.Now()
var renewed *providerMSPLicenseResolution
renewedPath := ProviderMSPRenewedLicensePath(dataDir)
if _, err := os.Stat(renewedPath); err == nil {
renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath)
if err == nil {
candidate, err := resolveProviderMSPLicenseFile(renewedPath, true)
if err != nil {
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
} else {
renewed = candidate
}
}
if renewed != nil && !renewed.lapsed(now) {
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
host, err := resolveProviderMSPLicenseFile(hostFile, true)
if err != nil {
if renewed != nil {
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
}
resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile)
if err != nil {
return nil, "", err
}
return resolved, ProviderMSPPlanSourceLicenseFile, nil
if !host.lapsed(now) || renewed == nil || !renewed.ExpiresAt.After(host.ExpiresAt) {
return host, ProviderMSPPlanSourceLicenseFile, nil
}
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
}
func readProviderMSPLicenseFile(path string) (string, error) {
+45
View File
@@ -991,6 +991,51 @@ func TestLoadConfig_SessionTTLProviderHostedDefault(t *testing.T) {
}
}
// Refusing to start on a lapsed licence took the provider's portal down at the
// moment the provider needed it to buy. The control plane now starts, reports
// the licence as lapsed, and when both licences have lapsed it starts on the
// one that expired later, so a lapsed paid plan reads as that plan.
func TestLoadConfig_ProviderHostedMSPStartsOnALapsedLicence(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_ENV", "production")
dataDir := t.TempDir()
t.Setenv("CP_DATA_DIR", dataDir)
issuer := newProviderMSPTestIssuer(t)
key := trialSigningEnvPublicKey(t)
hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"),
issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key))
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig with only a lapsed evaluation: %v", err)
}
if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("lapsed evaluation resolved plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource)
}
if !cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatal("ProviderMSPLicenseLapsed = false for an evaluation that ended 60 days ago")
}
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-20*24*time.Hour), key))
cfg, err = LoadConfig()
if err != nil {
t.Fatalf("LoadConfig with both licences lapsed: %v", err)
}
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPPlanVersion != "msp_solo" || !cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatalf("both lapsed resolved plan=%q source=%q lapsed=%v, want the later-expiring paid licence",
cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseLapsed(time.Now()))
}
// Inside the 7-day grace the licence is expired but not yet lapsed.
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-2*24*time.Hour), key))
if cfg, err = LoadConfig(); err != nil || cfg.ProviderMSPLicenseLapsed(time.Now()) {
t.Fatalf("licence 2 days past expiry: err=%v lapsed=%v, want running and not lapsed", err, cfg != nil && cfg.ProviderMSPLicenseLapsed(time.Now()))
}
}
func TestLoadConfig_SessionTTLOverride(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_SESSION_TTL", "36h")
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "d5c69f37d9cf32a2e8ef727af6779bd44148235ed352fcb427967ef8a6a24859",
"source_hash": "2fa7bbb64e7cb3e9e7e187a81019fdbb723c9591543e9905dba8cf2617d5c958",
"build_inputs": [
"package.json",
"tsconfig.json",
+24 -8
View File
@@ -1034,14 +1034,14 @@
}
function messageFromPayload(payload, fallback) {
if (payload && typeof payload === "object") {
var errorMessage = payload.error;
if (typeof errorMessage === "string" && errorMessage.trim()) {
return errorMessage;
}
var message = payload.message;
if (typeof message === "string" && message.trim()) {
return message;
}
var errorMessage = payload.error;
if (typeof errorMessage === "string" && errorMessage.trim()) {
return errorMessage;
}
}
if (typeof payload === "string" && payload.trim()) {
return payload;
@@ -1517,7 +1517,7 @@
if (body.error !== "workspace_limit_reached") return "";
var entity = clientLanguage ? "client workspaces" : "workspaces";
var counts = typeof body.current === "number" && typeof body.limit === "number" && body.limit > 0 ? " (" + body.current + " of " + body.limit + " in use)" : "";
return "Your license limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or upgrade your license to add more.";
return "Your plan limit for " + entity + " is reached" + counts + ". Remove a " + (clientLanguage ? "client" : "workspace") + " or move to a bigger plan to add more.";
}
var createWorkspace = async function(accountID) {
var nameEl = getElement("ws-name-" + accountID);
@@ -2728,15 +2728,30 @@
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
}
function planEnded(plan, now) {
if (plan.lapsed) return true;
if (!plan.expires_at) return false;
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && now >= expiresAt;
}
function renderCurrentPlan(plan, canManage, busy, inUse, now) {
var expires = formatDate(plan.expires_at);
var title = providerPlanName(plan.plan_version);
var description;
var meta = [inUse >= 0 ? String(inUse) + " of " + clientWorkspaces(plan.workspace_limit) + " in use" : clientWorkspaces(plan.workspace_limit)];
var cta = "";
if (plan.evaluation) {
var ended = planEnded(plan, now);
var lostFeatures = plan.lapsed ? " Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added." : "";
if (plan.evaluation && ended) {
description = "Your evaluation ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Buy a plan to keep your clients monitored and to add more.";
} else if (plan.evaluation) {
description = expires ? "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + " until " + expires + ". Buy a plan to keep your clients monitored after that and to add more." : "Your evaluation covers " + clientWorkspaces(plan.workspace_limit) + ". Buy a plan to add more.";
if (expires) meta.push("Expires " + expires);
} else if (ended) {
description = "Your " + title + " plan ended" + (expires ? " on " + expires : "") + "." + lostFeatures + " Renew from Manage billing or buy a plan below.";
if (canManage) {
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' + (busy ? " disabled" : "") + ">" + (busy === "manage-billing" ? "Opening\u2026" : "Manage billing") + "</button>";
}
} else {
description = paidLicenceInGrace(plan, now) ? "Your subscription has not renewed. Your clients keep this plan until " + expires + ". Open Manage billing to renew or update your payment method." : "Up to " + clientWorkspaces(plan.workspace_limit) + ". Renews automatically while your subscription is active.";
if (canManage) {
@@ -2774,7 +2789,8 @@
var plan = view.plan;
if (!plan) return parts.join("");
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
if (plan.evaluation) {
var ended = planEnded(plan, now);
if (plan.evaluation || ended) {
if (plan.plans_error) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + "</p>");
} else if (plan.purchase_available) {
@@ -2783,7 +2799,7 @@
});
var cycle = hasAnnual ? view.cycle : "monthly";
var offers = plan.plans.filter(function(option) {
return option.billing_cycle === cycle && option.workspace_limit > plan.workspace_limit;
return option.billing_cycle === cycle && (ended && !plan.evaluation ? option.workspace_limit >= plan.workspace_limit : option.workspace_limit > plan.workspace_limit);
});
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));
@@ -188,8 +188,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
var counts = typeof body.current === 'number' && typeof body.limit === 'number' && body.limit > 0
? ' (' + body.current + ' of ' + body.limit + ' in use)'
: '';
return 'Your license limit for ' + entity + ' is reached' + counts + '. Remove a ' +
(clientLanguage ? 'client' : 'workspace') + ' or upgrade your license to add more.';
return 'Your plan limit for ' + entity + ' is reached' + counts + '. Remove a ' +
(clientLanguage ? 'client' : 'workspace') + ' or move to a bigger plan to add more.';
}
var createWorkspace = async function(accountID: string): Promise<void> {
@@ -73,6 +73,31 @@ describe('portal api', function() {
});
});
// A code-plus-sentence payload must show the sentence: the provider saw
// "provider_msp_license_lapsed" instead of what to do about it.
it('prefers the human message over the machine error code', async function() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
headers: new Headers({ 'content-type': 'application/json' }),
json: async function() {
return { error: 'provider_msp_license_lapsed', message: 'Your Pulse MSP plan has ended, so no new clients can be added.' };
},
}));
var api = createPortalAPI({
getBootstrap: function() {
return bootstrap;
},
});
await expect(api.createWorkspace('acct_1', { display_name: 'Acme' })).rejects.toMatchObject({
name: 'PortalAPIError',
status: 403,
message: 'Your Pulse MSP plan has ended, so no new clients can be added.',
});
});
it('keeps task-specific fallback copy on network failures', async function() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
+7 -4
View File
@@ -109,16 +109,19 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI {
return null;
}
// Control plane errors carry a machine code in "error" and, when there is
// something to tell the person, a sentence in "message". Show the sentence;
// older handlers put their only human text in "error", so fall back to it.
function messageFromPayload(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
var errorMessage = (payload as { error?: unknown }).error;
if (typeof errorMessage === 'string' && errorMessage.trim()) {
return errorMessage;
}
var message = (payload as { message?: unknown }).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
var errorMessage = (payload as { error?: unknown }).error;
if (typeof errorMessage === 'string' && errorMessage.trim()) {
return errorMessage;
}
}
if (typeof payload === 'string' && payload.trim()) {
return payload;
@@ -96,6 +96,31 @@ describe('renderProviderPlanHTML', () => {
expect(lapsed).not.toContain('Renews automatically');
});
// The control plane keeps serving the portal on a lapsed licence precisely
// so the provider can buy here; the panel must say so and offer the plans.
it('tells a provider whose evaluation ended what happened and how to buy', () => {
const now = Date.parse('2026-12-10T00:00:00Z');
const html = renderProviderPlanHTML(view({
plan: evaluationPlan({ expires_at: '2026-11-22T20:49:54Z', lapsed: true }),
}), true, 2, now);
expect(html).toContain('Your evaluation ended on');
expect(html).toContain('lost their MSP features, and no new clients can be added');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
expect(html).not.toContain('Your evaluation covers');
});
it('offers the same plan again after a paid plan lapses', () => {
const now = Date.parse('2026-12-10T00:00:00Z');
const html = renderProviderPlanHTML(view({
plan: evaluationPlan({ plan_version: 'msp_solo', evaluation: false, workspace_limit: 3, expires_at: '2026-11-20T00:00:00Z', lapsed: true }),
}), true, 3, now);
expect(html).toContain('Your Solo plan ended on');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_solo"');
expect(html).toContain('data-provider-plan-action="buy" data-provider-plan-version="msp_starter"');
expect(html).toContain('data-provider-plan-action="manage-billing"');
expect(html).not.toContain('Renews automatically');
});
it('keeps the evaluation visible when plans cannot be loaded', () => {
const html = renderProviderPlanHTML(view({ plan: evaluationPlan({ plans: [], purchase_available: false, plans_error: 'Plans are unavailable right now.' }) }), true);
expect(html).toContain('Free evaluation');
@@ -20,6 +20,9 @@ export interface ProviderPlanState {
evaluation: boolean;
license_id?: string;
expires_at?: string;
// Past expiry and grace: client workspaces have lost MSP features and no
// new clients can be added until the provider buys or renews.
lapsed?: boolean;
workspace_limit: number;
purchase_available: boolean;
plans: ProviderPlanOption[];
@@ -80,17 +83,38 @@ function paidLicenceInGrace(plan: ProviderPlanState, now: number): boolean {
return !isNaN(expiresAt) && expiresAt - now <= PAID_GRACE_MS;
}
// The licence has run out: the evaluation or paid period is over. The control
// plane keeps serving this portal so the provider can buy or renew here.
function planEnded(plan: ProviderPlanState, now: number): boolean {
if (plan.lapsed) return true;
if (!plan.expires_at) return false;
var expiresAt = new Date(plan.expires_at).getTime();
return !isNaN(expiresAt) && now >= expiresAt;
}
function renderCurrentPlan(plan: ProviderPlanState, canManage: boolean, busy: string, inUse: number, now: number): string {
var expires = formatDate(plan.expires_at);
var title = providerPlanName(plan.plan_version);
var description: string;
var meta: string[] = [inUse >= 0 ? String(inUse) + ' of ' + clientWorkspaces(plan.workspace_limit) + ' in use' : clientWorkspaces(plan.workspace_limit)];
var cta = '';
if (plan.evaluation) {
var ended = planEnded(plan, now);
var lostFeatures = plan.lapsed
? ' Client workspaces keep running with core monitoring but have lost their MSP features, and no new clients can be added.'
: '';
if (plan.evaluation && ended) {
description = 'Your evaluation ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Buy a plan to keep your clients monitored and to add more.';
} else if (plan.evaluation) {
description = expires
? 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + ' until ' + expires + '. Buy a plan to keep your clients monitored after that and to add more.'
: 'Your evaluation covers ' + clientWorkspaces(plan.workspace_limit) + '. Buy a plan to add more.';
if (expires) meta.push('Expires ' + expires);
} else if (ended) {
description = 'Your ' + title + ' plan ended' + (expires ? ' on ' + expires : '') + '.' + lostFeatures + ' Renew from Manage billing or buy a plan below.';
if (canManage) {
cta = '<button class="btn-secondary billing-action-button" type="button" data-provider-plan-action="manage-billing"' +
(busy ? ' disabled' : '') + '>' + (busy === 'manage-billing' ? 'Opening…' : 'Manage billing') + '</button>';
}
} else {
// The renewal date lives in Manage billing; the licence date only
// matters, and only shows, once the subscription has stopped renewing.
@@ -162,14 +186,17 @@ export function renderProviderPlanHTML(view: ProviderPlanView, canManage: boolea
if (!plan) return parts.join('');
parts.push(renderCurrentPlan(plan, canManage, view.busy, inUse, now));
if (plan.evaluation) {
var ended = planEnded(plan, now);
if (plan.evaluation || ended) {
if (plan.plans_error) {
parts.push('<p class="billing-action-meta" role="alert">' + escapeText(plan.plans_error) + '</p>');
} else if (plan.purchase_available) {
var hasAnnual = plan.plans.some(function(option) { return option.billing_cycle === 'annual'; });
var cycle = hasAnnual ? view.cycle : 'monthly';
// After a paid plan ends, buying the same plan again is a valid choice.
var offers = plan.plans.filter(function(option) {
return option.billing_cycle === cycle && option.workspace_limit > plan!.workspace_limit;
return option.billing_cycle === cycle &&
(ended && !plan!.evaluation ? option.workspace_limit >= plan!.workspace_limit : option.workspace_limit > plan!.workspace_limit);
});
parts.push('<div class="billing-section-intro"><h3>Choose a plan</h3><p>You pay per client workspace, never per monitored system. Every client workspace is full Pulse.</p></div>');
parts.push(renderCycleToggle(cycle, hasAnnual));
@@ -389,6 +389,7 @@ type ProviderMSPPlanState struct {
Evaluation bool `json:"evaluation"`
LicenseID string `json:"license_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Lapsed bool `json:"lapsed"`
WorkspaceLimit int `json:"workspace_limit"`
PurchaseAvailable bool `json:"purchase_available"`
Plans []ProviderMSPPurchasablePlan `json:"plans"`
@@ -409,6 +410,7 @@ func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *Provide
if !cfg.ProviderMSPLicenseExpiresAt.IsZero() {
state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339)
}
state.Lapsed = cfg.ProviderMSPLicenseLapsed(time.Now())
if refresher == nil || state.LicenseID == "" {
return state
}
@@ -230,7 +230,7 @@ func TestProviderMSPLicenseRefreshAppliesShorterPaidPeriod(t *testing.T) {
refresher.SetRestart(func() { restarted <- struct{}{} })
result, err := refresher.Refresh(context.Background())
if err != nil || !result.Changed || !result.RestartScheduled || result.ExpiresAt != shorterExpiry.Format(time.RFC3339) {
if err != nil || !result.Changed || !result.RestartScheduled || result.ExpiresAt != shorterExpiry.UTC().Format(time.RFC3339) {
t.Fatalf("shorter paid-period refresh = %+v, %v", result, err)
}
installed, err := os.ReadFile(ProviderMSPRenewedLicensePath(cfg.DataDir))
@@ -244,6 +244,26 @@ func TestProviderMSPLicenseRefreshAppliesShorterPaidPeriod(t *testing.T) {
}
}
// Startup tolerates a lapsed licence so the portal stays up, but the refresher
// must never adopt one the licence server returns.
func TestProviderMSPLicenseRefreshRefusesALapsedLicence(t *testing.T) {
issuer := newProviderMSPTestIssuer(t)
fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusOK}
server := httptest.NewServer(fake)
defer server.Close()
cfg := newProviderMSPRefreshTestConfig(t, server.URL)
fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-30*24*time.Hour), trialSigningEnvPublicKey(t))
refresher := NewProviderMSPLicenseRefresher(cfg)
refresher.SetRestart(func() { t.Fatal("a lapsed licence must not restart the control plane") })
if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "unusable") {
t.Fatalf("Refresh with a lapsed licence err = %v, want refusal", err)
}
if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) {
t.Fatalf("lapsed licence was installed: %v", err)
}
}
func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) {
fake := &fakeProviderMSPLicenseServer{
t: t,
+1
View File
@@ -250,6 +250,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
if deps.Config.IsMSPControlPlane() {
workspaceLimitPolicy.ProviderHostedMSP = true
workspaceLimitPolicy.ProviderMSPPlanVersion = providerMSPPlanVersion(deps.Config)
workspaceLimitPolicy.ProviderMSPLicenseLapsed = func() bool { return deps.Config.ProviderMSPLicenseLapsed(time.Now()) }
}
createTenant := account.HandleCreateTenantWithWorkspaceLimitPolicy(deps.Registry, provisioner, workspaceLimitPolicy)
updateTenant := account.HandleUpdateTenant(deps.Registry)
+1 -1
View File
@@ -344,7 +344,7 @@ func (m *Monitor) ApplyKubernetesReport(report agentsk8s.Report, tokenRecord *co
m.state.UpsertKubernetesCluster(cluster)
m.state.SetConnectionHealth(kubernetesConnectionPrefix+identifier, true)
m.recordKubernetesPodMetrics(cluster, receivedAt)
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
return cluster, nil
}
+158 -3
View File
@@ -1301,6 +1301,11 @@ type Monitor struct {
// data, which is the dominant write amplification in #1966.
unifiedMetricSyncMu sync.Mutex
unifiedMetricSyncLast map[unifiedMetricSampleKey]unifiedMetricSample
// agentReportRefreshWindow folds accepted agent reports into at most one
// canonical store refresh per window; zero refreshes on every report.
agentReportRefreshWindow time.Duration
agentReportRefresh agentReportRefreshState
}
func (m *Monitor) setRuntimeContext(ctx context.Context, hub *websocket.Hub) {
@@ -1738,6 +1743,7 @@ func New(cfg *config.Config) (*Monitor, error) {
m := &Monitor{
config: cfg,
state: models.NewState(),
agentReportRefreshWindow: agentReportRefreshInterval,
pveClients: make(map[string]PVEClientInterface),
pbsClients: make(map[string]*pbs.Client),
pmgClients: make(map[string]*pmg.Client),
@@ -4359,7 +4365,9 @@ func (m *Monitor) GetLiveHostsSnapshot() []models.Host {
if m == nil || m.state == nil {
return nil
}
return m.state.GetSnapshot().Hosts
// Copy only the hosts. GetSnapshot deep-copies every guest as well, and
// this runs on each agent report, config fetch, and continuity lookup.
return m.state.GetHosts()
}
// SetOrgID sets the organization ID for this monitor instance.
@@ -5110,6 +5118,7 @@ func (m *Monitor) updateResourceStoreForRead(state models.StateSnapshot) {
return
}
recordSupplementalResourceChanges(store, m.collectSupplementalChanges())
store = newResourceSnapshotStore(store)
m.syncAllUnifiedMetrics(store)
m.syncUnifiedResourceAlertsToState(store.GetAll())
}
@@ -5157,6 +5166,7 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
if atomicStore, ok := store.(AtomicSnapshotResourceStore); ok {
atomicStore.PopulateSnapshotAndSupplemental(snapshotForStore, recordsBySource)
recordSupplementalResourceChanges(store, supplementalChanges)
store = newResourceSnapshotStore(store)
m.syncAllUnifiedMetrics(store)
for source, records := range recordsBySource {
if len(records) == 0 {
@@ -5188,12 +5198,55 @@ func (m *Monitor) updateResourceStore(state models.StateSnapshot) {
}
recordSupplementalResourceChanges(store, supplementalChanges)
store = newResourceSnapshotStore(store)
m.syncAllUnifiedMetrics(store)
m.syncUnifiedResourceAlertsToState(store.GetAll())
}
// refreshUnifiedResourceStoreAfterAgentStateChange makes accepted agent
// ingest and removal immediately visible to canonical ReadState consumers.
// resourceSnapshotStore serves one GetAll clone to every consumer of a single
// store-refresh pass. The metric syncs and the alert sync each cloned the
// whole registry for themselves, up to six clones for every accepted agent
// report (#2199). Consumers of a pass only read, so they can share one
// generation.
type resourceSnapshotStore struct {
ResourceStoreInterface
targets MetricsTargetResourceStore
resources []unifiedresources.Resource
listed bool
}
// newResourceSnapshotStore wraps store for one refresh pass. A store that
// cannot resolve metrics targets is returned as is, so the metric syncs'
// capability checks see exactly what they would without the wrapper.
func newResourceSnapshotStore(store ResourceStoreInterface) ResourceStoreInterface {
if store == nil {
return nil
}
if _, ok := store.(*resourceSnapshotStore); ok {
return store
}
targets, ok := store.(MetricsTargetResourceStore)
if !ok {
return store
}
return &resourceSnapshotStore{ResourceStoreInterface: store, targets: targets}
}
func (s *resourceSnapshotStore) GetAll() []unifiedresources.Resource {
if !s.listed {
s.resources = s.ResourceStoreInterface.GetAll()
s.listed = true
}
return s.resources
}
func (s *resourceSnapshotStore) MetricsTargetForResource(resourceID string) *unifiedresources.MetricsTarget {
return s.targets.MetricsTargetForResource(resourceID)
}
// refreshUnifiedResourceStoreAfterAgentStateChange makes agent removal and
// host-agent evaluation immediately visible to canonical ReadState consumers;
// accepted reports go through refreshUnifiedResourceStoreAfterAgentReport.
// WebSocket broadcasts may also rebuild the store for their own hydrate path,
// but client presence must never be the trigger that publishes agent-backed
// runtime truth or retires removed inventory.
@@ -5204,6 +5257,105 @@ func (m *Monitor) refreshUnifiedResourceStoreAfterAgentStateChange() {
m.updateResourceStore(m.GetState())
}
// agentReportRefreshInterval bounds how often accepted agent reports refresh
// the canonical store. It matches readPathRegistryFreshness, the staleness the
// read paths already accept.
const agentReportRefreshInterval = readPathRegistryFreshness
// agentReportRefreshState throttles report-driven store refreshes to a leading
// refresh plus at most one trailing refresh per window.
type agentReportRefreshState struct {
mu sync.Mutex
lastRun time.Time
running bool
pending *time.Timer
stopped bool
inFlight sync.WaitGroup
}
// refreshUnifiedResourceStoreAfterAgentReport publishes an accepted agent
// report to the canonical store. Every refresh is estate-wide, so refreshing
// on each report made total cost grow with agents times resources (#2199).
// The first report after a quiet window still refreshes before the handler
// returns. Reports within agentReportRefreshInterval of the previous refresh
// fold into one trailing refresh at the window's end, which reads the latest
// state and broadcasts it. Agent removal and host-agent evaluation keep
// refreshing immediately through refreshUnifiedResourceStoreAfterAgentStateChange.
func (m *Monitor) refreshUnifiedResourceStoreAfterAgentReport() {
if m == nil || m.state == nil {
return
}
window := m.agentReportRefreshWindow
if window <= 0 {
m.updateResourceStore(m.GetState())
return
}
r := &m.agentReportRefresh
r.mu.Lock()
if r.stopped || r.pending != nil {
// A due trailing refresh reads state after this report was applied.
r.mu.Unlock()
return
}
now := time.Now()
if !r.running && now.Sub(r.lastRun) >= window {
r.running = true
r.lastRun = now
r.mu.Unlock()
m.updateResourceStore(m.GetState())
r.mu.Lock()
r.running = false
r.mu.Unlock()
return
}
r.inFlight.Add(1)
r.pending = time.AfterFunc(max(window-now.Sub(r.lastRun), 0), m.runTrailingAgentReportRefresh)
r.mu.Unlock()
}
func (m *Monitor) runTrailingAgentReportRefresh() {
r := &m.agentReportRefresh
defer r.inFlight.Done()
r.mu.Lock()
r.pending = nil
if r.stopped {
r.mu.Unlock()
return
}
r.running = true
r.lastRun = time.Now()
r.mu.Unlock()
m.updateResourceStore(m.GetState())
r.mu.Lock()
r.running = false
r.mu.Unlock()
// No handler follows a trailing refresh, so publish it to clients here.
m.mu.RLock()
hub := m.wsHub
m.mu.RUnlock()
if hub != nil {
m.broadcastCurrentState(hub)
}
}
// stopAgentReportRefresh cancels a pending trailing refresh and waits for one
// already running, so none touches stores Stop is about to close.
func (m *Monitor) stopAgentReportRefresh() {
r := &m.agentReportRefresh
r.mu.Lock()
r.stopped = true
if r.pending != nil && r.pending.Stop() {
r.pending = nil
r.inFlight.Done()
}
r.mu.Unlock()
r.inFlight.Wait()
}
func recordSupplementalResourceChanges(store ResourceStoreInterface, changes []unifiedresources.ResourceChange) {
if store == nil || len(changes) == 0 {
return
@@ -7401,6 +7553,9 @@ const guestMetadataDrainTimeout = 2 * time.Second
func (m *Monitor) Stop() {
log.Info().Msg("stopping monitor")
// A trailing agent-report refresh must not run against stores closed below.
m.stopAgentReportRefresh()
if m.deadMan != nil {
m.deadMan.stop(time.Now().UTC(), m.alertManager)
}
+5 -5
View File
@@ -1743,7 +1743,7 @@ func (m *Monitor) applyRejectedHostReportLiveness(
// clear an offline alert, or renew the accepted telemetry lease.
m.state.SetConnectionHealth(hostConnectionPrefix+host.ID, true)
m.persistHostContinuity(host, report, order)
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
log.Debug().
Str("hostID", host.ID).
@@ -2206,7 +2206,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
if existing.ID != "" {
m.state.SetConnectionHealth(dockerConnectionPrefix+existing.ID, true)
}
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
log.Debug().
Str("dockerHostID", identifier).
Str("sequenceId", report.SequenceID).
@@ -2604,7 +2604,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
Str("dockerHost", host.Hostname).
Str("sequenceId", report.SequenceID).
Msg("Docker helper status processed without refreshing inventory-derived alerts or metrics")
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
return host, nil
}
@@ -2700,7 +2700,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
Int("containers", len(containers)).
Msg("Docker host report processed")
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
return host, nil
}
@@ -3800,7 +3800,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
// committed, because ownership is checked against that host ID.
m.ApplyProbeAvailabilityResults(host.ID, probeAvailabilityResultsFromReport(report.AvailabilityResults))
m.persistHostContinuity(host, report, reportOrder)
m.refreshUnifiedResourceStoreAfterAgentStateChange()
m.refreshUnifiedResourceStoreAfterAgentReport()
return host, nil
}
@@ -7,6 +7,7 @@ import (
"net"
"os"
"strings"
"sync/atomic"
"testing"
"time"
@@ -6408,3 +6409,224 @@ func TestApplyHostReportHonoursRemovalBlockWhenIdentityWouldFork(t *testing.T) {
t.Fatalf("expected removal block to be cleared, still have %+v", monitor.state.GetRemovedHostAgents())
}
}
// countingResourceStore counts registry clones handed out by the live store.
type countingResourceStore struct {
*unifiedresources.MonitorAdapter
getAll atomic.Int32
}
func (c *countingResourceStore) GetAll() []unifiedresources.Resource {
c.getAll.Add(1)
return c.MonitorAdapter.GetAll()
}
// plainResourceStore lacks metrics-target resolution.
type plainResourceStore struct {
ResourceStoreInterface
}
func TestResourceSnapshotStoreClonesOncePerPass(t *testing.T) {
now := time.Now().UTC()
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
adapter.PopulateFromSnapshot(models.StateSnapshot{
LastUpdate: now,
VMs: []models.VM{{
ID: "lab:pve1:101", VMID: 101, Name: "db", Node: "pve1", Instance: "lab",
Status: "running", Type: "qemu", LastSeen: now,
}},
})
counting := &countingResourceStore{MonitorAdapter: adapter}
store := newResourceSnapshotStore(counting)
first := store.GetAll()
second := store.GetAll()
if got := counting.getAll.Load(); got != 1 {
t.Fatalf("underlying GetAll calls = %d, want 1", got)
}
if len(first) != 1 || len(second) != 1 || &first[0] != &second[0] {
t.Fatalf("pass consumers did not share one snapshot: %d and %d resources", len(first), len(second))
}
resolver, ok := store.(MetricsTargetResourceStore)
if !ok || resolver.MetricsTargetForResource(first[0].ID) == nil {
t.Fatal("snapshot store does not forward metrics-target resolution")
}
if again := newResourceSnapshotStore(store); again != store {
t.Fatal("wrapping a pass snapshot again must reuse it")
}
plain := plainResourceStore{ResourceStoreInterface: adapter}
if wrapped := newResourceSnapshotStore(plain); wrapped != ResourceStoreInterface(plain) {
t.Fatal("a store without metrics-target resolution must pass through unwrapped")
}
}
// An accepted agent report refreshes the store once. Every metric sync and the
// alert sync in that pass must share a single registry clone (#2199).
func TestAgentReportRefreshClonesRegistryOnce(t *testing.T) {
now := time.Now().UTC()
state := models.NewState()
state.UpdateNodes([]models.Node{{ID: "lab-pve1", Name: "pve1", Instance: "lab", Status: "online", Type: "node", LastSeen: now}})
state.UpdateVMs([]models.VM{{
ID: "lab:pve1:101", VMID: 101, Name: "db", Node: "pve1", Instance: "lab",
Status: "running", Type: "qemu", CPU: 0.2, LastSeen: now,
}})
state.UpsertHost(models.Host{ID: "agent-1", Hostname: "pve1.example", Status: "online", CPUUsage: 3, LastSeen: now})
counting := &countingResourceStore{MonitorAdapter: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))}
m := &Monitor{
state: state,
resourceStore: counting,
metricsHistory: NewMetricsHistory(32, time.Hour),
}
m.refreshUnifiedResourceStoreAfterAgentStateChange()
if got := counting.getAll.Load(); got != 1 {
t.Fatalf("registry clones for one agent report refresh = %d, want 1", got)
}
}
// newAgentReportThrottleMonitor returns a monitor whose report-driven store
// refreshes are throttled to window, with one online host agent.
func newAgentReportThrottleMonitor(t *testing.T, window time.Duration) (*Monitor, *countingResourceStore) {
t.Helper()
state := models.NewState()
state.UpsertHost(models.Host{ID: "agent-1", Hostname: "pve1.example", Status: "online", CPUUsage: 1, LastSeen: time.Now().UTC()})
counting := &countingResourceStore{MonitorAdapter: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))}
m := &Monitor{
state: state,
resourceStore: counting,
metricsHistory: NewMetricsHistory(32, time.Hour),
agentReportRefreshWindow: window,
}
t.Cleanup(m.stopAgentReportRefresh)
return m, counting
}
func reportAgentCPU(m *Monitor, cpu float64) {
m.state.UpsertHost(models.Host{ID: "agent-1", Hostname: "pve1.example", Status: "online", CPUUsage: cpu, LastSeen: time.Now().UTC()})
m.refreshUnifiedResourceStoreAfterAgentReport()
}
func publishedAgentCPU(t *testing.T, store *countingResourceStore) float64 {
t.Helper()
for _, resource := range store.MonitorAdapter.GetAll() {
if resource.Type == unifiedresources.ResourceTypeAgent && resource.Metrics != nil && resource.Metrics.CPU != nil {
return resource.Metrics.CPU.Value
}
}
t.Fatal("no agent CPU in the canonical store")
return 0
}
// Accepted agent reports refresh the estate-wide store at most once per
// window: the first report after a quiet window is published before the call
// returns, and reports inside the window fold into one trailing refresh that
// publishes the latest state (#2199).
func TestAgentReportRefreshFoldsReportsWithinWindow(t *testing.T) {
const window = 500 * time.Millisecond
m, store := newAgentReportThrottleMonitor(t, window)
reportAgentCPU(m, 10)
if got := store.getAll.Load(); got != 1 {
t.Fatalf("refreshes after the leading report = %d, want 1", got)
}
if got := publishedAgentCPU(t, store); got != 10 {
t.Fatalf("leading report published CPU %v, want 10", got)
}
for cpu := 20.0; cpu <= 60; cpu += 10 {
reportAgentCPU(m, cpu)
}
if got := store.getAll.Load(); got != 1 {
t.Fatalf("refreshes during the window = %d, want the burst deferred", got)
}
deadline := time.Now().Add(5 * time.Second)
for store.getAll.Load() < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := store.getAll.Load(); got != 2 {
t.Fatalf("refreshes after the window = %d, want one trailing refresh", got)
}
if got := publishedAgentCPU(t, store); got != 60 {
t.Fatalf("trailing refresh published CPU %v, want the latest report's 60", got)
}
time.Sleep(window + 100*time.Millisecond)
reportAgentCPU(m, 70)
if got := store.getAll.Load(); got != 3 {
t.Fatalf("refreshes after a quiet window = %d, want a synchronous leading refresh", got)
}
if got := publishedAgentCPU(t, store); got != 70 {
t.Fatalf("leading report after a quiet window published CPU %v, want 70", got)
}
}
func TestStopCancelsPendingAgentReportRefresh(t *testing.T) {
const window = 100 * time.Millisecond
m, store := newAgentReportThrottleMonitor(t, window)
reportAgentCPU(m, 10)
reportAgentCPU(m, 20)
m.stopAgentReportRefresh()
time.Sleep(3 * window)
if got := store.getAll.Load(); got != 1 {
t.Fatalf("refreshes after stop = %d, want the pending trailing refresh cancelled", got)
}
}
// A standalone agent offline across a restart is added back to every canonical
// read-state lookup from host continuity. Alert evaluation makes one lookup per
// resource per poll, so the overlay must be reused, not rebuilt per lookup.
func TestStandaloneHostContinuityReadStateReusedAcrossLookups(t *testing.T) {
now := time.Now().UTC()
state := models.NewState()
state.UpsertHost(models.Host{ID: "agent-live", Hostname: "live.example", Status: "online", LastSeen: now})
continuity := config.NewHostContinuityStore(t.TempDir(), nil)
if err := continuity.Upsert(config.HostContinuityEntry{HostID: "agent-gone", Hostname: "gone.example", LastSeen: now.Add(-time.Hour)}); err != nil {
t.Fatalf("seed host continuity: %v", err)
}
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{state: state, resourceStore: adapter, hostContinuityStore: continuity}
m.refreshUnifiedResourceStoreAfterAgentStateChange()
first := m.GetUnifiedReadStateOrSnapshot()
if first == unifiedresources.ReadState(adapter) {
t.Fatal("read state has no continuity overlay for the offline standalone agent")
}
found := false
for _, host := range first.Hosts() {
found = found || host.Hostname() == "gone.example"
}
if !found {
t.Fatal("continuity overlay does not include the offline standalone agent")
}
if second := m.GetUnifiedReadStateOrSnapshot(); second != first {
t.Fatal("a second read-state lookup rebuilt the continuity overlay")
}
}
// GetLiveHostsSnapshot runs on every agent report, config fetch, and host
// continuity lookup, so it must copy the hosts alone, not the whole state.
func TestGetLiveHostsSnapshotCopiesOnlyHosts(t *testing.T) {
state := models.NewState()
state.UpsertHost(models.Host{ID: "agent-1", Hostname: "pve1.example", Status: "online"})
vms := make([]models.VM, 1000)
for i := range vms {
vms[i] = models.VM{
ID: fmt.Sprintf("lab:pve1:%d", 100+i), VMID: 100 + i, Name: fmt.Sprintf("vm-%d", i),
Node: "pve1", Instance: "lab", Status: "running", Type: "qemu",
Disks: []models.Disk{{Mountpoint: "/", Total: 10 << 30, Used: 1 << 30}},
}
}
state.UpdateVMs(vms)
m := &Monitor{state: state}
if hosts := m.GetLiveHostsSnapshot(); len(hosts) != 1 || hosts[0].ID != "agent-1" {
t.Fatalf("live hosts = %+v, want the one registered agent", hosts)
}
if allocs := testing.AllocsPerRun(10, func() { _ = m.GetLiveHostsSnapshot() }); allocs > 50 {
t.Fatalf("GetLiveHostsSnapshot allocated %.0f times with 1 host and 1000 guests; it is copying guests", allocs)
}
}
@@ -76,8 +76,9 @@ func (m *Monitor) readStateWithStandaloneHostContinuity(
return readState
}
liveHostIDs := make(map[string]struct{}, len(m.GetLiveHostsSnapshot()))
for _, host := range m.GetLiveHostsSnapshot() {
liveHosts := m.GetLiveHostsSnapshot()
liveHostIDs := make(map[string]struct{}, len(liveHosts))
for _, host := range liveHosts {
if id := strings.TrimSpace(host.ID); id != "" {
liveHostIDs[id] = struct{}{}
}
+79 -1
View File
@@ -1,6 +1,8 @@
package unifiedresources
import (
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"strings"
@@ -24,6 +26,27 @@ type MonitorAdapter struct {
activeAlerts []models.Alert
lastRebuiltAt time.Time
staleThresholds map[DataSource]time.Duration
overlays overlayReadStateCache
}
// overlayReadStateMaxAge bounds how long an overlay built from one registry
// generation is reused. The overlay evaluates source staleness when it is
// built, so reuse stays within the staleness read paths already accept.
const overlayReadStateMaxAge = 2 * time.Second
// overlayReadStateCache holds the last overlay built from this adapter's
// registry. Host continuity overlays are requested on every canonical
// read-state lookup, which alert evaluation makes once per resource per poll;
// rebuilding the registry for each made polls quadratic while a standalone
// agent stayed offline across a restart.
type overlayReadStateCache struct {
mu sync.Mutex
registry *ResourceRegistry
rebuiltAt time.Time
key [sha256.Size]byte
builtAt time.Time
overlay ReadState
}
// NewMonitorAdapter creates a monitor-facing adapter around a registry.
@@ -97,12 +120,67 @@ func readStateWithRecords(readState ReadState, source DataSource, records []Inge
if registry == nil {
return readState
}
// Capture the generation before reading it, so a mutation during the build
// leaves the cached overlay keyed to the older generation.
rebuiltAt := adapter.LastRebuiltAt()
key, keyed := overlayRecordsKey(source, records, onlyMissing)
now := time.Now()
if keyed {
if overlay := adapter.overlays.lookup(registry, rebuiltAt, key, now); overlay != nil {
return overlay
}
}
cloned := NewRegistry(registry.store)
thresholds := adapter.currentStaleThresholds()
cloned.IngestResourcesWithStaleThresholds(registry.List(), thresholds)
cloned.ingestRecords(source, records, onlyMissing)
return NewMonitorAdapterWithStaleThresholds(cloned, thresholds)
overlay := NewMonitorAdapterWithStaleThresholds(cloned, thresholds)
if keyed {
adapter.overlays.store(registry, rebuiltAt, key, now, overlay)
}
return overlay
}
// overlayRecordsKey fingerprints an overlay request. Resource.UpdatedAt is
// stamped when a record is built, not observed, so it is left out.
func overlayRecordsKey(source DataSource, records []IngestRecord, onlyMissing bool) ([sha256.Size]byte, bool) {
stable := make([]IngestRecord, len(records))
for i, record := range records {
record.Resource.UpdatedAt = time.Time{}
stable[i] = record
}
payload, err := json.Marshal(struct {
Source DataSource
OnlyMissing bool
Records []IngestRecord
}{source, onlyMissing, stable})
if err != nil {
return [sha256.Size]byte{}, false
}
return sha256.Sum256(payload), true
}
func (c *overlayReadStateCache) lookup(registry *ResourceRegistry, rebuiltAt time.Time, key [sha256.Size]byte, now time.Time) ReadState {
c.mu.Lock()
defer c.mu.Unlock()
if c.overlay == nil || c.registry != registry || !c.rebuiltAt.Equal(rebuiltAt) || c.key != key {
return nil
}
if age := now.Sub(c.builtAt); age < 0 || age >= overlayReadStateMaxAge {
return nil
}
return c.overlay
}
func (c *overlayReadStateCache) store(registry *ResourceRegistry, rebuiltAt time.Time, key [sha256.Size]byte, now time.Time, overlay ReadState) {
c.mu.Lock()
defer c.mu.Unlock()
c.registry = registry
c.rebuiltAt = rebuiltAt
c.key = key
c.builtAt = now
c.overlay = overlay
}
func (a *MonitorAdapter) currentRegistry() *ResourceRegistry {
@@ -892,3 +892,62 @@ func TestMonitorAdapterRebuildPersistsIdentityPins(t *testing.T) {
}
t.Fatalf("expected delly node resource in boot-window rebuild")
}
// Host continuity overlays are requested on every canonical read-state lookup.
// One built from a registry generation is reused until that generation, the
// requested records, or overlayReadStateMaxAge changes, and never mutates the
// live adapter.
func TestHostContinuityOverlayReusedWithinRegistryGeneration(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
LastUpdate: now,
Hosts: []models.Host{{ID: "live-1", Hostname: "live.example", Status: "online", LastSeen: now}},
}
adapter := NewMonitorAdapter(NewRegistry(nil))
adapter.PopulateFromSnapshot(snapshot)
offline := models.Host{ID: "offline-1", Hostname: "gone.example", Status: "offline", LastSeen: now.Add(-time.Hour)}
hasHost := func(readState ReadState, hostname string) bool {
for _, host := range readState.Hosts() {
if host.Hostname() == hostname {
return true
}
}
return false
}
first := ReadStateWithHostContinuity(adapter, []IngestRecord{HostIngestRecord(offline)})
if !hasHost(first, "gone.example") {
t.Fatal("continuity overlay does not include the offline host")
}
// Records are rebuilt per request and carry a fresh UpdatedAt stamp.
if again := ReadStateWithHostContinuity(adapter, []IngestRecord{HostIngestRecord(offline)}); again != first {
t.Fatal("identical continuity request rebuilt the overlay within one generation")
}
if hasHost(adapter, "gone.example") {
t.Fatal("continuity overlay mutated the live adapter")
}
other := offline
other.ID, other.Hostname = "offline-2", "also-gone.example"
if changed := ReadStateWithHostContinuity(adapter, []IngestRecord{HostIngestRecord(other)}); changed == first || !hasHost(changed, "also-gone.example") {
t.Fatal("different continuity records reused the previous overlay")
}
snapshot.LastUpdate = now.Add(time.Second)
adapter.PopulateFromSnapshot(snapshot)
next := ReadStateWithHostContinuity(adapter, []IngestRecord{HostIngestRecord(offline)})
if next == first || !hasHost(next, "gone.example") {
t.Fatal("a new registry generation reused the previous generation's overlay")
}
var cache overlayReadStateCache
registry := adapter.currentRegistry()
key, _ := overlayRecordsKey(SourceAgent, []IngestRecord{HostIngestRecord(offline)}, true)
cache.store(registry, now, key, now, next)
if cache.lookup(registry, now, key, now.Add(overlayReadStateMaxAge-time.Millisecond)) != next {
t.Fatal("overlay was not reused within its maximum age")
}
if cache.lookup(registry, now, key, now.Add(overlayReadStateMaxAge)) != nil {
t.Fatal("overlay was reused past its maximum age")
}
}
+54 -13
View File
@@ -126,12 +126,13 @@ type ResourceRegistry struct {
// viewsDirty is false, like every other cached field above.
cachedSourceTargets map[string][]SourceTarget
// agentNodeScanIndex buckets agent resources by lowercased node name for
// the duration of one buildChildCounts pass, where the agent-parent
// fallback would otherwise scan every resource once per guest. Only that
// pass sets it; nil means callers take the full scan. Bucket membership
// is fixed for the pass (Type and NodeName are never mutated mid-pass)
// while scores stay live through the shared pointers.
// agentNodeScanIndex buckets agent resources by lowercased node name
// while guests are ingested and during one buildChildCounts pass, where
// the agent-parent fallback would otherwise scan every resource once per
// guest. nil means callers take the full scan. ingestRecord rebuilds it
// whenever an ingest yields an agent, so bucket membership always matches
// what the full scan would see; scores stay live through the shared
// pointers.
agentNodeScanIndex map[string][]agentNodeCandidate
}
@@ -268,12 +269,23 @@ func (rr *ResourceRegistry) ingestSnapshot(snapshot models.StateSnapshot, thresh
rr.ingestPMGInstance(instance)
}
var guestSuccessions []CanonicalIDSuccession
// Every guest resolves its parent node, which can fall back to a scan of
// every resource. Each agent-producing source is ingested above, so bucket
// agents by node once for the guest loops instead of walking the registry
// per guest: that walk made every agent report's rebuild quadratic in the
// estate (#2199).
rr.mu.Lock()
rr.agentNodeScanIndex = rr.buildAgentNodeScanIndexLocked()
rr.mu.Unlock()
for _, vm := range snapshot.VMs {
guestSuccessions = append(guestSuccessions, rr.ingestVM(vm, clusterByInstance, nodeNamesByInstance)...)
}
for _, ct := range snapshot.Containers {
guestSuccessions = append(guestSuccessions, rr.ingestContainer(ct, clusterByInstance, nodeNamesByInstance)...)
}
rr.mu.Lock()
rr.agentNodeScanIndex = nil
rr.mu.Unlock()
rr.applyRecordSuccessions(guestSuccessions)
for _, storage := range snapshot.Storage {
rr.ingestStorage(storage)
@@ -2658,9 +2670,12 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
// normal canonical matching rules, but may only introduce absent resources.
// Returning an empty ID also prevents a skipped record from attaching retired
// identities or migrating operator-owned state onto the current resource.
func (rr *ResourceRegistry) ingestRecord(source DataSource, sourceID string, resource Resource, identity ResourceIdentity, onlyMissing bool) string {
func (rr *ResourceRegistry) ingestRecord(source DataSource, sourceID string, resource Resource, identity ResourceIdentity, onlyMissing bool) (ingestedID string) {
rr.mu.Lock()
defer rr.mu.Unlock()
if rr.agentNodeScanIndex != nil {
defer func() { rr.refreshAgentNodeScanIndexLocked(ingestedID) }()
}
rr.invalidateSourceTargetsLocked()
sourceID = normalizeSourceID(sourceID)
if sourceID == "" {
@@ -4549,6 +4564,11 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDFromResourcesLocked(instance, clu
// buildChildCounts is mid-pass: only this node's agent candidates
// can match, so skip the full walk.
for _, candidate := range rr.agentNodeScanIndex[strings.ToLower(nodeName)] {
// A merge since indexing may have replaced or removed the entry;
// the full scan would only see the registry's current object.
if rr.resources[candidate.id] != candidate.resource {
continue
}
consider(candidate.id, candidate.resource)
}
return bestID
@@ -4601,11 +4621,10 @@ func proxmoxNodeParentScopeScore(instance, clusterName string, parent *ProxmoxDa
}
}
func (rr *ResourceRegistry) buildChildCounts() {
// The parent-resolution loop below may hit the agent fallback scan once
// per guest. Bucketing agents by node name up front turns each of those
// scans into a lookup over that node's few candidates.
rr.agentNodeScanIndex = make(map[string][]agentNodeCandidate)
// buildAgentNodeScanIndexLocked buckets agent resources by lowercased node
// name. The caller holds rr.mu for writing.
func (rr *ResourceRegistry) buildAgentNodeScanIndexLocked() map[string][]agentNodeCandidate {
index := make(map[string][]agentNodeCandidate)
for id, r := range rr.resources {
if r == nil || r.Proxmox == nil || CanonicalResourceType(r.Type) != ResourceTypeAgent {
continue
@@ -4614,8 +4633,30 @@ func (rr *ResourceRegistry) buildChildCounts() {
if nodeName == "" {
continue
}
rr.agentNodeScanIndex[nodeName] = append(rr.agentNodeScanIndex[nodeName], agentNodeCandidate{id: id, resource: r})
index[nodeName] = append(index[nodeName], agentNodeCandidate{id: id, resource: r})
}
return index
}
// refreshAgentNodeScanIndexLocked rebuilds a live index after an ingest
// yields an agent, whose node name may be new to the buckets. Guest ingests,
// the common case while the index is live, leave it untouched.
func (rr *ResourceRegistry) refreshAgentNodeScanIndexLocked(resourceID string) {
if rr.agentNodeScanIndex == nil {
return
}
resource := rr.resources[CanonicalResourceID(resourceID)]
if resource == nil || resource.Proxmox == nil || CanonicalResourceType(resource.Type) != ResourceTypeAgent {
return
}
rr.agentNodeScanIndex = rr.buildAgentNodeScanIndexLocked()
}
func (rr *ResourceRegistry) buildChildCounts() {
// The parent-resolution loop below may hit the agent fallback scan once
// per guest. Bucketing agents by node name up front turns each of those
// scans into a lookup over that node's few candidates.
rr.agentNodeScanIndex = rr.buildAgentNodeScanIndexLocked()
defer func() { rr.agentNodeScanIndex = nil }()
// ChildCount and ParentName are derived fields. Clear prior values before
+129
View File
@@ -6650,3 +6650,132 @@ func TestIssue2076USBLinkedDiskAmbiguity(t *testing.T) {
})
}
}
// Guest ingest resolves each guest's parent node, which can fall back to a
// scan of every agent. The guest pass answers that fallback from
// agentNodeScanIndex, so the index must resolve exactly what the scan would.
// agentNodeScanFixture spans three clusters that reuse node names, so a
// by-name bucket holds candidates from every cluster and scope scoring has to
// pick the right one. Every other node runs a linked host agent. A fourth
// cluster's nodes are keyed by their endpoint instance while its guests report
// the cluster name, so no direct source mapping reaches their parent and only
// the agent scan resolves it.
func agentNodeScanFixture(now time.Time) models.StateSnapshot {
snapshot := models.StateSnapshot{LastUpdate: now}
for n := 0; n < 2; n++ {
nodeName := fmt.Sprintf("pve%d", n)
snapshot.Nodes = append(snapshot.Nodes, models.Node{
ID: "ep-" + nodeName, Name: nodeName, Instance: "ep", ClusterName: "prod",
Status: "online", Type: "node", LastSeen: now,
})
vmid := 900 + n
snapshot.VMs = append(snapshot.VMs, models.VM{
ID: fmt.Sprintf("prod:%s:%d", nodeName, vmid), VMID: vmid,
Name: fmt.Sprintf("vm-%d", vmid), Node: nodeName, Instance: "prod",
Status: "running", Type: "qemu", LastSeen: now,
})
}
for cluster := 0; cluster < 3; cluster++ {
instance := fmt.Sprintf("lab%d", cluster)
for n := 0; n < 4; n++ {
nodeName := fmt.Sprintf("pve%d", n)
nodeID := instance + "-" + nodeName
node := models.Node{
ID: nodeID,
Name: nodeName,
Instance: instance,
Status: "online",
Type: "node",
LastSeen: now,
}
if n%2 == 0 {
hostID := fmt.Sprintf("agent-%s-%s", instance, nodeName)
node.LinkedAgentID = hostID
snapshot.Hosts = append(snapshot.Hosts, models.Host{
ID: hostID,
Hostname: fmt.Sprintf("%s.%s.example", nodeName, instance),
Status: "online",
LastSeen: now,
LinkedNodeID: nodeID,
})
}
snapshot.Nodes = append(snapshot.Nodes, node)
for g := 0; g < 3; g++ {
vmid := 100 + cluster*100 + n*10 + g
snapshot.VMs = append(snapshot.VMs, models.VM{
ID: fmt.Sprintf("%s:%s:%d", instance, nodeName, vmid), VMID: vmid,
Name: fmt.Sprintf("vm-%d", vmid), Node: nodeName, Instance: instance,
Status: "running", Type: "qemu", LastSeen: now,
})
ctid := 5000 + vmid
snapshot.Containers = append(snapshot.Containers, models.Container{
ID: fmt.Sprintf("%s:%s:%d", instance, nodeName, ctid), VMID: ctid,
Name: fmt.Sprintf("ct-%d", ctid), Node: nodeName, Instance: instance,
Status: "running", Type: "lxc", LastSeen: now,
})
}
}
}
return snapshot
}
func TestGuestIngestParentsMatchFullAgentScan(t *testing.T) {
snapshot := agentNodeScanFixture(time.Now().UTC())
rr := NewRegistry(nil)
rr.IngestSnapshot(snapshot)
rr.mu.RLock()
defer rr.mu.RUnlock()
if rr.agentNodeScanIndex != nil {
t.Fatal("agent node index outlived the ingest pass")
}
checked := 0
for id, resource := range rr.resources {
if resource.Type != ResourceTypeVM && resource.Type != ResourceTypeSystemContainer {
continue
}
if resource.Proxmox == nil {
t.Fatalf("guest %s has no Proxmox data", id)
}
// With the index nil this resolves through the full scan.
want := rr.proxmoxNodeParentIDLocked(resource.Proxmox.Instance, resource.Proxmox.ClusterName, resource.Proxmox.NodeName, "")
if want == "" {
t.Fatalf("full scan found no parent for guest %s on %s/%s", id, resource.Proxmox.Instance, resource.Proxmox.NodeName)
}
if got := CanonicalResourceID(resource.parentBySource[SourceProxmox]); got != want {
t.Fatalf("guest %s on %s/%s: ingest parent %q, full scan %q", id, resource.Proxmox.Instance, resource.Proxmox.NodeName, got, want)
}
checked++
}
if want := len(snapshot.VMs) + len(snapshot.Containers); checked != want {
t.Fatalf("checked %d guests, want %d", checked, want)
}
}
func TestAgentNodeScanIndexTracksAgentsIngestedWhileLive(t *testing.T) {
now := time.Now().UTC()
rr := NewRegistry(nil)
rr.IngestSnapshot(models.StateSnapshot{
LastUpdate: now,
Nodes: []models.Node{{ID: "lab-pve1", Name: "pve1", Instance: "lab", Status: "online", Type: "node", LastSeen: now}},
})
rr.mu.Lock()
rr.agentNodeScanIndex = rr.buildAgentNodeScanIndexLocked()
rr.mu.Unlock()
defer func() {
rr.mu.Lock()
rr.agentNodeScanIndex = nil
rr.mu.Unlock()
}()
rr.ingestProxmoxNode(models.Node{ID: "lab-pve2", Name: "pve2", Instance: "lab", Status: "online", Type: "node", LastSeen: now}, nil)
rr.mu.RLock()
defer rr.mu.RUnlock()
candidates := rr.agentNodeScanIndex["pve2"]
if len(candidates) != 1 || rr.resources[candidates[0].id] != candidates[0].resource {
t.Fatalf("index bucket for an agent ingested while live = %+v, want its registry entry", candidates)
}
}
+50 -29
View File
@@ -614,8 +614,20 @@ type Hub struct {
tenantCoalesceGeneration map[string]uint64
stateBroadcastWake chan struct{}
stateBroadcastDone chan struct{}
// stateBroadcastInterval is the minimum spacing between state broadcasts
// to one audience (all clients, or one tenant); zero disables spacing.
stateBroadcastInterval time.Duration
lastStateBroadcast time.Time
tenantLastStateBroadcast map[string]time.Time
}
// defaultStateBroadcastInterval spaces state broadcasts to one audience. Each
// broadcast rebuilds and diffs the full frontend state, and every accepted
// agent report requests one, so a 100 ms debounce let broadcasts scale with
// agent count (#2199). Report-driven store refreshes run at most once per
// 2 seconds, so broadcasting more often mostly re-sent the same resources.
const defaultStateBroadcastInterval = 2 * time.Second
// Message represents a WebSocket message
type Message struct {
Type string `json:"type"`
@@ -702,6 +714,8 @@ func NewHub(getState func(orgID string) interface{}) *Hub {
tenantCoalesceGeneration: make(map[string]uint64),
stateBroadcastWake: make(chan struct{}, 1),
stateBroadcastDone: make(chan struct{}),
stateBroadcastInterval: defaultStateBroadcastInterval,
tenantLastStateBroadcast: make(map[string]time.Time),
}
}
@@ -1218,6 +1232,7 @@ func (h *Hub) markGlobalStateBroadcastReady(generation uint64) {
h.coalesceReady = h.coalescePending
h.coalescePending = nil
h.coalesceTimer = nil
h.lastStateBroadcast = time.Now()
h.coalesceMutex.Unlock()
h.wakeStateBroadcastWorker()
}
@@ -1237,6 +1252,7 @@ func (h *Hub) markTenantStateBroadcastReady(orgID string, generation uint64) {
delete(h.tenantCoalescePending, orgID)
delete(h.tenantCoalesceTimers, orgID)
delete(h.tenantCoalesceGeneration, orgID)
h.tenantLastStateBroadcast[orgID] = time.Now()
h.coalesceMutex.Unlock()
h.wakeStateBroadcastWorker()
}
@@ -1401,6 +1417,20 @@ func (h *Hub) marshalBroadcastMessage(msg Message, orgID string) ([]byte, bool)
return data, true
}
// stateBroadcastDelayLocked returns how long a newly requested state
// broadcast waits: coalesceWindow to merge simultaneous signals, and at least
// stateBroadcastInterval after the audience's previous broadcast. The caller
// holds coalesceMutex.
func (h *Hub) stateBroadcastDelayLocked(last time.Time) time.Duration {
delay := h.coalesceWindow
if !last.IsZero() && h.stateBroadcastInterval > 0 {
if wait := h.stateBroadcastInterval - time.Since(last); wait > delay {
delay = wait
}
}
return delay
}
// runBroadcastSequencer handles sequenced broadcasts with coalescing for rapid state updates
func (h *Hub) runBroadcastSequencer() {
for {
@@ -1410,22 +1440,18 @@ func (h *Hub) runBroadcastSequencer() {
if msg.Type == "rawData" {
h.coalesceMutex.Lock()
// Cancel pending timer if exists
if h.coalesceTimer != nil {
h.coalesceTimer.Stop()
}
// Update pending message
// The latest state wins. A due broadcast is not rescheduled, so
// a steady stream of signals cannot postpone it.
current := msg
h.coalescePending = &current
h.nextGeneration++
generation := h.nextGeneration
h.coalesceGeneration = generation
// Set timer to send after coalesce window
h.coalesceTimer = time.AfterFunc(h.coalesceWindow, func() {
h.markGlobalStateBroadcastReady(generation)
})
if h.coalesceTimer == nil {
h.nextGeneration++
generation := h.nextGeneration
h.coalesceGeneration = generation
h.coalesceTimer = time.AfterFunc(h.stateBroadcastDelayLocked(h.lastStateBroadcast), func() {
h.markGlobalStateBroadcastReady(generation)
})
}
h.coalesceMutex.Unlock()
} else {
@@ -1440,23 +1466,18 @@ func (h *Hub) runBroadcastSequencer() {
if tb.Message.Type == "rawData" {
h.coalesceMutex.Lock()
// Cancel pending timer for this tenant if exists
if timer := h.tenantCoalesceTimers[tb.OrgID]; timer != nil {
timer.Stop()
}
// Update pending message for this tenant
// The latest state wins; a due tenant broadcast keeps its time.
msgCopy := tb.Message
h.tenantCoalescePending[tb.OrgID] = &msgCopy
h.nextGeneration++
generation := h.nextGeneration
h.tenantCoalesceGeneration[tb.OrgID] = generation
// Set timer to send after coalesce window
orgID := tb.OrgID // Capture for closure
h.tenantCoalesceTimers[orgID] = time.AfterFunc(h.coalesceWindow, func() {
h.markTenantStateBroadcastReady(orgID, generation)
})
if h.tenantCoalesceTimers[tb.OrgID] == nil {
h.nextGeneration++
generation := h.nextGeneration
h.tenantCoalesceGeneration[tb.OrgID] = generation
orgID := tb.OrgID // Capture for closure
h.tenantCoalesceTimers[orgID] = time.AfterFunc(h.stateBroadcastDelayLocked(h.tenantLastStateBroadcast[orgID]), func() {
h.markTenantStateBroadcastReady(orgID, generation)
})
}
h.coalesceMutex.Unlock()
} else {
+123
View File
@@ -1245,3 +1245,126 @@ func TestBroadcastCurrentStateToTenantResolvesTenantState(t *testing.T) {
t.Fatalf("lazy tenant state org = %v, want org-123", payload["org"])
}
}
// startStateBroadcastHarness runs the sequencer and state worker for hub and
// registers one client, optionally in a tenant.
func startStateBroadcastHarness(t *testing.T, hub *Hub, orgID string) *Client {
t.Helper()
client := &Client{hub: hub, send: make(chan []byte, 16), id: "state-rate-client", orgID: orgID}
hub.mu.Lock()
hub.clients[client] = true
if orgID != "" {
if hub.clientsByTenant[orgID] == nil {
hub.clientsByTenant[orgID] = make(map[*Client]bool)
}
hub.clientsByTenant[orgID][client] = true
}
hub.mu.Unlock()
sequencerDone := make(chan struct{})
go func() {
hub.runBroadcastSequencer()
close(sequencerDone)
}()
go hub.runStateBroadcastWorker()
t.Cleanup(func() {
close(hub.stopChan)
<-sequencerDone
<-hub.stateBroadcastDone
})
return client
}
func receiveStateValue(t *testing.T, client *Client, within time.Duration) (string, bool) {
t.Helper()
select {
case data := <-client.send:
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal message: %v", err)
}
payload, _ := msg.Data.(map[string]interface{})
value, _ := payload["value"].(string)
return value, true
case <-time.After(within):
return "", false
}
}
// Every accepted agent report requests a state broadcast, and each broadcast
// rebuilds the full frontend state. Broadcasts to one audience are spaced by
// stateBroadcastInterval and carry the latest state (#2199).
func TestStateBroadcastsSpacedByInterval(t *testing.T) {
hub := NewHub(nil)
hub.coalesceWindow = 5 * time.Millisecond
hub.stateBroadcastInterval = 300 * time.Millisecond
client := startStateBroadcastHarness(t, hub, "")
hub.broadcastSeq <- Message{Type: "rawData", Data: map[string]string{"value": "first"}}
if value, ok := receiveStateValue(t, client, time.Second); !ok || value != "first" {
t.Fatalf("first broadcast = %q (received %v), want an immediate first broadcast", value, ok)
}
start := time.Now()
for _, value := range []string{"second", "third", "fourth"} {
hub.broadcastSeq <- Message{Type: "rawData", Data: map[string]string{"value": value}}
}
if value, ok := receiveStateValue(t, client, 150*time.Millisecond); ok {
t.Fatalf("broadcast %q arrived %v after the previous one, inside the interval", value, time.Since(start))
}
if value, ok := receiveStateValue(t, client, 2*time.Second); !ok || value != "fourth" {
t.Fatalf("spaced broadcast = %q (received %v), want the latest state", value, ok)
}
if value, ok := receiveStateValue(t, client, 100*time.Millisecond); ok {
t.Fatalf("burst produced a second spaced broadcast %q", value)
}
}
// A signal arriving while a broadcast is due must not push it back. With the
// earlier restart-on-signal debounce, signals closer together than the window
// postponed every broadcast until they stopped.
func TestStateBroadcastNotStarvedBySteadySignals(t *testing.T) {
hub := NewHub(nil)
hub.coalesceWindow = 60 * time.Millisecond
hub.stateBroadcastInterval = 0
client := startStateBroadcastHarness(t, hub, "")
stop := time.After(600 * time.Millisecond)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
received := 0
for streaming := true; streaming; {
select {
case <-ticker.C:
hub.broadcastSeq <- Message{Type: "rawData", Data: map[string]string{"value": "tick"}}
case <-client.send:
received++
case <-stop:
streaming = false
}
}
if received == 0 {
t.Fatal("no state broadcast while signals kept arriving every 10 ms")
}
}
func TestTenantStateBroadcastsSpacedByInterval(t *testing.T) {
hub := NewHub(nil)
hub.coalesceWindow = 5 * time.Millisecond
hub.stateBroadcastInterval = 300 * time.Millisecond
client := startStateBroadcastHarness(t, hub, "acme")
send := func(value string) {
hub.tenantBroadcast <- TenantBroadcast{OrgID: "acme", Message: Message{Type: "rawData", Data: map[string]string{"value": value}}}
}
send("first")
if value, ok := receiveStateValue(t, client, time.Second); !ok || value != "first" {
t.Fatalf("first tenant broadcast = %q (received %v)", value, ok)
}
send("second")
send("third")
if value, ok := receiveStateValue(t, client, 150*time.Millisecond); ok {
t.Fatalf("tenant broadcast %q arrived inside the interval", value)
}
if value, ok := receiveStateValue(t, client, 2*time.Second); !ok || value != "third" {
t.Fatalf("spaced tenant broadcast = %q (received %v), want the latest state", value, ok)
}
}
+49 -1
View File
@@ -1,6 +1,14 @@
package licensing
import "testing"
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"testing"
"time"
)
func TestNormalizeBillingStatePreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) {
state := &BillingState{
@@ -93,3 +101,43 @@ func TestCloudClaimsMissingPlanVersionDoesNotReintroduceMonitoringLimit(t *testi
t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", claims.EffectiveLimits())
}
}
// A provider control plane must keep starting on a lapsed licence so its
// portal can sell the renewal, but the licence must still be authentic, and
// client runtimes must still refuse to take MSP capabilities from it.
func TestValidateLicenseAllowingLapseReturnsLapsedButStillAuthenticLicence(t *testing.T) {
setupTestPublicKey(t)
providerPub, providerPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate provider key pair: %v", err)
}
lapsed := mintTestProviderMSPLicense(t, TierMSP, providerPub, -30*24*time.Hour)
if _, err := ValidateLicense(lapsed); !errors.Is(err, ErrExpiredLicense) {
t.Fatalf("ValidateLicense(lapsed) error = %v, want ErrExpiredLicense", err)
}
license, err := ValidateLicenseAllowingLapse(lapsed)
if err != nil || license == nil {
t.Fatalf("ValidateLicenseAllowingLapse(lapsed) = %v, %v; want the licence", license, err)
}
if !license.IsExpired() || license.GracePeriodEnd == nil || !license.GracePeriodEnd.Before(time.Now()) {
t.Fatalf("lapsed licence expired=%v graceEnd=%v; want expired with a grace end in the past", license.IsExpired(), license.GracePeriodEnd)
}
// Authenticity is not relaxed: a licence not signed by the Pulse root is
// still refused.
claims := Claims{LicenseID: "lic_forged", Email: "attacker@example.com", Tier: TierMSP, IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(-30 * 24 * time.Hour).Unix(), PlanVersion: "msp_starter",
EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(providerPub)}
payload, _ := json.Marshal(claims)
if _, err := ValidateLicenseAllowingLapse(signTestJWT(t, payload, providerPriv)); err == nil {
t.Fatal("ValidateLicenseAllowingLapse accepted a licence not signed by the Pulse root")
}
// Enforcement stays with the runtime: a lease chained to the lapsed
// licence does not verify.
token := signTestProviderLease(t, providerPriv, lapsed, []string{FeatureWhiteLabel, FeatureMultiTenant})
if _, err := VerifyEntitlementLeaseToken(token, testPublicKey, "t-acme.pulse.example-msp.com", time.Now()); err == nil {
t.Fatal("a lease chained to a lapsed provider licence must not verify")
}
}
+17
View File
@@ -935,6 +935,21 @@ func cloneClaims(in Claims) Claims {
// ValidateLicense validates a license key and returns the license if valid.
func ValidateLicense(licenseKey string) (*License, error) {
return validateLicense(licenseKey, false)
}
// ValidateLicenseAllowingLapse applies every ValidateLicense check (format,
// Pulse signature, required claims) but returns a licence past its expiry and
// grace period instead of rejecting it, with GracePeriodEnd set. The caller
// owns enforcement. A provider-hosted MSP control plane uses it to keep
// starting on a lapsed licence so its portal can sell the renewal; the client
// runtimes it serves still verify the licence with ValidateLicense and drop
// MSP capabilities once it lapses.
func ValidateLicenseAllowingLapse(licenseKey string) (*License, error) {
return validateLicense(licenseKey, true)
}
func validateLicense(licenseKey string, allowLapsed bool) (*License, error) {
// Trim whitespace
licenseKey = strings.TrimSpace(licenseKey)
if licenseKey == "" {
@@ -1028,6 +1043,8 @@ func ValidateLicense(licenseKey string) (*License, error) {
// Within grace period - allow activation but mark as in grace period
license.GracePeriodEnd = &gracePeriodEnd
// License is still valid during grace period
} else if allowLapsed {
license.GracePeriodEnd = &gracePeriodEnd
} else {
// Past grace period - reject
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",
@@ -643,3 +643,167 @@ func TestProviderMSPSetupLeavesPlatformRunning(t *testing.T) {
}
assertContainsAll(t, first, "DOMAIN", "ACME_EMAIL", "CF_DNS_API_TOKEN")
}
// Setup pins the images in .env to digests once, so an upgrade used to re-pull
// only the release a provider first installed. Run from a newly extracted
// bundle, upgrade.sh must install that bundle into the existing install,
// re-pin the two Pulse images to the bundle's release, keep a copy of the old
// .env, and only then hand over to the backup-gated flow.
func TestProviderMSPUpgradeFromBundleRepinsToTheBundleRelease(t *testing.T) {
newDigest := "sha256:" + strings.Repeat("b", 64)
oldControlPlane := "ghcr.io/rcourtman/pulse-control-plane@sha256:" + strings.Repeat("a", 64)
oldRuntime := "ghcr.io/rcourtman/pulse@sha256:" + strings.Repeat("a", 64)
setup := func(t *testing.T) (bundle, install, dockerLog string) {
t.Helper()
root := t.TempDir()
bundle = filepath.Join(root, "pulse-provider-msp-v6.6.0")
install = filepath.Join(root, "install")
bin := filepath.Join(root, "bin")
for _, dir := range []string{bundle, install, bin} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
for _, name := range []string{"docker-compose.yml", "traefik.yml", "traefik-dynamic.yml", ".env.example", "run-install-proof.sh", "upgrade.sh", "setup.sh"} {
content, err := os.ReadFile(repoFile("deploy", "provider-msp", name))
if err != nil {
t.Fatal(err)
}
if name == ".env.example" {
// What scripts/build-release.sh stamps into a release bundle.
text := strings.Replace(string(content), "\nCONTROL_PLANE_IMAGE=\n", "\nCONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane:v6.6.0\n", 1)
text = strings.Replace(text, "\nCP_PULSE_IMAGE=\n", "\nCP_PULSE_IMAGE=ghcr.io/rcourtman/pulse:v6.6.0\n", 1)
content = []byte(text)
}
if err := os.WriteFile(filepath.Join(bundle, name), content, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(bundle, "VERSION"), []byte("6.6.0\n"), 0o644); err != nil {
t.Fatal(err)
}
env := "DOMAIN=msp.example.com\nCONTROL_PLANE_IMAGE=" + oldControlPlane + "\nCP_PULSE_IMAGE=" + oldRuntime + "\nPULSE_PROVIDER_MSP_DATA_DIR=/data\n"
if err := os.WriteFile(filepath.Join(install, ".env"), []byte(env), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(install, "upgrade.sh"), []byte("#!/bin/sh\necho old-upgrade-script\n"), 0o755); err != nil {
t.Fatal(err)
}
dockerLog = filepath.Join(root, "docker.log")
// Log each call with the control-plane pin in .env and the image
// compose would use (a shell value wins over .env) at that moment.
fake := "#!/bin/sh\nfile=$(grep '^CONTROL_PLANE_IMAGE=' .env 2>/dev/null | cut -d= -f2)\n" +
"echo \"$* [env=${file}] [uses=${CONTROL_PLANE_IMAGE:-$file}]\" >> " + dockerLog + "\n" +
"case \"$*\" in\n" +
" 'buildx imagetools inspect '*' --format '*) echo '{\"digest\":\"" + newDigest + "\"}' ;;\n" +
" 'buildx imagetools inspect '*) echo 'Digest: " + newDigest + "' ;;\n" +
" *'backup create'*) echo 'archive_path=/data/backups/pre-upgrade.tar.gz' ;;\n" +
"esac\nexit 0\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(fake), 0o755); err != nil {
t.Fatal(err)
}
return bundle, install, dockerLog
}
run := func(t *testing.T, bundle, install string, args ...string) string {
t.Helper()
cmd := exec.Command("bash", append([]string{filepath.Join(bundle, "upgrade.sh")}, args...)...)
cmd.Env = append(os.Environ(), "PATH="+filepath.Join(filepath.Dir(bundle), "bin")+":"+os.Getenv("PATH"), "PULSE_PROVIDER_MSP_INSTALL_DIR="+install)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("upgrade.sh %v: %v\n%s", args, err, output)
}
return string(output)
}
envOf := func(t *testing.T, install string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(install, ".env"))
if err != nil {
t.Fatal(err)
}
return string(b)
}
t.Run("upgrade", func(t *testing.T) {
bundle, install, dockerLog := setup(t)
output := run(t, bundle, install)
env := envOf(t, install)
for _, want := range []string{
"CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@" + newDigest,
"CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@" + newDigest,
"DOMAIN=msp.example.com",
} {
if !strings.Contains(env, want) {
t.Fatalf("upgraded .env lacks %q:\n%s", want, env)
}
}
backups, _ := filepath.Glob(filepath.Join(install, ".env.pre-upgrade-*"))
if len(backups) != 1 {
t.Fatalf("pre-upgrade .env copies = %v, want one", backups)
}
if old, _ := os.ReadFile(backups[0]); !strings.Contains(string(old), oldControlPlane) {
t.Fatal("pre-upgrade copy does not hold the previous pins")
}
installed, _ := os.ReadFile(filepath.Join(install, "upgrade.sh"))
shipped, _ := os.ReadFile(filepath.Join(bundle, "upgrade.sh"))
if string(installed) != string(shipped) {
t.Fatal("bundle upgrade.sh was not installed")
}
logBytes, _ := os.ReadFile(dockerLog)
lines := strings.Split(string(logBytes), "\n")
lineWith := func(needle string) string {
for _, line := range lines {
if strings.Contains(line, needle) {
return line
}
}
t.Fatalf("docker was never called with %q:\n%s", needle, logBytes)
return ""
}
// The check and the backup run the new release's control plane as a
// one-off while .env still holds the old pins; .env changes only
// before the new images start.
newControlPlane := "ghcr.io/rcourtman/pulse-control-plane@" + newDigest
for _, step := range []string{"control-plane provider-msp status", "provider-msp backup create"} {
line := lineWith(step)
if !strings.Contains(line, "[env="+oldControlPlane+"]") || !strings.Contains(line, "[uses="+newControlPlane+"]") {
t.Fatalf("%s should run the new control plane with .env untouched: %s", step, line)
}
}
// The new runtime image is pulled before status checks it is present.
pull := strings.Index(string(logBytes), "pull ghcr.io/rcourtman/pulse@"+newDigest)
status := strings.Index(string(logBytes), "control-plane provider-msp status")
if pull < 0 || status < 0 || pull > status {
t.Fatalf("the new tenant runtime image must be pulled before the status gate:\n%s", logBytes)
}
if line := lineWith("compose up -d traefik docker-socket-proxy control-plane"); !strings.Contains(line, "[env="+newControlPlane+"]") {
t.Fatalf("new images did not start on the new pins: %s", line)
}
if !strings.Contains(output, "provider_msp_upgrade_ok=true") || !strings.Contains(output, "provider_msp_upgrade_bundle_version=6.6.0") {
t.Fatalf("upgrade output:\n%s", output)
}
})
t.Run("keep image pins", func(t *testing.T) {
bundle, install, _ := setup(t)
run(t, bundle, install, "--keep-image-pins")
env := envOf(t, install)
if !strings.Contains(env, "CONTROL_PLANE_IMAGE="+oldControlPlane) || !strings.Contains(env, "CP_PULSE_IMAGE="+oldRuntime) {
t.Fatalf("--keep-image-pins changed the pins:\n%s", env)
}
})
t.Run("dry run changes nothing", func(t *testing.T) {
bundle, install, _ := setup(t)
output := run(t, bundle, install, "--dry-run")
if env := envOf(t, install); !strings.Contains(env, "CONTROL_PLANE_IMAGE="+oldControlPlane) {
t.Fatalf("dry run changed .env:\n%s", env)
}
if installed, _ := os.ReadFile(filepath.Join(install, "upgrade.sh")); string(installed) != "#!/bin/sh\necho old-upgrade-script\n" {
t.Fatal("dry run installed bundle files")
}
if !strings.Contains(output, "current="+oldControlPlane) || !strings.Contains(output, "target=ghcr.io/rcourtman/pulse-control-plane@"+newDigest) {
t.Fatalf("dry run did not print the pin plan:\n%s", output)
}
})
}
@@ -291,10 +291,25 @@ test.describe("Journey: Agent Install → Registration → Host Visible", () =>
});
expect(updateRes.ok()).toBeTruthy();
const stateRes2 = await apiRequest(page, "/api/state");
expect(stateRes2.ok()).toBeTruthy();
const state2 = (await stateRes2.json()) as Record<string, unknown>;
const host2 = findRegisteredAgentResource(state2);
// Reports within one refresh window are published together by a
// trailing store refresh (agent-lifecycle contract, #2199), so the
// update is visible within the window rather than at once.
let host2: StateResource | undefined;
await expect
.poll(
async () => {
const stateRes2 = await apiRequest(page, "/api/state");
expect(stateRes2.ok()).toBeTruthy();
const state2 = (await stateRes2.json()) as Record<string, unknown>;
host2 = findRegisteredAgentResource(state2);
return readNumber(asRecord(host2?.cpu)?.current) ?? 0;
},
{
message: "CPU usage should reflect updated report (75%)",
timeout: 10_000,
},
)
.toBeGreaterThanOrEqual(70);
expect(host2, "Host should still exist after second report").toBeTruthy();
const lastSeen2 = readNumber(host2!.lastSeen);