Skip to content

Commit e470bc3

Browse files
committed
extensions: add sync functionality
This change introduces support for synchronizing extension files across management servers in a clustered environment. - Adds new syncExtension API to trigger synchronization for a selected extension. - Implements service to create .tgz archive of the extension directory or selected files. - Generates signed share URL with HMAC signature and expiry. - Sends DownloadAndSyncExtensionFilesCommand to peer management servers. - Handles archive download, staging, and extraction on receiver side. - Adds Sync Extension action in the UI Extensions view. - Updates events, logging, and cleanup of temporary share files. Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
1 parent a6ef24d commit e470bc3

35 files changed

Lines changed: 3931 additions & 2029 deletions

File tree

api/src/main/java/com/cloud/event/EventTypes.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,7 @@ public class EventTypes {
844844
public static final String EVENT_EXTENSION_CREATE = "EXTENSION.CREATE";
845845
public static final String EVENT_EXTENSION_UPDATE = "EXTENSION.UPDATE";
846846
public static final String EVENT_EXTENSION_DELETE = "EXTENSION.DELETE";
847+
public static final String EVENT_EXTENSION_SYNC = "EXTENSION.SYNC";
847848
public static final String EVENT_EXTENSION_RESOURCE_REGISTER = "EXTENSION.RESOURCE.REGISTER";
848849
public static final String EVENT_EXTENSION_RESOURCE_UNREGISTER = "EXTENSION.RESOURCE.UNREGISTER";
849850
public static final String EVENT_EXTENSION_CUSTOM_ACTION_ADD = "EXTENSION.CUSTOM.ACTION.ADD";
@@ -1385,6 +1386,7 @@ public class EventTypes {
13851386
entityEventDetails.put(EVENT_EXTENSION_CREATE, Extension.class);
13861387
entityEventDetails.put(EVENT_EXTENSION_UPDATE, Extension.class);
13871388
entityEventDetails.put(EVENT_EXTENSION_DELETE, Extension.class);
1389+
entityEventDetails.put(EVENT_EXTENSION_SYNC, Extension.class);
13881390
entityEventDetails.put(EVENT_EXTENSION_RESOURCE_REGISTER, Extension.class);
13891391
entityEventDetails.put(EVENT_EXTENSION_RESOURCE_UNREGISTER, Extension.class);
13901392
entityEventDetails.put(EVENT_EXTENSION_CUSTOM_ACTION_ADD, ExtensionCustomAction.class);

api/src/main/java/org/apache/cloudstack/api/ApiConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ public class ApiConstants {
547547

548548
public static final String SOURCE_CIDR_LIST = "sourcecidrlist";
549549
public static final String SOURCE_ZONE_ID = "sourcezoneid";
550+
public static final String SOURCE_MANAGEMENT_SERVER_ID = "sourcemanagementserverid";
550551
public static final String SSL_VERIFICATION = "sslverification";
551552
public static final String START_ASN = "startasn";
552553
public static final String START_DATE = "startdate";
@@ -567,6 +568,7 @@ public class ApiConstants {
567568
public static final String SWAP_OWNER = "swapowner";
568569
public static final String SYSTEM_VM_TYPE = "systemvmtype";
569570
public static final String TAGS = "tags";
571+
public static final String TARGET_MANAGEMENT_SERVER_IDS = "targetmanagementserverids";
570572
public static final String STORAGE_TAGS = "storagetags";
571573
public static final String STORAGE_ACCESS_GROUPS = "storageaccessgroups";
572574
public static final String STORAGE_ACCESS_GROUP = "storageaccessgroup";

client/conf/server.properties.in

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,19 @@ extensions.deployment.mode=@EXTENSIONSDEPLOYMENTMODE@
6262
# Thread pool configuration
6363
#threads.min=10
6464
#threads.max=500
65+
66+
# These properties configure the share endpoint, which enables controlled file sharing through the management server.
67+
# They allow administrators to enable or disable sharing, set the base directory for shared files, define cache
68+
# behavior, restrict access to specific directories, and secure access with a secret key. This ensures flexible and
69+
# secure file sharing for different modules such as extensions, etc.
70+
# Enable or disable file sharing feature (true/false). Default is true
71+
share.enabled=true
72+
# The base directory from which files can be shared. Default is <HOME_DIRECTORY_OF_CLOUD_USER>/share
73+
# share.base.dir=
74+
# The cache control header value to be used for shared files. Default is public,max-age=86400,immutable
75+
# share.cache.control=public,max-age=86400,immutable
76+
# Allow or disallow directory listing when accessing a directory. Default is false
77+
# share.dir.allowed=false
78+
# Secret key for securing links using HMAC signature. If not set then links will not be signed. Default is change-me
79+
# It is recommended to change this value to a strong secret key in production
80+
share.secret=change-me

client/src/main/java/org/apache/cloudstack/ServerDaemon.java

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,25 @@
2424
import java.io.InputStream;
2525
import java.lang.management.ManagementFactory;
2626
import java.net.URL;
27+
import java.nio.file.Files;
28+
import java.nio.file.Path;
29+
import java.nio.file.Paths;
2730
import java.util.Arrays;
31+
import java.util.EnumSet;
32+
import java.util.List;
2833
import java.util.Properties;
2934

30-
import com.cloud.api.ApiServer;
35+
import javax.servlet.DispatcherType;
36+
37+
import org.apache.cloudstack.utils.server.ServerPropertiesUtil;
3138
import org.apache.commons.daemon.Daemon;
3239
import org.apache.commons.daemon.DaemonContext;
3340
import org.apache.commons.lang3.StringUtils;
41+
import org.apache.logging.log4j.LogManager;
42+
import org.apache.logging.log4j.Logger;
3443
import org.eclipse.jetty.jmx.MBeanContainer;
3544
import org.eclipse.jetty.server.ForwardedRequestCustomizer;
45+
import org.eclipse.jetty.server.Handler;
3646
import org.eclipse.jetty.server.HttpConfiguration;
3747
import org.eclipse.jetty.server.HttpConnectionFactory;
3848
import org.eclipse.jetty.server.RequestLog;
@@ -46,14 +56,18 @@
4656
import org.eclipse.jetty.server.handler.RequestLogHandler;
4757
import org.eclipse.jetty.server.handler.gzip.GzipHandler;
4858
import org.eclipse.jetty.server.session.SessionHandler;
59+
import org.eclipse.jetty.servlet.DefaultServlet;
60+
import org.eclipse.jetty.servlet.FilterHolder;
61+
import org.eclipse.jetty.servlet.ServletContextHandler;
62+
import org.eclipse.jetty.servlet.ServletHolder;
63+
import org.eclipse.jetty.util.resource.Resource;
4964
import org.eclipse.jetty.util.ssl.KeyStoreScanner;
5065
import org.eclipse.jetty.util.ssl.SslContextFactory;
5166
import org.eclipse.jetty.util.thread.QueuedThreadPool;
5267
import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;
5368
import org.eclipse.jetty.webapp.WebAppContext;
54-
import org.apache.logging.log4j.Logger;
55-
import org.apache.logging.log4j.LogManager;
5669

70+
import com.cloud.api.ApiServer;
5771
import com.cloud.utils.Pair;
5872
import com.cloud.utils.PropertiesUtil;
5973
import com.cloud.utils.server.ServerProperties;
@@ -111,6 +125,12 @@ public class ServerDaemon implements Daemon {
111125
private int minThreads;
112126
private int maxThreads;
113127

128+
private boolean shareEnabled = false;
129+
private String shareBaseDir;
130+
private String shareCacheCtl;
131+
private boolean shareDirList = false;
132+
private String shareSecret;
133+
114134
//////////////////////////////////////////////////
115135
/////////////// Public methods ///////////////////
116136
//////////////////////////////////////////////////
@@ -121,6 +141,14 @@ public static void main(final String... anArgs) throws Exception {
121141
daemon.start();
122142
}
123143

144+
protected void initShareConfigFromProperties() {
145+
setShareEnabled(ServerPropertiesUtil.getShareEnabled());
146+
setShareBaseDir(ServerPropertiesUtil.getShareBaseDirectory());
147+
setShareCacheCtl(ServerPropertiesUtil.getShareCacheControl());
148+
setShareDirList(ServerPropertiesUtil.getShareDirAllowed());
149+
setShareSecret(ServerPropertiesUtil.getShareSecret());
150+
}
151+
124152
@Override
125153
public void init(final DaemonContext context) {
126154
final File confFile = PropertiesUtil.findConfigFile("server.properties");
@@ -153,6 +181,7 @@ public void init(final DaemonContext context) {
153181
setMaxFormKeys(Integer.valueOf(properties.getProperty(REQUEST_MAX_FORM_KEYS_KEY, String.valueOf(DEFAULT_REQUEST_MAX_FORM_KEYS))));
154182
setMinThreads(Integer.valueOf(properties.getProperty(THREADS_MIN, "10")));
155183
setMaxThreads(Integer.valueOf(properties.getProperty(THREADS_MAX, "500")));
184+
initShareConfigFromProperties();
156185
} catch (final IOException e) {
157186
logger.warn("Failed to read configuration from server.properties file", e);
158187
} finally {
@@ -288,6 +317,52 @@ private void createHttpsConnector(final HttpConfiguration httpConfig) {
288317
}
289318
}
290319

320+
/**
321+
* Creates a Jetty context at /share to serve static files for modules (e.g. Extensions Framework).
322+
* Controlled via server properties
323+
*
324+
* @return a configured Handler or null if disabled.
325+
*/
326+
private Handler createShareContextHandler() throws IOException {
327+
if (!shareEnabled) {
328+
logger.info("/{} context not mounted", ServerPropertiesUtil.SHARE_DIR);
329+
return null;
330+
}
331+
332+
final Path base = Paths.get(shareBaseDir);
333+
Files.createDirectories(base);
334+
335+
final ServletContextHandler shareCtx = new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
336+
shareCtx.setContextPath("/" + ServerPropertiesUtil.SHARE_DIR);
337+
shareCtx.setBaseResource(Resource.newResource(base.toAbsolutePath().toUri()));
338+
339+
// Efficient static file serving
340+
ServletHolder def = shareCtx.addServlet(DefaultServlet.class, "/*");
341+
def.setInitParameter("dirAllowed", Boolean.toString(shareDirList));
342+
def.setInitParameter("etags", "true");
343+
def.setInitParameter("cacheControl", shareCacheCtl);
344+
def.setInitParameter("useFileMappedBuffer", "true");
345+
def.setInitParameter("acceptRanges", "true");
346+
347+
// Gzip using modern Jetty handler
348+
org.eclipse.jetty.server.handler.gzip.GzipHandler gzipHandler =
349+
new org.eclipse.jetty.server.handler.gzip.GzipHandler();
350+
gzipHandler.setMinGzipSize(1024);
351+
gzipHandler.setIncludedMimeTypes(
352+
"text/html", "text/plain", "text/css", "text/javascript",
353+
"application/javascript", "application/json", "application/xml");
354+
gzipHandler.setHandler(shareCtx);
355+
356+
// Optional signed-URL guard (path + "|" + exp => HMAC-SHA256, base64url)
357+
if (StringUtils.isNotBlank(shareSecret)) {
358+
shareCtx.addFilter(new FilterHolder(new ShareSignedUrlFilter(true, shareSecret)),
359+
"/*", EnumSet.of(DispatcherType.REQUEST));
360+
}
361+
362+
logger.info("Mounted /{} static context at baseDir={}", ServerPropertiesUtil.SHARE_DIR, base);
363+
return shareCtx;
364+
}
365+
291366
private Pair<SessionHandler,HandlerCollection> createHandlers() {
292367
final WebAppContext webApp = new WebAppContext();
293368
webApp.setContextPath(contextPath);
@@ -318,8 +393,23 @@ private Pair<SessionHandler,HandlerCollection> createHandlers() {
318393
rootRedirect.setNewContextURL(contextPath);
319394
rootRedirect.setPermanent(true);
320395

396+
// Optional /share handler (served by createShareContextHandler)
397+
Handler shareHandler = null;
398+
try {
399+
shareHandler = createShareContextHandler();
400+
} catch (IOException e) {
401+
logger.error("Failed to initialize /share context", e);
402+
}
403+
404+
List<Handler> handlers = new java.util.ArrayList<>();
405+
handlers.add(log);
406+
handlers.add(gzipHandler);
407+
if (shareHandler != null) {
408+
handlers.add(shareHandler);
409+
}
321410
// Put rootRedirect at the end!
322-
return new Pair<>(webApp.getSessionHandler(), new HandlerCollection(log, gzipHandler, rootRedirect));
411+
handlers.add(rootRedirect);
412+
return new Pair<>(webApp.getSessionHandler(), new HandlerCollection(handlers.toArray(new Handler[0])));
323413
}
324414

325415
private RequestLog createRequestLog() {
@@ -408,4 +498,24 @@ public void setMinThreads(int minThreads) {
408498
public void setMaxThreads(int maxThreads) {
409499
this.maxThreads = maxThreads;
410500
}
501+
502+
public void setShareEnabled(boolean shareEnabled) {
503+
this.shareEnabled = shareEnabled;
504+
}
505+
506+
public void setShareBaseDir(String shareBaseDir) {
507+
this.shareBaseDir = shareBaseDir;
508+
}
509+
510+
public void setShareCacheCtl(String shareCacheCtl) {
511+
this.shareCacheCtl = shareCacheCtl;
512+
}
513+
514+
public void setShareDirList(boolean shareDirList) {
515+
this.shareDirList = shareDirList;
516+
}
517+
518+
public void setShareSecret(String shareSecret) {
519+
this.shareSecret = shareSecret;
520+
}
411521
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.cloudstack;
19+
20+
import java.io.IOException;
21+
import java.security.InvalidKeyException;
22+
import java.security.NoSuchAlgorithmException;
23+
import java.time.Instant;
24+
25+
import javax.servlet.Filter;
26+
import javax.servlet.FilterChain;
27+
import javax.servlet.ServletException;
28+
import javax.servlet.ServletRequest;
29+
import javax.servlet.ServletResponse;
30+
import javax.servlet.http.HttpServletRequest;
31+
import javax.servlet.http.HttpServletResponse;
32+
33+
import org.apache.cloudstack.utils.security.HMACSignUtil;
34+
import org.apache.commons.codec.DecoderException;
35+
36+
/**
37+
* Optional HMAC token check: /share/...?...&exp=1699999999&sig=BASE64URL(HMACSHA256(path|exp))
38+
* If no token params are present, request is allowed (set requireToken=true to enforce).
39+
*/
40+
public class ShareSignedUrlFilter implements Filter {
41+
private final boolean requireToken;
42+
private final String secret;
43+
44+
public ShareSignedUrlFilter(boolean requireToken, String secret) {
45+
this.requireToken = requireToken;
46+
this.secret = secret;
47+
}
48+
49+
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
50+
throws IOException, ServletException {
51+
HttpServletRequest r = (HttpServletRequest) req;
52+
HttpServletResponse w = (HttpServletResponse) res;
53+
54+
String expStr = r.getParameter("exp");
55+
String sig = r.getParameter("sig");
56+
57+
if (!requireToken && (expStr == null || sig == null)) {
58+
chain.doFilter(req, res);
59+
return;
60+
}
61+
if (expStr == null || sig == null) {
62+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Missing token");
63+
return;
64+
}
65+
long exp;
66+
try {
67+
exp = Long.parseLong(expStr);
68+
} catch (NumberFormatException e) {
69+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Bad exp");
70+
return;
71+
}
72+
if (Instant.now().getEpochSecond() > exp) {
73+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Token expired");
74+
return;
75+
}
76+
String want = "";
77+
try {
78+
String data = r.getRequestURI() + "|" + expStr;
79+
want = HMACSignUtil.generateSignature(data, secret);
80+
} catch (InvalidKeyException | NoSuchAlgorithmException | DecoderException e) {
81+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Auth error");
82+
return;
83+
}
84+
if (!want.equals(sig)) {
85+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Bad signature");
86+
return;
87+
}
88+
chain.doFilter(req, res);
89+
}
90+
}
91+

engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,6 @@
3535

3636
public interface ExternalProvisioner extends Manager {
3737

38-
String getExtensionsPath();
39-
40-
String getExtensionPath(String relativePath);
41-
42-
String getChecksumForExtensionPath(String extensionName, String relativePath);
43-
44-
void prepareExtensionPath(String extensionName, boolean userDefined, String extensionRelativePath);
45-
46-
void cleanupExtensionPath(String extensionName, String extensionRelativePath);
47-
48-
void cleanupExtensionData(String extensionName, int olderThanDays, boolean cleanupDirectory);
49-
5038
PrepareExternalProvisioningAnswer prepareExternalProvisioning(String hostGuid, String extensionName, String extensionRelativePath, PrepareExternalProvisioningCommand cmd);
5139

5240
StartAnswer startInstance(String hostGuid, String extensionName, String extensionRelativePath, StartCommand cmd);

framework/cluster/src/main/java/com/cloud/cluster/ClusterManagerImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ public String execute(final String strPeer, final long agentId, final String cmd
468468
return null;
469469
}
470470

471+
472+
471473
@Override
472474
public ManagementServerHostVO getPeer(final String mgmtServerId) {
473475
return _mshostDao.findByMsid(Long.parseLong(mgmtServerId));

framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDao.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ public interface ManagementServerHostDao extends GenericDao<ManagementServerHost
6161
ManagementServerHostVO findOneInUpState(Filter filter);
6262

6363
ManagementServerHostVO findOneByLongestRuntime();
64+
65+
List<ManagementServerHostVO> listUpByIds(List<Long> ids);
6466
}

framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDaoImpl.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@
2424
import java.util.List;
2525
import java.util.TimeZone;
2626

27-
27+
import org.apache.cloudstack.management.ManagementServerHost;
28+
import org.apache.cloudstack.management.ManagementServerHost.State;
2829
import org.apache.commons.collections.CollectionUtils;
2930

3031
import com.cloud.cluster.ClusterInvalidSessionException;
31-
import org.apache.cloudstack.management.ManagementServerHost;
32-
import org.apache.cloudstack.management.ManagementServerHost.State;
3332
import com.cloud.cluster.ManagementServerHostVO;
3433
import com.cloud.utils.DateUtil;
3534
import com.cloud.utils.db.DB;
@@ -318,4 +317,18 @@ public ManagementServerHostVO findOneByLongestRuntime() {
318317
return CollectionUtils.isNotEmpty(msHosts) ? msHosts.get(0) : null;
319318
}
320319

320+
@Override
321+
public List<ManagementServerHostVO> listUpByIds(List<Long> ids) {
322+
if (CollectionUtils.isEmpty(ids)) {
323+
return new ArrayList<>();
324+
}
325+
SearchBuilder<ManagementServerHostVO> sb = createSearchBuilder();
326+
sb.and("ids", sb.entity().getId(), SearchCriteria.Op.IN);
327+
sb.and("state", sb.entity().getState(), SearchCriteria.Op.EQ);
328+
sb.done();
329+
SearchCriteria<ManagementServerHostVO> sc = sb.create();
330+
sc.setParameters("ids", ids.toArray());
331+
sc.setParameters("state", ManagementServerHost.State.Up);
332+
return listBy(sc);
333+
}
321334
}

0 commit comments

Comments
 (0)