Skip to content

Commit d98ff4f

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. Refactors all filesystem related code to extensions framework layer. Checksum for extensions is now calculated and compared for all files in the extension directory. Signed-off-by: Abhishek Kumar <[email protected]>
1 parent a6ef24d commit d98ff4f

File tree

35 files changed

+3928
-2029
lines changed

35 files changed

+3928
-2029
lines changed

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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
*/
39+
public class ShareSignedUrlFilter implements Filter {
40+
private final boolean requireToken;
41+
private final String secret;
42+
43+
public ShareSignedUrlFilter(boolean requireToken, String secret) {
44+
this.requireToken = requireToken;
45+
this.secret = secret;
46+
}
47+
48+
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
49+
throws IOException, ServletException {
50+
HttpServletRequest r = (HttpServletRequest) req;
51+
HttpServletResponse w = (HttpServletResponse) res;
52+
53+
String expStr = r.getParameter("exp");
54+
String sig = r.getParameter("sig");
55+
56+
if (!requireToken && (expStr == null || sig == null)) {
57+
chain.doFilter(req, res);
58+
return;
59+
}
60+
if (expStr == null || sig == null) {
61+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Missing token");
62+
return;
63+
}
64+
long exp;
65+
try {
66+
exp = Long.parseLong(expStr);
67+
} catch (NumberFormatException e) {
68+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Bad exp");
69+
return;
70+
}
71+
if (Instant.now().getEpochSecond() > exp) {
72+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Token expired");
73+
return;
74+
}
75+
String want = "";
76+
try {
77+
String data = r.getRequestURI() + "|" + expStr;
78+
want = HMACSignUtil.generateSignature(data, secret);
79+
} catch (InvalidKeyException | NoSuchAlgorithmException | DecoderException e) {
80+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Auth error");
81+
return;
82+
}
83+
if (!want.equals(sig)) {
84+
w.sendError(HttpServletResponse.SC_FORBIDDEN, "Bad signature");
85+
return;
86+
}
87+
chain.doFilter(req, res);
88+
}
89+
}

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)