Skip to content
Draft
58 changes: 51 additions & 7 deletions docs/cli/newrelic_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
231 changes: 231 additions & 0 deletions internal/install/backup/creator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package backup

import (
"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(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),
}).Debug("Creating configuration backup")

// Create backup directory
if err := os.MkdirAll(backupDir, 0755); err != nil {
return &Result{
Success: false,
Error: fmt.Errorf("failed to create backup directory: %w", err),
}, err
}

result := &Result{
BackupID: backupID,
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),
}).Debug("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 func() { _ = 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, 0755); 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)
}

// Copy file and calculate checksum
checksum, size, copyErr := c.copyFileWithChecksum(srcFile, dstFile)
if closeErr := dstFile.Close(); closeErr != nil && copyErr == nil {
copyErr = fmt.Errorf("failed to close destination file: %w", closeErr)
}
if copyErr != nil {
return nil, fmt.Errorf("failed to copy file: %w", copyErr)
}

permissions := c.getFilePermissions(srcInfo)

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, 0644); 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"))
}
Loading
Loading