fix(deploy): Checkpoints — preserve STOPPED history, fix filter + placement
- 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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,17 +478,16 @@ export default function AppDeploymentPage() {
|
||||
stagedJar={stagedJar}
|
||||
onStagedJarChange={setStagedJar}
|
||||
deploying={deploymentInProgress}
|
||||
/>
|
||||
|
||||
{/* ── Checkpoints (deployed apps only) ── */}
|
||||
{app && (
|
||||
<Checkpoints
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
currentDeploymentId={currentDeployment?.id ?? null}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{app && (
|
||||
<Checkpoints
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
currentDeploymentId={currentDeployment?.id ?? null}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
</IdentitySection>
|
||||
|
||||
{/* ── Config tabs ── */}
|
||||
<div className={styles.tabGroup}>
|
||||
|
||||
Reference in New Issue
Block a user