refactor(core): AppService writes via ArtifactStore; remove resolveJarPath

Task 4 of the init-container JAR fetch plan: migrate AppService.uploadJar
off direct filesystem writes onto the ArtifactStore abstraction so future
backends (OCI/Zot, S3) can swap in without touching service or controller
code.

- AppService constructor now takes (AppRepository, AppVersionRepository,
  ArtifactStore, tenantId[, CreateGuard]). The store owns layout and the
  locator string written into app_versions.jar_path.
- uploadJar buffers the request body once for hashing + storage, then
  writes a scratch temp file solely for RuntimeDetector (which still
  takes a Path); scratch is unconditionally deleted in finally.
- Add coordinatesFor(AppVersion) helper so downstream callers (Task 5+)
  can derive ArtifactCoordinates without knowing the tenant binding.
- Remove resolveJarPath. DeploymentExecutor now reads jarPath directly
  off the AppVersion record; the clean cut to download-URL delivery
  lands in Task 11.
- RuntimeBeanConfig wires a FilesystemArtifactStore bean rooted at
  cameleer.server.runtime.jarstoragepath and threads tenantId into the
  AppService bean.
This commit is contained in:
hsiegeln
2026-04-27 15:05:40 +02:00
parent 5238c58dd5
commit 07a2fd6090
4 changed files with 109 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.config; package com.cameleer.server.app.config;
import com.cameleer.server.app.storage.FilesystemArtifactStore;
import com.cameleer.server.app.storage.PostgresAppRepository; import com.cameleer.server.app.storage.PostgresAppRepository;
import com.cameleer.server.app.storage.PostgresAppVersionRepository; import com.cameleer.server.app.storage.PostgresAppVersionRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository; import com.cameleer.server.app.storage.PostgresDeploymentRepository;
@@ -12,6 +13,7 @@ import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.DirtyStateCalculator; import com.cameleer.server.core.runtime.DirtyStateCalculator;
import com.cameleer.server.core.runtime.EnvironmentRepository; import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.runtime.EnvironmentService; import com.cameleer.server.core.runtime.EnvironmentService;
import com.cameleer.server.core.storage.ArtifactStore;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -56,11 +58,17 @@ public class RuntimeBeanConfig {
enforcer.assertWithinCap("max_environments", current, 1)); enforcer.assertWithinCap("max_environments", current, 1));
} }
@Bean
public ArtifactStore artifactStore(@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) {
return new FilesystemArtifactStore(jarStoragePath);
}
@Bean @Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo, public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath, ArtifactStore artifactStore,
@Value("${cameleer.server.tenant.id:default}") String tenantId,
com.cameleer.server.app.license.LicenseEnforcer enforcer) { com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AppService(appRepo, versionRepo, jarStoragePath, return new AppService(appRepo, versionRepo, artifactStore, tenantId,
current -> enforcer.assertWithinCap("max_apps", current, 1)); current -> enforcer.assertWithinCap("max_apps", current, 1));
} }

View File

