mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Stabilize Proxmox guest Docker inventory
This commit is contained in:
parent
d221640086
commit
b179b1782c
16 changed files with 359 additions and 50 deletions
|
|
@ -799,6 +799,13 @@ profile and assignment columns, but embedded table framing must route through
|
|||
the host, auto-detection and remote config must not start the Docker /
|
||||
Podman module.
|
||||
8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
|
||||
On Unix systemd hosts, `scripts/install.sh` must keep the default agent unit
|
||||
hardened, but command-enabled Proxmox VE agents selected with
|
||||
`--enable-commands --enable-proxmox` and type `pve` or `all` must explicitly
|
||||
allow the minimum `pct exec` / `lxc-attach` service posture by setting
|
||||
`NoNewPrivileges=false` and `RestrictSUIDSGID=false`. That exception is
|
||||
limited to the PVE command-agent path; it must not become the default for
|
||||
Docker / Podman, host-only, PBS-only, or ordinary non-command agents.
|
||||
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
|
||||
Approval-gated command execution must expose stable rejection reasons for
|
||||
invalid approval grants so fleet operators can distinguish missing, expired,
|
||||
|
|
@ -2097,6 +2104,12 @@ server itself cannot use afterward. That same selection path must only persist
|
|||
selected host; if every candidate fingerprint probe fails, registration must
|
||||
fall back to the preferred normalized host with strict TLS disabled instead of
|
||||
pretending public-CA verification is now safe for a self-signed Proxmox node.
|
||||
Cluster-member Proxmox agents that match a non-primary endpoint of an existing
|
||||
cluster source are already registered for lifecycle purposes, even when the
|
||||
cluster source's primary connection is currently disconnected. Only the primary
|
||||
configured endpoint may drive disconnected-source repair and token rotation; a
|
||||
covered member endpoint must not rotate the cluster token just because it can
|
||||
reach `/api/auto-register`.
|
||||
That same canonical behavior also includes one auth transport for Proxmox
|
||||
completion: runtime-side Unified Agent and script callers must send `/api/auto-register`
|
||||
authentication through a one-time setup token in the request-body
|
||||
|
|
|
|||
|
|
@ -719,6 +719,13 @@ payload shape change when the portal presents compact client rows.
|
|||
36. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint.
|
||||
That same canonical payload contract also owns strict-TLS truth for that selected host: `/api/auto-register` may only persist `VerifySSL=true` when Pulse actually captured a certificate fingerprint for the selected candidate, and it must not pretend public-CA verification is safe after every candidate fingerprint probe failed.
|
||||
For PVE cluster sources, that same contract must distinguish primary
|
||||
configured endpoints from covered cluster-member endpoints. A caller whose
|
||||
candidate hosts match a non-primary `clusterEndpoints` entry is already
|
||||
registered and must not rotate the shared Proxmox token, even if the
|
||||
primary connection is currently disconnected. A caller whose candidates
|
||||
match the primary configured endpoint may still drive disconnected-source
|
||||
repair.
|
||||
That same contract now owns stale-marker verification as well: setup-token-authenticated `checkRegistration` requests may omit token completion fields and must answer `{registered:boolean}` from canonical candidate-host matching so runtime repair can distinguish real registrations from stale local marker files without rotating tokens first.
|
||||
That same contract owns auto-register WebSocket event intent: a successful
|
||||
completion that creates a new PVE/PBS node may broadcast
|
||||
|
|
|
|||
|
|
@ -277,6 +277,12 @@ TLS floor in the dynamic config.
|
|||
When enabled, the terminal summary must also state that Proxmox LXC Docker
|
||||
inventory still requires explicit server-side
|
||||
`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true`.
|
||||
For command-enabled PVE agents, the generated systemd unit must keep the
|
||||
normal service hardening except for the two flags that block host-side
|
||||
`pct exec` / `lxc-attach`: `NoNewPrivileges=false` and
|
||||
`RestrictSUIDSGID=false`. That exception is deployment-owned operator truth
|
||||
for the Proxmox LXC Docker inventory path and must not leak into non-PVE or
|
||||
non-command agent installs.
|
||||
|
||||
## Extension Points
|
||||
|
||||
|
|
@ -397,6 +403,13 @@ TLS floor in the dynamic config.
|
|||
first-run reset removes the runtime `.env`; otherwise the Pro backend fails
|
||||
closed before binding the API port and the supervisor loops without ever
|
||||
reaching browser-verifiable health.
|
||||
Hot-dev backend launches, supervisor child launches, and takeover restarts
|
||||
must also preserve `LOG_LEVEL` and the Proxmox guest-Docker opt-in
|
||||
environment (`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION`,
|
||||
`PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY`, and
|
||||
`PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of
|
||||
host-side LXC Docker inventory does not silently restart into default-off
|
||||
monitoring.
|
||||
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
|
||||
That release-promotion boundary also owns prerelease note packet lineage:
|
||||
shipped RC notes must remain historically accurate, the top-level
|
||||
|
|
|
|||
|
|
@ -167,7 +167,12 @@ truth for live infrastructure data.
|
|||
server opt-in. LXC Docker inventory may only emit Docker / Podman
|
||||
module-compatible reports into `ApplyDockerReport`, must skip guests with a
|
||||
linked online guest-local host agent, and must keep the command set to
|
||||
minimal read-only Docker summary and aggregate stats collection.
|
||||
minimal read-only Docker summary and aggregate stats collection. The socket
|
||||
probe must run its yes/no marker inside the target LXC through `pct exec`;
|
||||
host-side `pct` / `lxc-attach` failures are probe errors and must not be
|
||||
converted into cached `HasDocker=false` results. Negative Docker detections
|
||||
may be rechecked on a short cadence so command enrollment, daemon startup,
|
||||
or transient Proxmox access failures do not hide later inventory.
|
||||
15. Add or change mock-mode Discovery context through the canonical mock
|
||||
fixture graph. Mock Discovery records must be derived from the same authored
|
||||
state graph as mock nodes, guests, Docker hosts, containers, and Kubernetes
|
||||
|
|
|
|||
|
|
@ -356,6 +356,11 @@ recovery scope, or a storage/recovery-owned secret source.
|
|||
for host-side Docker-in-LXC collection must not be reinterpreted as backup
|
||||
freshness, restore coverage, storage protection evidence, or remediation
|
||||
authority for the LXC guest.
|
||||
Proxmox auto-registration dedupe for cluster-member endpoints is likewise
|
||||
adjacent API/lifecycle continuity: treating a non-primary cluster endpoint
|
||||
as already registered prevents accidental shared-token rotation, but it does
|
||||
not create backup visibility, recovery readiness, restore scope, or
|
||||
storage-local authority for that endpoint.
|
||||
Global resource timeline routing may also pass through shared
|
||||
`internal/api/` handlers. Storage and recovery surfaces may read canonical
|
||||
timeline records as adjacent evidence, but they must not reinterpret an
|
||||
|
|
|
|||
|
|
@ -446,6 +446,56 @@ func TestHandleAutoRegisterCheckRegistrationReturnsFalseForMissingNode(t *testin
|
|||
}
|
||||
}
|
||||
|
||||
func TestAutoRegisteredPVEInstanceExistsTreatsClusterEndpointAsRegisteredWhenPrimaryDisconnected(t *testing.T) {
|
||||
instances := []config.PVEInstance{
|
||||
{
|
||||
Name: "delly",
|
||||
Host: "https://delly:8006",
|
||||
ClusterName: "homelab",
|
||||
IsCluster: true,
|
||||
ClusterEndpoints: []config.ClusterEndpoint{
|
||||
{
|
||||
NodeName: "minipc",
|
||||
Host: "https://minipc:8006",
|
||||
IP: "192.168.0.134",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
connStatuses := map[string]bool{
|
||||
"delly": false,
|
||||
}
|
||||
|
||||
if !autoRegisteredPVEInstanceExists(instances, connStatuses, []string{"https://192.168.0.134:8006"}) {
|
||||
t.Fatal("expected covered cluster endpoint to remain registered even while primary connection is disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRegisteredPVEInstanceExistsAllowsPrimaryDisconnectedRepair(t *testing.T) {
|
||||
instances := []config.PVEInstance{
|
||||
{
|
||||
Name: "delly",
|
||||
Host: "https://delly:8006",
|
||||
ClusterName: "homelab",
|
||||
IsCluster: true,
|
||||
ClusterEndpoints: []config.ClusterEndpoint{
|
||||
{
|
||||
NodeName: "minipc",
|
||||
Host: "https://minipc:8006",
|
||||
IP: "192.168.0.134",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
connStatuses := map[string]bool{
|
||||
"delly": false,
|
||||
}
|
||||
|
||||
if autoRegisteredPVEInstanceExists(instances, connStatuses, []string{"https://delly:8006"}) {
|
||||
t.Fatal("expected primary configured connection to be repairable when monitor confirms it is disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAutoRegisterRejectsMissingSource(t *testing.T) {
|
||||
stubAutoRegisterNetworkDeps(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -705,6 +705,52 @@ func autoRegisterHostMatchesCandidates(existingHost string, candidates []string)
|
|||
return false
|
||||
}
|
||||
|
||||
func autoRegisterClusterEndpointMatchesCandidates(endpoint config.ClusterEndpoint, candidates []string) bool {
|
||||
for _, endpointHost := range []string{
|
||||
endpoint.Host,
|
||||
endpoint.GuestURL,
|
||||
endpoint.IPOverride,
|
||||
endpoint.IP,
|
||||
} {
|
||||
if autoRegisterHostMatchesCandidates(endpointHost, candidates) {
|
||||
return true
|
||||
}
|
||||
if normalized, err := normalizeNodeHost(endpointHost, "pve"); err == nil && autoRegisterHostMatchesCandidates(normalized, candidates) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func autoRegisterPVEInstanceMatchesCandidates(node config.PVEInstance, candidates []string) (matched bool, primary bool) {
|
||||
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
||||
return true, true
|
||||
}
|
||||
for _, endpoint := range node.ClusterEndpoints {
|
||||
if autoRegisterClusterEndpointMatchesCandidates(endpoint, candidates) {
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func autoRegisteredPVEInstanceExists(instances []config.PVEInstance, connStatuses map[string]bool, candidates []string) bool {
|
||||
for _, node := range instances {
|
||||
matched, primary := autoRegisterPVEInstanceMatchesCandidates(node, candidates)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
// A non-primary cluster endpoint is already covered by the cluster
|
||||
// connection. Letting endpoint agents rotate the shared PVE token can
|
||||
// invalidate the primary configured connection.
|
||||
if primary && isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
|
|
@ -718,18 +764,7 @@ func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *Auto
|
|||
|
||||
switch req.Type {
|
||||
case "pve":
|
||||
for _, node := range h.getConfig(ctx).PVEInstances {
|
||||
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
||||
// Node exists in config. If the monitor has definitively confirmed
|
||||
// it is disconnected (stale token), allow re-registration so the
|
||||
// agent can update the token. Default to "registered" when there
|
||||
// is no monitor status yet (e.g. startup race).
|
||||
if isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return autoRegisteredPVEInstanceExists(h.getConfig(ctx).PVEInstances, connStatuses, candidates)
|
||||
case "pbs":
|
||||
for _, node := range h.getConfig(ctx).PBSInstances {
|
||||
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
||||
|
|
|
|||
|
|
@ -884,9 +884,13 @@ func TestProxmoxGuestDockerInventoryUsesCanonicalReportIngestPath(t *testing.T)
|
|||
requiredSnippets := map[string][]string{
|
||||
"docker_detection.go": {
|
||||
"type DockerInventoryCollector interface {",
|
||||
"proxmoxGuestDockerSocketMarker",
|
||||
"proxmoxGuestDockerNegativeRecheckAfter",
|
||||
"func (m *Monitor) CollectProxmoxGuestDockerInventory(ctx context.Context, containers []models.Container) {",
|
||||
"m.hasOnlineHostAgentForContainer(ct.ID)",
|
||||
"m.ApplyDockerReport(report, nil)",
|
||||
"pct exec %d -- sh -c",
|
||||
"container docker socket probe failed",
|
||||
`docker ps -a --no-trunc --format 'CONTAINER\\t{{json .ID}}`,
|
||||
`docker stats --no-stream --no-trunc --format 'STAT\\t{{json .ID}}`,
|
||||
"ParseProxmoxGuestDockerInventoryVMIDs",
|
||||
|
|
@ -908,6 +912,9 @@ func TestProxmoxGuestDockerInventoryUsesCanonicalReportIngestPath(t *testing.T)
|
|||
t.Fatalf("%s must contain %q", file, snippet)
|
||||
}
|
||||
}
|
||||
if file == "docker_detection.go" && strings.Contains(source, "&& echo yes || echo no") {
|
||||
t.Fatalf("%s must not mask host-side pct exec failures with a host-shell echo fallback", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const proxmoxGuestDockerInventoryMarker = "PULSE_DOCKER_GUEST_INVENTORY_V1"
|
||||
const (
|
||||
proxmoxGuestDockerInventoryMarker = "PULSE_DOCKER_GUEST_INVENTORY_V1"
|
||||
proxmoxGuestDockerSocketMarker = "PULSE_DOCKER_SOCKET"
|
||||
proxmoxGuestDockerNegativeRecheckAfter = 5 * time.Minute
|
||||
)
|
||||
|
||||
// DockerChecker provides the ability to check for Docker inside LXC containers.
|
||||
// This is typically implemented by wrapping the agentexec.Server.
|
||||
|
|
@ -157,6 +161,14 @@ func (m *Monitor) containerNeedsDockerCheck(ct models.Container, previousContain
|
|||
return "first_check"
|
||||
}
|
||||
|
||||
// Negative Docker checks can be transient during agent command enrollment,
|
||||
// Proxmox token rotation, or Docker daemon startup. Recheck them on a short
|
||||
// cadence so a single false result does not hide Docker-in-LXC inventory
|
||||
// until the guest restarts.
|
||||
if !prev.HasDocker && time.Since(prev.DockerCheckedAt) >= proxmoxGuestDockerNegativeRecheckAfter {
|
||||
return "negative_cache_expired"
|
||||
}
|
||||
|
||||
// Container restarted - uptime is less than before
|
||||
// (This catches containers that were stopped and started again)
|
||||
if ct.Uptime < prev.Uptime && prev.Uptime > 0 {
|
||||
|
|
@ -402,36 +414,39 @@ func NewAgentDockerChecker(executeCommand func(ctx context.Context, hostname str
|
|||
}
|
||||
|
||||
// CheckDockerInContainer checks if Docker is installed inside an LXC container
|
||||
// by running `pct exec <vmid> -- test -S /var/run/docker.sock` on the Proxmox node.
|
||||
// by running a socket probe inside the guest through the owning Proxmox node.
|
||||
func (c *AgentDockerChecker) CheckDockerInContainer(ctx context.Context, node string, vmid int) (bool, error) {
|
||||
if c.executeCommand == nil {
|
||||
return false, fmt.Errorf("no command executor configured")
|
||||
}
|
||||
|
||||
// Check for Docker socket - this is the most reliable indicator
|
||||
// We use test -S which checks if the file exists and is a socket
|
||||
cmd := fmt.Sprintf("pct exec %d -- test -S /var/run/docker.sock && echo yes || echo no", vmid)
|
||||
probe := fmt.Sprintf("if test -S /var/run/docker.sock; then printf '%s\\tyes\\n'; else printf '%s\\tno\\n'; fi", proxmoxGuestDockerSocketMarker, proxmoxGuestDockerSocketMarker)
|
||||
cmd := fmt.Sprintf("pct exec %d -- sh -c %s", vmid, shellSingleQuote(probe))
|
||||
|
||||
stdout, exitCode, err := c.executeCommand(ctx, node, cmd, 10) // 10 second timeout
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("command execution failed: %w", err)
|
||||
}
|
||||
|
||||
// The test command itself might fail if container is not accessible
|
||||
// but our echo fallback should always give us output
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
|
||||
// If we got "yes", Docker socket exists
|
||||
if strings.Contains(stdout, "yes") {
|
||||
return true, nil
|
||||
output := strings.TrimSpace(stdout)
|
||||
if exitCode != 0 {
|
||||
return false, fmt.Errorf("container docker socket probe failed (exit code %d): %s", exitCode, output)
|
||||
}
|
||||
|
||||
// If exit code is non-zero and we didn't get "no", the container might not be accessible
|
||||
if exitCode != 0 && !strings.Contains(stdout, "no") {
|
||||
return false, fmt.Errorf("container not accessible (exit code %d): %s", exitCode, stdout)
|
||||
for _, rawLine := range strings.Split(output, "\n") {
|
||||
fields := strings.Split(strings.TrimSpace(rawLine), "\t")
|
||||
if len(fields) != 2 || fields[0] != proxmoxGuestDockerSocketMarker {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fields[1])) {
|
||||
case "yes":
|
||||
return true, nil
|
||||
case "no":
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return false, fmt.Errorf("docker socket probe output missing %s marker: %s", proxmoxGuestDockerSocketMarker, output)
|
||||
}
|
||||
|
||||
// AgentDockerInventoryCollector implements DockerInventoryCollector using the
|
||||
|
|
|
|||
|
|
@ -277,15 +277,15 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "docker socket exists",
|
||||
stdout: "yes\n",
|
||||
stdout: proxmoxGuestDockerSocketMarker + "\tyes\n",
|
||||
exitCode: 0,
|
||||
wantDocker: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "docker socket does not exist",
|
||||
stdout: "no\n",
|
||||
exitCode: 1,
|
||||
stdout: proxmoxGuestDockerSocketMarker + "\tno\n",
|
||||
exitCode: 0,
|
||||
wantDocker: false,
|
||||
wantErr: false,
|
||||
},
|
||||
|
|
@ -296,6 +296,20 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
|
|||
wantDocker: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "host-side pct failure is not cached as no docker",
|
||||
stdout: "no\npct: CT 101 is locked",
|
||||
exitCode: 255,
|
||||
wantDocker: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing marker is an error",
|
||||
stdout: "no\n",
|
||||
exitCode: 0,
|
||||
wantDocker: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -319,6 +333,28 @@ func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAgentDockerChecker_ProbeRunsInsideContainer(t *testing.T) {
|
||||
var gotCommand string
|
||||
checker := NewAgentDockerChecker(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
|
||||
gotCommand = command
|
||||
return proxmoxGuestDockerSocketMarker + "\tyes\n", 0, nil
|
||||
})
|
||||
|
||||
hasDocker, err := checker.CheckDockerInContainer(context.Background(), "node1", 101)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckDockerInContainer returned error: %v", err)
|
||||
}
|
||||
if !hasDocker {
|
||||
t.Fatal("expected Docker to be detected")
|
||||
}
|
||||
if !strings.Contains(gotCommand, "pct exec 101 -- sh -c") {
|
||||
t.Fatalf("expected probe to execute inside the LXC, got %q", gotCommand)
|
||||
}
|
||||
if strings.Contains(gotCommand, "&& echo yes || echo no") {
|
||||
t.Fatalf("host-side shell fallback must not mask pct exec failures: %q", gotCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDockerInventoryCollector_MinimalCommandAndReport(t *testing.T) {
|
||||
var gotCommand string
|
||||
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
|
||||
|
|
@ -820,6 +856,48 @@ func TestContainerNeedsDockerCheck(t *testing.T) {
|
|||
},
|
||||
wantReason: "started",
|
||||
},
|
||||
{
|
||||
name: "recheck expired negative docker result",
|
||||
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
||||
previous: map[string]models.Container{
|
||||
"ct-1": {
|
||||
ID: "ct-1",
|
||||
Status: "running",
|
||||
Uptime: 3600,
|
||||
HasDocker: false,
|
||||
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Second),
|
||||
},
|
||||
},
|
||||
wantReason: "negative_cache_expired",
|
||||
},
|
||||
{
|
||||
name: "keep fresh negative docker result",
|
||||
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
||||
previous: map[string]models.Container{
|
||||
"ct-1": {
|
||||
ID: "ct-1",
|
||||
Status: "running",
|
||||
Uptime: 3600,
|
||||
HasDocker: false,
|
||||
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter + time.Second),
|
||||
},
|
||||
},
|
||||
wantReason: "",
|
||||
},
|
||||
{
|
||||
name: "keep positive docker result",
|
||||
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
||||
previous: map[string]models.Container{
|
||||
"ct-1": {
|
||||
ID: "ct-1",
|
||||
Status: "running",
|
||||
Uptime: 3600,
|
||||
HasDocker: true,
|
||||
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Hour),
|
||||
},
|
||||
},
|
||||
wantReason: "",
|
||||
},
|
||||
{
|
||||
name: "no check needed - same state",
|
||||
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
||||
|
|
|
|||
|
|
@ -568,6 +568,10 @@ start_hot_dev_child() {
|
|||
PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT}" \
|
||||
PULSE_DEV_API_URL="${PULSE_DEV_API_URL}" \
|
||||
PULSE_DEV_WS_URL="${PULSE_DEV_WS_URL}" \
|
||||
LOG_LEVEL="${LOG_LEVEL:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
|
||||
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
|
||||
HOT_DEV_SKIP_NPM_CLEANUP=true \
|
||||
ROOT_DIR="${ROOT_DIR}" \
|
||||
python3 - <<'PY' &
|
||||
|
|
@ -648,6 +652,10 @@ spawn_detached_supervisor() {
|
|||
PULSE_DEV_API_PORT="${PULSE_DEV_API_PORT}" \
|
||||
PULSE_DEV_API_URL="${PULSE_DEV_API_URL}" \
|
||||
PULSE_DEV_WS_URL="${PULSE_DEV_WS_URL}" \
|
||||
LOG_LEVEL="${LOG_LEVEL:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
|
||||
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
|
||||
ROOT_DIR="${ROOT_DIR}" \
|
||||
LOG_FILE="${LOG_FILE}" \
|
||||
HOT_DEV_BG_SCRIPT="${ROOT_DIR}/scripts/hot-dev-bg.sh" \
|
||||
|
|
@ -1143,6 +1151,9 @@ main() {
|
|||
;;
|
||||
stop) stop_bg ;;
|
||||
restart)
|
||||
if [[ "${takeover}" == "true" ]]; then
|
||||
stop_launchd_hot_dev_job
|
||||
fi
|
||||
stop_bg
|
||||
start_bg "${takeover}"
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ PULSE_DEV_DISABLE_BACKGROUND_AI="${PULSE_DEV_DISABLE_BACKGROUND_AI:-true}"
|
|||
PULSE_AUTH_USER="$(hot_dev_resolve_auth_user)"
|
||||
PULSE_AUTH_PASS="$(hot_dev_resolve_auth_pass)"
|
||||
export FRONTEND_PORT PULSE_DEV_API_PORT PORT PULSE_DEV ALLOW_ADMIN_BYPASS PULSE_AUTH_USER PULSE_AUTH_PASS LOG_LEVEL PULSE_DEV_DISABLE_BACKGROUND_AI
|
||||
export PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS
|
||||
|
||||
# Data Directory Setup
|
||||
# Mock and real mode use SEPARATE data directories so switching between them
|
||||
|
|
@ -496,6 +497,9 @@ start_backend_process() {
|
|||
PULSE_DEV="${PULSE_DEV:-true}" \
|
||||
PULSE_AUTH_USER="${PULSE_AUTH_USER:-}" \
|
||||
PULSE_AUTH_PASS="${PULSE_AUTH_PASS:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}" \
|
||||
PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}" \
|
||||
PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}" \
|
||||
ALLOWED_ORIGINS="${ALLOWED_ORIGINS:-}" \
|
||||
LOG_FILE="${BACKEND_DEBUG_LOG}" \
|
||||
LOG_MAX_SIZE="50" \
|
||||
|
|
|
|||
|
|
@ -701,30 +701,36 @@ ensure_freebsd_agent_enabled() {
|
|||
}
|
||||
|
||||
render_systemd_agent_unit() {
|
||||
local unit_path="$1"
|
||||
local exec_path="$2"
|
||||
local exec_args="$3"
|
||||
local after_targets="$4"
|
||||
local unit_path="$1"
|
||||
local exec_path="$2"
|
||||
local exec_args="$3"
|
||||
local after_targets="$4"
|
||||
local wants_targets="$5"
|
||||
local run_as_user="$6"
|
||||
local log_target="$7"
|
||||
local env_line=""
|
||||
local wants_line=""
|
||||
local user_line=""
|
||||
local log_lines=""
|
||||
local user_line=""
|
||||
local log_lines=""
|
||||
local no_new_privileges="true"
|
||||
local restrict_suidsgid="true"
|
||||
|
||||
env_line="$SYSTEMD_ENV_LINES"
|
||||
if [[ -n "$wants_targets" ]]; then
|
||||
wants_line=$'\n'"Wants=${wants_targets}"
|
||||
fi
|
||||
env_line="$SYSTEMD_ENV_LINES"
|
||||
if [[ -n "$wants_targets" ]]; then
|
||||
wants_line=$'\n'"Wants=${wants_targets}"
|
||||
fi
|
||||
if [[ -n "$run_as_user" ]]; then
|
||||
user_line=$'\n'"User=${run_as_user}"
|
||||
fi
|
||||
if [[ -n "$log_target" ]]; then
|
||||
log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}"
|
||||
fi
|
||||
if [[ -n "$log_target" ]]; then
|
||||
log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}"
|
||||
fi
|
||||
if systemd_agent_requires_lxc_attach; then
|
||||
no_new_privileges="false"
|
||||
restrict_suidsgid="false"
|
||||
fi
|
||||
|
||||
cat > "$unit_path" <<EOF
|
||||
cat > "$unit_path" <<EOF
|
||||
[Unit]
|
||||
Description=Pulse Unified Agent
|
||||
After=${after_targets}${wants_line}
|
||||
|
|
@ -736,13 +742,13 @@ ExecStart=${exec_path} ${exec_args}${env_line}
|
|||
Restart=always
|
||||
RestartSec=5s${user_line}${log_lines}
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
NoNewPrivileges=${no_new_privileges}
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictSUIDSGID=${restrict_suidsgid}
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
|
|
@ -750,6 +756,20 @@ WantedBy=multi-user.target
|
|||
EOF
|
||||
}
|
||||
|
||||
systemd_agent_requires_lxc_attach() {
|
||||
if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then
|
||||
return 1
|
||||
fi
|
||||
case "${PROXMOX_TYPE:-}" in
|
||||
""|pve|all)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_freebsd_rc_agent_script() {
|
||||
local script_path="$1"
|
||||
local exec_path="$2"
|
||||
|
|
|
|||
|
|
@ -155,13 +155,15 @@ func TestInstallSHAgentServiceSecurityDefaults(t *testing.T) {
|
|||
`if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi`,
|
||||
`--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;`,
|
||||
`UMask=0077`,
|
||||
`NoNewPrivileges=true`,
|
||||
`local no_new_privileges="true"`,
|
||||
`NoNewPrivileges=${no_new_privileges}`,
|
||||
`PrivateTmp=true`,
|
||||
`ProtectKernelTunables=true`,
|
||||
`ProtectKernelModules=true`,
|
||||
`ProtectControlGroups=true`,
|
||||
`LockPersonality=true`,
|
||||
`RestrictSUIDSGID=true`,
|
||||
`local restrict_suidsgid="true"`,
|
||||
`RestrictSUIDSGID=${restrict_suidsgid}`,
|
||||
`SystemCallArchitectures=native`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
|
|
@ -171,6 +173,27 @@ func TestInstallSHAgentServiceSecurityDefaults(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInstallSHAllowsProxmoxCommandAgentLXCAttach(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read install.sh: %v", err)
|
||||
}
|
||||
|
||||
script := string(content)
|
||||
required := []string{
|
||||
`systemd_agent_requires_lxc_attach() {`,
|
||||
`if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then`,
|
||||
`""|pve|all)`,
|
||||
`no_new_privileges="false"`,
|
||||
`restrict_suidsgid="false"`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(script, needle) {
|
||||
t.Fatalf("install.sh missing Proxmox command-agent LXC attach service handling: %s", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHPreflightChecksAgentDownloadArtifact(t *testing.T) {
|
||||
var requestedDownloadPath string
|
||||
var requestedDownloadArch string
|
||||
|
|
|
|||
|
|
@ -882,6 +882,17 @@ test_hot_dev_bg_script_advertises_managed_entrypoint() {
|
|||
assert_contains "hot-dev-bg can recover a missing managed pid file" "${output}" "recover_managed_pid_file()"
|
||||
}
|
||||
|
||||
test_hot_dev_bg_preserves_proxmox_guest_docker_env() {
|
||||
local output
|
||||
output="$(cat "${ROOT_DIR}/scripts/hot-dev-bg.sh")"
|
||||
|
||||
assert_contains "hot-dev-bg child preserves Proxmox guest Docker detection opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}"'
|
||||
assert_contains "hot-dev-bg child preserves Proxmox guest Docker inventory opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}"'
|
||||
assert_contains "hot-dev-bg child preserves scoped Proxmox guest Docker VMIDs" "${output}" 'PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}"'
|
||||
assert_contains "hot-dev-bg child preserves debug log level" "${output}" 'LOG_LEVEL="${LOG_LEVEL:-}"'
|
||||
assert_contains "hot-dev-bg restart takeover clears launchd before env-sensitive relaunch" "${output}" 'stop_launchd_hot_dev_job'
|
||||
}
|
||||
|
||||
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor() {
|
||||
local test_dir output
|
||||
test_dir="$(mktemp -d)"
|
||||
|
|
@ -1568,6 +1579,7 @@ main() {
|
|||
test_hot_dev_reconciles_agent_reachable_bind_address
|
||||
test_hot_dev_health_monitor_probes_api_health
|
||||
test_hot_dev_bg_script_advertises_managed_entrypoint
|
||||
test_hot_dev_bg_preserves_proxmox_guest_docker_env
|
||||
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor
|
||||
test_hot_dev_bg_preserves_pid_file_when_signal_probe_is_denied
|
||||
test_hot_dev_bg_status_degrades_ownership_when_process_inspection_is_restricted
|
||||
|
|
|
|||
|
|
@ -129,6 +129,16 @@ test_hot_dev_keeps_backend_launch_errors_in_debug_log() {
|
|||
assert_contains "backend LOG_FILE follows debug log override" "${output}" "LOG_FILE=\"\${BACKEND_DEBUG_LOG}\""
|
||||
}
|
||||
|
||||
test_hot_dev_preserves_proxmox_guest_docker_env() {
|
||||
local output
|
||||
output="$(sed -n '1,760p' "${HOT_DEV}")"
|
||||
|
||||
assert_contains "hot-dev exports Proxmox guest Docker detection opt-in" "${output}" "export PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"
|
||||
assert_contains "backend launch preserves Proxmox guest Docker detection opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION:-}"'
|
||||
assert_contains "backend launch preserves Proxmox guest Docker inventory opt-in" "${output}" 'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY="${PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY:-}"'
|
||||
assert_contains "backend launch preserves scoped Proxmox guest Docker VMIDs" "${output}" 'PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS="${PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS:-}"'
|
||||
}
|
||||
|
||||
test_hot_dev_avoids_self_killing_npm_wrapper() {
|
||||
local output
|
||||
output="$(sed -n '1,330p' "${HOT_DEV}")"
|
||||
|
|
@ -215,6 +225,7 @@ test_pulse_process_count_handles_zero_matches_under_pipefail
|
|||
test_pulse_process_count_counts_matching_processes
|
||||
test_hot_dev_uses_resilient_backend_process_count
|
||||
test_hot_dev_keeps_backend_launch_errors_in_debug_log
|
||||
test_hot_dev_preserves_proxmox_guest_docker_env
|
||||
test_hot_dev_avoids_self_killing_npm_wrapper
|
||||
test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
|
||||
test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue