From c6aef5ab35ecdf0ca0aeb97a0df340443a045210 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:26:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20Checkpoints=20=E2=80=94=20preser?= =?UTF-8?q?ve=20STOPPED=20history,=20fix=20filter=20+=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .claude/rules/core-classes.md | 2 +- .claude/rules/docker-orchestration.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../storage/PostgresDeploymentRepository.java | 4 ++-- .../PostgresDeploymentRepositoryIT.java | 21 +++++++++++++++++ .../core/runtime/DeploymentRepository.java | 3 ++- .../core/runtime/DeploymentService.java | 2 +- .../AppsTab/AppDeploymentPage/Checkpoints.tsx | 12 ++++------ .../AppDeploymentPage/IdentitySection.tsx | 6 +++-- .../pages/AppsTab/AppDeploymentPage/index.tsx | 23 +++++++++---------- 11 files changed, 49 insertions(+), 30 deletions(-) diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index ff98be93..61bb6535 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -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) diff --git a/.claude/rules/docker-orchestration.md b/.claude/rules/docker-orchestration.md index ffabbd47..b06aca51 100644 --- a/.claude/rules/docker-orchestration.md +++ b/.claude/rules/docker-orchestration.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2992b710..422e41d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # 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. diff --git a/CLAUDE.md b/CLAUDE.md index 0b75e758..88a2f756 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component # 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. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java index 51924e87..ac9cf512 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java @@ -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); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java index 6e122e8c..0863256b 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java @@ -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(); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentRepository.java index ccb61695..bcb01f0e 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentRepository.java @@ -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); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java index 9f05b448..474bba43 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java @@ -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(); } diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx index 95efba77..281bb0ad 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx @@ -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 ( diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx index 2176b3da..aed0959d 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx @@ -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(null); const slug = app?.slug ?? slugify(name); @@ -109,6 +110,7 @@ export function IdentitySection({ )} + {children} ); } diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx index 503ade45..4f202675 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx @@ -467,7 +467,7 @@ export default function AppDeploymentPage() { - {/* ── Identity & Artifact ── */} + {/* ── Identity & Artifact (with Checkpoints for deployed apps) ── */} - - {/* ── Checkpoints (deployed apps only) ── */} - {app && ( - - )} + > + {app && ( + + )} + {/* ── Config tabs ── */}