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:
@@ -1,5 +1,6 @@
|
||||
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.PostgresAppVersionRepository;
|
||||
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.EnvironmentRepository;
|
||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||
import com.cameleer.server.core.storage.ArtifactStore;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -56,11 +58,17 @@ public class RuntimeBeanConfig {
|
||||
enforcer.assertWithinCap("max_environments", current, 1));
|
||||
}
|
||||
|
||||
@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,
|
||||
@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) {
|
||||
return new AppService(appRepo, versionRepo, jarStoragePath,
|
||||
return new AppService(appRepo, versionRepo, artifactStore, tenantId,
|
||||
current -> enforcer.assertWithinCap("max_apps", current, 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ public class DeploymentExecutor {
|
||||
try {
|
||||
App app = appService.getById(deployment.appId());
|
||||
Environment env = envService.getById(deployment.environmentId());
|
||||
String jarPath = appService.resolveJarPath(deployment.appVersionId());
|
||||
String jarPath = appService.getVersion(deployment.appVersionId()).jarPath();
|
||||
String generation = generationOf(deployment);
|
||||
|
||||
var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(
|
||||
|
||||
@@ -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,32 +83,34 @@ 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);
|
||||
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={}",
|
||||
@@ -106,14 +119,11 @@ public class AppService {
|
||||
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown",
|
||||
appId, nextVersion, size, checksum);
|
||||
}
|
||||
|
||||
return versionRepo.findById(versionId).orElseThrow();
|
||||
} finally {
|
||||
try { java.nio.file.Files.deleteIfExists(scratch); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
public String resolveJarPath(UUID appVersionId) {
|
||||
AppVersion version = versionRepo.findById(appVersionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
|
||||
return version.jarPath();
|
||||
return versionRepo.findById(versionId).orElseThrow();
|
||||
}
|
||||
|
||||
public void deleteApp(UUID id) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user