Skip to content

Reference v0.14 stack upgrade

github-actions[bot] edited this page Jan 19, 2026 · 1 revision

v0.14 – Stack Upgrade Feature

Specification for the Stack Upgrade Feature: In-Place Updates of deployed Stacks.

Overview

Feature Scope Priority
Upgrade Detection Backend High
Upgrade API Backend High
Upgrade UI Frontend High
Variable Migration Backend Medium
Upgrade History Backend + DB Medium

1. Motivation

For complete rollback support, a stack must be able to be upgraded:

  • Rollback requires upgrade: Without upgrade, there are no snapshots to return to
  • Version management: Users expect stack upgrades without data loss
  • Continuous Delivery: Automated pipelines require upgrade APIs

Current State

The backend already supports In-Place Upgrades (DeploymentService.cs:693-728):

  • GetWithSnapshotsByStackName() checks for existing deployments
  • Snapshot is automatically created before upgrade
  • Deployment is reused (preserves snapshots)

What's missing:

  • UI to trigger an upgrade
  • Version detection (is update available?)
  • Explicit upgrade flow (vs. new deployment)

2. Domain Model

2.1 Deployment Aggregate Extension

The Deployment aggregate receives new properties for upgrade tracking:

public class Deployment : AggregateRoot<DeploymentId>
{
    // Existing properties...

    // NEW: Upgrade-History Tracking
    public DateTime? LastUpgradedAt { get; private set; }
    public string? PreviousVersion { get; private set; }
    public int UpgradeCount { get; private set; } = 0;

    /// <summary>
    /// Records an upgrade event, storing the previous version for reference.
    /// </summary>
    public void RecordUpgrade(string previousVersion, string newVersion)
    {
        PreviousVersion = previousVersion;
        LastUpgradedAt = SystemClock.UtcNow;
        UpgradeCount++;

        AddDomainEvent(new DeploymentUpgraded(
            Id,
            previousVersion,
            newVersion,
            LastUpgradedAt.Value));
    }
}

2.2 New Domain Event

// Domain/Deployment/Events/DeploymentUpgraded.cs
public record DeploymentUpgraded(
    DeploymentId DeploymentId,
    string PreviousVersion,
    string NewVersion,
    DateTime UpgradedAt) : IDomainEvent;

3. Application Layer

3.1 Use Case: UpgradeStack

Explicit upgrade use case that uses the existing deploy flow but with clear upgrade semantics:

// Application/UseCases/Deployments/UpgradeStack/UpgradeStackCommand.cs
public record UpgradeStackCommand(
    string EnvironmentId,
    string DeploymentId,
    string NewStackId,           // CatalogStackId with new version
    Dictionary<string, string> Variables,
    string? SessionId = null) : IRequest<UpgradeStackResponse>;

public record UpgradeStackResponse
{
    public bool Success { get; init; }
    public string? Message { get; init; }
    public string? DeploymentId { get; init; }
    public string? PreviousVersion { get; init; }
    public string? NewVersion { get; init; }
    public string? SnapshotId { get; init; }
    public List<string>? Errors { get; init; }
}
// Application/UseCases/Deployments/UpgradeStack/UpgradeStackHandler.cs
public class UpgradeStackHandler : IRequestHandler<UpgradeStackCommand, UpgradeStackResponse>
{
    public async Task<UpgradeStackResponse> Handle(UpgradeStackCommand request, CancellationToken ct)
    {
        // 1. Validate existing deployment
        var deployment = await _deploymentRepository.GetWithSnapshotsAsync(
            new DeploymentId(Guid.Parse(request.DeploymentId)), ct);

        if (deployment == null)
            return UpgradeStackResponse.Failed("Deployment not found");

        if (deployment.Status != DeploymentStatus.Running)
            return UpgradeStackResponse.Failed("Only running deployments can be upgraded");

        // 2. Load new stack version from catalog
        var newStack = await _productSourceService.GetStackAsync(request.NewStackId, ct);
        if (newStack == null)
            return UpgradeStackResponse.Failed($"Stack '{request.NewStackId}' not found");

        // 3. Validate upgrade path (optional: version comparison)
        var previousVersion = deployment.StackVersion;
        var newVersion = newStack.ProductVersion;

        // 4. Create pre-upgrade snapshot
        var snapshot = deployment.CreateSnapshot($"Before upgrade to {newVersion}");
        var snapshotId = snapshot?.Id;

        // 5. Merge variables (keep existing, overlay new)
        var mergedVariables = MergeVariables(
            deployment.Variables,
            request.Variables,
            newStack.Variables);

        // 6. Deploy new version using existing flow
        var deployResult = await _mediator.Send(new DeployStackCommand(
            request.EnvironmentId,
            request.NewStackId,
            deployment.StackName, // Keep same stack name
            mergedVariables,
            request.SessionId), ct);

        if (!deployResult.Success)
        {
            return new UpgradeStackResponse
            {
                Success = false,
                Message = deployResult.Message,
                Errors = deployResult.Errors
            };
        }

        // 7. Record upgrade in deployment
        deployment.RecordUpgrade(previousVersion, newVersion);
        await _deploymentRepository.UpdateAsync(deployment, ct);

        return new UpgradeStackResponse
        {
            Success = true,
            Message = $"Successfully upgraded from {previousVersion} to {newVersion}",
            DeploymentId = request.DeploymentId,
            PreviousVersion = previousVersion,
            NewVersion = newVersion,
            SnapshotId = snapshotId?.Value.ToString()
        };
    }

