mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Export/import payload bumped to v4.1 to include API tokens alongside existing
config bundle, eliminating blind spots in disaster recovery scenarios.
## Key Features
**API Tokens in Exports (v4.1)**
- Exports now include API token metadata (ID, name, hash, prefix, suffix, timestamps)
- Export format version bumped from 4.0 to 4.1
- Fixes gap where API tokens were lost during config migrations
**Transactional Atomic Imports**
- New importTransaction helper stages all writes before committing
- On failure, automatic rollback restores original configs
- Prevents partial/corrupted imports that could break running systems
- All config writes (nodes, alerts, email, webhooks, apprise, system, OIDC, API tokens, guest metadata) now transaction-aware
**Backward Compatibility**
- Version 4.0 exports (without API tokens) still import successfully
- System logs notice but proceeds, leaving existing API tokens untouched
- No breaking changes to existing export/import workflows
## Implementation
**Files Added:**
- internal/config/import_transaction.go - Transaction helper with staging/rollback
**Files Modified:**
- internal/config/export.go - v4.1 export, transactional ImportConfig wrapper
- internal/config/persistence.go - Transaction-aware Save* methods, beginTransaction/endTransaction helpers
- internal/config/persistence_test.go - 4 comprehensive unit tests
**Testing:**
- TestExportConfigIncludesAPITokens - Verifies API tokens in v4.1 exports
- TestImportConfigTransactionalSuccess - Validates atomic import success path
- TestImportConfigRollbackOnFailure - Confirms rollback on mid-import failure
- TestImportAcceptsVersion40Bundle - Ensures backward compatibility with v4.0
All tests passing ✅
## Migration Notes
- No manual migration required
- Users can re-export to generate v4.1 bundles with API tokens
- Existing 4.0 bundles remain valid for import
- Recommended: Re-run export after upgrade to ensure API tokens are captured
Co-authored-by: Codex (implementation)
Co-authored-by: Claude (coordination and testing)
191 lines
4.9 KiB
Go
191 lines
4.9 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// importTransaction coordinates staging config writes during an import so they
|
|
// can be committed atomically or rolled back on failure.
|
|
type importTransaction struct {
|
|
configDir string
|
|
stagingDir string
|
|
timestamp string
|
|
|
|
staged map[string]string // target path -> staged temp file
|
|
backups map[string]string // target path -> backup file path
|
|
|
|
committed bool
|
|
}
|
|
|
|
func newImportTransaction(configDir string) (*importTransaction, error) {
|
|
stagingDir, err := os.MkdirTemp(configDir, ".import-staging-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create import staging dir: %w", err)
|
|
}
|
|
|
|
tx := &importTransaction{
|
|
configDir: configDir,
|
|
stagingDir: stagingDir,
|
|
timestamp: time.Now().UTC().Format("20060102-150405"),
|
|
staged: make(map[string]string),
|
|
backups: make(map[string]string),
|
|
}
|
|
return tx, nil
|
|
}
|
|
|
|
// StageFile writes the provided data to a temporary file within the staging
|
|
// directory and records it for later commit.
|
|
func (tx *importTransaction) StageFile(target string, data []byte, perm os.FileMode) error {
|
|
if tx.committed {
|
|
return fmt.Errorf("transaction already committed")
|
|
}
|
|
|
|
if err := os.MkdirAll(tx.stagingDir, 0o700); err != nil {
|
|
return fmt.Errorf("ensure staging dir: %w", err)
|
|
}
|
|
|
|
// Remove any previously staged data for this target.
|
|
if existing, ok := tx.staged[target]; ok {
|
|
_ = os.Remove(existing)
|
|
}
|
|
|
|
base := filepath.Base(target)
|
|
if base == "" || base == string(os.PathSeparator) {
|
|
base = "staged"
|
|
}
|
|
prefix := strings.ReplaceAll(base, string(os.PathSeparator), "_")
|
|
if prefix == "" {
|
|
prefix = "staged"
|
|
}
|
|
if !strings.Contains(prefix, "*") {
|
|
prefix = prefix + ".tmp-*"
|
|
}
|
|
|
|
// Create the staged file.
|
|
tmpFile, err := os.CreateTemp(tx.stagingDir, prefix)
|
|
if err != nil {
|
|
return fmt.Errorf("create staged file for %s: %w", target, err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
_ = os.Remove(tmpFile.Name())
|
|
return fmt.Errorf("write staged file for %s: %w", target, err)
|
|
}
|
|
|
|
if err := tmpFile.Chmod(perm); err != nil {
|
|
_ = os.Remove(tmpFile.Name())
|
|
return fmt.Errorf("chmod staged file for %s: %w", target, err)
|
|
}
|
|
|
|
tx.staged[target] = tmpFile.Name()
|
|
return nil
|
|
}
|
|
|
|
// Commit atomically applies all staged files. If any step fails the transaction
|
|
// restores previous backups and returns an error.
|
|
func (tx *importTransaction) Commit() error {
|
|
if tx.committed {
|
|
return fmt.Errorf("transaction already committed")
|
|
}
|
|
tx.committed = true
|
|
|
|
targets := make([]string, 0, len(tx.staged))
|
|
for target := range tx.staged {
|
|
targets = append(targets, target)
|
|
}
|
|
sort.Strings(targets)
|
|
|
|
applied := make([]string, 0, len(targets))
|
|
|
|
restore := func() {
|
|
for i := len(applied) - 1; i >= 0; i-- {
|
|
target := applied[i]
|
|
stagedPath := tx.staged[target]
|
|
|
|
// Ensure staged file removed (best effort).
|
|
_ = os.Remove(stagedPath)
|
|
|
|
// Restore backup if present.
|
|
if backup := tx.backups[target]; backup != "" {
|
|
if _, err := os.Stat(backup); err == nil {
|
|
_ = os.Remove(target)
|
|
if err := os.Rename(backup, target); err == nil {
|
|
tx.backups[target] = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, target := range targets {
|
|
stagedPath := tx.staged[target]
|
|
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
|
restore()
|
|
return fmt.Errorf("ensure dir for %s: %w", target, err)
|
|
}
|
|
|
|
// Move current file to backup (if it exists and isn't already a dir).
|
|
if info, err := os.Stat(target); err == nil {
|
|
if info.IsDir() {
|
|
restore()
|
|
return fmt.Errorf("destination %s is a directory", target)
|
|
}
|
|
backupPath := fmt.Sprintf("%s.import-backup-%s", target, tx.timestamp)
|
|
if err := os.Rename(target, backupPath); err != nil {
|
|
restore()
|
|
return fmt.Errorf("backup existing file %s: %w", target, err)
|
|
}
|
|
tx.backups[target] = backupPath
|
|
} else if !os.IsNotExist(err) {
|
|
restore()
|
|
return fmt.Errorf("stat destination %s: %w", target, err)
|
|
}
|
|
|
|
if err := os.Rename(stagedPath, target); err != nil {
|
|
restore()
|
|
return fmt.Errorf("apply staged file to %s: %w", target, err)
|
|
}
|
|
|
|
applied = append(applied, target)
|
|
}
|
|
|
|
// Successful commit: remove backups (best effort).
|
|
for _, target := range applied {
|
|
if backup := tx.backups[target]; backup != "" {
|
|
_ = os.Remove(backup)
|
|
tx.backups[target] = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rollback drops all staged files and restores any backups already created.
|
|
func (tx *importTransaction) Rollback() {
|
|
for target, stagedPath := range tx.staged {
|
|
_ = os.Remove(stagedPath)
|
|
|
|
if backup := tx.backups[target]; backup != "" {
|
|
// Only attempt restore when backup still exists.
|
|
if _, err := os.Stat(backup); err != nil {
|
|
continue
|
|
}
|
|
_ = os.Remove(target)
|
|
if err := os.Rename(backup, target); err != nil {
|
|
continue
|
|
}
|
|
tx.backups[target] = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup removes the staging directory.
|
|
func (tx *importTransaction) Cleanup() {
|
|
_ = os.RemoveAll(tx.stagingDir)
|
|
}
|