package backendupdate import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "yms-daemon/internal/filestore" "yms-daemon/internal/hostnginx" "yms-daemon/internal/systemd" "yms-daemon/internal/transaction" ) type gatewayOperation struct { controller gatewayController before hostnginx.Snapshot after hostnginx.Snapshot receiptPath string } func (o *gatewayOperation) Apply(ctx context.Context) error { if err := o.controller.Apply(ctx, o.after); err != nil { return err } return writeImmutableFile(o.receiptPath, []byte(snapshotDigest(o.after)), 0o600) } func (o *gatewayOperation) Inspect(context.Context) (transaction.Inspection, error) { current, err := o.controller.Read() if err != nil { return transaction.Inspection{}, err } receipt, receiptErr := os.ReadFile(o.receiptPath) if current.ActivePort == o.after.ActivePort && bytes.Equal(current.Content, o.after.Content) { if receiptErr == nil && string(receipt) == snapshotDigest(o.after) { return transaction.Inspection{Status: transaction.InspectionApplied, Result: gatewayResult(current)}, nil } if errors.Is(receiptErr, os.ErrNotExist) { return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: gatewayResult(current)}, nil } if receiptErr != nil { return transaction.Inspection{}, fmt.Errorf("read host Nginx switch receipt: %w", receiptErr) } return transaction.Inspection{Status: transaction.InspectionUnknown, Result: gatewayResult(current)}, nil } if current.ActivePort == o.before.ActivePort && bytes.Equal(current.Content, o.before.Content) { return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: gatewayResult(current)}, nil } return transaction.Inspection{Status: transaction.InspectionUnknown, Result: gatewayResult(current)}, nil } type pathOperation struct { path string before pathState desired pathState } func (o *pathOperation) Apply(context.Context) error { return applyPathState(o.path, o.desired) } func (o *pathOperation) Inspect(context.Context) (transaction.Inspection, error) { desired, err := pathMatches(o.path, o.desired) if err != nil { return transaction.Inspection{}, err } if desired { return transaction.Inspection{Status: transaction.InspectionApplied, Result: pathResult(o.path, o.desired)}, nil } before, err := pathMatches(o.path, o.before) if err != nil { return transaction.Inspection{}, err } if before { return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: pathResult(o.path, o.before)}, nil } return transaction.Inspection{Status: transaction.InspectionUnknown}, nil } type unitStopOperation struct { units systemd.Manager name string } func (o *unitStopOperation) Apply(ctx context.Context) error { return o.units.Stop(ctx, o.name) } func (o *unitStopOperation) Inspect(ctx context.Context) (transaction.Inspection, error) { unit, err := o.units.Inspect(ctx, o.name) if err != nil { return transaction.Inspection{}, err } result, err := json.Marshal(unit) if err != nil { return transaction.Inspection{}, err } // systemd may leave a successfully stopped legacy service in failed when // its tracked JVM exits with SIGTERM (status 143). Both states // mean no process is active, which is the required result of this step. if unit.ActiveState == "inactive" || unit.ActiveState == "failed" { return transaction.Inspection{Status: transaction.InspectionApplied, Result: result}, nil } if unitRunning(unit) { return transaction.Inspection{Status: transaction.InspectionNotApplied, Result: result}, nil } return transaction.Inspection{Status: transaction.InspectionUnknown, Result: result}, nil } func snapshotPath(path string, backupPath string) (pathState, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return pathState{Kind: pathKindAbsent}, nil } if err != nil { return pathState{}, fmt.Errorf("inspect compatibility path %s: %w", path, err) } if info.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(path) if err != nil { return pathState{}, fmt.Errorf("read compatibility link %s: %w", path, err) } return pathState{Kind: pathKindSymlink, Target: target}, nil } if !info.Mode().IsRegular() { return pathState{}, fmt.Errorf("compatibility path is neither a regular file nor symbolic link: %s", path) } identity, err := copyFileSnapshot(path, backupPath, info.Mode().Perm()) if err != nil { return pathState{}, err } return pathState{Kind: pathKindRegular, BackupPath: backupPath, Identity: identity, Mode: uint32(info.Mode().Perm())}, nil } func applyPathState(path string, state pathState) error { parent := filepath.Dir(path) info, err := os.Lstat(parent) if err != nil { return fmt.Errorf("inspect path state parent %s: %w", parent, err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("path state parent is not a direct directory: %s", parent) } switch state.Kind { case pathKindAbsent: if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove path state %s: %w", path, err) } return syncDirectory(parent) case pathKindSymlink: if !filepath.IsAbs(state.Target) { return errors.New("path state symbolic link target must be absolute") } temporary := filepath.Join(parent, ".yms-daemon-link-"+rand.Text()) if err := os.Symlink(state.Target, temporary); err != nil { return fmt.Errorf("create temporary compatibility link: %w", err) } defer os.Remove(temporary) if err := os.Rename(temporary, path); err != nil { return fmt.Errorf("replace compatibility link %s: %w", path, err) } return syncDirectory(parent) case pathKindRegular: if err := verifyFileIdentity(state.BackupPath, state.Identity); err != nil { return fmt.Errorf("verify compatibility file snapshot: %w", err) } return copyFileAtomic(state.BackupPath, path, os.FileMode(state.Mode)) default: return fmt.Errorf("unsupported path state kind: %q", state.Kind) } } func pathMatches(path string, state pathState) (bool, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return state.Kind == pathKindAbsent, nil } if err != nil { return false, err } switch state.Kind { case pathKindAbsent: return false, nil case pathKindSymlink: if info.Mode()&os.ModeSymlink == 0 { return false, nil } target, err := os.Readlink(path) return target == state.Target, err case pathKindRegular: if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || uint32(info.Mode().Perm()) != state.Mode { return false, nil } if err := verifyFileIdentity(path, state.Identity); err != nil { return false, nil } return true, nil default: return false, fmt.Errorf("unsupported path state kind: %q", state.Kind) } } func copyFileSnapshot(sourcePath string, destinationPath string, mode os.FileMode) (filestore.Identity, error) { if err := os.MkdirAll(filepath.Dir(destinationPath), 0o750); err != nil { return filestore.Identity{}, err } source, err := os.Open(sourcePath) if err != nil { return filestore.Identity{}, err } defer source.Close() temporary, err := os.CreateTemp(filepath.Dir(destinationPath), ".snapshot-*") if err != nil { return filestore.Identity{}, err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { _ = temporary.Close() return filestore.Identity{}, err } digest := sha256.New() size, copyErr := io.Copy(io.MultiWriter(temporary, digest), source) if copyErr != nil { _ = temporary.Close() return filestore.Identity{}, copyErr } if err := temporary.Sync(); err != nil { _ = temporary.Close() return filestore.Identity{}, err } if err := temporary.Close(); err != nil { return filestore.Identity{}, err } if err := os.Rename(temporaryPath, destinationPath); err != nil { return filestore.Identity{}, err } if err := syncDirectory(filepath.Dir(destinationPath)); err != nil { return filestore.Identity{}, err } return filestore.Identity{Size: size, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil } func copyFileAtomic(sourcePath string, destinationPath string, mode os.FileMode) error { source, err := os.Open(sourcePath) if err != nil { return err } defer source.Close() parent := filepath.Dir(destinationPath) temporary, err := os.CreateTemp(parent, ".restore-*") if err != nil { return err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { _ = temporary.Close() return err } if _, err := io.Copy(temporary, source); err != nil { _ = temporary.Close() return err } if err := temporary.Sync(); err != nil { _ = temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := os.Rename(temporaryPath, destinationPath); err != nil { return err } return syncDirectory(parent) } func writeImmutableFile(path string, content []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) if errors.Is(err, os.ErrExist) { existing, readErr := os.ReadFile(path) if readErr != nil { return readErr } if !bytes.Equal(existing, content) { return fmt.Errorf("immutable file already exists with different content: %s", path) } return nil } if err != nil { return err } complete := false defer func() { if !complete { _ = os.Remove(path) } }() if _, err := file.Write(content); err != nil { _ = file.Close() return err } if err := file.Sync(); err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } if err := syncDirectory(filepath.Dir(path)); err != nil { return err } complete = true return nil } func readGatewaySnapshot(path string, port int) (hostnginx.Snapshot, error) { content, err := os.ReadFile(path) if err != nil { return hostnginx.Snapshot{}, err } actual, err := hostnginx.ActiveBackendPort(content) if err != nil { return hostnginx.Snapshot{}, err } if actual != port { return hostnginx.Snapshot{}, fmt.Errorf("persisted host Nginx snapshot port mismatch: got %d, want %d", actual, port) } return hostnginx.Snapshot{Content: content, ActivePort: port}, nil } func verifyFileIdentity(path string, identity filestore.Identity) error { if err := identity.Validate(); err != nil { return err } file, err := os.Open(path) if err != nil { return err } digest := sha256.New() size, copyErr := io.Copy(digest, file) closeErr := file.Close() if err := errors.Join(copyErr, closeErr); err != nil { return err } if size != identity.Size || !strings.EqualFold(hex.EncodeToString(digest.Sum(nil)), identity.SHA256) { return errors.New("file identity mismatch") } return nil } func snapshotDigest(snapshot hostnginx.Snapshot) string { digest := sha256.Sum256(snapshot.Content) return hex.EncodeToString(digest[:]) } func gatewayResult(snapshot hostnginx.Snapshot) json.RawMessage { result, _ := json.Marshal(struct { ActivePort int `json:"activePort"` SHA256 string `json:"sha256"` }{snapshot.ActivePort, snapshotDigest(snapshot)}) return result } func pathResult(path string, state pathState) json.RawMessage { result, _ := json.Marshal(struct { Path string `json:"path"` State pathState `json:"state"` }{path, state}) return result } func syncDirectory(directory string) error { file, err := os.Open(directory) if err != nil { return err } syncErr := file.Sync() closeErr := file.Close() return errors.Join(syncErr, closeErr) }