    /// <summary>
    /// Merges variables from existing deployment with new values and new stack defaults.
    /// Priority: Explicit request > Existing deployment > Stack defaults
    /// </summary>
    private Dictionary<string, string> MergeVariables(
        IReadOnlyDictionary<string, string> existing,
        Dictionary<string, string> requested,
        IReadOnlyList<Variable> stackVariables)
    {
        var merged = new Dictionary<string, string>();

        // Start with stack defaults
        foreach (var v in stackVariables)
        {
            if (!string.IsNullOrEmpty(v.DefaultValue))
                merged[v.Name] = v.DefaultValue;
        }

        // Overlay with existing deployment values
        foreach (var kvp in existing)
            merged[kvp.Key] = kvp.Value;

        // Overlay with explicitly requested values
        foreach (var kvp in requested)
            merged[kvp.Key] = kvp.Value;

        return merged;
    }
}

3.2 Use Case: CheckUpgradeAvailable

Query to check if an upgrade is available:

// Application/UseCases/Deployments/CheckUpgrade/CheckUpgradeQuery.cs
public record CheckUpgradeQuery(
    string EnvironmentId,
    string DeploymentId) : IRequest<CheckUpgradeResponse>;

public record CheckUpgradeResponse
{
    public bool Success { get; init; }
    public string? Message { get; init; }

    public bool UpgradeAvailable { get; init; }
    public string? CurrentVersion { get; init; }
    public string? LatestVersion { get; init; }
    public string? LatestStackId { get; init; }

    // NEW: Information about the new version
    public List<string>? NewVariables { get; init; }      // Newly added variables
    public List<string>? RemovedVariables { get; init; }  // Removed variables
    public List<string>? ChangedServices { get; init; }   // Changed services
}
// Application/UseCases/Deployments/CheckUpgrade/CheckUpgradeHandler.cs
public class CheckUpgradeHandler : IRequestHandler<CheckUpgradeQuery, CheckUpgradeResponse>
{
    public async Task<CheckUpgradeResponse> Handle(CheckUpgradeQuery request, CancellationToken ct)
    {
        // 1. Get deployment
        var deployment = await _deploymentRepository.GetAsync(
            new DeploymentId(Guid.Parse(request.DeploymentId)), ct);
        if (deployment == null)
            return new CheckUpgradeResponse { Success = false, Message = "Deployment not found" };

        // 2. Get current catalog stack (by CatalogStackId)
        if (string.IsNullOrEmpty(deployment.CatalogStackId))
            return new CheckUpgradeResponse
            {
                Success = true,
                UpgradeAvailable = false,
                Message = "Deployment was not created from catalog (manual YAML deployment)"
            };

        // 3. Find latest version of this product in catalog
        var productId = ExtractProductId(deployment.CatalogStackId);
        var product = await _productSourceService.GetProductAsync(productId, ct);

        if (product == null)
            return new CheckUpgradeResponse
            {
                Success = true,
                UpgradeAvailable = false,
                Message = "Product no longer available in catalog"
            };

        // 4. Compare versions
        var currentVersion = deployment.StackVersion;
        var latestVersion = product.ProductVersion;

        var upgradeAvailable = CompareVersions(currentVersion, latestVersion) < 0;

        // 5. Analyze changes between versions (if upgrade available)
        List<string>? newVars = null;
        List<string>? removedVars = null;
        if (upgradeAvailable)
        {
            var currentStack = await _productSourceService.GetStackAsync(deployment.CatalogStackId, ct);
            var latestStack = product.DefaultStack;

            (newVars, removedVars) = CompareVariables(
                currentStack?.Variables ?? [],
                latestStack.Variables);
        }

        return new CheckUpgradeResponse
        {
            Success = true,
            UpgradeAvailable = upgradeAvailable,
            CurrentVersion = currentVersion,
            LatestVersion = latestVersion,
            LatestStackId = $"{productId}:{product.DefaultStack.Name}",
            NewVariables = newVars,
            RemovedVariables = removedVars
        };
    }

