fleet/cmd/gitops-migrate/backup.go
Anthony Maxwell 288ea58bce
Feat: GitOps YAML Migration Tool (#32237)
# Overview

This pull request resolves #31165, implementing command-line tooling to
migrate GitOps YAML files following the [changes introduced in the
upcoming 4.74
release](https://github.com/fleetdm/fleet/pull/32237/files#diff-8769f6e90e8bdf15faad8f390fdf3ffb6fd2238b7d6087d83518c21464109119R7).

Aligning with the recommended steps in the `README`; [this is an example
of the first step](https://github.com/Illbjorn/fleet/pull/3/files)
(`gitops-migrate format`) and [this is an example of the second
step](https://github.com/Illbjorn/fleet/pull/4/files) (`gitops-migrate
migrate`).

---------

Signed-off-by: Illbjorn <am@hades.so>
Co-authored-by: Ian Littman <iansltx@gmail.com>
2025-09-08 12:42:25 -04:00

253 lines
6.4 KiB
Go

package main
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/fleetdm/fleet/v4/cmd/gitops-migrate/log"
)
// backup creates a backup of the path provided via 'from', to a gzipped tarball
// in the directory specified by 'to'.
func backup(ctx context.Context, from string, to string) (string, error) {
// Resolve and validate the backup archive output path.
output, err := resolveBackupTarget(to)
if err != nil {
return "", err
}
log.Info(
"Performing Fleet GitOps file backup.",
"Source", from,
"Destination", output,
)
// Get a writable handle to the output archive file.
//
//nolint:gosec,G304 // 'output' is a trusted input.
f, err := os.OpenFile(output, fileFlagsOverwrite, fileModeUserRW)
if err != nil {
return "", fmt.Errorf(
"failed to get a writable handle to archive file(%s): %w",
output, err,
)
}
// Init the gzip writer.
gz := gzip.NewWriter(f)
// Init the tar writer.
tw := tar.NewWriter(gz)
// Defer stream closure, in reverse order.
//
// This is a verbose chunk of code, but it's important to check each error
// here as the 'gzip.Writer' and 'tar.Writer' closures actually write critical
// trailer data to the file stream.
defer func() {
var errs error
// Close the output archive tar writer.
err = tw.Close()
if err != nil {
log.Errorf("Failed to close the backup archive tar writer: %s.", err)
}
errs = errors.Join(errs, err)
// Close the output archive gzip writer.
err = gz.Close()
if err != nil {
log.Errorf("Failed to close the backup archive gzip writer: %s.", err)
}
errs = errors.Join(errs, err)
// Close the output archive file stream.
err := f.Close()
if err != nil {
log.Errorf("Failed to close the backup archive file stream: %s.", err)
}
errs = errors.Join(errs, err)
// We want a good, clean backup before we proceed with hijacking all the
// targeted GitOps files. So, if we encounter any errors at all, close up
// shop.
if errs != nil {
log.Fatal("Errors encountered in backup archive stream close, exiting.")
}
}()
// Enumerate the file system, writing files to the tarball.
//
//nolint:wrapcheck // Not an external package error.
for file, err := range fsEnum(from) {
if err != nil {
return "", fmt.Errorf(
"encountered erorr in directory enumeration: %w",
err,
)
}
// Init the tar header for this file.
header, err := tar.FileInfoHeader(file.Stats, file.Path)
if err != nil {
return "", fmt.Errorf(
"failed to create tar header for file(%s): %w",
file.Path, err,
)
}
// Construct the relative file path.
filePathRelative := strings.TrimPrefix(file.Path, from)
filePathRelative = strings.TrimPrefix(
filePathRelative,
string(os.PathSeparator),
)
// Fix up the tar header.
//
// See 'tar.FileInfoHeader' docs for more on why we need to set this twice.
header.Name = filePathRelative
// If the item is a directory, zero the size.
if file.Stats.IsDir() {
header.Size = 0
}
// Write the tar header.
err = tw.WriteHeader(header)
if err != nil {
return "", fmt.Errorf(
"failed to write tar header to tar stream: %w", err,
)
}
// If the item is a directory, no further to-do here.
if file.Stats.IsDir() {
continue
}
log.Debug(
"Compressing file.",
"File Path", file.Path,
"Archive Path", filePathRelative,
)
// Get a readable handle to the file.
//
//nolint:gosec,G304 // 'path' is a trusted input.
f, err := os.Open(file.Path)
if err != nil {
return "", fmt.Errorf(
"failed to get a readable handle to file [%s] while performing backup: %w",
file.Path, err,
)
}
// Write the file content to the tar stream.
n, err := io.Copy(tw, f)
if err != nil {
return "", fmt.Errorf(
"failed to write file [%s] to tar stream during backup: %w",
file.Path, err,
)
}
// Ensure we wrote the content length we expect.
if n != file.Stats.Size() {
return "", fmt.Errorf(
"encountered no error during backup, however the stat'd "+
"file size(%d) didn't match the size we actually wrote(%d)",
file.Stats.Size(), n,
)
}
// Close the file stream.
err = f.Close()
if err != nil {
return "", fmt.Errorf(
"failed to close input file stream(%s) during backup: %w",
file.Path, err,
)
}
}
return output, nil
}
// resolveBackupTarget evaluates the type of path 'path' points to.
//
// If 'path' is a FILE, the parent directory is created if it doesn't exist and
// the path is returned unchanged.
//
// If 'path' is a DIRECTORY, the directory is created if it doesn't exist, a
// random file name is generated and concatenated to the end of 'path',
// finally returning the result.
func resolveBackupTarget(path string) (string, error) {
// Resolve the absolute file path.
path, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf(
"failed to identify absolute file path from [%s]: %w",
path, err,
)
}
// Attempt to identify if 'path' is a file or directory path by the presence
// of a file extension.
if filepath.Ext(path) == "" {
// 'path' is a directory.
return resolveBackupDirPath(path)
}
// 'path' is a file.
return resolveBackupFilePath(path)
}
func resolveBackupDirPath(path string) (string, error) {
// Create 'path' if it doesn't exist.
err := os.MkdirAll(path, fileModeUserRWX)
if err != nil && !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf(
"failed to create all or part of the provided path(%s): %w",
path, err,
)
}
now := time.Now()
// Generate a timestamped file name.
fileName := fmt.Sprintf(
"fleet-gitops-backup-%d-%d-%d_%d-%d-%d.tar.gz",
now.Month(), now.Day(), now.Year(), now.Hour(), now.Minute(), now.Second(),
)
// Concatenate the file name to the directory path and return.
return filepath.Join(path, fileName), nil
}
func resolveBackupFilePath(path string) (string, error) {
// Create 'path' if it doesn't exist.
err := os.MkdirAll(filepath.Dir(path), fileModeUserRWX)
if err != nil && !errors.Is(err, os.ErrExist) {
return "", fmt.Errorf(
"failed to create all or part of the provided path(%s): %w",
path, err,
)
}
// Return the file path, unchanged.
return path, nil
}
func mkBackupDir() (string, error) {
path, err := os.MkdirTemp(os.TempDir(), "fleet-gitops-*")
if err != nil {
return "", fmt.Errorf("failed to create temp backup directory: %w", err)
}
return path, nil
}