@@ -134,7 +134,7 @@ public class DeploymentExecutor {
try { try {
App app = appService.getById(deployment.appId()); App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId()); Environment env = envService.getById(deployment.environmentId());
String jarPath = appService.resolveJarPath(deployment.appVersionId()); String jarPath = appService.getVersion(deployment.appVersionId()).jarPath();
String generation = generationOf(deployment); String generation = generationOf(deployment);
var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults( var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(

View File

@@ -1,12 +1,14 @@
package com.cameleer.server.core.runtime; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
@@ -22,18 +24,21 @@ public class AppService {
private final AppRepository appRepo; private final AppRepository appRepo;
private final AppVersionRepository versionRepo; private final AppVersionRepository versionRepo;
private final String jarStoragePath; private final ArtifactStore artifactStore;
private final String tenantId;
private final CreateGuard createGuard; private final CreateGuard createGuard;
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) { public AppService(AppRepository appRepo, AppVersionRepository versionRepo,
this(appRepo, versionRepo, jarStoragePath, CreateGuard.NOOP); ArtifactStore artifactStore, String tenantId) {
this(appRepo, versionRepo, artifactStore, tenantId, CreateGuard.NOOP);
} }
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath, public AppService(AppRepository appRepo, AppVersionRepository versionRepo,
CreateGuard createGuard) { ArtifactStore artifactStore, String tenantId, CreateGuard createGuard) {
this.appRepo = appRepo; this.appRepo = appRepo;
this.versionRepo = versionRepo; this.versionRepo = versionRepo;
this.jarStoragePath = jarStoragePath; this.artifactStore = artifactStore;
this.tenantId = tenantId;
this.createGuard = createGuard; this.createGuard = createGuard;
} }
@@ -52,6 +57,12 @@ public class AppService {
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + versionId)); .orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + versionId));
} }
/** Coordinate corresponding to an AppVersion. Used by callers that need to
* pass the artifact identity to {@link ArtifactStore} (download, delete, exists). */
public ArtifactCoordinates coordinatesFor(AppVersion version) {
return new ArtifactCoordinates(tenantId, version.appId(), version.version());
}
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) { public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {
getById(id); // verify exists getById(id); // verify exists
appRepo.updateContainerConfig(id, containerConfig); appRepo.updateContainerConfig(id, containerConfig);
@@ -72,32 +83,34 @@ public class AppService {
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException { public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
getById(appId); // verify app exists getById(appId); // verify app exists
int nextVersion = versionRepo.findMaxVersion(appId) + 1; int nextVersion = versionRepo.findMaxVersion(appId) + 1;
ArtifactCoordinates coords = new ArtifactCoordinates(tenantId, appId, nextVersion);
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar // Buffer once for hashing + storage so RuntimeDetector can re-read without seeking.
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion); ByteArrayOutputStream buf = new ByteArrayOutputStream(
Files.createDirectories(versionDir); Math.max(8192, (int) Math.min(size, 64 * 1024 * 1024)));
Path jarFile = versionDir.resolve("app.jar");
MessageDigest digest; MessageDigest digest;
try { digest = MessageDigest.getInstance("SHA-256"); } try { digest = MessageDigest.getInstance("SHA-256"); }
catch (Exception e) { throw new RuntimeException(e); } catch (Exception e) { throw new RuntimeException(e); }
try (InputStream in = jarData) { try (InputStream in = jarData) {
byte[] buffer = new byte[8192]; byte[] tmp = new byte[8192];
int bytesRead; int n;
try (var out = Files.newOutputStream(jarFile)) { while ((n = in.read(tmp)) != -1) {
while ((bytesRead = in.read(buffer)) != -1) { buf.write(tmp, 0, n);
out.write(buffer, 0, bytesRead); digest.update(tmp, 0, n);
digest.update(buffer, 0, bytesRead);
}
} }
} }
byte[] bytes = buf.toByteArray();
String locator = artifactStore.put(coords, new ByteArrayInputStream(bytes), bytes.length);
String checksum = HexFormat.of().formatHex(digest.digest()); String checksum = HexFormat.of().formatHex(digest.digest());
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
// Detect runtime type from the saved JAR UUID versionId = versionRepo.create(appId, nextVersion, locator, checksum, filename, size);
RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(jarFile);
// RuntimeDetector currently takes a Path; write bytes to a scratch file just for detection.
java.nio.file.Path scratch = java.nio.file.Files.createTempFile("cameleer-detect-", ".jar");
try {
java.nio.file.Files.write(scratch, bytes);
RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(scratch);
if (detection.runtimeType() != null) { if (detection.runtimeType() != null) {
versionRepo.updateDetectedRuntime(versionId, detection.runtimeType().toConfigValue(), detection.mainClass()); versionRepo.updateDetectedRuntime(versionId, detection.runtimeType().toConfigValue(), detection.mainClass());
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected={}", log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected={}",
@@ -106,14 +119,11 @@ public class AppService {
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown", log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown",
appId, nextVersion, size, checksum); appId, nextVersion, size, checksum);
} }
} finally {
return versionRepo.findById(versionId).orElseThrow(); try { java.nio.file.Files.deleteIfExists(scratch); } catch (IOException ignored) {}
} }
public String resolveJarPath(UUID appVersionId) { return versionRepo.findById(versionId).orElseThrow();
AppVersion version = versionRepo.findById(appVersionId)
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
return version.jarPath();
} }
public void deleteApp(UUID id) { public void deleteApp(UUID id) {

View File

@@ -0,0 +1,51 @@
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.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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, null, 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);
}
}