Installation automation - License key approach

This commit is contained in:
2026-07-26 17:02:16 +05:30
parent d6dc33d9b1
commit 60f5450f47
385 changed files with 4557 additions and 2228 deletions

View File

@@ -0,0 +1,26 @@
# Cygnus installation wizard
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.
```bash
java -jar target/cygnus-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.
The selected output directory contains:
```text
compose.yml
config/
installation.yml
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.

48
cygnus-installer/pom.xml Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cygnus</groupId>
<artifactId>cygnus-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cygnus-installer</artifactId>
<name>Cygnus Installation Wizard</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution><goals><goal>repackage</goal></goals></execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,50 @@
package com.cygnus.installer;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
@Service
public class ActivationClient {
private final WebClient webClient;
public ActivationClient(WebClient webClient) {
this.webClient = webClient;
}
public ActivationDtos.ValidationResponse validate(
String clientCode,
String licenseKey,
UUID installationUuid,
String installerVersion) {
return webClient.post()
.uri("/api/v1/installations/activation/validate")
.bodyValue(new ActivationDtos.ValidationRequest(
clientCode, licenseKey, installationUuid, installerVersion))
.retrieve()
.bodyToMono(ActivationDtos.ValidationResponse.class)
.block();
}
public ActivationDtos.RegistrationResponse register(
ActivationDtos.ValidationResponse validation,
String installationCode,
String installationName,
String assertionPublicKey,
String softwareVersion,
String environment) {
return webClient.post()
.uri("/api/v1/installations/register")
.bodyValue(new ActivationDtos.RegistrationRequest(
validation.activationToken(),
installationCode,
installationName,
assertionPublicKey,
softwareVersion,
environment))
.retrieve()
.bodyToMono(ActivationDtos.RegistrationResponse.class)
.block();
}
}

View File

@@ -0,0 +1,37 @@
package com.cygnus.installer;
import java.time.OffsetDateTime;
import java.util.UUID;
final class ActivationDtos {
private ActivationDtos() {}
record ValidationRequest(
String clientCode,
String licenseKey,
UUID installationUuid,
String installerVersion) {}
record ValidationResponse(
String activationToken,
OffsetDateTime expiresAt,
UUID tenantId,
String tenantSlug,
String packageCode,
int maximumInstallations) {}
record RegistrationRequest(
String activationToken,
String installationCode,
String installationName,
String assertionPublicKey,
String softwareVersion,
String environment) {}
record RegistrationResponse(
UUID installationId,
UUID tenantId,
UUID clientId,
String installationCode,
String status) {}
}

View File

@@ -0,0 +1,34 @@
package com.cygnus.installer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
@Component
public class CommandExecutor {
public CommandResult execute(List<String> command, Duration timeout) {
try {
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.start();
boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!completed) {
process.destroyForcibly();
return new CommandResult(124, "Command timed out");
}
return new CommandResult(
process.exitValue(),
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)
.trim());
} catch (IOException e) {
return new CommandResult(127, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new CommandResult(130, "Command interrupted");
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.installer;
public record CommandResult(int exitCode, String output) {
public boolean successful() {
return exitCode == 0;
}
}

View File

@@ -0,0 +1,27 @@
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();
}
}

View File

@@ -0,0 +1,68 @@
package com.cygnus.installer;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class DeploymentWriter {
public Path write(
Path output,
UUID installationUuid,
String installationCode,
ActivationDtos.ValidationResponse validation,
ActivationDtos.RegistrationResponse registration,
InstallationKeyPair keys,
String cloudUrl) {
try {
Path config = output.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(),
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);
}
}
private void writePrivate(Path path, String content) throws IOException {
Files.writeString(path, content);
try {
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// Non-POSIX platforms use their native ACLs.
}
}
}

View File