    /// <summary>
    /// Compares semantic versions. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2.
    /// </summary>
    private static int CompareVersions(string? v1, string? v2)
    {
        if (string.IsNullOrEmpty(v1) && string.IsNullOrEmpty(v2)) return 0;
        if (string.IsNullOrEmpty(v1)) return -1;
        if (string.IsNullOrEmpty(v2)) return 1;

        // Try semantic version comparison
        if (Version.TryParse(v1.TrimStart('v', 'V'), out var ver1) &&
            Version.TryParse(v2.TrimStart('v', 'V'), out var ver2))
        {
            return ver1.CompareTo(ver2);
        }

        // Fallback to string comparison
        return string.Compare(v1, v2, StringComparison.OrdinalIgnoreCase);
    }
}

4. API Endpoints

4.1 Upgrade Stack

POST /api/environments/{environmentId}/deployments/{deploymentId}/upgrade

Request:
{
  "stackId": "example-stacks:wordpress:default",  // New version's catalog ID
  "variables": {                                   // Optional: override variables
    "MYSQL_ROOT_PASSWORD": "newpassword"
  },
  "sessionId": "optional-client-session"          // For SignalR progress
}

Response (200 OK):
{
  "success": true,
  "message": "Successfully upgraded from 5.9.0 to 6.0.0",
  "deploymentId": "abc-123",
  "previousVersion": "5.9.0",
  "newVersion": "6.0.0",
  "snapshotId": "snap-456"
}

Response (400 Bad Request):
{
  "success": false,
  "message": "Only running deployments can be upgraded",
  "errors": ["Deployment status is 'Failed'"]
}

4.2 Check Upgrade Available

GET /api/environments/{environmentId}/deployments/{deploymentId}/upgrade/check

Response (200 OK):
{
  "success": true,
  "upgradeAvailable": true,
  "currentVersion": "5.9.0",
  "latestVersion": "6.0.0",
  "latestStackId": "example-stacks:wordpress:default",
  "newVariables": ["NEW_FEATURE_FLAG"],
  "removedVariables": ["DEPRECATED_SETTING"],
  "changedServices": ["web", "worker"]
}

4.3 Redeploy (Same Version)

For redeploy without version change (e.g., after config change):

POST /api/environments/{environmentId}/deployments/{deploymentId}/redeploy

Request:
{
  "variables": {
    "MYSQL_ROOT_PASSWORD": "changedpassword"
  },
  "createSnapshot": true,
  "sessionId": "optional-client-session"
}

Response:
{
  "success": true,
  "message": "Successfully redeployed wordpress",
  "deploymentId": "abc-123",
  "snapshotId": "snap-789"
}

5. Database Schema

5.1 Deployment Table Extension

-- Migration: Add upgrade tracking columns
ALTER TABLE Deployments ADD COLUMN LastUpgradedAt TEXT NULL;
ALTER TABLE Deployments ADD COLUMN PreviousVersion TEXT NULL;
ALTER TABLE Deployments ADD COLUMN UpgradeCount INTEGER NOT NULL DEFAULT 0;

