405 lines
16 KiB
Go
405 lines
16 KiB
Go
// Package nativebackendexecutor prepares and starts one explicitly configured native backend slot.
|
|
// Gateway switching is deliberately outside this package.
|
|
package nativebackendexecutor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"yms-daemon/internal/filestore"
|
|
"yms-daemon/internal/healthcheck"
|
|
"yms-daemon/internal/systemd"
|
|
"yms-daemon/internal/transaction"
|
|
)
|
|
|
|
const (
|
|
healthPath = "/yms/actuator/health"
|
|
healthTimeout = 120 * time.Second
|
|
healthInterval = time.Second
|
|
stepInstallJar = "backend.native.jar.install"
|
|
stepBindSlot = "backend.native.slot.bind"
|
|
stepStartUnit = "backend.native.service.start"
|
|
stepCheckHealth = "backend.native.health"
|
|
stepStopUnit = "backend.native.service.stop"
|
|
stepRestoreSlot = "backend.native.slot.restore"
|
|
activeState = "active"
|
|
inactiveState = "inactive"
|
|
failedState = "failed"
|
|
)
|
|
|
|
// Request contains exact values from the immutable update request and local deployment configuration.
|
|
// UnitName, SlotJarPath and PreviousSlotTarget are opaque and are never derived from filenames or ports.
|
|
type Request struct {
|
|
ArtifactPath string
|
|
ArtifactIdentity filestore.Identity
|
|
ReleasePath string
|
|
SlotJarPath string
|
|
PreviousSlotTarget string
|
|
UnitName string
|
|
Port int
|
|
HealthEndpoint string
|
|
Progress func(transaction.State, string)
|
|
}
|
|
|
|
type actuatorChecker interface {
|
|
Check(context.Context, string, healthcheck.RunningProbe) (healthcheck.ActuatorReport, bool, error)
|
|
Wait(context.Context, string, time.Duration, healthcheck.RunningProbe) (healthcheck.ActuatorReport, error)
|
|
}
|
|
|
|
// Executor drives the persisted transaction up to SWITCHING after the inactive native backend is healthy.
|
|
type Executor struct {
|
|
store *transaction.Store
|
|
coordinator *transaction.Coordinator
|
|
releaseStore *filestore.Store
|
|
units systemd.Manager
|
|
checker actuatorChecker
|
|
}
|
|
|
|
func New(store *transaction.Store, coordinator *transaction.Coordinator, releaseStore *filestore.Store, units systemd.Manager, httpClient *http.Client) (*Executor, error) {
|
|
if store == nil {
|
|
return nil, errors.New("transaction store is required")
|
|
}
|
|
if coordinator == nil {
|
|
return nil, errors.New("transaction coordinator is required")
|
|
}
|
|
if releaseStore == nil {
|
|
return nil, errors.New("native backend release store is required")
|
|
}
|
|
if units == nil {
|
|
return nil, errors.New("systemd manager is required")
|
|
}
|
|
checker, err := healthcheck.NewActuatorChecker(httpClient, healthInterval)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Executor{store: store, coordinator: coordinator, releaseStore: releaseStore, units: units, checker: checker}, nil
|
|
}
|
|
|
|
// Run resumes from the transaction's persisted state. It does not switch gateway traffic.
|
|
func (e *Executor) Run(ctx context.Context, transactionID string, request Request) error {
|
|
if strings.TrimSpace(transactionID) == "" {
|
|
return errors.New("transaction ID is required")
|
|
}
|
|
return e.coordinator.RunExclusive(ctx, func(ctx context.Context) error {
|
|
return e.run(ctx, transactionID, request)
|
|
})
|
|
}
|
|
|
|
func (e *Executor) run(ctx context.Context, transactionID string, request Request) error {
|
|
for {
|
|
record, err := e.store.Transaction(ctx, transactionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch record.State {
|
|
case transaction.StateCreated:
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateValidating, "native backend validation started"); err != nil {
|
|
return err
|
|
}
|
|
case transaction.StateValidating:
|
|
reportProgress(request, record.State, "Validating inactive native backend slot")
|
|
if err := e.validate(ctx, request); err != nil {
|
|
_, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, err.Error())
|
|
return errors.Join(err, transitionErr)
|
|
}
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StatePrepared, "native backend inputs validated"); err != nil {
|
|
return err
|
|
}
|
|
case transaction.StatePrepared:
|
|
reportProgress(request, record.State, "Installing backend JAR and binding the inactive slot")
|
|
if _, err := e.prepare(ctx, transactionID, request); err != nil {
|
|
return e.failUnlessRecoverable(ctx, transactionID, err)
|
|
}
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateStarting, "native backend slot prepared"); err != nil {
|
|
return err
|
|
}
|
|
case transaction.StateStarting:
|
|
reportProgress(request, record.State, fmt.Sprintf("Preparing to start %s on port %d", request.UnitName, request.Port))
|
|
installedPath, err := e.prepare(ctx, transactionID, request)
|
|
if err != nil {
|
|
return e.failUnlessRecoverable(ctx, transactionID, err)
|
|
}
|
|
if err := e.startAndCheck(ctx, transactionID, request); err != nil {
|
|
if recoverable(err) {
|
|
return err
|
|
}
|
|
return e.rollbackBeforeSwitch(ctx, transactionID, request, installedPath, err)
|
|
}
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateSwitching, "native backend is healthy"); err != nil {
|
|
return err
|
|
}
|
|
case transaction.StateSwitching:
|
|
return nil
|
|
case transaction.StateRollingBack:
|
|
reportProgress(request, record.State, "Resuming native backend preparation compensation")
|
|
installed, found, err := e.releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect native backend JAR while resuming compensation: %w", err)
|
|
}
|
|
if !found {
|
|
return errors.New("installed native backend JAR is missing while compensation is pending")
|
|
}
|
|
if err := e.compensateBeforeSwitch(ctx, transactionID, request, installed.Path); err != nil {
|
|
return err
|
|
}
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend preparation rollback resumed and completed"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("native backend executor cannot run transaction %s in state %s", transactionID, record.State)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Executor) validate(ctx context.Context, request Request) error {
|
|
if err := validateRequest(request); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(request.ArtifactPath)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect native backend JAR %s: %w", request.ArtifactPath, err)
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("native backend JAR is not a regular file: %s", request.ArtifactPath)
|
|
}
|
|
if info.Size() != request.ArtifactIdentity.Size {
|
|
return fmt.Errorf("native backend JAR size mismatch: got %d, want %d", info.Size(), request.ArtifactIdentity.Size)
|
|
}
|
|
if err := inspectInitialSlot(request.SlotJarPath, request.PreviousSlotTarget); err != nil {
|
|
return err
|
|
}
|
|
unit, err := e.units.Inspect(ctx, request.UnitName)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect inactive native backend unit: %w", err)
|
|
}
|
|
if unit.ActiveState != inactiveState && unit.ActiveState != failedState {
|
|
return fmt.Errorf("native backend unit %s must be inactive or failed before preparation, active state is %q", request.UnitName, unit.ActiveState)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) prepare(ctx context.Context, transactionID string, request Request) (string, error) {
|
|
installOperation := &installJarOperation{
|
|
store: e.releaseStore,
|
|
sourcePath: request.ArtifactPath,
|
|
releasePath: request.ReleasePath,
|
|
identity: request.ArtifactIdentity,
|
|
}
|
|
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, installIntent(request), installOperation); err != nil {
|
|
return "", err
|
|
}
|
|
installed, found, err := e.releaseStore.Inspect(request.ReleasePath, request.ArtifactIdentity)
|
|
if err != nil {
|
|
return "", fmt.Errorf("inspect installed native backend JAR: %w", err)
|
|
}
|
|
if !found {
|
|
return "", errors.New("installed native backend JAR is missing after completed install step")
|
|
}
|
|
|
|
bindOperation := &slotLinkOperation{
|
|
path: request.SlotJarPath,
|
|
desiredTarget: installed.Path,
|
|
previousTarget: request.PreviousSlotTarget,
|
|
}
|
|
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, bindIntent(request, installed.Path), bindOperation); err != nil {
|
|
return "", err
|
|
}
|
|
return installed.Path, nil
|
|
}
|
|
|
|
func (e *Executor) startAndCheck(ctx context.Context, transactionID string, request Request) error {
|
|
reportProgress(request, transaction.StateStarting, "Starting native backend unit "+request.UnitName)
|
|
startOperation := &unitStartOperation{units: e.units, name: request.UnitName}
|
|
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, startIntent(request), startOperation); err != nil {
|
|
return err
|
|
}
|
|
reportProgress(request, transaction.StateStarting, fmt.Sprintf("Waiting up to %s for Actuator health: %s", healthTimeout, request.HealthEndpoint))
|
|
healthOperation := &healthOperation{
|
|
units: e.units,
|
|
checker: e.checker,
|
|
unitName: request.UnitName,
|
|
endpoint: request.HealthEndpoint,
|
|
timeout: healthTimeout,
|
|
}
|
|
_, err := e.coordinator.ExecuteStep(ctx, transactionID, healthIntent(request), healthOperation)
|
|
if err == nil {
|
|
reportProgress(request, transaction.StateStarting, "Actuator health status is UP")
|
|
}
|
|
return err
|
|
}
|
|
|
|
func reportProgress(request Request, state transaction.State, message string) {
|
|
if request.Progress != nil {
|
|
request.Progress(state, message)
|
|
}
|
|
}
|
|
|
|
func (e *Executor) rollbackBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string, cause error) error {
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateRollingBack, "native backend preparation failed; compensation started"); err != nil {
|
|
return errors.Join(cause, err)
|
|
}
|
|
if err := e.compensateBeforeSwitch(ctx, transactionID, request, installedPath); err != nil {
|
|
return errors.Join(cause, err)
|
|
}
|
|
if _, err := e.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend preparation rolled back"); err != nil {
|
|
return errors.Join(cause, err)
|
|
}
|
|
return cause
|
|
}
|
|
|
|
func (e *Executor) compensateBeforeSwitch(ctx context.Context, transactionID string, request Request, installedPath string) error {
|
|
stopOperation := &unitStopOperation{units: e.units, name: request.UnitName}
|
|
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, stopIntent(request), stopOperation); err != nil {
|
|
return err
|
|
}
|
|
restoreOperation := &slotLinkOperation{
|
|
path: request.SlotJarPath,
|
|
desiredTarget: request.PreviousSlotTarget,
|
|
previousTarget: installedPath,
|
|
}
|
|
if _, err := e.coordinator.ExecuteStep(ctx, transactionID, restoreIntent(request, installedPath), restoreOperation); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) failUnlessRecoverable(ctx context.Context, transactionID string, cause error) error {
|
|
if recoverable(cause) {
|
|
return cause
|
|
}
|
|
_, transitionErr := e.store.Transition(ctx, transactionID, transaction.StateFailed, cause.Error())
|
|
return errors.Join(cause, transitionErr)
|
|
}
|
|
|
|
func recoverable(cause error) bool {
|
|
var uncertain *transaction.UncertainStepError
|
|
return errors.As(cause, &uncertain) || errors.Is(cause, transaction.ErrStepConflict)
|
|
}
|
|
|
|
func validateRequest(request Request) error {
|
|
if !filepath.IsAbs(request.ArtifactPath) {
|
|
return errors.New("native backend JAR path must be absolute")
|
|
}
|
|
if err := request.ArtifactIdentity.Validate(); err != nil {
|
|
return fmt.Errorf("invalid native backend JAR identity: %w", err)
|
|
}
|
|
if !filepath.IsLocal(request.ReleasePath) || request.ReleasePath == "." {
|
|
return fmt.Errorf("native backend release path must be a local relative path: %q", request.ReleasePath)
|
|
}
|
|
if !filepath.IsAbs(request.SlotJarPath) {
|
|
return errors.New("native backend slot JAR path must be absolute")
|
|
}
|
|
if request.PreviousSlotTarget != "" && !filepath.IsAbs(request.PreviousSlotTarget) {
|
|
return errors.New("previous native backend slot target must be empty or absolute")
|
|
}
|
|
if request.UnitName == "" || strings.TrimSpace(request.UnitName) != request.UnitName {
|
|
return errors.New("exact native backend systemd unit name is required")
|
|
}
|
|
if request.Port != 8080 && request.Port != 8081 {
|
|
return fmt.Errorf("native backend port must be 8080 or 8081: %d", request.Port)
|
|
}
|
|
parsed, err := url.ParseRequestURI(request.HealthEndpoint)
|
|
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != healthPath {
|
|
return fmt.Errorf("health endpoint must be an HTTP URL with exact path %s", healthPath)
|
|
}
|
|
if parsed.Port() != strconv.Itoa(request.Port) {
|
|
return fmt.Errorf("health endpoint port must equal native backend port %d", request.Port)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectInitialSlot(path, previousTarget string) error {
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
if previousTarget == "" {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("native backend slot link %s is missing; expected target %s", path, previousTarget)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect native backend slot link %s: %w", path, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return fmt.Errorf("native backend slot path is not a symbolic link: %s", path)
|
|
}
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read native backend slot link %s: %w", path, err)
|
|
}
|
|
if target != previousTarget {
|
|
return fmt.Errorf("native backend slot link %s targets %q, expected %q", path, target, previousTarget)
|
|
}
|
|
if previousTarget != "" {
|
|
targetInfo, err := os.Lstat(previousTarget)
|
|
if err != nil {
|
|
return fmt.Errorf("inspect previous native backend slot target %s: %w", previousTarget, err)
|
|
}
|
|
if !targetInfo.Mode().IsRegular() || targetInfo.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("previous native backend slot target is not a regular file: %s", previousTarget)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func installIntent(request Request) transaction.StepIntent {
|
|
return intent(stepInstallJar, "install immutable native backend JAR", struct {
|
|
SourcePath string `json:"sourcePath"`
|
|
ReleasePath string `json:"releasePath"`
|
|
Identity filestore.Identity `json:"identity"`
|
|
}{request.ArtifactPath, request.ReleasePath, request.ArtifactIdentity})
|
|
}
|
|
|
|
func bindIntent(request Request, installedPath string) transaction.StepIntent {
|
|
return intent(stepBindSlot, "bind inactive native backend slot", struct {
|
|
SlotJarPath string `json:"slotJarPath"`
|
|
PreviousTarget string `json:"previousTarget"`
|
|
InstalledPath string `json:"installedPath"`
|
|
}{request.SlotJarPath, request.PreviousSlotTarget, installedPath})
|
|
}
|
|
|
|
func startIntent(request Request) transaction.StepIntent {
|
|
return intent(stepStartUnit, "start inactive native backend systemd unit", struct {
|
|
UnitName string `json:"unitName"`
|
|
}{request.UnitName})
|
|
}
|
|
|
|
func healthIntent(request Request) transaction.StepIntent {
|
|
return intent(stepCheckHealth, "wait for native backend Actuator health", struct {
|
|
UnitName string `json:"unitName"`
|
|
Endpoint string `json:"endpoint"`
|
|
Timeout time.Duration `json:"timeout"`
|
|
}{request.UnitName, request.HealthEndpoint, healthTimeout})
|
|
}
|
|
|
|
func stopIntent(request Request) transaction.StepIntent {
|
|
return intent(stepStopUnit, "stop failed inactive native backend systemd unit", struct {
|
|
UnitName string `json:"unitName"`
|
|
}{request.UnitName})
|
|
}
|
|
|
|
func restoreIntent(request Request, installedPath string) transaction.StepIntent {
|
|
return intent(stepRestoreSlot, "restore inactive native backend slot", struct {
|
|
SlotJarPath string `json:"slotJarPath"`
|
|
InstalledPath string `json:"installedPath"`
|
|
PreviousTarget string `json:"previousTarget"`
|
|
}{request.SlotJarPath, installedPath, request.PreviousSlotTarget})
|
|
}
|
|
|
|
func intent(key, name string, value any) transaction.StepIntent {
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("marshal internal step intent: %v", err))
|
|
}
|
|
return transaction.StepIntent{Key: key, Name: name, Intent: payload}
|
|
}
|