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,12 +1,14 @@
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.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
@@ -22,18 +24,21 @@ public class AppService {
private final AppRepository appRepo;
private final AppVersionRepository versionRepo;
private final String jarStoragePath;
private final ArtifactStore artifactStore;
private final String tenantId;
private final CreateGuard createGuard;
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
this(appRepo, versionRepo, jarStoragePath, CreateGuard.NOOP);
public AppService(AppRepository appRepo, AppVersionRepository versionRepo,
ArtifactStore artifactStore, String tenantId) {
this(appRepo, versionRepo, artifactStore, tenantId, CreateGuard.NOOP);
}
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath,
CreateGuard createGuard) {
public AppService(AppRepository appRepo, AppVersionRepository versionRepo,
ArtifactStore artifactStore, String tenantId, CreateGuard createGuard) {
this.appRepo = appRepo;
this.versionRepo = versionRepo;
this.jarStoragePath = jarStoragePath;
this.artifactStore = artifactStore;
this.tenantId = tenantId;
this.createGuard = createGuard;
}
@@ -52,6 +57,12 @@ public class AppService {
.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) {
getById(id); // verify exists
appRepo.updateContainerConfig(id, containerConfig);
@@ -72,50 +83,49 @@ public class AppService {
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
getById(appId); // verify app exists
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
ArtifactCoordinates coords = new ArtifactCoordinates(tenantId, appId, nextVersion);
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
Files.createDirectories(versionDir);
Path jarFile = versionDir.resolve("app.jar");
// Buffer once for hashing + storage so RuntimeDetector can re-read without seeking.
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[] buffer = new byte[8192];
int bytesRead;
try (var out = Files.newOutputStream(jarFile)) {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
digest.update(buffer, 0, bytesRead);
}
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 ByteArrayInputStream(bytes), bytes.length);
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
RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(jarFile);
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);
UUID versionId = versionRepo.create(appId, nextVersion, locator, checksum, filename, size);
// 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) {
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);
}
} finally {
try { java.nio.file.Files.deleteIfExists(scratch); } catch (IOException ignored) {}
}
return versionRepo.findById(versionId).orElseThrow();
}
public String resolveJarPath(UUID appVersionId) {
AppVersion version = versionRepo.findById(appVersionId)
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
return version.jarPath();
}
public void deleteApp(UUID id) {
appRepo.delete(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);
}
}