5.2 EF Core Configuration

// Infrastructure.DataAccess/Configurations/DeploymentConfiguration.cs
builder.Property(d => d.LastUpgradedAt);
builder.Property(d => d.PreviousVersion);
builder.Property(d => d.UpgradeCount).HasDefaultValue(0);

6. Frontend UI

6.1 Deployment Detail Page - Upgrade Banner

When an upgrade is available, a banner is displayed:

┌─────────────────────────────────────────────────────────────────────┐
│ ⬆️  Update Available                                        [Dismiss] │
│                                                                      │
│ A new version of WordPress is available:                            │
│ Current: 5.9.0  →  Latest: 6.0.0                                    │
│                                                                      │
│ Changes:                                                            │
│ • 2 new configuration options added                                  │
│ • 1 deprecated option removed                                        │
│                                                                      │
│                                              [View Details] [Upgrade]│
└─────────────────────────────────────────────────────────────────────┘

6.2 Upgrade Dialog

┌─────────────────────────────────────────────────────────────────────┐
│ Upgrade WordPress                                               [X]  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  Current Version: 5.9.0                                             │
│  Target Version:  6.0.0                                             │
│                                                                      │
│  ─────────────────────────────────────────────────────────────────  │
│                                                                      │
│  📦 Configuration                                                    │
│                                                                      │
│  ┌─ Existing Variables (preserved) ─────────────────────────────┐   │
│  │ WORDPRESS_DB_HOST        mysql                               │   │
│  │ WORDPRESS_DB_NAME        wordpress                           │   │
│  │ WORDPRESS_DB_USER        admin                               │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                                                      │
│  ┌─ New Variables (required) ───────────────────────────────────┐   │
│  │ NEW_CACHE_ENABLED        [x] Enable  [ ] Disable             │   │
│  │ NEW_CACHE_TTL            [________] (default: 3600)          │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                                                      │
│  ⚠️  Removed in this version: OLD_DEPRECATED_SETTING                │
│                                                                      │
│  ─────────────────────────────────────────────────────────────────  │
│                                                                      │
│  [x] Create snapshot before upgrade (recommended)                   │
│                                                                      │
│  ℹ️  You can rollback to version 5.9.0 after the upgrade            │
│                                                                      │
│                              [Cancel]  [Start Upgrade]              │
└─────────────────────────────────────────────────────────────────────┘

6.3 Upgrade Progress (via SignalR)

Uses the existing DeploymentHub for progress updates:

┌─────────────────────────────────────────────────────────────────────┐
│ Upgrading WordPress...                                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  [████████████████████░░░░░░░░░░░░░░░░] 60%                         │
│                                                                      │
│  ✓ Created snapshot (5.9.0)                                         │
│  ✓ Pulled new images                                                │
│  ⏳ Stopping old containers...                                       │
│  ○ Starting new containers                                          │
│  ○ Verifying health                                                 │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

6.4 New Components

Component Description
UpgradeBanner.tsx Banner on DeploymentDetail when update is available
UpgradeDialog.tsx Modal dialog for upgrade configuration
VariableMigration.tsx Shows new/removed variables
UpgradeProgress.tsx SignalR-based progress display

6.5 API Client Extension

// api/deployments.ts

export interface UpgradeCheckResponse {
  success: boolean;
  message?: string;
  upgradeAvailable: boolean;
  currentVersion?: string;
  latestVersion?: string;
  latestStackId?: string;
  newVariables?: string[];
  removedVariables?: string[];
}

export interface UpgradeRequest {
  stackId: string;
  variables?: Record<string, string>;
  sessionId?: string;
}

export interface UpgradeResponse {
  success: boolean;
  message?: string;
  deploymentId?: string;
  previousVersion?: string;
  newVersion?: string;
  snapshotId?: string;
  errors?: string[];
}

export async function checkUpgrade(
  environmentId: string,
  deploymentId: string
): Promise<UpgradeCheckResponse> {
  return client.get(`/environments/${environmentId}/deployments/${deploymentId}/upgrade/check`);
}