@@ -0,0 +1,31 @@
package com.cygnus.installer;
import java.time.Duration;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class DockerPrerequisiteChecker {
private static final Duration TIMEOUT = Duration.ofSeconds(15);
private final CommandExecutor executor;
public DockerPrerequisiteChecker(CommandExecutor executor) {
this.executor = executor;
}
public void verify() {
require(List.of("docker", "--version"), "Docker CLI is not installed");
require(List.of("docker", "info"), "Docker engine is not running");
require(
List.of("docker", "compose", "version"),
"Docker Compose v2 is not installed");
}
private void require(List<String> command, String message) {
CommandResult result = executor.execute(command, TIMEOUT);
if (!result.successful()) {
throw new PrerequisiteException(message + ". " + result.output());
}
}
}

View File

@@ -0,0 +1,3 @@
package com.cygnus.installer;
public record InstallationKeyPair(String privateKeyPem, String publicKeyPem) {}

View File

@@ -0,0 +1,28 @@
package com.cygnus.installer;
import java.security.KeyPairGenerator;
import java.util.Base64;
import org.springframework.stereotype.Service;
@Service
public class InstallationKeyService {
public InstallationKeyPair generate() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
var pair = generator.generateKeyPair();
return new InstallationKeyPair(
pem("PRIVATE KEY", pair.getPrivate().getEncoded()),
pem("PUBLIC KEY", pair.getPublic().getEncoded()));
} catch (Exception e) {
throw new IllegalStateException("Could not generate installation signing key", e);
}
}
private String pem(String type, byte[] encoded) {
return "-----BEGIN " + type + "-----\n"
+ Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(encoded)
+ "\n-----END " + type + "-----\n";
}
}

View File

@@ -0,0 +1,106 @@
package com.cygnus.installer;
import java.io.Console;
import java.nio.file.Path;
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;
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;
}
public void run() {
System.out.println("Cygnus installation wizard");
prerequisites.verify();
System.out.println("Docker prerequisites: OK");
Console console = System.console();
Scanner scanner = console == null ? new Scanner(System.in) : null;
String clientCode = prompt(console, scanner, "Client code");
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 outputDirectory = promptWithDefault(
console,
scanner,
"Output directory",
properties.outputDirectory());
UUID installationUuid = UUID.randomUUID();
var validation = activationClient.validate(
clientCode, licenseKey, installationUuid, properties.version());
var keys = keyService.generate();
var registration = activationClient.register(
validation,
installationCode,
installationName,
keys.publicKeyPem(),
properties.version(),
environment);
Path output = deploymentWriter.write(
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);
}
private String prompt(Console console, Scanner scanner, String label) {
String value = console != null
? console.readLine("%s: ", label)
: readScanner(scanner, label);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(label + " is required");
}
return value.trim();
}
private String secret(Console console, Scanner scanner, String label) {
if (console == null) {
return prompt(null, scanner, label);
}
char[] value = console.readPassword("%s: ", label);
if (value == null || value.length == 0) {
throw new IllegalArgumentException(label + " is required");
}
return new String(value);
}
private String promptWithDefault(
Console console, Scanner scanner, String label, String defaultValue) {
String prompt = label + " [" + defaultValue + "]";
String value = console != null
? console.readLine("%s: ", prompt)
: readScanner(scanner, prompt);
return value == null || value.isBlank() ? defaultValue : value.trim();
}
private String readScanner(Scanner scanner, String label) {
System.out.print(label + ": ");
return scanner.nextLine();
}
}

View File

@@ -0,0 +1,16 @@
package com.cygnus.installer;
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)
public class InstallerConfiguration {
@Bean
WebClient installerWebClient(InstallerProperties properties) {
return WebClient.builder().baseUrl(properties.cloudUrl()).build();
}
}

View File

@@ -0,0 +1,9 @@
package com.cygnus.installer;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "cygnus.installer")
public record InstallerProperties(
String cloudUrl,
String outputDirectory,
String version) {}

View File

