Public source validation / validate (push) Failing after 3m8s
574 lines
26 KiB
Go
574 lines
26 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const (
|
|
formatVersion = 2
|
|
defaultRetention = 5
|
|
manifestName = "manifest.json"
|
|
)
|
|
|
|
var ErrNotConfigured = errors.New("backup destination is not configured")
|
|
|
|
type Manager struct {
|
|
Pool *pgxpool.Pool
|
|
Directory string
|
|
Retention int
|
|
Now func() time.Time
|
|
}
|
|
|
|
type Manifest struct {
|
|
FormatVersion int `json:"formatVersion"`
|
|
BackupID string `json:"backupId"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
SchemaVersion int64 `json:"schemaVersion"`
|
|
Tables []TableManifest `json:"tables"`
|
|
Excluded []string `json:"excluded"`
|
|
}
|
|
|
|
type TableManifest struct {
|
|
Name string `json:"name"`
|
|
File string `json:"file"`
|
|
Rows int64 `json:"rows"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
type Result struct {
|
|
BackupID string `json:"backupId"`
|
|
Path string `json:"path"`
|
|
SHA256 string `json:"sha256"`
|
|
Bytes int64 `json:"bytes"`
|
|
Rows int64 `json:"rows"`
|
|
Created time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type tableSpec struct {
|
|
name string
|
|
columns string
|
|
restoreCols string
|
|
orderBy string
|
|
}
|
|
|
|
var tableSpecs = []tableSpec{
|
|
{name: "roles", columns: "id,name,created_at", restoreCols: "id,name,created_at", orderBy: "id"},
|
|
{name: "users", columns: "id,external_subject,display_name,email,status,created_at,updated_at,last_login_at", restoreCols: "id,external_subject,display_name,email,status,created_at,updated_at,last_login_at", orderBy: "id"},
|
|
{name: "user_roles", columns: "user_id,role_id,created_at", restoreCols: "user_id,role_id,created_at", orderBy: "user_id,role_id"},
|
|
{name: "data_sources", columns: "id,type,name,enabled,configuration_ref,capability_document,health_state,last_success_at,last_error_code,last_error_message,freshness_policy,created_at,updated_at", restoreCols: "id,type,name,enabled,configuration_ref,capability_document,health_state,last_success_at,last_error_code,last_error_message,freshness_policy,created_at,updated_at", orderBy: "id"},
|
|
{name: "collectors", columns: "id,datasource_id,kind,version,heartbeat,capabilities,status", restoreCols: "id,datasource_id,kind,version,heartbeat,capabilities,status", orderBy: "id"},
|
|
{name: "entities", columns: "id,entity_type,canonical_name,display_name,status,status_reasons,first_seen_at,last_seen_at,tombstoned_at,attributes", restoreCols: "id,entity_type,canonical_name,display_name,status,status_reasons,first_seen_at,last_seen_at,tombstoned_at,attributes", orderBy: "id"},
|
|
{name: "entity_aliases", columns: "entity_id,source_id,external_type,external_id", restoreCols: "entity_id,source_id,external_type,external_id", orderBy: "source_id,external_type,external_id"},
|
|
{name: "container_aliases", columns: "source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at", restoreCols: "source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at", orderBy: "source_id,runtime_id"},
|
|
{name: "entity_facts", columns: "entity_id,field_name,source_id,value,observed_at,confidence,valid_until", restoreCols: "entity_id,field_name,source_id,value,observed_at,confidence,valid_until", orderBy: "entity_id,field_name,source_id"},
|
|
{name: "entity_overrides", columns: "entity_id,field_name,value,user_id,updated_at", restoreCols: "entity_id,field_name,value,user_id,updated_at", orderBy: "entity_id,field_name"},
|
|
{name: "entity_relations", columns: "id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at", restoreCols: "id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at", orderBy: "id"},
|
|
{name: "dashboards", columns: "id,slug,name,description,owner_user_id,scope,archived_at,current_version_id,revision,created_at,updated_at", restoreCols: "id,slug,name,description,owner_user_id,scope,archived_at,revision,created_at,updated_at", orderBy: "id"},
|
|
{name: "dashboard_versions", columns: "id,dashboard_id,version_number,schema_version,document,change_summary,created_by,created_at", restoreCols: "id,dashboard_id,version_number,schema_version,document,change_summary,created_by,created_at", orderBy: "dashboard_id,version_number"},
|
|
{name: "events", columns: "id,event_type,severity,entity_id,source_id,occurred_at,received_at,dedup_key,summary,attributes,correlation_id", restoreCols: "id,event_type,severity,entity_id,source_id,occurred_at,received_at,dedup_key,summary,attributes,correlation_id", orderBy: "id"},
|
|
{name: "audit_events", columns: "id,actor,action,resource_type,resource_id,result,occurred_at,correlation_id,before_diff,after_diff", restoreCols: "id,actor,action,resource_type,resource_id,result,occurred_at,correlation_id,before_diff,after_diff", orderBy: "occurred_at,id"},
|
|
{name: "job_runs", columns: "id,job_type,job_key,scheduled_at,started_at,completed_at,status,counts,error_code,correlation_id,lease_owner,lease_until", restoreCols: "id,job_type,job_key,scheduled_at,started_at,completed_at,status,counts,error_code,correlation_id,lease_owner,lease_until", orderBy: "scheduled_at,id"},
|
|
{name: "services", columns: "id,entity_id,source_id,name,description,state,labels,revision,archived_at,created_by,created_at,updated_at", restoreCols: "id,entity_id,source_id,name,description,state,labels,revision,archived_at,created_by,created_at,updated_at", orderBy: "id"},
|
|
{name: "service_endpoints", columns: "id,service_id,source_id,name,endpoint_type,target,enabled,revision,archived_at,created_at,updated_at", restoreCols: "id,service_id,source_id,name,endpoint_type,target,enabled,revision,archived_at,created_at,updated_at", orderBy: "id"},
|
|
{name: "probes", columns: "id,service_id,endpoint_id,source_id,name,probe_type,target,interval_seconds,timeout_seconds,enabled,expected_status_codes,follow_redirects,verify_tls,content_assertion,network_policy_id,revision,archived_at,created_by,created_at,updated_at", restoreCols: "id,service_id,endpoint_id,source_id,name,probe_type,target,interval_seconds,timeout_seconds,enabled,expected_status_codes,follow_redirects,verify_tls,content_assertion,network_policy_id,revision,archived_at,created_by,created_at,updated_at", orderBy: "id"},
|
|
{name: "probe_results", columns: "id,probe_id,source_id,observed_at,completed_at,state,response_time_ms,status_code,error_class,error_message,attributes", restoreCols: "id,probe_id,source_id,observed_at,completed_at,state,response_time_ms,status_code,error_class,error_message,attributes", orderBy: "probe_id,observed_at"},
|
|
{name: "service_certificates", columns: "id,service_id,endpoint_id,source_id,observed_at,expires_at,issuer,subject,hostname_valid,verification_state,attributes", restoreCols: "id,service_id,endpoint_id,source_id,observed_at,expires_at,issuer,subject,hostname_valid,verification_state,attributes", orderBy: "id"},
|
|
{name: "service_dependencies", columns: "id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at", restoreCols: "id,service_id,depends_on_service_id,source_id,relation_type,confidence,confirmed,first_seen_at,last_seen_at,archived_at", orderBy: "id"},
|
|
{name: "service_permissions", columns: "service_id,role_id,permission,created_at", restoreCols: "service_id,role_id,permission,created_at", orderBy: "service_id,role_id,permission"},
|
|
{name: "alert_rules", columns: "id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,unknown_behavior,group_by,suppress_when,message,current_version_id,revision,created_by,created_at,updated_at,cooldown_seconds", restoreCols: "id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,unknown_behavior,group_by,suppress_when,message,revision,created_by,created_at,updated_at,cooldown_seconds", orderBy: "id"},
|
|
{name: "alert_rule_versions", columns: "id,rule_id,version_number,document,change_summary,created_by,created_at", restoreCols: "id,rule_id,version_number,document,change_summary,created_by,created_at", orderBy: "rule_id,version_number"},
|
|
{name: "alert_instances", columns: "id,rule_id,rule_version_id,fingerprint,entity_id,current_state,retained_state,active_since,recovery_since,last_evaluated_at,last_known_at,last_value,reason,source_health,acknowledged_by,acknowledged_at,revision,created_at,updated_at,cooldown_until", restoreCols: "id,rule_id,rule_version_id,fingerprint,entity_id,current_state,retained_state,active_since,recovery_since,last_evaluated_at,last_known_at,last_value,reason,source_health,acknowledged_by,acknowledged_at,revision,created_at,updated_at,cooldown_until", orderBy: "id"},
|
|
{name: "alert_occurrences", columns: "id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at", restoreCols: "id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at", orderBy: "instance_id,observed_at,id"},
|
|
{name: "alert_silences", columns: "id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", restoreCols: "id,name,reason,owner,matchers,starts_at,expires_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", orderBy: "id"},
|
|
{name: "maintenance_windows", columns: "id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", restoreCols: "id,name,reason,selector,starts_at,ends_at,status,created_by,created_at,revoked_by,revoked_at,expired_at,revision", orderBy: "id"},
|
|
{name: "incidents", columns: "id,correlation_key,title,summary,severity,status,started_at,resolved_at,owner_user_id,correlation_method,confidence,revision,created_at,updated_at", restoreCols: "id,correlation_key,title,summary,severity,status,started_at,resolved_at,owner_user_id,correlation_method,confidence,revision,created_at,updated_at", orderBy: "id"},
|
|
{name: "incident_alerts", columns: "incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by,created_at", restoreCols: "incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by,created_at", orderBy: "incident_id,alert_id"},
|
|
{name: "incident_entities", columns: "incident_id,entity_id,rationale,confidence,created_at", restoreCols: "incident_id,entity_id,rationale,confidence,created_at", orderBy: "incident_id,entity_id"},
|
|
{name: "incident_notes", columns: "id,incident_id,author,body,created_at", restoreCols: "id,incident_id,author,body,created_at", orderBy: "incident_id,created_at,id"},
|
|
}
|
|
|
|
// backupExcludedTables classifies application tables that deliberately do not
|
|
// belong in a portable archive. The PostgreSQL integration test requires every
|
|
// migrated application table to appear either here or in tableSpecs, so adding a
|
|
// migration cannot silently make restore incomplete.
|
|
var backupExcludedTables = map[string]string{
|
|
"agent_snapshots": "bounded runtime telemetry is republished by the agent after restart",
|
|
"capacity_samples": "bounded forecast telemetry is republished by the agent after restart",
|
|
"notification_channels": "secret references and channel configuration must be reattached",
|
|
"notification_deliveries": "runtime notification delivery history is intentionally excluded",
|
|
"notification_outbox": "runtime notification delivery state is intentionally excluded",
|
|
"system_settings": "runtime configuration and secret-bearing values must be reattached",
|
|
}
|
|
|
|
func backupExclusions() []string {
|
|
names := make([]string, 0, len(backupExcludedTables))
|
|
for name := range backupExcludedTables {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
result := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
result = append(result, name+" ("+backupExcludedTables[name]+")")
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (m Manager) Create(ctx context.Context) (Result, error) {
|
|
if m.Pool == nil || strings.TrimSpace(m.Directory) == "" {
|
|
return Result{}, ErrNotConfigured
|
|
}
|
|
if err := os.MkdirAll(m.Directory, 0o700); err != nil {
|
|
return Result{}, fmt.Errorf("create backup directory: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if m.Now != nil {
|
|
now = m.Now().UTC()
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("generate backup id: %w", err)
|
|
}
|
|
schemaVersion, err := currentSchemaVersion(ctx, m.Pool)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
temp, err := os.CreateTemp(m.Directory, ".pulse-backup-*.tmp")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create temporary backup: %w", err)
|
|
}
|
|
tempName := temp.Name()
|
|
defer os.Remove(tempName)
|
|
archive := zip.NewWriter(temp)
|
|
manifest := Manifest{FormatVersion: formatVersion, BackupID: id, CreatedAt: now, SchemaVersion: schemaVersion, Excluded: backupExclusions()}
|
|
for _, spec := range tableSpecs {
|
|
entry, err := archive.Create("data/" + spec.name + ".jsonl")
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create archive entry %s: %w", spec.name, err)
|
|
}
|
|
hash := sha256.New()
|
|
writer := io.MultiWriter(entry, hash)
|
|
rows, err := exportTable(ctx, m.Pool, spec, writer)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("export %s: %w", spec.name, err)
|
|
}
|
|
manifest.Tables = append(manifest.Tables, TableManifest{Name: spec.name, File: "data/" + spec.name + ".jsonl", Rows: rows, SHA256: hex.EncodeToString(hash.Sum(nil))})
|
|
}
|
|
manifestEntry, err := archive.Create(manifestName)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create manifest: %w", err)
|
|
}
|
|
if err := json.NewEncoder(manifestEntry).Encode(manifest); err != nil {
|
|
return Result{}, fmt.Errorf("write manifest: %w", err)
|
|
}
|
|
if err := archive.Close(); err != nil {
|
|
return Result{}, fmt.Errorf("close backup archive: %w", err)
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
return Result{}, fmt.Errorf("sync backup archive: %w", err)
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return Result{}, fmt.Errorf("close backup file: %w", err)
|
|
}
|
|
finalPath := filepath.Join(m.Directory, "pulse-backup-"+id+".zip")
|
|
if err := os.Rename(tempName, finalPath); err != nil {
|
|
return Result{}, fmt.Errorf("finalize backup: %w", err)
|
|
}
|
|
checksum, size, err := fileChecksum(finalPath)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.WriteFile(finalPath+".sha256", []byte(checksum+" "+filepath.Base(finalPath)+"\n"), 0o600); err != nil {
|
|
return Result{}, fmt.Errorf("write backup checksum: %w", err)
|
|
}
|
|
if err := m.prune(ctx, finalPath); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var rows int64
|
|
for _, table := range manifest.Tables {
|
|
rows += table.Rows
|
|
}
|
|
return Result{BackupID: id, Path: finalPath, SHA256: checksum, Bytes: size, Rows: rows, Created: now}, nil
|
|
}
|
|
|
|
func (m Manager) Verify(ctx context.Context, path string) (Manifest, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return Manifest{}, errors.New("backup path is required")
|
|
}
|
|
archive, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("open backup: %w", err)
|
|
}
|
|
defer archive.Close()
|
|
entries := make(map[string]*zip.File, len(archive.File))
|
|
for _, entry := range archive.File {
|
|
if _, exists := entries[entry.Name]; exists {
|
|
return Manifest{}, fmt.Errorf("duplicate archive entry %q", entry.Name)
|
|
}
|
|
entries[entry.Name] = entry
|
|
}
|
|
manifestFile, ok := entries[manifestName]
|
|
if !ok {
|
|
return Manifest{}, errors.New("backup manifest is missing")
|
|
}
|
|
manifestReader, err := manifestFile.Open()
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("open manifest: %w", err)
|
|
}
|
|
var manifest Manifest
|
|
err = json.NewDecoder(manifestReader).Decode(&manifest)
|
|
_ = manifestReader.Close()
|
|
if err != nil || manifest.FormatVersion != formatVersion || manifest.BackupID == "" || manifest.SchemaVersion <= 0 {
|
|
return Manifest{}, errors.New("backup manifest is invalid")
|
|
}
|
|
if len(manifest.Tables) != len(tableSpecs) {
|
|
return Manifest{}, fmt.Errorf("backup table set is incomplete: got %d, want %d", len(manifest.Tables), len(tableSpecs))
|
|
}
|
|
expectedFiles := map[string]bool{manifestName: true}
|
|
for _, spec := range tableSpecs {
|
|
expectedFiles["data/"+spec.name+".jsonl"] = true
|
|
}
|
|
for name := range entries {
|
|
if !expectedFiles[name] {
|
|
return Manifest{}, fmt.Errorf("unexpected backup archive entry %q", name)
|
|
}
|
|
}
|
|
seen := make(map[string]bool, len(manifest.Tables))
|
|
for _, table := range manifest.Tables {
|
|
if seen[table.Name] || table.Rows < 0 || table.SHA256 == "" || table.File != "data/"+table.Name+".jsonl" || !expectedFiles[table.File] {
|
|
return Manifest{}, fmt.Errorf("backup table entry %q is invalid", table.Name)
|
|
}
|
|
seen[table.Name] = true
|
|
entry, ok := entries[table.File]
|
|
if !ok {
|
|
return Manifest{}, fmt.Errorf("backup table file %q is missing", table.File)
|
|
}
|
|
if err := verifyTable(entry, table); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
}
|
|
for _, spec := range tableSpecs {
|
|
if !seen[spec.name] {
|
|
return Manifest{}, fmt.Errorf("backup table %q is missing", spec.name)
|
|
}
|
|
}
|
|
checksum, _, err := fileChecksum(path)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
sidecar, err := os.ReadFile(path + ".sha256")
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("read backup checksum: %w", err)
|
|
}
|
|
if !strings.HasPrefix(string(sidecar), checksum+" ") {
|
|
return Manifest{}, errors.New("backup archive checksum does not match sidecar")
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func (m Manager) List(ctx context.Context) ([]Result, error) {
|
|
if strings.TrimSpace(m.Directory) == "" {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
entries, err := os.ReadDir(m.Directory)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []Result{}, nil
|
|
}
|
|
return nil, fmt.Errorf("list backups: %w", err)
|
|
}
|
|
results := make([]Result, 0)
|
|
for _, entry := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pulse-backup-") || !strings.HasSuffix(entry.Name(), ".zip") {
|
|
continue
|
|
}
|
|
path := filepath.Join(m.Directory, entry.Name())
|
|
manifest, err := m.Verify(ctx, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("verify listed backup %s: %w", entry.Name(), err)
|
|
}
|
|
checksum, size, err := fileChecksum(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rows int64
|
|
for _, table := range manifest.Tables {
|
|
rows += table.Rows
|
|
}
|
|
results = append(results, Result{BackupID: manifest.BackupID, Path: path, SHA256: checksum, Bytes: size, Rows: rows, Created: manifest.CreatedAt})
|
|
}
|
|
sort.Slice(results, func(i, j int) bool { return results[i].Created.After(results[j].Created) })
|
|
return results, nil
|
|
}
|
|
func (m Manager) Restore(ctx context.Context, path string) (Manifest, error) {
|
|
if m.Pool == nil {
|
|
return Manifest{}, ErrNotConfigured
|
|
}
|
|
manifest, err := m.Verify(ctx, path)
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
for _, spec := range tableSpecs {
|
|
var count int64
|
|
if err := m.Pool.QueryRow(ctx, "SELECT count(*) FROM "+spec.name).Scan(&count); err != nil {
|
|
return Manifest{}, fmt.Errorf("check restore target %s: %w", spec.name, err)
|
|
}
|
|
if count != 0 {
|
|
return Manifest{}, fmt.Errorf("restore target is not empty: %s has %d rows", spec.name, count)
|
|
}
|
|
}
|
|
archive, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("open restore archive: %w", err)
|
|
}
|
|
defer archive.Close()
|
|
entries := make(map[string]*zip.File, len(archive.File))
|
|
for _, entry := range archive.File {
|
|
entries[entry.Name] = entry
|
|
}
|
|
tx, err := m.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("begin restore: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
for _, spec := range tableSpecs {
|
|
entry := entries["data/"+spec.name+".jsonl"]
|
|
reader, err := entry.Open()
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("open restore table %s: %w", spec.name, err)
|
|
}
|
|
decoder := json.NewDecoder(bufio.NewReader(reader))
|
|
for {
|
|
var row json.RawMessage
|
|
if err := decoder.Decode(&row); errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("decode restore table %s: %w", spec.name, err)
|
|
}
|
|
selectCols := spec.restoreCols
|
|
if spec.name == "alert_instances" {
|
|
selectCols = strings.Replace(selectCols, "last_value", "COALESCE(last_value, 'null'::jsonb)", 1)
|
|
} else if spec.name == "alert_occurrences" {
|
|
selectCols = strings.Replace(selectCols, "value", "COALESCE(value, 'null'::jsonb)", 1)
|
|
}
|
|
query := "INSERT INTO " + spec.name + " (" + spec.restoreCols + ") SELECT " + selectCols + " FROM jsonb_populate_record(NULL::" + spec.name + ", $1::jsonb)"
|
|
if _, err := tx.Exec(ctx, query, []byte(row)); err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("restore %s: %w", spec.name, err)
|
|
}
|
|
}
|
|
_ = reader.Close()
|
|
}
|
|
for _, deferred := range []struct{ table, id, column string }{{"dashboards", "id", "current_version_id"}, {"alert_rules", "id", "current_version_id"}} {
|
|
entry := entries["data/"+deferred.table+".jsonl"]
|
|
reader, err := entry.Open()
|
|
if err != nil {
|
|
return Manifest{}, fmt.Errorf("open deferred restore %s: %w", deferred.table, err)
|
|
}
|
|
decoder := json.NewDecoder(bufio.NewReader(reader))
|
|
for {
|
|
var row map[string]json.RawMessage
|
|
if err := decoder.Decode(&row); errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("decode deferred restore %s: %w", deferred.table, err)
|
|
}
|
|
id, ok := row[deferred.id]
|
|
value, valueOK := row[deferred.column]
|
|
if !ok || !valueOK || string(value) == "null" {
|
|
continue
|
|
}
|
|
var valueID, rowID string
|
|
if err := json.Unmarshal(value, &valueID); err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("decode deferred %s id: %w", deferred.table, err)
|
|
}
|
|
if err := json.Unmarshal(id, &rowID); err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("decode deferred %s row id: %w", deferred.table, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE "+deferred.table+" SET "+deferred.column+"=$1::uuid WHERE "+deferred.id+"=$2::uuid", valueID, rowID); err != nil {
|
|
_ = reader.Close()
|
|
return Manifest{}, fmt.Errorf("restore deferred %s: %w", deferred.table, err)
|
|
}
|
|
}
|
|
_ = reader.Close()
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Manifest{}, fmt.Errorf("commit restore: %w", err)
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func exportTable(ctx context.Context, pool *pgxpool.Pool, spec tableSpec, writer io.Writer) (int64, error) {
|
|
rows, err := pool.Query(ctx, "SELECT row_to_json(t) FROM (SELECT "+spec.columns+" FROM "+spec.name+" ORDER BY "+spec.orderBy+") t")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
var count int64
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return 0, err
|
|
}
|
|
if containsSensitiveKey(raw) {
|
|
return 0, fmt.Errorf("sensitive key detected in %s export", spec.name)
|
|
}
|
|
if _, err := writer.Write(append(raw, '\n')); err != nil {
|
|
return 0, err
|
|
}
|
|
count++
|
|
}
|
|
return count, rows.Err()
|
|
}
|
|
|
|
func verifyTable(entry *zip.File, expected TableManifest) error {
|
|
reader, err := entry.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("open table %s: %w", expected.Name, err)
|
|
}
|
|
defer reader.Close()
|
|
hash := sha256.New()
|
|
decoder := json.NewDecoder(io.TeeReader(reader, hash))
|
|
var rows int64
|
|
for {
|
|
var raw json.RawMessage
|
|
if err := decoder.Decode(&raw); errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
return fmt.Errorf("validate table %s: %w", expected.Name, err)
|
|
}
|
|
if containsSensitiveKey(raw) {
|
|
return fmt.Errorf("sensitive key detected in %s archive", expected.Name)
|
|
}
|
|
rows++
|
|
}
|
|
if rows != expected.Rows || hex.EncodeToString(hash.Sum(nil)) != expected.SHA256 {
|
|
return fmt.Errorf("table %s checksum or row count mismatch", expected.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentSchemaVersion(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
|
|
var version int64
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&version); err != nil {
|
|
return 0, fmt.Errorf("read schema version: %w", err)
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func fileChecksum(path string) (string, int64, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("open backup for checksum: %w", err)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("stat backup: %w", err)
|
|
}
|
|
hash := sha256.New()
|
|
if _, err := io.Copy(hash, file); err != nil {
|
|
return "", 0, fmt.Errorf("checksum backup: %w", err)
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil)), info.Size(), nil
|
|
}
|
|
|
|
func (m Manager) prune(ctx context.Context, keepPath string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
retention := m.Retention
|
|
if retention <= 0 {
|
|
retention = defaultRetention
|
|
}
|
|
entries, err := os.ReadDir(m.Directory)
|
|
if err != nil {
|
|
return fmt.Errorf("list backups: %w", err)
|
|
}
|
|
var archives []os.DirEntry
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "pulse-backup-") && strings.HasSuffix(entry.Name(), ".zip") {
|
|
archives = append(archives, entry)
|
|
}
|
|
}
|
|
sort.Slice(archives, func(i, j int) bool { return archives[i].Name() > archives[j].Name() })
|
|
if len(archives) <= retention {
|
|
return nil
|
|
}
|
|
for _, entry := range archives[retention:] {
|
|
path := filepath.Join(m.Directory, entry.Name())
|
|
if path == keepPath {
|
|
continue
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return fmt.Errorf("prune backup %s: %w", entry.Name(), err)
|
|
}
|
|
_ = os.Remove(path + ".sha256")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsSensitiveKey(raw []byte) bool {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) != nil {
|
|
return true
|
|
}
|
|
return sensitiveValue(value)
|
|
}
|
|
|
|
func sensitiveValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, nested := range typed {
|
|
lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_"))
|
|
for _, part := range []string{"authorization", "cookie", "password", "passwd", "secret", "token", "api_key", "apikey", "client_secret"} {
|
|
if strings.Contains(lower, part) {
|
|
return true
|
|
}
|
|
}
|
|
if sensitiveValue(nested) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, nested := range typed {
|
|
if sensitiveValue(nested) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func newID() (string, error) {
|
|
var bytes [16]byte
|
|
if _, err := rand.Read(bytes[:]); err != nil {
|
|
return "", err
|
|
}
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%s-%s-%s-%s-%s", hex.EncodeToString(bytes[0:4]), hex.EncodeToString(bytes[4:6]), hex.EncodeToString(bytes[6:8]), hex.EncodeToString(bytes[8:10]), hex.EncodeToString(bytes[10:16])), nil
|
|
}
|