# Init-Container JAR Fetch + ArtifactStore Abstraction Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace host-bind-mounted JAR delivery with an init-container download from Cameleer over HTTP, sitting behind a new `ArtifactStore` abstraction. Closes the last open hardening gap from issue #152 (`withUsernsMode`) and gives the storage layer a clean migration path to OCI later (tracked separately). **Architecture:** - New `ArtifactStore` interface in `cameleer-server-core` with one filesystem implementation today; the runtime never touches the filesystem path again. - Cameleer serves JARs over HTTP via short-lived HMAC-signed URLs (no JWT/RBAC dependency on the loader). - Tenant deploys now run a `cameleer-runtime-loader` container (busybox + 30-line `entrypoint.sh`) first, sharing a per-replica named Docker volume with the main container. Loader downloads `app.jar`, exits 0, main container starts with the volume mounted read-only at `/app/jars/`. - `withUsernsMode("host:1000:65536")` lands once host file ownership is no longer in the picture (everything in the container's UID namespace inside the named volume). - `agent.jar` stays baked into the runtime base image — out of scope for this change. **Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java client, Postgres (Flyway), busybox-based loader image, HMAC-SHA256 URL signing. --- ## File Structure ### New files | Path | Responsibility | |---|---| | `cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactStore.java` | Interface: put/get/exists/delete/downloadUrl over an opaque coordinate key | | `cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactCoordinates.java` | Value type: `(tenantId, appId, version)` — OCI-friendly so a future `OciArtifactStore` reuses it | | `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/FilesystemArtifactStore.java` | Filesystem impl preserving today's `{jarStoragePath}/{appId}/v{version}/app.jar` layout | | `cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java` | HMAC-SHA256 signer/verifier for download URLs; key = HMAC(jwtSecret, "cameleer-artifact-token-v1") | | `cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java` | `GET /api/v1/artifacts/{appVersionId}?exp=...&sig=...` — token-validated stream | | `cameleer-runtime-loader/Dockerfile` | busybox base, copies entrypoint.sh, runs as UID 1000 | | `cameleer-runtime-loader/entrypoint.sh` | wget the signed URL into `/app/jars/app.jar`, verify size, exit | | `cameleer-runtime-loader/README.md` | Build + push instructions (one-liner) | | `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/FilesystemArtifactStoreTest.java` | put/get round-trip, delete, listing, idempotency | | `cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java` | sign + verify, expiry, tamper detection | | `cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java` | 200 with valid sig, 401 wrong sig, 410 expired, 404 missing | | `cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestratorLoaderTest.java` | Loader-pattern launch contract: per-replica volume created, loader runs first, main mounts RO, userns_mode applied | ### Modified files | Path | Why | |---|---| | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java` | Drop `jarPath`/`jarVolumeName`/`jarVolumeMountPath`; add `appVersionId`, `artifactDownloadUrl`, `loaderImage` | | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` | `uploadJar` writes via `ArtifactStore.put`; `resolveJarPath` removed (callers now use store directly or the download URL flow) | | `cameleer-server-app/src/main/java/com/cameleer/server/app/retention/JarRetentionJob.java` | Deletes via `ArtifactStore.delete` | | `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` | Generates signed URL, threads through `ContainerRequest`; preflight no longer touches filesystem | | `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java` | Per-replica named volume, run loader synchronously, start main with volume RO at `/app/jars/`, add `withUsernsMode` | | `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` | Wire `ArtifactStore` bean, drop `jarStoragePath` from `AppService` ctor | | `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` | `permitAll` for `/api/v1/artifacts/**` (controller does HMAC validation) | | `.claude/rules/docker-orchestration.md`, `.claude/rules/core-classes.md`, `.claude/rules/app-classes.md` | Update class/endpoint maps | | `cameleer-server-app/src/main/resources/application.yml` | New properties: `cameleer.server.runtime.loaderimage`, `cameleer.server.runtime.artifacttokenttlseconds` | --- ## Task 1: Add `ArtifactCoordinates` value type **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactCoordinates.java` - Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/storage/ArtifactCoordinatesTest.java` - [ ] **Step 1: Write the failing test** ```java package com.cameleer.server.core.storage; import org.junit.jupiter.api.Test; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class ArtifactCoordinatesTest { @Test void rejectsNullsAndNonPositiveVersion() { UUID appId = UUID.randomUUID(); assertThatThrownBy(() -> new ArtifactCoordinates(null, appId, 1)) .isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> new ArtifactCoordinates("default", null, 1)) .isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> new ArtifactCoordinates("default", appId, 0)) .isInstanceOf(IllegalArgumentException.class); } @Test void filesystemKeyMatchesLegacyLayout() { UUID appId = UUID.fromString("11111111-1111-1111-1111-111111111111"); ArtifactCoordinates c = new ArtifactCoordinates("default", appId, 7); assertThat(c.filesystemKey()).isEqualTo("11111111-1111-1111-1111-111111111111/v7/app.jar"); } @Test void ociRefIsDeterministic() { UUID appId = UUID.fromString("11111111-1111-1111-1111-111111111111"); ArtifactCoordinates c = new ArtifactCoordinates("acme", appId, 7); assertThat(c.ociRef()).isEqualTo("acme/11111111-1111-1111-1111-111111111111:v7"); } } ``` - [ ] **Step 2: Run the test** Run: `mvn -pl cameleer-server-core test -Dtest=ArtifactCoordinatesTest` Expected: FAIL — class missing. - [ ] **Step 3: Implement `ArtifactCoordinates`** ```java package com.cameleer.server.core.storage; import java.util.Objects; import java.util.UUID; /** * Opaque coordinate that addresses one stored JAR. Designed so a future * OCI-backed store can reuse the same coordinate without schema changes — * see issue (Zot follow-up). */ public record ArtifactCoordinates(String tenantId, UUID appId, int version) { public ArtifactCoordinates { Objects.requireNonNull(tenantId, "tenantId"); Objects.requireNonNull(appId, "appId"); if (version <= 0) throw new IllegalArgumentException("version must be > 0, got " + version); } /** Path-fragment used by FilesystemArtifactStore. Mirrors today's layout. */ public String filesystemKey() { return appId + "/v" + version + "/app.jar"; } /** OCI reference used by a future registry-backed store. Stable so coordinates * survive a backend swap. */ public String ociRef() { return tenantId + "/" + appId + ":v" + version; } } ``` - [ ] **Step 4: Re-run test — expect PASS** - [ ] **Step 5: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactCoordinates.java \ cameleer-server-core/src/test/java/com/cameleer/server/core/storage/ArtifactCoordinatesTest.java git commit -m "feat(storage): add ArtifactCoordinates value type" ``` --- ## Task 2: Add `ArtifactStore` interface **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactStore.java` No test file — pure interface; behavior is tested via `FilesystemArtifactStore` in Task 3. - [ ] **Step 1: Write the interface** ```java package com.cameleer.server.core.storage; import java.io.IOException; import java.io.InputStream; /** * Storage abstraction for deployable JAR artifacts. Implementations decide where * the bytes live (filesystem today; OCI registry / S3 later — see Zot follow-up * issue). Callers never construct filesystem paths themselves. * *