export async function upgradeDeployment(
  environmentId: string,
  deploymentId: string,
  request: UpgradeRequest
): Promise<UpgradeResponse> {
  return client.post(
    `/environments/${environmentId}/deployments/${deploymentId}/upgrade`,
    request
  );
}

7. Upgrade Strategies

7.1 Standard Upgrade (In-Place)

1. Create snapshot
2. Pull new images
3. Stop old containers
4. Start new containers (same volumes, networks)
5. Wait for health check
6. Set status to Running

Advantages:

  • Simple to implement
  • Volumes are preserved
  • Fast

Disadvantages:

  • Short downtime during container switch
  • No zero-downtime possible

7.2 Recreate Upgrade

Like standard, but containers are recreated (not just stopped/started):

docker-compose up -d --force-recreate

7.3 Rolling Upgrade (Future: Post v1.0)

For multi-replica services:

1. Stop service 1/3, restart, health check
2. Stop service 2/3, restart, health check
3. Stop service 3/3, restart, health check

8. Error Handling

8.1 Upgrade Errors

Error Handling
Image pull failed Abort, no changes, rollback possible
Container start failed No rollback anymore (Point of No Return crossed)
Health check failed No rollback (containers already running)
Snapshot creation failed Warning, continue upgrade

8.2 Point of No Return

From the moment containers are started, automatic rollback is no longer possible:

┌─────────────────────────────────────────────────────────────────┐
│ Upgrade v1.0 → v2.0                                             │
│                                                                 │
│ 1. [ ] Validate new version               ─┐                    │
│ 2. [ ] Create Snapshot                     │  Rollback possible │
│ 3. [ ] Stop old containers                 │  on error          │
│ 4. [ ] Pull new images                    ─┘                    │
│ ─────────────────────────────────────────────────────────────── │
│ 5. [ ] Start Container                    ← POINT OF NO RETURN  │
│ 6. [ ] Health Check                                             │
│                                                                 │
│ From step 5: No rollback possible                               │
└─────────────────────────────────────────────────────────────────┘

Rationale: Once containers are started, irreversible changes can occur (DB migrations, init containers, etc.). An automatic rollback would be dangerous in this case.


9. Implementation Order

Phase 1: Backend (High Priority)

  1. Extend Domain Model (LastUpgradedAt, PreviousVersion, UpgradeCount)
  2. Create Domain Event DeploymentUpgraded
  3. Implement CheckUpgradeQuery/Handler
  4. Implement UpgradeStackCommand/Handler
  5. Add API Endpoints
  6. Create EF Core Migration

Phase 2: Frontend (High Priority)

  1. Extend API Client
  2. Implement UpgradeBanner.tsx (for DeploymentDetail)
  3. Implement UpgradeDialog.tsx
  4. VariableMigration.tsx for variable diff
  5. Integration in DeploymentDetailPage

Phase 3: Polish (Medium Priority)

  1. UpgradeProgress.tsx with SignalR
  2. Automatic rollback on error
  3. Display upgrade history in UI

10. Tests

Unit Tests

  • UpgradeStackHandlerTests.cs - Happy path, error cases
  • CheckUpgradeHandlerTests.cs - Version comparison, edge cases
  • DeploymentUpgradeTests.cs - Domain model upgrade recording

Integration Tests

  • Upgrade E2E with Docker TestContainers
  • API Endpoint Tests
  • Variable migration tests

Frontend Tests

  • UpgradeBanner rendering based on state
  • UpgradeDialog form validation
  • Progress updates via SignalR

11. Out of Scope

The following features are deferred to later versions:

  • Rolling Upgrades (Multi-Replica) → Post v1.0
  • Blue/Green Deployments → Post v1.0
  • Canary Deployments → Post v1.0
  • Automatic Upgrades (GitOps) → v0.19 (CI/CD Integration)
  • Upgrade Approval Workflow → Post v1.0
  • Downgrade Protection (only allow higher versions) → Optional

12. Dependencies

Prerequisites

  • v0.13 Health Monitoring (for health checks after upgrade)
  • v0.14 Rollback Feature (for snapshot creation)

