mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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(®istry.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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "d5c69f37d9cf32a2e8ef727af6779bd44148235ed352fcb427967ef8a6a24859",
|
||||
"source_hash": "2fa7bbb64e7cb3e9e7e187a81019fdbb723c9591543e9905dba8cf2617d5c958",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+24
-8
@@ -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')));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = ¤t
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user