From 87655442262b38bdfbd0c1d6842a85429188ce11 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 26 Apr 2024 09:59:20 +0200 Subject: [PATCH] Add pause resume (#413) --- .../service/HelmInstallService.java | 92 +++++++++++++++- .../controller/api/mylab/MyLabController.java | 87 +++++++++++++++ .../ServiceNotSuspendableException.java | 15 +++ .../api/events/SuspendResumeServiceEvent.java | 24 +++++ .../onyxia/api/services/AppsService.java | 25 +++++ .../api/services/impl/HelmAppsService.java | 101 +++++++++++++++++- .../onyxia/model/catalog/CatalogWrapper.java | 9 ++ .../insee/onyxia/model/service/Service.java | 22 ++++ 8 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/ServiceNotSuspendableException.java create mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/events/SuspendResumeServiceEvent.java diff --git a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/service/HelmInstallService.java b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/service/HelmInstallService.java index 2d8fb8d6..2d541f86 100644 --- a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/service/HelmInstallService.java +++ b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/service/HelmInstallService.java @@ -34,6 +34,62 @@ public class HelmInstallService { private static final String MANIFEST_INFO_TYPE = "manifest"; private static final String NOTES_INFO_TYPE = "notes"; + public void resume( + HelmConfiguration configuration, + String chart, + String namespace, + String name, + String version, + boolean dryRun, + final boolean skipTlsVerify, + String caFile) + throws InvalidExitValueException, + IOException, + InterruptedException, + TimeoutException, + IllegalArgumentException { + installChart( + configuration, + chart, + namespace, + name, + version, + dryRun, + null, + Map.of("global.suspend", "false"), + skipTlsVerify, + caFile, + true); + } + + public void suspend( + HelmConfiguration configuration, + String chart, + String namespace, + String name, + String version, + boolean dryRun, + final boolean skipTlsVerify, + String caFile) + throws InvalidExitValueException, + IOException, + InterruptedException, + TimeoutException, + IllegalArgumentException { + installChart( + configuration, + chart, + namespace, + name, + version, + dryRun, + null, + Map.of("global.suspend", "true"), + skipTlsVerify, + caFile, + true); + } + public HelmInstaller installChart( HelmConfiguration configuration, String chart, @@ -50,6 +106,37 @@ public HelmInstaller installChart( InterruptedException, TimeoutException, IllegalArgumentException { + return installChart( + configuration, + chart, + namespace, + name, + version, + dryRun, + values, + env, + skipTlsVerify, + caFile, + false); + } + + public HelmInstaller installChart( + HelmConfiguration configuration, + String chart, + String namespace, + String name, + String version, + boolean dryRun, + File values, + Map env, + final boolean skipTlsVerify, + String caFile, + boolean reuseValues) + throws InvalidExitValueException, + IOException, + InterruptedException, + TimeoutException, + IllegalArgumentException { StringBuilder command = new StringBuilder("helm upgrade --install "); if (skipTlsVerify) { command.append("--insecure-skip-tls-verify "); @@ -86,6 +173,9 @@ public HelmInstaller installChart( if (dryRun) { command.append(" --dry-run"); } + if (reuseValues) { + command.append(" --reuse-values"); + } String res = Command.executeAndGetResponseAsJson(configuration, command.toString()) .getOutput() @@ -176,7 +266,7 @@ private String buildEnvVar(Map env) { if (env != null) { Set envKeys = env.keySet(); return envKeys.stream() - .map(key -> "--set " + key + "=" + env.get(key)) + .map(key -> " --set " + key + "=" + env.get(key)) .collect(Collectors.joining(" ")); } return ""; 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 a56be01f..8142ffc3 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 @@ -4,6 +4,7 @@ import fr.insee.onyxia.api.configuration.CatalogWrapper; import fr.insee.onyxia.api.configuration.Catalogs; import fr.insee.onyxia.api.configuration.NotFoundException; +import fr.insee.onyxia.api.controller.exception.ServiceNotSuspendableException; import fr.insee.onyxia.api.services.AppsService; import fr.insee.onyxia.api.services.CatalogService; import fr.insee.onyxia.api.services.UserProvider; @@ -144,6 +145,80 @@ public Service getApp( return null; } + @PostMapping("/app/suspend") + public void suspendApp( + @Parameter(hidden = true) Region region, + @Parameter(hidden = true) Project project, + @RequestBody SuspendOrResumeRequestDTO request) + throws Exception { + suspendOrResume(region, project, request.getServiceID(), true); + } + + @PostMapping("/app/resume") + public void resumeApp( + @Parameter(hidden = true) Region region, + @Parameter(hidden = true) Project project, + @RequestBody SuspendOrResumeRequestDTO request) + throws Exception { + suspendOrResume(region, project, request.getServiceID(), false); + } + + private void suspendOrResume(Region region, Project project, String serviceId, boolean suspend) + throws Exception { + if (Service.ServiceType.KUBERNETES.equals(region.getServices().getType())) { + User user = userProvider.getUser(region); + Service userService = + helmAppsService.getUserService( + region, project, userProvider.getUser(region), serviceId); + if (!userService.isSuspendable()) { + throw new ServiceNotSuspendableException(); + } + String chart = userService.getChart(); + int split = chart.lastIndexOf('-'); + String chartName = chart.substring(0, split); + String version = chart.substring(split + 1); + List elligibleCatalogs = + catalogService.getCatalogs(region, user).getCatalogs().stream() + .filter( + catalog -> + catalog.getCatalog() + .getPackageByNameAndVersion(chartName, version) + .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(); + Pkg pkg = catalog.getCatalog().getPackageByNameAndVersion(chartName, version).get(); + if (suspend) { + helmAppsService.suspend( + region, + project, + catalog.getId(), + pkg, + user, + serviceId, + catalog.getSkipTlsVerify(), + catalog.getCaFile(), + false); + } else { + helmAppsService.resume( + region, + project, + catalog.getId(), + pkg, + user, + serviceId, + catalog.getSkipTlsVerify(), + catalog.getCaFile(), + false); + } + } + } + @Operation( summary = "Get the logs of a task in an installed service.", description = @@ -356,4 +431,16 @@ private Collection publishApps( return helmAppsService.installApp( region, project, requestDTO, catalogId, pkg, user, fusion, skipTlsVerify, caFile); } + + public static class SuspendOrResumeRequestDTO { + private String serviceID; + + public String getServiceID() { + return serviceID; + } + + public void setServiceID(String serviceID) { + this.serviceID = serviceID; + } + } } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/ServiceNotSuspendableException.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/ServiceNotSuspendableException.java new file mode 100644 index 00000000..fda58167 --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/ServiceNotSuspendableException.java @@ -0,0 +1,15 @@ +package fr.insee.onyxia.api.controller.exception; + +import fr.insee.onyxia.api.services.impl.HelmAppsService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.BAD_REQUEST) +public class ServiceNotSuspendableException extends RuntimeException { + public ServiceNotSuspendableException() { + super( + "This service is not suspendable. To be suspendable, a service must define " + + HelmAppsService.SUSPEND_KEY + + " as a key in values.schema.json"); + } +} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/events/SuspendResumeServiceEvent.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/events/SuspendResumeServiceEvent.java new file mode 100644 index 00000000..5c9427ca --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/events/SuspendResumeServiceEvent.java @@ -0,0 +1,24 @@ +package fr.insee.onyxia.api.events; + +public class SuspendResumeServiceEvent extends InstallServiceEvent { + + private boolean isSuspend; + + public SuspendResumeServiceEvent() {} + + public SuspendResumeServiceEvent( + String username, + String namespace, + String releaseName, + String packageName, + String catalogId, + boolean suspend) { + super(username, namespace, releaseName, packageName, catalogId); + this.isSuspend = suspend; + } + + @Override + public String getType() { + return isSuspend ? "service.suspend" : "service.install"; + } +} 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 b0f02025..1d832598 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 @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; import org.springframework.scheduling.annotation.Async; public interface AppsService { @@ -52,4 +53,28 @@ UninstallService destroyService( Watch getEvents(Region region, Project project, User user, Watcher watcher) throws HelmInstallService.MultipleServiceFound, ParseException; + + void resume( + Region region, + Project project, + String catalogId, + Pkg pkg, + User user, + String serviceId, + boolean skipTlsVerify, + String caFile, + boolean dryRun) + throws IOException, InterruptedException, TimeoutException; + + void suspend( + Region region, + Project project, + String catalogId, + Pkg pkg, + User user, + String serviceId, + boolean skipTlsVerify, + String caFile, + boolean dryRun) + throws IOException, InterruptedException, TimeoutException; } 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 1d97b703..fd5aefc7 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 @@ -9,6 +9,7 @@ import fr.insee.onyxia.api.controller.exception.NamespaceNotFoundException; import fr.insee.onyxia.api.events.InstallServiceEvent; 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.services.AppsService; import fr.insee.onyxia.api.services.control.AdmissionControllerHelm; @@ -54,6 +55,8 @@ @Qualifier("Helm") public class HelmAppsService implements AppsService { + public static final String SUSPEND_KEY = "global.suspend"; + private static final Logger LOGGER = LoggerFactory.getLogger(HelmAppsService.class); private final ObjectMapper mapperHelm; @@ -184,7 +187,6 @@ public String getInitScript( String namespaceId = kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); try { - HelmInstaller res = getHelmInstallService() .installChart( @@ -398,6 +400,10 @@ private Service getHelmApp(Region region, User user, HelmLs release) { currentNode -> mapAppender(result, currentNode, new ArrayList())); service.setEnv(result); + service.setSuspendable(service.getEnv().containsKey(SUSPEND_KEY)); + if (service.getEnv().containsKey(SUSPEND_KEY)) { + service.setSuspended(Boolean.parseBoolean(service.getEnv().get(SUSPEND_KEY))); + } } catch (Exception e) { LOGGER.warn("Exception occurred", e); } @@ -410,6 +416,99 @@ private Service getHelmApp(Region region, User user, HelmLs release) { return service; } + @Override + public void suspend( + Region region, + Project project, + String catalogId, + Pkg pkg, + User user, + String serviceId, + boolean skipTlsVerify, + String caFile, + boolean dryRun) + throws IOException, InterruptedException, TimeoutException { + suspendOrResume( + region, + project, + catalogId, + pkg, + user, + serviceId, + skipTlsVerify, + caFile, + dryRun, + true); + } + + @Override + public void resume( + Region region, + Project project, + String catalogId, + Pkg pkg, + User user, + String serviceId, + boolean skipTlsVerify, + String caFile, + boolean dryRun) + throws IOException, InterruptedException, TimeoutException { + suspendOrResume( + region, + project, + catalogId, + pkg, + user, + serviceId, + skipTlsVerify, + caFile, + dryRun, + false); + } + + public void suspendOrResume( + Region region, + Project project, + String catalogId, + Pkg pkg, + User user, + String serviceId, + boolean skipTlsVerify, + String caFile, + boolean dryRun, + boolean suspend) + throws IOException, InterruptedException, TimeoutException { + String namespaceId = + kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + if (suspend) { + getHelmInstallService() + .suspend( + getHelmConfiguration(region, user), + catalogId + "/" + pkg.getName(), + namespaceId, + serviceId, + pkg.getVersion(), + dryRun, + skipTlsVerify, + caFile); + } else { + getHelmInstallService() + .resume( + getHelmConfiguration(region, user), + catalogId + "/" + pkg.getName(), + namespaceId, + serviceId, + pkg.getVersion(), + dryRun, + skipTlsVerify, + caFile); + } + SuspendResumeServiceEvent event = + new SuspendResumeServiceEvent( + user.getIdep(), namespaceId, serviceId, pkg.getName(), catalogId, suspend); + onyxiaEventPublisher.publishEvent(event); + } + private void mapAppender( Map result, Map.Entry node, List names) { names.add(node.getKey()); diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/catalog/CatalogWrapper.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/catalog/CatalogWrapper.java index b2f767d1..eb24dd33 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/catalog/CatalogWrapper.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/catalog/CatalogWrapper.java @@ -16,6 +16,15 @@ public Optional getPackageByName(String name) { return entries.get(name).stream().findFirst(); } + public Optional getPackageByNameAndVersion(String name, String version) { + if (!entries.containsKey(name)) { + return Optional.empty(); + } + return entries.get(name).stream() + .filter(p -> version.equalsIgnoreCase(p.getVersion())) + .findFirst(); + } + /** * @return the packages */ diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/service/Service.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/service/Service.java index eee067a4..9aa28743 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/service/Service.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/service/Service.java @@ -71,6 +71,12 @@ public class Service { @Schema(description = "This should be removed in v1.0 ") private long startedAt; + @Schema(description = "Is this service suspendable ?") + private boolean suspendable = false; + + @Schema(description = "Is this service suspended ?") + private boolean suspended = false; + @Schema(description = "") private Map labels; @@ -242,6 +248,22 @@ public String getPostInstallInstructions() { return postInstallInstructions; } + public boolean isSuspendable() { + return suspendable; + } + + public boolean isSuspended() { + return suspended; + } + + public void setSuspendable(boolean suspendable) { + this.suspendable = suspendable; + } + + public void setSuspended(boolean suspended) { + this.suspended = suspended; + } + @Schema(description = "") public static enum ServiceStatus { DEPLOYING,