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.
|
- `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
|
- `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.
|
- `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
|
- `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)
|
- `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)
|
- `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.
|
**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
|
## JAR Management
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> 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:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> 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
|
@Override
|
||||||
public void deleteTerminalByAppAndEnvironment(UUID appId, UUID environmentId) {
|
public void deleteFailedByAppAndEnvironment(UUID appId, UUID environmentId) {
|
||||||
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status IN ('STOPPED', 'FAILED')",
|
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status = 'FAILED'",
|
||||||
appId, environmentId);
|
appId, environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,27 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
|||||||
assertThat(loaded.deployedConfigSnapshot()).isNull();
|
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
|
@Test
|
||||||
void deployedConfigSnapshot_canBeClearedToNull() {
|
void deployedConfigSnapshot_canBeClearedToNull() {
|
||||||
UUID jarVersionId = UUID.randomUUID();
|
UUID jarVersionId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ public interface DeploymentRepository {
|
|||||||
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
||||||
void markDeployed(UUID id);
|
void markDeployed(UUID id);
|
||||||
void markStopped(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);
|
Environment env = envService.getById(environmentId);
|
||||||
String containerName = env.slug() + "-" + app.slug();
|
String containerName = env.slug() + "-" + app.slug();
|
||||||
|
|
||||||
deployRepo.deleteTerminalByAppAndEnvironment(appId, environmentId);
|
deployRepo.deleteFailedByAppAndEnvironment(appId, environmentId);
|
||||||
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
|
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
|
||||||
return deployRepo.findById(deploymentId).orElseThrow();
|
return deployRepo.findById(deploymentId).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,11 @@ export function Checkpoints({ deployments, versions, currentDeploymentId, onRest
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
||||||
|
|
||||||
// Deployments that reached COMPLETE stage and captured a snapshot (RUNNING or DEGRADED).
|
// Any deployment that captured a snapshot is restorable — that covers RUNNING,
|
||||||
// Exclude the currently-running one.
|
// DEGRADED, and STOPPED (blue/green swap previous, user-stopped). Exclude the
|
||||||
|
// currently-running one and anything without a snapshot (FAILED, STARTING).
|
||||||
const checkpoints = deployments
|
const checkpoints = deployments
|
||||||
.filter(
|
.filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
|
||||||
(d) =>
|
|
||||||
d.deployedAt &&
|
|
||||||
(d.status === 'RUNNING' || d.status === 'DEGRADED') &&
|
|
||||||
d.id !== currentDeploymentId,
|
|
||||||
)
|
|
||||||
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
||||||
|
|
||||||
return (
|
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 { SectionHeader, Input, MonoText, Button } from '@cameleer/design-system';
|
||||||
import type { App, AppVersion } from '../../../api/queries/admin/apps';
|
import type { App, AppVersion } from '../../../api/queries/admin/apps';
|
||||||
import type { Environment } from '../../../api/queries/admin/environments';
|
import type { Environment } from '../../../api/queries/admin/environments';
|
||||||
@@ -25,11 +25,12 @@ interface IdentitySectionProps {
|
|||||||
stagedJar: File | null;
|
stagedJar: File | null;
|
||||||
onStagedJarChange: (file: File | null) => void;
|
onStagedJarChange: (file: File | null) => void;
|
||||||
deploying: boolean;
|
deploying: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IdentitySection({
|
export function IdentitySection({
|
||||||
mode, environment, app, currentVersion,
|
mode, environment, app, currentVersion,
|
||||||
name, onNameChange, stagedJar, onStagedJarChange, deploying,
|
name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
|
||||||
}: IdentitySectionProps) {
|
}: IdentitySectionProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const slug = app?.slug ?? slugify(name);
|
const slug = app?.slug ?? slugify(name);
|
||||||
@@ -109,6 +110,7 @@ export function IdentitySection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ export default function AppDeploymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Identity & Artifact ── */}
|
{/* ── Identity & Artifact (with Checkpoints for deployed apps) ── */}
|
||||||
<IdentitySection
|
<IdentitySection
|
||||||
mode={mode}
|
mode={mode}
|
||||||
environment={env}
|
environment={env}
|
||||||
@@ -478,17 +478,16 @@ export default function AppDeploymentPage() {
|
|||||||
stagedJar={stagedJar}
|
stagedJar={stagedJar}
|
||||||
onStagedJarChange={setStagedJar}
|
onStagedJarChange={setStagedJar}
|
||||||
deploying={deploymentInProgress}
|
deploying={deploymentInProgress}
|
||||||
/>
|
>
|
||||||
|
{app && (
|
||||||
{/* ── Checkpoints (deployed apps only) ── */}
|
<Checkpoints
|
||||||
{app && (
|
deployments={deployments}
|
||||||
<Checkpoints
|
versions={versions}
|
||||||
deployments={deployments}
|
currentDeploymentId={currentDeployment?.id ?? null}
|
||||||
versions={versions}
|
onRestore={handleRestore}
|
||||||
currentDeploymentId={currentDeployment?.id ?? null}
|
/>
|
||||||
onRestore={handleRestore}
|
)}
|
||||||
/>
|
</IdentitySection>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Config tabs ── */}
|
{/* ── Config tabs ── */}
|
||||||
<div className={styles.tabGroup}>
|
<div className={styles.tabGroup}>
|
||||||
|
|||||||
Reference in New Issue
Block a user