Docker installer workflow and licensing flow done - docker container is working fine
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "\"\"";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
cygnus-onprem-app/.dockerignore
Normal file
2
cygnus-onprem-app/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!target/matrix.war
|
||||
21
cygnus-onprem-app/Dockerfile
Normal file
21
cygnus-onprem-app/Dockerfile
Normal 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"]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user