diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/mylab/MyLabController.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/mylab/MyLabController.java index 657e94cf..b61432d7 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/mylab/MyLabController.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/mylab/MyLabController.java @@ -14,6 +14,7 @@ import fr.insee.onyxia.model.catalog.Pkg; import fr.insee.onyxia.model.dto.CreateServiceDTO; import fr.insee.onyxia.model.dto.ServicesListing; +import fr.insee.onyxia.model.dto.UpgradeServiceDTO; import fr.insee.onyxia.model.helm.Chart; import fr.insee.onyxia.model.project.Project; import fr.insee.onyxia.model.region.Region; @@ -36,7 +37,9 @@ import org.json.JSONObject; import org.json.JSONTokener; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @Tag(name = "My lab", description = "My services") @@ -533,26 +536,7 @@ private Collection publishApps( Map fusion = new HashMap<>(); fusion.putAll((Map) requestDTO.getOptions()); - // If getConfig is null that means that no schema is provided : skip validation - if (pkg.getConfig() != null) { - JSONObject jsonSchema = - new JSONObject( - new JSONTokener( - jsonSchemaResolutionService - .resolveReferences(pkg.getConfig(), user.getRoles()) - .toString())); - - SchemaLoader loader = - SchemaLoader.builder() - .schemaJson(jsonSchema) - .draftV6Support() // or draftV7Support() - .build(); - org.everit.json.schema.Schema schema = loader.load().build(); - // Convert the options map to a JSONObject - JSONObject jsonObject = new JSONObject(fusion); - // Validate the options object against the schema - schema.validate(jsonObject); - } + validateOptionsAgainstSchema(pkg, user, fusion); boolean skipTlsVerify = catalog.getSkipTlsVerify(); String caFile = catalog.getCaFile(); @@ -571,6 +555,124 @@ private Collection publishApps( caFile); } + private void validateOptionsAgainstSchema(Pkg pkg, User user, Map fusion) + throws Exception { + // If getConfig is null that means that no schema is provided : skip validation + if (pkg.getConfig() == null) { + return; + } + JSONObject jsonSchema = + new JSONObject( + new JSONTokener( + jsonSchemaResolutionService + .resolveReferences(pkg.getConfig(), user.getRoles()) + .toString())); + + SchemaLoader loader = + SchemaLoader.builder() + .schemaJson(jsonSchema) + .draftV6Support() // or draftV7Support() + .build(); + org.everit.json.schema.Schema schema = loader.load().build(); + // Convert the options map to a JSONObject + JSONObject jsonObject = new JSONObject(fusion); + // Validate the options object against the schema + schema.validate(jsonObject); + } + + @Operation( + summary = "Upgrade an existing service.", + description = + "Re-deploy an existing service with new parameters (values.yaml, chart version, friendly name, share). The service name and namespace are kept. If the chart version is not provided, the service is upgraded with its current chart version.", + parameters = { + @Parameter( + name = "ONYXIA-PROJECT", + description = + "Project associated with the namespace, defaults to user project.", + in = ParameterIn.HEADER, + schema = + @Schema( + name = "ONYXIA-PROJECT", + type = "string", + description = "Generated project id.", + example = "project-id-example")) + }) + @PutMapping("/app/{serviceId}") + public Object upgradeApp( + @Parameter(hidden = true) Region region, + @Parameter(hidden = true) Project project, + @PathVariable String serviceId, + @RequestBody UpgradeServiceDTO requestDTO) + throws Exception { + User user = userProvider.getUser(region); + Service userService = helmAppsService.getUserService(region, project, user, serviceId); + if (!serviceId.equals(userService.getId())) { + throw new NotFoundException(); + } + String chart = userService.getChart(); + int split = chart.lastIndexOf('-'); + String chartName = chart.substring(0, split); + String currentVersion = chart.substring(split + 1); + if (!(requestDTO.getOptions() instanceof Map)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "options must be an object"); + } + String version = + requestDTO.getPackageVersion() != null + ? requestDTO.getPackageVersion() + : currentVersion; + String catalogId = userService.getCatalogId(); + // This code is for legacy compat for services that were created with Onyxia < v2.7.0 + // before introduction of Onyxia's secret + if (catalogId == null) { + List elligibleCatalogs = + catalogService.getCatalogs(region, user).getCatalogs().stream() + .filter( + catalog -> + catalog.getCatalog() + .getPackageByName(chartName) + .isPresent()) + .toList(); + if (elligibleCatalogs.isEmpty()) { + throw new NotFoundException(); + } + if (elligibleCatalogs.size() > 1) { + throw new IllegalStateException("Chart is present in multiple catalogs, abort"); + } + CatalogWrapper catalog = elligibleCatalogs.getFirst(); + catalogId = catalog.getId(); + } + Optional catalog = catalogService.getCatalogById(catalogId, user); + if (catalog.isEmpty()) { + throw new IllegalStateException("Catalog " + catalogId + " is not available anymore"); + } + + Pkg pkg = + catalog.get() + .getCatalog() + .getPackageByNameAndVersion(chartName, version) + .orElseThrow(NotFoundException::new); + + Map fusion = new HashMap<>(); + fusion.putAll((Map) requestDTO.getOptions()); + + validateOptionsAgainstSchema(pkg, user, fusion); + + return helmAppsService.upgradeApp( + region, + project, + catalogId, + chartName, + version, + user, + serviceId, + fusion, + requestDTO.getFriendlyName(), + requestDTO.getShare(), + catalog.get().getSkipTlsVerify(), + catalog.get().getTimeout(), + catalog.get().getCaFile()); + } + public static class SuspendOrResumeRequestDTO { private String serviceID; diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/events/UpgradeServiceEvent.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/events/UpgradeServiceEvent.java new file mode 100644 index 00000000..50fc8a50 --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/events/UpgradeServiceEvent.java @@ -0,0 +1,23 @@ +package fr.insee.onyxia.api.events; + +public class UpgradeServiceEvent extends InstallServiceEvent { + + public static final String TYPE = "service.upgrade"; + + public UpgradeServiceEvent() {} + + public UpgradeServiceEvent( + String username, + String namespace, + String releaseName, + String packageName, + String catalogId, + String friendlyName) { + super(username, namespace, releaseName, packageName, catalogId, friendlyName); + } + + @Override + public String getType() { + return TYPE; + } +} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/AppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/AppsService.java index 5927c96b..cd5b5ee3 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/AppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/AppsService.java @@ -44,6 +44,22 @@ Collection installApp( final String caFile) throws Exception; + Collection upgradeApp( + Region region, + Project project, + String catalogId, + String chartName, + String version, + User user, + String serviceId, + Map fusion, + String friendlyName, + Boolean share, + final boolean skipTlsVerify, + String timeout, + final String caFile) + throws Exception; + Service getUserService(Region region, Project project, User user, String serviceId) throws Exception; diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index e6b596f6..eab4a38e 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -11,6 +11,7 @@ import fr.insee.onyxia.api.events.OnyxiaEventPublisher; import fr.insee.onyxia.api.events.SuspendResumeServiceEvent; import fr.insee.onyxia.api.events.UninstallServiceEvent; +import fr.insee.onyxia.api.events.UpgradeServiceEvent; import fr.insee.onyxia.api.services.AppsService; import fr.insee.onyxia.api.services.impl.kubernetes.KubernetesService; import fr.insee.onyxia.api.services.utils.Base64Utils; @@ -157,6 +158,72 @@ public Collection installApp( } } + @Override + public Collection upgradeApp( + Region region, + Project project, + String catalogId, + String chartName, + String version, + User user, + String serviceId, + Map fusion, + String friendlyName, + Boolean share, + final boolean skipTlsVerify, + String timeout, + final String caFile) + throws IOException, TimeoutException, InterruptedException { + + File values = File.createTempFile("values", ".yaml"); + mapperHelm.writeValue(values, fusion); + String namespaceId = + kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + try { + HelmInstaller res = + getHelmInstallService() + .installChart( + getHelmConfiguration(region, user), + catalogId + "/" + chartName, + namespaceId, + serviceId, + version, + false, + values, + null, + skipTlsVerify, + timeout, + caFile, + false); + UpgradeServiceEvent upgradeServiceEvent = + new UpgradeServiceEvent( + user.getIdep(), + namespaceId, + serviceId, + chartName, + catalogId, + friendlyName); + onyxiaEventPublisher.publishEvent(upgradeServiceEvent); + Map metadata = new HashMap<>(); + if (friendlyName != null) { + metadata.put(FRIENDLY_NAME, friendlyName); + } + if (share != null) { + metadata.put(SHARE, String.valueOf(share)); + } + if (!metadata.isEmpty()) { + patchOnyxiaSecret(region, project, user, serviceId, metadata); + } + return List.of(res.getManifest()); + } catch (IllegalArgumentException e) { + throw new AccessDeniedException(e.getMessage()); + } finally { + if (!values.delete()) { + LOGGER.warn("Failed to delete values file, path {}", values.getAbsolutePath()); + } + } + } + @Override public CompletableFuture getUserServices( Region region, Project project, User user) throws IOException, IllegalAccessException { @@ -589,7 +656,12 @@ private Service getServiceFromRelease( c -> { Container container = new Container(); container.setName(c.getName()); - container.setReady(c.getReady()); + container.setReady( + pod.getMetadata() + .getDeletionTimestamp() + == null + && Boolean.TRUE.equals( + c.getReady())); currentTask.getContainers().add(container); }); currentTask.setStatus(status); diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/dto/UpgradeServiceDTO.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/dto/UpgradeServiceDTO.java new file mode 100644 index 00000000..3973bd80 --- /dev/null +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/dto/UpgradeServiceDTO.java @@ -0,0 +1,60 @@ +package fr.insee.onyxia.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Parameter for service upgrade (re-deploying an existing service)") +public class UpgradeServiceDTO { + @Schema( + description = + "Version of the helm package to upgrade to. If null, the service is upgraded with its current chart version.", + required = false) + String packageVersion; + + @Schema(description = "Content of values.yaml", required = true) + Object options; + + @Schema( + description = + "When true, all users of the namespace will list this service. If null, the current value is kept.", + required = false) + Boolean share; + + @Schema( + description = "A friendly name for the service. If null, the current value is kept.", + required = false) + String friendlyName; + + public String getPackageVersion() { + return packageVersion; + } + + public void setPackageVersion(String packageVersion) { + this.packageVersion = packageVersion; + } + + public Object getOptions() { + return options; + } + + public void setOptions(Object options) { + this.options = options; + } + + public Boolean getShare() { + return share; + } + + public void setShare(Boolean share) { + this.share = share; + } + + public String getFriendlyName() { + return friendlyName; + } + + public void setFriendlyName(String friendlyName) { + this.friendlyName = friendlyName; + } +}