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.
+
+
+
+
+
+
+
+