fix(cli): harden backup publication and quiescing
This commit is contained in:
@@ -70,12 +70,13 @@ type archiveRunner interface {
|
||||
}
|
||||
|
||||
type dependencies struct {
|
||||
runner archiveRunner
|
||||
now func() time.Time
|
||||
homeDir func() (string, error)
|
||||
revision func(context.Context, string) (string, error)
|
||||
sleep func(time.Duration)
|
||||
rename func(string, string) error
|
||||
runner archiveRunner
|
||||
now func() time.Time
|
||||
homeDir func() (string, error)
|
||||
revision func(context.Context, string) (string, error)
|
||||
sleep func(time.Duration)
|
||||
reserveOutput func(string) (*archiveReservation, error)
|
||||
publishReserved func(*archiveReservation, string) error
|
||||
}
|
||||
|
||||
// Create creates an archive with the real Docker command boundary. It performs no shell
|
||||
@@ -94,13 +95,14 @@ func Create(ctx context.Context, installation config.Installation, request Creat
|
||||
}
|
||||
return strings.TrimSpace(string(value)), nil
|
||||
},
|
||||
sleep: time.Sleep,
|
||||
rename: os.Rename,
|
||||
sleep: time.Sleep,
|
||||
reserveOutput: reserveArchiveOutput,
|
||||
publishReserved: publishReservedArchive,
|
||||
})
|
||||
}
|
||||
|
||||
func createWithDependencies(ctx context.Context, installation config.Installation, request CreateRequest, dependencies dependencies) (result Result, resultErr error) {
|
||||
if dependencies.runner == nil || dependencies.now == nil || dependencies.homeDir == nil || dependencies.revision == nil || dependencies.sleep == nil || dependencies.rename == nil {
|
||||
if dependencies.runner == nil || dependencies.now == nil || dependencies.homeDir == nil || dependencies.revision == nil || dependencies.sleep == nil || dependencies.reserveOutput == nil || dependencies.publishReserved == nil {
|
||||
return Result{}, errors.New("backup dependencies are incomplete")
|
||||
}
|
||||
if request.IncludeSecrets && !request.Confirm {
|
||||
@@ -142,6 +144,16 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
if err := ensureNewArchivePath(output); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
reservation, err := dependencies.reserveOutput(output)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
published := false
|
||||
defer func() {
|
||||
if !published {
|
||||
_ = reservation.RemoveIfOwned()
|
||||
}
|
||||
}()
|
||||
|
||||
rendered, err := renderedConfiguration(ctx, installation, dependencies.runner)
|
||||
if err != nil {
|
||||
@@ -201,9 +213,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
Images: images,
|
||||
Volumes: volumes,
|
||||
}
|
||||
if err := writeArchive(ctx, output, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
|
||||
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
published = true
|
||||
if wasRunning {
|
||||
if err := composeStartAndVerify(ctx, installation, dependencies.runner); err != nil {
|
||||
return Result{}, err
|
||||
@@ -286,15 +299,90 @@ func ensureNewArchivePath(output string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil {
|
||||
return fmt.Errorf("create backup directory: %w", err)
|
||||
}
|
||||
if info, err := os.Lstat(output); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
if err == nil && info.Mode().IsRegular() {
|
||||
return errors.New("backup output already exists")
|
||||
}
|
||||
return errors.New("backup output path is unavailable")
|
||||
return nil
|
||||
}
|
||||
|
||||
// archiveReservation claims an output path without replacing an existing archive. Its file
|
||||
// identity is retained so cleanup never removes a path another process took over.
|
||||
type archiveReservation struct {
|
||||
path string
|
||||
file *os.File
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func reserveArchiveOutput(output string) (*archiveReservation, error) {
|
||||
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return nil, errors.New("backup output already exists")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reserve backup output: %w", err)
|
||||
}
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("inspect reserved backup output: %w", statErr)
|
||||
}
|
||||
return &archiveReservation{path: output, file: file, info: info}, nil
|
||||
}
|
||||
|
||||
func publishReservedArchive(reservation *archiveReservation, temporaryPath string) error {
|
||||
if reservation == nil || reservation.file == nil {
|
||||
return errors.New("backup output reservation is unavailable")
|
||||
}
|
||||
temporary, err := os.Open(temporaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open temporary backup archive: %w", err)
|
||||
}
|
||||
defer temporary.Close()
|
||||
if err := reservation.file.Truncate(0); err != nil {
|
||||
return fmt.Errorf("prepare reserved backup output: %w", err)
|
||||
}
|
||||
if _, err := reservation.file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("seek reserved backup output: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(reservation.file, temporary); err != nil {
|
||||
return fmt.Errorf("write reserved backup output: %w", err)
|
||||
}
|
||||
if err := reservation.file.Sync(); err != nil {
|
||||
return fmt.Errorf("fsync reserved backup output: %w", err)
|
||||
}
|
||||
if err := reservation.file.Close(); err != nil {
|
||||
return fmt.Errorf("close reserved backup output: %w", err)
|
||||
}
|
||||
reservation.file = nil
|
||||
current, err := os.Stat(reservation.path)
|
||||
if err != nil || !os.SameFile(reservation.info, current) {
|
||||
return errors.New("backup output ownership changed before publication")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveIfOwned removes the reservation only when the output path still names the exact file
|
||||
// created by this invocation. It is safe to call after another process has claimed the path.
|
||||
func (reservation *archiveReservation) RemoveIfOwned() error {
|
||||
if reservation == nil {
|
||||
return nil
|
||||
}
|
||||
if reservation.file != nil {
|
||||
if err := reservation.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
reservation.file = nil
|
||||
}
|
||||
current, err := os.Stat(reservation.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(reservation.info, current) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(reservation.path)
|
||||
}
|
||||
|
||||
type renderedCompose struct {
|
||||
Volumes map[string]struct {
|
||||
Name string `json:"name"`
|
||||
@@ -398,11 +486,74 @@ func imageIdentities(ctx context.Context, installation config.Installation, runn
|
||||
}
|
||||
|
||||
func installationRunning(ctx context.Context, installation config.Installation, runner archiveRunner) (bool, error) {
|
||||
result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--status", "running", "-q"), nil)
|
||||
result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
|
||||
if err != nil {
|
||||
return false, dockerError("inspect running services", result, err)
|
||||
return false, dockerError("inspect service states", result, err)
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) != "", nil
|
||||
states, err := backupServiceStates(result.Stdout)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
running := false
|
||||
coreRunning := false
|
||||
for _, service := range states {
|
||||
switch service.State {
|
||||
case "exited", "dead":
|
||||
continue
|
||||
case "running":
|
||||
running = true
|
||||
if service.Service == "core" {
|
||||
coreRunning = true
|
||||
}
|
||||
default:
|
||||
return false, fmt.Errorf("service %q is %q and is not safely quiesced; stop the installation before backup", service.Service, service.State)
|
||||
}
|
||||
}
|
||||
if running && !coreRunning {
|
||||
return false, errors.New("core is not running while other services are active; stop the installation before backup")
|
||||
}
|
||||
return running, nil
|
||||
}
|
||||
|
||||
type backupServiceState struct {
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
}
|
||||
|
||||
func backupServiceStates(value string) ([]backupServiceState, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || trimmed == "[]" {
|
||||
return nil, nil
|
||||
}
|
||||
var array []backupServiceState
|
||||
if err := json.Unmarshal([]byte(trimmed), &array); err == nil {
|
||||
return normalizeBackupServiceStates(array)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(trimmed))
|
||||
var states []backupServiceState
|
||||
for {
|
||||
var state backupServiceState
|
||||
err := decoder.Decode(&state)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("Docker Compose returned invalid service states")
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
return normalizeBackupServiceStates(states)
|
||||
}
|
||||
|
||||
func normalizeBackupServiceStates(states []backupServiceState) ([]backupServiceState, error) {
|
||||
for index := range states {
|
||||
states[index].Service = strings.TrimSpace(states[index].Service)
|
||||
states[index].State = strings.ToLower(strings.TrimSpace(states[index].State))
|
||||
if states[index].Service == "" || states[index].State == "" {
|
||||
return nil, errors.New("Docker Compose returned invalid service states")
|
||||
}
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func maintenance(ctx context.Context, installation config.Installation, runner archiveRunner, activate bool) error {
|
||||
@@ -480,14 +631,13 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeArchive(ctx context.Context, output string, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
directory := filepath.Dir(output)
|
||||
temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary backup archive: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
published := false
|
||||
closedFile := false
|
||||
defer func() {
|
||||
var closeErr error
|
||||
@@ -497,10 +647,7 @@ func writeArchive(ctx context.Context, output string, installation config.Instal
|
||||
if closeErr != nil && resultErr == nil {
|
||||
resultErr = closeErr
|
||||
}
|
||||
if !published {
|
||||
_ = os.Remove(temporaryPath)
|
||||
_ = os.Remove(output)
|
||||
}
|
||||
_ = os.Remove(temporaryPath)
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return fmt.Errorf("protect temporary backup archive: %w", err)
|
||||
@@ -632,10 +779,9 @@ func writeArchive(ctx context.Context, output string, installation config.Instal
|
||||
return fmt.Errorf("close backup archive: %w", err)
|
||||
}
|
||||
closedFile = true
|
||||
if err := dependencies.rename(temporaryPath, output); err != nil {
|
||||
if err := dependencies.publishReserved(reservation, temporaryPath); err != nil {
|
||||
return fmt.Errorf("publish backup archive: %w", err)
|
||||
}
|
||||
published = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user