diff --git a/docs/cli/newrelic_install.md b/docs/cli/newrelic_install.md index 041708837..bf0cb0857 100644 --- a/docs/cli/newrelic_install.md +++ b/docs/cli/newrelic_install.md @@ -9,13 +9,17 @@ newrelic install [flags] ### Options ``` - -y, --assumeYes use "yes" for all questions during install - -h, --help help for install - --localRecipes string a path to local recipes to load instead of service other fetching - -n, --recipe strings the name of a recipe to install - -c, --recipePath strings the path to a recipe file to install - --tag string comma-separated list of tags ("key:value,key:value") - -t, --testMode fakes operations for UX testing + -y, --assumeYes use "yes" for all questions during install + --backup-location string custom location for backup files (default: platform-specific) + -h, --help help for install + --list-backups list all available configuration backups and exit + --localRecipes string a path to local recipes to load instead of service other fetching + -n, --recipe strings the name of a recipe to install + -c, --recipePath strings the path to a recipe file to install + --restore-backup string restore configuration from a specific backup ID (e.g. backup-2026-02-19-143022) + --skip-backup skip backing up existing configuration files before install + --tag string comma-separated list of tags ("key:value,key:value") + -t, --testMode fakes operations for UX testing ``` ### Options inherited from parent commands @@ -40,6 +44,46 @@ If no version is specified, the latest available version will be installed. This For a list of available versions, please refer to the [Infrastructure Agent Release Notes](https://docs.newrelic.com/docs/release-notes/infrastructure-release-notes/infrastructure-agent-release-notes/). +### Configuration Backup + +Before any recipe is executed, the CLI automatically detects and backs up existing New Relic configuration files (Infrastructure agent, APM agents, Logging, Integrations). Backups are timestamped and stored in a platform-specific location: + +| Platform | Default backup location | +|------------------|------------------------------------| +| Linux (root) | `/opt/.newrelic-backups/` | +| Linux (non-root) | `~/.newrelic-backups/` | +| Windows | `%ProgramData%\.newrelic-backups\` | +| macOS | `~/.newrelic-backups/` | + +The last 5 backups are retained automatically; older ones are removed. + +Each backup contains a `manifest.json` with the timestamp, list of files, SHA256 checksums, and CLI version used. + +**Skip backup (CI/CD environments):** +``` +newrelic install --skip-backup +``` + +**Use a custom backup directory:** +``` +newrelic install --backup-location /var/backups/newrelic +``` + +**List all available backups:** +``` +newrelic install --list-backups +``` + +**Restore configuration from a specific backup:** +``` +newrelic install --restore-backup backup-2026-02-19-143022 +``` + +Use `-y` / `--assumeYes` with `--restore-backup` to skip the confirmation prompt: +``` +newrelic install --restore-backup backup-2026-02-19-143022 --assumeYes +``` + ### SEE ALSO - [newrelic](newrelic.md) - The New Relic CLI diff --git a/internal/install/backup/creator.go b/internal/install/backup/creator.go new file mode 100644 index 000000000..35e082cd5 --- /dev/null +++ b/internal/install/backup/creator.go @@ -0,0 +1,236 @@ +package backup + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "time" + + log "github.com/sirupsen/logrus" +) + +// Creator handles creating timestamped backups with checksums +type Creator struct { + baseBackupDir string +} + +// NewCreator creates a new backup creator +func NewCreator(baseBackupDir string) *Creator { + return &Creator{ + baseBackupDir: baseBackupDir, + } +} + +// CreateBackup creates a timestamped backup of config files with checksums +func (c *Creator) CreateBackup(ctx context.Context, files []string, platform string, cliVersion string) (*Result, error) { + if len(files) == 0 { + log.Debug("No files to backup") + return &Result{ + Success: true, + Warnings: []string{"No config files found to backup"}, + }, nil + } + + backupID := c.generateBackupID() + backupDir := filepath.Join(c.baseBackupDir, backupID) + + log.WithFields(log.Fields{ + "backupID": backupID, + "backupDir": backupDir, + "files": len(files), + }).Info("Creating configuration backup") + + // Create backup directory + if err := os.MkdirAll(backupDir, 0750); err != nil { + return &Result{ + Success: false, + Error: fmt.Errorf("failed to create backup directory: %w", err), + }, err + } + + result := &Result{ + BackupDir: backupDir, + Success: true, + Warnings: []string{}, + } + + manifest := &Manifest{ + Timestamp: time.Now(), + BackupID: backupID, + Platform: platform, + Reason: "guided-install", + CLIVersion: cliVersion, + Files: []BackedUpFile{}, + } + + // Backup each file + for _, srcPath := range files { + backedUpFile, err := c.backupSingleFile(srcPath, backupDir) + if err != nil { + warning := fmt.Sprintf("Failed to backup %s: %v", srcPath, err) + result.Warnings = append(result.Warnings, warning) + log.Warn(warning) + continue + } + + manifest.Files = append(manifest.Files, *backedUpFile) + result.FilesBackedUp++ + } + + // Write manifest + manifestPath, err := c.writeManifest(manifest, backupDir) + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to write manifest: %v", err)) + log.WithError(err).Warn("Failed to write backup manifest") + } else { + result.ManifestPath = manifestPath + } + + if result.FilesBackedUp == 0 { + result.Success = false + result.Error = fmt.Errorf("no files were successfully backed up") + return result, result.Error + } + + log.WithFields(log.Fields{ + "backupID": backupID, + "filesBackedUp": result.FilesBackedUp, + "warnings": len(result.Warnings), + }).Info("Backup completed") + + return result, nil +} + +// backupSingleFile backs up a single file with checksum +func (c *Creator) backupSingleFile(srcPath string, backupDir string) (*BackedUpFile, error) { + // Open source file + srcFile, err := os.Open(srcPath) + if err != nil { + return nil, fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + // Get file info + srcInfo, err := srcFile.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat source file: %w", err) + } + + // Create destination path preserving relative structure. + // Strip the volume name (e.g. "C:" on Windows, "" on Unix) and the + // leading path separator so the result is always a relative path, + // otherwise filepath.Join discards the backupDir prefix entirely. + // e.g. Linux: /etc/newrelic-infra.yml → etc/newrelic-infra.yml + // Windows: C:\Program Files\New Relic\newrelic-infra.yml → Program Files\New Relic\newrelic-infra.yml + relPath := srcPath + if filepath.IsAbs(srcPath) { + vol := filepath.VolumeName(srcPath) // "C:" on Windows, "" on Unix + afterVol := srcPath[len(vol):] + if len(afterVol) > 0 && afterVol[0] == filepath.Separator { + relPath = afterVol[1:] + } else { + relPath = afterVol + } + } + dstPath := filepath.Join(backupDir, relPath) + + // Create destination directory + dstDir := filepath.Dir(dstPath) + if err := os.MkdirAll(dstDir, 0750); err != nil { + return nil, fmt.Errorf("failed to create destination directory: %w", err) + } + + // Create destination file + dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return nil, fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + // Copy file and calculate checksum + checksum, size, err := c.copyFileWithChecksum(srcFile, dstFile) + if err != nil { + return nil, fmt.Errorf("failed to copy file: %w", err) + } + + permissions := c.getFilePermissions(srcInfo) + + log.WithFields(log.Fields{ + "source": srcPath, + "destination": dstPath, + "size": size, + "checksum": checksum[:16] + "...", + }).Debug("File backed up successfully") + + return &BackedUpFile{ + OriginalPath: srcPath, + BackupPath: dstPath, + SHA256Checksum: checksum, + Size: size, + Permissions: permissions, + }, nil +} + +// copyFileWithChecksum copies a file and calculates SHA256 checksum +func (c *Creator) copyFileWithChecksum(src io.Reader, dst io.Writer) (checksum string, size int64, err error) { + hash := sha256.New() + writer := io.MultiWriter(dst, hash) + + size, err = io.Copy(writer, src) + if err != nil { + return "", 0, err + } + + checksum = hex.EncodeToString(hash.Sum(nil)) + return checksum, size, nil +} + +// writeManifest writes the backup manifest to JSON +func (c *Creator) writeManifest(manifest *Manifest, backupDir string) (string, error) { + manifestPath := filepath.Join(backupDir, "manifest.json") + + jsonData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal manifest: %w", err) + } + + if err := os.WriteFile(manifestPath, jsonData, 0640); err != nil { + return "", fmt.Errorf("failed to write manifest file: %w", err) + } + + log.Debugf("Manifest written to: %s", manifestPath) + return manifestPath, nil +} + +// getFilePermissions returns a string representation of file permissions +func (c *Creator) getFilePermissions(info os.FileInfo) string { + if runtime.GOOS == "windows" { + // Windows: simplified permission string + mode := info.Mode() + perms := "" + if mode&0400 != 0 { + perms += "R" + } + if mode&0200 != 0 { + perms += "W" + } + if perms == "" { + perms = "RO" + } + return perms + } + + // Unix: octal permission string + return fmt.Sprintf("%04o", info.Mode().Perm()) +} + +// generateBackupID generates a timestamp-based backup ID +func (c *Creator) generateBackupID() string { + return fmt.Sprintf("backup-%s", time.Now().Format("2006-01-02-150405")) +} diff --git a/internal/install/backup/creator_test.go b/internal/install/backup/creator_test.go new file mode 100644 index 000000000..78968a766 --- /dev/null +++ b/internal/install/backup/creator_test.go @@ -0,0 +1,143 @@ +//go:build unit + +package backup + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreator_CreateBackup(t *testing.T) { + // Create temp directories + srcDir := t.TempDir() + backupBaseDir := t.TempDir() + + // Create mock config files + configFile1 := filepath.Join(srcDir, "newrelic-infra.yml") + configFile2 := filepath.Join(srcDir, "integrations.d", "mysql.yml") + + require.NoError(t, os.WriteFile(configFile1, []byte("license_key: test1"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Dir(configFile2), 0755)) + require.NoError(t, os.WriteFile(configFile2, []byte("interval: 30s"), 0644)) + + // Create backup + creator := NewCreator(backupBaseDir) + result, err := creator.CreateBackup(context.Background(), []string{configFile1, configFile2}, "linux", "v0.106.23") + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 2, result.FilesBackedUp) + assert.NotEmpty(t, result.BackupDir) + assert.NotEmpty(t, result.ManifestPath) + + // Verify backup directory was created + _, err = os.Stat(result.BackupDir) + assert.NoError(t, err) + + // Verify manifest exists + manifestData, err := os.ReadFile(result.ManifestPath) + require.NoError(t, err) + + var manifest Manifest + require.NoError(t, json.Unmarshal(manifestData, &manifest)) + + assert.Equal(t, 2, len(manifest.Files)) + assert.Equal(t, "linux", manifest.Platform) + assert.Equal(t, "guided-install", manifest.Reason) + assert.Equal(t, "v0.106.23", manifest.CLIVersion) + + // Verify checksums are not empty + for _, file := range manifest.Files { + assert.NotEmpty(t, file.SHA256Checksum) + assert.Greater(t, file.Size, int64(0)) + assert.NotEmpty(t, file.Permissions) + } + + // Verify the backed-up file exists at the correct nested path inside backupDir. + vol := filepath.VolumeName(configFile1) + afterVol := configFile1[len(vol):] + if len(afterVol) > 0 && afterVol[0] == filepath.Separator { + afterVol = afterVol[1:] + } + expectedPath := filepath.Join(result.BackupDir, afterVol) + _, statErr := os.Stat(expectedPath) + assert.NoError(t, statErr, "backed up file should exist at correct nested path inside backupDir") +} + +func TestCreator_CreateBackup_NoFiles(t *testing.T) { + backupBaseDir := t.TempDir() + + creator := NewCreator(backupBaseDir) + result, err := creator.CreateBackup(context.Background(), []string{}, "linux", "v0.106.23") + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 0, result.FilesBackedUp) + assert.Len(t, result.Warnings, 1) +} + +func TestCreator_CreateBackup_PartialFailure(t *testing.T) { + srcDir := t.TempDir() + backupBaseDir := t.TempDir() + + // Create one valid file + validFile := filepath.Join(srcDir, "valid.yml") + require.NoError(t, os.WriteFile(validFile, []byte("test"), 0644)) + + // Reference one nonexistent file + invalidFile := filepath.Join(srcDir, "nonexistent.yml") + + creator := NewCreator(backupBaseDir) + result, err := creator.CreateBackup(context.Background(), []string{validFile, invalidFile}, "linux", "v0.106.23") + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 1, result.FilesBackedUp) + assert.NotEmpty(t, result.Warnings) +} + +func TestCreator_generateBackupID(t *testing.T) { + creator := &Creator{} + + backupID := creator.generateBackupID() + + assert.NotEmpty(t, backupID) + assert.Contains(t, backupID, "backup-") +} + +func TestCreator_copyFileWithChecksum(t *testing.T) { + srcDir := t.TempDir() + dstDir := t.TempDir() + + // Create source file + srcFile := filepath.Join(srcDir, "test.yml") + content := []byte("test content for checksum") + require.NoError(t, os.WriteFile(srcFile, content, 0644)) + + // Open files for copying + src, err := os.Open(srcFile) + require.NoError(t, err) + defer src.Close() + + dstFile := filepath.Join(dstDir, "test.yml") + dst, err := os.Create(dstFile) + require.NoError(t, err) + defer dst.Close() + + creator := &Creator{} + checksum, size, err := creator.copyFileWithChecksum(src, dst) + + require.NoError(t, err) + assert.NotEmpty(t, checksum) + assert.Equal(t, int64(len(content)), size) + assert.Len(t, checksum, 64) // SHA256 hex is 64 chars +} diff --git a/internal/install/backup/detector.go b/internal/install/backup/detector.go new file mode 100644 index 000000000..8ac14bdfa --- /dev/null +++ b/internal/install/backup/detector.go @@ -0,0 +1,267 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/newrelic/newrelic-cli/internal/install/types" +) + +// InstallationDetector detects existing New Relic installations +type InstallationDetector struct { + manifest *types.DiscoveryManifest + platform string +} + +// NewInstallationDetector creates a new installation detector +func NewInstallationDetector(manifest *types.DiscoveryManifest) *InstallationDetector { + return &InstallationDetector{ + manifest: manifest, + platform: runtime.GOOS, + } +} + +// DetectExistingInstallation checks for existing New Relic installations +// and returns information about all detected config files across all agent types +func (d *InstallationDetector) DetectExistingInstallation(ctx context.Context) (*InstallationInfo, error) { + log.Debug("Detecting existing New Relic installations (all agent types)") + + configPaths := d.GetAllConfigPaths() + existingFiles, err := d.findExistingFiles(configPaths) + if err != nil { + return nil, err + } + + if len(existingFiles) == 0 { + log.Debug("No existing New Relic config files detected") + return &InstallationInfo{ + IsInstalled: false, + ConfigFiles: []string{}, + }, nil + } + + log.WithFields(log.Fields{ + "platform": d.platform, + "files": len(existingFiles), + }).Infof("Detected %d New Relic config files across all agent types", len(existingFiles)) + + return &InstallationInfo{ + IsInstalled: true, + ConfigFiles: existingFiles, + }, nil +} + +// GetAllConfigPaths returns all possible config paths for New Relic agents +// across Infrastructure, APM agents, Integrations, and Logging +func (d *InstallationDetector) GetAllConfigPaths() []string { + switch d.platform { + case "linux": + return d.getLinuxPaths() + case "windows": + return d.getWindowsPaths() + case "darwin": + return d.getDarwinPaths() + default: + log.Warnf("Unsupported platform: %s", d.platform) + return []string{} + } +} + +// getLinuxPaths returns all New Relic config paths for Linux +func (d *InstallationDetector) getLinuxPaths() []string { + return []string{ + // Infrastructure Agent + "/etc/newrelic-infra.yml", + "/etc/newrelic-infra/", + "/var/db/newrelic-infra/", + + // APM Agents (generic) + "/etc/newrelic/newrelic.yml", + "/etc/newrelic/", + "/usr/local/etc/newrelic/", + + // APM Agents (language-specific) + "/etc/newrelic-java/newrelic.yml", + "/etc/newrelic-java/", + "/etc/php.d/newrelic.ini", + "/etc/php/*/conf.d/newrelic.ini", + + // User-level configs + filepath.Join(os.Getenv("HOME"), ".newrelic/"), + + // Logging + "/etc/newrelic-infra/logging.d/", + "/var/log/newrelic/", + } +} + +// getWindowsPaths returns all New Relic config paths for Windows +func (d *InstallationDetector) getWindowsPaths() []string { + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + programFiles = "C:\\Program Files" + } + + programData := os.Getenv("ProgramData") + if programData == "" { + programData = "C:\\ProgramData" + } + + userProfile := os.Getenv("USERPROFILE") + if userProfile == "" { + userProfile = "C:\\Users\\*" + } + + return []string{ + // Infrastructure Agent + filepath.Join(programFiles, "New Relic", "newrelic-infra", "newrelic-infra.yml"), + filepath.Join(programFiles, "New Relic", "newrelic-infra", "integrations.d"), + filepath.Join(programFiles, "New Relic", "newrelic-infra"), + + // .NET Agent + filepath.Join(programData, "New Relic", ".NET Agent"), + filepath.Join(programFiles, "New Relic", ".NET Agent"), + + // Generic APM configs + filepath.Join(programFiles, "New Relic", "newrelic.yml"), + filepath.Join(programData, "New Relic"), + + // User-level configs + filepath.Join(userProfile, "AppData", "Local", "New Relic"), + filepath.Join(userProfile, ".newrelic"), + } +} + +// getDarwinPaths returns all New Relic config paths for macOS +func (d *InstallationDetector) getDarwinPaths() []string { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = "/Users/*" + } + + return []string{ + // Infrastructure Agent + "/usr/local/etc/newrelic-infra/newrelic-infra.yml", + "/usr/local/etc/newrelic-infra/", + "/opt/newrelic-infra/", + + // APM Agents + "/usr/local/etc/newrelic/", + "/Library/Application Support/New Relic/", + + // User-level configs + filepath.Join(homeDir, ".newrelic/"), + filepath.Join(homeDir, "Library", "Application Support", "New Relic"), + } +} + +// findExistingFiles recursively finds all New Relic config files +func (d *InstallationDetector) findExistingFiles(paths []string) ([]string, error) { + var existingFiles []string + seen := make(map[string]bool) // Deduplicate files + + // Expand wildcard paths into concrete paths before iterating + var expandedPaths []string + for _, path := range paths { + if strings.Contains(path, "*") { + matches, err := filepath.Glob(path) + if err != nil { + log.WithError(err).Debugf("Error globbing path: %s", path) + continue + } + expandedPaths = append(expandedPaths, matches...) + } else { + expandedPaths = append(expandedPaths, path) + } + } + + for _, path := range expandedPaths { + info, err := os.Stat(path) + if err != nil { + // Path doesn't exist, skip silently + continue + } + + if info.IsDir() { + // Walk directory to find config files + err = filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, err error) error { + if err != nil { + return nil // Continue on errors + } + + if fileInfo.IsDir() { + return nil + } + + // Check if this is a New Relic config file + if d.isNewRelicConfigFile(filePath) && !seen[filePath] { + existingFiles = append(existingFiles, filePath) + seen[filePath] = true + } + + return nil + }) + if err != nil { + log.WithError(err).Debugf("Error walking directory: %s", path) + } + } else { + // Single file + if d.isNewRelicConfigFile(path) && !seen[path] { + existingFiles = append(existingFiles, path) + seen[path] = true + } + } + } + + log.Debugf("Found %d existing New Relic config files", len(existingFiles)) + return existingFiles, nil +} + +// isNewRelicConfigFile checks if a file is a New Relic config file +func (d *InstallationDetector) isNewRelicConfigFile(filePath string) bool { + fileName := strings.ToLower(filepath.Base(filePath)) + ext := strings.ToLower(filepath.Ext(filePath)) + dirName := strings.ToLower(filepath.Dir(filePath)) + + // Check for known config file extensions + validExtensions := []string{".yml", ".yaml", ".xml", ".ini", ".json"} + hasValidExtension := false + for _, validExt := range validExtensions { + if ext == validExt { + hasValidExtension = true + break + } + } + + if !hasValidExtension { + return false + } + + // Check if filename or directory contains "newrelic" + containsNewRelic := strings.Contains(fileName, "newrelic") || + strings.Contains(dirName, "newrelic") || + strings.Contains(dirName, "new relic") + + // Check for known config file patterns + knownPatterns := []string{ + "newrelic.yml", + "newrelic.yaml", + "newrelic-infra.yml", + "newrelic.ini", + "newrelic.xml", + "newrelic.config", + } + + for _, pattern := range knownPatterns { + if fileName == pattern { + return true + } + } + + return containsNewRelic +} diff --git a/internal/install/backup/detector_test.go b/internal/install/backup/detector_test.go new file mode 100644 index 000000000..cbfb8fce3 --- /dev/null +++ b/internal/install/backup/detector_test.go @@ -0,0 +1,126 @@ +//go:build unit + +package backup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/newrelic/newrelic-cli/internal/install/types" +) + +func TestInstallationDetector_DetectExistingInstallation(t *testing.T) { + // Create temp directory with mock config files + tmpDir := t.TempDir() + + // Create mock New Relic config files + configDir := filepath.Join(tmpDir, "etc", "newrelic-infra") + require.NoError(t, os.MkdirAll(configDir, 0755)) + + configFile := filepath.Join(configDir, "newrelic-infra.yml") + require.NoError(t, os.WriteFile(configFile, []byte("license_key: test"), 0644)) + + // Create detector with mock paths + manifest := &types.DiscoveryManifest{} + detector := NewInstallationDetector(manifest) + + // Override paths to use temp directory + paths := []string{filepath.Join(tmpDir, "etc")} + + files, err := detector.findExistingFiles(paths) + require.NoError(t, err) + assert.NotEmpty(t, files) +} + +func TestInstallationDetector_isNewRelicConfigFile(t *testing.T) { + detector := &InstallationDetector{} + + tests := []struct { + name string + filePath string + want bool + }{ + { + name: "newrelic-infra.yml", + filePath: "/etc/newrelic-infra.yml", + want: true, + }, + { + name: "newrelic.yml", + filePath: "/etc/newrelic/newrelic.yml", + want: true, + }, + { + name: "file in newrelic directory", + filePath: "/etc/newrelic-infra/config.yml", + want: true, + }, + { + name: "non-newrelic file", + filePath: "/etc/apache2/apache2.conf", + want: false, + }, + { + name: "newrelic.ini", + filePath: "/etc/php.d/newrelic.ini", + want: true, + }, + { + name: "wrong extension", + filePath: "/etc/newrelic-infra.txt", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detector.isNewRelicConfigFile(tt.filePath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestInstallationDetector_DetectExistingInstallation_NoFiles(t *testing.T) { + manifest := &types.DiscoveryManifest{} + detector := NewInstallationDetector(manifest) + + // Use a directory that doesn't exist + paths := []string{"/nonexistent/path"} + files, err := detector.findExistingFiles(paths) + + require.NoError(t, err) + assert.Empty(t, files) +} + +func TestInstallationDetector_GetAllConfigPaths(t *testing.T) { + manifest := &types.DiscoveryManifest{} + detector := NewInstallationDetector(manifest) + + paths := detector.GetAllConfigPaths() + + // Should return paths for current platform + assert.NotEmpty(t, paths) + + // Verify paths contain newrelic in some form + foundNewRelic := false + for _, path := range paths { + if containsNewRelicPath(path) { + foundNewRelic = true + break + } + } + assert.True(t, foundNewRelic, "Expected at least one path to contain 'newrelic'") +} + +func containsNewRelicPath(path string) bool { + lower := filepath.ToSlash(path) + return filepath.Base(lower) != lower && + (filepath.Base(lower) == "newrelic" || + filepath.Base(lower) == "newrelic-infra" || + filepath.Base(filepath.Dir(lower)) == "newrelic" || + filepath.Base(filepath.Dir(lower)) == "newrelic-infra") +} diff --git a/internal/install/backup/lifecycle.go b/internal/install/backup/lifecycle.go new file mode 100644 index 000000000..b72ca7f1e --- /dev/null +++ b/internal/install/backup/lifecycle.go @@ -0,0 +1,198 @@ +package backup + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// Manager handles backup lifecycle and rotation +type Manager struct { + baseBackupDir string + maxBackups int +} + +// NewManager creates a new backup manager +func NewManager(baseBackupDir string, maxBackups int) *Manager { + return &Manager{ + baseBackupDir: baseBackupDir, + maxBackups: maxBackups, + } +} + +// RotateBackups keeps only the most recent N backups and deletes older ones +func (m *Manager) RotateBackups(ctx context.Context) error { + log.Debugf("Rotating backups (keeping last %d)", m.maxBackups) + + // Check if backup directory exists + if _, err := os.Stat(m.baseBackupDir); os.IsNotExist(err) { + log.Debug("Backup directory does not exist, nothing to rotate") + return nil + } + + // List all backup directories + entries, err := os.ReadDir(m.baseBackupDir) + if err != nil { + return fmt.Errorf("failed to read backup directory: %w", err) + } + + // Parse timestamps and sort + type backupEntry struct { + name string + timestamp time.Time + } + + var backups []backupEntry + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasPrefix(name, "backup-") { + continue + } + + timestamp, err := m.parseBackupTimestamp(name) + if err != nil { + log.WithError(err).Warnf("Failed to parse timestamp from backup: %s", name) + continue + } + + backups = append(backups, backupEntry{ + name: name, + timestamp: timestamp, + }) + } + + // Sort by timestamp (newest first) + sort.Slice(backups, func(i, j int) bool { + return backups[i].timestamp.After(backups[j].timestamp) + }) + + // Delete old backups + if len(backups) > m.maxBackups { + backupsToDelete := backups[m.maxBackups:] + log.Infof("Rotating out %d old backups", len(backupsToDelete)) + + for _, backup := range backupsToDelete { + backupPath := filepath.Join(m.baseBackupDir, backup.name) + log.Debugf("Deleting old backup: %s", backupPath) + + if err := os.RemoveAll(backupPath); err != nil { + log.WithError(err).Warnf("Failed to delete backup: %s", backup.name) + // Continue with other deletions + } + } + } else { + log.Debugf("Only %d backups exist, no rotation needed", len(backups)) + } + + return nil +} + +// ListBackups returns all available backups sorted by timestamp (newest first) +func (m *Manager) ListBackups() ([]*Manifest, error) { + // Check if backup directory exists + if _, err := os.Stat(m.baseBackupDir); os.IsNotExist(err) { + return []*Manifest{}, nil + } + + entries, err := os.ReadDir(m.baseBackupDir) + if err != nil { + return nil, fmt.Errorf("failed to read backup directory: %w", err) + } + + var manifests []*Manifest + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasPrefix(name, "backup-") { + continue + } + + manifest, err := m.GetManifest(name) + if err != nil { + log.WithError(err).Warnf("Failed to read manifest for backup: %s", name) + continue + } + + manifests = append(manifests, manifest) + } + + // Sort by timestamp (newest first) + sort.Slice(manifests, func(i, j int) bool { + return manifests[i].Timestamp.After(manifests[j].Timestamp) + }) + + return manifests, nil +} + +// GetManifest reads the manifest for a specific backup +func (m *Manager) GetManifest(backupID string) (*Manifest, error) { + manifestPath := filepath.Join(m.baseBackupDir, backupID, "manifest.json") + + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + return &manifest, nil +} + +// DeleteBackup removes a specific backup directory +func (m *Manager) DeleteBackup(backupID string) error { + backupPath := filepath.Join(m.baseBackupDir, backupID) + + // Verify it's a backup directory + if !strings.HasPrefix(backupID, "backup-") { + return fmt.Errorf("invalid backup ID: %s", backupID) + } + + log.Infof("Deleting backup: %s", backupID) + + if err := os.RemoveAll(backupPath); err != nil { + return fmt.Errorf("failed to delete backup: %w", err) + } + + return nil +} + +// parseBackupTimestamp parses the timestamp from a backup directory name +// Format: backup-2026-02-18-150405 +func (m *Manager) parseBackupTimestamp(backupID string) (time.Time, error) { + // Remove "backup-" prefix + if !strings.HasPrefix(backupID, "backup-") { + return time.Time{}, fmt.Errorf("invalid backup ID format: %s", backupID) + } + + timestampStr := strings.TrimPrefix(backupID, "backup-") + + // Parse timestamp: 2006-01-02-150405 + timestamp, err := time.Parse("2006-01-02-150405", timestampStr) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse timestamp: %w", err) + } + + return timestamp, nil +} + +// GetBackupDir returns the full path to a backup directory +func (m *Manager) GetBackupDir(backupID string) string { + return filepath.Join(m.baseBackupDir, backupID) +} diff --git a/internal/install/backup/lifecycle_test.go b/internal/install/backup/lifecycle_test.go new file mode 100644 index 000000000..5c4069334 --- /dev/null +++ b/internal/install/backup/lifecycle_test.go @@ -0,0 +1,194 @@ +//go:build unit + +package backup + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_RotateBackups(t *testing.T) { + backupBaseDir := t.TempDir() + + // Create 7 mock backups + backupIDs := []string{ + "backup-2026-02-10-100000", + "backup-2026-02-11-100000", + "backup-2026-02-12-100000", + "backup-2026-02-13-100000", + "backup-2026-02-14-100000", + "backup-2026-02-15-100000", + "backup-2026-02-16-100000", + } + + for _, id := range backupIDs { + backupDir := filepath.Join(backupBaseDir, id) + require.NoError(t, os.MkdirAll(backupDir, 0755)) + + // Create a manifest file + manifest := Manifest{ + BackupID: id, + Platform: "linux", + Timestamp: time.Now(), + CLIVersion: "v0.106.23", + Reason: "test", + } + manifestData, _ := json.Marshal(manifest) + manifestPath := filepath.Join(backupDir, "manifest.json") + require.NoError(t, os.WriteFile(manifestPath, manifestData, 0644)) + } + + // Keep only last 5 + manager := NewManager(backupBaseDir, 5) + err := manager.RotateBackups(context.Background()) + require.NoError(t, err) + + // Verify only 5 backups remain + entries, err := os.ReadDir(backupBaseDir) + require.NoError(t, err) + + count := 0 + for _, entry := range entries { + if entry.IsDir() { + count++ + } + } + assert.Equal(t, 5, count) + + // Verify the oldest 2 backups were deleted + _, err = os.Stat(filepath.Join(backupBaseDir, backupIDs[0])) + assert.True(t, os.IsNotExist(err)) + + _, err = os.Stat(filepath.Join(backupBaseDir, backupIDs[1])) + assert.True(t, os.IsNotExist(err)) + + // Verify the newest 5 still exist + for i := 2; i < 7; i++ { + _, err = os.Stat(filepath.Join(backupBaseDir, backupIDs[i])) + assert.NoError(t, err) + } +} + +func TestManager_ListBackups(t *testing.T) { + backupBaseDir := t.TempDir() + + // Create 3 mock backups with different timestamps + backupData := []struct { + id string + timestamp time.Time + }{ + {"backup-2026-02-16-100000", time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC)}, + {"backup-2026-02-15-100000", time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC)}, + {"backup-2026-02-14-100000", time.Date(2026, 2, 14, 10, 0, 0, 0, time.UTC)}, + } + + for _, data := range backupData { + backupDir := filepath.Join(backupBaseDir, data.id) + require.NoError(t, os.MkdirAll(backupDir, 0755)) + + manifest := Manifest{ + BackupID: data.id, + Platform: "linux", + Timestamp: data.timestamp, + CLIVersion: "v0.106.23", + Reason: "guided-install", + Files: []BackedUpFile{}, + } + manifestData, _ := json.Marshal(manifest) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0644)) + } + + manager := NewManager(backupBaseDir, 5) + backups, err := manager.ListBackups() + + require.NoError(t, err) + assert.Equal(t, 3, len(backups)) + + // Verify sorted by timestamp (newest first) + assert.Equal(t, "backup-2026-02-16-100000", backups[0].BackupID) + assert.Equal(t, "backup-2026-02-15-100000", backups[1].BackupID) + assert.Equal(t, "backup-2026-02-14-100000", backups[2].BackupID) +} + +func TestManager_GetManifest(t *testing.T) { + backupBaseDir := t.TempDir() + + backupID := "backup-2026-02-18-143022" + backupDir := filepath.Join(backupBaseDir, backupID) + require.NoError(t, os.MkdirAll(backupDir, 0755)) + + expectedManifest := Manifest{ + BackupID: backupID, + Platform: "linux", + Timestamp: time.Now(), + CLIVersion: "v0.106.23", + Reason: "guided-install", + Files: []BackedUpFile{ + { + OriginalPath: "/etc/newrelic-infra.yml", + BackupPath: "/backup/etc/newrelic-infra.yml", + SHA256Checksum: "abc123", + Size: 100, + Permissions: "0644", + }, + }, + } + + manifestData, _ := json.Marshal(expectedManifest) + require.NoError(t, os.WriteFile(filepath.Join(backupDir, "manifest.json"), manifestData, 0644)) + + manager := NewManager(backupBaseDir, 5) + manifest, err := manager.GetManifest(backupID) + + require.NoError(t, err) + assert.Equal(t, backupID, manifest.BackupID) + assert.Equal(t, "linux", manifest.Platform) + assert.Equal(t, 1, len(manifest.Files)) + assert.Equal(t, "/etc/newrelic-infra.yml", manifest.Files[0].OriginalPath) +} + +func TestManager_parseBackupTimestamp(t *testing.T) { + manager := &Manager{} + + tests := []struct { + name string + backupID string + wantError bool + }{ + { + name: "valid timestamp", + backupID: "backup-2026-02-18-143022", + wantError: false, + }, + { + name: "invalid format", + backupID: "invalid-format", + wantError: true, + }, + { + name: "missing prefix", + backupID: "2026-02-18-143022", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestamp, err := manager.parseBackupTimestamp(tt.backupID) + + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotZero(t, timestamp) + } + }) + } +} diff --git a/internal/install/backup/orchestrator.go b/internal/install/backup/orchestrator.go new file mode 100644 index 000000000..78814d7e4 --- /dev/null +++ b/internal/install/backup/orchestrator.go @@ -0,0 +1,178 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "runtime" + + log "github.com/sirupsen/logrus" + + "github.com/newrelic/newrelic-cli/internal/install/types" +) + +// Orchestrator coordinates all backup operations +type Orchestrator struct { + manifest *types.DiscoveryManifest + options Options + detector *InstallationDetector + creator *Creator + manager *Manager +} + +// NewOrchestrator creates a new backup orchestrator +func NewOrchestrator(manifest *types.DiscoveryManifest, options Options, cliVersion string) (*Orchestrator, error) { + // Set defaults + if options.MaxBackups == 0 { + options.MaxBackups = 5 + } + + // Determine backup location + backupLocation := options.BackupLocation + if backupLocation == "" { + backupLocation = GetDefaultBackupLocation() + } + + log.WithFields(log.Fields{ + "backupLocation": backupLocation, + "skipBackup": options.SkipBackup, + "maxBackups": options.MaxBackups, + }).Debug("Initializing backup orchestrator") + + detector := NewInstallationDetector(manifest) + creator := NewCreator(backupLocation) + manager := NewManager(backupLocation, options.MaxBackups) + + return &Orchestrator{ + manifest: manifest, + options: options, + detector: detector, + creator: creator, + manager: manager, + }, nil +} + +// PerformBackup executes the backup workflow (recipe-agnostic) +// This method detects ALL existing New Relic configurations and backs them up +// before ANY recipe installation, regardless of which recipe is being installed +func (o *Orchestrator) PerformBackup(ctx context.Context, cliVersion string) (*Result, error) { + // 1. Check if backup is skipped + if o.options.SkipBackup { + log.Info("Backup skipped by user option") + return nil, nil + } + + // 2. Detect ALL existing New Relic installations (recipe-agnostic) + // This scans for config files across ALL agent types (Infrastructure, APM, Logging, Integrations) + installInfo, err := o.detector.DetectExistingInstallation(ctx) + if err != nil { + log.WithError(err).Warn("Failed to detect existing installations") + return nil, nil // Don't fail installation on detection error + } + + if !installInfo.IsInstalled || len(installInfo.ConfigFiles) == 0 { + log.Info("No existing New Relic installations detected, skipping backup") + return nil, nil + } + + log.WithFields(log.Fields{ + "configFiles": len(installInfo.ConfigFiles), + "platform": runtime.GOOS, + }).Infof("Detected %d New Relic config files across all agent types", len(installInfo.ConfigFiles)) + + // 3. Create backup of ALL detected configs + // Works the same regardless of which recipe is being installed + result, err := o.creator.CreateBackup(ctx, installInfo.ConfigFiles, runtime.GOOS, cliVersion) + if err != nil { + log.WithError(err).Warn("Backup failed. Installation will continue.") + return result, nil // Don't fail installation on backup error + } + + if !result.Success { + log.Warn("Backup completed with errors. Installation will continue.") + if len(result.Warnings) > 0 { + for _, warning := range result.Warnings { + log.Warn(warning) + } + } + return result, nil + } + + // 4. Rotate old backups + if err := o.manager.RotateBackups(ctx); err != nil { + log.WithError(err).Warn("Failed to rotate old backups") + // Don't fail on rotation error + } + + return result, nil +} + +// GetDefaultBackupLocation returns the platform-specific default backup location +func GetDefaultBackupLocation() string { + var baseDir string + + switch runtime.GOOS { + case "linux": + // Check if running as root + if os.Geteuid() == 0 { + baseDir = "/opt/.newrelic-backups" + } else { + homeDir, err := os.UserHomeDir() + if err != nil { + baseDir = "/tmp/.newrelic-backups" + } else { + baseDir = filepath.Join(homeDir, ".newrelic-backups") + } + } + + case "windows": + programData := os.Getenv("ProgramData") + if programData == "" { + programData = "C:\\ProgramData" + } + baseDir = filepath.Join(programData, ".newrelic-backups") + + case "darwin": + homeDir, err := os.UserHomeDir() + if err != nil { + baseDir = "/tmp/.newrelic-backups" + } else { + baseDir = filepath.Join(homeDir, ".newrelic-backups") + } + + default: + // Fallback for unknown platforms + homeDir, err := os.UserHomeDir() + if err != nil { + baseDir = "/tmp/.newrelic-backups" + } else { + baseDir = filepath.Join(homeDir, ".newrelic-backups") + } + } + + return baseDir +} + +// ListBackups returns all available backups +func (o *Orchestrator) ListBackups() ([]*Manifest, error) { + return o.manager.ListBackups() +} + +// RestoreBackup restores a specific backup +func (o *Orchestrator) RestoreBackup(ctx context.Context, backupID string, verifyChecksums bool) error { + backupLocation := o.options.BackupLocation + if backupLocation == "" { + backupLocation = GetDefaultBackupLocation() + } + + restorer := NewRestorer(backupLocation) + return restorer.RestoreBackup(ctx, backupID, verifyChecksums) +} + +// GetBackupLocation returns the configured backup location +func (o *Orchestrator) GetBackupLocation() string { + if o.options.BackupLocation != "" { + return o.options.BackupLocation + } + return GetDefaultBackupLocation() +} diff --git a/internal/install/backup/orchestrator_test.go b/internal/install/backup/orchestrator_test.go new file mode 100644 index 000000000..631a1e3c7 --- /dev/null +++ b/internal/install/backup/orchestrator_test.go @@ -0,0 +1,106 @@ +//go:build unit + +package backup + +import ( + "context" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/newrelic/newrelic-cli/internal/install/types" +) + +func TestOrchestrator_PerformBackup_SkipBackup(t *testing.T) { + manifest := &types.DiscoveryManifest{} + options := Options{ + SkipBackup: true, + MaxBackups: 5, + } + + orchestrator, err := NewOrchestrator(manifest, options, "v0.106.23") + require.NoError(t, err) + + result, err := orchestrator.PerformBackup(context.Background(), "v0.106.23") + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestOrchestrator_PerformBackup_NoExistingInstallation(t *testing.T) { + // Use a manifest with no existing files + manifest := &types.DiscoveryManifest{} + + backupBaseDir := t.TempDir() + options := Options{ + SkipBackup: false, + BackupLocation: backupBaseDir, + MaxBackups: 5, + } + + orchestrator, err := NewOrchestrator(manifest, options, "v0.106.23") + require.NoError(t, err) + + // Note: The detector scans the real system and may find existing NR config files + // This is expected behavior - the backup system is recipe-agnostic and detects all NR configs + result, err := orchestrator.PerformBackup(context.Background(), "v0.106.23") + require.NoError(t, err) + // Result may be nil (no configs found) or have a backup (configs found on system) + // Both are valid outcomes depending on the test environment + if result != nil { + assert.True(t, result.Success) + } +} + +func TestGetDefaultBackupLocation(t *testing.T) { + location := GetDefaultBackupLocation() + + assert.NotEmpty(t, location) + assert.Contains(t, location, ".newrelic-backups") + + // Platform-specific checks + switch runtime.GOOS { + case "linux": + // Should contain either /opt or home directory + assert.True(t, filepath.IsAbs(location)) + case "windows": + // Should be in ProgramData or similar + assert.True(t, filepath.IsAbs(location)) + case "darwin": + // Should be in home directory + assert.Contains(t, location, ".newrelic-backups") + } +} + +func TestOrchestrator_GetBackupLocation(t *testing.T) { + manifest := &types.DiscoveryManifest{} + + t.Run("custom location", func(t *testing.T) { + customPath := "/custom/backup/path" + options := Options{ + BackupLocation: customPath, + MaxBackups: 5, + } + + orchestrator, err := NewOrchestrator(manifest, options, "v0.106.23") + require.NoError(t, err) + + location := orchestrator.GetBackupLocation() + assert.Equal(t, customPath, location) + }) + + t.Run("default location", func(t *testing.T) { + options := Options{ + MaxBackups: 5, + } + + orchestrator, err := NewOrchestrator(manifest, options, "v0.106.23") + require.NoError(t, err) + + location := orchestrator.GetBackupLocation() + assert.NotEmpty(t, location) + assert.Contains(t, location, ".newrelic-backups") + }) +} diff --git a/internal/install/backup/restorer.go b/internal/install/backup/restorer.go new file mode 100644 index 000000000..836e89d48 --- /dev/null +++ b/internal/install/backup/restorer.go @@ -0,0 +1,202 @@ +package backup + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// Restorer handles restoring configurations from backups +type Restorer struct { + baseBackupDir string + manager *Manager +} + +// NewRestorer creates a new backup restorer +func NewRestorer(baseBackupDir string) *Restorer { + return &Restorer{ + baseBackupDir: baseBackupDir, + manager: NewManager(baseBackupDir, 5), + } +} + +// RestoreBackup restores files from a backup +func (r *Restorer) RestoreBackup(ctx context.Context, backupID string, verifyChecksums bool) error { + log.WithFields(log.Fields{ + "backupID": backupID, + "verifyChecksums": verifyChecksums, + }).Info("Starting backup restoration") + + // Read manifest + manifest, err := r.manager.GetManifest(backupID) + if err != nil { + return fmt.Errorf("failed to read backup manifest: %w", err) + } + + // Verify backup integrity if requested + if verifyChecksums { + valid, warnings := r.verifyBackupIntegrity(manifest) + if !valid { + return fmt.Errorf("backup integrity check failed: %v", warnings) + } + if len(warnings) > 0 { + for _, warning := range warnings { + log.Warn(warning) + } + } + } + + // Restore each file + restored := 0 + failed := 0 + + for _, file := range manifest.Files { + if err := r.restoreFile(file); err != nil { + log.WithError(err).Errorf("Failed to restore file: %s", file.OriginalPath) + failed++ + } else { + restored++ + } + } + + if failed > 0 { + return fmt.Errorf("restoration completed with errors: %d restored, %d failed", restored, failed) + } + + log.WithFields(log.Fields{ + "backupID": backupID, + "restored": restored, + }).Info("Backup restored successfully") + + return nil +} + +// verifyBackupIntegrity verifies checksums of backed up files +func (r *Restorer) verifyBackupIntegrity(manifest *Manifest) (bool, []string) { + log.Info("Verifying backup integrity") + + var warnings []string + allValid := true + + for _, file := range manifest.Files { + // Check if backup file exists + if _, err := os.Stat(file.BackupPath); os.IsNotExist(err) { + warning := fmt.Sprintf("Backup file missing: %s", file.BackupPath) + warnings = append(warnings, warning) + allValid = false + continue + } + + // Verify checksum + checksum, err := r.calculateChecksum(file.BackupPath) + if err != nil { + warning := fmt.Sprintf("Failed to calculate checksum for %s: %v", file.BackupPath, err) + warnings = append(warnings, warning) + allValid = false + continue + } + + if checksum != file.SHA256Checksum { + warning := fmt.Sprintf("Checksum mismatch for %s", file.BackupPath) + warnings = append(warnings, warning) + allValid = false + } + } + + if allValid { + log.Info("Backup integrity verified successfully") + } else { + log.Warn("Backup integrity check failed") + } + + return allValid, warnings +} + +// restoreFile restores a single file from backup +func (r *Restorer) restoreFile(file BackedUpFile) error { + log.Debugf("Restoring file: %s", file.OriginalPath) + + // Open backup file + srcFile, err := os.Open(file.BackupPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer srcFile.Close() + + // Get backup file info + srcInfo, err := srcFile.Stat() + if err != nil { + return fmt.Errorf("failed to stat backup file: %w", err) + } + + // Create destination directory if it doesn't exist + dstDir := filepath.Dir(file.OriginalPath) + if err := os.MkdirAll(dstDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Create destination file + dstFile, err := os.OpenFile(file.OriginalPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + // Copy file + if _, err := io.Copy(dstFile, srcFile); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + // Restore permissions + if err := r.restorePermissions(file); err != nil { + log.WithError(err).Warnf("Failed to restore permissions for %s", file.OriginalPath) + // Don't fail restoration on permission errors + } + + log.Debugf("File restored successfully: %s", file.OriginalPath) + return nil +} + +// restorePermissions restores file permissions +func (r *Restorer) restorePermissions(file BackedUpFile) error { + // Parse permission string + // Unix format: "0640" (octal) + // Windows format: "RW" (ignored on Windows as chmod doesn't work the same way) + + if len(file.Permissions) == 0 { + return nil + } + + // Try to parse as octal (Unix) + if perm, err := strconv.ParseUint(file.Permissions, 8, 32); err == nil { + if err := os.Chmod(file.OriginalPath, os.FileMode(perm)); err != nil { + return fmt.Errorf("failed to set permissions: %w", err) + } + } + // Windows permissions are descriptive only, chmod may not work as expected + + return nil +} + +// calculateChecksum calculates SHA256 checksum of a file +func (r *Restorer) calculateChecksum(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/internal/install/backup/types.go b/internal/install/backup/types.go new file mode 100644 index 000000000..a84a51f0e --- /dev/null +++ b/internal/install/backup/types.go @@ -0,0 +1,45 @@ +package backup + +import "time" + +// Manifest contains metadata about a backup +type Manifest struct { + Timestamp time.Time `json:"timestamp"` + BackupID string `json:"backupId"` // backup-2026-02-18-143022 + Platform string `json:"platform"` // linux, windows, darwin + Files []BackedUpFile `json:"files"` + Reason string `json:"reason"` // "guided-install" + CLIVersion string `json:"cliVersion"` +} + +// BackedUpFile represents a single backed up file +type BackedUpFile struct { + OriginalPath string `json:"originalPath"` + BackupPath string `json:"backupPath"` + SHA256Checksum string `json:"sha256Checksum"` + Size int64 `json:"size"` + Permissions string `json:"permissions"` // Unix: "0640", Windows: "RW" +} + +// Options controls backup behavior +type Options struct { + SkipBackup bool + BackupLocation string + MaxBackups int +} + +// Result contains the outcome of a backup operation +type Result struct { + BackupDir string + ManifestPath string + FilesBackedUp int + Success bool + Error error + Warnings []string +} + +// InstallationInfo contains detected installation details +type InstallationInfo struct { + IsInstalled bool + ConfigFiles []string +} diff --git a/internal/install/command.go b/internal/install/command.go index 96b52364b..24bfb6b2d 100644 --- a/internal/install/command.go +++ b/internal/install/command.go @@ -1,6 +1,7 @@ package install import ( + "context" "errors" "fmt" "os" @@ -13,6 +14,7 @@ import ( "github.com/newrelic/newrelic-cli/internal/client" "github.com/newrelic/newrelic-cli/internal/config" configAPI "github.com/newrelic/newrelic-cli/internal/config/api" + "github.com/newrelic/newrelic-cli/internal/install/backup" "github.com/newrelic/newrelic-cli/internal/install/types" "github.com/newrelic/newrelic-cli/internal/utils" nrErrors "github.com/newrelic/newrelic-client-go/v2/pkg/errors" @@ -20,11 +22,15 @@ import ( ) var ( - assumeYes bool - localRecipes string - recipeNames []string - recipePaths []string - tags []string + assumeYes bool + localRecipes string + recipeNames []string + recipePaths []string + tags []string + skipBackup bool + backupLocation string + listBackups bool + restoreBackup string ) // processRecipeNames validates, extracts recipe names, and sets environment variables. @@ -63,16 +69,28 @@ var Command = &cobra.Command{ Short: "Install New Relic.", PreRun: client.RequireClient, RunE: func(cmd *cobra.Command, args []string) error { + // Handle --list-backups + if listBackups { + return handleListBackups() + } + + // Handle --restore-backup + if restoreBackup != "" { + return handleRestoreBackup(restoreBackup) + } + extractedRecipeNames, err := processRecipeNames(recipeNames) if err != nil { return types.NewDetailError(types.EventTypes.OtherError, err.Error()) } ic := types.InstallerContext{ - AssumeYes: assumeYes, - LocalRecipes: localRecipes, - RecipeNames: extractedRecipeNames, - RecipePaths: recipePaths, + AssumeYes: assumeYes, + LocalRecipes: localRecipes, + RecipeNames: extractedRecipeNames, + RecipePaths: recipePaths, + SkipBackup: skipBackup, + BackupLocation: backupLocation, } ic.SetTags(tags) @@ -140,6 +158,12 @@ func init() { Command.Flags().BoolVarP(&assumeYes, "assumeYes", "y", false, "use \"yes\" for all questions during install") Command.Flags().StringVarP(&localRecipes, "localRecipes", "", "", "a path to local recipes to load instead of service other fetching") Command.Flags().StringSliceVarP(&tags, "tag", "", []string{}, "the tags to add during install, can be multiple. Example: --tag tag1:test,tag2:test") + + // Backup flags + Command.Flags().BoolVarP(&skipBackup, "skip-backup", "", false, "skip backing up existing configuration files (useful for CI/CD)") + Command.Flags().StringVarP(&backupLocation, "backup-location", "", "", "custom location for backup files (default: platform-specific)") + Command.Flags().BoolVarP(&listBackups, "list-backups", "", false, "list all available configuration backups and exit") + Command.Flags().StringVarP(&restoreBackup, "restore-backup", "", "", "restore configuration from a specific backup ID (e.g., backup-2026-02-18-143022)") } func validateProfile() *types.DetailError { @@ -297,3 +321,64 @@ func fetchLicenseKeyFromProfile() string { return "" } + +func handleListBackups() error { + backupLocation := backupLocation + if backupLocation == "" { + backupLocation = backup.GetDefaultBackupLocation() + } + + manager := backup.NewManager(backupLocation, 5) + backups, err := manager.ListBackups() + if err != nil { + return fmt.Errorf("failed to list backups: %w", err) + } + + if len(backups) == 0 { + fmt.Println("No backups found.") + return nil + } + + fmt.Printf("Found %d backups:\n\n", len(backups)) + for _, b := range backups { + fmt.Printf(" %s\n", b.BackupID) + fmt.Printf(" Date: %s\n", b.Timestamp.Format("2006-01-02 15:04:05")) + fmt.Printf(" Platform: %s\n", b.Platform) + fmt.Printf(" Files: %d\n", len(b.Files)) + fmt.Printf(" CLI Version: %s\n\n", b.CLIVersion) + } + + return nil +} + +func handleRestoreBackup(backupID string) error { + backupLocation := backupLocation + if backupLocation == "" { + backupLocation = backup.GetDefaultBackupLocation() + } + + restorer := backup.NewRestorer(backupLocation) + + fmt.Printf("⚠️ This will overwrite current configuration files with backup: %s\n", backupID) + + if !assumeYes { + fmt.Print("Continue? (y/N): ") + + var response string + if _, err := fmt.Scanln(&response); err != nil { + fmt.Println("Restore canceled.") + return nil + } + if response != "y" && response != "Y" { + fmt.Println("Restore canceled.") + return nil + } + } + + if err := restorer.RestoreBackup(context.Background(), backupID, true); err != nil { + return fmt.Errorf("restore failed: %w", err) + } + + fmt.Println("✅ Configuration restored successfully.") + return nil +} diff --git a/internal/install/recipe_installer.go b/internal/install/recipe_installer.go index 51ba26a81..6d0f7e743 100644 --- a/internal/install/recipe_installer.go +++ b/internal/install/recipe_installer.go @@ -18,6 +18,7 @@ import ( "github.com/newrelic/newrelic-cli/internal/config" configAPI "github.com/newrelic/newrelic-cli/internal/config/api" "github.com/newrelic/newrelic-cli/internal/diagnose" + "github.com/newrelic/newrelic-cli/internal/install/backup" "github.com/newrelic/newrelic-cli/internal/install/discovery" "github.com/newrelic/newrelic-cli/internal/install/execution" "github.com/newrelic/newrelic-cli/internal/install/recipes" @@ -311,6 +312,12 @@ func (i *RecipeInstall) install(ctx context.Context) error { return err } + // Backup existing configurations before installation + if err := i.backupExistingConfigs(ctx, m); err != nil { + // Log but continue - backup failures shouldn't block installation + log.Warnf("Configuration backup failed: %s", err) + } + repo := recipes.NewRecipeRepository(func() ([]*types.OpenInstallationRecipe, error) { fetchRecipes, err2 := i.recipeFetcher.FetchRecipes(ctx) return fetchRecipes, err2 @@ -568,6 +575,38 @@ func (i *RecipeInstall) discover(ctx context.Context) (*types.DiscoveryManifest, return m, nil } +func (i *RecipeInstall) backupExistingConfigs(ctx context.Context, manifest *types.DiscoveryManifest) error { + backupOpts := backup.Options{ + SkipBackup: i.InstallerContext.SkipBackup, + BackupLocation: i.InstallerContext.BackupLocation, + MaxBackups: 5, + } + + orchestrator, err := backup.NewOrchestrator(manifest, backupOpts, cli.Version()) + if err != nil { + return err + } + + i.progressIndicator.Start("Checking for existing New Relic configurations...") + + result, err := orchestrator.PerformBackup(ctx, cli.Version()) + if err != nil { + i.progressIndicator.Stop() + return err + } + + if result != nil && result.Success { + i.progressIndicator.Success(fmt.Sprintf("Existing New Relic configuration detected. Backed up to: %s", result.BackupDir)) + log.Infof("Backed up %d config files. Manifest: %s", result.FilesBackedUp, result.ManifestPath) + } else if result != nil && !result.Success { + i.progressIndicator.Fail("Configuration backup failed, continuing installation. Check logs for details.") + } else { + i.progressIndicator.Stop() + } + + return nil +} + func (i *RecipeInstall) reportUnsupportedTargetedRecipes(bundle *recipes.Bundle, repo *recipes.RecipeRepository) { for _, recipeName := range i.RecipeNames { br := bundle.GetBundleRecipe(recipeName) diff --git a/internal/install/types/install_context.go b/internal/install/types/install_context.go index 2916a854b..3fe3f6f55 100644 --- a/internal/install/types/install_context.go +++ b/internal/install/types/install_context.go @@ -22,6 +22,10 @@ type InstallerContext struct { // LocalRecipes is the path to a local recipe directory from which to load recipes. LocalRecipes string deployedBy string + + // Backup configuration + SkipBackup bool + BackupLocation string } func (i *InstallerContext) RecipePathsProvided() bool {