Installed GUI Integration
This commit is contained in:
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.
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.
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.
@@ -1,26 +1,83 @@
|
||||
# Cygnus installation wizard
|
||||
# Technobee Service Installer
|
||||
|
||||
The wizard validates Docker, activates a client license, generates a unique
|
||||
3072-bit installation signing key, registers the installation, and writes the
|
||||
deployment files. The license key is never persisted.
|
||||
One Swing-based Java installer supports Matrix and Cygnus through an external
|
||||
INI product profile. It never connects to PostgreSQL or Redis. The selected
|
||||
product cloud service validates the activation key and registers the
|
||||
installation.
|
||||
|
||||
## Build and run
|
||||
|
||||
```bash
|
||||
java -jar target/cygnus-installer-1.0.0-SNAPSHOT.jar
|
||||
mvn -pl cygnus-installer -am package
|
||||
TECHNOBEE_INSTALLER_CONFIG=cygnus-installer/config/matrix-installer.ini \
|
||||
java -jar cygnus-installer/target/technobee-service-installer-1.0.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
Set `CYGNUS_CLOUD_URL` before execution. The wizard asks where deployment
|
||||
files should be written and offers `CYGNUS_INSTALL_OUTPUT` as the default.
|
||||
Use `config/cygnus-installer.ini` for Cygnus. Before production use, set the
|
||||
cloud URL and the cloud assertion-encryption public-key path in the selected
|
||||
INI. Both profiles use these cloud endpoints by default:
|
||||
|
||||
The selected output directory contains:
|
||||
- `POST /api/v1/installations/activation/validate`
|
||||
- `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:
|
||||
|
||||
```bash
|
||||
TECHNOBEE_INSTALLER_CONFIG=cygnus-installer/config/matrix-installer.ini \
|
||||
java -jar cygnus-installer/target/technobee-service-installer-1.0.0-SNAPSHOT.jar --cli
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ini
|
||||
[installer]
|
||||
product=matrix
|
||||
cloud_service_url=https://cloud.example.com
|
||||
installer_api_base_path=/api/v1/installations
|
||||
environment=production
|
||||
output_directory=./matrix-installation
|
||||
|
||||
[profile.matrix]
|
||||
display_name=Matrix
|
||||
service_name=matrix-onprem
|
||||
image_environment_variable=MATRIX_IMAGE
|
||||
configuration_root=matrix
|
||||
configuration_file_name=installation.yml
|
||||
installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG
|
||||
container_configuration_path=/opt/matrix/config/installation.yml
|
||||
token_path=/oauth2/token
|
||||
assertion_encryption_public_key=/secure/cloud-assertion-public.pem
|
||||
optional_fields=
|
||||
```
|
||||
|
||||
The installer validates the product, URL, API path, environment, profile
|
||||
values, and public-key file before asking installation questions.
|
||||
|
||||
## Generated output
|
||||
|
||||
```text
|
||||
.env
|
||||
compose.yml
|
||||
config/
|
||||
installation.yml
|
||||
machine-assertion.jwt
|
||||
keys/
|
||||
client-signing-private.pem
|
||||
client-signing-public.pem
|
||||
```
|
||||
|
||||
The private key is created with owner-only permissions on POSIX systems. The
|
||||
plaintext installation license key is never written to the output directory.
|
||||
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.
|
||||
|
||||
The RSA-3072 pair is generated locally. Only the public key is sent during
|
||||
registration. The private key and encrypted machine assertion remain on
|
||||
premises and receive owner-only permissions on POSIX systems. The activation
|
||||
key is never written to disk.
|
||||
|
||||
`scripts/setup-local-communication.sh` remains a development/provisioning
|
||||
utility; it is not used by this installer.
|
||||
|
||||
18
cygnus-installer/config/cygnus-installer.ini
Normal file
18
cygnus-installer/config/cygnus-installer.ini
Normal file
@@ -0,0 +1,18 @@
|
||||
[installer]
|
||||
product=cygnus
|
||||
cloud_service_url=http://localhost:8090
|
||||
installer_api_base_path=/api/v1/installations
|
||||
environment=production
|
||||
output_directory=./cygnus-installation
|
||||
|
||||
[profile.cygnus]
|
||||
display_name=Cygnus
|
||||
service_name=cygnus-onprem
|
||||
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
|
||||
token_path=/oauth2/token
|
||||
assertion_encryption_public_key=../../config/keys/assertion-decryption-public.pem
|
||||
optional_fields=
|
||||
18
cygnus-installer/config/matrix-installer.ini
Normal file
18
cygnus-installer/config/matrix-installer.ini
Normal file
@@ -0,0 +1,18 @@
|
||||
[installer]
|
||||
product=matrix
|
||||
cloud_service_url=http://localhost:8090
|
||||
installer_api_base_path=/api/v1/installations
|
||||
environment=production
|
||||
output_directory=./matrix-installation
|
||||
|
||||
[profile.matrix]
|
||||
display_name=Matrix
|
||||
service_name=matrix-onprem
|
||||
image_environment_variable=MATRIX_IMAGE
|
||||
configuration_root=matrix
|
||||
configuration_file_name=installation.yml
|
||||
installation_config_environment_variable=MATRIX_INSTALLATION_CONFIG
|
||||
container_configuration_path=/opt/matrix/config/installation.yml
|
||||
token_path=/oauth2/token
|
||||
assertion_encryption_public_key=../../config/keys/assertion-decryption-public.pem
|
||||
optional_fields=
|
||||
@@ -9,8 +9,8 @@
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>cygnus-installer</artifactId>
|
||||
<name>Cygnus Installation Wizard</name>
|
||||
<artifactId>technobee-service-installer</artifactId>
|
||||
<name>Technobee Service Installer</name>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@@ -27,6 +27,11 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>${nimbus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux-test</artifactId>
|
||||
|
||||
@@ -8,9 +8,11 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
public class ActivationClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final InstallerSettings settings;
|
||||
|
||||
public ActivationClient(WebClient webClient) {
|
||||
public ActivationClient(WebClient webClient, InstallerSettings settings) {
|
||||
this.webClient = webClient;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public ActivationDtos.ValidationResponse validate(
|
||||
@@ -19,7 +21,7 @@ public class ActivationClient {
|
||||
UUID installationUuid,
|
||||
String installerVersion) {
|
||||
return webClient.post()
|
||||
.uri("/api/v1/installations/activation/validate")
|
||||
.uri(settings.validationPath())
|
||||
.bodyValue(new ActivationDtos.ValidationRequest(
|
||||
clientCode, licenseKey, installationUuid, installerVersion))
|
||||
.retrieve()
|
||||
@@ -35,7 +37,7 @@ public class ActivationClient {
|
||||
String softwareVersion,
|
||||
String environment) {
|
||||
return webClient.post()
|
||||
.uri("/api/v1/installations/register")
|
||||
.uri(settings.registrationPath())
|
||||
.bodyValue(new ActivationDtos.RegistrationRequest(
|
||||
validation.activationToken(),
|
||||
installationCode,
|
||||
|
||||
@@ -30,8 +30,10 @@ final class ActivationDtos {
|
||||
|
||||
record RegistrationResponse(
|
||||
UUID installationId,
|
||||
UUID installationUuid,
|
||||
UUID tenantId,
|
||||
UUID clientId,
|
||||
String clientId,
|
||||
String installationCode,
|
||||
int securityVersion,
|
||||
String status) {}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
|
||||
@SpringBootApplication
|
||||
public class CygnusInstallerApplication implements CommandLineRunner {
|
||||
|
||||
private final InstallationWizard wizard;
|
||||
|
||||
public CygnusInstallerApplication(InstallationWizard wizard) {
|
||||
this.wizard = wizard;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication application = new SpringApplication(CygnusInstallerApplication.class);
|
||||
application.setWebApplicationType(WebApplicationType.NONE);
|
||||
System.exit(SpringApplication.exit(application.run(args)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
wizard.run();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -14,55 +17,161 @@ public class DeploymentWriter {
|
||||
Path output,
|
||||
UUID installationUuid,
|
||||
String installationCode,
|
||||
String clientId,
|
||||
ActivationDtos.ValidationResponse validation,
|
||||
ActivationDtos.RegistrationResponse registration,
|
||||
InstallationKeyPair keys,
|
||||
String cloudUrl) {
|
||||
String machineAssertion,
|
||||
InstallerSettings settings,
|
||||
ProductProfile profile,
|
||||
String dockerImage,
|
||||
Map<String, String> optionalFields) {
|
||||
try {
|
||||
Path config = output.resolve("config");
|
||||
Path normalizedOutput = output.toAbsolutePath().normalize();
|
||||
Path config = normalizedOutput.resolve("config");
|
||||
Path keyDirectory = config.resolve("keys");
|
||||
Files.createDirectories(keyDirectory);
|
||||
writePrivate(keyDirectory.resolve("client-signing-private.pem"), keys.privateKeyPem());
|
||||
Files.writeString(
|
||||
keyDirectory.resolve("client-signing-public.pem"), keys.publicKeyPem());
|
||||
Files.writeString(config.resolve("installation.yml"), """
|
||||
cygnus:
|
||||
cloud-url: "%s"
|
||||
tenant-id: "%s"
|
||||
client-id: "%s"
|
||||
installation-id: "%s"
|
||||
installation-uuid: "%s"
|
||||
installation-code: "%s"
|
||||
signing-private-key: "file:./config/keys/client-signing-private.pem"
|
||||
""".formatted(
|
||||
cloudUrl,
|
||||
validation.tenantId(),
|
||||
registration.clientId(),
|
||||
registration.installationId(),
|
||||
|
||||
String privateRelative = "config/keys/client-signing-private.pem";
|
||||
String publicRelative = "config/keys/client-signing-public.pem";
|
||||
String assertionRelative = "config/machine-assertion.jwt";
|
||||
writePrivate(
|
||||
normalizedOutput.resolve(privateRelative),
|
||||
keys.privateKeyPem());
|
||||
writePublic(
|
||||
normalizedOutput.resolve(publicRelative),
|
||||
keys.publicKeyPem());
|
||||
writePrivate(
|
||||
normalizedOutput.resolve(assertionRelative),
|
||||
machineAssertion);
|
||||
writePublic(
|
||||
config.resolve(profile.configurationFileName()),
|
||||
installationConfiguration(
|
||||
installationUuid,
|
||||
installationCode));
|
||||
Files.writeString(output.resolve("compose.yml"), """
|
||||
services:
|
||||
cygnus-onprem:
|
||||
image: ${CYGNUS_IMAGE:?Set CYGNUS_IMAGE}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config:/opt/cygnus/config:ro
|
||||
environment:
|
||||
CYGNUS_INSTALLATION_CONFIG: /opt/cygnus/config/installation.yml
|
||||
""");
|
||||
return output;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not write installation files", e);
|
||||
installationCode,
|
||||
clientId,
|
||||
validation,
|
||||
registration,
|
||||
settings,
|
||||
profile,
|
||||
privateRelative,
|
||||
publicRelative,
|
||||
assertionRelative,
|
||||
optionalFields));
|
||||
writePublic(
|
||||
normalizedOutput.resolve("compose.yml"),
|
||||
compose(profile));
|
||||
writePrivate(
|
||||
normalizedOutput.resolve(".env"),
|
||||
profile.imageEnvironmentVariable() + "=" + dockerImage + "\n");
|
||||
return normalizedOutput;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not write installation files", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String installationConfiguration(
|
||||
UUID installationUuid,
|
||||
String installationCode,
|
||||
String clientId,
|
||||
ActivationDtos.ValidationResponse validation,
|
||||
ActivationDtos.RegistrationResponse registration,
|
||||
InstallerSettings settings,
|
||||
ProductProfile profile,
|
||||
String privateKey,
|
||||
String publicKey,
|
||||
String assertion,
|
||||
Map<String, String> optionalFields) {
|
||||
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(" token-url: ")
|
||||
.append(quoted(settings.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: ")
|
||||
.append(quoted(value(registration.installationId()))).append('\n')
|
||||
.append(" installation-uuid: ")
|
||||
.append(quoted(installationUuid.toString())).append('\n')
|
||||
.append(" installation-code: ").append(quoted(installationCode)).append('\n')
|
||||
.append(" environment: ").append(quoted(settings.environment())).append('\n')
|
||||
.append(" machine-assertion: ").append(quoted("file:./" + assertion)).append('\n')
|
||||
.append(" signing-private-key: ").append(quoted("file:./" + privateKey)).append('\n')
|
||||
.append(" signing-public-key: ").append(quoted("file:./" + publicKey)).append('\n');
|
||||
if (!optionalFields.isEmpty()) {
|
||||
yaml.append(" installation-fields:\n");
|
||||
optionalFields.forEach((key, value) -> yaml
|
||||
.append(" ").append(key).append(": ").append(quoted(value)).append('\n'));
|
||||
}
|
||||
return yaml.toString();
|
||||
}
|
||||
|
||||
private String compose(ProductProfile profile) {
|
||||
Path containerConfiguration = Path.of(profile.containerConfigurationPath());
|
||||
Path containerConfigDirectory = containerConfiguration.getParent();
|
||||
if (containerConfigDirectory == null) {
|
||||
throw new InstallerConfigurationException(
|
||||
"Profile container_configuration_path must include a directory");
|
||||
}
|
||||
return """
|
||||
services:
|
||||
%s:
|
||||
image: ${%s:?Set %s}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config:%s:ro
|
||||
environment:
|
||||
%s: %s
|
||||
""".formatted(
|
||||
profile.serviceName(),
|
||||
profile.imageEnvironmentVariable(),
|
||||
profile.imageEnvironmentVariable(),
|
||||
containerConfigDirectory,
|
||||
profile.installationConfigEnvironmentVariable(),
|
||||
profile.containerConfigurationPath());
|
||||
}
|
||||
|
||||
private String quoted(String value) {
|
||||
if (value == null) {
|
||||
return "\"\"";
|
||||
}
|
||||
return '"' + value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\r", "")
|
||||
.replace("\n", "\\n") + '"';
|
||||
}
|
||||
|
||||
private String value(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
private void writePrivate(Path path, String content) throws IOException {
|
||||
Files.writeString(path, content);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(
|
||||
path,
|
||||
content,
|
||||
StandardCharsets.US_ASCII,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
try {
|
||||
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Non-POSIX platforms use their native ACLs.
|
||||
// Non-POSIX platforms use native ACLs.
|
||||
}
|
||||
}
|
||||
|
||||
private void writePublic(Path path, String content) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(
|
||||
path,
|
||||
content,
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class IniConfigurationLoader {
|
||||
|
||||
private static final Set<String> PRODUCTS = Set.of("matrix", "cygnus");
|
||||
|
||||
public InstallerSettings load(Path path, String installerVersion) {
|
||||
IniDocument ini = parse(path);
|
||||
String product = ini.required("installer", "product").toLowerCase(Locale.ROOT);
|
||||
if (!PRODUCTS.contains(product)) {
|
||||
throw new InstallerConfigurationException(
|
||||
"Unsupported product '" + product + "'. Expected matrix or cygnus");
|
||||
}
|
||||
URI cloudUri = absoluteHttpUri(ini.required("installer", "cloud_service_url"));
|
||||
String apiBasePath = normalizedApiPath(
|
||||
ini.required("installer", "installer_api_base_path"));
|
||||
String environment = ini.required("installer", "environment");
|
||||
String output = ini.optional(
|
||||
"installer", "output_directory", "./" + product + "-installation");
|
||||
return new InstallerSettings(
|
||||
product,
|
||||
cloudUri,
|
||||
apiBasePath,
|
||||
environment,
|
||||
Path.of(output),
|
||||
path.toAbsolutePath().normalize().getParent(),
|
||||
installerVersion,
|
||||
ini);
|
||||
}
|
||||
|
||||
IniDocument parse(Path path) {
|
||||
if (path == null || !Files.isRegularFile(path)) {
|
||||
throw new InstallerConfigurationException(
|
||||
"Installer INI file was not found: " + path);
|
||||
}
|
||||
Map<String, Map<String, String>> sections = new LinkedHashMap<>();
|
||||
String currentSection = null;
|
||||
try {
|
||||
int lineNumber = 0;
|
||||
for (String rawLine : Files.readAllLines(path)) {
|
||||
lineNumber++;
|
||||
String line = rawLine.trim();
|
||||
if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
currentSection = line.substring(1, line.length() - 1)
|
||||
.trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (currentSection.isBlank()) {
|
||||
throw malformed(path, lineNumber);
|
||||
}
|
||||
sections.computeIfAbsent(currentSection, ignored -> new LinkedHashMap<>());
|
||||
continue;
|
||||
}
|
||||
int separator = line.indexOf('=');
|
||||
if (currentSection == null || separator < 1) {
|
||||
throw malformed(path, lineNumber);
|
||||
}
|
||||
String key = line.substring(0, separator).trim().toLowerCase(Locale.ROOT);
|
||||
String value = line.substring(separator + 1).trim();
|
||||
if (key.isBlank()) {
|
||||
throw malformed(path, lineNumber);
|
||||
}
|
||||
sections.get(currentSection).put(key, value);
|
||||
}
|
||||
return new IniDocument(sections);
|
||||
} catch (IOException exception) {
|
||||
throw new InstallerConfigurationException(
|
||||
"Unable to read installer INI file: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private URI absoluteHttpUri(String value) {
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
if (!uri.isAbsolute()
|
||||
|| !("http".equalsIgnoreCase(uri.getScheme())
|
||||
|| "https".equalsIgnoreCase(uri.getScheme()))
|
||||
|| uri.getHost() == null
|
||||
|| uri.getQuery() != null
|
||||
|| uri.getFragment() != null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return URI.create(value.replaceAll("/+$", ""));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new InstallerConfigurationException(
|
||||
"cloud_service_url must be an absolute http(s) URL");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizedApiPath(String value) {
|
||||
String path = value.trim().replaceAll("/+$", "");
|
||||
if (!path.startsWith("/")
|
||||
|| path.contains("..")
|
||||
|| path.contains("?")
|
||||
|| path.contains("#")) {
|
||||
throw new InstallerConfigurationException(
|
||||
"installer_api_base_path must be an absolute safe URL path");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private InstallerConfigurationException malformed(Path path, int line) {
|
||||
return new InstallerConfigurationException(
|
||||
"Malformed INI configuration at " + path + ":" + line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class IniDocument {
|
||||
private final Map<String, Map<String, String>> sections;
|
||||
|
||||
IniDocument(Map<String, Map<String, String>> sections) {
|
||||
Map<String, Map<String, String>> copy = new LinkedHashMap<>();
|
||||
sections.forEach((name, values) ->
|
||||
copy.put(name, Collections.unmodifiableMap(new LinkedHashMap<>(values))));
|
||||
this.sections = Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
String required(String section, String key) {
|
||||
String value = optional(section, key, null);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new InstallerConfigurationException(
|
||||
"Missing required INI setting [" + section + "] " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String optional(String section, String key, String defaultValue) {
|
||||
return sections.getOrDefault(section, Map.of()).getOrDefault(key, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface InstallationProgressListener {
|
||||
void update(int percentage, String message);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
public record InstallationRequest(
|
||||
String clientCode,
|
||||
String licenseKey,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String dockerImage,
|
||||
Path outputDirectory,
|
||||
Map<String, String> optionalFields) {
|
||||
|
||||
public InstallationRequest {
|
||||
optionalFields = optionalFields == null ? Map.of() : Map.copyOf(optionalFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
public record InstallationResult(
|
||||
UUID installationId,
|
||||
String installationCode,
|
||||
String clientId,
|
||||
Path outputDirectory) {}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class InstallationService {
|
||||
|
||||
private final DockerPrerequisiteChecker prerequisites;
|
||||
private final ActivationClient activationClient;
|
||||
private final InstallationKeyService keyService;
|
||||
private final MachineAssertionService assertionService;
|
||||
private final DeploymentWriter deploymentWriter;
|
||||
private final InstallerSettings settings;
|
||||
private final ProductProfile profile;
|
||||
|
||||
public InstallationService(
|
||||
DockerPrerequisiteChecker prerequisites,
|
||||
ActivationClient activationClient,
|
||||
InstallationKeyService keyService,
|
||||
MachineAssertionService assertionService,
|
||||
DeploymentWriter deploymentWriter,
|
||||
InstallerSettings settings,
|
||||
ProductProfile profile) {
|
||||
this.prerequisites = prerequisites;
|
||||
this.activationClient = activationClient;
|
||||
this.keyService = keyService;
|
||||
this.assertionService = assertionService;
|
||||
this.deploymentWriter = deploymentWriter;
|
||||
this.settings = settings;
|
||||
this.profile = profile;
|
||||
}
|
||||
|
||||
public void verifyPrerequisites() {
|
||||
prerequisites.verify();
|
||||
}
|
||||
|
||||
public InstallationResult install(
|
||||
InstallationRequest request,
|
||||
InstallationProgressListener progress) {
|
||||
validate(request);
|
||||
progress.update(5, "Checking Docker prerequisites");
|
||||
prerequisites.verify();
|
||||
|
||||
UUID installationUuid = UUID.randomUUID();
|
||||
progress.update(20, "Validating activation key");
|
||||
var validation = activationClient.validate(
|
||||
request.clientCode().trim(),
|
||||
request.licenseKey(),
|
||||
installationUuid,
|
||||
settings.installerVersion());
|
||||
|
||||
progress.update(40, "Generating local RSA signing keys");
|
||||
var keys = keyService.generate();
|
||||
|
||||
progress.update(58, "Registering installation with "
|
||||
+ profile.displayName() + " cloud service");
|
||||
var registration = activationClient.register(
|
||||
validation,
|
||||
request.installationCode().trim(),
|
||||
request.installationName().trim(),
|
||||
keys.publicKeyPem(),
|
||||
settings.installerVersion(),
|
||||
settings.environment());
|
||||
String clientId = registration.clientId() == null
|
||||
|| registration.clientId().isBlank()
|
||||
? validation.tenantSlug()
|
||||
: registration.clientId();
|
||||
|
||||
progress.update(75, "Creating encrypted machine assertion");
|
||||
String assertion = assertionService.generate(
|
||||
clientId,
|
||||
request.installationCode().trim(),
|
||||
settings.cloudServiceUrl() + profile.tokenPath(),
|
||||
keys,
|
||||
profile);
|
||||
|
||||
progress.update(88, "Writing on-premises deployment files");
|
||||
Path output = deploymentWriter.write(
|
||||
request.outputDirectory().toAbsolutePath().normalize(),
|
||||
installationUuid,
|
||||
request.installationCode().trim(),
|
||||
clientId,
|
||||
validation,
|
||||
registration,
|
||||
keys,
|
||||
assertion,
|
||||
settings,
|
||||
profile,
|
||||
request.dockerImage().trim(),
|
||||
request.optionalFields());
|
||||
progress.update(100, "Installation package created successfully");
|
||||
return new InstallationResult(
|
||||
registration.installationId(),
|
||||
request.installationCode().trim(),
|
||||
clientId,
|
||||
output);
|
||||
}
|
||||
|
||||
private void validate(InstallationRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Installation request is required");
|
||||
}
|
||||
required(request.clientCode(), "Client code");
|
||||
required(request.licenseKey(), "License key");
|
||||
required(request.installationCode(), "Installation code");
|
||||
required(request.installationName(), "Installation name");
|
||||
required(request.dockerImage(), "Docker image");
|
||||
if (!request.dockerImage().matches(
|
||||
"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,254}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Docker image contains invalid characters");
|
||||
}
|
||||
if (request.outputDirectory() == null) {
|
||||
throw new IllegalArgumentException("Output directory is required");
|
||||
}
|
||||
}
|
||||
|
||||
private void required(String value, String label) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(label + " is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,31 @@ package com.cygnus.installer;
|
||||
|
||||
import java.io.Console;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Scanner;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class InstallationWizard {
|
||||
|
||||
private final DockerPrerequisiteChecker prerequisites;
|
||||
private final ActivationClient activationClient;
|
||||
private final InstallationKeyService keyService;
|
||||
private final DeploymentWriter deploymentWriter;
|
||||
private final InstallerProperties properties;
|
||||
private final InstallationService installationService;
|
||||
private final InstallerSettings settings;
|
||||
private final ProductProfile profile;
|
||||
|
||||
public InstallationWizard(
|
||||
DockerPrerequisiteChecker prerequisites,
|
||||
ActivationClient activationClient,
|
||||
InstallationKeyService keyService,
|
||||
DeploymentWriter deploymentWriter,
|
||||
InstallerProperties properties) {
|
||||
this.prerequisites = prerequisites;
|
||||
this.activationClient = activationClient;
|
||||
this.keyService = keyService;
|
||||
this.deploymentWriter = deploymentWriter;
|
||||
this.properties = properties;
|
||||
InstallationService installationService,
|
||||
InstallerSettings settings,
|
||||
ProductProfile profile) {
|
||||
this.installationService = installationService;
|
||||
this.settings = settings;
|
||||
this.profile = profile;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
System.out.println("Cygnus installation wizard");
|
||||
prerequisites.verify();
|
||||
System.out.println("Technobee Service Installer");
|
||||
System.out.println("Selected product: " + profile.displayName());
|
||||
installationService.verifyPrerequisites();
|
||||
System.out.println("Docker prerequisites: OK");
|
||||
|
||||
Console console = System.console();
|
||||
@@ -39,34 +35,44 @@ public class InstallationWizard {
|
||||
String licenseKey = secret(console, scanner, "License key");
|
||||
String installationCode = prompt(console, scanner, "Installation code");
|
||||
String installationName = prompt(console, scanner, "Installation name");
|
||||
String environment = prompt(console, scanner, "Environment (prod/uat/dev)");
|
||||
String dockerImage = prompt(
|
||||
console,
|
||||
scanner,
|
||||
profile.displayName() + " Docker image");
|
||||
Map<String, String> optionalFields = promptOptionalFields(console, scanner);
|
||||
String outputDirectory = promptWithDefault(
|
||||
console,
|
||||
scanner,
|
||||
"Output directory",
|
||||
properties.outputDirectory());
|
||||
settings.defaultOutputDirectory().toString());
|
||||
|
||||
UUID installationUuid = UUID.randomUUID();
|
||||
var validation = activationClient.validate(
|
||||
clientCode, licenseKey, installationUuid, properties.version());
|
||||
var keys = keyService.generate();
|
||||
var registration = activationClient.register(
|
||||
validation,
|
||||
InstallationResult result = installationService.install(
|
||||
new InstallationRequest(
|
||||
clientCode,
|
||||
licenseKey,
|
||||
installationCode,
|
||||
installationName,
|
||||
keys.publicKeyPem(),
|
||||
properties.version(),
|
||||
environment);
|
||||
Path output = deploymentWriter.write(
|
||||
dockerImage,
|
||||
Path.of(outputDirectory).toAbsolutePath().normalize(),
|
||||
installationUuid,
|
||||
installationCode,
|
||||
validation,
|
||||
registration,
|
||||
keys,
|
||||
properties.cloudUrl());
|
||||
System.out.println("Installation registered: " + registration.installationId());
|
||||
System.out.println("Deployment files created at: " + output);
|
||||
optionalFields),
|
||||
(percentage, message) ->
|
||||
System.out.println("[" + percentage + "%] " + message));
|
||||
System.out.println("Installation registered: " + result.installationId());
|
||||
System.out.println("Deployment files created at: " + result.outputDirectory());
|
||||
}
|
||||
|
||||
private Map<String, String> promptOptionalFields(
|
||||
Console console, Scanner scanner) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
for (String field : profile.optionalInstallationFields()) {
|
||||
values.put(field, prompt(console, scanner, displayLabel(field)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String displayLabel(String field) {
|
||||
String words = field.replace('_', ' ').replace('-', ' ');
|
||||
return Character.toUpperCase(words.charAt(0)) + words.substring(1);
|
||||
}
|
||||
|
||||
private String prompt(Console console, Scanner scanner, String label) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "technobee.installer")
|
||||
public record InstallerBootstrapProperties(String config, String version) {}
|
||||
@@ -1,16 +1,35 @@
|
||||
package com.cygnus.installer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(InstallerProperties.class)
|
||||
@EnableConfigurationProperties(InstallerBootstrapProperties.class)
|
||||
public class InstallerConfiguration {
|
||||
|
||||
@Bean
|
||||
WebClient installerWebClient(InstallerProperties properties) {
|
||||
return WebClient.builder().baseUrl(properties.cloudUrl()).build();
|
||||
InstallerSettings installerSettings(
|
||||
InstallerBootstrapProperties bootstrap,
|
||||
IniConfigurationLoader loader) {
|
||||
return loader.load(
|
||||
Path.of(bootstrap.config()).toAbsolutePath().normalize(),
|
||||
bootstrap.version());
|
||||
}
|
||||
|
||||
@Bean
|
||||
ProductProfile productProfile(
|
||||
InstallerSettings settings,
|
||||
ProductProfileRegistry registry) {
|
||||
return registry.resolve(settings);
|
||||
}
|
||||
|
||||
@Bean
|
||||
WebClient installerWebClient(InstallerSettings settings) {
|
||||
return WebClient.builder()
|
||||
.baseUrl(settings.cloudServiceUrl().toString())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user