feat: backend native executor implement

This commit is contained in:
2026-08-16 01:27:30 +08:00
parent fbce91d797
commit 390e0565d3
44 changed files with 6430 additions and 31 deletions
+66
View File
@@ -0,0 +1,66 @@
package backendupdate
import (
"encoding/json"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/transaction"
)
func gatewaySwitchIntent(request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot) transaction.StepIntent {
return stepIntent("backend.gateway.switch", "switch host Nginx backend upstream", struct {
BeforePort int `json:"beforePort"`
BeforeHash string `json:"beforeHash"`
AfterPort int `json:"afterPort"`
AfterHash string `json:"afterHash"`
}{before.ActivePort, snapshotDigest(before), after.ActivePort, snapshotDigest(after)})
}
func activeLinkIntent(request persistedRequest, installedPath string) transaction.StepIntent {
return stepIntent("backend.native.active-link", "replace backend compatibility link", struct {
Path string `json:"path"`
Before pathState `json:"before"`
InstalledPath string `json:"installedPath"`
}{request.ActiveJARPath, request.ActiveJARBefore, installedPath})
}
func stopPreviousUnitIntent(request persistedRequest) transaction.StepIntent {
return stepIntent("backend.previous-unit.stop", "stop previous backend unit after drain", struct {
Unit string `json:"unit"`
}{request.PreviousUnit})
}
func activeLinkRestoreIntent(request persistedRequest) transaction.StepIntent {
return stepIntent("backend.native.active-link.restore", "restore backend compatibility path", struct {
Path string `json:"path"`
Before pathState `json:"before"`
}{request.ActiveJARPath, request.ActiveJARBefore})
}
func gatewayRestoreIntent(request persistedRequest) transaction.StepIntent {
return stepIntent("backend.gateway.restore", "restore host Nginx backend upstream", struct {
Port int `json:"port"`
}{request.PreviousGatewayPort})
}
func stopTargetUnitIntent(request persistedRequest) transaction.StepIntent {
return stepIntent("backend.target-unit.stop", "stop compensated backend target unit", struct {
Unit string `json:"unit"`
}{request.TargetUnit})
}
func restoreTargetSlotIntent(request persistedRequest, installedPath string) transaction.StepIntent {
return stepIntent("backend.target-slot.restore", "restore compensated backend target slot", struct {
Path string `json:"path"`
InstalledPath string `json:"installedPath"`
PreviousTarget string `json:"previousTarget"`
}{request.TargetSlotJAR, installedPath, request.PreviousSlotTarget})
}
func stepIntent(key string, name string, value any) transaction.StepIntent {
content, err := json.Marshal(value)
if err != nil {
panic(err)
}
return transaction.StepIntent{Key: key, Name: name, Intent: content}
}
+58
View File
@@ -0,0 +1,58 @@
package backendupdate
import "yms-daemon/internal/filestore"
type updateInput struct {
IdempotencyKey string
InputType string
SourcePath string
SourceSHA256 string
CustomerCode string
VersionID string
ArtifactID int64
ArtifactFileName string
ArtifactIdentity filestore.Identity
ReleasePath string
Materialize func(string) error
}
type persistedRequest struct {
InputType string `json:"inputType"`
SourcePath string `json:"sourcePath"`
SourceSHA256 string `json:"sourceSHA256"`
CustomerCode string `json:"customerCode,omitempty"`
VersionID string `json:"versionId,omitempty"`
ArtifactID int64 `json:"artifactId,omitempty"`
ArtifactFileName string `json:"artifactFileName"`
ArtifactPath string `json:"artifactPath"`
ArtifactIdentity filestore.Identity `json:"artifactIdentity"`
ReleasePath string `json:"releasePath"`
TargetPort int `json:"targetPort"`
TargetUnit string `json:"targetUnit"`
TargetSlotJAR string `json:"targetSlotJar"`
TargetHealthEndpoint string `json:"targetHealthEndpoint"`
PreviousSlotTarget string `json:"previousSlotTarget"`
PreviousGatewayPort int `json:"previousGatewayPort"`
PreviousUnit string `json:"previousUnit"`
GatewayBeforePath string `json:"gatewayBeforePath"`
GatewayAfterPath string `json:"gatewayAfterPath"`
GatewayReceiptPath string `json:"gatewayReceiptPath"`
ActiveJARPath string `json:"activeJarPath"`
ActiveJARBefore pathState `json:"activeJarBefore"`
}
type pathKind string
const (
pathKindAbsent pathKind = "absent"
pathKindRegular pathKind = "regular"
pathKindSymlink pathKind = "symlink"
)
type pathState struct {
Kind pathKind `json:"kind"`
Target string `json:"target,omitempty"`
BackupPath string `json:"backupPath,omitempty"`
Identity filestore.Identity `json:"identity,omitempty"`
Mode uint32 `json:"mode,omitempty"`
}
+393
View File
@@ -0,0 +1,393 @@
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)
}
+99
View File
@@ -0,0 +1,99 @@
package backendupdate
import (
"context"
"os"
"path/filepath"
"testing"
"yms-daemon/internal/systemd"
"yms-daemon/internal/transaction"
)
func TestPathOperationReplacesRegularCompatibilityJarAndRestoresIt(t *testing.T) {
root := t.TempDir()
activePath := filepath.Join(root, "glory-soft-yms.jar")
original := []byte("original backend JAR")
if err := os.WriteFile(activePath, original, 0o644); err != nil {
t.Fatalf("write original compatibility JAR: %v", err)
}
before, err := snapshotPath(activePath, filepath.Join(root, "transaction", "active.before"))
if err != nil {
t.Fatalf("snapshot compatibility JAR: %v", err)
}
installedPath := filepath.Join(root, "releases", "new.jar")
if err := os.MkdirAll(filepath.Dir(installedPath), 0o750); err != nil {
t.Fatalf("create release directory: %v", err)
}
if err := os.WriteFile(installedPath, []byte("new backend JAR"), 0o640); err != nil {
t.Fatalf("write new backend JAR: %v", err)
}
apply := &pathOperation{path: activePath, before: before, desired: pathState{Kind: pathKindSymlink, Target: installedPath}}
if err := apply.Apply(context.Background()); err != nil {
t.Fatalf("replace compatibility JAR with link: %v", err)
}
if target, err := os.Readlink(activePath); err != nil || target != installedPath {
t.Fatalf("unexpected compatibility link: target=%q err=%v", target, err)
}
restore := &pathOperation{path: activePath, before: pathState{Kind: pathKindSymlink, Target: installedPath}, desired: before}
if err := restore.Apply(context.Background()); err != nil {
t.Fatalf("restore compatibility JAR: %v", err)
}
actual, err := os.ReadFile(activePath)
if err != nil || string(actual) != string(original) {
t.Fatalf("unexpected restored compatibility JAR: content=%q err=%v", actual, err)
}
}
func TestResolveCurrentUnitSupportsFirstLegacyMigrationAndTemplateRotation(t *testing.T) {
tests := []struct {
name string
configured string
legacy string
want string
}{
{name: "legacy", configured: "inactive", legacy: "activating", want: legacyUnit8081},
{name: "template", configured: "active", legacy: "failed", want: "yms-backend@8081.service"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
units := &unitStateManager{units: map[string]systemd.Unit{
"yms-backend@8081.service": {Name: "yms-backend@8081.service", LoadState: "loaded", ActiveState: test.configured},
legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: test.legacy},
}}
updater := &Updater{units: units}
actual, err := updater.resolveCurrentUnit(context.Background(), 8081, "yms-backend@8081.service")
if err != nil || actual != test.want {
t.Fatalf("unexpected current unit: unit=%q err=%v", actual, err)
}
})
}
}
func TestUnitStopOperationAcceptsSystemdFailedAsStopped(t *testing.T) {
units := &unitStateManager{units: map[string]systemd.Unit{
legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "failed"},
}}
operation := &unitStopOperation{units: units, name: legacyUnit8081}
inspection, err := operation.Inspect(context.Background())
if err != nil {
t.Fatalf("inspect stopped legacy unit: %v", err)
}
if inspection.Status != transaction.InspectionApplied {
t.Fatalf("unexpected stopped legacy unit inspection: %+v", inspection)
}
}
type unitStateManager struct {
units map[string]systemd.Unit
}
func (m *unitStateManager) Inspect(_ context.Context, name string) (systemd.Unit, error) {
return m.units[name], nil
}
func (m *unitStateManager) Start(context.Context, string) error { return nil }
func (m *unitStateManager) Stop(context.Context, string) error { return nil }
+20
View File
@@ -0,0 +1,20 @@
package backendupdate
import "yms-daemon/internal/transaction"
// Progress is one live update event sent to the local CLI. It is observational
// only: delivery failure must not change the persisted update transaction.
type Progress struct {
TransactionID string
State transaction.State
Message string
}
type ProgressReporter func(Progress)
func operationLabel(inputType string) string {
if inputType == inputTypeCurrentRelease {
return "restart"
}
return "update"
}
+146
View File
@@ -0,0 +1,146 @@
package backendupdate
import (
"context"
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"yms-daemon/internal/transaction"
"yms-daemon/internal/updatepackage"
)
// Restart performs a zero-downtime rotation with the exact release currently
// exposed by the compatibility JAR path.
func (u *Updater) Restart(ctx context.Context, report ProgressReporter) (transaction.Transaction, error) {
reportProgress(report, Progress{Message: "Resolving the current native backend release"})
active, err := u.store.ActiveTransaction(ctx)
if err == nil {
if active.Service != serviceBackend {
return active, &transaction.ActiveTransactionError{TransactionID: active.ID}
}
var request persistedRequest
if decodeErr := decodePersistedRequest(active.Request, &request); decodeErr != nil {
return active, decodeErr
}
if request.InputType != inputTypeCurrentRelease {
return active, &transaction.ActiveTransactionError{TransactionID: active.ID}
}
input, inputErr := u.persistedRestartInput(request, active.IdempotencyKey)
if inputErr != nil {
return active, inputErr
}
return u.update(ctx, input, report)
}
if !errors.Is(err, transaction.ErrNotFound) {
return transaction.Transaction{}, err
}
sourcePath, err := currentReleaseSource(u.config.Backend.ActiveJAR)
if err != nil {
return transaction.Transaction{}, err
}
jar, err := updatepackage.OpenDirectNativeJAR(sourcePath)
if err != nil {
return transaction.Transaction{}, err
}
releasePath, err := u.restartReleasePath(jar)
if err != nil {
return transaction.Transaction{}, err
}
restartID := rand.Text()
return u.update(ctx, updateInput{
IdempotencyKey: serviceBackend + ":restart:" + restartID,
InputType: inputTypeCurrentRelease,
SourcePath: jar.Path,
SourceSHA256: jar.SHA256,
ArtifactFileName: jar.FileName,
ArtifactIdentity: jar.Identity,
ReleasePath: releasePath,
Materialize: jar.CopyArtifact,
}, report)
}
func currentReleaseSource(activeJAR string) (string, error) {
info, err := os.Lstat(activeJAR)
if err != nil {
return "", fmt.Errorf("inspect active compatibility JAR %s: %w", activeJAR, err)
}
if info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
return activeJAR, nil
}
if info.Mode()&os.ModeSymlink == 0 {
return "", fmt.Errorf("active compatibility JAR is neither a direct regular file nor symbolic link: %s", activeJAR)
}
target, err := os.Readlink(activeJAR)
if err != nil {
return "", fmt.Errorf("read active compatibility JAR link %s: %w", activeJAR, err)
}
if !filepath.IsAbs(target) {
return "", fmt.Errorf("active compatibility JAR link target must be absolute: %s", target)
}
resolved, err := filepath.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("resolve active compatibility JAR target %s: %w", target, err)
}
return resolved, nil
}
func (u *Updater) restartReleasePath(jar updatepackage.DirectNativeJAR) (string, error) {
resolvedReleaseDir, err := filepath.EvalSymlinks(u.config.Backend.ReleaseDir)
if err != nil {
return "", fmt.Errorf("resolve native backend release directory: %w", err)
}
relative, err := filepath.Rel(resolvedReleaseDir, jar.Path)
if err != nil {
return "", fmt.Errorf("compare current JAR with native backend release directory: %w", err)
}
if relative != "." && filepath.IsLocal(relative) {
return relative, nil
}
return filepath.Join("direct", jar.SHA256[:directReleaseDigestLength], jar.FileName), nil
}
func (u *Updater) persistedRestartInput(request persistedRequest, idempotencyKey string) (updateInput, error) {
materialize, err := u.restartMaterializer(request)
if err != nil {
return updateInput{}, err
}
return updateInput{
IdempotencyKey: idempotencyKey,
InputType: request.InputType,
SourcePath: request.SourcePath,
SourceSHA256: request.SourceSHA256,
ArtifactFileName: request.ArtifactFileName,
ArtifactIdentity: request.ArtifactIdentity,
ReleasePath: request.ReleasePath,
Materialize: materialize,
}, nil
}
func (u *Updater) restartMaterializer(request persistedRequest) (func(string) error, error) {
if info, err := os.Lstat(request.ArtifactPath); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("persisted backend restart artifact is not a direct regular file: %s", request.ArtifactPath)
}
return func(string) error {
return errors.New("persisted backend restart artifact disappeared during resume")
}, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect persisted backend restart artifact: %w", err)
}
paths := []string{request.SourcePath, filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)}
for _, sourcePath := range paths {
jar, err := updatepackage.OpenDirectNativeJAR(sourcePath)
if err != nil {
continue
}
if jar.SHA256 == request.SourceSHA256 && jar.Identity == request.ArtifactIdentity {
return jar.CopyArtifact, nil
}
}
return nil, fmt.Errorf("backend restart cannot recover artifact with identity %+v", request.ArtifactIdentity)
}
+40
View File
@@ -0,0 +1,40 @@
package backendupdate
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/transaction"
)
func TestRestartRejectsUnfinishedBackendUpdate(t *testing.T) {
ctx := context.Background()
store, err := transaction.OpenStore(ctx, filepath.Join(t.TempDir(), "transactions.db"))
if err != nil {
t.Fatalf("open transaction store: %v", err)
}
defer store.Close()
request, err := json.Marshal(persistedRequest{InputType: daemonapi.InputTypeNativeJAR})
if err != nil {
t.Fatalf("encode unfinished update request: %v", err)
}
active, _, err := store.CreateTransaction(ctx, transaction.CreateRequest{
ID: "unfinished-backend-update",
IdempotencyKey: "backend:update:unfinished",
Source: sourceLocalCLI,
Service: serviceBackend,
Request: request,
})
if err != nil {
t.Fatalf("create unfinished update transaction: %v", err)
}
updater := &Updater{store: store}
record, err := updater.Restart(ctx, nil)
if !errors.Is(err, transaction.ErrActiveExists) || record.ID != active.ID {
t.Fatalf("unexpected restart result with unfinished update: record=%+v err=%v", record, err)
}
}
+535
View File
@@ -0,0 +1,535 @@
// Package backendupdate orchestrates one native backend update through commit or compensation.
package backendupdate
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"yms-daemon/internal/daemonapi"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/filestore"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/nativebackendexecutor"
"yms-daemon/internal/systemd"
"yms-daemon/internal/transaction"
"yms-daemon/internal/updatepackage"
)
const (
serviceBackend = "backend"
sourceLocalCLI = "local-cli"
drainDuration = 5 * time.Second
directReleaseDigestLength = 12
inputTypeCurrentRelease = "current-native-release"
legacyUnit8080 = "yms.service"
legacyUnit8081 = "ymsback.service"
)
// Updater executes the current native backend contract on one server.
type Updater struct {
config deploymentconfig.Config
workRoot string
store *transaction.Store
coordinator *transaction.Coordinator
releaseStore *filestore.Store
units systemd.Manager
gateway gatewayController
executor nativeExecutor
logger *slog.Logger
drain time.Duration
}
type gatewayController interface {
Read() (hostnginx.Snapshot, error)
Apply(context.Context, hostnginx.Snapshot) error
}
type nativeExecutor interface {
Run(context.Context, string, nativebackendexecutor.Request) error
}
// New creates the complete native backend update orchestrator.
func New(
config deploymentconfig.Config,
workRoot string,
store *transaction.Store,
coordinator *transaction.Coordinator,
releaseStore *filestore.Store,
units systemd.Manager,
gateway gatewayController,
httpClient *http.Client,
logger *slog.Logger,
) (*Updater, error) {
if err := config.Validate(); err != nil {
return nil, err
}
if !filepath.IsAbs(workRoot) || filepath.Clean(workRoot) != workRoot {
return nil, errors.New("backend update work root must be a clean absolute path")
}
if store == nil || coordinator == nil || releaseStore == nil || units == nil || gateway == nil {
return nil, errors.New("backend update dependencies are required")
}
if logger == nil {
logger = slog.Default()
}
executor, err := nativebackendexecutor.New(store, coordinator, releaseStore, units, httpClient)
if err != nil {
return nil, err
}
return &Updater{
config: config,
workRoot: workRoot,
store: store,
coordinator: coordinator,
releaseStore: releaseStore,
units: units,
gateway: gateway,
executor: executor,
logger: logger,
drain: drainDuration,
}, nil
}
// UpdateRepack applies one repack ZIP selected by an absolute local path.
func (u *Updater) UpdateRepack(ctx context.Context, packagePath string, report ProgressReporter) (transaction.Transaction, error) {
reportProgress(report, Progress{Message: "Validating repack ZIP"})
updatePackage, err := updatepackage.OpenBackendNative(packagePath)
if err != nil {
return transaction.Transaction{}, err
}
defer updatePackage.Close()
reportProgress(report, Progress{Message: "Repack ZIP validated"})
return u.update(ctx, updateInput{
IdempotencyKey: serviceBackend + ":" + updatePackage.PackageSHA256,
InputType: daemonapi.InputTypeRepackZIP,
SourcePath: updatePackage.PackagePath,
SourceSHA256: updatePackage.PackageSHA256,
CustomerCode: updatePackage.CustomerCode,
VersionID: updatePackage.VersionID,
ArtifactID: updatePackage.ArtifactID,
ArtifactFileName: updatePackage.FileName,
ArtifactIdentity: updatePackage.Identity,
ReleasePath: updatePackage.FileName,
Materialize: updatePackage.ExtractArtifact,
}, report)
}
// UpdateNativeJAR applies one JAR copied directly to the server.
func (u *Updater) UpdateNativeJAR(ctx context.Context, jarPath string, report ProgressReporter) (transaction.Transaction, error) {
reportProgress(report, Progress{Message: "Validating direct native backend JAR and computing SHA-256"})
jar, err := updatepackage.OpenDirectNativeJAR(jarPath)
if err != nil {
return transaction.Transaction{}, err
}
reportProgress(report, Progress{Message: "Direct native backend JAR validated: sha256=" + jar.SHA256})
return u.update(ctx, updateInput{
IdempotencyKey: serviceBackend + ":" + jar.SHA256,
InputType: daemonapi.InputTypeNativeJAR,
SourcePath: jar.Path,
SourceSHA256: jar.SHA256,
ArtifactFileName: jar.FileName,
ArtifactIdentity: jar.Identity,
ReleasePath: filepath.Join("direct", jar.SHA256[:directReleaseDigestLength], jar.FileName),
Materialize: jar.CopyArtifact,
}, report)
}
func (u *Updater) update(ctx context.Context, input updateInput, report ProgressReporter) (transaction.Transaction, error) {
existing, request, created, err := u.createOrResume(ctx, input)
if err != nil {
return transaction.Transaction{}, err
}
operation := operationLabel(input.InputType)
transactionMessage := "Resuming backend " + operation + " transaction"
if created {
transactionMessage = "Created backend " + operation + " transaction"
}
transactionMessage += " " + existing.ID
reportProgress(report, Progress{TransactionID: existing.ID, State: existing.State, Message: transactionMessage})
if !created && existing.State.Terminal() {
return terminalResult(existing)
}
if err := os.MkdirAll(filepath.Dir(request.ArtifactPath), 0o750); err != nil {
return u.fail(ctx, existing.ID, fmt.Errorf("create backend transaction work directory: %w", err))
}
reportProgress(report, Progress{TransactionID: existing.ID, State: existing.State, Message: "Staging backend artifact in transaction workspace"})
if err := ensureTransactionArtifact(input, request); err != nil {
return u.fail(ctx, existing.ID, err)
}
executorRequest := nativebackendexecutor.Request{
ArtifactPath: request.ArtifactPath,
ArtifactIdentity: request.ArtifactIdentity,
ReleasePath: request.ReleasePath,
SlotJarPath: request.TargetSlotJAR,
PreviousSlotTarget: request.PreviousSlotTarget,
UnitName: request.TargetUnit,
Port: request.TargetPort,
HealthEndpoint: request.TargetHealthEndpoint,
Progress: func(state transaction.State, message string) {
reportProgress(report, Progress{TransactionID: existing.ID, State: state, Message: message})
},
}
switch existing.State {
case transaction.StateCreated, transaction.StateValidating, transaction.StatePrepared, transaction.StateStarting:
if err := u.executor.Run(ctx, existing.ID, executorRequest); err != nil {
return u.currentWithError(ctx, existing.ID, err)
}
case transaction.StateSwitching, transaction.StateVerifying, transaction.StateDraining:
case transaction.StateRollingBack:
if err := u.executor.Run(ctx, existing.ID, executorRequest); err != nil {
return u.currentWithError(ctx, existing.ID, err)
}
default:
return u.currentWithError(ctx, existing.ID, fmt.Errorf("backend update cannot resume transaction %s in state %s", existing.ID, existing.State))
}
current, err := u.store.Transaction(ctx, existing.ID)
if err != nil {
return transaction.Transaction{}, err
}
if current.State.Terminal() {
return terminalResult(current)
}
if err := u.switchAndCommit(ctx, existing.ID, request, report); err != nil {
return u.currentWithError(ctx, existing.ID, err)
}
return u.store.Transaction(ctx, existing.ID)
}
func terminalResult(record transaction.Transaction) (transaction.Transaction, error) {
if record.State == transaction.StateCommitted {
return record, nil
}
return record, fmt.Errorf("backend update transaction %s is terminal in state %s", record.ID, record.State)
}
func (u *Updater) createOrResume(ctx context.Context, input updateInput) (transaction.Transaction, persistedRequest, bool, error) {
gatewayBefore, err := u.gateway.Read()
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
targetPort := otherPort(gatewayBefore.ActivePort)
targetSlot, err := u.config.Backend.SlotForPort(targetPort)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
currentSlot, err := u.config.Backend.SlotForPort(gatewayBefore.ActivePort)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
previousUnit, err := u.resolveCurrentUnit(ctx, gatewayBefore.ActivePort, currentSlot.Unit)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
previousSlotTarget, err := readOptionalSymlink(targetSlot.JAR)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
transactionID := rand.Text()
transactionRoot := filepath.Join(u.workRoot, transactionID)
if err := os.MkdirAll(transactionRoot, 0o750); err != nil {
return transaction.Transaction{}, persistedRequest{}, false, fmt.Errorf("create backend transaction directory: %w", err)
}
gatewayAfterContent, err := hostnginx.RenderBackendPort(gatewayBefore.Content, targetPort)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
gatewayBeforePath := filepath.Join(transactionRoot, "gateway.before.conf")
gatewayAfterPath := filepath.Join(transactionRoot, "gateway.after.conf")
if err := writeImmutableFile(gatewayBeforePath, gatewayBefore.Content, 0o640); err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
if err := writeImmutableFile(gatewayAfterPath, gatewayAfterContent, 0o640); err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
activeBefore, err := snapshotPath(u.config.Backend.ActiveJAR, filepath.Join(transactionRoot, "active-jar.before"))
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
request := persistedRequest{
InputType: input.InputType,
SourcePath: input.SourcePath,
SourceSHA256: input.SourceSHA256,
CustomerCode: input.CustomerCode,
VersionID: input.VersionID,
ArtifactID: input.ArtifactID,
ArtifactFileName: input.ArtifactFileName,
ArtifactPath: filepath.Join(transactionRoot, "backend.jar"),
ArtifactIdentity: input.ArtifactIdentity,
ReleasePath: input.ReleasePath,
TargetPort: targetPort,
TargetUnit: targetSlot.Unit,
TargetSlotJAR: targetSlot.JAR,
TargetHealthEndpoint: targetSlot.HealthEndpoint,
PreviousSlotTarget: previousSlotTarget,
PreviousGatewayPort: gatewayBefore.ActivePort,
PreviousUnit: previousUnit,
GatewayBeforePath: gatewayBeforePath,
GatewayAfterPath: gatewayAfterPath,
GatewayReceiptPath: filepath.Join(transactionRoot, "gateway.applied"),
ActiveJARPath: u.config.Backend.ActiveJAR,
ActiveJARBefore: activeBefore,
}
requestJSON, err := json.Marshal(request)
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, fmt.Errorf("encode backend update request: %w", err)
}
record, created, err := u.store.CreateTransaction(ctx, transaction.CreateRequest{
ID: transactionID,
IdempotencyKey: input.IdempotencyKey,
Source: sourceLocalCLI,
Service: serviceBackend,
Request: requestJSON,
})
if err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
if created {
return record, request, true, nil
}
var persisted persistedRequest
if err := decodePersistedRequest(record.Request, &persisted); err != nil {
return transaction.Transaction{}, persistedRequest{}, false, err
}
if persisted.InputType != input.InputType || persisted.SourceSHA256 != input.SourceSHA256 {
return transaction.Transaction{}, persistedRequest{}, false, errors.New("persisted backend transaction input identity mismatch")
}
return record, persisted, false, nil
}
func (u *Updater) switchAndCommit(ctx context.Context, transactionID string, request persistedRequest, report ProgressReporter) error {
operation := operationLabel(request.InputType)
before, err := readGatewaySnapshot(request.GatewayBeforePath, request.PreviousGatewayPort)
if err != nil {
return err
}
after, err := readGatewaySnapshot(request.GatewayAfterPath, request.TargetPort)
if err != nil {
return err
}
for {
record, err := u.store.Transaction(ctx, transactionID)
if err != nil {
return err
}
switch record.State {
case transaction.StateSwitching:
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Switching host Nginx backend traffic to port %d", request.TargetPort)})
gatewayOperation := &gatewayOperation{
controller: u.gateway,
before: before,
after: after,
receiptPath: request.GatewayReceiptPath,
}
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, gatewaySwitchIntent(request, before, after), gatewayOperation); err != nil {
return u.rollbackAfterPreparation(ctx, transactionID, request, before, after, err)
}
if _, err := u.store.Transition(ctx, transactionID, transaction.StateVerifying, "host Nginx now routes backend traffic to the healthy native slot"); err != nil {
return err
}
case transaction.StateVerifying:
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Updating the active compatibility JAR link"})
installedPath := filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)
activeOperation := &pathOperation{
path: request.ActiveJARPath,
before: request.ActiveJARBefore,
desired: pathState{Kind: pathKindSymlink, Target: installedPath},
}
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, activeLinkIntent(request, installedPath), activeOperation); err != nil {
return u.rollbackAfterPreparation(ctx, transactionID, request, before, after, err)
}
if _, err := u.store.Transition(ctx, transactionID, transaction.StateDraining, "backend compatibility link committed; previous unit draining"); err != nil {
return err
}
case transaction.StateDraining:
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: fmt.Sprintf("Draining previous backend unit for %s", u.drain)})
if err := waitContext(ctx, u.drain); err != nil {
return err
}
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Stopping previous backend unit " + request.PreviousUnit})
stopOperation := &unitStopOperation{units: u.units, name: request.PreviousUnit}
if _, err := u.coordinator.ExecuteStep(ctx, transactionID, stopPreviousUnitIntent(request), stopOperation); err != nil {
return err
}
_, err = u.store.Transition(ctx, transactionID, transaction.StateCommitted, "native backend "+operation+" committed")
if err == nil {
reportProgress(report, Progress{TransactionID: transactionID, State: transaction.StateCommitted, Message: "Native backend " + operation + " committed"})
}
return err
case transaction.StateCommitted:
reportProgress(report, Progress{TransactionID: transactionID, State: record.State, Message: "Native backend " + operation + " already committed"})
return nil
default:
return fmt.Errorf("backend commit cannot continue transaction %s in state %s", transactionID, record.State)
}
}
}
func reportProgress(report ProgressReporter, progress Progress) {
if report != nil {
report(progress)
}
}
func (u *Updater) rollbackAfterPreparation(ctx context.Context, transactionID string, request persistedRequest, before hostnginx.Snapshot, after hostnginx.Snapshot, cause error) error {
record, readErr := u.store.Transaction(ctx, transactionID)
if readErr != nil {
return errors.Join(cause, readErr)
}
if record.State != transaction.StateRollingBack {
if _, err := u.store.Transition(ctx, transactionID, transaction.StateRollingBack, "native backend post-start compensation started"); err != nil {
return errors.Join(cause, err)
}
}
activeRestore := &pathOperation{
path: request.ActiveJARPath,
before: pathState{Kind: pathKindSymlink, Target: filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)},
desired: request.ActiveJARBefore,
}
_, activeErr := u.coordinator.ExecuteStep(ctx, transactionID, activeLinkRestoreIntent(request), activeRestore)
gatewayRestore := &gatewayOperation{
controller: u.gateway,
before: after,
after: before,
receiptPath: request.GatewayReceiptPath + ".restore",
}
_, gatewayErr := u.coordinator.ExecuteStep(ctx, transactionID, gatewayRestoreIntent(request), gatewayRestore)
stopTarget := &unitStopOperation{units: u.units, name: request.TargetUnit}
_, stopErr := u.coordinator.ExecuteStep(ctx, transactionID, stopTargetUnitIntent(request), stopTarget)
installedPath := filepath.Join(u.config.Backend.ReleaseDir, request.ReleasePath)
previousSlotState := pathState{Kind: pathKindAbsent}
if request.PreviousSlotTarget != "" {
previousSlotState = pathState{Kind: pathKindSymlink, Target: request.PreviousSlotTarget}
}
slotRestore := &pathOperation{
path: request.TargetSlotJAR,
before: pathState{Kind: pathKindSymlink, Target: installedPath},
desired: previousSlotState,
}
_, slotErr := u.coordinator.ExecuteStep(ctx, transactionID, restoreTargetSlotIntent(request, installedPath), slotRestore)
if err := errors.Join(activeErr, gatewayErr, stopErr, slotErr); err != nil {
return errors.Join(cause, err)
}
_, transitionErr := u.store.Transition(ctx, transactionID, transaction.StateRolledBack, "native backend post-start compensation completed")
return errors.Join(cause, transitionErr)
}
func (u *Updater) resolveCurrentUnit(ctx context.Context, port int, configuredUnit string) (string, error) {
legacyUnit, err := legacyUnitForPort(port)
if err != nil {
return "", err
}
configured, err := u.units.Inspect(ctx, configuredUnit)
if err != nil {
return "", fmt.Errorf("inspect configured active-port unit %s: %w", configuredUnit, err)
}
legacy, err := u.units.Inspect(ctx, legacyUnit)
if err != nil {
return "", fmt.Errorf("inspect legacy active-port unit %s: %w", legacyUnit, err)
}
configuredRunning := unitRunning(configured)
legacyRunning := unitRunning(legacy)
if configuredRunning == legacyRunning {
return "", fmt.Errorf("backend port %d requires exactly one running unit, configured=%s(%s), legacy=%s(%s)", port, configuredUnit, configured.ActiveState, legacyUnit, legacy.ActiveState)
}
if configuredRunning {
return configuredUnit, nil
}
return legacyUnit, nil
}
func (u *Updater) fail(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) {
_, transitionErr := u.store.Transition(ctx, transactionID, transaction.StateFailed, cause.Error())
return u.currentWithError(ctx, transactionID, errors.Join(cause, transitionErr))
}
func (u *Updater) currentWithError(ctx context.Context, transactionID string, cause error) (transaction.Transaction, error) {
record, err := u.store.Transaction(ctx, transactionID)
return record, errors.Join(cause, err)
}
func otherPort(port int) int {
if port == deploymentconfig.BackendPort8080 {
return deploymentconfig.BackendPort8081
}
return deploymentconfig.BackendPort8080
}
func legacyUnitForPort(port int) (string, error) {
switch port {
case deploymentconfig.BackendPort8080:
return legacyUnit8080, nil
case deploymentconfig.BackendPort8081:
return legacyUnit8081, nil
default:
return "", fmt.Errorf("unsupported legacy backend port: %d", port)
}
}
func unitRunning(unit systemd.Unit) bool {
return unit.ActiveState != "inactive" && unit.ActiveState != "failed"
}
func readOptionalSymlink(path string) (string, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("inspect native backend target slot %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink == 0 {
return "", fmt.Errorf("native backend target slot is not a symbolic link: %s", path)
}
target, err := os.Readlink(path)
if err != nil {
return "", fmt.Errorf("read native backend target slot %s: %w", path, err)
}
return target, nil
}
func ensureTransactionArtifact(input updateInput, request persistedRequest) error {
info, err := os.Lstat(request.ArtifactPath)
if errors.Is(err, os.ErrNotExist) {
return input.Materialize(request.ArtifactPath)
}
if err != nil {
return fmt.Errorf("inspect extracted native backend artifact: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("extracted native backend artifact is not a direct regular file")
}
return verifyFileIdentity(request.ArtifactPath, request.ArtifactIdentity)
}
func decodePersistedRequest(content json.RawMessage, request *persistedRequest) error {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(request); err != nil {
return fmt.Errorf("decode persisted backend update request: %w", err)
}
return nil
}
func waitContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+331
View File
@@ -0,0 +1,331 @@
package backendupdate
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"yms-daemon/internal/deploymentconfig"
"yms-daemon/internal/hostnginx"
"yms-daemon/internal/nativebackendexecutor"
"yms-daemon/internal/systemd"
"yms-daemon/internal/transaction"
"yms-daemon/internal/updatepackage"
)
func TestUpdaterMigratesLegacyBackendAndCommits(t *testing.T) {
testUpdaterMigratesLegacyBackendAndCommits(t, false)
}
func TestUpdaterCommitsDirectNativeJAR(t *testing.T) {
testUpdaterMigratesLegacyBackendAndCommits(t, true)
}
func testUpdaterMigratesLegacyBackendAndCommits(t *testing.T, direct bool) {
t.Helper()
ctx := context.Background()
root := t.TempDir()
store, err := transaction.OpenStore(ctx, filepath.Join(root, "transactions.db"))
if err != nil {
t.Fatalf("open transaction store: %v", err)
}
defer store.Close()
coordinator, err := transaction.NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("create transaction coordinator: %v", err)
}
releaseDir := filepath.Join(root, "releases")
activeJAR := filepath.Join(root, "lib", "glory-soft-yms.jar")
if err := os.MkdirAll(filepath.Dir(activeJAR), 0o750); err != nil {
t.Fatalf("create backend lib directory: %v", err)
}
if err := os.WriteFile(activeJAR, []byte("legacy backend JAR"), 0o644); err != nil {
t.Fatalf("write legacy backend JAR: %v", err)
}
config := deploymentconfig.Config{Backend: deploymentconfig.Backend{
Type: deploymentconfig.BackendTypeNative,
ReleaseDir: releaseDir,
ActiveJAR: activeJAR,
Slot: deploymentconfig.BackendSlots{
Port8080: deploymentconfig.BackendSlot{
Unit: "yms-backend@8080.service",
JAR: filepath.Join(root, "lib", "glory-soft-yms-8080.jar"),
HealthEndpoint: "http://127.0.0.1:8080/yms/actuator/health",
},
Port8081: deploymentconfig.BackendSlot{
Unit: "yms-backend@8081.service",
JAR: filepath.Join(root, "lib", "glory-soft-yms-8081.jar"),
HealthEndpoint: "http://127.0.0.1:8081/yms/actuator/health",
},
},
}}
units := &updateUnitManager{units: map[string]systemd.Unit{
"yms-backend@8080.service": {Name: "yms-backend@8080.service", LoadState: "loaded", ActiveState: "inactive"},
"yms-backend@8081.service": {Name: "yms-backend@8081.service", LoadState: "loaded", ActiveState: "inactive"},
legacyUnit8080: {Name: legacyUnit8080, LoadState: "loaded", ActiveState: "failed"},
legacyUnit8081: {Name: legacyUnit8081, LoadState: "loaded", ActiveState: "activating"},
}}
gateway := &memoryGateway{snapshot: hostnginx.Snapshot{Content: []byte(serverConfiguration8081), ActivePort: 8081}}
executor := &preparingExecutor{store: store, releaseDir: releaseDir, units: units}
updater := &Updater{
config: config,
workRoot: filepath.Join(root, "work"),
store: store,
coordinator: coordinator,
units: units,
gateway: gateway,
executor: executor,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
drain: 0,
}
var record transaction.Transaction
var installedPath string
var progress []Progress
report := func(event Progress) {
progress = append(progress, event)
}
if direct {
jarPath := writeDirectBackendJAR(t, []byte("new backend JAR"))
jar, err := updatepackage.OpenDirectNativeJAR(jarPath)
if err != nil {
t.Fatalf("inspect direct native backend JAR: %v", err)
}
installedPath = filepath.Join(releaseDir, "direct", jar.SHA256[:12], jar.FileName)
record, err = updater.UpdateNativeJAR(ctx, jarPath, report)
} else {
packagePath := writeNativeBackendPackage(t, []byte("new backend JAR"))
installedPath = filepath.Join(releaseDir, "glory-soft-yms-test.jar")
record, err = updater.UpdateRepack(ctx, packagePath, report)
}
if err != nil {
t.Fatalf("update native backend: %v", err)
}
if record.State != transaction.StateCommitted || gateway.snapshot.ActivePort != 8080 {
t.Fatalf("unexpected committed update: record=%+v gateway=%+v", record, gateway.snapshot)
}
if target, err := os.Readlink(activeJAR); err != nil || target != installedPath {
t.Fatalf("unexpected compatibility link: target=%q installed=%q err=%v", target, installedPath, err)
}
if units.units[legacyUnit8081].ActiveState != "failed" || units.stopped != legacyUnit8081 {
t.Fatalf("legacy backend unit was not stopped: unit=%+v stopped=%s", units.units[legacyUnit8081], units.stopped)
}
if len(progress) == 0 || progress[len(progress)-1].State != transaction.StateCommitted {
t.Fatalf("missing committed update progress: %+v", progress)
}
if direct {
previousTransactionID := record.ID
restartSource, sourceErr := currentReleaseSource(activeJAR)
if sourceErr != nil {
t.Fatalf("resolve restart source: %v", sourceErr)
}
restartJAR, sourceErr := updatepackage.OpenDirectNativeJAR(restartSource)
if sourceErr != nil {
t.Fatalf("open restart source: %v", sourceErr)
}
restartReleasePath, sourceErr := updater.restartReleasePath(restartJAR)
if sourceErr != nil {
t.Fatalf("resolve restart release path: %v", sourceErr)
}
pendingRestart, _, created, sourceErr := updater.createOrResume(ctx, updateInput{
IdempotencyKey: "backend:restart:test-resume",
InputType: inputTypeCurrentRelease,
SourcePath: restartJAR.Path,
SourceSHA256: restartJAR.SHA256,
ArtifactFileName: restartJAR.FileName,
ArtifactIdentity: restartJAR.Identity,
ReleasePath: restartReleasePath,
Materialize: restartJAR.CopyArtifact,
})
if sourceErr != nil || !created {
t.Fatalf("create interrupted restart transaction: record=%+v created=%t err=%v", pendingRestart, created, sourceErr)
}
progress = nil
record, err = updater.Restart(ctx, report)
if err != nil {
t.Fatalf("restart native backend: %v", err)
}
if record.ID == previousTransactionID || record.ID != pendingRestart.ID || record.State != transaction.StateCommitted {
t.Fatalf("unexpected resumed restart transaction: previous=%s pending=%s record=%+v", previousTransactionID, pendingRestart.ID, record)
}
if gateway.snapshot.ActivePort != 8081 || units.units["yms-backend@8081.service"].ActiveState != "active" || units.units["yms-backend@8080.service"].ActiveState != "failed" {
t.Fatalf("restart did not rotate native backend slots: gateway=%+v units=%+v", gateway.snapshot, units.units)
}
if len(progress) == 0 || progress[len(progress)-1].Message != "Native backend restart committed" {
t.Fatalf("missing committed restart progress: %+v", progress)
}
}
}
const serverConfiguration8081 = `http {
upstream yms-server {
# yms-update managed upstream begin
# server 10.11.1.117:8080 max_fails=1 fail_timeout=2s;
server 10.11.1.117:8081 max_fails=1 fail_timeout=2s;
# yms-update managed upstream end
}
}
`
type memoryGateway struct {
snapshot hostnginx.Snapshot
}
func (g *memoryGateway) Read() (hostnginx.Snapshot, error) {
return hostnginx.Snapshot{Content: append([]byte(nil), g.snapshot.Content...), ActivePort: g.snapshot.ActivePort}, nil
}
func (g *memoryGateway) Apply(_ context.Context, snapshot hostnginx.Snapshot) error {
g.snapshot = hostnginx.Snapshot{Content: append([]byte(nil), snapshot.Content...), ActivePort: snapshot.ActivePort}
return nil
}
type updateUnitManager struct {
units map[string]systemd.Unit
stopped string
}
func (m *updateUnitManager) Inspect(_ context.Context, name string) (systemd.Unit, error) {
return m.units[name], nil
}
func (m *updateUnitManager) Start(_ context.Context, name string) error {
unit := m.units[name]
unit.ActiveState = "active"
m.units[name] = unit
return nil
}
func (m *updateUnitManager) Stop(_ context.Context, name string) error {
unit := m.units[name]
unit.ActiveState = "failed"
m.units[name] = unit
m.stopped = name
return nil
}
type preparingExecutor struct {
store *transaction.Store
releaseDir string
units *updateUnitManager
}
func (e *preparingExecutor) Run(ctx context.Context, transactionID string, request nativebackendexecutor.Request) error {
if err := os.MkdirAll(e.releaseDir, 0o750); err != nil {
return err
}
content, err := os.ReadFile(request.ArtifactPath)
if err != nil {
return err
}
installedPath := filepath.Join(e.releaseDir, request.ReleasePath)
if err := os.MkdirAll(filepath.Dir(installedPath), 0o750); err != nil {
return err
}
if err := os.WriteFile(installedPath, content, 0o640); err != nil {
return err
}
if err := os.Symlink(installedPath, request.SlotJarPath); err != nil {
return err
}
if err := e.units.Start(ctx, request.UnitName); err != nil {
return err
}
for _, state := range []transaction.State{
transaction.StateValidating,
transaction.StatePrepared,
transaction.StateStarting,
transaction.StateSwitching,
} {
if _, err := e.store.Transition(ctx, transactionID, state, "test transition"); err != nil {
return err
}
}
return nil
}
func writeNativeBackendPackage(t *testing.T, jar []byte) string {
t.Helper()
digest := sha256.Sum256(jar)
manifest := map[string]any{
"customerCode": "customer-01",
"customerDisplayName": "Customer 01",
"versionId": "V1.1.8",
"items": []string{"deploy-sync.sh"},
"backendArtifacts": []any{map[string]any{
"id": int64(42),
"versionCode": "V1.1.8",
"artifactKind": "BACKEND",
"type": "native",
"selectedType": "native",
"platform": nil,
"fileName": "glory-soft-yms-test.jar",
"filePath": "/archive/glory-soft-yms-test.jar",
"sha256": hex.EncodeToString(digest[:]),
"imageRef": nil,
}},
"frontendArtifacts": []any{},
"nodeSsrArtifacts": []any{},
"remark": nil,
}
manifestContent, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("encode artifact selection: %v", err)
}
packagePath := filepath.Join(t.TempDir(), "package.zip")
file, err := os.Create(packagePath)
if err != nil {
t.Fatalf("create update package: %v", err)
}
writer := zip.NewWriter(file)
for name, content := range map[string][]byte{
"artifact-selection.json": manifestContent,
"glory-soft-yms-test.jar": jar,
} {
entry, err := writer.Create(name)
if err != nil {
t.Fatalf("create update package entry: %v", err)
}
if _, err := entry.Write(content); err != nil {
t.Fatalf("write update package entry: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close update package writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close update package: %v", err)
}
return packagePath
}
func writeDirectBackendJAR(t *testing.T, content []byte) string {
t.Helper()
jarPath := filepath.Join(t.TempDir(), "glory-soft-yms.jar")
file, err := os.Create(jarPath)
if err != nil {
t.Fatalf("create direct backend JAR: %v", err)
}
writer := zip.NewWriter(file)
entry, err := writer.Create("BOOT-INF/classes/application.properties")
if err != nil {
t.Fatalf("create direct backend JAR entry: %v", err)
}
if _, err := entry.Write(content); err != nil {
t.Fatalf("write direct backend JAR entry: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close direct backend JAR writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close direct backend JAR: %v", err)
}
return jarPath
}