The interface is intentionally narrow: enough to upload, fetch, and delete. * {@link #downloadUrl} returns the URL the loader container hits — for the * filesystem store today this is a Cameleer-served signed URL; for an OCI store * later it could be a pre-signed registry URL. */ public interface ArtifactStore { /** Persist {@code bytes} of {@code size} bytes under {@code coords}. Idempotent * on identical content; overwrites on same coords with different content. */ String put(ArtifactCoordinates coords, InputStream bytes, long size) throws IOException; /** Open the artifact for reading. Caller closes. */ InputStream get(ArtifactCoordinates coords) throws IOException; boolean exists(ArtifactCoordinates coords); /** Remove the artifact and any backend-specific scaffolding (empty parent dirs, etc.). * Silently no-ops if the artifact is already absent. */ void delete(ArtifactCoordinates coords) throws IOException; /** Stable identifier for this storage location — written into * {@code app_versions.jar_path} so existing rows still resolve. For the * filesystem store this is the absolute path; for OCI it's the ref. */ String locator(ArtifactCoordinates coords); } ``` - [ ] **Step 2: Verify it compiles** Run: `mvn -pl cameleer-server-core compile` - [ ] **Step 3: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/storage/ArtifactStore.java git commit -m "feat(storage): add ArtifactStore interface" ``` --- ## Task 3: Implement `FilesystemArtifactStore` **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/FilesystemArtifactStore.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/storage/FilesystemArtifactStoreTest.java` - [ ] **Step 1: Write the failing test** ```java package com.cameleer.server.app.storage; import com.cameleer.server.core.storage.ArtifactCoordinates; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class FilesystemArtifactStoreTest { @Test void roundTripsBytes(@TempDir Path tmp) throws Exception { FilesystemArtifactStore store = new FilesystemArtifactStore(tmp.toString()); ArtifactCoordinates c = new ArtifactCoordinates("default", UUID.randomUUID(), 1); byte[] payload = "hello-jar".getBytes(); store.put(c, new ByteArrayInputStream(payload), payload.length); assertThat(store.exists(c)).isTrue(); try (InputStream in = store.get(c)) { assertThat(in.readAllBytes()).isEqualTo(payload); } } @Test void deleteRemovesFileAndEmptyParents(@TempDir Path tmp) throws Exception { FilesystemArtifactStore store = new FilesystemArtifactStore(tmp.toString()); UUID appId = UUID.randomUUID(); ArtifactCoordinates c = new ArtifactCoordinates("default", appId, 1); store.put(c, new ByteArrayInputStream("x".getBytes()), 1); store.delete(c); assertThat(store.exists(c)).isFalse(); // {tmp}/{appId}/v1/ should be gone too assertThat(Files.exists(tmp.resolve(appId.toString()).resolve("v1"))).isFalse(); } @Test void deleteIsIdempotent(@TempDir Path tmp) throws Exception { FilesystemArtifactStore store = new FilesystemArtifactStore(tmp.toString()); ArtifactCoordinates c = new ArtifactCoordinates("default", UUID.randomUUID(), 1); store.delete(c); // no exception assertThat(store.exists(c)).isFalse(); } @Test void locatorMatchesLegacyAbsolutePath(@TempDir Path tmp) { FilesystemArtifactStore store = new FilesystemArtifactStore(tmp.toString()); UUID appId = UUID.fromString("11111111-1111-1111-1111-111111111111"); ArtifactCoordinates c = new ArtifactCoordinates("default", appId, 7); assertThat(store.locator(c)) .isEqualTo(tmp.resolve("11111111-1111-1111-1111-111111111111/v7/app.jar").toString()); } } ``` - [ ] **Step 2: Run — expect FAIL (class missing)** Run: `mvn -pl cameleer-server-app test -Dtest=FilesystemArtifactStoreTest` - [ ] **Step 3: Implement** ```java package com.cameleer.server.app.storage; import com.cameleer.server.core.storage.ArtifactCoordinates; import com.cameleer.server.core.storage.ArtifactStore; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; public class FilesystemArtifactStore implements ArtifactStore { private final Path root; public FilesystemArtifactStore(String root) { this.root = Path.of(root); } private Path pathOf(ArtifactCoordinates coords) { return root.resolve(coords.filesystemKey()); } @Override public String put(ArtifactCoordinates coords, InputStream bytes, long size) throws IOException { Path target = pathOf(coords); Files.createDirectories(target.getParent()); try (InputStream in = bytes) { Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } return target.toString(); } @Override public InputStream get(ArtifactCoordinates coords) throws IOException { return Files.newInputStream(pathOf(coords)); } @Override public boolean exists(ArtifactCoordinates coords) { return Files.exists(pathOf(coords)); } @Override public void delete(ArtifactCoordinates coords) throws IOException { Path target = pathOf(coords); Files.deleteIfExists(target); // Sweep empty {appId}/v{n}/ then {appId}/ if now empty. Path versionDir = target.getParent(); if (versionDir != null && Files.isDirectory(versionDir) && isEmpty(versionDir)) { Files.delete(versionDir); Path appDir = versionDir.getParent(); if (appDir != null && Files.isDirectory(appDir) && isEmpty(appDir)) { Files.delete(appDir); } } } @Override public String locator(ArtifactCoordinates coords) { return pathOf(coords).toString(); } private static boolean isEmpty(Path dir) throws IOException { try (var entries = Files.list(dir)) { return entries.findFirst().isEmpty(); } } } ``` - [ ] **Step 4: Re-run test — expect PASS** - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/FilesystemArtifactStore.java \ cameleer-server-app/src/test/java/com/cameleer/server/app/storage/FilesystemArtifactStoreTest.java git commit -m "feat(storage): add FilesystemArtifactStore (one impl of ArtifactStore)" ``` --- ## Task 4: Migrate `AppService.uploadJar` to use `ArtifactStore`; remove `resolveJarPath` **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java` - Test: `cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/AppServiceUploadTest.java` (new) The store is in `cameleer-server-app` but the interface is in `cameleer-server-core`, so `AppService` only depends on the interface. - [ ] **Step 1: Write the failing test** ```java package com.cameleer.server.core.runtime; import com.cameleer.server.core.storage.ArtifactCoordinates; import com.cameleer.server.core.storage.ArtifactStore; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppServiceUploadTest { @Test void uploadJarStoresViaArtifactStoreAndRecordsLocator() throws Exception { AppRepository appRepo = mock(AppRepository.class); AppVersionRepository versionRepo = mock(AppVersionRepository.class); ArtifactStore store = mock(ArtifactStore.class); UUID appId = UUID.randomUUID(); UUID versionId = UUID.randomUUID(); when(appRepo.findById(appId)).thenReturn(Optional.of( new App(appId, UUID.randomUUID(), "demo", "Demo", null))); when(versionRepo.findMaxVersion(appId)).thenReturn(2); when(store.put(any(), any(InputStream.class), anyLong())).thenReturn("/data/jars/" + appId + "/v3/app.jar"); when(versionRepo.create(eq(appId), eq(3), anyString(), anyString(), eq("a.jar"), eq(5L))) .thenReturn(versionId); when(versionRepo.findById(versionId)).thenReturn(Optional.of( new AppVersion(versionId, appId, 3, "/data/jars/" + appId + "/v3/app.jar", "deadbeef", "a.jar", 5L, null, null, null))); AppService svc = new AppService(appRepo, versionRepo, store, "default"); byte[] body = "hello".getBytes(); AppVersion result = svc.uploadJar(appId, "a.jar", new ByteArrayInputStream(body), body.length); verify(store).put(eq(new ArtifactCoordinates("default", appId, 3)), any(InputStream.class), eq(5L)); assertThat(result.version()).isEqualTo(3); } } ``` - [ ] **Step 2: Run — expect FAIL (constructor mismatch)** Run: `mvn -pl cameleer-server-core test -Dtest=AppServiceUploadTest` - [ ] **Step 3: Refactor `AppService`** Replace the file contents: ```java package com.cameleer.server.core.runtime; import com.cameleer.server.core.storage.ArtifactCoordinates; import com.cameleer.server.core.storage.ArtifactStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.regex.Pattern; public class AppService { private static final Logger log = LoggerFactory.getLogger(AppService.class); private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); private final AppRepository appRepo; private final AppVersionRepository versionRepo; private final ArtifactStore artifactStore; private final String tenantId; private final CreateGuard createGuard; public AppService(AppRepository appRepo, AppVersionRepository versionRepo, ArtifactStore artifactStore, String tenantId) { this(appRepo, versionRepo, artifactStore, tenantId, CreateGuard.NOOP); } public AppService(AppRepository appRepo, AppVersionRepository versionRepo, ArtifactStore artifactStore, String tenantId, CreateGuard createGuard) { this.appRepo = appRepo; this.versionRepo = versionRepo; this.artifactStore = artifactStore; this.tenantId = tenantId; this.createGuard = createGuard; } public List listAll() { return appRepo.findAll(); } public List listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); } public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); } public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); } public App getByEnvironmentAndSlug(UUID environmentId, String slug) { return appRepo.findByEnvironmentIdAndSlug(environmentId, slug) .orElseThrow(() -> new IllegalArgumentException("App not found in environment: " + slug)); } public List listVersions(UUID appId) { return versionRepo.findByAppId(appId); } public AppVersion getVersion(UUID versionId) { return versionRepo.findById(versionId) .orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + versionId)); } public ArtifactCoordinates coordinatesFor(AppVersion version) { return new ArtifactCoordinates(tenantId, version.appId(), version.version()); } public void updateContainerConfig(UUID id, Map containerConfig) { getById(id); appRepo.updateContainerConfig(id, containerConfig); } public UUID createApp(UUID environmentId, String slug, String displayName) { if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) { throw new IllegalArgumentException( "Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$"); } createGuard.check(appRepo.count()); if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); } return appRepo.create(environmentId, slug, displayName); } public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException { getById(appId); int nextVersion = versionRepo.findMaxVersion(appId) + 1; ArtifactCoordinates coords = new ArtifactCoordinates(tenantId, appId, nextVersion); // Buffer once for hashing + storage so we can also detect runtime type without re-reading. ByteArrayOutputStream buf = new ByteArrayOutputStream(Math.max(8192, (int) Math.min(size, 64 * 1024 * 1024))); MessageDigest digest; try { digest = MessageDigest.getInstance("SHA-256"); } catch (Exception e) { throw new RuntimeException(e); } try (InputStream in = jarData) { byte[] tmp = new byte[8192]; int n; while ((n = in.read(tmp)) != -1) { buf.write(tmp, 0, n); digest.update(tmp, 0, n); } } byte[] bytes = buf.toByteArray(); String locator = artifactStore.put(coords, new java.io.ByteArrayInputStream(bytes), bytes.length); String checksum = HexFormat.of().formatHex(digest.digest()); UUID versionId = versionRepo.create(appId, nextVersion, locator, checksum, filename, size); RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(new java.io.ByteArrayInputStream(bytes)); if (detection.runtimeType() != null) { versionRepo.updateDetectedRuntime(versionId, detection.runtimeType().toConfigValue(), detection.mainClass()); log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected={}", appId, nextVersion, size, checksum, detection.runtimeType().toConfigValue()); } else { log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown", appId, nextVersion, size, checksum); } return versionRepo.findById(versionId).orElseThrow(); } public void deleteApp(UUID id) { appRepo.delete(id); } } ``` > Note: `RuntimeDetector.detect(InputStream)` may not exist today — verify and add an `InputStream` overload if needed (it currently takes a `Path`). If adding the overload is non-trivial, write the bytes to a temp file in this method and pass the path; document the temp-file lifetime. - [ ] **Step 4: Update `RuntimeBeanConfig`** ```java // In cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java // Replace the AppService bean: @Bean public ArtifactStore artifactStore(@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) { return new FilesystemArtifactStore(jarStoragePath); } @Bean public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo, ArtifactStore artifactStore, @Value("${cameleer.server.tenant.id:default}") String tenantId, LicenseEnforcer licenseEnforcer) { return new AppService(appRepo, versionRepo, artifactStore, tenantId, current -> licenseEnforcer.assertWithinCap("max_apps", current, 1)); } ``` - [ ] **Step 5: Run all core tests — expect PASS** ```bash mvn -pl cameleer-server-core test mvn -pl cameleer-server-app test -Dtest='*AppService*,*RuntimeBeanConfig*' ``` Fix any callers of removed `resolveJarPath` — should be only `DeploymentExecutor` (handled in Task 7) and the orphaned-app cleanup. - [ ] **Step 6: Commit** ```bash git add -A git commit -m "refactor(core): AppService writes via ArtifactStore; remove resolveJarPath" ``` --- ## Task 5: Migrate `JarRetentionJob` to `ArtifactStore` **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/retention/JarRetentionJob.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/retention/JarRetentionJobTest.java` (extend existing or new) - [ ] **Step 1: Write the failing test** ```java @Test void cleanupDeletesViaArtifactStore() { EnvironmentService envSvc = mock(EnvironmentService.class); AppService appSvc = mock(AppService.class); AppVersionRepository versionRepo = mock(AppVersionRepository.class); DeploymentRepository deployRepo = mock(DeploymentRepository.class); ArtifactStore store = mock(ArtifactStore.class); UUID envId = UUID.randomUUID(), appId = UUID.randomUUID(); Environment env = new Environment(envId, "dev", "Dev", false, true, null, 2, "slate", Instant.now(), 7, 7, 7); App app = new App(appId, envId, "demo", "Demo", null); AppVersion v1 = new AppVersion(UUID.randomUUID(), appId, 1, "ignored", "h", "a.jar", 1L, null, null, null); AppVersion v2 = new AppVersion(UUID.randomUUID(), appId, 2, "ignored", "h", "a.jar", 1L, null, null, null); AppVersion v3 = new AppVersion(UUID.randomUUID(), appId, 3, "ignored", "h", "a.jar", 1L, null, null, null); when(envSvc.listAll()).thenReturn(List.of(env)); when(appSvc.listByEnvironment(envId)).thenReturn(List.of(app)); when(versionRepo.findByAppId(appId)).thenReturn(List.of(v3, v2, v1)); // DESC when(deployRepo.findByAppId(appId)).thenReturn(List.of()); when(appSvc.coordinatesFor(v1)).thenReturn(new ArtifactCoordinates("default", appId, 1)); new JarRetentionJob(envSvc, appSvc, versionRepo, deployRepo, store).cleanupOldVersions(); verify(store).delete(new ArtifactCoordinates("default", appId, 1)); verify(versionRepo).delete(v1.id()); verifyNoMoreInteractions(store); } ``` - [ ] **Step 2: Run — expect FAIL (constructor mismatch)** - [ ] **Step 3: Refactor** Replace `deleteJarFile` with a delegation to `store.delete(appSvc.coordinatesFor(version))`. Add `ArtifactStore` to the constructor. ```java private final ArtifactStore store; public JarRetentionJob(EnvironmentService environmentService, AppService appService, AppVersionRepository versionRepo, DeploymentRepository deploymentRepo, ArtifactStore store) { this.environmentService = environmentService; this.appService = appService; this.versionRepo = versionRepo; this.deploymentRepo = deploymentRepo; this.store = store; } private int cleanupApp(App app, int retentionCount) { List versions = versionRepo.findByAppId(app.id()); if (versions.size() <= retentionCount) return 0; Set deployedVersionIds = deploymentRepo.findByAppId(app.id()).stream() .map(Deployment::appVersionId).collect(Collectors.toSet()); int deleted = 0; for (int i = retentionCount; i < versions.size(); i++) { AppVersion version = versions.get(i); if (deployedVersionIds.contains(version.id())) continue; try { store.delete(appService.coordinatesFor(version)); } catch (IOException e) { log.warn("Failed to delete artifact for version {}: {}", version.id(), e.getMessage()); } versionRepo.delete(version.id()); deleted++; log.info("Deleted version v{} of app {} ({})", version.version(), app.slug(), version.id()); } return deleted; } ``` Delete the old `deleteJarFile` method and unused `Files`/`Path` imports. - [ ] **Step 4: Re-run tests — expect PASS** - [ ] **Step 5: Commit** ```bash git add -A git commit -m "refactor(retention): JarRetentionJob deletes via ArtifactStore" ``` --- ## Task 6: HMAC `ArtifactDownloadTokenSigner` **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java` - [ ] **Step 1: Write the failing test** ```java package com.cameleer.server.app.web; import org.junit.jupiter.api.Test; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; class ArtifactDownloadTokenSignerTest { private final String secret = "test-secret-do-not-use"; private final Instant now = Instant.parse("2026-04-27T10:00:00Z"); private final Clock clock = Clock.fixed(now, ZoneOffset.UTC); @Test void signedTokenVerifiesWithinTtl() { var signer = new ArtifactDownloadTokenSigner(secret, clock); UUID id = UUID.randomUUID(); var token = signer.sign(id, Duration.ofMinutes(5)); assertThat(signer.verify(id, token.exp(), token.sig())).isTrue(); } @Test void rejectsTamperedSignature() { var signer = new ArtifactDownloadTokenSigner(secret, clock); UUID id = UUID.randomUUID(); var token = signer.sign(id, Duration.ofMinutes(5)); assertThat(signer.verify(id, token.exp(), token.sig() + "x")).isFalse(); } @Test void rejectsMismatchedAppVersionId() { var signer = new ArtifactDownloadTokenSigner(secret, clock); UUID id = UUID.randomUUID(); var token = signer.sign(id, Duration.ofMinutes(5)); assertThat(signer.verify(UUID.randomUUID(), token.exp(), token.sig())).isFalse(); } @Test void rejectsExpired() { var signer = new ArtifactDownloadTokenSigner(secret, clock); UUID id = UUID.randomUUID(); long pastExp = now.minusSeconds(1).getEpochSecond(); String sig = signer.signRaw(id, pastExp); assertThat(signer.verify(id, pastExp, sig)).isFalse(); } } ``` - [ ] **Step 2: Run — expect FAIL** - [ ] **Step 3: Implement** ```java package com.cameleer.server.app.web; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; import java.util.Base64; import java.util.UUID; /** * HMAC-SHA256 signed URL tokens for artifact downloads. Key derivation is * deterministic from the JWT signing secret (HMAC-SHA256(jwtSecret, * "cameleer-artifact-token-v1")) so server restarts don't invalidate fresh * tokens. The loader container does NOT carry the JWT or the bootstrap token — * it only carries the signed URL, which is scoped to one appVersionId and one * short TTL. */ public class ArtifactDownloadTokenSigner { private static final String DERIVATION = "cameleer-artifact-token-v1"; public record SignedToken(long exp, String sig) {} private final byte[] key; private final Clock clock; public ArtifactDownloadTokenSigner(String jwtSecret, Clock clock) { this.key = deriveKey(jwtSecret); this.clock = clock; } private static byte[] deriveKey(String jwtSecret) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac.doFinal(DERIVATION.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { throw new IllegalStateException("HMAC init failed", e); } } public SignedToken sign(UUID appVersionId, Duration ttl) { long exp = clock.instant().plus(ttl).getEpochSecond(); return new SignedToken(exp, signRaw(appVersionId, exp)); } public String signRaw(UUID appVersionId, long exp) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); String payload = appVersionId + ":" + exp; byte[] tag = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(tag); } catch (Exception e) { throw new IllegalStateException("sign failed", e); } } public boolean verify(UUID appVersionId, long exp, String sig) { if (sig == null || sig.isBlank()) return false; if (clock.instant().getEpochSecond() > exp) return false; String expected = signRaw(appVersionId, exp); // constant-time compare return java.security.MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), sig.getBytes(StandardCharsets.UTF_8)); } } ``` - [ ] **Step 4: Run — expect PASS** - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat(web): add HMAC token signer for artifact downloads" ``` --- ## Task 7: `ArtifactDownloadController` **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadController.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadControllerTest.java` - [ ] **Step 1: Write the failing test (`@WebMvcTest`)** ```java @WebMvcTest(ArtifactDownloadController.class) class ArtifactDownloadControllerTest { @Autowired MockMvc mvc; @MockBean AppService appService; @MockBean ArtifactStore artifactStore; @MockBean ArtifactDownloadTokenSigner signer; @Test void streamsBytesWhenSignatureValid() throws Exception { UUID id = UUID.randomUUID(); when(signer.verify(eq(id), eq(123L), eq("good"))).thenReturn(true); AppVersion v = new AppVersion(id, UUID.randomUUID(), 1, "loc", "h", "a.jar", 5L, null, null, null); when(appService.getVersion(id)).thenReturn(v); when(appService.coordinatesFor(v)).thenReturn(new ArtifactCoordinates("default", v.appId(), 1)); when(artifactStore.get(any())).thenReturn(new ByteArrayInputStream("hello".getBytes())); mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "good")) .andExpect(status().isOk()) .andExpect(header().string("Content-Type", "application/java-archive")) .andExpect(content().bytes("hello".getBytes())); } @Test void rejectsBadSignature() throws Exception { UUID id = UUID.randomUUID(); when(signer.verify(any(), anyLong(), any())).thenReturn(false); mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "bad")) .andExpect(status().isUnauthorized()); } @Test void returns404WhenArtifactMissing() throws Exception { UUID id = UUID.randomUUID(); when(signer.verify(any(), anyLong(), any())).thenReturn(true); when(appService.getVersion(id)).thenThrow(new IllegalArgumentException("AppVersion not found")); mvc.perform(get("/api/v1/artifacts/{id}", id).param("exp", "123").param("sig", "ok")) .andExpect(status().isNotFound()); } } ``` - [ ] **Step 2: Run — expect FAIL** - [ ] **Step 3: Implement** ```java package com.cameleer.server.app.web; import com.cameleer.server.core.runtime.AppService; import com.cameleer.server.core.runtime.AppVersion; import com.cameleer.server.core.storage.ArtifactStore; import org.springframework.core.io.InputStreamResource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; import java.io.InputStream; import java.util.UUID; /** * Token-validated artifact download endpoint hit by the cameleer-runtime-loader * init container. Auth is the HMAC sig + exp on the URL — NOT JWT/bootstrap * token. permitAll'd in SecurityConfig because all auth is in the controller. */ @RestController @RequestMapping("/api/v1/artifacts") public class ArtifactDownloadController { private final AppService appService; private final ArtifactStore artifactStore; private final ArtifactDownloadTokenSigner signer; public ArtifactDownloadController(AppService appService, ArtifactStore artifactStore, ArtifactDownloadTokenSigner signer) { this.appService = appService; this.artifactStore = artifactStore; this.signer = signer; } @GetMapping("/{appVersionId}") public ResponseEntity download(@PathVariable UUID appVersionId, @RequestParam("exp") long exp, @RequestParam("sig") String sig) throws IOException { if (!signer.verify(appVersionId, exp, sig)) { return ResponseEntity.status(401).build(); } AppVersion version; try { version = appService.getVersion(appVersionId); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } InputStream in = artifactStore.get(appService.coordinatesFor(version)); return ResponseEntity.ok() .contentType(MediaType.parseMediaType("application/java-archive")) .contentLength(version.jarSizeBytes()) .body(new InputStreamResource(in)); } } ``` - [ ] **Step 4: Add `permitAll` to `SecurityConfig`** In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`, add `/api/v1/artifacts/**` to the permitAll matchers (alongside `/api/v1/auth/**`, `/api/v1/health`, etc.). - [ ] **Step 5: Wire `ArtifactDownloadTokenSigner` bean** (in `SecurityBeanConfig` or `RuntimeBeanConfig`) ```java @Bean public ArtifactDownloadTokenSigner artifactDownloadTokenSigner( @Value("${cameleer.server.security.jwtsecret}") String jwtSecret) { return new ArtifactDownloadTokenSigner(jwtSecret, java.time.Clock.systemUTC()); } ``` - [ ] **Step 6: Run all tests — expect PASS** - [ ] **Step 7: Commit** ```bash git add -A git commit -m "feat(web): add ArtifactDownloadController with HMAC URL auth" ``` --- ## Task 8: Build the `cameleer-runtime-loader` image **Files:** - Create: `cameleer-runtime-loader/Dockerfile` - Create: `cameleer-runtime-loader/entrypoint.sh` - Create: `cameleer-runtime-loader/README.md` - [ ] **Step 1: Write `entrypoint.sh`** ```sh #!/bin/sh # cameleer-runtime-loader: fetches one JAR from a signed URL into the shared # /app/jars/ volume, verifies size, exits. Runs in the same hardened sandbox as # the main container (cap_drop ALL, read-only rootfs, etc.) — only /app/jars/ # is writeable. set -eu : "${ARTIFACT_URL:?ARTIFACT_URL is required}" : "${ARTIFACT_EXPECTED_SIZE:?ARTIFACT_EXPECTED_SIZE is required}" OUT=/app/jars/app.jar mkdir -p /app/jars echo "loader: fetching artifact (expected $ARTIFACT_EXPECTED_SIZE bytes)" # -q quiet, -O output, --tries=3 retry transient network blips, # --timeout=30 cap stalls. wget exits non-zero on HTTP >=400. wget -q --tries=3 --timeout=30 -O "$OUT" "$ARTIFACT_URL" actual=$(wc -c < "$OUT") if [ "$actual" -ne "$ARTIFACT_EXPECTED_SIZE" ]; then echo "loader: size mismatch — expected $ARTIFACT_EXPECTED_SIZE, got $actual" >&2 exit 2 fi echo "loader: artifact written to $OUT ($actual bytes)" ``` - [ ] **Step 2: Write `Dockerfile`** ```dockerfile # Tiny init-container image. No app code, no shell-injection surface — script # only sees env vars set by the orchestrator. FROM busybox:1.37-musl # Run as non-root (UID 1000 inside the container; with userns_mode this is # remapped to host UID ~101000 — fully unprivileged on the host). RUN adduser -D -u 1000 loader COPY entrypoint.sh /usr/local/bin/loader RUN chmod +x /usr/local/bin/loader USER loader WORKDIR /app ENTRYPOINT ["/usr/local/bin/loader"] ``` - [ ] **Step 3: Build + smoke-test locally** ```bash docker build -t gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest cameleer-runtime-loader/ docker run --rm -v test-vol:/app/jars \ -e ARTIFACT_URL=https://example.invalid/missing \ -e ARTIFACT_EXPECTED_SIZE=0 \ gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest || echo "expected fail OK" ``` - [ ] **Step 4: Push to gitea registry** ```bash docker push gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest ``` - [ ] **Step 5: Write `README.md`** ```markdown # cameleer-runtime-loader Init container that fetches the deployable JAR into a shared volume before the main runtime container starts. Pairs with `DockerRuntimeOrchestrator` / (future) K8s init-container deploys. ## Build docker build -t gitea.siegeln.net/cameleer/cameleer-runtime-loader: . docker push gitea.siegeln.net/cameleer/cameleer-runtime-loader: ## Contract - Env: `ARTIFACT_URL` (signed download URL), `ARTIFACT_EXPECTED_SIZE` (bytes). - Volume: writes `/app/jars/app.jar`. - Exit 0 on success; non-zero on fetch/size failure. - Runs as UID 1000 (loader user), drops all caps, read-only rootfs except `/app/jars`. See `docs/superpowers/plans/2026-04-27-init-container-jar-fetch.md`. ``` - [ ] **Step 6: Commit** ```bash git add cameleer-runtime-loader/ git commit -m "feat(loader): add cameleer-runtime-loader image (busybox + entrypoint)" ``` --- ## Task 9: Refactor `ContainerRequest` **Files:** - Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java` No test — record refactor; behavior tested in Task 10. - [ ] **Step 1: Replace the record** ```java package com.cameleer.server.core.runtime; import java.util.List; import java.util.Map; import java.util.UUID; public record ContainerRequest( String containerName, String baseImage, UUID appVersionId, String artifactDownloadUrl, long artifactExpectedSize, String loaderImage, String network, List additionalNetworks, Map envVars, Map labels, long memoryLimitBytes, Long memoryReserveBytes, int cpuShares, Long cpuQuota, List exposedPorts, int healthCheckPort, String restartPolicyName, int restartPolicyMaxRetries, String runtimeType, String customArgs, String mainClass ) {} ``` - [ ] **Step 2: Compile (will break orchestrator + executor — fixed in next tasks)** Run: `mvn -pl cameleer-server-core compile` - [ ] **Step 3: Commit (broken intermediate — okay because next two tasks restore green)** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java git commit -m "refactor(core): replace ContainerRequest jar fields with appVersionId+downloadUrl" ``` > If you prefer never to commit a broken state, fold Tasks 9, 10, 11 into one commit. --- ## Task 10: `DockerRuntimeOrchestrator` loader-pattern launch **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java` - Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestratorLoaderTest.java` This is the central change. The orchestrator now: (1) creates a per-replica named volume, (2) creates + starts + waits for the loader container, (3) checks loader exit code, (4) creates the main container with the volume mounted RO at `/app/jars/`, (5) cleans up the loader container on success or failure. - [ ] **Step 1: Write the failing test** ```java @Test void startContainerCreatesVolumeRunsLoaderThenStartsMain() throws Exception { DockerClient docker = mockDockerClientWithRuntimes(Map.of()); // capture every createContainerCmd invocation in order CreateContainerCmd loaderCreate = mock(CreateContainerCmd.class, Answers.RETURNS_SELF); CreateContainerCmd mainCreate = mock(CreateContainerCmd.class, Answers.RETURNS_SELF); when(docker.createContainerCmd(eq("gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest"))).thenReturn(loaderCreate); when(docker.createContainerCmd(eq("base-image:latest"))).thenReturn(mainCreate); CreateContainerResponse loaderResp = mock(CreateContainerResponse.class); when(loaderResp.getId()).thenReturn("loader-id"); when(loaderCreate.exec()).thenReturn(loaderResp); CreateContainerResponse mainResp = mock(CreateContainerResponse.class); when(mainResp.getId()).thenReturn("main-id"); when(mainCreate.exec()).thenReturn(mainResp); when(docker.startContainerCmd(any())).thenReturn(mock(StartContainerCmd.class)); when(docker.removeContainerCmd(any())).thenReturn(mock(RemoveContainerCmd.class, Answers.RETURNS_SELF)); // wait returns exit 0 var waitCmd = mock(WaitContainerCmd.class); when(docker.waitContainerCmd("loader-id")).thenReturn(waitCmd); var waitCallback = mock(WaitContainerResultCallback.class); when(waitCmd.exec(any())).thenReturn(waitCallback); when(waitCallback.awaitStatusCode(anyLong(), any())).thenReturn(0); // volume create when(docker.createVolumeCmd()).thenReturn(mock(CreateVolumeCmd.class, Answers.RETURNS_SELF)); var orchestrator = new DockerRuntimeOrchestrator(docker, ""); orchestrator.startContainer(sampleRequest("base-image:latest", "https://srv/api/v1/artifacts/uuid?exp=1&sig=x", 12345L)); // loader was created with volume RW at /app/jars and the right env ArgumentCaptor loaderHc = ArgumentCaptor.forClass(HostConfig.class); verify(loaderCreate).withHostConfig(loaderHc.capture()); assertThat(loaderHc.getValue().getBinds()).anyMatch(b -> b.getVolume().getPath().equals("/app/jars") && b.getAccessMode() == AccessMode.rw); // main was created with volume RO at /app/jars ArgumentCaptor mainHc = ArgumentCaptor.forClass(HostConfig.class); verify(mainCreate).withHostConfig(mainHc.capture()); assertThat(mainHc.getValue().getBinds()).anyMatch(b -> b.getVolume().getPath().equals("/app/jars") && b.getAccessMode() == AccessMode.ro); // loader started, waited for, removed; then main started InOrder order = inOrder(docker); order.verify(docker).startContainerCmd("loader-id"); order.verify(docker).waitContainerCmd("loader-id"); order.verify(docker).removeContainerCmd("loader-id"); order.verify(docker).startContainerCmd("main-id"); } @Test void startContainerAbortsAndCleansUpWhenLoaderFails() { // wire wait to return exit 2; expect RuntimeException; expect volume + loader removed // and main container NEVER created. // ... (use the same scaffolding; assert `docker.createContainerCmd("base-image:latest")` is never called) } @Test void startContainerAppliesUsernsModeToBothContainers() { // assert hostConfig.getUsernsMode() == "host:1000:65536" on both loader and main HostConfig captures. } ``` - [ ] **Step 2: Run — expect FAIL** - [ ] **Step 3: Implement** Replace the `startContainer` method body with the loader-then-main flow. Key code: ```java private static final String LOADER_VOLUME_MOUNT = "/app/jars"; private static final String USERNS_MODE = "host:1000:65536"; @Override public String startContainer(ContainerRequest request) { String volumeName = "cameleer-jars-" + request.containerName(); dockerClient.createVolumeCmd().withName(volumeName).exec(); // 1) Loader container — RW on the shared volume. String loaderId = createAndStartLoader(request, volumeName); int exitCode; try { exitCode = dockerClient.waitContainerCmd(loaderId) .exec(new com.github.dockerjava.core.command.WaitContainerResultCallback()) .awaitStatusCode(120, java.util.concurrent.TimeUnit.SECONDS); } catch (Exception e) { cleanup(loaderId, volumeName); throw new RuntimeException("Loader wait failed for " + request.containerName(), e); } finally { try { dockerClient.removeContainerCmd(loaderId).withForce(true).exec(); } catch (Exception e) { log.warn("Failed to remove loader {}: {}", loaderId, e.getMessage()); } } if (exitCode != 0) { cleanupVolume(volumeName); throw new RuntimeException("Loader exited " + exitCode + " for " + request.containerName()); } // 2) Main container — RO on the shared volume. return createAndStartMain(request, volumeName); } private String createAndStartLoader(ContainerRequest request, String volumeName) { HostConfig hc = baseHardenedHostConfig() .withBinds(new Bind(volumeName, new Volume(LOADER_VOLUME_MOUNT), AccessMode.rw)) .withUsernsMode(USERNS_MODE) .withNetworkMode(request.network()); if (!dockerRuntime.isBlank()) hc.withRuntime(dockerRuntime); var create = dockerClient.createContainerCmd(request.loaderImage()) .withName(request.containerName() + "-loader") .withEnv(List.of( "ARTIFACT_URL=" + request.artifactDownloadUrl(), "ARTIFACT_EXPECTED_SIZE=" + request.artifactExpectedSize())) .withHostConfig(hc); String id = create.exec().getId(); dockerClient.startContainerCmd(id).exec(); return id; } private String createAndStartMain(ContainerRequest request, String volumeName) { List envList = request.envVars().entrySet().stream() .map(e -> e.getKey() + "=" + e.getValue()).toList(); HostConfig hc = baseHardenedHostConfig() .withMemory(request.memoryLimitBytes()) .withMemorySwap(request.memoryLimitBytes()) .withCpuShares(request.cpuShares()) .withNetworkMode(request.network()) .withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries())) .withUsernsMode(USERNS_MODE) .withBinds(new Bind(volumeName, new Volume(LOADER_VOLUME_MOUNT), AccessMode.ro)); if (!dockerRuntime.isBlank()) hc.withRuntime(dockerRuntime); if (request.memoryReserveBytes() != null) hc.withMemoryReservation(request.memoryReserveBytes()); if (request.cpuQuota() != null) hc.withCpuQuota(request.cpuQuota()); String appJarPath = LOADER_VOLUME_MOUNT + "/app.jar"; String customArgs = request.customArgs() != null && !request.customArgs().isBlank() ? " " + request.customArgs() : ""; String entrypoint = switch (request.runtimeType()) { case "plain-java" -> "exec java -javaagent:/app/agent.jar" + customArgs + " -cp " + appJarPath + " " + request.mainClass(); case "native" -> "exec " + appJarPath + customArgs; default -> "exec java -javaagent:/app/agent.jar" + customArgs + " -jar " + appJarPath; }; var create = dockerClient.createContainerCmd(request.baseImage()) .withName(request.containerName()) .withEnv(envList) .withLabels(request.labels() != null ? request.labels() : Map.of()) .withHostConfig(hc) .withHealthcheck(new HealthCheck() .withTest(List.of("CMD-SHELL", "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1")) .withInterval(10_000_000_000L) .withTimeout(5_000_000_000L) .withRetries(3) .withStartPeriod(30_000_000_000L)) .withEntrypoint("sh", "-c", entrypoint); if (request.exposedPorts() != null && !request.exposedPorts().isEmpty()) { var ports = request.exposedPorts().stream() .map(com.github.dockerjava.api.model.ExposedPort::tcp) .toArray(com.github.dockerjava.api.model.ExposedPort[]::new); create.withExposedPorts(ports); } String id = create.exec().getId(); dockerClient.startContainerCmd(id).exec(); log.info("Started container {} ({})", request.containerName(), id); return id; } /** Hardening contract from issue #152 — applied uniformly to loader + main. */ private HostConfig baseHardenedHostConfig() { return HostConfig.newHostConfig() .withCapDrop(Capability.values()) .withSecurityOpts(List.of("no-new-privileges:true", "apparmor=docker-default")) .withReadonlyRootfs(true) .withPidsLimit(PIDS_LIMIT) .withTmpFs(Map.of("/tmp", TMPFS_TMP_OPTS)); } private void cleanup(String loaderId, String volumeName) { try { dockerClient.removeContainerCmd(loaderId).withForce(true).exec(); } catch (Exception ignored) {} cleanupVolume(volumeName); } private void cleanupVolume(String volumeName) { try { dockerClient.removeVolumeCmd(volumeName).exec(); } catch (Exception ignored) {} } ``` - [ ] **Step 4: Volume cleanup on container removal** In `removeContainer(String containerId)`, also remove the per-replica volume. The volume name is derived from the container name; look up the container name first via `inspectContainerCmd`: ```java @Override public void removeContainer(String containerId) { String volumeName = null; try { var name = dockerClient.inspectContainerCmd(containerId).exec().getName(); if (name != null) { // Docker prefixes inspected names with '/' volumeName = "cameleer-jars-" + name.replaceFirst("^/", ""); } } catch (Exception e) { log.warn("Could not inspect {} for volume name: {}", containerId, e.getMessage()); } try { dockerClient.removeContainerCmd(containerId).withForce(true).exec(); log.info("Removed container {}", containerId); } catch (Exception e) { log.warn("Failed to remove container {}: {}", containerId, e.getMessage()); } if (volumeName != null) cleanupVolume(volumeName); } ``` - [ ] **Step 5: Run all orchestrator tests — expect PASS** Run: `mvn -pl cameleer-server-app test -Dtest='DockerRuntimeOrchestrator*'` The existing `DockerRuntimeOrchestratorHardeningTest` will need updating because `startContainer` now creates a volume and runs the loader first. Either update it to mock the loader path, or rewrite its assertions to inspect the *main* container's `HostConfig` capture (which still has the full hardening contract). Both paths must keep `cap_drop ALL`, `no-new-privileges`, `apparmor`, `readonly rootfs`, `pids_limit`, `/tmp` tmpfs. - [ ] **Step 6: Commit** ```bash git add -A git commit -m "feat(runtime): orchestrator launches loader init container then main; lands withUsernsMode" ``` --- ## Task 11: `DeploymentExecutor` — generate signed URL, drop filesystem checks **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` - [ ] **Step 1: Inject `ArtifactDownloadTokenSigner` + new config** Add to constructor + @Value: ```java @Value("${cameleer.server.runtime.loaderimage:gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest}") private String loaderImage; @Value("${cameleer.server.runtime.artifacttokenttlseconds:600}") private long artifactTokenTtlSeconds; @Value("${cameleer.server.runtime.artifactbaseurl:}") private String artifactBaseUrl; // e.g. http://cameleer-server:8081 — falls back to globalServerUrl @Autowired private ArtifactDownloadTokenSigner artifactTokenSigner; ``` - [ ] **Step 2: Replace JAR resolution in `executeAsync`** Drop the `String jarPath = appService.resolveJarPath(deployment.appVersionId());` line. Replace with: ```java AppVersion appVersion = appService.getVersion(deployment.appVersionId()); String artifactBase = artifactBaseUrl.isBlank() ? (globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl) : artifactBaseUrl; var token = artifactTokenSigner.sign(appVersion.id(), java.time.Duration.ofSeconds(artifactTokenTtlSeconds)); String artifactUrl = artifactBase + "/api/v1/artifacts/" + appVersion.id() + "?exp=" + token.exp() + "&sig=" + token.sig(); long expectedSize = appVersion.jarSizeBytes() == null ? 0L : appVersion.jarSizeBytes(); ``` Pass `appVersion`, `artifactUrl`, `expectedSize` into the `DeployCtx`: ```java private record DeployCtx( Deployment deployment, App app, Environment env, ResolvedContainerConfig config, UUID appVersionId, String artifactUrl, long artifactExpectedSize, String resolvedRuntimeType, String mainClass, String generation, String primaryNetwork, List additionalNets, Map baseEnvVars, Map prometheusLabels, long deployStart ) {} ``` - [ ] **Step 3: Drop the filesystem check in `preFlightChecks`** ```java private void preFlightChecks(AppVersion appVersion, ResolvedContainerConfig config) { if (appVersion.jarSizeBytes() == null || appVersion.jarSizeBytes() <= 0) { throw new IllegalStateException("AppVersion " + appVersion.id() + " has no recorded jarSizeBytes"); } if (config.memoryLimitMb() <= 0) throw new IllegalStateException("..."); if (config.appPort() <= 0 || config.appPort() > 65535) throw new IllegalStateException("..."); if (config.replicas() < 1) throw new IllegalStateException("..."); } ``` Remove the `Files.exists` import. - [ ] **Step 4: Update `startReplica` to build the new `ContainerRequest`** ```java ContainerRequest request = new ContainerRequest( containerName, baseImage, ctx.appVersionId(), ctx.artifactUrl(), ctx.artifactExpectedSize(), loaderImage, ctx.primaryNetwork(), ctx.additionalNets(), replicaEnvVars, labels, config.memoryLimitBytes(), config.memoryReserveBytes(), config.dockerCpuShares(), config.dockerCpuQuota(), config.exposedPorts(), agentHealthPort, "on-failure", 3, ctx.resolvedRuntimeType(), config.customArgs(), ctx.mainClass() ); ``` Drop the `volumeName`/`jarStoragePath` plumbing. - [ ] **Step 5: Drop `jarDockerVolume` + `jarStoragePath` @Value fields** They're unused now. Also drop the `cameleer.server.runtime.jardockervolume` doc references in CLAUDE.md. - [ ] **Step 6: Run executor tests — expect PASS** ```bash mvn -pl cameleer-server-app test -Dtest='DeploymentExecutor*' ``` - [ ] **Step 7: Commit** ```bash git add -A git commit -m "feat(runtime): DeploymentExecutor generates signed URL; drops filesystem JAR plumbing" ``` --- ## Task 12: Configuration + integration smoke test **Files:** - Modify: `cameleer-server-app/src/main/resources/application.yml` - New: `cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/InitContainerDeployIT.java` - [ ] **Step 1: Add new properties to `application.yml`** ```yaml cameleer: server: runtime: loaderimage: ${CAMELEER_SERVER_RUNTIME_LOADERIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest} artifacttokenttlseconds: ${CAMELEER_SERVER_RUNTIME_ARTIFACTTOKENTTLSECONDS:600} artifactbaseurl: ${CAMELEER_SERVER_RUNTIME_ARTIFACTBASEURL:} ``` Drop `jardockervolume` and (keep `jarstoragepath` for the filesystem store, but mark it as filesystem-store-specific). - [ ] **Step 2: Write the integration test (Testcontainers + real Docker)** Drives the whole flow: spin up the server in MockMvc/full context, upload a tiny JAR, trigger a deployment against a local Docker daemon, assert: per-replica volume created → loader exited 0 → main container running → JAR contents in `/app/jars/app.jar` match. Use `AbstractPostgresIT` as a base. Build the loader image inside the test if not present in the local cache, OR skip when not present and emit a warning. (Detailed test code omitted — model after `cameleer-server-app/src/test/java/.../runtime/*IT.java`.) - [ ] **Step 3: Run with Docker daemon** ```bash mvn -pl cameleer-server-app verify -Dit.test=InitContainerDeployIT ``` - [ ] **Step 4: Commit** ```bash git add -A git commit -m "test(runtime): end-to-end IT for init-container deploy" ``` --- ## Task 13: Update class/endpoint maps **Files:** - Modify: `.claude/rules/docker-orchestration.md` — describe the loader pattern, named volume per replica, `withUsernsMode`, drop the "JAR bind-mount" lines - Modify: `.claude/rules/core-classes.md` — add `ArtifactStore`, `ArtifactCoordinates` to a new "storage/" subsection; update `ContainerRequest` field list; remove `AppService.resolveJarPath` - Modify: `.claude/rules/app-classes.md` — add `ArtifactDownloadController` + `ArtifactDownloadTokenSigner` + `FilesystemArtifactStore`; update the flat-endpoint allow-list with `/api/v1/artifacts/{appVersionId}` (reason: "Content-addressed download. HMAC-signed URL, no JWT context") - Modify: `CLAUDE.md` — drop the `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` mention, replace with the loader-image / token-TTL / artifact-base-url envs - [ ] **Step 1: Edit each rule file** (No code template — read the existing file and make targeted updates.) - [ ] **Step 2: Run `gitnexus_detect_changes` to verify scope** ```bash # This is project policy per CLAUDE.md ``` - [ ] **Step 3: Commit** ```bash git add -A git commit -m "docs(rules): document init-container loader + ArtifactStore; drop bind-mount" ``` --- ## Task 14: Regenerate OpenAPI schema The new `ArtifactDownloadController` adds a public endpoint — SPA types need to know about it (even if the SPA never calls it directly). - [ ] **Step 1: Start the backend** ```bash mvn -pl cameleer-server-app spring-boot:run ``` - [ ] **Step 2: Regenerate** ```bash cd ui && npm run generate-api:live ``` - [ ] **Step 3: Fix any TypeScript compile errors that surface** ```bash cd ui && npm run typecheck ``` - [ ] **Step 4: Commit** ```bash git add ui/src/api/openapi.json ui/src/api/schema.d.ts git commit -m "chore(ui): regenerate openapi schema for /api/v1/artifacts" ``` --- ## Self-Review - **Spec coverage** — all four "discussed" items map to tasks: - ArtifactStore abstraction → Tasks 1–5 - HMAC-signed URL + controller → Tasks 6–7 - Loader image → Task 8 - Container request + orchestrator + executor → Tasks 9–11 - `withUsernsMode` → embedded in Task 10 (lands once volume model is in) - Maven coordinates UX → **deferred**, separate plan (out of scope; documented as a follow-on in the parent issue discussion) - **Type consistency** — `coordinatesFor(AppVersion)` is referenced in Tasks 5 and 7 with the same signature; `ArtifactDownloadTokenSigner.SignedToken(exp, sig)` reused unchanged in 6/7/11; `ContainerRequest`'s new fields used identically in 9/10/11. - **Placeholder scan** — Task 12 step 2 ("Detailed test code omitted") is the only deliberate omission; flagged so the engineer doesn't miss it. Everything else has either runnable code or specific edits. - **Hidden assumption to verify in Task 4 step 3:** `RuntimeDetector.detect(InputStream)` may need to be added — current code takes a `Path`. Either add the overload or stage to a temp file inside `uploadJar`. - **Migration safety** — existing `app_versions.jar_path` rows continue to resolve because `FilesystemArtifactStore.locator()` returns the same absolute path the upload code wrote yesterday. No data migration required.