No New NuGet/NPM Packages Required

The feature uses existing infrastructure:

  • MediatR for CQRS
  • SignalR for Progress
  • EF Core for Persistence

13. Design Decisions

13.1 No Downgrade Support

Decision: Downgrades are not supported for v1. Only upgrades to newer versions are allowed.

Rationale:

  • Significantly simplifies implementation
  • Avoids DB incompatibilities (older code with newer schema)
  • Clear upgrade direction: forward only
  • Rollback (via snapshot) covers the "back" case

Implementation:

// In UpgradeStackHandler
var comparison = VersionComparer.Compare(currentVersion, newVersion);

if (comparison == null)
{
    return UpgradeStackResponse.Failed(
        "Version comparison not possible. Ensure both versions use SemVer format.");
}

if (comparison >= 0) // Same version or downgrade
{
    return UpgradeStackResponse.Failed(
        comparison == 0
            ? $"Already running version {currentVersion}"
            : $"Downgrade from {currentVersion} to {newVersion} is not supported. Use rollback instead.");
}

UI: In the upgrade dialog, only newer versions are offered as targets. Older versions are not displayed.

13.2 Concurrency: Environment-Level Blocking

Decision: Only one operation (Deploy/Upgrade/Remove) can run per environment at a time. No queue, but blocking with error message.

Rationale:

  • Simple to implement
  • Prevents race conditions in container operations
  • Admin can release stuck operations
  • Queue would be too complex for v1

Domain Model Extension:

// Domain/Deployment/Environments/Environment.cs
public class Environment : AggregateRoot<EnvironmentId>
{
    // Existing properties...

    public bool IsBusy { get; private set; }
    public string? CurrentOperation { get; private set; }
    public DateTime? OperationStartedAt { get; private set; }

    /// <summary>
    /// Starts an operation on this environment.
    /// Throws if another operation is already running.
    /// </summary>
    public void StartOperation(string operationDescription)
    {
        SelfAssertState(!IsBusy,
            $"Environment is busy: {CurrentOperation}");

        IsBusy = true;
        CurrentOperation = operationDescription;
        OperationStartedAt = SystemClock.UtcNow;
    }

    /// <summary>
    /// Marks the current operation as complete.
    /// </summary>
    public void CompleteOperation()
    {
        IsBusy = false;
        CurrentOperation = null;
        OperationStartedAt = null;
    }

    /// <summary>
    /// Force-releases a stuck operation. Admin function.
    /// </summary>
    public void ForceReleaseOperation()
    {
        if (IsBusy)
        {
            AddDomainEvent(new OperationForceReleased(Id, CurrentOperation));
        }
        CompleteOperation();
    }

    /// <summary>
    /// Checks if the current operation has exceeded the timeout.
    /// </summary>
    public bool IsOperationStuck(TimeSpan timeout)
    {
        return IsBusy &&
               OperationStartedAt.HasValue &&
               (SystemClock.UtcNow - OperationStartedAt.Value) > timeout;
    }
}

API Response when Environment is Busy:

{
  "success": false,
  "message": "Environment is busy",
  "error": {
    "code": "ENVIRONMENT_BUSY",
    "currentOperation": "Deploying wordpress",
    "startedAt": "2025-12-15T14:30:00Z"
  }
}

UI when Environment is Busy:

┌─────────────────────────────────────────────────────────────────────┐
│ ⏳ Environment Busy                                                  │
│                                                                      │
│ Another operation is in progress:                                   │
│ "Deploying wordpress" (started 2 minutes ago)                       │
│                                                                      │
│ Please wait for completion or contact an admin.                     │
│                                          [Refresh] [Force Release*] │
└─────────────────────────────────────────────────────────────────────┘
* Only visible for admins, after timeout (default: 10 minutes)

Database Schema:

ALTER TABLE Environments ADD COLUMN IsBusy INTEGER NOT NULL DEFAULT 0;
ALTER TABLE Environments ADD COLUMN CurrentOperation TEXT NULL;
ALTER TABLE Environments ADD COLUMN OperationStartedAt TEXT NULL;

13.3 Versioning: SemVer Only

