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 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 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); } }