Pulse/internal/config/import_transaction.go
rcourtman 999da6d900 feat: production-ready import/export with API tokens and transactional rollback
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)
2025-10-21 14:37:44 +00:00

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)
}