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;
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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