mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Read canonical backup inventory in investigations (#2977)
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Provider pair Docker acceptance (push) Blocked by required conditions
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Compile backend race tests (no execution) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-0) (push) Blocked by required conditions
Build and Test / Backend tests (api-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-2) (push) Blocked by required conditions
Build and Test / Backend tests (api-3) (push) Blocked by required conditions
Build and Test / Backend tests (api-4) (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests (shard 0/2) (push) Blocked by required conditions
Build and Test / Script smoke tests (shard 1/2) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Provider MSP startup / Provider MSP production startup (push) Waiting to run
Public docs / check (push) Blocked by required conditions
Public docs / Documentation recipes (0) (push) Waiting to run
Public docs / Documentation recipes (1) (push) Waiting to run
Public docs / Documentation recipes (2) (push) Waiting to run
Public docs / Documentation recipes (3) (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Build source-bound E2E images once (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Blocked by required conditions
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Provider pair Docker acceptance (push) Blocked by required conditions
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Compile backend race tests (no execution) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-0) (push) Blocked by required conditions
Build and Test / Backend tests (api-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-2) (push) Blocked by required conditions
Build and Test / Backend tests (api-3) (push) Blocked by required conditions
Build and Test / Backend tests (api-4) (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests (shard 0/2) (push) Blocked by required conditions
Build and Test / Script smoke tests (shard 1/2) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Provider MSP startup / Provider MSP production startup (push) Waiting to run
Public docs / check (push) Blocked by required conditions
Public docs / Documentation recipes (0) (push) Waiting to run
Public docs / Documentation recipes (1) (push) Waiting to run
Public docs / Documentation recipes (2) (push) Waiting to run
Public docs / Documentation recipes (3) (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Build source-bound E2E images once (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Blocked by required conditions
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Backup investigations could report an empty inventory for canonical resource IDs even when the recovery API had collected artifacts. The inventory now queries the same org-scoped recovery source and returns canonical artifact records with repository references, verification state and provenance. Pagination follows one global ordering across providers. Read failures remain errors. The obsolete PBS/PVE summary lists and unrelated recent-task cache are removed from this tool read. Regression coverage checks mapped PVE/PBS artifacts, duplicate VM IDs across clusters, tenant isolation, unknown verification, pagination beyond 4,000 records, unavailable reads and HTTP/tool parity in real and demo modes. The original regression fails against the old implementation and passes after repair. Tools tests, focused API tests, frontend type-check/build and private runtime builds passed on the validation worker. Browser verification exercised the saved investigation and expanded failure/artifact tool evidence on /patrol at 1440x900 and 390x844, including keyboard close/reopen and reload. This was a read-only view of synthetic investigation records, not action or release qualification.
This commit is contained in:
commit
1bd5e36afd
10 changed files with 250 additions and 288 deletions
|
|
@ -9376,7 +9376,7 @@
|
|||
"lane_followups": [
|
||||
{
|
||||
"id": "architecture-post-rc-canonicalization",
|
||||
"summary": "Track the remaining post-RC architecture cleanup that still belongs to L6 after canonicalizing Connected infrastructure and the frontend state contract by construction, including retirement of newly legacy or clearly obsolete old-way internal paths unless a boundary-only exception is explicitly governed. The investigation backup inventory overview still filters legacy guest identifiers (including a rune conversion for PVE VM IDs), so canonical resource queries can return empty while recovery points exist. Move that overview onto canonical recovery-point reads; backup-task failure reads and storage history parity do not close this separate inventory gap.",
|
||||
"summary": "Track the remaining post-RC architecture cleanup that still belongs to L6 after canonicalizing Connected infrastructure and the frontend state contract by construction, including retirement of newly legacy or clearly obsolete old-way internal paths unless a boundary-only exception is explicitly governed.",
|
||||
"owner": "project-owner",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-03-13",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ The API and tool adapter share `Manager.ListPointsForOrg`, including the
|
|||
canonical graph source in demo mode, rather than presenting fixture evidence
|
||||
through HTTP alone while tools consult an empty store.
|
||||
`TestInvestigationReadsCanonicalBackupFailureWithoutAgent` uses mapped, persisted
|
||||
task evidence and checks subject and tenant isolation.
|
||||
task evidence and checks subject and tenant isolation. The `backups` inventory
|
||||
also reads this org-scoped source with `KindBackup` and canonical subject IDs.
|
||||
It returns canonical points with repository references, provenance and optional
|
||||
verification fields, rather than VM-ID-filtered PBS/PVE summary lists or an
|
||||
unscoped recent-task cache. Global offset pagination preserves source ordering
|
||||
across providers and does not scan earlier pages. Read failures remain errors;
|
||||
an artifact record alone is not proof of restorability.
|
||||
|
||||
Storage performance reads resolve the canonical registry metrics target and use
|
||||
the monitor-owned history selector. Responses contain usage percentages with
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ reader. Demo composition reads the canonical fixture graph through the recovery
|
|||
query helpers; ordinary tenants read their persisted store. Moving the existing
|
||||
in-memory filters into recovery preserves point, series and facet query semantics
|
||||
and pagination bounds. The API/tool parity regression checks that a collected
|
||||
backup failure remains readable without a PVE client or command agent. No route,
|
||||
entitlement, recovery mutation or action approval is added.
|
||||
backup failure remains readable without a PVE client or command agent. The
|
||||
investigation backup inventory uses the same `KindBackup` and canonical subject
|
||||
query as the points endpoint, retaining point identity, repository references,
|
||||
tri-state verification and evidence provenance in real and demo modes. Tool
|
||||
pagination translates global offsets into the shared source's bounded pages.
|
||||
No route, entitlement, recovery mutation or action approval is added.
|
||||
|
||||
### Patrol setup panel reuses existing AI endpoints
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import (
|
|||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
|
@ -22,34 +22,24 @@ func mustParseJSONMapControl(t *testing.T, text string) map[string]interface{} {
|
|||
}
|
||||
|
||||
func TestPulseToolExecutor_ExecuteListBackups(t *testing.T) {
|
||||
backupProv := &mockBackupProvider{}
|
||||
resourceID := unifiedresources.ProxmoxGuestCanonicalID(unifiedresources.ResourceTypeVM, "west", 101)
|
||||
point := recovery.RecoveryPoint{ID: "backup-artifact", Provider: recovery.ProviderProxmoxPBS, Kind: recovery.KindBackup, SubjectResourceID: resourceID}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
BackupProvider: backupProv,
|
||||
ControlLevel: ControlLevelReadOnly,
|
||||
RecoveryPointsProvider: inventoryPointsProviderFunc(func(ctx context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
|
||||
assert.Equal(t, recovery.KindBackup, opts.Kind)
|
||||
assert.Equal(t, resourceID, opts.SubjectResourceID)
|
||||
return []recovery.RecoveryPoint{point}, 1, nil
|
||||
}),
|
||||
ControlLevel: ControlLevelReadOnly,
|
||||
})
|
||||
|
||||
expectedBackups := models.Backups{
|
||||
PBS: []models.PBSBackup{
|
||||
{VMID: "101", BackupType: "vm", BackupTime: time.Now(), Instance: "pbs1", Datastore: "ds1", Size: 1024 * 1024 * 1024},
|
||||
},
|
||||
PVE: models.PVEBackups{
|
||||
StorageBackups: []models.StorageBackup{
|
||||
{VMID: 102, Time: time.Now(), Size: 2 * 1024 * 1024 * 1024, Storage: "local"},
|
||||
},
|
||||
BackupTasks: []models.BackupTask{
|
||||
{VMID: 101, Node: "node1", Status: "OK", StartTime: time.Now()},
|
||||
},
|
||||
},
|
||||
}
|
||||
backupProv.On("GetBackups").Return(expectedBackups)
|
||||
backupProv.On("GetPBSInstances").Return([]models.PBSInstance{})
|
||||
|
||||
// Use pulse_storage tool with type: "backups"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_storage", map[string]interface{}{
|
||||
"type": "backups",
|
||||
"type": "backups", "resource_id": resourceID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
var response BackupsResponse
|
||||
assert.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &response))
|
||||
assert.Equal(t, []recovery.RecoveryPoint{point}, response.Points)
|
||||
}
|
||||
|
||||
func TestPulseToolExecutor_ExecuteControlGuest(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/agents/filesystem"
|
||||
)
|
||||
|
|
@ -1028,13 +1029,12 @@ func (r PatternsResponse) NormalizeCollections() PatternsResponse {
|
|||
return r
|
||||
}
|
||||
|
||||
// BackupsResponse is returned by pulse_list_backups
|
||||
// BackupsResponse is the canonical artifact inventory returned by pulse_storage backups.
|
||||
// RecoveryPoint preserves provider identity, repository references and unknown
|
||||
// verification instead of collapsing them into vendor-specific summary lists.
|
||||
type BackupsResponse struct {
|
||||
PBS []PBSBackupSummary `json:"pbs"`
|
||||
PVE []PVEBackupSummary `json:"pve"`
|
||||
PBSServers []PBSServerSummary `json:"pbs_servers"`
|
||||
RecentTasks []BackupTaskSummary `json:"recent_tasks"`
|
||||
Pagination *PaginationInfo `json:"pagination,omitempty"`
|
||||
Points []recovery.RecoveryPoint `json:"points"`
|
||||
Pagination *PaginationInfo `json:"pagination"`
|
||||
}
|
||||
|
||||
func EmptyBackupsResponse() BackupsResponse {
|
||||
|
|
@ -1042,74 +1042,12 @@ func EmptyBackupsResponse() BackupsResponse {
|
|||
}
|
||||
|
||||
func (r BackupsResponse) NormalizeCollections() BackupsResponse {
|
||||
if r.PBS == nil {
|
||||
r.PBS = []PBSBackupSummary{}
|
||||
}
|
||||
if r.PVE == nil {
|
||||
r.PVE = []PVEBackupSummary{}
|
||||
}
|
||||
if r.PBSServers == nil {
|
||||
r.PBSServers = []PBSServerSummary{}
|
||||
}
|
||||
for i := range r.PBSServers {
|
||||
r.PBSServers[i] = r.PBSServers[i].NormalizeCollections()
|
||||
}
|
||||
if r.RecentTasks == nil {
|
||||
r.RecentTasks = []BackupTaskSummary{}
|
||||
if r.Points == nil {
|
||||
r.Points = []recovery.RecoveryPoint{}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// PBSBackupSummary is a summarized PBS backup
|
||||
type PBSBackupSummary struct {
|
||||
VMID string `json:"vmid"`
|
||||
BackupType string `json:"backup_type"`
|
||||
BackupTime time.Time `json:"backup_time"`
|
||||
Instance string `json:"instance"`
|
||||
Datastore string `json:"datastore"`
|
||||
SizeGB float64 `json:"size_gb"`
|
||||
Verified bool `json:"verified"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
// PVEBackupSummary is a summarized PVE backup
|
||||
type PVEBackupSummary struct {
|
||||
VMID int `json:"vmid"`
|
||||
BackupTime time.Time `json:"backup_time"`
|
||||
SizeGB float64 `json:"size_gb"`
|
||||
Storage string `json:"storage"`
|
||||
}
|
||||
|
||||
// PBSServerSummary is a summarized PBS server
|
||||
type PBSServerSummary struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Status string `json:"status"`
|
||||
Datastores []DatastoreSummary `json:"datastores"`
|
||||
}
|
||||
|
||||
func (s PBSServerSummary) NormalizeCollections() PBSServerSummary {
|
||||
if s.Datastores == nil {
|
||||
s.Datastores = []DatastoreSummary{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// DatastoreSummary is a summarized datastore
|
||||
type DatastoreSummary struct {
|
||||
Name string `json:"name"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
FreeGB float64 `json:"free_gb"`
|
||||
}
|
||||
|
||||
// BackupTaskSummary is a summarized backup task
|
||||
type BackupTaskSummary struct {
|
||||
VMID int `json:"vmid"`
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
}
|
||||
|
||||
// StorageResponse is returned by pulse_list_storage
|
||||
type StorageResponse struct {
|
||||
Pools []StoragePoolSummary `json:"pools"`
|
||||
|
|
|
|||
|
|
@ -472,21 +472,8 @@ func TestSharedToolSummaryOwnersUseCanonicalEmptyCollections(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(BackupsResponse{
|
||||
PBSServers: []PBSServerSummary{{Name: "pbs-1"}},
|
||||
}.NormalizeCollections())
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload, &decoded))
|
||||
servers := decoded["pbs_servers"].([]any)
|
||||
server := servers[0].(map[string]any)
|
||||
datastores, ok := server["datastores"].([]any)
|
||||
if !ok || len(datastores) != 0 {
|
||||
t.Fatalf("expected pbs_servers[0].datastores to be an empty array, got %T (%v)", server["datastores"], server["datastores"])
|
||||
}
|
||||
|
||||
payload, err = json.Marshal(StorageResponse{
|
||||
payload, err := json.Marshal(StorageResponse{
|
||||
Pools: []StoragePoolSummary{{ID: "pool-1", Name: "local"}},
|
||||
}.NormalizeCollections())
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -17,6 +18,128 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInvestigationBackupInventoryUsesCanonicalArtifacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := recoverymanager.New(config.NewMultiTenantPersistence(t.TempDir()))
|
||||
store, err := manager.StoreForOrg("default")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
at := time.Unix(1800, 0).UTC()
|
||||
resourceID := unifiedresources.ProxmoxGuestCanonicalID(unifiedresources.ResourceTypeVM, "west", 101)
|
||||
otherResourceID := unifiedresources.ProxmoxGuestCanonicalID(unifiedresources.ResourceTypeVM, "east", 101)
|
||||
archives := []models.StorageBackup{
|
||||
{ID: "west-archive", VMID: 101, Instance: "west", Node: "pve1", Type: "qemu", Storage: "local", Time: at, Size: 1024},
|
||||
{ID: "east-archive", VMID: 101, Instance: "east", Node: "pve1", Type: "qemu", Storage: "local", Time: at, Size: 2048},
|
||||
}
|
||||
points := proxmoxmapper.FromPVEStorageBackups(archives, map[string]proxmoxmapper.GuestInfo{
|
||||
"west|pve1|101": {ResourceID: resourceID, ResourceType: unifiedresources.ResourceTypeVM},
|
||||
"east|pve1|101": {ResourceID: otherResourceID, ResourceType: unifiedresources.ResourceTypeVM},
|
||||
})
|
||||
points = append(points, proxmoxmapper.FromPBSBackups([]models.PBSBackup{{
|
||||
ID: "remote-archive", VMID: "101", BackupType: "vm", Instance: "pbs-west", Datastore: "daily", BackupTime: at, Size: 4096,
|
||||
}}, map[string][]proxmoxmapper.GuestCandidate{
|
||||
"vm:101": {{ResourceID: resourceID, ResourceType: unifiedresources.ResourceTypeVM, InstanceName: "west", NodeName: "pve1", VMID: 101, SourceID: "west:pve1:101"}},
|
||||
})...)
|
||||
// Inventory is provider-agnostic and excludes execution/snapshot evidence.
|
||||
points = append(points,
|
||||
recovery.RecoveryPoint{ID: "agent-backup", Provider: recovery.ProviderAgent, Kind: recovery.KindBackup, Mode: recovery.ModeRemote, Outcome: recovery.OutcomeUnknown, SubjectResourceID: resourceID},
|
||||
recovery.RecoveryPoint{ID: "pve-task:failure", Provider: recovery.ProviderProxmoxPVE, Kind: recovery.KindOther, SubjectResourceID: resourceID},
|
||||
recovery.RecoveryPoint{ID: "pve-snapshot:old", Provider: recovery.ProviderProxmoxPVE, Kind: recovery.KindSnapshot, SubjectResourceID: resourceID},
|
||||
)
|
||||
require.NoError(t, store.UpsertPoints(ctx, points))
|
||||
foreign, err := manager.StoreForOrg("foreign")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = foreign.Close() })
|
||||
foreignPoint := points[0]
|
||||
foreignPoint.ID = "foreign-archive"
|
||||
require.NoError(t, foreign.UpsertPoints(ctx, []recovery.RecoveryPoint{foreignPoint}))
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
RecoveryPointsProvider: NewRecoveryPointsToolAdapter(manager, "default"),
|
||||
// A legacy cache must not shadow, supplement or filter canonical records.
|
||||
BackupProvider: &stubBackupProvider{backups: models.Backups{PVE: models.PVEBackups{StorageBackups: archives}}},
|
||||
})
|
||||
result, err := executor.executeListBackups(ctx, map[string]interface{}{"resource_id": resourceID})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError, result.Content)
|
||||
var response struct {
|
||||
Points []recovery.RecoveryPoint `json:"points"`
|
||||
Pagination PaginationInfo `json:"pagination"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &response))
|
||||
require.Len(t, response.Points, 3)
|
||||
require.Equal(t, 3, response.Pagination.Total)
|
||||
byID := map[string]recovery.RecoveryPoint{}
|
||||
for _, point := range response.Points {
|
||||
require.Equal(t, resourceID, point.SubjectResourceID)
|
||||
require.Equal(t, recovery.KindBackup, point.Kind)
|
||||
byID[point.ID] = point
|
||||
}
|
||||
require.Contains(t, byID, "pve-backup:west-archive")
|
||||
require.Nil(t, byID["pve-backup:west-archive"].Verified, "uncollected verification remains unknown")
|
||||
require.Contains(t, byID, "pbs-backup:remote-archive")
|
||||
remote := byID["pbs-backup:remote-archive"]
|
||||
require.NotNil(t, remote.Verified)
|
||||
require.False(t, *remote.Verified)
|
||||
require.NotNil(t, remote.RepositoryRef)
|
||||
require.Equal(t, "daily", remote.RepositoryRef.Name)
|
||||
require.Equal(t, int64(4096), *remote.SizeBytes)
|
||||
require.Equal(t, at, *remote.CompletedAt)
|
||||
require.Contains(t, byID, "agent-backup")
|
||||
}
|
||||
|
||||
type inventoryPointsProviderFunc func(context.Context, recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error)
|
||||
|
||||
func (f inventoryPointsProviderFunc) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
|
||||
return f(ctx, opts)
|
||||
}
|
||||
|
||||
func TestInvestigationBackupInventoryPagination(t *testing.T) {
|
||||
all := make([]recovery.RecoveryPoint, 5010)
|
||||
for i := range all {
|
||||
all[i] = recovery.RecoveryPoint{ID: fmt.Sprintf("archive-%04d", i), Kind: recovery.KindBackup, SubjectResourceID: "vm-canonical"}
|
||||
}
|
||||
for _, tc := range []struct{ offset, limit, wantOffset, wantLimit, wantCount int }{
|
||||
{499, 3, 499, 3, 3}, {5000, 2, 5000, 2, 2}, {5010, 3, 5010, 3, 0},
|
||||
{-1, 0, 0, recovery.DefaultListPageLimit, recovery.DefaultListPageLimit},
|
||||
{0, 900, 0, recovery.MaxListPageLimit, recovery.MaxListPageLimit},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("offset-%d-limit-%d", tc.offset, tc.limit), func(t *testing.T) {
|
||||
var queries []recovery.ListPointsOptions
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{RecoveryPointsProvider: inventoryPointsProviderFunc(func(ctx context.Context, opts recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
|
||||
queries = append(queries, opts)
|
||||
require.Equal(t, recovery.KindBackup, opts.Kind)
|
||||
require.Equal(t, "vm-canonical", opts.SubjectResourceID)
|
||||
return recovery.PaginatePoints(all, opts.Page, opts.Limit), len(all), nil
|
||||
})})
|
||||
result, err := executor.executeListBackups(context.Background(), map[string]interface{}{"resource_id": "vm-canonical", "offset": tc.offset, "limit": tc.limit})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError, result.Content)
|
||||
var response struct {
|
||||
Points []recovery.RecoveryPoint `json:"points"`
|
||||
Pagination PaginationInfo `json:"pagination"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &response))
|
||||
require.Len(t, response.Points, tc.wantCount)
|
||||
require.Equal(t, PaginationInfo{Total: len(all), Offset: tc.wantOffset, Limit: tc.wantLimit}, response.Pagination)
|
||||
if tc.wantCount > 0 {
|
||||
require.Equal(t, all[tc.wantOffset:tc.wantOffset+tc.wantCount], response.Points)
|
||||
}
|
||||
require.LessOrEqual(t, len(queries), 2, "offset pagination must not scan earlier pages")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationBackupInventoryDoesNotHideReadFailures(t *testing.T) {
|
||||
for _, provider := range []RecoveryPointsProvider{nil, inventoryPointsProviderFunc(func(context.Context, recovery.ListPointsOptions) ([]recovery.RecoveryPoint, int, error) {
|
||||
return nil, 0, context.Canceled
|
||||
})} {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{RecoveryPointsProvider: provider, BackupProvider: &stubBackupProvider{}})
|
||||
result, err := executor.executeListBackups(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.IsError, "unavailable inventory must not become an empty successful result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationReadsCanonicalBackupFailureWithoutAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := recoverymanager.New(config.NewMultiTenantPersistence(t.TempDir()))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ func (e *PulseToolExecutor) registerStorageTools() {
|
|||
e.registry.registerBuiltin(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: agentcapabilities.PulseStorageToolName,
|
||||
Description: `Query storage pools, backups, snapshots, Ceph, replication, RAID, and disk health. Use the "type" parameter to select what to query. backup_tasks returns collected backup attempt status, timestamps and failure error text without requiring host command access. Task success alone does not prove a restorable backup exists.`,
|
||||
Description: `Query storage pools, backups, snapshots, Ceph, replication, RAID, and disk health. Use the "type" parameter to select what to query. backups returns collected backup artifact records from the canonical recovery inventory, including repository references and verification evidence when collected. backup_tasks returns collected backup attempt status, timestamps and failure error text without requiring host command access. Neither an artifact record nor task success alone proves a restorable backup exists.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
|
|
@ -34,7 +34,7 @@ func (e *PulseToolExecutor) registerStorageTools() {
|
|||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by resource ID (canonical subject ID for backup_tasks; VM/container ID for backups, snapshots, resource_disks)",
|
||||
Description: "Filter by resource ID (canonical subject ID for backups and backup_tasks; VM/container ID for snapshots, resource_disks)",
|
||||
},
|
||||
"guest_id": {
|
||||
Type: "string",
|
||||
|
|
@ -137,96 +137,52 @@ func (e *PulseToolExecutor) executeStorage(ctx context.Context, args map[string]
|
|||
}
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeListBackups(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
func (e *PulseToolExecutor) executeListBackups(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceID, _ := args["resource_id"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
|
||||
if e.backupProvider == nil {
|
||||
return NewTextResult("Backup information not available."), nil
|
||||
if limit <= 0 {
|
||||
limit = recovery.DefaultListPageLimit
|
||||
}
|
||||
if limit > recovery.MaxListPageLimit {
|
||||
limit = recovery.MaxListPageLimit
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if e.recoveryPointsProvider == nil {
|
||||
return NewErrorResult(fmt.Errorf("backup inventory unavailable: recovery points provider not configured")), nil
|
||||
}
|
||||
|
||||
backups := e.backupProvider.GetBackups()
|
||||
pbsInstances := e.backupProvider.GetPBSInstances()
|
||||
|
||||
response := EmptyBackupsResponse()
|
||||
response.Pagination = &PaginationInfo{Limit: limit, Offset: offset}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// PBS Backups
|
||||
count := 0
|
||||
for _, b := range backups.PBS {
|
||||
if resourceID != "" && b.VMID != resourceID {
|
||||
continue
|
||||
// Translate the tool's global offset into the canonical store's pages.
|
||||
// At most two pages are needed, even for offsets beyond the first 4,000
|
||||
// records. Do not scan or paginate each provider's legacy lists separately.
|
||||
const pageLimit = recovery.MaxListPageLimit
|
||||
page, skip := offset/pageLimit+1, offset%pageLimit
|
||||
for len(response.Points) < limit {
|
||||
points, total, err := e.recoveryPointsProvider.ListPoints(ctx, recovery.ListPointsOptions{
|
||||
Kind: recovery.KindBackup, SubjectResourceID: strings.TrimSpace(resourceID),
|
||||
Page: page, Limit: pageLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
if count < offset {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
if len(response.PBS) >= limit {
|
||||
response.Pagination.Total = total
|
||||
if skip >= len(points) {
|
||||
break
|
||||
}
|
||||
response.PBS = append(response.PBS, PBSBackupSummary{
|
||||
VMID: b.VMID,
|
||||
BackupType: b.BackupType,
|
||||
BackupTime: b.BackupTime,
|
||||
Instance: b.Instance,
|
||||
Datastore: b.Datastore,
|
||||
SizeGB: float64(b.Size) / (1024 * 1024 * 1024),
|
||||
Verified: b.Verified,
|
||||
Protected: b.Protected,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
// PVE Backups
|
||||
count = 0
|
||||
for _, b := range backups.PVE.StorageBackups {
|
||||
if resourceID != "" && string(rune(b.VMID)) != resourceID {
|
||||
continue
|
||||
}
|
||||
if count < offset {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
if len(response.PVE) >= limit {
|
||||
end := skip + min(limit-len(response.Points), len(points)-skip)
|
||||
response.Points = append(response.Points, points[skip:end]...)
|
||||
if end < len(points) || len(points) < pageLimit {
|
||||
break
|
||||
}
|
||||
response.PVE = append(response.PVE, PVEBackupSummary{
|
||||
VMID: b.VMID,
|
||||
BackupTime: b.Time,
|
||||
SizeGB: float64(b.Size) / (1024 * 1024 * 1024),
|
||||
Storage: b.Storage,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
// PBS Servers
|
||||
for _, pbs := range pbsInstances {
|
||||
server := PBSServerSummary{
|
||||
Name: pbs.Name,
|
||||
Host: pbs.Host,
|
||||
Status: pbs.Status,
|
||||
}
|
||||
for _, ds := range pbs.Datastores {
|
||||
server.Datastores = append(server.Datastores, DatastoreSummary{
|
||||
Name: ds.Name,
|
||||
UsagePercent: ds.Usage,
|
||||
FreeGB: float64(ds.Free) / (1024 * 1024 * 1024),
|
||||
})
|
||||
}
|
||||
response.PBSServers = append(response.PBSServers, server)
|
||||
}
|
||||
|
||||
// Recent tasks
|
||||
for _, t := range backups.PVE.BackupTasks {
|
||||
if len(response.RecentTasks) >= 20 {
|
||||
break
|
||||
}
|
||||
response.RecentTasks = append(response.RecentTasks, BackupTaskSummary{
|
||||
VMID: t.VMID,
|
||||
Node: t.Node,
|
||||
Status: t.Status,
|
||||
StartTime: t.StartTime,
|
||||
})
|
||||
page++
|
||||
skip = 0
|
||||
}
|
||||
|
||||
return NewJSONResult(response.NormalizeCollections()), nil
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
|
|
@ -71,75 +70,8 @@ func (s *stubUpdatesProvider) IsUpdateActionsEnabled() bool {
|
|||
return s.enabled
|
||||
}
|
||||
|
||||
func TestExecuteListBackupsAndStorage(t *testing.T) {
|
||||
func TestExecuteListStorage(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
||||
executor.backupProvider = &stubBackupProvider{
|
||||
backups: models.Backups{
|
||||
PBS: []models.PBSBackup{
|
||||
{
|
||||
VMID: "100",
|
||||
BackupType: "vm",
|
||||
BackupTime: time.Unix(1000, 0),
|
||||
Instance: "pbs1",
|
||||
Datastore: "ds1",
|
||||
Size: 1024 * 1024 * 1024,
|
||||
Verified: true,
|
||||
Protected: true,
|
||||
},
|
||||
},
|
||||
PVE: models.PVEBackups{
|
||||
StorageBackups: []models.StorageBackup{
|
||||
{
|
||||
VMID: 101,
|
||||
Time: time.Unix(1100, 0),
|
||||
Size: 2 * 1024 * 1024 * 1024,
|
||||
Storage: "local",
|
||||
},
|
||||
},
|
||||
BackupTasks: []models.BackupTask{
|
||||
{
|
||||
VMID: 101,
|
||||
Node: "node1",
|
||||
Status: "OK",
|
||||
StartTime: time.Unix(1200, 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pbs: []models.PBSInstance{
|
||||
{
|
||||
Name: "pbs1",
|
||||
Host: "10.0.0.1",
|
||||
Status: "online",
|
||||
Datastores: []models.PBSDatastore{
|
||||
{
|
||||
Name: "ds1",
|
||||
Usage: 50.0,
|
||||
Free: 1024 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := executor.executeListBackups(context.Background(), map[string]interface{}{})
|
||||
var backupsResp BackupsResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &backupsResp); err != nil {
|
||||
t.Fatalf("decode backups response: %v", err)
|
||||
}
|
||||
if len(backupsResp.PBS) != 1 || backupsResp.PBS[0].SizeGB != 1 {
|
||||
t.Fatalf("unexpected PBS backups: %+v", backupsResp.PBS)
|
||||
}
|
||||
if len(backupsResp.PVE) != 1 || backupsResp.PVE[0].SizeGB != 2 {
|
||||
t.Fatalf("unexpected PVE backups: %+v", backupsResp.PVE)
|
||||
}
|
||||
if len(backupsResp.PBSServers) != 1 || len(backupsResp.PBSServers[0].Datastores) != 1 {
|
||||
t.Fatalf("unexpected PBS servers: %+v", backupsResp.PBSServers)
|
||||
}
|
||||
if len(backupsResp.RecentTasks) != 1 {
|
||||
t.Fatalf("unexpected recent tasks: %+v", backupsResp.RecentTasks)
|
||||
}
|
||||
|
||||
// Storage pools and Ceph data come from the unified resource provider.
|
||||
storageUsedBytes := int64(1024 * 1024 * 1024)
|
||||
storageTotalBytes := int64(4 * 1024 * 1024 * 1024)
|
||||
|
|
@ -192,7 +124,7 @@ func TestExecuteListBackupsAndStorage(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
result, _ = executor.executeListStorage(context.Background(), map[string]interface{}{})
|
||||
result, _ := executor.executeListStorage(context.Background(), map[string]interface{}{})
|
||||
var storageResp StorageResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &storageResp); err != nil {
|
||||
t.Fatalf("decode storage response: %v", err)
|
||||
|
|
@ -214,32 +146,6 @@ func TestStorageUsagePercentNotDoubled(t *testing.T) {
|
|||
// The bug was multiplying by 100 again, turning 25% into 2500%.
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
||||
|
||||
// PBS datastore path
|
||||
executor.backupProvider = &stubBackupProvider{
|
||||
pbs: []models.PBSInstance{
|
||||
{
|
||||
Name: "pbs1",
|
||||
Host: "10.0.0.1",
|
||||
Status: "online",
|
||||
Datastores: []models.PBSDatastore{
|
||||
{Name: "ds1", Usage: 12.3, Free: 1024 * 1024 * 1024},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, _ := executor.executeListBackups(context.Background(), map[string]interface{}{})
|
||||
var backupsResp BackupsResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &backupsResp); err != nil {
|
||||
t.Fatalf("decode backups response: %v", err)
|
||||
}
|
||||
if len(backupsResp.PBSServers) != 1 || len(backupsResp.PBSServers[0].Datastores) != 1 {
|
||||
t.Fatalf("unexpected PBS servers: %+v", backupsResp.PBSServers)
|
||||
}
|
||||
got := backupsResp.PBSServers[0].Datastores[0].UsagePercent
|
||||
if got != 12.3 {
|
||||
t.Errorf("PBS datastore UsagePercent = %v, want 12.3 (double-multiplied = %v)", got, 12.3*100)
|
||||
}
|
||||
|
||||
// Storage pool path
|
||||
storageUsedBytes := int64(1024 * 1024 * 1024)
|
||||
storageTotalBytes := int64(4 * 1024 * 1024 * 1024)
|
||||
|
|
@ -346,7 +252,7 @@ func TestStorageResponsesUseCanonicalEmptyCollections(t *testing.T) {
|
|||
if err := json.Unmarshal(backupsPayload, &backupsMap); err != nil {
|
||||
t.Fatalf("decode empty backups response: %v", err)
|
||||
}
|
||||
for _, key := range []string{"pbs", "pve", "pbs_servers", "recent_tasks"} {
|
||||
for _, key := range []string{"points"} {
|
||||
values, ok := backupsMap[key].([]any)
|
||||
if !ok || len(values) != 0 {
|
||||
t.Fatalf("expected %s to serialize as an empty array, got %T (%v)", key, backupsMap[key], backupsMap[key])
|
||||
|
|
|
|||
|
|
@ -91,6 +91,58 @@ func TestInvestigationBackupTasksUseCommunityRecoverySource(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInvestigationBackupInventoryMatchesCommunityPoints(t *testing.T) {
|
||||
testutil.SetMockMode(t, true)
|
||||
manager := newRecoveryReadManager(config.NewMultiTenantPersistence(t.TempDir()))
|
||||
var selected recovery.RecoveryPoint
|
||||
for _, point := range mock.CurrentFixtureGraph().RecoveryPoints() {
|
||||
if point.Kind == recovery.KindBackup && point.SubjectResourceID != "" {
|
||||
selected = point
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, selected.ID)
|
||||
store, err := manager.StoreForOrg("default")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
require.NoError(t, store.UpsertPoints(context.Background(), []recovery.RecoveryPoint{selected}))
|
||||
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{RecoveryPointsProvider: tools.NewRecoveryPointsToolAdapter(manager, "default")})
|
||||
for _, demo := range []bool{true, false, true} {
|
||||
require.NoError(t, mock.SetEnabled(demo))
|
||||
for _, resourceID := range []string{selected.SubjectResourceID, "vm-missing"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
NewRecoveryHandlers(manager).HandleListPoints(recorder, httptest.NewRequest(http.MethodGet,
|
||||
"/api/recovery/points?kind=backup&limit=500&itemResourceId="+url.QueryEscape(resourceID), nil))
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
var apiResponse struct {
|
||||
Data []recovery.RecoveryPoint `json:"data"`
|
||||
Meta struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &apiResponse))
|
||||
result, err := executor.ExecuteTool(context.Background(), "pulse_storage", map[string]interface{}{
|
||||
"type": "backups", "resource_id": resourceID, "limit": 500,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError, result.Content)
|
||||
var toolResponse struct {
|
||||
Points []recovery.RecoveryPoint `json:"points"`
|
||||
Pagination tools.PaginationInfo `json:"pagination"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &toolResponse))
|
||||
require.Equal(t, apiResponse.Meta.Total, toolResponse.Pagination.Total)
|
||||
require.Equal(t, apiResponse.Data, toolResponse.Points, "inventory identity, repository, verification and provenance must match HTTP")
|
||||
if resourceID == selected.SubjectResourceID {
|
||||
require.NotEmpty(t, toolResponse.Points)
|
||||
} else {
|
||||
require.NotNil(t, toolResponse.Points)
|
||||
require.Empty(t, toolResponse.Points)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type investigationHistoryResources struct{ resource unifiedresources.Resource }
|
||||
|
||||
func (p investigationHistoryResources) GetByType(kind unifiedresources.ResourceType) []unifiedresources.Resource {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue