Skip to content

Commit 9e6e6be

Browse files
committed
self updater manual update boundaries
1 parent 652f648 commit 9e6e6be

16 files changed

Lines changed: 480 additions & 45 deletions

backend/api/handlers/system.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,15 @@ func (h *SystemHandler) CheckUpgradeAvailable(ctx context.Context, input *CheckU
505505
canUpgrade, err := h.upgradeService.CanUpgrade(ctx)
506506
if err != nil {
507507
slog.Debug("System upgrade check failed", "error", err)
508+
if errors.Is(err, services.ErrManualUpdateRequired) {
509+
return &CheckUpgradeOutput{
510+
Body: UpgradeCheckResultData{
511+
CanUpgrade: false,
512+
Error: false,
513+
Message: strings.TrimPrefix(err.Error(), services.ErrManualUpdateRequired.Error()+": "),
514+
},
515+
}, nil
516+
}
508517
return &CheckUpgradeOutput{
509518
Body: UpgradeCheckResultData{
510519
CanUpgrade: false,
@@ -547,6 +556,9 @@ func (h *SystemHandler) TriggerUpgrade(ctx context.Context, input *TriggerUpgrad
547556
if errors.Is(err, services.ErrUpgradeInProgress) {
548557
return nil, huma.Error409Conflict((&common.UpgradeTriggerError{Err: err}).Error())
549558
}
559+
if errors.Is(err, services.ErrManualUpdateRequired) {
560+
return nil, huma.Error409Conflict(strings.TrimPrefix(err.Error(), services.ErrManualUpdateRequired.Error()+": "))
561+
}
550562

551563
return nil, huma.Error500InternalServerError((&common.UpgradeTriggerError{Err: err}).Error())
552564
}

backend/cli/upgrade/upgrade.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,18 @@ func upgradeContainer(ctx context.Context, dockerClient *client.Client, oldConta
419419
return fmt.Errorf("stop old container: %w", err)
420420
}
421421

422+
if shouldRunMigratorForContainerInternal(oldContainer) {
423+
fmt.Println("PROGRESS:73:Running database migrator")
424+
slog.Info("Running database migrator before starting new container", "image", newImage)
425+
if err := runMigratorContainerInternal(ctx, dockerClient, oldContainer, newImage, hostConfig, networkConfig, apiVersion); err != nil {
426+
_, _ = dockerClient.ContainerStart(ctx, oldContainer.ID, client.ContainerStartOptions{})
427+
_, _ = dockerClient.ContainerRename(ctx, oldContainer.ID, client.ContainerRenameOptions{NewName: originalName})
428+
return fmt.Errorf("run database migrator: %w", err)
429+
}
430+
} else {
431+
slog.Info("Skipping database migrator for non-server Arcane target", "container", originalName)
432+
}
433+
422434
fmt.Println("PROGRESS:75:Creating new container")
423435
slog.Info("Creating new container", "name", originalName)
424436
resp, err := libarcane.ContainerCreateWithCompatibilityForAPIVersion(ctx, dockerClient, client.ContainerCreateOptions{
@@ -460,6 +472,86 @@ func upgradeContainer(ctx context.Context, dockerClient *client.Client, oldConta
460472
return nil
461473
}
462474

475+
func shouldRunMigratorForContainerInternal(cont container.InspectResponse) bool {
476+
if cont.Config == nil {
477+
return false
478+
}
479+
480+
return !isAgentContainer(cont)
481+
}
482+
483+
func runMigratorContainerInternal(
484+
ctx context.Context,
485+
dockerClient *client.Client,
486+
oldContainer container.InspectResponse,
487+
image string,
488+
baseHostConfig *container.HostConfig,
489+
networkConfig *network.NetworkingConfig,
490+
apiVersion string,
491+
) error {
492+
if oldContainer.Config == nil {
493+
return fmt.Errorf("container config is unavailable")
494+
}
495+
496+
migratorConfig := *oldContainer.Config
497+
migratorConfig.Image = image
498+
migratorConfig.Entrypoint = []string{"/app/arcane-migrator"}
499+
migratorConfig.Cmd = []string{"up"}
500+
migratorConfig.ExposedPorts = nil
501+
migratorConfig.Healthcheck = nil
502+
503+
migratorHostConfig := &container.HostConfig{}
504+
if baseHostConfig != nil {
505+
copiedHostConfig := *baseHostConfig
506+
migratorHostConfig = &copiedHostConfig
507+
}
508+
migratorHostConfig.AutoRemove = false
509+
migratorHostConfig.PortBindings = nil
510+
migratorHostConfig.PublishAllPorts = false
511+
migratorHostConfig.RestartPolicy = container.RestartPolicy{Name: "no"}
512+
513+
migratorName := fmt.Sprintf("%s-migrator-%d", strings.TrimPrefix(oldContainer.Name, "/"), time.Now().UnixNano())
514+
resp, err := libarcane.ContainerCreateWithCompatibilityForAPIVersion(ctx, dockerClient, client.ContainerCreateOptions{
515+
Config: &migratorConfig,
516+
HostConfig: migratorHostConfig,
517+
NetworkingConfig: networkConfig,
518+
Name: migratorName,
519+
}, apiVersion)
520+
if err != nil {
521+
return fmt.Errorf("create migrator container: %w", err)
522+
}
523+
524+
removeMigrator := true
525+
defer func() {
526+
if removeMigrator {
527+
if _, err := dockerClient.ContainerRemove(ctx, resp.ID, client.ContainerRemoveOptions{Force: true}); err != nil {
528+
slog.Warn("Failed to remove migrator container", "id", resp.ID[:12], "error", err)
529+
}
530+
}
531+
}()
532+
533+
if _, err := dockerClient.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil {
534+
return fmt.Errorf("start migrator container: %w", err)
535+
}
536+
537+
waitResult := dockerClient.ContainerWait(ctx, resp.ID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
538+
select {
539+
case err := <-waitResult.Error:
540+
if err != nil {
541+
return fmt.Errorf("wait for migrator container: %w", err)
542+
}
543+
case status := <-waitResult.Result:
544+
if status.StatusCode != 0 {
545+
return fmt.Errorf("migrator container exited with status %d", status.StatusCode)
546+
}
547+
case <-ctx.Done():
548+
return ctx.Err()
549+
}
550+
551+
slog.Info("Database migrator completed successfully", "container", migratorName)
552+
return nil
553+
}
554+
463555
// looksLikeContainerID checks if a string looks like a Docker container ID
464556
// (12 or 64 lowercase hex characters, which Docker auto-generates as hostnames)
465557
func looksLikeContainerID(s string) bool {

backend/internal/services/system_upgrade_service.go

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import (
2121
)
2222

2323
var (
24-
ErrNotRunningInDocker = errors.New("arcane is not running in a Docker container")
25-
ErrContainerNotFound = errors.New("could not find Arcane container")
26-
ErrUpgradeInProgress = errors.New("an upgrade is already in progress")
27-
ErrDockerSocketAccess = errors.New("docker socket is not accessible")
28-
ArcaneUpgraderImage = "ghcr.io/getarcaneapp/arcane:latest"
24+
ErrNotRunningInDocker = errors.New("arcane is not running in a Docker container")
25+
ErrContainerNotFound = errors.New("could not find Arcane container")
26+
ErrUpgradeInProgress = errors.New("an upgrade is already in progress")
27+
ErrDockerSocketAccess = errors.New("docker socket is not accessible")
28+
ErrManualUpdateRequired = errors.New("manual update required")
29+
ArcaneUpgraderImage = "ghcr.io/getarcaneapp/arcane:latest"
2930
)
3031

3132
type SystemUpgradeService struct {
@@ -70,40 +71,61 @@ func (s *SystemUpgradeService) CanUpgrade(ctx context.Context) (bool, error) {
7071
return false, err
7172
}
7273

74+
if err := s.checkManualUpdateRequirement(ctx); err != nil {
75+
return false, err
76+
}
77+
7378
return true, nil
7479
}
7580

7681
// TriggerUpgradeViaCLI spawns the upgrade CLI command in a separate container
7782
// This avoids self-termination issues by running the upgrade from outside
7883
func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user models.User) error {
84+
return s.TriggerUpgradeViaCLIForContainer(ctx, user, "")
85+
}
86+
87+
// TriggerUpgradeViaCLIForContainer spawns the upgrade CLI command targeting a
88+
// specific Arcane container. An empty container ID keeps the legacy self-targeting behavior.
89+
func (s *SystemUpgradeService) TriggerUpgradeViaCLIForContainer(ctx context.Context, user models.User, containerID string) error {
90+
if err := s.checkManualUpdateRequirement(ctx); err != nil {
91+
return err
92+
}
93+
7994
if !s.upgrading.CompareAndSwap(false, true) {
8095
return ErrUpgradeInProgress
8196
}
8297
defer s.upgrading.Store(false)
8398

84-
// Get current container name
85-
containerId, err := s.getCurrentContainerID()
86-
if err != nil {
87-
return fmt.Errorf("get current container: %w", err)
99+
targetContainerID := strings.TrimSpace(containerID)
100+
if targetContainerID == "" {
101+
var err error
102+
targetContainerID, err = s.getCurrentContainerID()
103+
if err != nil {
104+
return fmt.Errorf("get current container: %w", err)
105+
}
88106
}
89107

90-
currentContainer, err := s.findArcaneContainer(ctx, containerId)
108+
targetContainer, err := s.findArcaneContainer(ctx, targetContainerID)
91109
if err != nil {
92110
return fmt.Errorf("inspect container: %w", err)
93111
}
94112

95-
containerName := strings.TrimPrefix(currentContainer.Name, "/")
113+
logContainerID := targetContainer.ID
114+
if logContainerID == "" {
115+
logContainerID = targetContainerID
116+
}
117+
containerName := strings.TrimPrefix(targetContainer.Name, "/")
96118

97119
// Determine binary path based on container type (agent vs main)
98120
binaryPath := "/app/arcane"
99-
if currentContainer.Config != nil {
100-
binaryPath = determineUpgradeBinaryPathInternal(currentContainer.Config.Labels)
121+
if targetContainer.Config != nil {
122+
binaryPath = determineUpgradeBinaryPathInternal(targetContainer.Config.Labels)
101123
}
102124

103125
// Log upgrade event
104126
metadata := models.JSON{
105127
"action": "system_upgrade_cli",
106-
"containerId": containerId,
128+
"containerId": logContainerID,
107129
"containerName": containerName,
108130
"method": "cli",
109131
}
@@ -113,8 +135,8 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo
113135

114136
// Use the same image reference as the currently running Arcane container for the upgrader.
115137
// This avoids mismatches where a newer/older upgrader CLI expects different behavior.
116-
if currentContainer.Config != nil {
117-
if img := strings.TrimSpace(currentContainer.Config.Image); img != "" {
138+
if targetContainer.Config != nil {
139+
if img := strings.TrimSpace(targetContainer.Config.Image); img != "" {
118140
ArcaneUpgraderImage = img
119141
}
120142
}
@@ -154,7 +176,7 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo
154176
slog.Info("Upgrader image pulled successfully", "image", ArcaneUpgraderImage)
155177

156178
// Try to get the /app/data mount from current container so upgrade logs persist.
157-
appDataMount := dockerutils.MountForDestination(currentContainer.Mounts, "/app/data", "/app/data")
179+
appDataMount := dockerutils.MountForDestination(targetContainer.Mounts, "/app/data", "/app/data")
158180
if appDataMount == nil {
159181
slog.Warn("Could not detect /app/data mount; upgrader logs may not persist")
160182
} else {
@@ -210,6 +232,22 @@ func (s *SystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user mo
210232
return nil
211233
}
212234

235+
func (s *SystemUpgradeService) checkManualUpdateRequirement(ctx context.Context) error {
236+
if s.versionService == nil {
237+
return nil
238+
}
239+
240+
required, message := s.versionService.ManualUpdateRequirement(ctx, "", "")
241+
if !required {
242+
return nil
243+
}
244+
if strings.TrimSpace(message) == "" {
245+
message = ErrManualUpdateRequired.Error()
246+
}
247+
248+
return fmt.Errorf("%w: %s", ErrManualUpdateRequired, message)
249+
}
250+
213251
func determineUpgradeBinaryPathInternal(labels map[string]string) string {
214252
if libupdater.IsArcaneAgentContainer(labels) {
215253
return "/app/arcane-agent"

backend/internal/services/system_upgrade_service_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,14 @@ func TestSystemUpgradeService_ErrorVariables(t *testing.T) {
3939
require.Error(t, ErrContainerNotFound)
4040
require.Error(t, ErrUpgradeInProgress)
4141
require.Error(t, ErrDockerSocketAccess)
42+
require.Error(t, ErrManualUpdateRequired)
4243

4344
// Test error messages
4445
require.Equal(t, "arcane is not running in a Docker container", ErrNotRunningInDocker.Error())
4546
require.Equal(t, "could not find Arcane container", ErrContainerNotFound.Error())
4647
require.Equal(t, "an upgrade is already in progress", ErrUpgradeInProgress.Error())
4748
require.Equal(t, "docker socket is not accessible", ErrDockerSocketAccess.Error())
49+
require.Equal(t, "manual update required", ErrManualUpdateRequired.Error())
4850
}
4951

5052
// TestSystemUpgradeService_UpgradingFlag_ConcurrentAccess tests upgrading flag

backend/internal/services/updater_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type UpdaterService struct {
4747
}
4848

4949
type selfUpgradeService interface {
50-
TriggerUpgradeViaCLI(ctx context.Context, user models.User) error
50+
TriggerUpgradeViaCLIForContainer(ctx context.Context, user models.User, containerID string) error
5151
}
5252

5353
// NewUpdaterService constructs an UpdaterService with the dependencies needed
@@ -1617,7 +1617,7 @@ func (s *UpdaterService) triggerSelfUpdateViaCLIInternal(ctx context.Context, so
16171617
"container", containerName,
16181618
"instanceType", instanceType)
16191619

1620-
if err := s.upgradeService.TriggerUpgradeViaCLI(ctx, systemUser); err != nil {
1620+
if err := s.upgradeService.TriggerUpgradeViaCLIForContainer(ctx, systemUser, containerID); err != nil {
16211621
wrappedErr := fmt.Errorf("CLI upgrade failed: %w", err)
16221622
slog.WarnContext(ctx, source+": CLI upgrade failed",
16231623
"containerId", containerID,

backend/internal/services/updater_service_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ import (
2929

3030
// mockSystemUpgradeService is a simple mock implementation for testing
3131
type mockSystemUpgradeService struct {
32-
triggerCalled bool
33-
triggerError error
34-
capturedUser *models.User
35-
canUpgrade bool
32+
triggerCalled bool
33+
triggerError error
34+
capturedUser *models.User
35+
capturedContainerID string
36+
canUpgrade bool
3637
}
3738

3839
func (m *mockSystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, user models.User) error {
@@ -41,6 +42,13 @@ func (m *mockSystemUpgradeService) TriggerUpgradeViaCLI(ctx context.Context, use
4142
return m.triggerError
4243
}
4344

45+
func (m *mockSystemUpgradeService) TriggerUpgradeViaCLIForContainer(ctx context.Context, user models.User, containerID string) error {
46+
m.triggerCalled = true
47+
m.capturedUser = &user
48+
m.capturedContainerID = containerID
49+
return m.triggerError
50+
}
51+
4452
func (m *mockSystemUpgradeService) CanUpgrade(ctx context.Context) (bool, error) {
4553
return m.canUpgrade, nil
4654
}
@@ -88,6 +96,7 @@ func TestUpdaterService_ArcaneAgentLabel_TriggersCLIUpgrade(t *testing.T) {
8896
assert.True(t, mockUpgrade.triggerCalled, "TriggerUpgradeViaCLI should have been called for Arcane agent container")
8997
assert.NotNil(t, mockUpgrade.capturedUser)
9098
assert.Equal(t, systemUser.ID, mockUpgrade.capturedUser.ID)
99+
assert.Equal(t, "container-1", mockUpgrade.capturedContainerID)
91100
}
92101

93102
// TestUpdaterService_NonArcaneLabel_DoesNotTriggerCLI verifies that containers without

0 commit comments

Comments
 (0)