Docker installer workflow and licensing flow done - docker container is working fine

This commit is contained in:
2026-07-26 23:38:05 +05:30
parent d684931bc5
commit 8ef8bf5d92
75 changed files with 866 additions and 156 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -21,9 +21,12 @@ INI. Both profiles use these cloud endpoints by default:
- `POST /api/v1/installations/register`
The desktop wizard is the default. It checks Docker, collects activation and
installation details, lets the user choose an output directory, and reports
installation progress. For servers without a desktop or for automation, append
`--cli` to retain the interactive terminal workflow:
installation details, asks for the Docker registry, image, username, and
password, lets the user choose an output directory, and reports installation
progress. It authenticates with `docker login --password-stdin`; the registry
password is never written into generated files or command arguments. For
servers without a desktop or for automation, append `--cli` to retain the
interactive terminal workflow:
```bash
TECHNOBEE_INSTALLER_CONFIG=cygnus-installer/config/matrix-installer.ini \
@@ -48,6 +51,7 @@ configuration_root=matrix
configuration_file_name=installation.yml
installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG
container_configuration_path=/opt/matrix/config/installation.yml
port_mapping=8080:8080
token_path=/oauth2/token
assertion_encryption_public_key=/secure/cloud-assertion-public.pem
optional_fields=
@@ -72,7 +76,8 @@ config/
The installer asks for the product Docker image, validates it, and stores it
under the profile's image variable in `.env`. Docker Compose loads this file
automatically, so no separate `export MATRIX_IMAGE` or `export CYGNUS_IMAGE`
step is required.
step is required. Docker stores successful login state using its configured
credential store; the installer does not persist the registry password.
The RSA-3072 pair is generated locally. Only the public key is sent during
registration. The private key and encrypted machine assertion remain on

View File

@@ -1,6 +1,7 @@
[installer]
product=cygnus
cloud_service_url=http://localhost:8090
container_cloud_service_url=http://host.docker.internal:8090
installer_api_base_path=/api/v1/installations
environment=production
output_directory=./cygnus-installation
@@ -13,6 +14,10 @@ configuration_root=cygnus
configuration_file_name=installation.yml
installation_config_environment_variable=CYGNUS_INSTALLATION_CONFIG
container_configuration_path=/opt/cygnus/config/installation.yml
port_mapping=8080:8080
service_path=/
token_path=/oauth2/token
assertion_encryption_public_key=../../config/keys/assertion-decryption-public.pem
login_encryption_public_key=../../config/keys/login-public.pem
login_key_id=cygnus-login-2026-01
optional_fields=

View File

@@ -1,6 +1,7 @@
[installer]
product=matrix
cloud_service_url=http://localhost:8090
container_cloud_service_url=http://host.docker.internal:8090
installer_api_base_path=/api/v1/installations
environment=production
output_directory=./matrix-installation
@@ -13,6 +14,10 @@ configuration_root=matrix
configuration_file_name=installation.yml
installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG
container_configuration_path=/opt/matrix/config/installation.yml
port_mapping=8080:8080
service_path=/matrix/
token_path=/oauth2/token
assertion_encryption_public_key=../../config/keys/assertion-decryption-public.pem
login_encryption_public_key=../../config/keys/login-public.pem
login_key_id=cygnus-login-2026-01
optional_fields=

View File

@@ -11,10 +11,25 @@ import org.springframework.stereotype.Component;
public class CommandExecutor {
public CommandResult execute(List<String> command, Duration timeout) {
return execute(command, timeout, null);
}
public CommandResult execute(
List<String> command,
Duration timeout,
String standardInput) {
try {
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
if (standardInput != null) {
try (var input = process.getOutputStream()) {
input.write(standardInput.getBytes(StandardCharsets.UTF_8));
input.flush();
}
} else {
process.getOutputStream().close();
}
boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!completed) {
process.destroyForcibly();

View File

@@ -25,7 +25,8 @@ public class DeploymentWriter {
InstallerSettings settings,
ProductProfile profile,
String dockerImage,
Map<String, String> optionalFields) {
Map<String, String> optionalFields,
RuntimeConfiguration runtime) {
try {
Path normalizedOutput = output.toAbsolutePath().normalize();
Path config = normalizedOutput.resolve("config");
@@ -41,6 +42,10 @@ public class DeploymentWriter {
writePublic(
normalizedOutput.resolve(publicRelative),
keys.publicKeyPem());
String loginPublicRelative = "config/keys/login-public.pem";
writePublic(
normalizedOutput.resolve(loginPublicRelative),
Files.readString(profile.loginEncryptionPublicKey()));
writePrivate(
normalizedOutput.resolve(assertionRelative),
machineAssertion);
@@ -57,13 +62,20 @@ public class DeploymentWriter {
privateRelative,
publicRelative,
assertionRelative,
optionalFields));
optionalFields,
runtime));
writePublic(
normalizedOutput.resolve("compose.yml"),
compose(profile));
writePrivate(
normalizedOutput.resolve(".env"),
profile.imageEnvironmentVariable() + "=" + dockerImage + "\n");
environment(
profile,
dockerImage,
clientId,
installationCode,
settings,
runtime));
return normalizedOutput;
} catch (IOException exception) {
throw new IllegalStateException("Could not write installation files", exception);
@@ -81,14 +93,16 @@ public class DeploymentWriter {
String privateKey,
String publicKey,
String assertion,
Map<String, String> optionalFields) {
Map<String, String> optionalFields,
RuntimeConfiguration runtime) {
String cloudServiceUrl = normalizedCloudServiceUrl(runtime);
StringBuilder yaml = new StringBuilder()
.append(profile.configurationRoot()).append(":\n")
.append(" product: ").append(quoted(profile.product())).append('\n')
.append(" cloud-url: ")
.append(quoted(settings.cloudServiceUrl().toString())).append('\n')
.append(quoted(cloudServiceUrl)).append('\n')
.append(" token-url: ")
.append(quoted(settings.cloudServiceUrl() + profile.tokenPath())).append('\n')
.append(quoted(cloudServiceUrl + profile.tokenPath())).append('\n')
.append(" tenant-id: ").append(quoted(value(validation.tenantId()))).append('\n')
.append(" client-id: ").append(quoted(clientId)).append('\n')
.append(" installation-id: ")
@@ -120,19 +134,94 @@ public class DeploymentWriter {
%s:
image: ${%s:?Set %s}
restart: unless-stopped
ports:
- "%s"
volumes:
- ./config:%s:ro
env_file:
- ./.env
environment:
%s: %s
""".formatted(
profile.serviceName(),
profile.imageEnvironmentVariable(),
profile.imageEnvironmentVariable(),
profile.portMapping(),
containerConfigDirectory,
profile.installationConfigEnvironmentVariable(),
profile.containerConfigurationPath());
}
private String environment(
ProductProfile profile,
String dockerImage,
String clientId,
String installationCode,
InstallerSettings settings,
RuntimeConfiguration runtime) {
Path containerConfiguration = Path.of(profile.containerConfigurationPath());
Path containerConfigDirectory = containerConfiguration.getParent();
if (containerConfigDirectory == null) {
throw new InstallerConfigurationException(
"Profile container_configuration_path must include a directory");
}
String configRoot = containerConfigDirectory.toString();
StringBuilder env = new StringBuilder();
appendEnvironment(env, profile.imageEnvironmentVariable(), dockerImage);
appendEnvironment(env, "MATRIX_DB_URL", runtime.databaseUrl());
appendEnvironment(env, "MATRIX_DB_USERNAME", runtime.databaseUsername());
appendEnvironment(env, "MATRIX_DB_PASSWORD", runtime.databasePassword());
appendEnvironment(env, "REDIS_HOST", runtime.redisHost());
appendEnvironment(env, "REDIS_PORT", runtime.redisPort());
appendEnvironment(env, "REDIS_PASSWORD", runtime.redisPassword());
appendEnvironment(env, "REDIS_SSL", Boolean.toString(runtime.redisSsl()));
appendEnvironment(
env, "CYGNUS_CLOUD_BASE_URL", normalizedCloudServiceUrl(runtime));
appendEnvironment(
env,
"CYGNUS_TOKEN_URL",
normalizedCloudServiceUrl(runtime) + profile.tokenPath());
appendEnvironment(env, "CYGNUS_CLIENT_ID", clientId);
appendEnvironment(env, "CYGNUS_INSTALLATION_ID", installationCode);
appendEnvironment(
env,
"CYGNUS_CLIENT_ASSERTION",
"file:" + configRoot + "/machine-assertion.jwt");
appendEnvironment(env, "CYGNUS_LOGIN_KEY_ID", profile.loginKeyId());
appendEnvironment(
env,
"CYGNUS_LOGIN_PUBLIC_KEY",
"file:" + configRoot + "/keys/login-public.pem");
appendEnvironment(
env,
"CYGNUS_CLOUD_REQUEST_TIMEOUT",
runtime.cloudRequestTimeout());
appendEnvironment(
env,
"CYGNUS_TOKEN_REFRESH_SKEW",
runtime.tokenRefreshSkew());
return env.toString();
}
private String normalizedCloudServiceUrl(RuntimeConfiguration runtime) {
String value = runtime.containerCloudServiceUrl().trim();
return value.endsWith("/")
? value.substring(0, value.length() - 1)
: value;
}
private void appendEnvironment(StringBuilder env, String name, String value) {
String safeValue = value == null ? "" : value;
env.append(name)
.append("=\"")
.append(safeValue
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r", "")
.replace("\n", ""))
.append("\"\n");
}
private String quoted(String value) {
if (value == null) {
return "\"\"";

View File

@@ -0,0 +1,33 @@
package com.cygnus.installer;
import java.time.Duration;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class DockerRegistryLoginService {
private static final Duration LOGIN_TIMEOUT = Duration.ofSeconds(45);
private final CommandExecutor executor;
public DockerRegistryLoginService(CommandExecutor executor) {
this.executor = executor;
}
public void login(String registry, String username, String password) {
CommandResult result = executor.execute(
List.of(
"docker",
"login",
registry,
"--username",
username,
"--password-stdin"),
LOGIN_TIMEOUT,
password + System.lineSeparator());
if (!result.successful()) {
throw new IllegalStateException(
"Docker registry login failed. " + result.output());
}
}
}

View File

@@ -4,13 +4,18 @@ import java.nio.file.Path;
import java.util.Map;
public record InstallationRequest(
String installerCloudServiceUrl,
String clientCode,
String licenseKey,
String installationCode,
String installationName,
String dockerImage,
String dockerRegistry,
String dockerUsername,
String dockerPassword,
Path outputDirectory,
Map<String, String> optionalFields) {
Map<String, String> optionalFields,
RuntimeConfiguration runtimeConfiguration) {
public InstallationRequest {
optionalFields = optionalFields == null ? Map.of() : Map.copyOf(optionalFields);

View File

@@ -8,26 +8,32 @@ import org.springframework.stereotype.Service;
public class InstallationService {
private final DockerPrerequisiteChecker prerequisites;
private final DockerRegistryLoginService registryLogin;
private final ActivationClient activationClient;
private final InstallationKeyService keyService;
private final MachineAssertionService assertionService;
private final DeploymentWriter deploymentWriter;
private final DockerComposeDeploymentService composeDeployment;
private final InstallerSettings settings;
private final ProductProfile profile;
public InstallationService(
DockerPrerequisiteChecker prerequisites,
DockerRegistryLoginService registryLogin,
ActivationClient activationClient,
InstallationKeyService keyService,
MachineAssertionService assertionService,
DeploymentWriter deploymentWriter,
DockerComposeDeploymentService composeDeployment,
InstallerSettings settings,
ProductProfile profile) {
this.prerequisites = prerequisites;
this.registryLogin = registryLogin;
this.activationClient = activationClient;
this.keyService = keyService;
this.assertionService = assertionService;
this.deploymentWriter = deploymentWriter;
this.composeDeployment = composeDeployment;
this.settings = settings;
this.profile = profile;
}
@@ -43,9 +49,16 @@ public class InstallationService {
progress.update(5, "Checking Docker prerequisites");
prerequisites.verify();
progress.update(12, "Signing in to Docker registry");
registryLogin.login(
request.dockerRegistry().trim(),
request.dockerUsername().trim(),
request.dockerPassword());
UUID installationUuid = UUID.randomUUID();
progress.update(20, "Validating activation key");
var validation = activationClient.validate(
request.installerCloudServiceUrl().trim(),
request.clientCode().trim(),
request.licenseKey(),
installationUuid,
@@ -57,6 +70,7 @@ public class InstallationService {
progress.update(58, "Registering installation with "
+ profile.displayName() + " cloud service");
var registration = activationClient.register(
request.installerCloudServiceUrl().trim(),
validation,
request.installationCode().trim(),
request.installationName().trim(),
@@ -72,11 +86,12 @@ public class InstallationService {
String assertion = assertionService.generate(
clientId,
request.installationCode().trim(),
settings.cloudServiceUrl() + profile.tokenPath(),
normalizedCloudServiceUrl(request.installerCloudServiceUrl())
+ profile.tokenPath(),
keys,
profile);
progress.update(88, "Writing on-premises deployment files");
progress.update(82, "Writing protected on-premises deployment files");
Path output = deploymentWriter.write(
request.outputDirectory().toAbsolutePath().normalize(),
installationUuid,
@@ -89,13 +104,27 @@ public class InstallationService {
settings,
profile,
request.dockerImage().trim(),
request.optionalFields());
progress.update(100, "Installation package created successfully");
request.optionalFields(),
request.runtimeConfiguration());
progress.update(88, "Pulling the on-premises service image");
composeDeployment.pull(output);
progress.update(94, "Starting the on-premises service");
composeDeployment.start(output);
progress.update(98, "Verifying the service container");
composeDeployment.verifyRunning(output, profile.serviceName());
progress.update(100, "Installation completed and service is running");
String hostPort = profile.portMapping().substring(
0, profile.portMapping().indexOf(':'));
return new InstallationResult(
registration.installationId(),
request.installationCode().trim(),
clientId,
output);
output,
profile.serviceName(),
"http://localhost:" + hostPort + profile.servicePath(),
"RUNNING");
}
private void validate(InstallationRequest request) {
@@ -103,18 +132,55 @@ public class InstallationService {
throw new IllegalArgumentException("Installation request is required");
}
required(request.clientCode(), "Client code");
required(request.installerCloudServiceUrl(), "Cloud service URL");
validateAbsoluteHttpUrl(
request.installerCloudServiceUrl(),
"Cloud service URL");
required(request.licenseKey(), "License key");
required(request.installationCode(), "Installation code");
required(request.installationName(), "Installation name");
required(request.dockerImage(), "Docker image");
required(request.dockerRegistry(), "Docker registry");
required(request.dockerUsername(), "Docker username");
required(request.dockerPassword(), "Docker password");
if (!request.dockerImage().matches(
"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,254}")) {
throw new IllegalArgumentException(
"Docker image contains invalid characters");
}
if (!request.dockerRegistry().matches(
"[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?")) {
throw new IllegalArgumentException(
"Docker registry must be a host name with an optional port");
}
if (request.outputDirectory() == null) {
throw new IllegalArgumentException("Output directory is required");
}
RuntimeConfiguration runtime = request.runtimeConfiguration();
if (runtime == null) {
throw new IllegalArgumentException("Runtime configuration is required");
}
required(runtime.containerCloudServiceUrl(), "Container cloud service URL");
validateAbsoluteHttpUrl(
runtime.containerCloudServiceUrl(),
"Container cloud service URL");
required(runtime.databaseUrl(), "Database URL");
required(runtime.databaseUsername(), "Database username");
required(runtime.databasePassword(), "Database password");
required(runtime.redisHost(), "Redis host");
required(runtime.redisPort(), "Redis port");
required(runtime.redisPassword(), "Redis password");
required(runtime.cloudRequestTimeout(), "Cloud request timeout");
required(runtime.tokenRefreshSkew(), "Token refresh skew");
try {
int redisPort = Integer.parseInt(runtime.redisPort());
if (redisPort < 1 || redisPort > 65535) {
throw new NumberFormatException();
}
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Redis port must be between 1 and 65535");
}
}
private void required(String value, String label) {
@@ -122,4 +188,26 @@ public class InstallationService {
throw new IllegalArgumentException(label + " is required");
}
}
private void validateAbsoluteHttpUrl(String value, String label) {
try {
var uri = java.net.URI.create(value.trim());
if (!uri.isAbsolute()
|| (!"http".equalsIgnoreCase(uri.getScheme())
&& !"https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) {
throw new IllegalArgumentException();
}
} catch (Exception exception) {
throw new IllegalArgumentException(
label + " must be an absolute HTTP or HTTPS URL");
}
}
private String normalizedCloudServiceUrl(String value) {
String normalized = value.trim();
return normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1)
: normalized;
}
}

View File

@@ -31,6 +31,11 @@ public class InstallationWizard {
Console console = System.console();
Scanner scanner = console == null ? new Scanner(System.in) : null;
String cloudServiceUrl = promptWithDefault(
console,
scanner,
"Cloud service URL",
settings.cloudServiceUrl().toString());
String clientCode = prompt(console, scanner, "Client code");
String licenseKey = secret(console, scanner, "License key");
String installationCode = prompt(console, scanner, "Installation code");
@@ -39,7 +44,25 @@ public class InstallationWizard {
console,
scanner,
profile.displayName() + " Docker image");
String dockerRegistry = prompt(console, scanner, "Docker registry");
String dockerUsername = prompt(console, scanner, "Docker username");
String dockerPassword = secret(console, scanner, "Docker password");
Map<String, String> optionalFields = promptOptionalFields(console, scanner);
String containerCloudServiceUrl = promptWithDefault(
console,
scanner,
"Container cloud service URL",
settings.containerCloudServiceUrl().toString());
String databaseUrl = promptWithDefault(
console, scanner, "Database URL",
"jdbc:postgresql://localhost:5432/matrix");
String databaseUsername = promptWithDefault(
console, scanner, "Database username", "postgres");
String databasePassword = secret(console, scanner, "Database password");
String redisHost = promptWithDefault(console, scanner, "Redis host", "localhost");
String redisPort = promptWithDefault(console, scanner, "Redis port", "7901");
String redisPassword = secret(console, scanner, "Redis password");
String redisSsl = promptWithDefault(console, scanner, "Redis SSL", "false");
String outputDirectory = promptWithDefault(
console,
scanner,
@@ -48,16 +71,33 @@ public class InstallationWizard {
InstallationResult result = installationService.install(
new InstallationRequest(
cloudServiceUrl,
clientCode,
licenseKey,
installationCode,
installationName,
dockerImage,
dockerRegistry,
dockerUsername,
dockerPassword,
Path.of(outputDirectory).toAbsolutePath().normalize(),
optionalFields),
optionalFields,
new RuntimeConfiguration(
containerCloudServiceUrl,
databaseUrl,
databaseUsername,
databasePassword,
redisHost,
redisPort,
redisPassword,
Boolean.parseBoolean(redisSsl),
"PT10S",
"PT30S")),
(percentage, message) ->
System.out.println("[" + percentage + "%] " + message));
System.out.println("Installation registered: " + result.installationId());
System.out.println("Service status: " + result.serviceStatus());
System.out.println("Service URL: " + result.serviceUrl());
System.out.println("Deployment files created at: " + result.outputDirectory());
}

View File

@@ -12,6 +12,10 @@ public record ProductProfile(
String configurationFileName,
String installationConfigEnvironmentVariable,
String containerConfigurationPath,
String portMapping,
String servicePath,
String tokenPath,
Path assertionEncryptionPublicKey,
Path loginEncryptionPublicKey,
String loginKeyId,
List<String> optionalInstallationFields) {}

View File

@@ -20,6 +20,16 @@ public class ProductProfileRegistry {
throw new InstallerConfigurationException(
"Assertion encryption public key was not found: " + publicKey);
}
Path loginPublicKey = resolvePath(
settings.configurationDirectory(),
ini.optional(
section,
"login_encryption_public_key",
ini.required(section, "assertion_encryption_public_key")));
if (!Files.isRegularFile(loginPublicKey)) {
throw new InstallerConfigurationException(
"Login encryption public key was not found: " + loginPublicKey);
}
return new ProductProfile(
settings.product(),
ini.required(section, "display_name"),
@@ -42,8 +52,12 @@ public class ProductProfileRegistry {
"installation_config_environment_variable"),
absoluteContainerPath(
ini.required(section, "container_configuration_path")),
portMapping(ini.required(section, "port_mapping")),
absoluteApiPath(ini.optional(section, "service_path", "/")),
absoluteApiPath(ini.required(section, "token_path")),
publicKey,
loginPublicKey,
ini.optional(section, "login_key_id", "cygnus-login-2026-01"),
commaSeparated(ini.optional(section, "optional_fields", "")));
}
@@ -77,6 +91,14 @@ public class ProductProfileRegistry {
return value;
}
private String portMapping(String value) {
if (!value.matches("[0-9]{1,5}:[0-9]{1,5}")) {
throw new InstallerConfigurationException(
"port_mapping must use hostPort:containerPort");
}
return value;
}
private List<String> commaSeparated(String value) {
if (value.isBlank()) {
return List.of();

View File

@@ -20,6 +20,7 @@ import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
@@ -42,6 +43,7 @@ public class SwingInstallationWizard {
private static final String WELCOME = "welcome";
private static final String DETAILS = "details";
private static final String RUNTIME = "runtime";
private static final String INSTALLING = "installing";
private static final Color ACCENT = new Color(35, 97, 146);
private static final Color BACKGROUND = new Color(236, 243, 247);
@@ -55,16 +57,32 @@ public class SwingInstallationWizard {
private JFrame frame;
private CardLayout cards;
private JPanel cardPanel;
private JTextField installerCloudServiceUrl;
private JTextField clientCode;
private JPasswordField licenseKey;
private JTextField installationCode;
private JTextField installationName;
private JTextField dockerImage;
private JTextField dockerRegistry;
private JTextField dockerUsername;
private JPasswordField dockerPassword;
private JTextField outputDirectory;
private JTextField containerCloudServiceUrl;
private JTextField databaseUrl;
private JTextField databaseUsername;
private JPasswordField databasePassword;
private JTextField redisHost;
private JTextField redisPort;
private JPasswordField redisPassword;
private JCheckBox redisSsl;
private JTextField cloudRequestTimeout;
private JTextField tokenRefreshSkew;
private final Map<String, JTextField> optionalFields = new LinkedHashMap<>();
private JProgressBar progressBar;
private JLabel progressPercentage;
private JLabel progressMessage;
private JTextArea resultText;
private JButton backToConfigurationButton;
private JButton closeButton;
public SwingInstallationWizard(
@@ -122,6 +140,7 @@ public class SwingInstallationWizard {
cardPanel.setBackground(BACKGROUND);
cardPanel.add(welcomePanel(), WELCOME);
cardPanel.add(detailsPanel(), DETAILS);
cardPanel.add(runtimePanel(), RUNTIME);
cardPanel.add(installingPanel(), INSTALLING);
root.add(cardPanel, BorderLayout.CENTER);
return root;
@@ -156,7 +175,11 @@ public class SwingInstallationWizard {
+ "and creates the protected on-premises deployment package."));
copy.add(Box.createVerticalStrut(22));
copy.add(summaryRow("Product", profile.displayName()));
copy.add(summaryRow("Cloud service", settings.cloudServiceUrl().toString()));
installerCloudServiceUrl =
new JTextField(settings.cloudServiceUrl().toString());
installerCloudServiceUrl.setEditable(true);
installerCloudServiceUrl.setEnabled(true);
copy.add(editableSummaryRow("Cloud service", installerCloudServiceUrl));
copy.add(summaryRow("Environment", settings.environment()));
copy.add(summaryRow("Configuration", settings.configurationDirectory().toString()));
panel.add(copy, BorderLayout.CENTER);
@@ -186,7 +209,14 @@ public class SwingInstallationWizard {
licenseKey = new JPasswordField();
installationCode = new JTextField();
installationName = new JTextField();
containerCloudServiceUrl =
new JTextField(settings.containerCloudServiceUrl().toString());
containerCloudServiceUrl.setEditable(true);
containerCloudServiceUrl.setEnabled(true);
dockerImage = new JTextField();
dockerRegistry = new JTextField();
dockerUsername = new JTextField();
dockerPassword = new JPasswordField();
outputDirectory = new JTextField(
settings.defaultOutputDirectory().toAbsolutePath().normalize().toString());
int row = 0;
@@ -194,12 +224,21 @@ public class SwingInstallationWizard {
row = addField(form, row, "License key", licenseKey, null);
row = addField(form, row, "Installation code", installationCode, null);
row = addField(form, row, "Installation name", installationName, null);
row = addField(
form,
row,
"Cloud service URL for container",
containerCloudServiceUrl,
null);
row = addField(
form,
row,
profile.displayName() + " Docker image",
dockerImage,
null);
row = addField(form, row, "Docker registry", dockerRegistry, null);
row = addField(form, row, "Docker username", dockerUsername, null);
row = addField(form, row, "Docker password", dockerPassword, null);
for (String field : profile.optionalInstallationFields()) {
JTextField input = new JTextField();
optionalFields.put(field, input);
@@ -216,6 +255,56 @@ public class SwingInstallationWizard {
JButton back = secondaryButton("Back");
back.addActionListener(event -> cards.show(cardPanel, WELCOME));
JButton next = primaryButton("Next");
next.addActionListener(event -> cards.show(cardPanel, RUNTIME));
panel.add(actions(back, next), BorderLayout.SOUTH);
return panel;
}
private JPanel runtimePanel() {
JPanel panel = contentPanel();
panel.setLayout(new BorderLayout(0, 12));
JPanel top = new JPanel(new BorderLayout());
top.setOpaque(false);
top.add(heading("Runtime configuration"), BorderLayout.NORTH);
top.add(text("Configure the database and Redis services used by the "
+ profile.displayName() + " on-premises container."), BorderLayout.SOUTH);
panel.add(top, BorderLayout.NORTH);
JPanel form = new JPanel(new GridBagLayout());
form.setBackground(PANEL);
form.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(new Color(205, 217, 225)),
BorderFactory.createEmptyBorder(14, 16, 14, 16)));
databaseUrl = new JTextField("jdbc:postgresql://localhost:5432/matrix");
databaseUsername = new JTextField("postgres");
databasePassword = new JPasswordField();
redisHost = new JTextField("localhost");
redisPort = new JTextField("7901");
redisPassword = new JPasswordField();
cloudRequestTimeout = new JTextField("PT10S");
tokenRefreshSkew = new JTextField("PT30S");
int row = 0;
row = addField(form, row, "Database URL", databaseUrl, null);
row = addField(form, row, "Database username", databaseUsername, null);
row = addField(form, row, "Database password", databasePassword, null);
row = addField(form, row, "Redis host", redisHost, null);
row = addField(form, row, "Redis port", redisPort, null);
row = addField(form, row, "Redis password", redisPassword, null);
row = addField(form, row, "Cloud request timeout", cloudRequestTimeout, null);
row = addField(form, row, "Token refresh skew", tokenRefreshSkew, null);
redisSsl = new JCheckBox("Use TLS/SSL for Redis");
redisSsl.setOpaque(false);
GridBagConstraints sslConstraints = constraints(1, row);
sslConstraints.anchor = GridBagConstraints.LINE_START;
form.add(redisSsl, sslConstraints);
panel.add(form, BorderLayout.CENTER);
JButton back = secondaryButton("Back");
back.addActionListener(event -> cards.show(cardPanel, DETAILS));
JButton install = primaryButton("Install");
install.addActionListener(event -> startInstallation());
panel.add(actions(back, install), BorderLayout.SOUTH);
@@ -228,35 +317,89 @@ public class SwingInstallationWizard {
JPanel center = new JPanel();
center.setOpaque(false);
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
center.add(heading("Installing " + profile.displayName()));
center.add(Box.createVerticalStrut(20));
JPanel progressSection = new JPanel();
progressSection.setOpaque(false);
progressSection.setLayout(new BoxLayout(progressSection, BoxLayout.Y_AXIS));
progressSection.setAlignmentX(Component.LEFT_ALIGNMENT);
progressSection.setMaximumSize(new Dimension(Integer.MAX_VALUE, 110));
JLabel installingTitle = heading("Installing " + profile.displayName());
installingTitle.setHorizontalAlignment(SwingConstants.CENTER);
installingTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
installingTitle.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24));
progressSection.add(installingTitle);
progressSection.add(Box.createVerticalStrut(20));
progressBar = new JProgressBar(0, 100);
progressBar.setStringPainted(true);
progressBar.setStringPainted(false);
progressBar.setAlignmentX(Component.LEFT_ALIGNMENT);
progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, 28));
center.add(progressBar);
center.add(Box.createVerticalStrut(10));
progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, 12));
progressBar.setPreferredSize(new Dimension(600, 12));
progressSection.add(progressBar);
progressSection.add(Box.createVerticalStrut(4));
progressPercentage = new JLabel("0%");
progressPercentage.setHorizontalAlignment(SwingConstants.RIGHT);
progressPercentage.setAlignmentX(Component.LEFT_ALIGNMENT);
progressPercentage.setMaximumSize(new Dimension(Integer.MAX_VALUE, 18));
progressSection.add(progressPercentage);
progressSection.add(Box.createVerticalStrut(6));
progressMessage = text("Preparing installation");
center.add(progressMessage);
progressMessage.setHorizontalAlignment(SwingConstants.CENTER);
progressMessage.setMaximumSize(new Dimension(Integer.MAX_VALUE, 20));
progressSection.add(progressMessage);
center.add(progressSection);
center.add(Box.createVerticalStrut(20));
resultText = new JTextArea(7, 40);
resultText.setEditable(false);
resultText.setLineWrap(true);
resultText.setWrapStyleWord(true);
resultText.setBackground(PANEL);
resultText.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
resultText.setAlignmentX(Component.LEFT_ALIGNMENT);
resultText.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
resultText.setVisible(false);
center.add(resultText);
panel.add(center, BorderLayout.CENTER);
backToConfigurationButton = secondaryButton("Back to configuration");
backToConfigurationButton.setVisible(false);
backToConfigurationButton.addActionListener(event -> {
resultText.setVisible(false);
resultText.setForeground(UIManager.getColor("TextArea.foreground"));
progressBar.setValue(0);
progressPercentage.setText("0%");
progressMessage.setText("Preparing installation");
cards.show(cardPanel, RUNTIME);
containerCloudServiceUrl.requestFocusInWindow();
});
closeButton = primaryButton("Close");
closeButton.setEnabled(false);
closeButton.addActionListener(event -> close());
panel.add(actions(null, closeButton), BorderLayout.SOUTH);
panel.add(actions(backToConfigurationButton, closeButton), BorderLayout.SOUTH);
return panel;
}
private void checkPrerequisites(JButton button) {
try {
validateAbsoluteHttpUrl(
installerCloudServiceUrl.getText(),
"Cloud service URL");
if (containerCloudServiceUrl.getText().trim().equals(
settings.containerCloudServiceUrl().toString())
&& !installerCloudServiceUrl.getText().trim().equals(
settings.cloudServiceUrl().toString())) {
containerCloudServiceUrl.setText(
installerCloudServiceUrl.getText().trim());
}
} catch (IllegalArgumentException exception) {
showError(exception.getMessage());
return;
}
button.setEnabled(false);
button.setText("Checking Docker…");
new SwingWorker<Void, Void>() {
@@ -283,25 +426,51 @@ public class SwingInstallationWizard {
private void startInstallation() {
char[] secret = licenseKey.getPassword();
char[] registrySecret = dockerPassword.getPassword();
char[] databaseSecret = databasePassword.getPassword();
char[] redisSecret = redisPassword.getPassword();
InstallationRequest request;
try {
request = new InstallationRequest(
installerCloudServiceUrl.getText(),
clientCode.getText(),
new String(secret),
installationCode.getText(),
installationName.getText(),
dockerImage.getText(),
dockerRegistry.getText(),
dockerUsername.getText(),
new String(registrySecret),
Path.of(outputDirectory.getText().trim()),
optionalValues());
optionalValues(),
new RuntimeConfiguration(
containerCloudServiceUrl.getText(),
databaseUrl.getText(),
databaseUsername.getText(),
new String(databaseSecret),
redisHost.getText(),
redisPort.getText(),
new String(redisSecret),
redisSsl.isSelected(),
cloudRequestTimeout.getText(),
tokenRefreshSkew.getText()));
validateForm(request);
} catch (Exception exception) {
Arrays.fill(secret, '\0');
Arrays.fill(registrySecret, '\0');
Arrays.fill(databaseSecret, '\0');
Arrays.fill(redisSecret, '\0');
showError(exception.getMessage());
return;
}
Arrays.fill(secret, '\0');
Arrays.fill(registrySecret, '\0');
Arrays.fill(databaseSecret, '\0');
Arrays.fill(redisSecret, '\0');
cards.show(cardPanel, INSTALLING);
backToConfigurationButton.setVisible(false);
closeButton.setEnabled(false);
resultText.setForeground(UIManager.getColor("TextArea.foreground"));
resultText.setVisible(false);
new SwingWorker<InstallationResult, ProgressUpdate>() {
@@ -317,6 +486,7 @@ public class SwingInstallationWizard {
protected void process(java.util.List<ProgressUpdate> updates) {
ProgressUpdate update = updates.getLast();
progressBar.setValue(update.percentage());
progressPercentage.setText(update.percentage() + "%");
progressMessage.setText(update.message());
}
@@ -326,27 +496,38 @@ public class SwingInstallationWizard {
try {
InstallationResult result = get();
progressBar.setValue(100);
progressPercentage.setText("100%");
progressMessage.setText("Installation completed");
resultText.setText(
"Installation ID: " + result.installationId()
+ "\nClient ID: " + result.clientId()
+ "\nService: " + result.serviceName()
+ "\nStatus: " + result.serviceStatus()
+ "\nService URL: " + result.serviceUrl()
+ "\nOutput directory: " + result.outputDirectory()
+ "\n\nKeep the private key and machine assertion secure.");
+ "\n\nThe deployment directory is required for "
+ "restarts and upgrades. Keep it secure.");
resultText.setVisible(true);
resultText.revalidate();
backToConfigurationButton.setVisible(false);
} catch (Exception exception) {
progressMessage.setText("Installation failed");
progressBar.setValue(0);
progressPercentage.setText("0%");
resultText.setText(message(exception));
resultText.setForeground(new Color(160, 35, 35));
resultText.setVisible(true);
resultText.revalidate();
backToConfigurationButton.setVisible(true);
}
}
}.execute();
}
private void validateForm(InstallationRequest request) {
validateAbsoluteHttpUrl(
request.installerCloudServiceUrl(),
"Cloud service URL");
if (request.clientCode().isBlank()) {
throw new IllegalArgumentException("Client code is required.");
}
@@ -362,9 +543,49 @@ public class SwingInstallationWizard {
if (request.dockerImage().isBlank()) {
throw new IllegalArgumentException("Docker image is required.");
}
if (request.dockerRegistry().isBlank()) {
throw new IllegalArgumentException("Docker registry is required.");
}
if (request.dockerUsername().isBlank()) {
throw new IllegalArgumentException("Docker username is required.");
}
if (request.dockerPassword().isBlank()) {
throw new IllegalArgumentException("Docker password is required.");
}
if (outputDirectory.getText().isBlank()) {
throw new IllegalArgumentException("Output directory is required.");
}
RuntimeConfiguration runtime = request.runtimeConfiguration();
validateAbsoluteHttpUrl(
runtime.containerCloudServiceUrl(),
"Container cloud service URL");
if (runtime.databaseUrl().isBlank()
|| runtime.databaseUsername().isBlank()
|| runtime.databasePassword().isBlank()) {
throw new IllegalArgumentException(
"Complete all database connection fields.");
}
if (runtime.redisHost().isBlank()
|| runtime.redisPort().isBlank()
|| runtime.redisPassword().isBlank()) {
throw new IllegalArgumentException(
"Complete all Redis connection fields.");
}
}
private void validateAbsoluteHttpUrl(String value, String label) {
try {
var uri = java.net.URI.create(value.trim());
if (!uri.isAbsolute()
|| (!"http".equalsIgnoreCase(uri.getScheme())
&& !"https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) {
throw new IllegalArgumentException();
}
} catch (Exception exception) {
throw new IllegalArgumentException(
label + " must be an absolute HTTP or HTTPS URL.");
}
}
private Map<String, String> optionalValues() {
@@ -466,6 +687,20 @@ public class SwingInstallationWizard {
return row;
}
private JPanel editableSummaryRow(String label, JTextField input) {
JPanel row = new JPanel(new BorderLayout(18, 0));
row.setOpaque(false);
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.setMaximumSize(new Dimension(Integer.MAX_VALUE, 34));
JLabel name = new JLabel(label);
name.setFont(name.getFont().deriveFont(Font.BOLD));
name.setPreferredSize(new Dimension(130, 28));
input.setPreferredSize(new Dimension(360, 28));
row.add(name, BorderLayout.WEST);
row.add(input, BorderLayout.CENTER);
return row;
}
private JButton primaryButton(String text) {
JButton button = new JButton(text);
button.setBackground(ACCENT);

View File

@@ -46,12 +46,18 @@ class DeploymentWriterTest {
"installation.yml",
"MATRIX_INSTALLATION_CONFIG",
"/srv/matrix/config/installation.yml",
"8080:8080",
"/matrix/",
"/oauth2/token",
temporaryDirectory.resolve("unused.pem"),
temporaryDirectory.resolve("login-public.pem"),
"cygnus-login-2026-01",
List.of("region"));
Files.writeString(profile.loginEncryptionPublicKey(), "login-public-key");
var settings = new InstallerSettings(
"matrix",
URI.create("https://cloud.example.com"),
URI.create("http://host.docker.internal:8090"),
"/api/v1/installations",
"production",
temporaryDirectory,
@@ -71,22 +77,45 @@ class DeploymentWriterTest {
settings,
profile,
"registry.example.com/matrix:1.0.0",
Map.of("region", "north"));
Map.of("region", "north"),
new RuntimeConfiguration(
"http://host.docker.internal:8090",
"jdbc:postgresql://db:5432/matrix",
"postgres",
"db-secret",
"redis",
"7901",
"redis-secret",
false,
"PT10S",
"PT30S"));
String config = Files.readString(output.resolve("config/installation.yml"));
String compose = Files.readString(output.resolve("compose.yml"));
assertThat(config)
.contains("client-id: \"matrix-client\"")
.contains("installation-id: \"" + installationId + "\"")
.contains("cloud-url: \"https://cloud.example.com\"")
.contains("cloud-url: \"http://host.docker.internal:8090\"")
.contains("token-url: \"http://host.docker.internal:8090/oauth2/token\"")
.contains("region: \"north\"")
.doesNotContain("activation-token");
assertThat(compose)
.contains("matrix-onprem:")
.contains("- \"8080:8080\"")
.contains("./config:/srv/matrix/config:ro")
.contains("MATRIX_INSTALLATION_CONFIG");
assertThat(output.resolve(".env"))
.hasContent("MATRIX_IMAGE=registry.example.com/matrix:1.0.0\n");
assertThat(Files.readString(output.resolve(".env")))
.contains("MATRIX_IMAGE=\"registry.example.com/matrix:1.0.0\"")
.contains("MATRIX_DB_URL=\"jdbc:postgresql://db:5432/matrix\"")
.contains("REDIS_HOST=\"redis\"")
.contains("CYGNUS_CLOUD_BASE_URL=\"http://host.docker.internal:8090\"")
.contains("CYGNUS_TOKEN_URL=\"http://host.docker.internal:8090/oauth2/token\"")
.contains("CYGNUS_CLIENT_ID=\"matrix-client\"")
.contains("CYGNUS_INSTALLATION_ID=\"primary\"")
.contains("CYGNUS_CLIENT_ASSERTION=\"file:/srv/matrix/config/machine-assertion.jwt\"")
.contains("CYGNUS_LOGIN_PUBLIC_KEY=\"file:/srv/matrix/config/keys/login-public.pem\"");
assertThat(output.resolve("config/keys/login-public.pem"))
.hasContent("login-public-key");
assertThat(output.resolve("config/machine-assertion.jwt")).hasContent(
"encrypted.assertion.value");
assertThat(output.resolve("config/keys/client-signing-private.pem"))

View File

@@ -11,17 +11,23 @@ class InstallationServiceTest {
@Test
void rejectsIncompleteRequestBeforeCallingInfrastructure() {
InstallationService service =
new InstallationService(null, null, null, null, null, null, null);
new InstallationService(
null, null, null, null, null, null, null, null, null);
assertThatThrownBy(() -> service.install(
new InstallationRequest(
"",
"",
"",
"",
"",
"",
"",
"",
"",
Path.of("."),
Map.of()),
Map.of(),
null),
(percentage, message) -> {}))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Client code is required");

View File

@@ -32,8 +32,12 @@ class MachineAssertionServiceTest {
"installation.yml",
"MATRIX_INSTALLATION_CONFIG",
"/opt/matrix/config/installation.yml",
"8080:8080",
"/matrix/",
"/oauth2/token",
cloudPublicKey,
cloudPublicKey,
"cygnus-login-2026-01",
List.of());
String assertion = new MachineAssertionService().generate(

View File

@@ -16,6 +16,8 @@ class ProductProfileRegistryTest {
void appliesProductProfileOverridesWithoutInstallerBranching() throws Exception {
Path publicKey = temporaryDirectory.resolve("cloud.pem");
Files.writeString(publicKey, "test");
Path loginPublicKey = temporaryDirectory.resolve("login.pem");
Files.writeString(loginPublicKey, "test-login");
Path ini = temporaryDirectory.resolve("installer.ini");
Files.writeString(ini, """
[installer]
@@ -27,12 +29,16 @@ class ProductProfileRegistryTest {
[profile.cygnus]
display_name=Cygnus
assertion_encryption_public_key=cloud.pem
login_encryption_public_key=login.pem
login_key_id=cygnus-login-2026-01
service_name=custom-cygnus
image_environment_variable=CYGNUS_IMAGE
configuration_root=cygnus
configuration_file_name=installation.yml
installation_config_environment_variable=CYGNUS_INSTALLATION_CONFIG
container_configuration_path=/opt/cygnus/config/installation.yml
port_mapping=8080:8080
service_path=/matrix/
token_path=/oauth2/token
optional_fields=region,site_code
""");
@@ -45,5 +51,6 @@ class ProductProfileRegistryTest {
assertThat(profile.optionalInstallationFields())
.containsExactly("region", "site_code");
assertThat(profile.assertionEncryptionPublicKey()).isEqualTo(publicKey);
assertThat(profile.loginEncryptionPublicKey()).isEqualTo(loginPublicKey);
}
}

View File

@@ -11,11 +11,13 @@ com/cygnus/installer/InstallationWizard.class
com/cygnus/installer/TechnobeeServiceInstallerApplication.class
com/cygnus/installer/DeploymentWriter.class
com/cygnus/installer/InstallationKeyService.class
com/cygnus/installer/DockerComposeDeploymentService.class
com/cygnus/installer/ActivationClient.class
com/cygnus/installer/ActivationDtos$RegistrationResponse.class
com/cygnus/installer/IniDocument.class
com/cygnus/installer/SwingInstallationWizard$3.class
com/cygnus/installer/InstallerConfigurationException.class
com/cygnus/installer/DockerRegistryLoginService.class
com/cygnus/installer/SwingInstallationWizard.class
com/cygnus/installer/ActivationDtos.class
com/cygnus/installer/DockerPrerequisiteChecker.class
@@ -25,6 +27,7 @@ com/cygnus/installer/CommandExecutor.class
com/cygnus/installer/IniConfigurationLoader.class
com/cygnus/installer/InstallationService.class
com/cygnus/installer/InstallationResult.class
com/cygnus/installer/RuntimeConfiguration.class
com/cygnus/installer/ActivationDtos$RegistrationRequest.class
com/cygnus/installer/ActivationDtos$ValidationRequest.class
com/cygnus/installer/PrerequisiteException.class

View File

@@ -3,7 +3,9 @@
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/CommandExecutor.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/CommandResult.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DeploymentWriter.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DockerComposeDeploymentService.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DockerPrerequisiteChecker.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DockerRegistryLoginService.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/IniConfigurationLoader.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/IniDocument.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallationKeyPair.java
@@ -21,5 +23,6 @@
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/PrerequisiteException.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/ProductProfile.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/ProductProfileRegistry.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/RuntimeConfiguration.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/SwingInstallationWizard.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/TechnobeeServiceInstallerApplication.java

View File

@@ -1,7 +1,11 @@
com/cygnus/installer/MachineAssertionServiceTest.class
com/cygnus/installer/DockerRegistryLoginServiceTest$CapturingExecutor.class
com/cygnus/installer/DockerRegistryLoginServiceTest.class
com/cygnus/installer/DockerComposeDeploymentServiceTest$CapturingExecutor.class
com/cygnus/installer/InstallationServiceTest.class
com/cygnus/installer/DockerPrerequisiteCheckerTest$1.class
com/cygnus/installer/ProductProfileRegistryTest.class
com/cygnus/installer/IniConfigurationLoaderTest.class
com/cygnus/installer/DockerComposeDeploymentServiceTest.class
com/cygnus/installer/DeploymentWriterTest.class
com/cygnus/installer/DockerPrerequisiteCheckerTest.class

View File

@@ -1,5 +1,7 @@
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/DeploymentWriterTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/DockerComposeDeploymentServiceTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/DockerPrerequisiteCheckerTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/DockerRegistryLoginServiceTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/IniConfigurationLoaderTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/InstallationServiceTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/MachineAssertionServiceTest.java

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.installer.DeploymentWriterTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.206 s -- in com.cygnus.installer.DeploymentWriterTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.174 s -- in com.cygnus.installer.DeploymentWriterTest

View File

@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.installer.DockerRegistryLoginServiceTest
-------------------------------------------------------------------------------
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.010 s -- in com.cygnus.installer.DockerRegistryLoginServiceTest

View File

@@ -1,4 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.installer.IniConfigurationLoaderTest
-------------------------------------------------------------------------------
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 s -- in com.cygnus.installer.IniConfigurationLoaderTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.008 s -- in com.cygnus.installer.IniConfigurationLoaderTest

View File

@@ -1,4 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.installer.MachineAssertionServiceTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.301 s -- in com.cygnus.installer.MachineAssertionServiceTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.570 s -- in com.cygnus.installer.MachineAssertionServiceTest

View File

@@ -1,4 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.cygnus.installer.ProductProfileRegistryTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.010 s -- in com.cygnus.installer.ProductProfileRegistryTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in com.cygnus.installer.ProductProfileRegistryTest

View File

@@ -0,0 +1,2 @@
*
!target/matrix.war

View File

@@ -0,0 +1,21 @@
FROM tomcat:10.1-jdk21-temurin
LABEL org.opencontainers.image.title="Technobee Matrix On-Premises Service"
LABEL org.opencontainers.image.vendor="Technobee Solutions"
RUN rm -rf /usr/local/tomcat/webapps/*
COPY target/matrix.war /usr/local/tomcat/webapps/matrix.war
RUN mkdir -p /opt/matrix/config \
&& chown -R 1000:0 /opt/matrix /usr/local/tomcat \
&& chmod -R g=u /opt/matrix /usr/local/tomcat
ENV MATRIX_INSTALLATION_CONFIG=/opt/matrix/config/installation.yml
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Djava.awt.headless=true"
EXPOSE 8080
USER 1000
CMD ["catalina.sh", "run"]

View File

@@ -6,6 +6,8 @@
2. One or more tenant accounts are attached to that registration.
3. A time-bound license defines package, user, and installation limits.
4. An activation key is generated, stored only as a BCrypt hash, and emailed.
The recipient is always the primary contact email stored on the client
registration; API callers cannot redirect the key to another address.
5. The Technobee Service Installer blocks unless Docker, Docker Engine, and
Compose v2 work.
6. The installer validates the key, generates an RSA-3072 installation key, and
@@ -44,6 +46,9 @@ All administration endpoints require an access token with
3. `POST /api/v1/admin/tenants/{tenantId}/licenses`
4. `POST /api/v1/admin/tenants/{tenantId}/licenses/{licenseId}/activation-key`
The activation-key request contains only `expiresAt`. The cloud service reads
the delivery address from `client_registration_details.primary_contact_email`.
For replacements:
- `POST /api/v1/admin/tenants/{tenantId}/installations/{id}/decommission`
@@ -71,6 +76,9 @@ compiled into the installer workflow.
The installer asks for the Matrix or Cygnus Docker image and writes it to
`.env` using the selected profile's image variable. After a successful run,
review `compose.yml` and `.env`, then start with `docker compose up -d`.
It also asks for the registry host, username, and password and performs
`docker login --password-stdin`. The password is not stored in the generated
installation package.
The installer asks for the output directory. Press Enter to accept the INI
`output_directory`. It creates: