fix(deploy): Checkpoints — preserve STOPPED history, fix filter + placement
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m15s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

- Backend: rename deleteTerminalByAppAndEnvironment → deleteFailedByAppAndEnvironment.
  STOPPED rows were being wiped on every redeploy, so Checkpoints was always empty.
  Now only FAILED rows are pruned; STOPPED deployments are retained as restorable
  checkpoints (they still carry deployed_config_snapshot from their RUNNING window).
- UI filter: any deployment with a snapshot is a checkpoint (was RUNNING|DEGRADED only,
  which excluded the main case — the previous blue/green deployment now in STOPPED).
- UI placement: Checkpoints disclosure now renders inside IdentitySection, matching
  the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 10:26:46 +02:00
parent 007597715a
commit c6aef5ab35
11 changed files with 49 additions and 30 deletions

View File

@@ -32,7 +32,7 @@ paths:
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED. `DEGRADED` is reserved for post-deploy drift (a replica died after RUNNING); `DeploymentExecutor` now marks partial-healthy deploys FAILED, not DEGRADED.
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
- `DeploymentStrategy` — enum: BLUE_GREEN, ROLLING. Stored on `ResolvedContainerConfig.deploymentStrategy` as kebab-case string (`"blue-green"` / `"rolling"`). `fromWire(String)` is the only conversion entry point; unknown/null inputs fall back to BLUE_GREEN so the executor dispatch site never null-checks or throws.
- `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped
- `DeploymentService` — createDeployment (calls `deleteFailedByAppAndEnvironment` first so FAILED rows don't pile up; STOPPED rows are preserved as restorable checkpoints), markRunning, markFailed, markStopped
- `RuntimeType` — enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE
- `RuntimeDetector` — probes JAR files at upload time: detects runtime from manifest Main-Class (Spring Boot loader, Quarkus entry point, plain Java) or native binary (non-ZIP magic bytes)
- `ContainerRequest` — record: 20 fields for Docker container creation (includes runtimeType, customArgs, mainClass)

View File

@@ -49,7 +49,7 @@ Traffic routing is implicit: Traefik labels (`cameleer.app`, `cameleer.environme
**Deploy stages** (`DeployStage`): PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE (or FAILED at any stage). Rolling reuses the same stage labels inside the per-replica loop; the UI progress bar shows the most recent stage.
**Deployment uniqueness**: `DeploymentService.createDeployment()` deletes any STOPPED/FAILED deployments for the same app+environment before creating a new one, preventing duplicate rows.
**Deployment retention**: `DeploymentService.createDeployment()` deletes FAILED deployments for the same app+environment before creating a new one, preventing failed-attempt buildup. STOPPED deployments are preserved as restorable checkpoints — the UI Checkpoints disclosure lists every deployment with a non-null `deployed_config_snapshot` (RUNNING, DEGRADED, STOPPED) minus the current one.
## JAR Management

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9318 symbols, 23997 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -85,7 +85,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9318 symbols, 23997 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -126,8 +126,8 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
@Override
public void deleteTerminalByAppAndEnvironment(UUID appId, UUID environmentId) {
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status IN ('STOPPED', 'FAILED')",
public void deleteFailedByAppAndEnvironment(UUID appId, UUID environmentId) {
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status = 'FAILED'",
appId, environmentId);
}

View File

@@ -87,6 +87,27 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
assertThat(loaded.deployedConfigSnapshot()).isNull();
}
@Test
void deleteFailedByAppAndEnvironment_keepsStoppedAndActive() {
// given: one STOPPED (checkpoint), one FAILED, one RUNNING
UUID stoppedId = repository.create(appId, appVersionId, envId, "stopped");
repository.updateStatus(stoppedId, com.cameleer.server.core.runtime.DeploymentStatus.STOPPED, null, null);
UUID failedId = repository.create(appId, appVersionId, envId, "failed");
repository.updateStatus(failedId, com.cameleer.server.core.runtime.DeploymentStatus.FAILED, null, "boom");
UUID runningId = repository.create(appId, appVersionId, envId, "running");
repository.updateStatus(runningId, com.cameleer.server.core.runtime.DeploymentStatus.RUNNING, "c1", null);
// when
repository.deleteFailedByAppAndEnvironment(appId, envId);
// then: STOPPED and RUNNING survive; FAILED is gone
assertThat(repository.findById(stoppedId)).isPresent();
assertThat(repository.findById(runningId)).isPresent();
assertThat(repository.findById(failedId)).isEmpty();
}
@Test
void deployedConfigSnapshot_canBeClearedToNull() {
UUID jarVersionId = UUID.randomUUID();

View File

@@ -14,5 +14,6 @@ public interface DeploymentRepository {
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
void markDeployed(UUID id);
void markStopped(UUID id);
void deleteTerminalByAppAndEnvironment(UUID appId, UUID environmentId);
/** Delete FAILED deployments for this (app, env). STOPPED deployments are preserved as checkpoints. */
void deleteFailedByAppAndEnvironment(UUID appId, UUID environmentId);
}

View File

@@ -28,7 +28,7 @@ public class DeploymentService {
Environment env = envService.getById(environmentId);
String containerName = env.slug() + "-" + app.slug();
deployRepo.deleteTerminalByAppAndEnvironment(appId, environmentId);
deployRepo.deleteFailedByAppAndEnvironment(appId, environmentId);
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
return deployRepo.findById(deploymentId).orElseThrow();
}

View File

@@ -15,15 +15,11 @@ export function Checkpoints({ deployments, versions, currentDeploymentId, onRest
const [open, setOpen] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v]));
// Deployments that reached COMPLETE stage and captured a snapshot (RUNNING or DEGRADED).
// Exclude the currently-running one.
// Any deployment that captured a snapshot is restorable — that covers RUNNING,
// DEGRADED, and STOPPED (blue/green swap previous, user-stopped). Exclude the
// currently-running one and anything without a snapshot (FAILED, STARTING).
const checkpoints = deployments
.filter(
(d) =>
d.deployedAt &&
(d.status === 'RUNNING' || d.status === 'DEGRADED') &&
d.id !== currentDeploymentId,
)
.filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
return (

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useRef, type ReactNode } from 'react';
import { SectionHeader, Input, MonoText, Button } from '@cameleer/design-system';
import type { App, AppVersion } from '../../../api/queries/admin/apps';
import type { Environment } from '../../../api/queries/admin/environments';
@@ -25,11 +25,12 @@ interface IdentitySectionProps {
stagedJar: File | null;
onStagedJarChange: (file: File | null) => void;
deploying: boolean;
children?: ReactNode;
}
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying,
name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
}: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name);
@@ -109,6 +110,7 @@ export function IdentitySection({
)}
</div>
</div>
{children}
</div>
);
}

View File

@@ -467,7 +467,7 @@ export default function AppDeploymentPage() {
</div>
</div>
{/* ── Identity & Artifact ── */}
{/* ── Identity & Artifact (with Checkpoints for deployed apps) ── */}
<IdentitySection
mode={mode}
environment={env}
@@ -478,9 +478,7 @@ export default function AppDeploymentPage() {
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={deploymentInProgress}
/>
{/* ── Checkpoints (deployed apps only) ── */}
>
{app && (
<Checkpoints
deployments={deployments}
@@ -489,6 +487,7 @@ export default function AppDeploymentPage() {
onRestore={handleRestore}
/>
)}
</IdentitySection>
{/* ── Config tabs ── */}
<div className={styles.tabGroup}>