diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 8fca652518f2..54c07008880f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -374,6 +374,7 @@ public class ApiConstants { public static final String LB_PROVIDER = "lbprovider"; public static final String MAC_ADDRESS = "macaddress"; public static final String MAC_ADDRESSES = "macaddresses"; + public static final String MANIFEST_URL = "manifesturl"; public static final String MANUAL_UPGRADE = "manualupgrade"; public static final String MAX = "max"; public static final String MAX_SNAPS = "maxsnaps"; diff --git a/framework/extensions/pom.xml b/framework/extensions/pom.xml index 4d42d6840ddf..7196e5710a67 100644 --- a/framework/extensions/pom.xml +++ b/framework/extensions/pom.xml @@ -50,5 +50,10 @@ 4.23.0.0-SNAPSHOT compile + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${cs.jackson.version} + diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ImportExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ImportExtensionCmd.java new file mode 100644 index 000000000000..7999a75590f0 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ImportExtensionCmd.java @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.api; + +import java.util.EnumSet; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsImportManager; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.user.Account; + +@APICommand(name = "importExtension", + description = "Imports an extension", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class ImportExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Inject + ExtensionsImportManager extensionsImportManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.MANIFEST_URL, type = CommandType.STRING, required = true, + description = "URL of the extension manifest import file") + private String manifestUrl; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getManifestUrl() { + return manifestUrl; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + Extension extension = extensionsImportManager.importExtension(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManager.java new file mode 100644 index 000000000000..c09888a5299b --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManager.java @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.manager; + +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.api.ImportExtensionCmd; + +public interface ExtensionsImportManager { + Extension importExtension(ImportExtensionCmd cmd); +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManagerImpl.java new file mode 100644 index 000000000000..444f763d4ba8 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsImportManagerImpl.java @@ -0,0 +1,126 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.manager; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import javax.inject.Inject; + +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.api.ImportExtensionCmd; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.util.ExtensionConfig; +import org.apache.cloudstack.framework.extensions.util.YamlParser; +import org.apache.cloudstack.framework.extensions.util.ZipExtractor; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.hypervisor.ExternalProvisioner; +import com.cloud.utils.FileUtil; +import com.cloud.utils.HttpUtils; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallbackWithException; +import com.cloud.utils.exception.CloudRuntimeException; + +public class ExtensionsImportManagerImpl extends ManagerBase implements ExtensionsImportManager { + + @Inject + ExtensionsManager extensionsManager; + + @Inject + ExternalProvisioner externalProvisioner; + + @Inject + ExtensionDao extensionDao; + + protected Extension importExtensionInternal(String manifestUrl, Path tempDir) { + Path manifestPath = tempDir.resolve("manifest.yaml"); + HttpUtils.downloadFileWithProgress(manifestUrl, manifestPath.toString(), logger); + if (!Files.exists(manifestPath)) { + throw new CloudRuntimeException("Failed to download extension manifest from URL: " + manifestUrl); + } + final ExtensionConfig extensionConfig = YamlParser.parseYamlFile(manifestPath.toString()); + //Parse the manifest and create the extension + final String name = extensionConfig.metadata.name; + final String extensionArchiveURL = extensionConfig.getArchiveUrl(); + ExtensionVO extensionByName = extensionDao.findByName(name); + if (extensionByName != null) { + throw new CloudRuntimeException("Extension by name already exists"); + } + if (StringUtils.isBlank(extensionArchiveURL)) { + throw new CloudRuntimeException("Unable to retrieve archive URL for extension source during import"); + } + Path extensionArchivePath = tempDir.resolve(UUID.randomUUID() + ".zip"); + HttpUtils.downloadFileWithProgress(extensionArchiveURL, extensionArchivePath.toString(), logger); + if (!Files.exists(extensionArchivePath)) { + throw new CloudRuntimeException("Failed to download extension archive from URL: " + extensionArchiveURL); + } + final String extensionRootPath = externalProvisioner.getExtensionsPath() + File.separator + name; + try { + ZipExtractor.extractZipContents(extensionArchivePath.toString(), extensionRootPath); + } catch (IOException e) { + throw new CloudRuntimeException("Failed to extract extension archive during import at: " + extensionRootPath, e); + } + return Transaction.execute((TransactionCallbackWithException) status -> { + Extension extension = extensionsManager.createExtension(name, extensionConfig.metadata.description, + extensionConfig.spec.type, extensionConfig.spec.entrypoint.path, Extension.State.Enabled.name(), + false, Collections.emptyMap()); + + for (ExtensionConfig.CustomAction action : extensionConfig.spec.customActions) { + Map> parameters = action.getParametersAsMap(); + extensionsManager.addCustomAction(action.name, action.description, extension.getId(), + action.resourcetype, action.allowedroletypes, action.timeout, true, parameters, + null, null, Collections.emptyMap()); + } + return extension; + }); + } + + @Override + public Extension importExtension(ImportExtensionCmd cmd) { + final String manifestUrl = cmd.getManifestUrl(); + final String extensionsRootPath = externalProvisioner.getExtensionsPath(); + + Path tempDir; + try { + Path extensionsRootDir = Paths.get(extensionsRootPath); + Files.createDirectories(extensionsRootDir); + tempDir = Files.createTempDirectory(extensionsRootDir, "import-ext-"); + + } catch (IOException e) { + logger.error("Failed to create working directory for import extension, {}", extensionsRootPath, e); + throw new CloudRuntimeException("Failed to create working directory for import extension", e); + } + try { + return importExtensionInternal(manifestUrl, tempDir); + } catch (Exception e) { + logger.error(e.getMessage(), e); + throw e; + } finally { + FileUtil.deletePath(tempDir.toString()); + } + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java index 1b1a175c5975..e50f0fa61db9 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -57,6 +57,9 @@ public interface ExtensionsManager extends Manager { Extension createExtension(CreateExtensionCmd cmd); + Extension createExtension(String name, String description, String type, String relativePath, String state, + Boolean orchestratorRequiresPrepareVm, Map details); + boolean prepareExtensionPathAcrossServers(Extension extension); List listExtensions(ListExtensionsCmd cmd); @@ -79,6 +82,10 @@ public interface ExtensionsManager extends Manager { ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd); + ExtensionCustomAction addCustomAction(String name, String description, long extensionId, String resourceTypeStr, + List rolesStrList, int timeout , boolean enabled, Map parametersMap, String successMessage, + String errorMessage, Map details); + boolean deleteCustomAction(DeleteCustomActionCmd cmd); List listCustomActions(ListCustomActionCmd cmd); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java index 4171b9615fea..7c711d885476 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -68,6 +68,7 @@ import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; import org.apache.cloudstack.framework.extensions.api.DeleteCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.DeleteExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.ImportExtensionCmd; import org.apache.cloudstack.framework.extensions.api.ListCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.ListExtensionsCmd; import org.apache.cloudstack.framework.extensions.api.RegisterExtensionCmd; @@ -570,13 +571,8 @@ public String getExtensionsPath() { @Override @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CREATE, eventDescription = "creating extension") - public Extension createExtension(CreateExtensionCmd cmd) { - final String name = cmd.getName(); - final String description = cmd.getDescription(); - final String typeStr = cmd.getType(); - String relativePath = cmd.getPath(); - final Boolean orchestratorRequiresPrepareVm = cmd.isOrchestratorRequiresPrepareVm(); - final String stateStr = cmd.getState(); + public Extension createExtension(String name, String description, String typeStr, String relativePath, + String stateStr, Boolean orchestratorRequiresPrepareVm, Map details) { ExtensionVO extensionByName = extensionDao.findByName(name); if (extensionByName != null) { throw new CloudRuntimeException("Extension by name already exists"); @@ -612,7 +608,6 @@ public Extension createExtension(CreateExtensionCmd cmd) { } extension = extensionDao.persist(extension); - Map details = cmd.getDetails(); List detailsVOList = new ArrayList<>(); if (MapUtils.isNotEmpty(details)) { for (Map.Entry entry : details.entrySet()) { @@ -640,6 +635,22 @@ public Extension createExtension(CreateExtensionCmd cmd) { return extensionVO; } + + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CREATE, eventDescription = "creating extension") + public Extension createExtension(CreateExtensionCmd cmd) { + final String name = cmd.getName(); + final String description = cmd.getDescription(); + final String typeStr = cmd.getType(); + String relativePath = cmd.getPath(); + final Boolean orchestratorRequiresPrepareVm = cmd.isOrchestratorRequiresPrepareVm(); + final String stateStr = cmd.getState(); + final Map details = cmd.getDetails(); + return createExtension(name, description, typeStr, relativePath, stateStr, orchestratorRequiresPrepareVm, + details); + } + @Override public boolean prepareExtensionPathAcrossServers(Extension extension) { boolean prepared = true; @@ -973,18 +984,10 @@ public ExtensionResponse createExtensionResponse(Extension extension, @Override @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_ADD, eventDescription = "adding extension custom action") - public ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd) { - String name = cmd.getName(); - String description = cmd.getDescription(); - Long extensionId = cmd.getExtensionId(); - String resourceTypeStr = cmd.getResourceType(); - List rolesStrList = cmd.getAllowedRoleTypes(); - final int timeout = ObjectUtils.defaultIfNull(cmd.getTimeout(), 3); - final boolean enabled = cmd.isEnabled(); - Map parametersMap = cmd.getParametersMap(); - final String successMessage = cmd.getSuccessMessage(); - final String errorMessage = cmd.getErrorMessage(); - Map details = cmd.getDetails(); + public ExtensionCustomAction addCustomAction(String name, String description, long extensionId, + String resourceTypeStr, List rolesStrList, int timeout , boolean enabled, Map parametersMap, + String successMessage, String errorMessage, Map details) { + if (name == null || !name.matches("^[a-zA-Z0-9 _-]+$")) { throw new InvalidParameterValueException(String.format("Invalid action name: %s. It can contain " + "only alphabets, numbers, hyphen, underscore and space", name)); @@ -1004,7 +1007,7 @@ public ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd) { if (resourceType == null) { throw new InvalidParameterValueException( String.format("Invalid resource type specified: %s. Valid values are: %s", resourceTypeStr, - EnumSet.allOf(ExtensionCustomAction.ResourceType.class))); + EnumSet.allOf(ExtensionCustomAction.ResourceType.class))); } } if (resourceType == null && Extension.Type.Orchestrator.equals(extensionVO.getType())) { @@ -1050,6 +1053,24 @@ public ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd) { }); } + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_ADD, eventDescription = "adding extension custom action") + public ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd) { + String name = cmd.getName(); + String description = cmd.getDescription(); + Long extensionId = cmd.getExtensionId(); + String resourceTypeStr = cmd.getResourceType(); + List rolesStrList = cmd.getAllowedRoleTypes(); + final int timeout = ObjectUtils.defaultIfNull(cmd.getTimeout(), 3); + final boolean enabled = cmd.isEnabled(); + Map parametersMap = cmd.getParametersMap(); + final String successMessage = cmd.getSuccessMessage(); + final String errorMessage = cmd.getErrorMessage(); + Map details = cmd.getDetails(); + return addCustomAction(name, description, extensionId, resourceTypeStr, rolesStrList, timeout, enabled, + parametersMap, successMessage, errorMessage, details); + } + @Override @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_DELETE, eventDescription = "deleting extension custom action") public boolean deleteCustomAction(DeleteCustomActionCmd cmd) { @@ -1637,6 +1658,7 @@ public List> getCommands() { cmds.add(RunCustomActionCmd.class); cmds.add(CreateExtensionCmd.class); + cmds.add(ImportExtensionCmd.class); cmds.add(ListExtensionsCmd.class); cmds.add(DeleteExtensionCmd.class); cmds.add(UpdateExtensionCmd.class); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ExtensionConfig.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ExtensionConfig.java new file mode 100644 index 000000000000..04daff1ec917 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ExtensionConfig.java @@ -0,0 +1,139 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.util; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExtensionConfig { + + public String apiVersion; + public String kind; + public Metadata metadata; + public Source source; + public Spec spec; + + String archiveUrl; + + // ----------------------------- + // Nested compact model + // ----------------------------- + + public static class Metadata { + public String name; + public String displayName; + public String description; + public String version; + public String maintainer; + public String homepage; + } + + public static class Source { + public String type; + public String url; + public String refs; + } + + public static class Spec { + public String type; + public Compatibility compatibility; + public Entrypoint entrypoint; + public Orchestrator orchestrator; + private Map details; + public boolean enabled; + public List customActions; + + public Map getDetails() { + return details; + } + + public void setDetails(Map details) { + this.details = details; + } + } + + public static class Compatibility { + public CloudStack cloudstack; + } + + public static class CloudStack { + public String minVersion; + } + + public static class Entrypoint { + public String language; + public String path; + public String targetDir; + } + + public static class Orchestrator { + public boolean requiresPrepareVm; + } + + public static class CustomAction { + public String name; + public String displayName; + public String description; + public String resourcetype; + public boolean enabled; + public int timeout; + public List allowedroletypes; + public List parameters; + + public Map> getParametersAsMap() { + Map> paramMap = new HashMap<>(); + int index = 0; + for (Parameter param : parameters) { + Map singleParamMap = new HashMap<>(); + singleParamMap.put(ApiConstants.NAME, param.name); + singleParamMap.put(ApiConstants.TYPE, param.type); + singleParamMap.put(ApiConstants.VALIDATION_FORMAT, param.validationformat); + singleParamMap.put(ApiConstants.REQUIRED, Boolean.toString(param.required)); + paramMap.put(index++, singleParamMap); + } + return paramMap; + } + } + + public static class Parameter { + public String name; + public String type; + public String validationformat; + public boolean required; + } + + public String getArchiveUrl() { + String type = source != null ? source.type : null; + if ("git".equalsIgnoreCase(type) && source.url != null && source.url.contains("github.com")) { + // ToDo: improve + String ref = source.refs != null ? source.refs : "main"; + return source.url.replace("github.com", "codeload.github.com") + "/zip/refs/heads/" + ref; + } + return source == null ? null : source.url; + } + + public Spec getSpec() { + return spec; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/YamlParser.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/YamlParser.java new file mode 100644 index 000000000000..83da965ef6f4 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/YamlParser.java @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.util; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +public class YamlParser { + private static final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + + public static ExtensionConfig parseYamlFile(String filePath) { + ExtensionConfig extensionConfig = null; + try (InputStream in = Files.newInputStream(Path.of(filePath))) { + extensionConfig = mapper.readValue(in, ExtensionConfig.class); + } catch (Exception ex) { + throw new CloudRuntimeException("Failed to parse YAML", ex); + } + return extensionConfig; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ZipExtractor.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ZipExtractor.java new file mode 100644 index 000000000000..e6d559e8fae8 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/util/ZipExtractor.java @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.util; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +public class ZipExtractor { + + /** + * Extracts a GitHub ZIP file contents directly into destDir, skipping top-level folder. + * + * @param zipFilePath Path to the ZIP file + * @param destDir Destination directory + */ + public static void extractZipContents(String zipFilePath, String destDir) throws IOException { + Path destPath = Paths.get(destDir); + if (!Files.exists(destPath)) { + Files.createDirectories(destPath); + } + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(Paths.get(zipFilePath)))) { + ZipEntry entry; + + while ((entry = zis.getNextEntry()) != null) { + String entryName = entry.getName(); + + // Skip the top-level folder (everything before first '/') + int firstSlash = entryName.indexOf('/'); + if (firstSlash >= 0) { + entryName = entryName.substring(firstSlash + 1); + } + + if (entryName.isEmpty()) { + zis.closeEntry(); + continue; // skip the top-level folder itself + } + + Path newPath = safeResolve(destPath, entryName); + + if (entry.isDirectory()) { + Files.createDirectories(newPath); + } else { + if (newPath.getParent() != null) { + Files.createDirectories(newPath.getParent()); + } + try (OutputStream os = Files.newOutputStream(newPath)) { + zis.transferTo(os); + } + } + + zis.closeEntry(); + } + } + } + + /** + * Protects from ZIP Slip vulnerability. + */ + private static Path safeResolve(Path destDir, String entryName) throws IOException { + Path resolved = destDir.resolve(entryName).normalize(); + if (!resolved.startsWith(destDir)) { + throw new IOException("ZIP entry outside target dir: " + entryName); + } + return resolved; + } +} diff --git a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml index 9d44d8ff7f3d..51f2df32dc69 100644 --- a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml +++ b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml @@ -31,6 +31,7 @@ + diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/util/YamlParserTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/util/YamlParserTest.java new file mode 100644 index 000000000000..c45ad5778159 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/util/YamlParserTest.java @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.util; + +import org.junit.Test; + +import junit.framework.TestCase; + +public class YamlParserTest extends TestCase { + + @Test + public void testParseYaml() { + String yamlFilePath = getClass().getResource("testmanifest.yaml").getFile(); + ExtensionConfig config = YamlParser.parseYamlFile(yamlFilePath); + assertNotNull(config); + } +} diff --git a/framework/extensions/src/test/resources/org/apache/cloudstack/framework/extensions/util/testmanifest.yaml b/framework/extensions/src/test/resources/org/apache/cloudstack/framework/extensions/util/testmanifest.yaml new file mode 100644 index 000000000000..9495da6b58f8 --- /dev/null +++ b/framework/extensions/src/test/resources/org/apache/cloudstack/framework/extensions/util/testmanifest.yaml @@ -0,0 +1,110 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +apiVersion: cloudstack.apache.org/v1 +kind: OrchestratorExtension + +metadata: + name: test + displayName: Test Extension + description: > + Test extension via the Orchestrator Extension Framework. + version: 0.1.0 + maintainer: "Test Maintainer " + homepage: "https://github.com/maintainer/test" + +source: + type: git + url: "https://github.com/maintainer/test" + refs: "branchName" + +spec: + type: Orchestrator + + compatibility: + cloudstack: + minVersion: 4.23.0 + + entrypoint: + path: test.py + targetDir: /usr/share/cloudstack-management/extensions/test + + orchestrator: + requiresPrepareVm: false + + details: + key1: value1 + key2: value2 + + enabled: true + + customActions: + - name: CreateSnapshot + displayName: "Create Snapshot" + description: "Create a snapshot for a virtual machine instance." + resourcetype: VirtualMachine + enabled: true + timeout: 600 + allowedroletypes: [Admin, DomainAdmin, User] + parameters: + - name: snap_name + type: STRING + validationformat: NONE + required: true + - name: snap_description + type: STRING + validationformat: NONE + required: false + - name: snap_save_memory + type: BOOLEAN + validationformat: NONE + required: false + + - name: ListSnapshots + displayName: "List Snapshots" + description: "List all snapshots for a virtual machine instance." + resourcetype: VirtualMachine + enabled: true + timeout: 120 + allowedroletypes: [Admin, DomainAdmin, User] + parameters: [] + + - name: RestoreSnapshot + displayName: "Restore Snapshot" + description: "Restore a virtual machine instance from a given snapshot." + resourcetype: VirtualMachine + enabled: true + timeout: 900 + allowedroletypes: [Admin, DomainAdmin, User] + parameters: + - name: snap_name + type: STRING + validationformat: NONE + required: true + + - name: DeleteSnapshot + displayName: "Delete Snapshot" + description: "Delete a snapshot for a virtual machine instance." + resourcetype: VirtualMachine + enabled: true + timeout: 300 + allowedroletypes: [Admin, DomainAdmin, User] + parameters: + - name: snap_name + type: STRING + validationformat: NONE + required: true diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 529929eb5243..4cf2515ac03d 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1246,6 +1246,7 @@ "label.images": "Images", "label.imagestoreid": "Secondary Storage", "label.import.backup.offering": "Import Backup Offering", +"label.import.extension": "Import Extension", "label.import.instance": "Import Instance", "label.import.offering": "Import Offering", "label.import.role": "Import Role", @@ -1517,6 +1518,7 @@ "label.management.servers": "Management Servers", "label.management.server.peers": "Peers", "label.managementservers": "Number of management servers", +"label.manifesturl": "Manifest URL", "label.matchall": "Match all", "label.max": "Max.", "label.max.primary.storage": "Max. primary (GiB)", diff --git a/ui/src/config/section/extension.js b/ui/src/config/section/extension.js index 4c6d9ebf0761..60f483a0da54 100644 --- a/ui/src/config/section/extension.js +++ b/ui/src/config/section/extension.js @@ -86,6 +86,15 @@ export default { popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/extension/CreateExtension.vue'))) }, + { + api: 'importExtension', + icon: 'cloud-upload-outlined', + label: 'label.import.extension', + docHelp: 'adminguide/extensions.html', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/ImportExtension.vue'))) + }, { api: 'updateExtension', icon: 'edit-outlined', diff --git a/ui/src/views/extension/ImportExtension.vue b/ui/src/views/extension/ImportExtension.vue new file mode 100644 index 000000000000..2e8fd55b6c15 --- /dev/null +++ b/ui/src/views/extension/ImportExtension.vue @@ -0,0 +1,116 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + +