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` - `POST /api/v1/installations/register`
The desktop wizard is the default. It checks Docker, collects activation and The desktop wizard is the default. It checks Docker, collects activation and
installation details, lets the user choose an output directory, and reports installation details, asks for the Docker registry, image, username, and
installation progress. For servers without a desktop or for automation, append password, lets the user choose an output directory, and reports installation
`--cli` to retain the interactive terminal workflow: 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 ```bash
TECHNOBEE_INSTALLER_CONFIG=cygnus-installer/config/matrix-installer.ini \ TECHNOBEE_INSTALLER_CONFIG=cygnus-installer/config/matrix-installer.ini \
@@ -48,6 +51,7 @@ configuration_root=matrix
configuration_file_name=installation.yml configuration_file_name=installation.yml
installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG
container_configuration_path=/opt/matrix/config/installation.yml container_configuration_path=/opt/matrix/config/installation.yml
port_mapping=8080:8080
token_path=/oauth2/token token_path=/oauth2/token
assertion_encryption_public_key=/secure/cloud-assertion-public.pem assertion_encryption_public_key=/secure/cloud-assertion-public.pem
optional_fields= optional_fields=
@@ -72,7 +76,8 @@ config/
The installer asks for the product Docker image, validates it, and stores it 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 under the profile's image variable in `.env`. Docker Compose loads this file
automatically, so no separate `export MATRIX_IMAGE` or `export CYGNUS_IMAGE` 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 The RSA-3072 pair is generated locally. Only the public key is sent during
registration. The private key and encrypted machine assertion remain on registration. The private key and encrypted machine assertion remain on

View File

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

View File

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

View File

@@ -11,10 +11,25 @@ import org.springframework.stereotype.Component;
public class CommandExecutor { public class CommandExecutor {
public CommandResult execute(List<String> command, Duration timeout) { public CommandResult execute(List<String> command, Duration timeout) {
return execute(command, timeout, null);
}
public CommandResult execute(
List<String> command,
Duration timeout,
String standardInput) {
try { try {
Process process = new ProcessBuilder(command) Process process = new ProcessBuilder(command)
.redirectErrorStream(true) .redirectErrorStream(true)
.start(); .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); boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!completed) { if (!completed) {
process.destroyForcibly(); process.destroyForcibly();

View File

@@ -25,7 +25,8 @@ public class DeploymentWriter {
InstallerSettings settings, InstallerSettings settings,
ProductProfile profile, ProductProfile profile,
String dockerImage, String dockerImage,
Map<String, String> optionalFields) { Map<String, String> optionalFields,
RuntimeConfiguration runtime) {
try { try {
Path normalizedOutput = output.toAbsolutePath().normalize(); Path normalizedOutput = output.toAbsolutePath().normalize();
Path config = normalizedOutput.resolve("config"); Path config = normalizedOutput.resolve("config");
@@ -41,6 +42,10 @@ public class DeploymentWriter {
writePublic( writePublic(
normalizedOutput.resolve(publicRelative), normalizedOutput.resolve(publicRelative),
keys.publicKeyPem()); keys.publicKeyPem());
String loginPublicRelative = "config/keys/login-public.pem";
writePublic(
normalizedOutput.resolve(loginPublicRelative),
Files.readString(profile.loginEncryptionPublicKey()));
writePrivate( writePrivate(
normalizedOutput.resolve(assertionRelative), normalizedOutput.resolve(assertionRelative),
machineAssertion); machineAssertion);
@@ -57,13 +62,20 @@ public class DeploymentWriter {
privateRelative, privateRelative,
publicRelative, publicRelative,
assertionRelative, assertionRelative,
optionalFields)); optionalFields,
runtime));
writePublic( writePublic(
normalizedOutput.resolve("compose.yml"), normalizedOutput.resolve("compose.yml"),
compose(profile)); compose(profile));
writePrivate( writePrivate(
normalizedOutput.resolve(".env"), normalizedOutput.resolve(".env"),
profile.imageEnvironmentVariable() + "=" + dockerImage + "\n"); environment(
profile,
dockerImage,
clientId,
installationCode,
settings,
runtime));
return normalizedOutput; return normalizedOutput;
} catch (IOException exception) { } catch (IOException exception) {
throw new IllegalStateException("Could not write installation files", exception); throw new IllegalStateException("Could not write installation files", exception);
@@ -81,14 +93,16 @@ public class DeploymentWriter {
String privateKey, String privateKey,
String publicKey, String publicKey,
String assertion, String assertion,
Map<String, String> optionalFields) { Map<String, String> optionalFields,
RuntimeConfiguration runtime) {
String cloudServiceUrl = normalizedCloudServiceUrl(runtime);
StringBuilder yaml = new StringBuilder() StringBuilder yaml = new StringBuilder()
.append(profile.configurationRoot()).append(":\n") .append(profile.configurationRoot()).append(":\n")
.append(" product: ").append(quoted(profile.product())).append('\n') .append(" product: ").append(quoted(profile.product())).append('\n')
.append(" cloud-url: ") .append(" cloud-url: ")
.append(quoted(settings.cloudServiceUrl().toString())).append('\n') .append(quoted(cloudServiceUrl)).append('\n')
.append(" token-url: ") .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(" tenant-id: ").append(quoted(value(validation.tenantId()))).append('\n')
.append(" client-id: ").append(quoted(clientId)).append('\n') .append(" client-id: ").append(quoted(clientId)).append('\n')
.append(" installation-id: ") .append(" installation-id: ")
@@ -120,19 +134,94 @@ public class DeploymentWriter {
%s: %s:
image: ${%s:?Set %s} image: ${%s:?Set %s}
restart: unless-stopped restart: unless-stopped
ports:
- "%s"
volumes: volumes:
- ./config:%s:ro - ./config:%s:ro
env_file:
- ./.env
environment: environment:
%s: %s %s: %s
""".formatted( """.formatted(
profile.serviceName(), profile.serviceName(),
profile.imageEnvironmentVariable(), profile.imageEnvironmentVariable(),
profile.imageEnvironmentVariable(), profile.imageEnvironmentVariable(),
profile.portMapping(),
containerConfigDirectory, containerConfigDirectory,
profile.installationConfigEnvironmentVariable(), profile.installationConfigEnvironmentVariable(),
profile.containerConfigurationPath()); 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) { private String quoted(String value) {
if (value == null) { if (value == null) {
return "\"\""; 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; import java.util.Map;
public record InstallationRequest( public record InstallationRequest(
String installerCloudServiceUrl,
String clientCode, String clientCode,
String licenseKey, String licenseKey,
String installationCode, String installationCode,
String installationName, String installationName,
String dockerImage, String dockerImage,
String dockerRegistry,
String dockerUsername,
String dockerPassword,
Path outputDirectory, Path outputDirectory,
Map<String, String> optionalFields) { Map<String, String> optionalFields,
RuntimeConfiguration runtimeConfiguration) {
public InstallationRequest { public InstallationRequest {
optionalFields = optionalFields == null ? Map.of() : Map.copyOf(optionalFields); optionalFields = optionalFields == null ? Map.of() : Map.copyOf(optionalFields);

View File

@@ -8,26 +8,32 @@ import org.springframework.stereotype.Service;
public class InstallationService { public class InstallationService {
private final DockerPrerequisiteChecker prerequisites; private final DockerPrerequisiteChecker prerequisites;
private final DockerRegistryLoginService registryLogin;
private final ActivationClient activationClient; private final ActivationClient activationClient;
private final InstallationKeyService keyService; private final InstallationKeyService keyService;
private final MachineAssertionService assertionService; private final MachineAssertionService assertionService;
private final DeploymentWriter deploymentWriter; private final DeploymentWriter deploymentWriter;
private final DockerComposeDeploymentService composeDeployment;
private final InstallerSettings settings; private final InstallerSettings settings;
private final ProductProfile profile; private final ProductProfile profile;
public InstallationService( public InstallationService(
DockerPrerequisiteChecker prerequisites, DockerPrerequisiteChecker prerequisites,
DockerRegistryLoginService registryLogin,
ActivationClient activationClient, ActivationClient activationClient,
InstallationKeyService keyService, InstallationKeyService keyService,
MachineAssertionService assertionService, MachineAssertionService assertionService,
DeploymentWriter deploymentWriter, DeploymentWriter deploymentWriter,
DockerComposeDeploymentService composeDeployment,
InstallerSettings settings, InstallerSettings settings,
ProductProfile profile) { ProductProfile profile) {
this.prerequisites = prerequisites; this.prerequisites = prerequisites;
this.registryLogin = registryLogin;
this.activationClient = activationClient; this.activationClient = activationClient;
this.keyService = keyService; this.keyService = keyService;
this.assertionService = assertionService; this.assertionService = assertionService;
this.deploymentWriter = deploymentWriter; this.deploymentWriter = deploymentWriter;
this.composeDeployment = composeDeployment;
this.settings = settings; this.settings = settings;
this.profile = profile; this.profile = profile;
} }
@@ -43,9 +49,16 @@ public class InstallationService {
progress.update(5, "Checking Docker prerequisites"); progress.update(5, "Checking Docker prerequisites");
prerequisites.verify(); prerequisites.verify();
progress.update(12, "Signing in to Docker registry");
registryLogin.login(
request.dockerRegistry().trim(),
request.dockerUsername().trim(),
request.dockerPassword());
UUID installationUuid = UUID.randomUUID(); UUID installationUuid = UUID.randomUUID();
progress.update(20, "Validating activation key"); progress.update(20, "Validating activation key");
var validation = activationClient.validate( var validation = activationClient.validate(
request.installerCloudServiceUrl().trim(),
request.clientCode().trim(), request.clientCode().trim(),
request.licenseKey(), request.licenseKey(),
installationUuid, installationUuid,
@@ -57,6 +70,7 @@ public class InstallationService {
progress.update(58, "Registering installation with " progress.update(58, "Registering installation with "
+ profile.displayName() + " cloud service"); + profile.displayName() + " cloud service");
var registration = activationClient.register( var registration = activationClient.register(
request.installerCloudServiceUrl().trim(),
validation, validation,
request.installationCode().trim(), request.installationCode().trim(),
request.installationName().trim(), request.installationName().trim(),
@@ -72,11 +86,12 @@ public class InstallationService {
String assertion = assertionService.generate( String assertion = assertionService.generate(
clientId, clientId,
request.installationCode().trim(), request.installationCode().trim(),
settings.cloudServiceUrl() + profile.tokenPath(), normalizedCloudServiceUrl(request.installerCloudServiceUrl())
+ profile.tokenPath(),
keys, keys,
profile); profile);
progress.update(88, "Writing on-premises deployment files"); progress.update(82, "Writing protected on-premises deployment files");
Path output = deploymentWriter.write( Path output = deploymentWriter.write(
request.outputDirectory().toAbsolutePath().normalize(), request.outputDirectory().toAbsolutePath().normalize(),
installationUuid, installationUuid,
@@ -89,13 +104,27 @@ public class InstallationService {
settings, settings,
profile, profile,
request.dockerImage().trim(), request.dockerImage().trim(),
request.optionalFields()); request.optionalFields(),
progress.update(100, "Installation package created successfully"); 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( return new InstallationResult(
registration.installationId(), registration.installationId(),
request.installationCode().trim(), request.installationCode().trim(),
clientId, clientId,
output); output,
profile.serviceName(),
"http://localhost:" + hostPort + profile.servicePath(),
"RUNNING");
} }
private void validate(InstallationRequest request) { private void validate(InstallationRequest request) {
@@ -103,18 +132,55 @@ public class InstallationService {
throw new IllegalArgumentException("Installation request is required"); throw new IllegalArgumentException("Installation request is required");
} }
required(request.clientCode(), "Client code"); required(request.clientCode(), "Client code");
required(request.installerCloudServiceUrl(), "Cloud service URL");
validateAbsoluteHttpUrl(
request.installerCloudServiceUrl(),
"Cloud service URL");
required(request.licenseKey(), "License key"); required(request.licenseKey(), "License key");
required(request.installationCode(), "Installation code"); required(request.installationCode(), "Installation code");
required(request.installationName(), "Installation name"); required(request.installationName(), "Installation name");
required(request.dockerImage(), "Docker image"); required(request.dockerImage(), "Docker image");
required(request.dockerRegistry(), "Docker registry");
required(request.dockerUsername(), "Docker username");
required(request.dockerPassword(), "Docker password");
if (!request.dockerImage().matches( if (!request.dockerImage().matches(
"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,254}")) { "[A-Za-z0-9][A-Za-z0-9._:/@-]{0,254}")) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Docker image contains invalid characters"); "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) { if (request.outputDirectory() == null) {
throw new IllegalArgumentException("Output directory is required"); 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) { private void required(String value, String label) {
@@ -122,4 +188,26 @@ public class InstallationService {
throw new IllegalArgumentException(label + " is required"); 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(); Console console = System.console();
Scanner scanner = console == null ? new Scanner(System.in) : null; 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 clientCode = prompt(console, scanner, "Client code");
String licenseKey = secret(console, scanner, "License key"); String licenseKey = secret(console, scanner, "License key");
String installationCode = prompt(console, scanner, "Installation code"); String installationCode = prompt(console, scanner, "Installation code");
@@ -39,7 +44,25 @@ public class InstallationWizard {
console, console,
scanner, scanner,
profile.displayName() + " Docker image"); 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); 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( String outputDirectory = promptWithDefault(
console, console,
scanner, scanner,
@@ -48,16 +71,33 @@ public class InstallationWizard {
InstallationResult result = installationService.install( InstallationResult result = installationService.install(
new InstallationRequest( new InstallationRequest(
cloudServiceUrl,
clientCode, clientCode,
licenseKey, licenseKey,
installationCode, installationCode,
installationName, installationName,
dockerImage, dockerImage,
dockerRegistry,
dockerUsername,
dockerPassword,
Path.of(outputDirectory).toAbsolutePath().normalize(), Path.of(outputDirectory).toAbsolutePath().normalize(),
optionalFields), optionalFields,
new RuntimeConfiguration(
containerCloudServiceUrl,
databaseUrl,
databaseUsername,
databasePassword,
redisHost,
redisPort,
redisPassword,
Boolean.parseBoolean(redisSsl),
"PT10S",
"PT30S")),
(percentage, message) -> (percentage, message) ->
System.out.println("[" + percentage + "%] " + message)); System.out.println("[" + percentage + "%] " + message));
System.out.println("Installation registered: " + result.installationId()); 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()); System.out.println("Deployment files created at: " + result.outputDirectory());
} }

View File

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

View File

@@ -20,6 +20,16 @@ public class ProductProfileRegistry {
throw new InstallerConfigurationException( throw new InstallerConfigurationException(
"Assertion encryption public key was not found: " + publicKey); "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( return new ProductProfile(
settings.product(), settings.product(),
ini.required(section, "display_name"), ini.required(section, "display_name"),
@@ -42,8 +52,12 @@ public class ProductProfileRegistry {
"installation_config_environment_variable"), "installation_config_environment_variable"),
absoluteContainerPath( absoluteContainerPath(
ini.required(section, "container_configuration_path")), ini.required(section, "container_configuration_path")),
portMapping(ini.required(section, "port_mapping")),
absoluteApiPath(ini.optional(section, "service_path", "/")),
absoluteApiPath(ini.required(section, "token_path")), absoluteApiPath(ini.required(section, "token_path")),
publicKey, publicKey,
loginPublicKey,
ini.optional(section, "login_key_id", "cygnus-login-2026-01"),
commaSeparated(ini.optional(section, "optional_fields", ""))); commaSeparated(ini.optional(section, "optional_fields", "")));
} }
@@ -77,6 +91,14 @@ public class ProductProfileRegistry {
return value; 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) { private List<String> commaSeparated(String value) {
if (value.isBlank()) { if (value.isBlank()) {
return List.of(); return List.of();

View File

@@ -20,6 +20,7 @@ import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
import javax.swing.BoxLayout; import javax.swing.BoxLayout;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JFileChooser; import javax.swing.JFileChooser;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel; import javax.swing.JLabel;
@@ -42,6 +43,7 @@ public class SwingInstallationWizard {
private static final String WELCOME = "welcome"; private static final String WELCOME = "welcome";
private static final String DETAILS = "details"; private static final String DETAILS = "details";
private static final String RUNTIME = "runtime";
private static final String INSTALLING = "installing"; private static final String INSTALLING = "installing";
private static final Color ACCENT = new Color(35, 97, 146); private static final Color ACCENT = new Color(35, 97, 146);
private static final Color BACKGROUND = new Color(236, 243, 247); private static final Color BACKGROUND = new Color(236, 243, 247);
@@ -55,16 +57,32 @@ public class SwingInstallationWizard {
private JFrame frame; private JFrame frame;
private CardLayout cards; private CardLayout cards;
private JPanel cardPanel; private JPanel cardPanel;
private JTextField installerCloudServiceUrl;
private JTextField clientCode; private JTextField clientCode;
private JPasswordField licenseKey; private JPasswordField licenseKey;
private JTextField installationCode; private JTextField installationCode;
private JTextField installationName; private JTextField installationName;
private JTextField dockerImage; private JTextField dockerImage;
private JTextField dockerRegistry;
private JTextField dockerUsername;
private JPasswordField dockerPassword;
private JTextField outputDirectory; 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 final Map<String, JTextField> optionalFields = new LinkedHashMap<>();
private JProgressBar progressBar; private JProgressBar progressBar;
private JLabel progressPercentage;
private JLabel progressMessage; private JLabel progressMessage;
private JTextArea resultText; private JTextArea resultText;
private JButton backToConfigurationButton;
private JButton closeButton; private JButton closeButton;
public SwingInstallationWizard( public SwingInstallationWizard(
@@ -122,6 +140,7 @@ public class SwingInstallationWizard {
cardPanel.setBackground(BACKGROUND); cardPanel.setBackground(BACKGROUND);
cardPanel.add(welcomePanel(), WELCOME); cardPanel.add(welcomePanel(), WELCOME);
cardPanel.add(detailsPanel(), DETAILS); cardPanel.add(detailsPanel(), DETAILS);
cardPanel.add(runtimePanel(), RUNTIME);
cardPanel.add(installingPanel(), INSTALLING); cardPanel.add(installingPanel(), INSTALLING);
root.add(cardPanel, BorderLayout.CENTER); root.add(cardPanel, BorderLayout.CENTER);
return root; return root;
@@ -156,7 +175,11 @@ public class SwingInstallationWizard {
+ "and creates the protected on-premises deployment package.")); + "and creates the protected on-premises deployment package."));
copy.add(Box.createVerticalStrut(22)); copy.add(Box.createVerticalStrut(22));
copy.add(summaryRow("Product", profile.displayName())); 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("Environment", settings.environment()));
copy.add(summaryRow("Configuration", settings.configurationDirectory().toString())); copy.add(summaryRow("Configuration", settings.configurationDirectory().toString()));
panel.add(copy, BorderLayout.CENTER); panel.add(copy, BorderLayout.CENTER);
@@ -186,7 +209,14 @@ public class SwingInstallationWizard {
licenseKey = new JPasswordField(); licenseKey = new JPasswordField();
installationCode = new JTextField(); installationCode = new JTextField();
installationName = new JTextField(); installationName = new JTextField();
containerCloudServiceUrl =
new JTextField(settings.containerCloudServiceUrl().toString());
containerCloudServiceUrl.setEditable(true);
containerCloudServiceUrl.setEnabled(true);
dockerImage = new JTextField(); dockerImage = new JTextField();
dockerRegistry = new JTextField();
dockerUsername = new JTextField();
dockerPassword = new JPasswordField();
outputDirectory = new JTextField( outputDirectory = new JTextField(
settings.defaultOutputDirectory().toAbsolutePath().normalize().toString()); settings.defaultOutputDirectory().toAbsolutePath().normalize().toString());
int row = 0; int row = 0;
@@ -194,12 +224,21 @@ public class SwingInstallationWizard {
row = addField(form, row, "License key", licenseKey, null); row = addField(form, row, "License key", licenseKey, null);
row = addField(form, row, "Installation code", installationCode, null); row = addField(form, row, "Installation code", installationCode, null);
row = addField(form, row, "Installation name", installationName, null); row = addField(form, row, "Installation name", installationName, null);
row = addField(
form,
row,
"Cloud service URL for container",
containerCloudServiceUrl,
null);
row = addField( row = addField(
form, form,
row, row,
profile.displayName() + " Docker image", profile.displayName() + " Docker image",
dockerImage, dockerImage,
null); 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()) { for (String field : profile.optionalInstallationFields()) {
JTextField input = new JTextField(); JTextField input = new JTextField();
optionalFields.put(field, input); optionalFields.put(field, input);
@@ -216,6 +255,56 @@ public class SwingInstallationWizard {
JButton back = secondaryButton("Back"); JButton back = secondaryButton("Back");
back.addActionListener(event -> cards.show(cardPanel, WELCOME)); 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"); JButton install = primaryButton("Install");
install.addActionListener(event -> startInstallation()); install.addActionListener(event -> startInstallation());
panel.add(actions(back, install), BorderLayout.SOUTH); panel.add(actions(back, install), BorderLayout.SOUTH);
@@ -228,35 +317,89 @@ public class SwingInstallationWizard {
JPanel center = new JPanel(); JPanel center = new JPanel();
center.setOpaque(false); center.setOpaque(false);
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS)); 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 = new JProgressBar(0, 100);
progressBar.setStringPainted(true); progressBar.setStringPainted(false);
progressBar.setAlignmentX(Component.LEFT_ALIGNMENT); progressBar.setAlignmentX(Component.LEFT_ALIGNMENT);
progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, 28)); progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, 12));
center.add(progressBar); progressBar.setPreferredSize(new Dimension(600, 12));
center.add(Box.createVerticalStrut(10)); 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"); 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)); center.add(Box.createVerticalStrut(20));
resultText = new JTextArea(7, 40); resultText = new JTextArea(7, 40);
resultText.setEditable(false); resultText.setEditable(false);
resultText.setLineWrap(true); resultText.setLineWrap(true);
resultText.setWrapStyleWord(true); resultText.setWrapStyleWord(true);
resultText.setBackground(PANEL); resultText.setBackground(PANEL);
resultText.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); 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); resultText.setVisible(false);
center.add(resultText); center.add(resultText);
panel.add(center, BorderLayout.CENTER); 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 = primaryButton("Close");
closeButton.setEnabled(false); closeButton.setEnabled(false);
closeButton.addActionListener(event -> close()); closeButton.addActionListener(event -> close());
panel.add(actions(null, closeButton), BorderLayout.SOUTH); panel.add(actions(backToConfigurationButton, closeButton), BorderLayout.SOUTH);
return panel; return panel;
} }
private void checkPrerequisites(JButton button) { 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.setEnabled(false);
button.setText("Checking Docker…"); button.setText("Checking Docker…");
new SwingWorker<Void, Void>() { new SwingWorker<Void, Void>() {
@@ -283,25 +426,51 @@ public class SwingInstallationWizard {
private void startInstallation() { private void startInstallation() {
char[] secret = licenseKey.getPassword(); char[] secret = licenseKey.getPassword();
char[] registrySecret = dockerPassword.getPassword();
char[] databaseSecret = databasePassword.getPassword();
char[] redisSecret = redisPassword.getPassword();
InstallationRequest request; InstallationRequest request;
try { try {
request = new InstallationRequest( request = new InstallationRequest(
installerCloudServiceUrl.getText(),
clientCode.getText(), clientCode.getText(),
new String(secret), new String(secret),
installationCode.getText(), installationCode.getText(),
installationName.getText(), installationName.getText(),
dockerImage.getText(), dockerImage.getText(),
dockerRegistry.getText(),
dockerUsername.getText(),
new String(registrySecret),
Path.of(outputDirectory.getText().trim()), 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); validateForm(request);
} catch (Exception exception) { } catch (Exception exception) {
Arrays.fill(secret, '\0'); Arrays.fill(secret, '\0');
Arrays.fill(registrySecret, '\0');
Arrays.fill(databaseSecret, '\0');
Arrays.fill(redisSecret, '\0');
showError(exception.getMessage()); showError(exception.getMessage());
return; return;
} }
Arrays.fill(secret, '\0'); Arrays.fill(secret, '\0');
Arrays.fill(registrySecret, '\0');
Arrays.fill(databaseSecret, '\0');
Arrays.fill(redisSecret, '\0');
cards.show(cardPanel, INSTALLING); cards.show(cardPanel, INSTALLING);
backToConfigurationButton.setVisible(false);
closeButton.setEnabled(false); closeButton.setEnabled(false);
resultText.setForeground(UIManager.getColor("TextArea.foreground"));
resultText.setVisible(false); resultText.setVisible(false);
new SwingWorker<InstallationResult, ProgressUpdate>() { new SwingWorker<InstallationResult, ProgressUpdate>() {
@@ -317,6 +486,7 @@ public class SwingInstallationWizard {
protected void process(java.util.List<ProgressUpdate> updates) { protected void process(java.util.List<ProgressUpdate> updates) {
ProgressUpdate update = updates.getLast(); ProgressUpdate update = updates.getLast();
progressBar.setValue(update.percentage()); progressBar.setValue(update.percentage());
progressPercentage.setText(update.percentage() + "%");
progressMessage.setText(update.message()); progressMessage.setText(update.message());
} }
@@ -326,27 +496,38 @@ public class SwingInstallationWizard {
try { try {
InstallationResult result = get(); InstallationResult result = get();
progressBar.setValue(100); progressBar.setValue(100);
progressPercentage.setText("100%");
progressMessage.setText("Installation completed"); progressMessage.setText("Installation completed");
resultText.setText( resultText.setText(
"Installation ID: " + result.installationId() "Installation ID: " + result.installationId()
+ "\nClient ID: " + result.clientId() + "\nClient ID: " + result.clientId()
+ "\nService: " + result.serviceName()
+ "\nStatus: " + result.serviceStatus()
+ "\nService URL: " + result.serviceUrl()
+ "\nOutput directory: " + result.outputDirectory() + "\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.setVisible(true);
resultText.revalidate(); resultText.revalidate();
backToConfigurationButton.setVisible(false);
} catch (Exception exception) { } catch (Exception exception) {
progressMessage.setText("Installation failed"); progressMessage.setText("Installation failed");
progressBar.setValue(0); progressBar.setValue(0);
progressPercentage.setText("0%");
resultText.setText(message(exception)); resultText.setText(message(exception));
resultText.setForeground(new Color(160, 35, 35)); resultText.setForeground(new Color(160, 35, 35));
resultText.setVisible(true); resultText.setVisible(true);
resultText.revalidate(); resultText.revalidate();
backToConfigurationButton.setVisible(true);
} }
} }
}.execute(); }.execute();
} }
private void validateForm(InstallationRequest request) { private void validateForm(InstallationRequest request) {
validateAbsoluteHttpUrl(
request.installerCloudServiceUrl(),
"Cloud service URL");
if (request.clientCode().isBlank()) { if (request.clientCode().isBlank()) {
throw new IllegalArgumentException("Client code is required."); throw new IllegalArgumentException("Client code is required.");
} }
@@ -362,9 +543,49 @@ public class SwingInstallationWizard {
if (request.dockerImage().isBlank()) { if (request.dockerImage().isBlank()) {
throw new IllegalArgumentException("Docker image is required."); 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()) { if (outputDirectory.getText().isBlank()) {
throw new IllegalArgumentException("Output directory is required."); 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() { private Map<String, String> optionalValues() {
@@ -466,6 +687,20 @@ public class SwingInstallationWizard {
return row; 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) { private JButton primaryButton(String text) {
JButton button = new JButton(text); JButton button = new JButton(text);
button.setBackground(ACCENT); button.setBackground(ACCENT);

View File

@@ -46,12 +46,18 @@ class DeploymentWriterTest {
"installation.yml", "installation.yml",
"MATRIX_INSTALLATION_CONFIG", "MATRIX_INSTALLATION_CONFIG",
"/srv/matrix/config/installation.yml", "/srv/matrix/config/installation.yml",
"8080:8080",
"/matrix/",
"/oauth2/token", "/oauth2/token",
temporaryDirectory.resolve("unused.pem"), temporaryDirectory.resolve("unused.pem"),
temporaryDirectory.resolve("login-public.pem"),
"cygnus-login-2026-01",
List.of("region")); List.of("region"));
Files.writeString(profile.loginEncryptionPublicKey(), "login-public-key");
var settings = new InstallerSettings( var settings = new InstallerSettings(
"matrix", "matrix",
URI.create("https://cloud.example.com"), URI.create("https://cloud.example.com"),
URI.create("http://host.docker.internal:8090"),
"/api/v1/installations", "/api/v1/installations",
"production", "production",
temporaryDirectory, temporaryDirectory,
@@ -71,22 +77,45 @@ class DeploymentWriterTest {
settings, settings,
profile, profile,
"registry.example.com/matrix:1.0.0", "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 config = Files.readString(output.resolve("config/installation.yml"));
String compose = Files.readString(output.resolve("compose.yml")); String compose = Files.readString(output.resolve("compose.yml"));
assertThat(config) assertThat(config)
.contains("client-id: \"matrix-client\"") .contains("client-id: \"matrix-client\"")
.contains("installation-id: \"" + installationId + "\"") .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\"") .contains("region: \"north\"")
.doesNotContain("activation-token"); .doesNotContain("activation-token");
assertThat(compose) assertThat(compose)
.contains("matrix-onprem:") .contains("matrix-onprem:")
.contains("- \"8080:8080\"")
.contains("./config:/srv/matrix/config:ro") .contains("./config:/srv/matrix/config:ro")
.contains("MATRIX_INSTALLATION_CONFIG"); .contains("MATRIX_INSTALLATION_CONFIG");
assertThat(output.resolve(".env")) assertThat(Files.readString(output.resolve(".env")))
.hasContent("MATRIX_IMAGE=registry.example.com/matrix:1.0.0\n"); .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( assertThat(output.resolve("config/machine-assertion.jwt")).hasContent(
"encrypted.assertion.value"); "encrypted.assertion.value");
assertThat(output.resolve("config/keys/client-signing-private.pem")) assertThat(output.resolve("config/keys/client-signing-private.pem"))

View File

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

View File

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

View File

@@ -16,6 +16,8 @@ class ProductProfileRegistryTest {
void appliesProductProfileOverridesWithoutInstallerBranching() throws Exception { void appliesProductProfileOverridesWithoutInstallerBranching() throws Exception {
Path publicKey = temporaryDirectory.resolve("cloud.pem"); Path publicKey = temporaryDirectory.resolve("cloud.pem");
Files.writeString(publicKey, "test"); Files.writeString(publicKey, "test");
Path loginPublicKey = temporaryDirectory.resolve("login.pem");
Files.writeString(loginPublicKey, "test-login");
Path ini = temporaryDirectory.resolve("installer.ini"); Path ini = temporaryDirectory.resolve("installer.ini");
Files.writeString(ini, """ Files.writeString(ini, """
[installer] [installer]
@@ -27,12 +29,16 @@ class ProductProfileRegistryTest {
[profile.cygnus] [profile.cygnus]
display_name=Cygnus display_name=Cygnus
assertion_encryption_public_key=cloud.pem assertion_encryption_public_key=cloud.pem
login_encryption_public_key=login.pem
login_key_id=cygnus-login-2026-01
service_name=custom-cygnus service_name=custom-cygnus
image_environment_variable=CYGNUS_IMAGE image_environment_variable=CYGNUS_IMAGE
configuration_root=cygnus configuration_root=cygnus
configuration_file_name=installation.yml configuration_file_name=installation.yml
installation_config_environment_variable=CYGNUS_INSTALLATION_CONFIG installation_config_environment_variable=CYGNUS_INSTALLATION_CONFIG
container_configuration_path=/opt/cygnus/config/installation.yml container_configuration_path=/opt/cygnus/config/installation.yml
port_mapping=8080:8080
service_path=/matrix/
token_path=/oauth2/token token_path=/oauth2/token
optional_fields=region,site_code optional_fields=region,site_code
"""); """);
@@ -45,5 +51,6 @@ class ProductProfileRegistryTest {
assertThat(profile.optionalInstallationFields()) assertThat(profile.optionalInstallationFields())
.containsExactly("region", "site_code"); .containsExactly("region", "site_code");
assertThat(profile.assertionEncryptionPublicKey()).isEqualTo(publicKey); 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/TechnobeeServiceInstallerApplication.class
com/cygnus/installer/DeploymentWriter.class com/cygnus/installer/DeploymentWriter.class
com/cygnus/installer/InstallationKeyService.class com/cygnus/installer/InstallationKeyService.class
com/cygnus/installer/DockerComposeDeploymentService.class
com/cygnus/installer/ActivationClient.class com/cygnus/installer/ActivationClient.class
com/cygnus/installer/ActivationDtos$RegistrationResponse.class com/cygnus/installer/ActivationDtos$RegistrationResponse.class
com/cygnus/installer/IniDocument.class com/cygnus/installer/IniDocument.class
com/cygnus/installer/SwingInstallationWizard$3.class com/cygnus/installer/SwingInstallationWizard$3.class
com/cygnus/installer/InstallerConfigurationException.class com/cygnus/installer/InstallerConfigurationException.class
com/cygnus/installer/DockerRegistryLoginService.class
com/cygnus/installer/SwingInstallationWizard.class com/cygnus/installer/SwingInstallationWizard.class
com/cygnus/installer/ActivationDtos.class com/cygnus/installer/ActivationDtos.class
com/cygnus/installer/DockerPrerequisiteChecker.class com/cygnus/installer/DockerPrerequisiteChecker.class
@@ -25,6 +27,7 @@ com/cygnus/installer/CommandExecutor.class
com/cygnus/installer/IniConfigurationLoader.class com/cygnus/installer/IniConfigurationLoader.class
com/cygnus/installer/InstallationService.class com/cygnus/installer/InstallationService.class
com/cygnus/installer/InstallationResult.class com/cygnus/installer/InstallationResult.class
com/cygnus/installer/RuntimeConfiguration.class
com/cygnus/installer/ActivationDtos$RegistrationRequest.class com/cygnus/installer/ActivationDtos$RegistrationRequest.class
com/cygnus/installer/ActivationDtos$ValidationRequest.class com/cygnus/installer/ActivationDtos$ValidationRequest.class
com/cygnus/installer/PrerequisiteException.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/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/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/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/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/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/IniDocument.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallationKeyPair.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/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/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/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/SwingInstallationWizard.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/main/java/com/cygnus/installer/TechnobeeServiceInstallerApplication.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/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/InstallationServiceTest.class
com/cygnus/installer/DockerPrerequisiteCheckerTest$1.class com/cygnus/installer/DockerPrerequisiteCheckerTest$1.class
com/cygnus/installer/ProductProfileRegistryTest.class com/cygnus/installer/ProductProfileRegistryTest.class
com/cygnus/installer/IniConfigurationLoaderTest.class com/cygnus/installer/IniConfigurationLoaderTest.class
com/cygnus/installer/DockerComposeDeploymentServiceTest.class
com/cygnus/installer/DeploymentWriterTest.class com/cygnus/installer/DeploymentWriterTest.class
com/cygnus/installer/DockerPrerequisiteCheckerTest.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/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/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/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/InstallationServiceTest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-installer/src/test/java/com/cygnus/installer/MachineAssertionServiceTest.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 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 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 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 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. 2. One or more tenant accounts are attached to that registration.
3. A time-bound license defines package, user, and installation limits. 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. 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 5. The Technobee Service Installer blocks unless Docker, Docker Engine, and
Compose v2 work. Compose v2 work.
6. The installer validates the key, generates an RSA-3072 installation key, and 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` 3. `POST /api/v1/admin/tenants/{tenantId}/licenses`
4. `POST /api/v1/admin/tenants/{tenantId}/licenses/{licenseId}/activation-key` 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: For replacements:
- `POST /api/v1/admin/tenants/{tenantId}/installations/{id}/decommission` - `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 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, `.env` using the selected profile's image variable. After a successful run,
review `compose.yml` and `.env`, then start with `docker compose up -d`. 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 The installer asks for the output directory. Press Enter to accept the INI
`output_directory`. It creates: `output_directory`. It creates: