14-task TDD plan to replace bind-mount JAR delivery with init-container download from Cameleer over HTTP, sitting behind a new ArtifactStore abstraction. Lands withUsernsMode hardening (last open gap from #152) and gives storage a clean migration path to OCI (Zot) tracked separately in issue #158. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
64 KiB
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
ArtifactStoreinterface incameleer-server-corewith 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-loadercontainer (busybox + 30-lineentrypoint.sh) first, sharing a per-replica named Docker volume with the main container. Loader downloadsapp.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.jarstays 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
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
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
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
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.
*
* <p>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
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
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
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
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
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:
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<App> listAll() { return appRepo.findAll(); }
public List<App> 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<AppVersion> 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<String, Object> 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 anInputStreamoverload if needed (it currently takes aPath). 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
// 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
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
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
@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.
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<AppVersion> versions = versionRepo.findByAppId(app.id());
if (versions.size() <= retentionCount) return 0;
Set<UUID> 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
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
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
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
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)
@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
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<InputStreamResource> 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
permitAlltoSecurityConfig
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
ArtifactDownloadTokenSignerbean (inSecurityBeanConfigorRuntimeBeanConfig)
@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
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
#!/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
# 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
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
docker push gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest
- Step 5: Write
README.md
# 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:<tag> .
docker push gitea.siegeln.net/cameleer/cameleer-runtime-loader:<tag>
## 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
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
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<String> additionalNetworks,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
Long memoryReserveBytes,
int cpuShares,
Long cpuQuota,
List<Integer> 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)
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
@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<HostConfig> 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<HostConfig> 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:
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<String> 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:
@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
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:
@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:
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:
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<String> additionalNets,
Map<String, String> baseEnvVars,
Map<String, String> prometheusLabels,
long deployStart
) {}
- Step 3: Drop the filesystem check in
preFlightChecks
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
startReplicato build the newContainerRequest
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
mvn -pl cameleer-server-app test -Dtest='DeploymentExecutor*'
- Step 7: Commit
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
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
mvn -pl cameleer-server-app verify -Dit.test=InitContainerDeployIT
- Step 4: Commit
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— addArtifactStore,ArtifactCoordinatesto a new "storage/" subsection; updateContainerRequestfield list; removeAppService.resolveJarPath -
Modify:
.claude/rules/app-classes.md— addArtifactDownloadController+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 theCAMELEER_SERVER_RUNTIME_JARDOCKERVOLUMEmention, 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_changesto verify scope
# This is project policy per CLAUDE.md
- Step 3: Commit
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
mvn -pl cameleer-server-app spring-boot:run
- Step 2: Regenerate
cd ui && npm run generate-api:live
- Step 3: Fix any TypeScript compile errors that surface
cd ui && npm run typecheck
- Step 4: Commit
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 aPath. Either add the overload or stage to a temp file insideuploadJar. - Migration safety — existing
app_versions.jar_pathrows continue to resolve becauseFilesystemArtifactStore.locator()returns the same absolute path the upload code wrote yesterday. No data migration required.