mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Merge remote-tracking branch 'origin/main'
Change-source: pulse-maintainer
This commit is contained in:
@@ -26,6 +26,12 @@ Generate an installation command in the UI:
|
||||
|
||||
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
|
||||
|
||||
The generated command is not tied to a single machine. For a Proxmox VE
|
||||
cluster, one API connection already provides cluster-wide inventory; the agent
|
||||
is per host, so run the same generated command on each cluster node where you
|
||||
want agent-provided telemetry (temperatures, SMART, host identity). Each agent
|
||||
registers itself and attaches to its own cluster member.
|
||||
|
||||
The same generated command is also the supported v5-to-v6 agent upgrade path.
|
||||
Run it on the host that already has the v5 `pulse-agent` service to replace the
|
||||
binary and service configuration in place; do not uninstall the old service
|
||||
|
||||
@@ -186,7 +186,10 @@ func TestProxmoxGuestActionExecutorUsesIndependentControlPlaneVerification(t *te
|
||||
}}
|
||||
observer := &fakeProxmoxGuestPostconditionObserver{observations: []proxmoxGuestPostconditionObservation{
|
||||
proxmoxGuestActionObservation(now.Add(-time.Second), "running", 3600, "proxmox-control-plane:default:homelab"),
|
||||
proxmoxGuestActionObservation(now.Add(time.Second), "stopped", 0, "proxmox-control-plane:default:homelab"),
|
||||
// The after observation must postdate actionStartedAt, which is stamped
|
||||
// inside ExecuteAction after handler setup; a one-second offset loses
|
||||
// that race on a loaded runner, so use a generous margin.
|
||||
proxmoxGuestActionObservation(now.Add(time.Minute), "stopped", 0, "proxmox-control-plane:default:homelab"),
|
||||
}}
|
||||
executor := newProxmoxGuestActionExecutor(h, agents, observer)
|
||||
|
||||
@@ -215,7 +218,7 @@ func TestProxmoxGuestActionExecutorRequiresUptimeResetToVerifyReboot(t *testing.
|
||||
agents := &fakeDockerActionAgentCommander{results: []*agentexec.CommandResultPayload{{RequestID: "act_vm", Success: true, ExitCode: 0, Stdout: "reboot requested"}}}
|
||||
observer := &fakeProxmoxGuestPostconditionObserver{observations: []proxmoxGuestPostconditionObservation{
|
||||
proxmoxGuestActionObservation(now.Add(-time.Second), "running", 7200, "proxmox-control-plane:default:homelab"),
|
||||
proxmoxGuestActionObservation(now.Add(time.Second), "running", 4, "proxmox-control-plane:default:homelab"),
|
||||
proxmoxGuestActionObservation(now.Add(time.Minute), "running", 4, "proxmox-control-plane:default:homelab"),
|
||||
}}
|
||||
executor := newProxmoxGuestActionExecutor(h, agents, observer)
|
||||
|
||||
@@ -239,7 +242,7 @@ func TestProxmoxGuestActionExecutorKeepsIndependentContradictionSeparateFromExec
|
||||
agents := &fakeDockerActionAgentCommander{results: []*agentexec.CommandResultPayload{{RequestID: "act_vm", Success: true, ExitCode: 0, Stdout: "reboot requested"}}}
|
||||
observer := &fakeProxmoxGuestPostconditionObserver{observations: []proxmoxGuestPostconditionObservation{
|
||||
proxmoxGuestActionObservation(now.Add(-time.Second), "running", 7200, "proxmox-control-plane:default:homelab"),
|
||||
proxmoxGuestActionObservation(now.Add(time.Second), "running", 7201, "proxmox-control-plane:default:homelab"),
|
||||
proxmoxGuestActionObservation(now.Add(time.Minute), "running", 7201, "proxmox-control-plane:default:homelab"),
|
||||
}}
|
||||
executor := newProxmoxGuestActionExecutor(h, agents, observer)
|
||||
ctx, cancel := context.WithTimeout(actionDispatchTestContext(t, "act_vm"), 100*time.Millisecond)
|
||||
|
||||
@@ -201,6 +201,11 @@ func TestValidateTrustedExecutableRequiresControlledExecutableFile(t *testing.T)
|
||||
if err := os.WriteFile(writable, []byte("binary"), 0o720); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// WriteFile filters mode through the process umask, which strips the
|
||||
// group-write bit under the common 022 umask; force the mode we assert on.
|
||||
if err := os.Chmod(writable, 0o720); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateTrustedExecutable(writable, uint32(os.Geteuid())); err == nil {
|
||||
t.Fatal("group-writable executable was trusted")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func coalescePresentationHostResourcesOnce(
|
||||
coalesced := make([]Resource, 0, len(resources))
|
||||
indexesByHostKey := make(map[string][]int, len(resources))
|
||||
parentRedirects := make(map[string]string)
|
||||
guardedHostKeys := presentationAmbiguousProxmoxHostKeys(resources)
|
||||
for _, resource := range resources {
|
||||
resource.Type = CanonicalResourceType(resource.Type)
|
||||
hostKey := presentationHostMergeKey(resource)
|
||||
@@ -64,6 +65,9 @@ func coalescePresentationHostResourcesOnce(
|
||||
if presentationHostIdentitiesDistinct(existing, resource) {
|
||||
continue
|
||||
}
|
||||
if guardedHostKeys[hostKey] && !presentationGuardedMergeAllowed(existing, resource) {
|
||||
continue
|
||||
}
|
||||
if !presentationHostnamesCompatible(existing, resource) {
|
||||
continue
|
||||
}
|
||||
@@ -215,9 +219,98 @@ func presentationHostIdentitiesDistinct(left, right Resource) bool {
|
||||
presentationIdentityValuesConflict(left.Proxmox.ClusterName, right.Proxmox.ClusterName) {
|
||||
return true
|
||||
}
|
||||
if presentationProxmoxNodeScopesDistinct(left.Proxmox, right.Proxmox) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// presentationProxmoxNodeScopesDistinct reports whether two Proxmox node
|
||||
// facets describe different provider connections without any same-machine
|
||||
// proof. Standalone connections are provider scopes too, and both machines in
|
||||
// two hand-added sites are commonly just "pve" (#1753), so a shared short
|
||||
// hostname must not fold one site's node row into the other's. The proof
|
||||
// mirrors the state-layer rule: the same connection instance, the same node
|
||||
// identity, the same non-empty cluster, or the same endpoint host still
|
||||
// merge; anything less keeps the rows apart.
|
||||
func presentationProxmoxNodeScopesDistinct(left, right *ProxmoxData) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.NodeName) == "" || strings.TrimSpace(right.NodeName) == "" {
|
||||
return false
|
||||
}
|
||||
if !presentationIdentityValuesConflict(left.Instance, right.Instance) {
|
||||
return false
|
||||
}
|
||||
if presentationIdentityValuesEqual(left.NodeIdentity, right.NodeIdentity) {
|
||||
return false
|
||||
}
|
||||
if presentationIdentityValuesEqual(left.ClusterName, right.ClusterName) {
|
||||
return false
|
||||
}
|
||||
if presentationIdentityValuesEqual(extractHostname(left.HostURL), extractHostname(right.HostURL)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func presentationIdentityValuesEqual(left, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
return left != "" && right != "" && strings.EqualFold(left, right)
|
||||
}
|
||||
|
||||
// presentationAmbiguousProxmoxHostKeys marks merge buckets that contain node
|
||||
// facets from two or more distinct Proxmox provider scopes. Inside such a
|
||||
// bucket a bare shared hostname no longer identifies a machine, so
|
||||
// hostname-only merges of an agent row into a node row must fail closed and
|
||||
// only the state-layer agent link may attach one (#1753: the Agent badge and
|
||||
// per-node stats jumped between two sites named "pve" on every refresh).
|
||||
func presentationAmbiguousProxmoxHostKeys(resources []Resource) map[string]bool {
|
||||
facetsByKey := make(map[string][]*ProxmoxData)
|
||||
guarded := make(map[string]bool)
|
||||
for i := range resources {
|
||||
facet := resources[i].Proxmox
|
||||
if facet == nil || strings.TrimSpace(facet.NodeName) == "" {
|
||||
continue
|
||||
}
|
||||
hostKey := presentationHostMergeKey(resources[i])
|
||||
if hostKey == "" || guarded[hostKey] {
|
||||
continue
|
||||
}
|
||||
for _, existing := range facetsByKey[hostKey] {
|
||||
if presentationProxmoxNodeScopesDistinct(existing, facet) {
|
||||
guarded[hostKey] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
facetsByKey[hostKey] = append(facetsByKey[hostKey], facet)
|
||||
}
|
||||
return guarded
|
||||
}
|
||||
|
||||
// presentationGuardedMergeAllowed gates merges inside an ambiguous bucket.
|
||||
// A pair of node facets is already governed by the scope veto, and agent-only
|
||||
// pairs never satisfy the runtime-platform source requirement, so the case
|
||||
// that matters is an agent row meeting a node row: it may only attach to the
|
||||
// node whose state-layer agent link names it.
|
||||
func presentationGuardedMergeAllowed(left, right Resource) bool {
|
||||
leftFacet := left.Proxmox != nil && strings.TrimSpace(left.Proxmox.NodeName) != ""
|
||||
rightFacet := right.Proxmox != nil && strings.TrimSpace(right.Proxmox.NodeName) != ""
|
||||
if leftFacet == rightFacet {
|
||||
return true
|
||||
}
|
||||
node, agent := left, right
|
||||
if rightFacet {
|
||||
node, agent = right, left
|
||||
}
|
||||
if agent.Agent == nil {
|
||||
return false
|
||||
}
|
||||
return presentationIdentityValuesEqual(node.Proxmox.LinkedAgentID, agent.Agent.AgentID)
|
||||
}
|
||||
|
||||
func presentationIdentityValuesConflict(left, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
@@ -338,6 +431,13 @@ func mergePresentationHostResources(left, right Resource) Resource {
|
||||
}
|
||||
if merged.Proxmox == nil {
|
||||
merged.Proxmox = secondary.Proxmox
|
||||
// The Proxmox node row's name is display-name aware (the configured
|
||||
// Node Name), while an agent-backed primary is named after the bare
|
||||
// reported hostname. Keep the configured name on the merged row
|
||||
// (#1753: "Node Name field not observed").
|
||||
if merged.Proxmox != nil && strings.TrimSpace(secondary.Name) != "" {
|
||||
merged.Name = secondary.Name
|
||||
}
|
||||
}
|
||||
if merged.Docker == nil {
|
||||
merged.Docker = secondary.Docker
|
||||
|
||||
@@ -536,3 +536,100 @@ func TestCoalescePresentationHostResourcesKeepsSameShortNameEstatesApart(t *test
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Two hand-added standalone Proxmox connections whose native node name is the
|
||||
// same short hostname (both machines are called "pve") must keep separate
|
||||
// host rows once their agents connect. The provider node rows carry no
|
||||
// machine identity of their own, so before the provider-scope veto existed
|
||||
// the first node row absorbed an agent row and then the other site's node row
|
||||
// folded in on bare short-hostname compatibility, collapsing the estate into
|
||||
// one flip-flopping row (#1753, v6.4.1 retest).
|
||||
func TestCoalescePresentationHostResourcesKeepsStandaloneProviderScopesApart(t *testing.T) {
|
||||
nodeRow := func(id, instance, host, displayName, linkedAgentID string) Resource {
|
||||
return Resource{
|
||||
ID: id,
|
||||
Type: ResourceTypeAgent,
|
||||
Name: displayName,
|
||||
Status: StatusOnline,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
Identity: ResourceIdentity{Hostnames: []string{"pve"}},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: id,
|
||||
NodeIdentity: id,
|
||||
NodeName: "pve",
|
||||
Instance: instance,
|
||||
HostURL: host,
|
||||
LinkedAgentID: linkedAgentID,
|
||||
},
|
||||
}
|
||||
}
|
||||
agentRow := func(id, machineID string) Resource {
|
||||
return Resource{
|
||||
ID: "agent-" + id,
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "pve",
|
||||
Status: StatusOnline,
|
||||
Sources: []DataSource{SourceAgent},
|
||||
Identity: ResourceIdentity{Hostnames: []string{"pve"}, MachineID: machineID},
|
||||
Agent: &AgentData{AgentID: id, Hostname: "pve", MachineID: machineID, OSName: "Proxmox VE"},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("linked agents keep both sites separate and attached", func(t *testing.T) {
|
||||
resources := []Resource{
|
||||
nodeRow("staging-pve", "hema-staging", "https://pve.hemastaging.hot:8006", "hema-staging", "host-staging"),
|
||||
agentRow("host-staging", "machine-staging"),
|
||||
nodeRow("production-pve", "hema-production", "https://pve.hemaproduction.hot:8006", "hema-production", "host-production"),
|
||||
agentRow("host-production", "machine-production"),
|
||||
}
|
||||
got := CoalescePresentationHostResources(resources)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected two merged site rows, got %d rows: %#v", len(got), got)
|
||||
}
|
||||
for _, resource := range got {
|
||||
if resource.Proxmox == nil || resource.Agent == nil {
|
||||
t.Fatalf("expected each site row to keep its node and agent facets, got %#v", resource)
|
||||
}
|
||||
want := "host-staging"
|
||||
if resource.Proxmox.Instance == "hema-production" {
|
||||
want = "host-production"
|
||||
}
|
||||
if resource.Agent.AgentID != want {
|
||||
t.Fatalf("agent %q attached to instance %q", resource.Agent.AgentID, resource.Proxmox.Instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unlinked agents fail closed instead of collapsing the sites", func(t *testing.T) {
|
||||
resources := []Resource{
|
||||
nodeRow("staging-pve", "hema-staging", "https://192.168.1.10:8006", "hema-staging", ""),
|
||||
agentRow("host-staging", "machine-staging"),
|
||||
nodeRow("production-pve", "hema-production", "https://192.168.2.10:8006", "hema-production", ""),
|
||||
agentRow("host-production", "machine-production"),
|
||||
}
|
||||
got := CoalescePresentationHostResources(resources)
|
||||
nodeRows := 0
|
||||
for _, resource := range got {
|
||||
if resource.Proxmox != nil {
|
||||
nodeRows++
|
||||
}
|
||||
}
|
||||
if nodeRows != 2 {
|
||||
t.Fatalf("expected both provider node rows to survive, got %d in %d rows: %#v", nodeRows, len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single site still merges its node and agent rows", func(t *testing.T) {
|
||||
resources := []Resource{
|
||||
nodeRow("home-pve", "home", "https://pve.home.lan:8006", "home", ""),
|
||||
agentRow("host-home", "machine-home"),
|
||||
}
|
||||
got := CoalescePresentationHostResources(resources)
|
||||
if len(got) != 1 || got[0].Proxmox == nil || got[0].Agent == nil {
|
||||
t.Fatalf("expected single-site node and agent to merge, got %#v", got)
|
||||
}
|
||||
if got[0].Name != "home" {
|
||||
t.Fatalf("expected merged row to keep the configured node name, got %q", got[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user