286 lines
12 KiB
Java
286 lines
12 KiB
Java
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;
|
|
|
|
@Service
|
|
public class DeploymentWriter {
|
|
|
|
public Path write(
|
|
Path output,
|
|
UUID installationUuid,
|
|
String installationCode,
|
|
String clientId,
|
|
ActivationDtos.ValidationResponse validation,
|
|
ActivationDtos.RegistrationResponse registration,
|
|
InstallationKeyPair keys,
|
|
String machineAssertion,
|
|
InstallerSettings settings,
|
|
ProductProfile profile,
|
|
String dockerImage,
|
|
Map<String, String> optionalFields,
|
|
RuntimeConfiguration runtime) {
|
|
try {
|
|
Path normalizedOutput = output.toAbsolutePath().normalize();
|
|
Path config = normalizedOutput.resolve("config");
|
|
protectCloudConfiguration(config, profile);
|
|
Path keyDirectory = config.resolve("keys");
|
|
Files.createDirectories(keyDirectory);
|
|
|
|
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());
|
|
String loginPublicRelative = "config/keys/login-public.pem";
|
|
writePublic(
|
|
normalizedOutput.resolve(loginPublicRelative),
|
|
Files.readString(profile.loginEncryptionPublicKey()));
|
|
writePrivate(
|
|
normalizedOutput.resolve(assertionRelative),
|
|
machineAssertion);
|
|
writePublic(
|
|
config.resolve(profile.configurationFileName()),
|
|
installationConfiguration(
|
|
installationUuid,
|
|
installationCode,
|
|
clientId,
|
|
validation,
|
|
registration,
|
|
settings,
|
|
profile,
|
|
privateRelative,
|
|
publicRelative,
|
|
assertionRelative,
|
|
optionalFields,
|
|
runtime));
|
|
writePublic(
|
|
normalizedOutput.resolve("compose.yml"),
|
|
compose(profile));
|
|
writePrivate(
|
|
normalizedOutput.resolve(".env"),
|
|
environment(
|
|
profile,
|
|
dockerImage,
|
|
clientId,
|
|
installationCode,
|
|
settings,
|
|
runtime));
|
|
return normalizedOutput;
|
|
} catch (IOException exception) {
|
|
throw new IllegalStateException("Could not write installation files", exception);
|
|
}
|
|
}
|
|
|
|
private void protectCloudConfiguration(Path deploymentConfig, ProductProfile profile) {
|
|
if (isInside(deploymentConfig, profile.assertionEncryptionPublicKey())
|
|
|| isInside(deploymentConfig, profile.loginEncryptionPublicKey())) {
|
|
throw new IllegalArgumentException(
|
|
"Refusing to write deployment files over the cloud-service "
|
|
+ "configuration directory");
|
|
}
|
|
}
|
|
|
|
private boolean isInside(Path directory, Path file) {
|
|
if (file == null) {
|
|
return false;
|
|
}
|
|
return file.toAbsolutePath().normalize().startsWith(
|
|
directory.toAbsolutePath().normalize());
|
|
}
|
|
|
|
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,
|
|
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(cloudServiceUrl)).append('\n')
|
|
.append(" token-url: ")
|
|
.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: ")
|
|
.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
|
|
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_DATABASE", "1");
|
|
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 "\"\"";
|
|
}
|
|
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.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 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);
|
|
}
|
|
}
|