feat: per-app resource limits, auto-slug, and polished create dialogs
Add per-app memory limit and CPU shares (stored on AppEntity, used by DeploymentService with fallback to global defaults). JAR upload is now optional at creation time. Both create modals show the computed slug in the dialog title and use consistent Cancel-left/Action-right button layout with inline styles to avoid Modal CSS conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,13 +52,14 @@ public class AppController {
|
||||
public ResponseEntity<AppResponse> create(
|
||||
@PathVariable UUID environmentId,
|
||||
@RequestPart("metadata") String metadataJson,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestPart(value = "file", required = false) MultipartFile file,
|
||||
Authentication authentication) {
|
||||
|
||||
try {
|
||||
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId);
|
||||
var entity = appService.create(environmentId, request.slug(), request.displayName(),
|
||||
file, request.memoryLimit(), request.cpuShares(), actorId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
var msg = e.getMessage();
|
||||
@@ -168,6 +169,7 @@ public class AppController {
|
||||
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||
app.getExposedPort(), routeUrl,
|
||||
app.getMemoryLimit(), app.getCpuShares(),
|
||||
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||
app.getCreatedAt(), app.getUpdatedAt());
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ public class AppEntity {
|
||||
@Column(name = "exposed_port")
|
||||
private Integer exposedPort;
|
||||
|
||||
@Column(name = "memory_limit", length = 20)
|
||||
private String memoryLimit;
|
||||
|
||||
@Column(name = "cpu_shares")
|
||||
private Integer cpuShares;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -81,6 +87,10 @@ public class AppEntity {
|
||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
||||
public Integer getExposedPort() { return exposedPort; }
|
||||
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
|
||||
public String getMemoryLimit() { return memoryLimit; }
|
||||
public void setMemoryLimit(String memoryLimit) { this.memoryLimit = memoryLimit; }
|
||||
public Integer getCpuShares() { return cpuShares; }
|
||||
public void setCpuShares(Integer cpuShares) { this.cpuShares = cpuShares; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
||||
@@ -41,8 +41,11 @@ public class AppService {
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
}
|
||||
|
||||
public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) {
|
||||
validateJarFile(jarFile);
|
||||
public AppEntity create(UUID envId, String slug, String displayName,
|
||||
MultipartFile jarFile, String memoryLimit, Integer cpuShares, UUID actorId) {
|
||||
if (jarFile != null && !jarFile.isEmpty()) {
|
||||
validateJarFile(jarFile);
|
||||
}
|
||||
|
||||
var env = environmentRepository.findById(envId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
|
||||
@@ -54,17 +57,21 @@ public class AppService {
|
||||
var tenantId = env.getTenantId();
|
||||
enforceTierLimit(tenantId);
|
||||
|
||||
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
|
||||
var checksum = storeJar(jarFile, relativePath);
|
||||
|
||||
var entity = new AppEntity();
|
||||
entity.setEnvironmentId(envId);
|
||||
entity.setSlug(slug);
|
||||
entity.setDisplayName(displayName);
|
||||
entity.setJarStoragePath(relativePath);
|
||||
entity.setJarChecksum(checksum);
|
||||
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
|
||||
entity.setJarSizeBytes(jarFile.getSize());
|
||||
entity.setMemoryLimit(memoryLimit);
|
||||
entity.setCpuShares(cpuShares);
|
||||
|
||||
if (jarFile != null && !jarFile.isEmpty()) {
|
||||
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
|
||||
var checksum = storeJar(jarFile, relativePath);
|
||||
entity.setJarStoragePath(relativePath);
|
||||
entity.setJarChecksum(checksum);
|
||||
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
|
||||
entity.setJarSizeBytes(jarFile.getSize());
|
||||
}
|
||||
|
||||
var saved = appRepository.save(entity);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ public record AppResponse(
|
||||
String jarChecksum,
|
||||
Integer exposedPort,
|
||||
String routeUrl,
|
||||
String memoryLimit,
|
||||
Integer cpuShares,
|
||||
UUID currentDeploymentId,
|
||||
UUID previousDeploymentId,
|
||||
Instant createdAt,
|
||||
|
||||
@@ -9,5 +9,9 @@ public record CreateAppRequest(
|
||||
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
String slug,
|
||||
@NotBlank @Size(max = 255)
|
||||
String displayName
|
||||
String displayName,
|
||||
@Size(max = 20)
|
||||
@Pattern(regexp = "^(\\d+[mgMG])?$", message = "Memory limit must be like 256m, 512m, 1g")
|
||||
String memoryLimit,
|
||||
Integer cpuShares
|
||||
) {}
|
||||
|
||||
@@ -137,6 +137,13 @@ public class DeploymentService {
|
||||
String.valueOf(app.getExposedPort()));
|
||||
}
|
||||
|
||||
long memoryBytes = app.getMemoryLimit() != null
|
||||
? parseMemoryBytes(app.getMemoryLimit())
|
||||
: runtimeConfig.parseMemoryLimitBytes();
|
||||
int cpuShares = app.getCpuShares() != null
|
||||
? app.getCpuShares()
|
||||
: runtimeConfig.getContainerCpuShares();
|
||||
|
||||
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||
deployment.getImageRef(),
|
||||
containerName,
|
||||
@@ -149,8 +156,8 @@ public class DeploymentService {
|
||||
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
||||
"CAMELEER_DISPLAY_NAME", containerName
|
||||
),
|
||||
runtimeConfig.parseMemoryLimitBytes(),
|
||||
runtimeConfig.getContainerCpuShares(),
|
||||
memoryBytes,
|
||||
cpuShares,
|
||||
runtimeConfig.getAgentHealthPort(),
|
||||
labels
|
||||
));
|
||||
@@ -232,6 +239,16 @@ public class DeploymentService {
|
||||
return deploymentRepository.findById(deploymentId);
|
||||
}
|
||||
|
||||
static long parseMemoryBytes(String limit) {
|
||||
var s = limit.trim().toLowerCase();
|
||||
if (s.endsWith("g")) {
|
||||
return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024 * 1024;
|
||||
} else if (s.endsWith("m")) {
|
||||
return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024;
|
||||
}
|
||||
return Long.parseLong(s);
|
||||
}
|
||||
|
||||
boolean waitForHealthy(String containerId, int timeoutSeconds) {
|
||||
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20);
|
||||
ALTER TABLE apps ADD COLUMN cpu_shares INTEGER;
|
||||
@@ -84,7 +84,7 @@ class AppServiceTest {
|
||||
.thenReturn(Optional.of(license));
|
||||
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = appService.create(envId, "myapp", "My App", jarFile, actorId);
|
||||
var result = appService.create(envId, "myapp", "My App", jarFile, null, null, actorId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("myapp");
|
||||
assertThat(result.getDisplayName()).isEqualTo("My App");
|
||||
@@ -109,7 +109,7 @@ class AppServiceTest {
|
||||
|
||||
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
|
||||
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, actorId))
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, null, null, actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(".jar");
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class AppServiceTest {
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, actorId))
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, null, null, actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("myapp");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user