ref) {
- DefaultSecurityManager defaultInstance = (DefaultSecurityManager) securityManager;
- defaultInstance.getRealms().remove(realmMap.get(ref));
+ GossRealm removed = realmMap.remove(ref);
+ if (removed != null && securityManager != null) {
+ DefaultSecurityManager defaultInstance = (DefaultSecurityManager) securityManager;
+ if (defaultInstance.getRealms() != null) {
+ defaultInstance.getRealms().remove(removed);
+ }
+ }
}
@Override
diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/ldap/GossLDAPRealm.java b/pnnl.goss.core/src/pnnl/goss/core/security/ldap/GossLDAPRealm.java
index d1b24ff8..729ebac6 100644
--- a/pnnl.goss.core/src/pnnl/goss/core/security/ldap/GossLDAPRealm.java
+++ b/pnnl.goss.core/src/pnnl/goss/core/security/ldap/GossLDAPRealm.java
@@ -1,141 +1,290 @@
package pnnl.goss.core.security.ldap;
-import java.util.Map;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
import java.util.HashSet;
+import java.util.Map;
import java.util.Set;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.component.annotations.Modified;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.authz.permission.PermissionResolver;
+import org.apache.shiro.realm.ldap.DefaultLdapRealm;
import org.apache.shiro.realm.ldap.JndiLdapContextFactory;
-import org.apache.shiro.realm.ldap.JndiLdapRealm;
import org.apache.shiro.subject.PrincipalCollection;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.northconcepts.exception.SystemException;
import pnnl.goss.core.security.GossPermissionResolver;
import pnnl.goss.core.security.GossRealm;
-import com.northconcepts.exception.SystemException;
+/**
+ * LDAP-based authentication realm for GOSS.
+ *
+ * This component only activates when a configuration file exists
+ * (pnnl.goss.core.security.ldap.cfg) with enabled=true and the LDAP server is
+ * reachable.
+ *
+ * Example configuration:
+ *
+ *
+ * enabled=true
+ * ldap.url=ldap://localhost:10389
+ * ldap.userDnTemplate=uid={0},ou=users,ou=goss,ou=system
+ * ldap.systemUsername=uid=admin,ou=system
+ * ldap.systemPassword=secret
+ * ldap.connectionTimeout=5000
+ *
+ */
+@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.ldap", configurationPolicy = ConfigurationPolicy.REQUIRE)
+public class GossLDAPRealm extends DefaultLdapRealm implements GossRealm {
+
+ private static final Logger log = LoggerFactory.getLogger(GossLDAPRealm.class);
-@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.ldap")
-public class GossLDAPRealm extends JndiLdapRealm implements GossRealm {
- private static final String CONFIG_PID = "pnnl.goss.core.security.ldap";
+ private static final String PROP_ENABLED = "enabled";
+ private static final String PROP_LDAP_URL = "ldap.url";
+ private static final String PROP_USER_DN_TEMPLATE = "ldap.userDnTemplate";
+ private static final String PROP_SYSTEM_USERNAME = "ldap.systemUsername";
+ private static final String PROP_SYSTEM_PASSWORD = "ldap.systemPassword";
+ private static final String PROP_CONNECTION_TIMEOUT = "ldap.connectionTimeout";
+
+ private static final String DEFAULT_USER_DN_TEMPLATE = "uid={0},ou=users,ou=goss,ou=system";
+ private static final int DEFAULT_CONNECTION_TIMEOUT = 5000;
@Reference
- GossPermissionResolver gossPermissionResolver;
+ private GossPermissionResolver gossPermissionResolver;
+
+ private boolean enabled = false;
+ private String ldapUrl = null;
public GossLDAPRealm() {
- // TODO move these to config
- setUserDnTemplate("uid={0},ou=users,ou=goss,ou=system");
- JndiLdapContextFactory fac = new JndiLdapContextFactory();
- fac.setUrl("ldap://localhost:10389");
- // fac.setSystemUsername("uid=admin,ou=system");
- // fac.setSystemPassword("SYSTEMPW");
- setContextFactory(fac);
+ // Don't configure in constructor - wait for configuration
}
- @Override
- public Set getPermissions(String identifier) {
- // TODO Auto-generated method stub
- System.out.println("LDAP GET PERMISSIONS " + identifier);
- // TODO get roles for identifier
+ @Activate
+ public void activate(Map properties) {
+ log.info("Activating GossLDAPRealm");
+ configure(properties);
+ }
+
+ @Deactivate
+ public void deactivate() {
+ log.info("Deactivating GossLDAPRealm");
+ enabled = false;
+ }
+
+ private void configure(Map properties) {
+ if (properties == null) {
+ log.warn("No configuration provided for LDAP realm");
+ enabled = false;
+ return;
+ }
+
+ // Check if enabled
+ String enabledStr = getStringProperty(properties, PROP_ENABLED, "false");
+ if (!"true".equalsIgnoreCase(enabledStr)) {
+ log.info("LDAP realm is disabled by configuration");
+ enabled = false;
+ return;
+ }
+
+ // Get LDAP URL
+ ldapUrl = getStringProperty(properties, PROP_LDAP_URL, null);
+ if (ldapUrl == null || ldapUrl.isEmpty()) {
+ log.warn("LDAP URL not configured - LDAP realm will not be active");
+ enabled = false;
+ return;
+ }
+
+ // Get connection timeout
+ int connectionTimeout = getIntProperty(properties, PROP_CONNECTION_TIMEOUT,
+ DEFAULT_CONNECTION_TIMEOUT);
+
+ // Test connectivity before enabling
+ if (!isLdapServerReachable(ldapUrl, connectionTimeout)) {
+ log.warn("LDAP server at {} is not reachable - LDAP realm will not be active", ldapUrl);
+ enabled = false;
+ return;
+ }
+
+ // Configure the realm
+ String userDnTemplate = getStringProperty(properties, PROP_USER_DN_TEMPLATE,
+ DEFAULT_USER_DN_TEMPLATE);
+ String systemUsername = getStringProperty(properties, PROP_SYSTEM_USERNAME, null);
+ String systemPassword = getStringProperty(properties, PROP_SYSTEM_PASSWORD, null);
+
+ try {
+ setUserDnTemplate(userDnTemplate);
+
+ JndiLdapContextFactory contextFactory = new JndiLdapContextFactory();
+ contextFactory.setUrl(ldapUrl);
+
+ if (systemUsername != null && !systemUsername.isEmpty()) {
+ contextFactory.setSystemUsername(systemUsername);
+ }
+ if (systemPassword != null && !systemPassword.isEmpty()) {
+ contextFactory.setSystemPassword(systemPassword);
+ }
- // look up permissions based on roles
+ setContextFactory(contextFactory);
+ enabled = true;
+ log.info("LDAP realm configured: url={}, userDnTemplate={}", ldapUrl, userDnTemplate);
- return new HashSet();
+ } catch (Exception e) {
+ log.error("Failed to configure LDAP realm", e);
+ enabled = false;
+ }
+ }
+
+ private boolean isLdapServerReachable(String url, int timeoutMs) {
+ try {
+ URI uri = new URI(url);
+ String host = uri.getHost();
+ int port = uri.getPort();
+
+ if (port == -1) {
+ port = "ldaps".equalsIgnoreCase(uri.getScheme()) ? 636 : 389;
+ }
+
+ log.debug("Testing LDAP connectivity to {}:{}", host, port);
+
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(host, port), timeoutMs);
+ log.debug("LDAP server {}:{} is reachable", host, port);
+ return true;
+ }
+ } catch (Exception e) {
+ log.debug("LDAP server {} is not reachable: {}", url, e.getMessage());
+ return false;
+ }
+ }
+
+ private String getStringProperty(Map props, String key, String defaultVal) {
+ Object value = props.get(key);
+ if (value instanceof String && !((String) value).isEmpty()) {
+ return (String) value;
+ }
+ return defaultVal;
+ }
+
+ private int getIntProperty(Map props, String key, int defaultVal) {
+ Object value = props.get(key);
+ if (value instanceof Integer) {
+ return (Integer) value;
+ }
+ if (value instanceof String) {
+ try {
+ return Integer.parseInt((String) value);
+ } catch (NumberFormatException e) {
+ // Fall through
+ }
+ }
+ return defaultVal;
+ }
+
+ @Override
+ public Set getPermissions(String identifier) {
+ if (!enabled) {
+ return new HashSet<>();
+ }
+ log.debug("LDAP getPermissions for: {}", identifier);
+ // TODO: Implement LDAP-based permission lookup
+ return new HashSet<>();
}
@Override
public boolean hasIdentifier(String identifier) {
- // TODO Auto-generated method stub
- System.out.println("HAS IDENTIFIER " + identifier);
+ if (!enabled) {
+ return false;
+ }
+ log.debug("LDAP hasIdentifier: {}", identifier);
return false;
}
@Override
- protected AuthorizationInfo doGetAuthorizationInfo(
- PrincipalCollection principals) {
- // TODO Auto-generated method stub
- System.out.println("DO GET AUTH INFO");
- for (Object p : principals.asList()) {
- System.out.println(" principal: " + p + " " + p.getClass());
+ protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
+ if (!enabled) {
+ return null;
}
+
+ log.debug("LDAP doGetAuthorizationInfo for principals: {}", principals);
AuthorizationInfo info = super.doGetAuthorizationInfo(principals);
- System.out.println("info " + info);
if (info == null) {
- // try {
- info = new SimpleAuthorizationInfo();
- // at the very least make sure they have the user role and can use the request
- // and advisory topic
- ((SimpleAuthorizationInfo) info).addRole("user");
-
- ((SimpleAuthorizationInfo) info).addStringPermission("queue:*");
- ((SimpleAuthorizationInfo) info).addStringPermission("temp-queue:*");
- ((SimpleAuthorizationInfo) info).addStringPermission("topic:*"); //
-
- // LdapContext ctx = getContextFactory().getSystemLdapContext();
- // TODO lookup roles for user
-
- // } catch (NamingException e) {
- // // TODO Auto-generated catch block
- // e.printStackTrace();
- // }
-
+ // Provide default permissions for authenticated LDAP users
+ SimpleAuthorizationInfo defaultInfo = new SimpleAuthorizationInfo();
+ defaultInfo.addRole("user");
+ defaultInfo.addStringPermission("queue:*");
+ defaultInfo.addStringPermission("temp-queue:*");
+ defaultInfo.addStringPermission("topic:*");
+ info = defaultInfo;
}
return info;
}
@Override
- public void setUserDnTemplate(String arg0) throws IllegalArgumentException {
- // TODO Auto-generated method stub
- super.setUserDnTemplate(arg0);
- }
-
- @Override
- protected AuthenticationInfo doGetAuthenticationInfo(
- AuthenticationToken token) throws AuthenticationException {
+ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
+ throws AuthenticationException {
+ if (!enabled) {
+ log.debug("LDAP realm not enabled, skipping authentication");
+ return null;
+ }
- // TODO Auto-generated method stub
- System.out.println("GET AUTH TOKEN " + token);
- AuthenticationInfo info = super.doGetAuthenticationInfo(token);
- System.out.println("GOT INFO " + info);
- return info;
+ log.debug("LDAP authentication attempt for: {}", token.getPrincipal());
+ try {
+ AuthenticationInfo info = super.doGetAuthenticationInfo(token);
+ if (info != null) {
+ log.info("LDAP authentication successful for: {}", token.getPrincipal());
+ }
+ return info;
+ } catch (AuthenticationException e) {
+ log.debug("LDAP authentication failed for {}: {}", token.getPrincipal(), e.getMessage());
+ throw e;
+ }
}
@Override
public boolean supports(AuthenticationToken token) {
- System.out.println("SUPPORTS " + token);
- // TODO Auto-generated method stub
+ if (!enabled) {
+ return false;
+ }
boolean supports = super.supports(token);
- System.out.println("SUPPORTS " + supports);
+ log.debug("LDAP supports token {}: {}", token.getClass().getSimpleName(), supports);
return supports;
}
@Modified
public synchronized void updated(Map properties) throws SystemException {
-
- if (properties != null) {
- // TODO
- // shouldStartBroker = Boolean.parseBoolean(Optional
- // .ofNullable((String) properties.get(PROP_START_BROKER))
- // .orElse("true"));
-
- }
+ log.info("Updating GossLDAPRealm configuration");
+ configure(properties);
}
@Override
public PermissionResolver getPermissionResolver() {
- if (gossPermissionResolver != null)
+ if (gossPermissionResolver != null) {
return gossPermissionResolver;
- else
- return super.getPermissionResolver();
+ }
+ return super.getPermissionResolver();
+ }
+
+ public boolean isEnabled() {
+ return enabled;
}
+ public String getLdapUrl() {
+ return ldapUrl;
+ }
}
diff --git a/pnnl.goss.core/src/pnnl/goss/core/security/propertyfile/PropertyBasedRealm.java b/pnnl.goss.core/src/pnnl/goss/core/security/propertyfile/PropertyBasedRealm.java
index fa387a21..c353bae8 100644
--- a/pnnl.goss.core/src/pnnl/goss/core/security/propertyfile/PropertyBasedRealm.java
+++ b/pnnl.goss.core/src/pnnl/goss/core/security/propertyfile/PropertyBasedRealm.java
@@ -5,9 +5,6 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.component.annotations.Modified;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
@@ -17,14 +14,19 @@
import org.apache.shiro.authz.permission.PermissionResolver;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.northconcepts.exception.SystemException;
+
import pnnl.goss.core.security.GossPermissionResolver;
import pnnl.goss.core.security.GossRealm;
-import com.northconcepts.exception.SystemException;
-
/**
* This class handles property based authentication/authorization. It will only
* be started as a component if a pnnl.goss.core.security.properties.cfg file
@@ -41,10 +43,9 @@
* @author Craig Allwardt
*
*/
-@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.propertyfile")
+@Component(service = GossRealm.class, configurationPid = "pnnl.goss.core.security.propertyfile", configurationPolicy = ConfigurationPolicy.REQUIRE)
public class PropertyBasedRealm extends AuthorizingRealm implements GossRealm {
- private static final String CONFIG_PID = "pnnl.goss.core.security.propertyfile";
private static final Logger log = LoggerFactory.getLogger(PropertyBasedRealm.class);
private final Map userMap = new ConcurrentHashMap<>();
@@ -53,6 +54,12 @@ public class PropertyBasedRealm extends AuthorizingRealm implements GossRealm {
@Reference
GossPermissionResolver gossPermissionResolver;
+ @Activate
+ public void activate(Map properties) {
+ log.info("Activating PropertyBasedRealm");
+ updated(properties);
+ }
+
@Override
protected AuthorizationInfo doGetAuthorizationInfo(
PrincipalCollection principals) {
@@ -78,24 +85,39 @@ protected AuthenticationInfo doGetAuthenticationInfo(
public synchronized void updated(Map properties) throws SystemException {
if (properties != null) {
- log.debug("Updating PropertyBasedRealm");
+ log.debug("Updating PropertyBasedRealm with {} properties", properties.size());
userMap.clear();
userPermissions.clear();
- Set perms = new HashSet<>();
for (String k : properties.keySet()) {
- String v = (String) properties.get(k);
+ // Skip OSGi/ConfigAdmin metadata properties
+ if (k.startsWith("service.") || k.startsWith("component.") ||
+ k.startsWith("felix.") || k.equals("osgi.ds.satisfying.condition.target")) {
+ continue;
+ }
+
+ Object value = properties.get(k);
+ // Only process String values (skip Long, Boolean, etc.)
+ if (!(value instanceof String)) {
+ log.debug("Skipping non-string property: {} = {} ({})", k, value,
+ value != null ? value.getClass().getName() : "null");
+ continue;
+ }
+
+ String v = (String) value;
String[] credAndPermissions = v.split(",");
SimpleAccount acnt = new SimpleAccount(k, credAndPermissions[0], getName());
+ Set perms = new HashSet<>();
for (int i = 1; i < credAndPermissions.length; i++) {
acnt.addStringPermission(credAndPermissions[i]);
perms.add(credAndPermissions[i]);
}
userMap.put(k, acnt);
userPermissions.put(k, perms);
-
+ log.debug("Loaded user: {} with {} permissions", k, perms.size());
}
+ log.info("PropertyBasedRealm configured with {} users", userMap.size());
}
}
diff --git a/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java b/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java
index 235ce7f0..b67d38db 100644
--- a/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java
+++ b/pnnl.goss.core/src/pnnl/goss/core/server/impl/GridOpticsServer.java
@@ -53,19 +53,12 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
-import java.util.Map;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
-import jakarta.jms.Connection;
-import jakarta.jms.ConnectionFactory;
-import jakarta.jms.DeliveryMode;
-import jakarta.jms.Destination;
-import jakarta.jms.JMSException;
-import jakarta.jms.MessageProducer;
-import jakarta.jms.Session;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.TrustManager;
@@ -78,24 +71,31 @@
import org.apache.activemq.broker.SslBrokerService;
import org.apache.activemq.shiro.ShiroPlugin;
import org.apache.commons.io.FilenameUtils;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.component.annotations.Modified;
+import org.apache.shiro.mgt.SecurityManager;
import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
-import org.apache.shiro.mgt.SecurityManager;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.northconcepts.exception.ConnectionCode;
import com.northconcepts.exception.SystemException;
+import jakarta.jms.Connection;
+import jakarta.jms.ConnectionFactory;
+import jakarta.jms.DeliveryMode;
+import jakarta.jms.Destination;
+import jakarta.jms.JMSException;
+import jakarta.jms.MessageProducer;
+import jakarta.jms.Session;
import pnnl.goss.core.GossCoreContants;
import pnnl.goss.core.security.GossRealm;
import pnnl.goss.core.server.RequestHandlerRegistry;
import pnnl.goss.core.server.ServerControl;
-@Component(service = ServerControl.class, configurationPid = "pnnl.goss.core.server")
+@Component(service = ServerControl.class, configurationPid = "pnnl.goss.core.server", configurationPolicy = org.osgi.service.component.annotations.ConfigurationPolicy.REQUIRE)
public class GridOpticsServer implements ServerControl {
private static final Logger log = LoggerFactory.getLogger(GridOpticsServer.class);
@@ -128,10 +128,6 @@ public class GridOpticsServer implements ServerControl {
private Session session;
private Destination destination;
- // System manager username/password (required * privleges on the message bus)
- private String systemManager = null;
- private String systemManagerPassword = null;
-
// Should we automatically start the broker?
private boolean shouldStartBroker = false;
// The connectionUri to create if shouldStartBroker is set to true.
@@ -150,13 +146,11 @@ public class GridOpticsServer implements ServerControl {
// SSL Parameters
private boolean sslEnabled = false;
private String sslClientKeyStore = null;
- private String sslClientKeyStorePassword = null;
private String sslClientTrustStore = null;
private String sslClientTrustStorePassword = null;
private String sslServerKeyStore = null;
private String sslServerKeyStorePassword = null;
- private String sslServerTrustStore = null;
private String sslServerTrustStorePassword = null;
private String gossClockTickTopic = null;
@@ -206,9 +200,9 @@ public synchronized void updated(Map properties) throws SystemEx
if (properties != null) {
- systemManager = getProperty((String) properties.get(PROP_SYSTEM_MANAGER),
+ getProperty((String) properties.get(PROP_SYSTEM_MANAGER),
"system");
- systemManagerPassword = getProperty((String) properties.get(PROP_SYSTEM_MANAGER_PASSWORD),
+ getProperty((String) properties.get(PROP_SYSTEM_MANAGER_PASSWORD),
"manager");
shouldStartBroker = Boolean.parseBoolean(
@@ -223,8 +217,9 @@ public synchronized void updated(Map properties) throws SystemEx
stompTransport = getProperty((String) properties.get(PROP_STOMP_TRANSPORT),
"stomp://localhost:61613");
- wsTransport = getProperty((String) properties.get(PROP_WS_TRANSPORT),
- "ws://localhost:61614");
+ // WebSocket transport is optional - set to null by default
+ // since it requires the jetty-websocket-server bundle
+ wsTransport = getProperty((String) properties.get(PROP_WS_TRANSPORT), null);
requestQueue = getProperty((String) properties.get(GossCoreContants.PROP_REQUEST_QUEUE), "Request");
@@ -238,13 +233,13 @@ public synchronized void updated(Map properties) throws SystemEx
sslTransport = getProperty((String) properties.get(PROP_SSL_TRANSPORT), "tcp://localhost:61443");
sslClientKeyStore = getProperty((String) properties.get(PROP_SSL_CLIENT_KEYSTORE), null);
- sslClientKeyStorePassword = getProperty((String) properties.get(PROP_SSL_CLIENT_KEYSTORE_PASSWORD), null);
+ getProperty((String) properties.get(PROP_SSL_CLIENT_KEYSTORE_PASSWORD), null);
sslClientTrustStore = getProperty((String) properties.get(PROP_SSL_CLIENT_TRUSTSTORE), null);
sslClientTrustStorePassword = getProperty((String) properties.get(PROP_SSL_CLIENT_TRUSTSTORE_PASSWORD),
null);
sslServerKeyStore = getProperty((String) properties.get(PROP_SSL_SERVER_KEYSTORE), null);
sslServerKeyStorePassword = getProperty((String) properties.get(PROP_SSL_SERVER_KEYSTORE_PASSWORD), null);
- sslServerTrustStore = getProperty((String) properties.get(PROP_SSL_SERVER_TRUSTSTORE), null);
+ getProperty((String) properties.get(PROP_SSL_SERVER_TRUSTSTORE), null);
sslServerTrustStorePassword = getProperty((String) properties.get(PROP_SSL_SERVER_TRUSTSTORE_PASSWORD),
null);
@@ -312,7 +307,12 @@ private void createBroker() throws Exception {
broker = new BrokerService();
broker.addConnector(openwireTransport);
broker.addConnector(stompTransport);
- broker.addConnector(wsTransport);
+ // Only add WebSocket connector if configured (requires jetty-websocket-server
+ // bundle)
+ if (wsTransport != null && !wsTransport.isEmpty()) {
+ log.debug("Adding WebSocket connector: " + wsTransport);
+ broker.addConnector(wsTransport);
+ }
}
broker.setPersistent(false);
broker.setUseJmx(false);
@@ -390,8 +390,17 @@ public void run() {
}
@Override
+ public void start() throws SystemException {
+ // This method satisfies the ServerControl interface
+ // The actual activation is handled by start(Map) with configuration
+ throw SystemException.wrap(new UnsupportedOperationException(
+ "Use start(Map) for DS activation with configuration"));
+ }
+
@Activate
- public void start() {
+ public void start(Map properties) {
+ // Apply configuration from ConfigAdmin before starting
+ updated(properties);
// If goss should have start the broker service then this will be set.
// this variable is mapped from goss.start.broker
diff --git a/pnnl.goss.core/test/pnnl/goss/core/client/test/DestinationTypeTest.java b/pnnl.goss.core/test/pnnl/goss/core/client/test/DestinationTypeTest.java
new file mode 100644
index 00000000..eb534269
--- /dev/null
+++ b/pnnl.goss.core/test/pnnl/goss/core/client/test/DestinationTypeTest.java
@@ -0,0 +1,104 @@
+package pnnl.goss.core.client.test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import pnnl.goss.core.Client.DESTINATION_TYPE;
+
+/**
+ * Tests for the DESTINATION_TYPE enum and queue/topic support in the GOSS
+ * client.
+ *
+ * The Java GOSS client now supports both QUEUE and TOPIC destination types,
+ * matching the Python client's behavior.
+ *
+ * Key differences between Queue and Topic: - QUEUE: Point-to-point messaging.
+ * Each message is consumed by exactly one consumer. This is the default for
+ * getResponse() to match Python client behavior. - TOPIC: Publish-subscribe
+ * messaging. Each message is delivered to all subscribers. This is the default
+ * for subscribe() as topics are typically used for broadcast events.
+ *
+ * Usage examples:
+ *
+ * // Subscribe to a queue (for request/response patterns)
+ * client.subscribe("goss.gridappsd.process.request", handler,
+ * DESTINATION_TYPE.QUEUE);
+ *
+ * // Subscribe to a topic (for broadcast events)
+ * client.subscribe("goss.gridappsd.simulation.output.123", handler,
+ * DESTINATION_TYPE.TOPIC);
+ *
+ * // Publish to a queue client.publish("goss.gridappsd.process.request",
+ * message, DESTINATION_TYPE.QUEUE);
+ *
+ * // Publish to a topic client.publish("goss.gridappsd.platform.log", message,
+ * DESTINATION_TYPE.TOPIC);
+ *
+ * // Send request and get response (defaults to QUEUE)
+ * client.getResponse(request, "goss.gridappsd.process.request",
+ * RESPONSE_FORMAT.JSON);
+ *
+ * // Send request with explicit destination type client.getResponse(request,
+ * "my.topic", RESPONSE_FORMAT.JSON, DESTINATION_TYPE.TOPIC);
+ */
+public class DestinationTypeTest {
+
+ @Test
+ @DisplayName("DESTINATION_TYPE enum should have QUEUE and TOPIC values")
+ public void destinationTypeHasQueueAndTopic() {
+ // Verify enum values exist
+ assertThat(DESTINATION_TYPE.values()).hasSize(2);
+ assertThat(DESTINATION_TYPE.valueOf("QUEUE")).isEqualTo(DESTINATION_TYPE.QUEUE);
+ assertThat(DESTINATION_TYPE.valueOf("TOPIC")).isEqualTo(DESTINATION_TYPE.TOPIC);
+ }
+
+ @Test
+ @DisplayName("QUEUE should be the preferred type for request/response patterns")
+ public void queueIsPreferredForRequestResponse() {
+ // Document that QUEUE is recommended for request/response
+ // This matches Python client behavior where get_response uses /queue/
+ // prefix
+ DESTINATION_TYPE requestResponseType = DESTINATION_TYPE.QUEUE;
+
+ assertThat(requestResponseType)
+ .as("Request/response patterns should use QUEUE for point-to-point delivery")
+ .isEqualTo(DESTINATION_TYPE.QUEUE);
+ }
+
+ @Test
+ @DisplayName("TOPIC should be used for broadcast/event patterns")
+ public void topicIsPreferredForBroadcast() {
+ // Document that TOPIC is recommended for events/broadcasts
+ DESTINATION_TYPE broadcastType = DESTINATION_TYPE.TOPIC;
+
+ assertThat(broadcastType)
+ .as("Broadcast patterns should use TOPIC for pub/sub delivery")
+ .isEqualTo(DESTINATION_TYPE.TOPIC);
+ }
+
+ @Test
+ @DisplayName("Enum ordinal values should be stable")
+ public void enumOrdinalsAreStable() {
+ // Verify ordinal values for serialization stability
+ assertThat(DESTINATION_TYPE.TOPIC.ordinal()).isEqualTo(0);
+ assertThat(DESTINATION_TYPE.QUEUE.ordinal()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("Enum should support standard operations")
+ public void enumSupportsStandardOperations() {
+ // Test enum operations
+ assertThat(DESTINATION_TYPE.QUEUE.name()).isEqualTo("QUEUE");
+ assertThat(DESTINATION_TYPE.TOPIC.name()).isEqualTo("TOPIC");
+
+ // Test comparison
+ assertThat(DESTINATION_TYPE.QUEUE).isNotEqualTo(DESTINATION_TYPE.TOPIC);
+
+ // Test valueOf round-trip
+ for (DESTINATION_TYPE type : DESTINATION_TYPE.values()) {
+ assertThat(DESTINATION_TYPE.valueOf(type.name())).isEqualTo(type);
+ }
+ }
+}
diff --git a/push-to-local-goss-repository.py b/push-to-local-goss-repository.py
new file mode 100755
index 00000000..fe950635
--- /dev/null
+++ b/push-to-local-goss-repository.py
@@ -0,0 +1,287 @@
+#!/usr/bin/env python3
+"""
+Push GOSS artifacts to GOSS-Repository
+Copies JARs from build output to the specified GOSS-Repository (release or snapshot)
+"""
+
+import argparse
+import hashlib
+import os
+import re
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+
+# ANSI Colors
+class Colors:
+ RED = '\033[0;31m'
+ GREEN = '\033[0;32m'
+ YELLOW = '\033[1;33m'
+ BLUE = '\033[0;34m'
+ CYAN = '\033[0;36m'
+ NC = '\033[0m' # No Color
+
+
+def log_info(msg: str) -> None:
+ print(f"{Colors.GREEN}[INFO]{Colors.NC} {msg}")
+
+
+def log_warn(msg: str) -> None:
+ print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
+
+
+def log_error(msg: str) -> None:
+ print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
+
+
+def extract_bundle_version(jar_path: Path) -> str | None:
+ """Extract Bundle-Version from JAR manifest."""
+ try:
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ manifest_data = zf.read('META-INF/MANIFEST.MF').decode('utf-8')
+ except (zipfile.BadZipFile, KeyError, IOError):
+ return None
+
+ # Parse manifest (handle line continuations)
+ lines = manifest_data.replace('\r\n ', '').replace('\r\n\t', '').split('\r\n')
+ if len(lines) == 1:
+ lines = manifest_data.replace('\n ', '').replace('\n\t', '').split('\n')
+
+ for line in lines:
+ if line.startswith('Bundle-Version:'):
+ return line.split(':', 1)[1].strip()
+ return None
+
+
+def is_snapshot_version(version: str) -> bool:
+ """Check if a version string indicates a snapshot."""
+ return 'SNAPSHOT' in version.upper()
+
+
+def find_built_jars(goss_dir: Path) -> list[Path]:
+ """Find all built JAR files in GOSS project."""
+ jars = []
+
+ # Look in generated directories for bundle JARs
+ for generated_dir in goss_dir.rglob('generated'):
+ for jar in generated_dir.glob('*.jar'):
+ if jar.is_file():
+ jars.append(jar)
+
+ return jars
+
+
+def get_bundle_name_from_jar(jar_path: Path) -> str | None:
+ """Extract Bundle-SymbolicName from JAR manifest."""
+ try:
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ manifest_data = zf.read('META-INF/MANIFEST.MF').decode('utf-8')
+ except (zipfile.BadZipFile, KeyError, IOError):
+ return None
+
+ # Parse manifest
+ lines = manifest_data.replace('\r\n ', '').replace('\r\n\t', '').split('\r\n')
+ if len(lines) == 1:
+ lines = manifest_data.replace('\n ', '').replace('\n\t', '').split('\n')
+
+ for line in lines:
+ if line.startswith('Bundle-SymbolicName:'):
+ bsn = line.split(':', 1)[1].strip()
+ # Remove directives
+ if ';' in bsn:
+ bsn = bsn.split(';')[0].strip()
+ return bsn
+ return None
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description='Push GOSS artifacts to GOSS-Repository (release or snapshot)',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog='''
+Examples:
+ %(prog)s --snapshot # Push snapshot versions to snapshot/
+ %(prog)s --release # Push release versions to release/
+ %(prog)s --snapshot --dry-run # Show what would be copied
+ %(prog)s --repo /path/to/GOSS-Repository --snapshot
+'''
+ )
+ parser.add_argument(
+ '--repo', '-r',
+ type=Path,
+ default=None,
+ help='Path to GOSS-Repository (default: ../GOSS-Repository)'
+ )
+ target_group = parser.add_mutually_exclusive_group(required=True)
+ target_group.add_argument(
+ '--snapshot', '-s',
+ action='store_true',
+ help='Push to snapshot/ directory'
+ )
+ target_group.add_argument(
+ '--release',
+ action='store_true',
+ help='Push to release/ directory'
+ )
+ parser.add_argument(
+ '--dry-run', '-n',
+ action='store_true',
+ help='Show what would be copied without actually copying'
+ )
+ parser.add_argument(
+ '--no-index',
+ action='store_true',
+ help='Skip generating repository index after copying'
+ )
+ parser.add_argument(
+ '--force', '-f',
+ action='store_true',
+ help='Overwrite existing JARs even if same size'
+ )
+
+ args = parser.parse_args()
+
+ script_dir = Path(__file__).parent.resolve()
+
+ # Determine repository path
+ if args.repo:
+ goss_repo_dir = args.repo.resolve()
+ else:
+ goss_repo_dir = script_dir.parent / 'GOSS-Repository'
+
+ # Determine target directory
+ if args.snapshot:
+ target_name = 'snapshot'
+ else:
+ target_name = 'release'
+
+ dest_repo_dir = goss_repo_dir / target_name
+
+ # Validate destination repository
+ if not goss_repo_dir.is_dir():
+ log_error(f"GOSS-Repository not found at: {goss_repo_dir}")
+ print()
+ print(f" The GOSS-Repository must be cloned locally as a sibling directory.")
+ print(f" Expected location: {goss_repo_dir}")
+ print()
+ print(f" To fix this, clone the repository:")
+ print(f" cd {script_dir.parent}")
+ print(f" git clone https://github.com/GridOPTICS/GOSS-Repository.git")
+ print()
+ print(f" Or specify a custom path with --repo:")
+ print(f" {sys.argv[0]} --repo /path/to/GOSS-Repository --{target_name}")
+ return 1
+
+ if not dest_repo_dir.is_dir():
+ log_error(f"Target directory not found: {dest_repo_dir}")
+ print()
+ print(f" The '{target_name}/' directory does not exist in GOSS-Repository.")
+ print(f" Please create it or check that you have the correct repository.")
+ return 1
+
+ log_info(f"GOSS Directory: {script_dir}")
+ log_info(f"Target: {dest_repo_dir}")
+
+ if args.dry_run:
+ log_info(f"{Colors.YELLOW}DRY RUN - no files will be copied{Colors.NC}")
+
+ # Find built JARs
+ built_jars = find_built_jars(script_dir)
+
+ if not built_jars:
+ log_warn("No built JARs found. Run './gradlew build' first.")
+ return 1
+
+ log_info(f"Found {len(built_jars)} built JAR(s)")
+
+ # Track statistics
+ copied_count = 0
+ skipped_count = 0
+ updated_count = 0
+ version_mismatch_count = 0
+
+ # Process each JAR
+ for jar_file in sorted(built_jars):
+ version = extract_bundle_version(jar_file)
+ bsn = get_bundle_name_from_jar(jar_file)
+
+ if not version or not bsn:
+ log_warn(f" Skipping (no OSGi metadata): {jar_file.name}")
+ continue
+
+ # Check if version matches target type
+ is_snapshot = is_snapshot_version(version)
+ if args.snapshot and not is_snapshot:
+ version_mismatch_count += 1
+ continue
+ if args.release and is_snapshot:
+ version_mismatch_count += 1
+ continue
+
+ # Determine destination path: //-.jar
+ dest_dir = dest_repo_dir / bsn
+ dest_filename = f"{bsn}-{version}.jar"
+ dest_path = dest_dir / dest_filename
+
+ # Check if already exists
+ if dest_path.exists():
+ source_size = jar_file.stat().st_size
+ dest_size = dest_path.stat().st_size
+
+ if source_size == dest_size and not args.force:
+ skipped_count += 1
+ continue
+ else:
+ if not args.dry_run:
+ shutil.copy2(str(jar_file), str(dest_path))
+ log_info(f" Updated: {bsn}/{dest_filename}")
+ updated_count += 1
+ else:
+ if not args.dry_run:
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(str(jar_file), str(dest_path))
+ log_info(f" Copied: {bsn}/{dest_filename}")
+ copied_count += 1
+
+ # Summary
+ print()
+ print(f"{Colors.GREEN}========================================{Colors.NC}")
+ print(f"{Colors.GREEN}Push to GOSS-Repository Complete!{Colors.NC}")
+ print(f" Target: {Colors.CYAN}{target_name}/{Colors.NC}")
+ print(f" New JARs copied: {Colors.GREEN}{copied_count}{Colors.NC}")
+ print(f" JARs updated: {Colors.BLUE}{updated_count}{Colors.NC}")
+ print(f" JARs skipped: {Colors.YELLOW}{skipped_count}{Colors.NC} (same size, use --force to overwrite)")
+ if version_mismatch_count > 0:
+ print(f" Version mismatch: {Colors.YELLOW}{version_mismatch_count}{Colors.NC} (wrong type for target)")
+ print(f"{Colors.GREEN}========================================{Colors.NC}")
+ print()
+
+ # Generate repository index
+ if not args.no_index and not args.dry_run and (copied_count > 0 or updated_count > 0):
+ log_info(f"Generating repository index for {target_name}/...")
+
+ sh_script = goss_repo_dir / 'generate-repository-index.sh'
+
+ if sh_script.exists():
+ result = subprocess.run(
+ ['bash', str(sh_script), target_name],
+ cwd=goss_repo_dir
+ )
+ if result.returncode != 0:
+ log_warn("generate-repository-index.sh failed")
+ else:
+ log_warn("generate-repository-index.sh not found, skipping index generation")
+
+ if args.dry_run:
+ log_info(f"{Colors.YELLOW}DRY RUN complete - no files were modified{Colors.NC}")
+ else:
+ log_info(f"{Colors.GREEN}✓ All done!{Colors.NC}")
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/check-api.py b/scripts/check-api.py
new file mode 100755
index 00000000..41fcc6af
--- /dev/null
+++ b/scripts/check-api.py
@@ -0,0 +1,449 @@
+#!/usr/bin/env python3
+"""
+GOSS API Change Detector
+
+Analyzes Java class files to detect API changes and suggest appropriate version bumps:
+- Major: Interface changes, removed public methods, breaking changes
+- Minor: New public methods on classes, new classes
+- Patch: Implementation-only changes
+
+Uses javap to extract public API signatures from JAR files.
+"""
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+# ANSI Colors
+class Colors:
+ RED = '\033[0;31m'
+ GREEN = '\033[0;32m'
+ YELLOW = '\033[1;33m'
+ BLUE = '\033[0;34m'
+ CYAN = '\033[0;36m'
+ MAGENTA = '\033[0;35m'
+ NC = '\033[0m' # No Color
+
+
+def log_info(msg: str) -> None:
+ print(f"{Colors.GREEN}[INFO]{Colors.NC} {msg}")
+
+
+def log_warn(msg: str) -> None:
+ print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
+
+
+def log_error(msg: str) -> None:
+ print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
+
+
+@dataclass
+class ClassInfo:
+ """Information about a Java class's public API."""
+ name: str
+ is_interface: bool = False
+ is_abstract: bool = False
+ is_enum: bool = False
+ superclass: Optional[str] = None
+ interfaces: list[str] = field(default_factory=list)
+ public_methods: list[str] = field(default_factory=list)
+ public_fields: list[str] = field(default_factory=list)
+
+ def signature_hash(self) -> str:
+ """Generate a hash of the public API signature."""
+ sig = f"{self.name}|{self.is_interface}|{self.superclass}|"
+ sig += "|".join(sorted(self.interfaces))
+ sig += "|".join(sorted(self.public_methods))
+ sig += "|".join(sorted(self.public_fields))
+ return hashlib.md5(sig.encode()).hexdigest()[:12]
+
+
+def extract_class_info(jar_path: Path, class_name: str) -> Optional[ClassInfo]:
+ """Extract public API information from a class using javap."""
+ try:
+ result = subprocess.run(
+ ['javap', '-public', '-classpath', str(jar_path), class_name],
+ capture_output=True,
+ text=True,
+ timeout=10
+ )
+ if result.returncode != 0:
+ return None
+
+ output = result.stdout
+ info = ClassInfo(name=class_name)
+
+ # Parse class declaration
+ class_match = re.search(
+ r'(public\s+)?(abstract\s+)?(interface|class|enum)\s+\S+',
+ output
+ )
+ if class_match:
+ info.is_interface = 'interface' in class_match.group(0)
+ info.is_abstract = 'abstract' in (class_match.group(0) or '')
+ info.is_enum = 'enum' in class_match.group(0)
+
+ # Parse extends
+ extends_match = re.search(r'extends\s+(\S+)', output)
+ if extends_match:
+ info.superclass = extends_match.group(1)
+
+ # Parse implements
+ implements_match = re.search(r'implements\s+([^{]+)', output)
+ if implements_match:
+ info.interfaces = [i.strip() for i in implements_match.group(1).split(',')]
+
+ # Parse public methods (simplified)
+ for line in output.split('\n'):
+ line = line.strip()
+ if line.startswith('public') and '(' in line and ')' in line:
+ # Extract method signature
+ method_sig = re.sub(r'\s+', ' ', line.rstrip(';'))
+ info.public_methods.append(method_sig)
+ elif line.startswith('public') and '(' not in line and ';' in line:
+ # Public field
+ info.public_fields.append(line.rstrip(';'))
+
+ return info
+ except Exception as e:
+ return None
+
+
+def list_classes_in_jar(jar_path: Path) -> list[str]:
+ """List all class files in a JAR."""
+ classes = []
+ try:
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ for name in zf.namelist():
+ if name.endswith('.class') and not name.startswith('META-INF/'):
+ # Convert path to class name
+ class_name = name[:-6].replace('/', '.')
+ # Skip inner classes for now
+ if '$' not in class_name:
+ classes.append(class_name)
+ except Exception:
+ pass
+ return sorted(classes)
+
+
+def analyze_jar(jar_path: Path) -> dict[str, ClassInfo]:
+ """Analyze all public APIs in a JAR file."""
+ apis = {}
+ classes = list_classes_in_jar(jar_path)
+
+ for class_name in classes:
+ info = extract_class_info(jar_path, class_name)
+ if info:
+ apis[class_name] = info
+
+ return apis
+
+
+@dataclass
+class ApiChange:
+ """Represents a single API change."""
+ change_type: str # 'major', 'minor', 'patch'
+ category: str # 'interface', 'class', 'method', 'field'
+ description: str
+ class_name: str
+
+
+def compare_apis(old_apis: dict[str, ClassInfo], new_apis: dict[str, ClassInfo]) -> list[ApiChange]:
+ """Compare two API snapshots and return list of changes."""
+ changes = []
+
+ old_classes = set(old_apis.keys())
+ new_classes = set(new_apis.keys())
+
+ # Removed classes = MAJOR (breaking change)
+ for removed in old_classes - new_classes:
+ old_info = old_apis[removed]
+ change_type = 'major' if old_info.is_interface else 'major'
+ changes.append(ApiChange(
+ change_type=change_type,
+ category='interface' if old_info.is_interface else 'class',
+ description=f"Removed: {removed}",
+ class_name=removed
+ ))
+
+ # Added classes = MINOR (backward compatible addition)
+ for added in new_classes - old_classes:
+ new_info = new_apis[added]
+ changes.append(ApiChange(
+ change_type='minor',
+ category='interface' if new_info.is_interface else 'class',
+ description=f"Added: {added}",
+ class_name=added
+ ))
+
+ # Changed classes
+ for class_name in old_classes & new_classes:
+ old_info = old_apis[class_name]
+ new_info = new_apis[class_name]
+
+ # Interface changes are always MAJOR
+ if old_info.is_interface or new_info.is_interface:
+ old_methods = set(old_info.public_methods)
+ new_methods = set(new_info.public_methods)
+
+ # Removed methods from interface = MAJOR
+ for removed in old_methods - new_methods:
+ changes.append(ApiChange(
+ change_type='major',
+ category='interface',
+ description=f"Interface method removed: {removed}",
+ class_name=class_name
+ ))
+
+ # Added methods to interface = MAJOR (breaks implementors)
+ for added in new_methods - old_methods:
+ changes.append(ApiChange(
+ change_type='major',
+ category='interface',
+ description=f"Interface method added: {added}",
+ class_name=class_name
+ ))
+ else:
+ # Class changes
+ old_methods = set(old_info.public_methods)
+ new_methods = set(new_info.public_methods)
+
+ # Removed public methods = MAJOR
+ for removed in old_methods - new_methods:
+ changes.append(ApiChange(
+ change_type='major',
+ category='method',
+ description=f"Public method removed: {removed}",
+ class_name=class_name
+ ))
+
+ # Added public methods = MINOR
+ for added in new_methods - old_methods:
+ changes.append(ApiChange(
+ change_type='minor',
+ category='method',
+ description=f"Public method added: {added}",
+ class_name=class_name
+ ))
+
+ # Check superclass changes = MAJOR
+ if old_info.superclass != new_info.superclass:
+ changes.append(ApiChange(
+ change_type='major',
+ category='class',
+ description=f"Superclass changed: {old_info.superclass} -> {new_info.superclass}",
+ class_name=class_name
+ ))
+
+ # Check interface changes = MAJOR (for classes)
+ old_interfaces = set(old_info.interfaces)
+ new_interfaces = set(new_info.interfaces)
+
+ for removed in old_interfaces - new_interfaces:
+ changes.append(ApiChange(
+ change_type='major',
+ category='class',
+ description=f"Interface removed: {removed}",
+ class_name=class_name
+ ))
+
+ return changes
+
+
+def find_baseline_jar(bundle_name: str, release_repo: Path) -> Optional[Path]:
+ """Find the baseline JAR for a bundle in the release repository."""
+ bundle_dir = release_repo / bundle_name
+ if not bundle_dir.is_dir():
+ return None
+
+ # Find the latest JAR
+ jars = list(bundle_dir.glob('*.jar'))
+ if not jars:
+ return None
+
+ # Sort by version (simple string sort works for semver)
+ jars.sort(key=lambda p: p.name, reverse=True)
+ return jars[0]
+
+
+def find_current_jar(bundle_name: str, goss_root: Path) -> Optional[Path]:
+ """Find the current built JAR for a bundle."""
+ for generated in goss_root.rglob('generated'):
+ for jar in generated.glob(f'{bundle_name}*.jar'):
+ if jar.is_file():
+ return jar
+ return None
+
+
+def get_bundle_name_from_jar(jar_path: Path) -> Optional[str]:
+ """Extract Bundle-SymbolicName from JAR manifest."""
+ try:
+ with zipfile.ZipFile(jar_path, 'r') as zf:
+ manifest = zf.read('META-INF/MANIFEST.MF').decode('utf-8')
+ for line in manifest.replace('\r\n ', '').replace('\n ', '').split('\n'):
+ if line.startswith('Bundle-SymbolicName:'):
+ bsn = line.split(':', 1)[1].strip()
+ if ';' in bsn:
+ bsn = bsn.split(';')[0].strip()
+ return bsn
+ except Exception:
+ pass
+ return None
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description='Analyze API changes and suggest version bump type',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog='''
+Version Bump Rules:
+ MAJOR (X.0.0): Interface changes, removed public methods, breaking changes
+ MINOR (x.Y.0): New public methods on classes, new classes (backward compatible)
+ PATCH (x.y.Z): Implementation-only changes, no public API changes
+
+Examples:
+ %(prog)s # Analyze all bundles
+ %(prog)s --bundle pnnl.goss.core.core-api # Analyze specific bundle
+ %(prog)s --verbose # Show detailed change information
+'''
+ )
+
+ parser.add_argument('--bundle', '-b', help='Specific bundle to analyze')
+ parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed changes')
+ parser.add_argument('--baseline', help='Path to baseline repository (default: cnf/releaserepo)')
+
+ args = parser.parse_args()
+
+ script_dir = Path(__file__).parent.resolve()
+ goss_root = script_dir.parent
+
+ # Determine baseline repository
+ if args.baseline:
+ baseline_repo = Path(args.baseline)
+ else:
+ baseline_repo = goss_root / 'cnf' / 'releaserepo'
+
+ if not baseline_repo.is_dir():
+ log_warn(f"Baseline repository not found: {baseline_repo}")
+ log_warn("No baseline to compare against. All changes will be considered MINOR.")
+ log_warn("Run './gradlew release' to populate the baseline repository.")
+ print()
+
+ # Find all current JARs
+ current_jars = []
+ for generated in goss_root.rglob('generated'):
+ for jar in generated.glob('pnnl.goss.*.jar'):
+ if jar.is_file() and 'runner' not in jar.name:
+ current_jars.append(jar)
+
+ if not current_jars:
+ log_error("No built JARs found. Run './gradlew build' first.")
+ return 1
+
+ # Filter to specific bundle if requested
+ if args.bundle:
+ current_jars = [j for j in current_jars if args.bundle in j.name]
+ if not current_jars:
+ log_error(f"Bundle not found: {args.bundle}")
+ return 1
+
+ print(f"\n{Colors.CYAN}API Change Analysis{Colors.NC}")
+ print("=" * 60)
+
+ overall_bump = 'patch'
+ all_changes: list[ApiChange] = []
+
+ for current_jar in sorted(current_jars):
+ bundle_name = get_bundle_name_from_jar(current_jar)
+ if not bundle_name:
+ continue
+
+ baseline_jar = find_baseline_jar(bundle_name, baseline_repo)
+
+ print(f"\n{Colors.BLUE}{bundle_name}{Colors.NC}")
+
+ if not baseline_jar:
+ print(f" {Colors.YELLOW}No baseline found{Colors.NC} - treating as new bundle (MINOR)")
+ if overall_bump == 'patch':
+ overall_bump = 'minor'
+ continue
+
+ # Analyze both JARs
+ old_apis = analyze_jar(baseline_jar)
+ new_apis = analyze_jar(current_jar)
+
+ if not old_apis and not new_apis:
+ print(f" {Colors.YELLOW}Could not analyze APIs{Colors.NC}")
+ continue
+
+ # Compare
+ changes = compare_apis(old_apis, new_apis)
+ all_changes.extend(changes)
+
+ if not changes:
+ # Check if implementation changed (hash comparison)
+ old_hashes = {k: v.signature_hash() for k, v in old_apis.items()}
+ new_hashes = {k: v.signature_hash() for k, v in new_apis.items()}
+
+ if old_hashes == new_hashes:
+ print(f" {Colors.GREEN}No API changes{Colors.NC}")
+ else:
+ print(f" {Colors.GREEN}Implementation changes only{Colors.NC} (PATCH)")
+ else:
+ # Categorize changes
+ major_changes = [c for c in changes if c.change_type == 'major']
+ minor_changes = [c for c in changes if c.change_type == 'minor']
+
+ if major_changes:
+ print(f" {Colors.RED}MAJOR changes detected:{Colors.NC}")
+ overall_bump = 'major'
+ if args.verbose:
+ for c in major_changes[:5]:
+ print(f" - {c.description}")
+ if len(major_changes) > 5:
+ print(f" ... and {len(major_changes) - 5} more")
+ else:
+ print(f" {len(major_changes)} breaking change(s)")
+
+ if minor_changes:
+ print(f" {Colors.YELLOW}MINOR changes detected:{Colors.NC}")
+ if overall_bump == 'patch':
+ overall_bump = 'minor'
+ if args.verbose:
+ for c in minor_changes[:5]:
+ print(f" - {c.description}")
+ if len(minor_changes) > 5:
+ print(f" ... and {len(minor_changes) - 5} more")
+ else:
+ print(f" {len(minor_changes)} addition(s)")
+
+ # Summary
+ print("\n" + "=" * 60)
+ print(f"{Colors.CYAN}Recommended Version Bump:{Colors.NC}")
+
+ if overall_bump == 'major':
+ print(f" {Colors.RED}MAJOR{Colors.NC} - Breaking API changes detected")
+ print(f" Run: {Colors.CYAN}make bump-major{Colors.NC}")
+ elif overall_bump == 'minor':
+ print(f" {Colors.YELLOW}MINOR{Colors.NC} - New API additions (backward compatible)")
+ print(f" Run: {Colors.CYAN}make bump-minor{Colors.NC}")
+ else:
+ print(f" {Colors.GREEN}PATCH{Colors.NC} - Implementation changes only")
+ print(f" Run: {Colors.CYAN}make bump-patch{Colors.NC} or {Colors.CYAN}make next-snapshot{Colors.NC}")
+
+ print()
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/version.py b/scripts/version.py
new file mode 100755
index 00000000..5076006f
--- /dev/null
+++ b/scripts/version.py
@@ -0,0 +1,318 @@
+#!/usr/bin/env python3
+"""
+GOSS Version Management Script
+
+Commands:
+ show - Display versions of all bundles
+ release - Set release version (removes -SNAPSHOT)
+ snapshot - Set snapshot version (adds -SNAPSHOT)
+ bump-patch - Bump patch version (x.y.Z) and set as snapshot
+ bump-minor - Bump minor version (x.Y.0) and set as snapshot
+ bump-major - Bump major version (X.0.0) and set as snapshot
+ next-snapshot - Bump patch version after a release
+"""
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+
+# ANSI Colors
+class Colors:
+ RED = '\033[0;31m'
+ GREEN = '\033[0;32m'
+ YELLOW = '\033[1;33m'
+ BLUE = '\033[0;34m'
+ CYAN = '\033[0;36m'
+ NC = '\033[0m' # No Color
+
+
+def log_info(msg: str) -> None:
+ print(f"{Colors.GREEN}[INFO]{Colors.NC} {msg}")
+
+
+def log_warn(msg: str) -> None:
+ print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
+
+
+def log_error(msg: str) -> None:
+ print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
+
+
+def find_bnd_files(root: Path) -> list[Path]:
+ """Find all .bnd files that contain Bundle-Version."""
+ bnd_files = []
+ for bnd_file in root.rglob('*.bnd'):
+ # Skip cnf/ext directory (these are config files, not bundles)
+ if 'cnf/ext' in str(bnd_file):
+ continue
+ # Skip cnf/build.bnd and cnf/bnd.bnd (workspace config)
+ if bnd_file.parent.name == 'cnf' and bnd_file.name in ('build.bnd', 'bnd.bnd'):
+ continue
+ # Check if file contains Bundle-Version
+ content = bnd_file.read_text()
+ if 'Bundle-Version:' in content:
+ bnd_files.append(bnd_file)
+ return sorted(bnd_files)
+
+
+def extract_bundle_info(bnd_file: Path) -> tuple[str, str] | None:
+ """Extract bundle name and version from a .bnd file."""
+ content = bnd_file.read_text()
+
+ # Extract Bundle-Version
+ version_match = re.search(r'Bundle-Version:\s*(.+)', content)
+ if not version_match:
+ return None
+
+ version = version_match.group(1).strip()
+
+ # Derive bundle name from file path
+ # e.g., pnnl.goss.core/core-api.bnd -> pnnl.goss.core.core-api
+ parent_dir = bnd_file.parent.name
+ bundle_name = bnd_file.stem
+
+ if bundle_name == 'bnd':
+ # Main bundle file (e.g., pnnl.goss.core/bnd.bnd)
+ full_name = parent_dir
+ else:
+ # Sub-bundle file (e.g., pnnl.goss.core/core-api.bnd)
+ full_name = f"{parent_dir}.{bundle_name}"
+
+ return (full_name, version)
+
+
+def show_versions(root: Path) -> None:
+ """Display versions of all bundles."""
+ bnd_files = find_bnd_files(root)
+
+ if not bnd_files:
+ log_warn("No bundle .bnd files found")
+ return
+
+ print(f"\n{Colors.CYAN}GOSS Bundle Versions{Colors.NC}")
+ print("=" * 60)
+
+ # Group by version
+ versions: dict[str, list[str]] = {}
+
+ for bnd_file in bnd_files:
+ info = extract_bundle_info(bnd_file)
+ if info:
+ name, version = info
+ if version not in versions:
+ versions[version] = []
+ versions[version].append(name)
+
+ # Display grouped by version
+ for version in sorted(versions.keys()):
+ is_snapshot = '-SNAPSHOT' in version
+ version_color = Colors.YELLOW if is_snapshot else Colors.GREEN
+ print(f"\n{version_color}{version}{Colors.NC}:")
+ for name in sorted(versions[version]):
+ print(f" - {name}")
+
+ print("\n" + "=" * 60)
+ print(f"Total bundles: {sum(len(v) for v in versions.values())}")
+
+ # Summary
+ snapshot_count = sum(len(v) for ver, v in versions.items() if '-SNAPSHOT' in ver)
+ release_count = sum(len(v) for ver, v in versions.items() if '-SNAPSHOT' not in ver)
+
+ if snapshot_count > 0:
+ print(f" {Colors.YELLOW}Snapshot:{Colors.NC} {snapshot_count}")
+ if release_count > 0:
+ print(f" {Colors.GREEN}Release:{Colors.NC} {release_count}")
+ print()
+
+
+def update_version(bnd_file: Path, new_version: str) -> bool:
+ """Update Bundle-Version in a .bnd file."""
+ content = bnd_file.read_text()
+
+ # Replace Bundle-Version line
+ new_content, count = re.subn(
+ r'(Bundle-Version:\s*).+',
+ f'\\g<1>{new_version}',
+ content
+ )
+
+ if count > 0:
+ bnd_file.write_text(new_content)
+ return True
+ return False
+
+
+def get_current_version(root: Path) -> str | None:
+ """Get the current version from .bnd files (returns base version without -SNAPSHOT)."""
+ bnd_files = find_bnd_files(root)
+
+ versions: set[str] = set()
+ for bnd_file in bnd_files:
+ info = extract_bundle_info(bnd_file)
+ if info:
+ _, version = info
+ # Strip -SNAPSHOT suffix for comparison
+ base_version = version.replace('-SNAPSHOT', '')
+ versions.add(base_version)
+
+ if len(versions) == 0:
+ return None
+ if len(versions) > 1:
+ log_warn(f"Multiple versions found: {sorted(versions)}")
+ # Return the highest version
+ return sorted(versions, key=lambda v: [int(x) for x in v.split('.')])[-1]
+
+ return versions.pop()
+
+
+def bump_version(version: str, bump_type: str) -> str:
+ """Bump a version string by the specified type (major, minor, patch)."""
+ parts = [int(x) for x in version.split('.')]
+
+ if bump_type == 'major':
+ parts[0] += 1
+ parts[1] = 0
+ parts[2] = 0
+ elif bump_type == 'minor':
+ parts[1] += 1
+ parts[2] = 0
+ elif bump_type == 'patch':
+ parts[2] += 1
+
+ return '.'.join(str(p) for p in parts)
+
+
+def set_version(root: Path, version: str, snapshot: bool = False) -> None:
+ """Set version for all bundles."""
+ # Validate version format
+ if not re.match(r'^\d+\.\d+\.\d+$', version):
+ log_error(f"Invalid version format: {version}")
+ log_error("Expected format: x.y.z (e.g., 11.0.0)")
+ sys.exit(1)
+
+ # Add or remove -SNAPSHOT suffix
+ if snapshot:
+ full_version = f"{version}-SNAPSHOT"
+ else:
+ full_version = version
+
+ bnd_files = find_bnd_files(root)
+
+ if not bnd_files:
+ log_warn("No bundle .bnd files found")
+ return
+
+ action = "snapshot" if snapshot else "release"
+ log_info(f"Setting {action} version: {full_version}")
+ print()
+
+ updated_count = 0
+ for bnd_file in bnd_files:
+ info = extract_bundle_info(bnd_file)
+ if info:
+ name, old_version = info
+ if update_version(bnd_file, full_version):
+ rel_path = bnd_file.relative_to(root)
+ print(f" {Colors.GREEN}✓{Colors.NC} {name}: {old_version} -> {full_version}")
+ updated_count += 1
+
+ print()
+ log_info(f"Updated {updated_count} bundle(s) to version {full_version}")
+
+ if not snapshot:
+ print()
+ log_info("Next steps for release:")
+ print(f" 1. Build: ./gradlew build")
+ print(f" 2. Test: ./gradlew check")
+ print(f" 3. Commit: git commit -am 'Release version {version}'")
+ print(f" 4. Tag: git tag -a v{version} -m 'Version {version}'")
+ print(f" 5. Push: git push && git push --tags")
+ print()
+
+
+def do_bump(root: Path, bump_type: str) -> int:
+ """Bump version and set as snapshot."""
+ current = get_current_version(root)
+ if not current:
+ log_error("Could not determine current version")
+ return 1
+
+ new_version = bump_version(current, bump_type)
+ log_info(f"Bumping {bump_type} version: {current} -> {new_version}-SNAPSHOT")
+ set_version(root, new_version, snapshot=True)
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description='GOSS Version Management',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog='''
+Examples:
+ %(prog)s show # Show all bundle versions
+ %(prog)s release 11.0.0 # Set release version 11.0.0
+ %(prog)s snapshot 11.1.0 # Set snapshot version 11.1.0-SNAPSHOT
+ %(prog)s bump-patch # 11.0.0 -> 11.0.1-SNAPSHOT
+ %(prog)s bump-minor # 11.0.0 -> 11.1.0-SNAPSHOT
+ %(prog)s bump-major # 11.0.0 -> 12.0.0-SNAPSHOT
+ %(prog)s next-snapshot # After release: bump patch to next snapshot
+
+Typical release workflow:
+ 1. %(prog)s show # Verify current version (e.g., 11.0.0-SNAPSHOT)
+ 2. %(prog)s release 11.0.0 # Remove -SNAPSHOT for release
+ 3. make build && make test # Build and test
+ 4. make push-release # Push to GOSS-Repository
+ 5. git tag v11.0.0 && git push # Tag and push
+ 6. %(prog)s next-snapshot # Bump to 11.0.1-SNAPSHOT for next development
+'''
+ )
+
+ subparsers = parser.add_subparsers(dest='command', help='Command to run')
+
+ # show command
+ subparsers.add_parser('show', help='Show versions of all bundles')
+
+ # release command
+ release_parser = subparsers.add_parser('release', help='Set release version (removes -SNAPSHOT)')
+ release_parser.add_argument('version', help='Version number (e.g., 11.0.0)')
+
+ # snapshot command
+ snapshot_parser = subparsers.add_parser('snapshot', help='Set snapshot version (adds -SNAPSHOT)')
+ snapshot_parser.add_argument('version', help='Version number (e.g., 11.1.0)')
+
+ # bump commands
+ subparsers.add_parser('bump-patch', help='Bump patch version (x.y.Z) and set as snapshot')
+ subparsers.add_parser('bump-minor', help='Bump minor version (x.Y.0) and set as snapshot')
+ subparsers.add_parser('bump-major', help='Bump major version (X.0.0) and set as snapshot')
+ subparsers.add_parser('next-snapshot', help='Bump patch version after a release (alias for bump-patch)')
+
+ args = parser.parse_args()
+
+ # Find root directory (where this script's parent's parent is)
+ script_dir = Path(__file__).parent.resolve()
+ root = script_dir.parent
+
+ if not args.command:
+ parser.print_help()
+ return 1
+
+ if args.command == 'show':
+ show_versions(root)
+ elif args.command == 'release':
+ set_version(root, args.version, snapshot=False)
+ elif args.command == 'snapshot':
+ set_version(root, args.version, snapshot=True)
+ elif args.command in ('bump-patch', 'next-snapshot'):
+ return do_bump(root, 'patch')
+ elif args.command == 'bump-minor':
+ return do_bump(root, 'minor')
+ elif args.command == 'bump-major':
+ return do_bump(root, 'major')
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())