feat: backend native executor implement
This commit is contained in:
@@ -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}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user