diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java index a7fd5941..b76e08a7 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java @@ -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)); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java index 8e2f0a59..4ffc6ea9 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java @@ -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( diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java index 884b844a..ad99ddad 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java @@ -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 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); } diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/AppServiceUploadTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/AppServiceUploadTest.java new file mode 100644 index 00000000..c59d0a82 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/AppServiceUploadTest.java @@ -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); + } +}