-
Notifications
You must be signed in to change notification settings - Fork 0
Reference v0.14 stack upgrade
Specification for the Stack Upgrade Feature: In-Place Updates of deployed Stacks.
| Feature | Scope | Priority |
|---|---|---|
| Upgrade Detection | Backend | High |
| Upgrade API | Backend | High |
| Upgrade UI | Frontend | High |
| Variable Migration | Backend | Medium |
| Upgrade History | Backend + DB | Medium |
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
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)
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));
}
}// Domain/Deployment/Events/DeploymentUpgraded.cs
public record DeploymentUpgraded(
DeploymentId DeploymentId,
string PreviousVersion,
string NewVersion,
DateTime UpgradedAt) : IDomainEvent;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;
}
}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);
}
}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'"]
}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"]
}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"
}-- 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;// Infrastructure.DataAccess/Configurations/DeploymentConfiguration.cs
builder.Property(d => d.LastUpgradedAt);
builder.Property(d => d.PreviousVersion);
builder.Property(d => d.UpgradeCount).HasDefaultValue(0);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]│
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 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] │
└─────────────────────────────────────────────────────────────────────┘
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 │
│ │
└─────────────────────────────────────────────────────────────────────┘
| 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 |
// 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
);
}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
Like standard, but containers are recreated (not just stopped/started):
docker-compose up -d --force-recreate
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
| 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 |
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.
- Extend Domain Model (
LastUpgradedAt,PreviousVersion,UpgradeCount) - Create Domain Event
DeploymentUpgraded - Implement
CheckUpgradeQuery/Handler - Implement
UpgradeStackCommand/Handler - Add API Endpoints
- Create EF Core Migration
- Extend API Client
- Implement
UpgradeBanner.tsx(for DeploymentDetail) - Implement
UpgradeDialog.tsx -
VariableMigration.tsxfor variable diff - Integration in DeploymentDetailPage
-
UpgradeProgress.tsxwith SignalR - Automatic rollback on error
- Display upgrade history in UI
-
UpgradeStackHandlerTests.cs- Happy path, error cases -
CheckUpgradeHandlerTests.cs- Version comparison, edge cases -
DeploymentUpgradeTests.cs- Domain model upgrade recording
- Upgrade E2E with Docker TestContainers
- API Endpoint Tests
- Variable migration tests
- UpgradeBanner rendering based on state
- UpgradeDialog form validation
- Progress updates via SignalR
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
- v0.13 Health Monitoring (for health checks after upgrade)
- v0.14 Rollback Feature (for snapshot creation)
The feature uses existing infrastructure:
- MediatR for CQRS
- SignalR for Progress
- EF Core for Persistence
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.
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;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 < v2), 0 (equal), 1 (v1 > 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)"
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"
}| 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) |
Getting Started
Architecture
Configuration
Security
Setup Wizard
Development
Operations
CI/CD
Reference
- Roadmap
- API Reference
- Configuration Reference
- Manifest Schema
- Multi-Environment
- Stack Sources
- Plugin System
- Technical Specification
- Full Specification
Specifications
Release Notes