Decision: For v1, only Semantic Versioning is supported. Non-SemVer versions are accepted with a warning, but upgrade detection does not work.

Rationale:

  • Clear rules for version comparison
  • Simple implementation
  • Industry standard

Implementation:

public static class VersionComparer
{
    /// <summary>
    /// Compares two SemVer versions.
    /// Returns: -1 (v1 &lt; v2), 0 (equal), 1 (v1 &gt; v2)
    /// Returns null if either version is not valid SemVer.
    /// </summary>
    public static int? Compare(string? v1, string? v2)
    {
        if (!TryParseSemVer(v1, out var ver1) ||
            !TryParseSemVer(v2, out var ver2))
        {
            return null;
        }

        return ver1.CompareTo(ver2);
    }

    public static bool IsUpgrade(string? current, string? target)
    {
        var comparison = Compare(current, target);
        return comparison.HasValue && comparison.Value < 0;
    }

    public static bool IsDowngrade(string? current, string? target)
    {
        var comparison = Compare(current, target);
        return comparison.HasValue && comparison.Value > 0;
    }

    public static bool IsSameVersion(string? v1, string? v2)
    {
        var comparison = Compare(v1, v2);
        return comparison.HasValue && comparison.Value == 0;
    }

    private static bool TryParseSemVer(string? version, out Version result)
    {
        result = null!;
        if (string.IsNullOrEmpty(version))
            return false;

        // Normalize: remove 'v' prefix
        var normalized = version.TrimStart('v', 'V');
        return Version.TryParse(normalized, out result!);
    }
}

Validation on Product Import:

// In LocalDirectoryProductSourceProvider
if (!string.IsNullOrEmpty(manifest.Metadata?.ProductVersion))
{
    if (!VersionComparer.TryParseSemVer(manifest.Metadata.ProductVersion, out _))
    {
        _logger.LogWarning(
            "Product {Name} has non-SemVer version '{Version}'. " +
            "Upgrade detection will not work for this product.",
            manifest.Metadata.Name,
            manifest.Metadata.ProductVersion);
    }
}

UI Behavior with Non-SemVer:

  • No "Upgrade Available" banner
  • Manual upgrade via Catalog possible
  • Warning: "Version comparison not available (non-SemVer format)"

13.4 Rollback = Manual Recovery Before Container Start

Decision: Rollback is only possible if the upgrade failed before container start. The user must trigger rollback manually.

Terminology:

Term Meaning Supported
Rollback Recovery after failed upgrade (before container start) Yes, manual
Downgrade Deliberate choice of an older version No (v1)

Rationale:

  • Clear semantics: Rollback = Recovery on error in early upgrade phases
  • Safe "Point of No Return": Once containers have been started, rollback is no longer possible
  • User retains control (manual trigger)
  • Avoids complex logic for migration containers and DB changes

Rollback Flow:

v1.0 (initial deploy)     → No rollback possible (no snapshot)
     ↓ Upgrade
     [Image Pull FAILED]  → Rollback to v1.0 possible (manual)
     ↓ User clicks Rollback
v1.0 restored             → Snapshot deleted
v1.0 (initial deploy)     → No rollback possible (no snapshot)
     ↓ Upgrade
     [Container Start OK] → POINT OF NO RETURN crossed
v1.1 (Running)            → No rollback possible anymore

When Rollback is Available:

  • Upgrade was started and snapshot created
  • Upgrade failed in phase 1-4 (Validation, Snapshot, Stop, Pull)
  • User clicks "Rollback" button manually

When Rollback is NOT Available:

  • Initial deployment (no snapshot)
  • Containers have already been started (phase 5+)
  • Upgrade completed successfully

UI on Failed Upgrade (before container start):

┌─────────────────────────────────────────────────────────────────────┐
│ ❌ Upgrade Failed                                                    │
│                                                                      │
│ Upgrade to v2.0 failed: Image 'myapp:2.0' not found                 │
│                                                                      │
│ Previous version v1.1 is available for rollback.                    │
│                                                                      │
│                    [View Logs]  [Retry]  [Rollback to v1.1]         │
└─────────────────────────────────────────────────────────────────────┘

UI on Failed Upgrade (after container start):