@@ -0,0 +1,7 @@
package com.cygnus.installer;
public class PrerequisiteException extends RuntimeException {
public PrerequisiteException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,13 @@
spring:
main:
banner-mode: "off"
application:
name: cygnus-installer
cygnus:
installer:
cloud-url: ${CYGNUS_CLOUD_URL:http://localhost:8090}
output-directory: ${CYGNUS_INSTALL_OUTPUT:./cygnus-installation}
version: ${CYGNUS_INSTALLER_VERSION:1.0.0}
logging:
level:
root: WARN

View File

@@ -0,0 +1,32 @@
package com.cygnus.installer;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
class DockerPrerequisiteCheckerTest {
@Test
void blocksInstallationWhenDockerIsUnavailable() {
CommandExecutor executor = mock(CommandExecutor.class);
when(executor.execute(any(List.class), any(Duration.class)))
.thenReturn(new CommandResult(127, "not found"));
DockerPrerequisiteChecker checker = new DockerPrerequisiteChecker(executor);
assertThrows(PrerequisiteException.class, checker::verify);
}
@Test
void acceptsDockerEngineAndComposeV2() {
CommandExecutor executor = mock(CommandExecutor.class);
when(executor.execute(any(List.class), any(Duration.class)))
.thenReturn(new CommandResult(0, "ok"));
DockerPrerequisiteChecker checker = new DockerPrerequisiteChecker(executor);
assertDoesNotThrow(checker::verify);
}
}

View File

@@ -0,0 +1,13 @@
spring:
main:
banner-mode: "off"
application:
name: cygnus-installer
cygnus:
installer:
cloud-url: ${CYGNUS_CLOUD_URL:http://localhost:8090}
output-directory: ${CYGNUS_INSTALL_OUTPUT:./cygnus-installation}
version: ${CYGNUS_INSTALLER_VERSION:1.0.0}
logging:
level:
root: WARN

View File

@@ -0,0 +1,3 @@
artifactId=cygnus-installer
groupId=com.cygnus
version=1.0.0-SNAPSHOT

View File

@@ -0,0 +1,17 @@
com/cygnus/installer/ActivationDtos$ValidationResponse.class
com/cygnus/installer/ActivationDtos.class
com/cygnus/installer/DockerPrerequisiteChecker.class
com/cygnus/installer/CommandResult.class
com/cygnus/installer/InstallerConfiguration.class
com/cygnus/installer/CygnusInstallerApplication.class
com/cygnus/installer/InstallationWizard.class
com/cygnus/installer/CommandExecutor.class
com/cygnus/installer/DeploymentWriter.class
com/cygnus/installer/InstallerProperties.class
com/cygnus/installer/InstallationKeyService.class
com/cygnus/installer/ActivationClient.class
com/cygnus/installer/ActivationDtos$RegistrationRequest.class
com/cygnus/installer/ActivationDtos$RegistrationResponse.class
com/cygnus/installer/ActivationDtos$ValidationRequest.class
com/cygnus/installer/PrerequisiteException.class
com/cygnus/installer/InstallationKeyPair.class

View File

@@ -0,0 +1,13 @@
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/ActivationClient.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/ActivationDtos.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/CommandExecutor.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/CommandResult.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/CygnusInstallerApplication.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DeploymentWriter.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/DockerPrerequisiteChecker.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallationKeyPair.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallationKeyService.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallationWizard.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallerConfiguration.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/InstallerProperties.java
/Users/maddy/Projects/matrix/cygnus-installer/src/main/java/com/cygnus/installer/PrerequisiteException.java

View File

@@ -0,0 +1 @@
com/cygnus/installer/DockerPrerequisiteCheckerTest.class

View File

@@ -0,0 +1 @@
/Users/maddy/Projects/matrix/cygnus-installer/src/test/java/com/cygnus/installer/DockerPrerequisiteCheckerTest.java

File diff suppressed because one or more lines are too long

View File

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