┌─────────────────────────────────────────────────────────────────────┐
│ ❌ Upgrade Failed                                                    │
│                                                                      │
│ Upgrade to v2.0 failed: Container 'api' health check failed         │
│                                                                      │
│ ⚠️ Rollback not available - containers were already started.        │
│    Please investigate logs or deploy a different version.           │
│                                                                      │
│                              [View Logs]  [Deploy Different Version]│
└─────────────────────────────────────────────────────────────────────┘

Implementation in Upgrade Handler:

public async Task<UpgradeStackResponse> Handle(UpgradeStackCommand request, CancellationToken ct)
{
    // 1. Create snapshot (before upgrade)
    var snapshot = deployment.CreateSnapshot($"Before upgrade to {newVersion}");

    // 2. Pre-Container Phases (Validation, Stop, Pull)
    var preResult = await ExecutePreContainerPhases(plan);
    if (!preResult.Success)
    {
        // Error before container start → Keep snapshot for manual rollback
        deployment.MarkAsFailed(preResult.Message);
        await _deploymentRepository.UpdateAsync(deployment, ct);

        return UpgradeStackResponse.Failed(
            preResult.Message,
            canRollback: snapshot != null,
            rollbackVersion: snapshot?.StackVersion);
    }

    // 3. POINT OF NO RETURN: Start containers
    deployment.ClearSnapshot(); // No rollback from here
    await _deploymentRepository.UpdateAsync(deployment, ct);

    var startResult = await StartContainers(plan);
    if (!startResult.Success)
    {
        deployment.MarkAsFailed(startResult.Message);
        await _deploymentRepository.UpdateAsync(deployment, ct);

        // No rollback possible - containers were already started
        return UpgradeStackResponse.Failed(
            startResult.Message,
            canRollback: false);
    }

    return UpgradeStackResponse.Success(...);
}

Domain Model:

public class Deployment : AggregateRoot<DeploymentId>
{
    // Snapshot is only held during upgrade (until container start)
    public DeploymentSnapshot? PendingUpgradeSnapshot { get; private set; }

    public DeploymentSnapshot? CreateSnapshot(string? description = null)
    {
        PendingUpgradeSnapshot = new DeploymentSnapshot { ... };
        return PendingUpgradeSnapshot;
    }

    public void RollbackToPrevious()
    {
        SelfAssertState(PendingUpgradeSnapshot != null,
            "No snapshot available for rollback.");
        SelfAssertState(Status == DeploymentStatus.Failed,
            "Rollback only available after failed upgrade (before container start)");

        // Restore from snapshot
        StackVersion = PendingUpgradeSnapshot.StackVersion;
        _variables.Clear();
        foreach (var (key, value) in PendingUpgradeSnapshot.Variables)
            _variables[key] = value;

        var snapshotId = PendingUpgradeSnapshot.Id;
        PendingUpgradeSnapshot = null;

        AddDomainEvent(new DeploymentRolledBack(Id, snapshotId, StackVersion));
    }

    public void ClearSnapshot()
    {
        // Point of No Return reached or upgrade successful
        PendingUpgradeSnapshot = null;
    }

    public bool CanRollback()
    {
        return PendingUpgradeSnapshot != null &&
               Status == DeploymentStatus.Failed;
    }
}

API:

# Rollback after failed upgrade (manual)
POST /api/environments/{environmentId}/deployments/{deploymentId}/rollback

Response (Success):
{
  "success": true,
  "message": "Rolled back from 2.0.0 to 1.1.0",
  "restoredVersion": "1.1.0"
}

Response (Error - no snapshot):
{
  "success": false,
  "message": "Rollback not available - containers were already started"
}

Response (Error - no Failed status):
{
  "success": false,
  "message": "Rollback only available after failed upgrade"
}

14. Open Questions (Resolved)

Question Decision
Version comparison SemVer only for v1
Downgrade allowed? No, not supported for v1
When rollback? Only if upgrade failed before container start
Manual rollback? Yes, user triggers manually (no auto-rollback)
Concurrent upgrades Environment-level blocking (no queue)
Catalog sync Outside scope of this spec (existing behavior)

Clone this wiki locally