ContainerFactory.java
package com.cloudforgeci.api.storage;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforge.core.annotation.DeploymentContext;
import com.cloudforge.core.annotation.SystemContext;
import com.cloudforge.core.enums.AuthMode;
import com.cloudforge.core.interfaces.ApplicationSpec;
import com.cloudforge.core.interfaces.CmsSpec;
import com.cloudforge.core.interfaces.DatabaseSpec;
import com.cloudforge.core.interfaces.OidcConfiguration;
import com.cloudforge.core.interfaces.OidcIntegration;
import com.cloudforge.core.local.DeploymentTarget;
import software.amazon.awscdk.Duration;
import software.amazon.awscdk.services.ecs.*;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationLoadBalancer;
import software.amazon.awscdk.services.iam.Effect;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.amazon.awscdk.services.logs.LogGroup;
import software.amazon.awscdk.services.secretsmanager.ISecret;
import software.amazon.awscdk.services.secretsmanager.Secret;
import software.amazon.awscdk.services.ssm.StringParameter;
import software.constructs.Construct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
public class ContainerFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(ContainerFactory.class.getName());
private final ContainerImage image;
@DeploymentContext("fqdn")
private String fqdn;
@DeploymentContext("domain")
private String domain;
@DeploymentContext("certificateArn")
private String certificateArn;
@DeploymentContext("enableSsl")
private Boolean enableSsl;
@DeploymentContext("authMode")
private AuthMode authMode;
// ========== Optional Port Configuration ==========
// These flags control which optional ports are exposed for applications
// Ports are NOT exposed by default - must be explicitly enabled
@DeploymentContext("enableAgents")
private Boolean enableAgents;
@DeploymentContext("enableSsh")
private Boolean enableSsh;
@DeploymentContext("enableSmtp")
private Boolean enableSmtp;
@DeploymentContext("enableSmtps")
private Boolean enableSmtps;
@DeploymentContext("enableClustering")
private Boolean enableClustering;
@DeploymentContext("enableDockerRegistry")
private Boolean enableDockerRegistry;
@DeploymentContext("enableMetrics")
private Boolean enableMetrics;
@DeploymentContext("enableNotary")
private Boolean enableNotary;
@DeploymentContext("enableTrivy")
private Boolean enableTrivy;
@DeploymentContext("enableSentinel")
private Boolean enableSentinel;
@DeploymentContext("managerTarget")
private DeploymentTarget managerTarget;
@DeploymentContext("enableCluster")
private Boolean enableCluster;
@SystemContext("fargateTaskDef")
private TaskDefinition fargateTaskDef;
@SystemContext("logs")
private LogGroup logs;
@SystemContext("applicationSpec")
private ApplicationSpec applicationSpec;
@SystemContext("dbConnection")
private DatabaseSpec.DatabaseConnection dbConnection;
@SystemContext("dbDatasourceParameter")
private StringParameter dbDatasourceParameter;
@SystemContext("redisSessionStoreEndpoint")
private String redisSessionStoreEndpoint;
@SystemContext("redisSessionStorePort")
private Integer redisSessionStorePort;
@SystemContext("accountCipherKeySecretArn")
private String accountCipherKeySecretArn;
@SystemContext("licenseKeySecretArn")
private String licenseKeySecretArn;
@SystemContext("autoAdminPasswordSecretArn")
private String autoAdminPasswordSecretArn;
@SystemContext("applicationOidcConfig")
private OidcConfiguration applicationOidcConfig;
@SystemContext("alb")
private ApplicationLoadBalancer alb;
@SystemContext("samlIdpMetadataUrl")
private String samlIdpMetadataUrl;
public ContainerFactory(Construct scope, String id, ContainerImage image) {
super(scope, id);
this.image = image;
// fqdn, enableSsl, authMode, and applicationSpec are automatically injected by BaseFactory
}
@Override
public void create() {
// Get configuration values from annotated fields
boolean sslEnabled = Boolean.TRUE.equals(enableSsl);
// Get application-specific environment variables from ApplicationSpec
// Each application can define its own environment configuration
Map<String, String> environment = new HashMap<>();
if (applicationSpec != null) {
// Check if application implements DatabaseSpec and has database connection
if (applicationSpec instanceof DatabaseSpec && dbConnection != null) {
// Pass database connection to applications that support it (GitLab, Mattermost, etc.)
LOG.info("Database connection available - configuring " + applicationSpec.applicationId() + " with RDS");
// Use reflection to call the 4-parameter method if it exists
try {
java.lang.reflect.Method method = applicationSpec.getClass().getMethod(
"containerEnvironmentVariables",
String.class, boolean.class, String.class, DatabaseSpec.DatabaseConnection.class
);
@SuppressWarnings("unchecked")
Map<String, String> dbEnv = (Map<String, String>) method.invoke(
applicationSpec, fqdn, sslEnabled, authMode.getValue(), dbConnection
);
environment.putAll(dbEnv);
} catch (NoSuchMethodException e) {
// CMS specifications use the DatabaseSpec databaseEnvVars contract rather
// than the newer four-argument ApplicationSpec convenience method.
// Preserve their normal application settings and add the database values.
LOG.info("Application " + applicationSpec.applicationId()
+ " uses DatabaseSpec database environment variables");
environment.putAll(applicationSpec.containerEnvironmentVariables(fqdn, sslEnabled, authMode.getValue()));
if (applicationSpec instanceof CmsSpec cmsSpec) {
environment.putAll(cmsSpec.databaseEnvVars(
dbConnection.endpoint(),
dbConnection.port(),
dbConnection.databaseName(),
dbConnection.username()));
}
} catch (Exception e) {
LOG.warning("Error calling containerEnvironmentVariables with database connection: " + e.getMessage());
environment.putAll(applicationSpec.containerEnvironmentVariables(fqdn, sslEnabled, authMode.getValue()));
}
} else if (applicationSpec instanceof DatabaseSpec) {
// No database connection - use embedded database fallback
LOG.info("No database connection - " + applicationSpec.applicationId() + " will use embedded database");
environment.putAll(applicationSpec.containerEnvironmentVariables(fqdn, sslEnabled, authMode.getValue()));
} else {
// Standard applications without database support
environment.putAll(applicationSpec.containerEnvironmentVariables(fqdn, sslEnabled, authMode.getValue()));
}
}
if (applicationSpec != null) {
String albSignerArnEnvVar = applicationSpec.albSignerArnEnvVar();
if (albSignerArnEnvVar != null && !albSignerArnEnvVar.isBlank()
&& authMode == AuthMode.ALB_OIDC && alb != null) {
environment.put(albSignerArnEnvVar, alb.getLoadBalancerArn());
}
String publicTlsTrustedEnvVar = applicationSpec.publicTlsTrustedEnvVar();
if (publicTlsTrustedEnvVar != null && !publicTlsTrustedEnvVar.isBlank()) {
boolean publiclyTrusted = com.cloudforgeci.api.core.TlsTrustEvaluator.isPubliclyTrusted(
sslEnabled, domain, fqdn, certificateArn);
environment.put(publicTlsTrustedEnvVar, String.valueOf(publiclyTrusted));
}
}
// ManagerRuntimeConfiguration.Target's own defaultTarget() is null whenever this is
// unset -- until this was added, a real AWS self-deployment never set CFC_MANAGER_TARGET
// at all, so ManagerDatabase.open()'s AWS-only fail-closed guard (a missing remote DB
// host must never silently fall back to embedded H2 on a real deploy) could never
// actually fire on the one target it exists to protect. managerTarget has no default in
// DeploymentConfig (required = false) precisely so LocalStack/MiniStack's own explicit
// "localstack"/"ministack" opt-in stays the only way this resolves to anything other than
// null -- a real AWS deployment's deployment-context.json must set managerTarget: "aws"
// for itself, same as LocalStack/MiniStack already set their own value today.
if (applicationSpec != null) {
String deploymentTargetEnvVar = applicationSpec.deploymentTargetEnvVar();
if (deploymentTargetEnvVar != null && !deploymentTargetEnvVar.isBlank() && managerTarget != null) {
environment.put(deploymentTargetEnvVar, managerTarget.configKey());
}
String[] sessionStoreEnvVars = applicationSpec.sessionStoreEnvVars();
if (sessionStoreEnvVars != null && sessionStoreEnvVars.length == 4
&& redisSessionStoreEndpoint != null && !redisSessionStoreEndpoint.isBlank()) {
// See ApplicationFactory's provisionManagerRedisSessions handling for where this
// cluster gets created. CmsObjectCacheConfiguration.createRedisCluster() builds a
// single-node CfnCacheCluster, which has no transitEncryptionEnabled property at
// all (unlike CfnReplicationGroup) — there is no TLS listener for a client to
// connect to, so the fourth env var is always told "false".
environment.put(sessionStoreEnvVars[0], "redis");
environment.put(sessionStoreEnvVars[1], redisSessionStoreEndpoint);
environment.put(sessionStoreEnvVars[2],
String.valueOf(redisSessionStorePort != null ? redisSessionStorePort : 6379));
environment.put(sessionStoreEnvVars[3], "false");
LOG.info("✅ Configured session store: mode=redis (" + redisSessionStoreEndpoint + ")");
}
}
// Collect ECS secrets (from Secrets Manager) to be mounted as environment variables
Map<String, software.amazon.awscdk.services.ecs.Secret> ecsSecrets = new HashMap<>();
// Bind a provisioned AES cipher-key secret to whichever env var name the app's own
// ApplicationSpec declares (see ApplicationFactory's provisioning above). Independent of
// database provisioning — applies whenever the secret was provisioned, embedded storage
// included.
String cipherKeySecretEnvVar = applicationSpec != null ? applicationSpec.cipherKeySecretEnvVar() : null;
if (cipherKeySecretEnvVar != null && !cipherKeySecretEnvVar.isBlank()
&& accountCipherKeySecretArn != null && !accountCipherKeySecretArn.isBlank()) {
ISecret accountCipherKeySecret =
Secret.fromSecretCompleteArn(this, "AccountCipherKeySecret", accountCipherKeySecretArn);
if (fargateTaskDef.getExecutionRole() != null) {
fargateTaskDef.getExecutionRole().addToPrincipalPolicy(
PolicyStatement.Builder.create()
.sid("AllowReadAccountCipherKey")
.effect(Effect.ALLOW)
.actions(List.of(
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
))
.resources(List.of(accountCipherKeySecretArn))
.build()
);
LOG.info(" ✅ Added IAM policy for account cipher key secret access");
}
ecsSecrets.put(cipherKeySecretEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(accountCipherKeySecret));
LOG.info(" ✅ Account cipher key mapped to " + cipherKeySecretEnvVar);
}
// Bind a provisioned deploy-time-supplied license key to whichever env var name the app's
// own ApplicationSpec declares (see ApplicationFactory's provisioning above). Independent
// of database provisioning, same as the cipher key above.
String licenseKeySecretEnvVar = applicationSpec != null ? applicationSpec.licenseKeySecretEnvVar() : null;
if (licenseKeySecretEnvVar != null && !licenseKeySecretEnvVar.isBlank()
&& licenseKeySecretArn != null && !licenseKeySecretArn.isBlank()) {
ISecret licenseKeySecret =
Secret.fromSecretCompleteArn(this, "LicenseKeySecret", licenseKeySecretArn);
if (fargateTaskDef.getExecutionRole() != null) {
fargateTaskDef.getExecutionRole().addToPrincipalPolicy(
PolicyStatement.Builder.create()
.sid("AllowReadLicenseKey")
.effect(Effect.ALLOW)
.actions(List.of(
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
))
.resources(List.of(licenseKeySecretArn))
.build()
);
LOG.info(" ✅ Added IAM policy for license key secret access");
}
ecsSecrets.put(licenseKeySecretEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(licenseKeySecret));
LOG.info(" ✅ License key mapped to " + licenseKeySecretEnvVar);
}
// Bind an application's auto-generated initial admin password (see ApplicationFactory's
// provisioning above) to whichever env var name its own ApplicationSpec declares via
// autoAdminPasswordEnvVar() (e.g. JOOMLA_ADMIN_PASSWORD). Combined with the plain admin/
// site-name variables that spec's own containerEnvironmentVariables() already sets, this
// completes the full set an application's official image needs for a non-interactive
// install — without it, its web installer's wizard, including the database-connection
// step, is left for a human.
String autoAdminPasswordEnvVar = applicationSpec == null ? null : applicationSpec.autoAdminPasswordEnvVar();
if (autoAdminPasswordEnvVar != null && !autoAdminPasswordEnvVar.isBlank()
&& autoAdminPasswordSecretArn != null && !autoAdminPasswordSecretArn.isBlank()) {
ISecret autoAdminPasswordSecret =
Secret.fromSecretCompleteArn(this, "AutoAdminPasswordSecret", autoAdminPasswordSecretArn);
if (fargateTaskDef.getExecutionRole() != null) {
fargateTaskDef.getExecutionRole().addToPrincipalPolicy(
PolicyStatement.Builder.create()
.sid("AllowReadAutoAdminPassword")
.effect(Effect.ALLOW)
.actions(List.of(
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
))
.resources(List.of(autoAdminPasswordSecretArn))
.build()
);
LOG.info(" ✅ Added IAM policy for auto-admin password secret access");
}
ecsSecrets.put(autoAdminPasswordEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(autoAdminPasswordSecret));
LOG.info(" ✅ Admin password mapped to JOOMLA_ADMIN_PASSWORD");
}
// Add database password from Secrets Manager for applications with external database
if (applicationSpec instanceof DatabaseSpec && dbConnection != null) {
LOG.info("Adding database password secret for " + applicationSpec.applicationId());
// Extract secret name from ARN
// ARN format: arn:aws:secretsmanager:region:account:secret:name-randomsuffix
String secretArn = dbConnection.passwordSecretArn();
ISecret dbSecret = Secret.fromSecretCompleteArn(this, "DatabasePasswordSecret", secretArn);
// Grant task execution role permission to read the secret
if (fargateTaskDef.getExecutionRole() != null) {
fargateTaskDef.getExecutionRole().addToPrincipalPolicy(
PolicyStatement.Builder.create()
.sid("AllowReadDatabasePassword")
.effect(Effect.ALLOW)
.actions(List.of(
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
))
.resources(List.of(secretArn))
.build()
);
LOG.info(" ✅ Added IAM policy for database password secret access");
}
// Map password to application-specific environment variable names
// Different applications expect different env var names for the database password
String appId = applicationSpec.applicationId();
String databasePasswordEnvVar = applicationSpec.databasePasswordEnvVar();
if (databasePasswordEnvVar != null && !databasePasswordEnvVar.isBlank()) {
ecsSecrets.put(databasePasswordEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to " + databasePasswordEnvVar);
} else switch (appId) {
case "gitlab":
ecsSecrets.put("GITLAB_DATABASE_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to GITLAB_DATABASE_PASSWORD");
break;
case "metabase":
ecsSecrets.put("MB_DB_PASS",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to MB_DB_PASS");
break;
case "grafana":
ecsSecrets.put("GF_DATABASE_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to GF_DATABASE_PASSWORD");
break;
case "harbor":
ecsSecrets.put("POSTGRESQL_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to POSTGRESQL_PASSWORD");
break;
case "superset":
ecsSecrets.put("SUPERSET_DATABASE_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to SUPERSET_DATABASE_PASSWORD");
break;
case "joomla":
ecsSecrets.put("JOOMLA_DB_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to JOOMLA_DB_PASSWORD");
break;
case "wordpress":
case "woocommerce":
// Same official wordpress:* image bootstrap either way (WooCommerce is a
// WordPress plugin on top, sharing WordPressApplicationSpec's env var
// conventions — see WooCommerceApplicationSpec). Falling through to the
// generic DATABASE_PASSWORD default here left WORDPRESS_DB_PASSWORD unset —
// the official image's entrypoint reads that name specifically, not
// DATABASE_PASSWORD, so it connected with no password at all and failed.
ecsSecrets.put("WORDPRESS_DB_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to WORDPRESS_DB_PASSWORD");
break;
case "mattermost-enterprise":
case "mattermost-team":
// Mattermost is distroless (Go binary, no shell) - cannot use shell variable substitution
// RdsFactory creates an SSM Parameter with the complete datasource URL
// The SSM parameter value uses CloudFormation dynamic reference to resolve the password
if (dbDatasourceParameter != null) {
// Grant read permission to the task execution role
if (fargateTaskDef.getExecutionRole() != null) {
dbDatasourceParameter.grantRead(fargateTaskDef.getExecutionRole());
}
// Inject as ECS secret from SSM Parameter Store
ecsSecrets.put("MM_SQLSETTINGS_DATASOURCE",
software.amazon.awscdk.services.ecs.Secret.fromSsmParameter(dbDatasourceParameter));
LOG.info(" ✅ Complete datasource URL mapped to MM_SQLSETTINGS_DATASOURCE from SSM Parameter");
} else {
LOG.warning(" ⚠️ Datasource SSM parameter not found - Mattermost database connection not configured");
LOG.warning(" ⚠️ Mattermost REQUIRES a database - deployment will fail without it");
}
break;
default:
// Fallback - use generic name
ecsSecrets.put("DATABASE_PASSWORD",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(dbSecret, "password"));
LOG.info(" ✅ Database password mapped to DATABASE_PASSWORD (default)");
}
}
// Add OIDC environment variables if APPLICATION_OIDC mode is enabled
if (authMode == AuthMode.APPLICATION_OIDC && applicationSpec != null && applicationSpec.supportsOidcIntegration()) {
LOG.info("ContainerFactory: application-oidc mode detected for " + applicationSpec.applicationId());
if (applicationOidcConfig != null) {
LOG.info(" ✅ applicationOidcConfig found!");
OidcIntegration oidcIntegration = applicationSpec.getOidcIntegration();
if (oidcIntegration != null) {
Map<String, String> oidcEnv = oidcIntegration.getEnvironmentVariables(applicationOidcConfig);
environment.putAll(oidcEnv);
LOG.info(" Added " + oidcEnv.size() + " OIDC environment variables for " + applicationSpec.applicationId());
// Add OIDC client secret from Secrets Manager if available
// Skip for Identity Center SAML - uses IAM authentication, not client secrets
String clientSecretArn = applicationOidcConfig.getClientSecretArn();
boolean isIdentityCenterSaml = oidcIntegration.supportsIdentityCenterSaml() &&
"identity-center".equals(applicationOidcConfig.getProviderType());
if (clientSecretArn != null && !clientSecretArn.isEmpty() && !isIdentityCenterSaml) {
LOG.info(" Mounting OIDC client secret from Secrets Manager for application: " + applicationSpec.applicationId());
// OidcConfiguration historically calls this field clientSecretArn, but
// external-provider configuration supplies a Secrets Manager *name*.
// Support both forms so application OIDC can synthesize and deploy with
// managed or pre-existing secrets.
//
// clientSecretArn.startsWith("arn:") alone is NOT enough: the Cognito
// auto-provision path (CognitoAuthenticationFactory) hands this value in
// as cognitoSecret.getSecretArn() — a CDK Token, i.e. an opaque unresolved
// placeholder string at this point in synthesis, not literally "arn:..."
// yet. startsWith() on a Token silently returns false, sending an ARN down
// the fromSecretNameV2 (bare-name) branch — which then wraps the eventual
// resolved ARN inside ANOTHER "arn:aws:secretsmanager:...:secret:" prefix,
// producing a doubled ARN that fails at deploy time with "unexpected ARN
// format" (via the ECS deployment circuit breaker). Token.isUnresolved()
// catches exactly this case — Cognito's own getSecretArn() contract always
// resolves to a complete ARN, so a Token here is routed the same way a
// literal "arn:" string already is; only a resolved, non-ARN-shaped
// literal (a hand-typed bare name in DeploymentContext) takes the
// fromSecretNameV2 branch.
boolean isCompleteArn = software.amazon.awscdk.Token.isUnresolved(clientSecretArn)
|| clientSecretArn.startsWith("arn:");
ISecret clientSecret = isCompleteArn
? Secret.fromSecretCompleteArn(this, "OidcClientSecret", clientSecretArn)
: Secret.fromSecretNameV2(this, "OidcClientSecret", clientSecretArn);
// Grant the ECS task execution role permission to read the secret
if (fargateTaskDef.getExecutionRole() != null) {
fargateTaskDef.getExecutionRole().addToPrincipalPolicy(
PolicyStatement.Builder.create()
.sid("AllowReadOidcClientSecret")
.effect(Effect.ALLOW)
.actions(List.of(
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
))
.resources(List.of(clientSecret.getSecretArn()))
.build()
);
LOG.info(" ✅ Added IAM policy for secret access");
} else {
LOG.warning(" ⚠️ Task execution role not found - cannot grant secret read permission");
}
// Add as ECS secret (mounted as environment variable at runtime)
// Map to application-specific environment variable names
// Different applications expect different env var names for OIDC client secret
String appId = applicationSpec.applicationId();
String oidcClientSecretEnvVar = applicationSpec.oidcClientSecretEnvVar();
if (oidcClientSecretEnvVar != null && !oidcClientSecretEnvVar.isBlank()) {
ecsSecrets.put(oidcClientSecretEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to " + oidcClientSecretEnvVar);
} else switch (appId) {
case "mattermost-enterprise":
// Mattermost Enterprise - native OpenID Connect
ecsSecrets.put("MM_OPENIDSETTINGS_SECRET",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to MM_OPENIDSETTINGS_SECRET");
break;
case "mattermost-team":
// Mattermost Team Edition (free) - GitLab OAuth
ecsSecrets.put("MM_GITLABSETTINGS_SECRET",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to MM_GITLABSETTINGS_SECRET");
break;
case "jenkins":
// Jenkins CasC expects JENKINS_OIDC_CLIENT_SECRET placeholder
ecsSecrets.put("JENKINS_OIDC_CLIENT_SECRET",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to JENKINS_OIDC_CLIENT_SECRET");
break;
case "gitlab":
ecsSecrets.put("GITLAB_OIDC_CLIENT_SECRET",
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to GITLAB_OIDC_CLIENT_SECRET");
break;
default:
// Generic naming: <APP>_OIDC_CLIENT_SECRET
String secretEnvVar = appId.toUpperCase() + "_OIDC_CLIENT_SECRET";
ecsSecrets.put(secretEnvVar,
software.amazon.awscdk.services.ecs.Secret.fromSecretsManager(clientSecret));
LOG.info(" ✅ OIDC client secret mapped to env var (default)");
}
} else {
LOG.warning(" ⚠️ Client secret ARN not found in OIDC config - secret will not be mounted");
}
} else {
LOG.warning(" ⚠️ OidcIntegration is null!");
}
} else {
LOG.severe(" ❌ applicationOidcConfig NOT FOUND in SystemContext!");
LOG.severe(" This means ApplicationOidcFactory did not run or failed to set the config");
}
}
// Get application-specific configuration from ApplicationSpec
String containerUser = applicationSpec != null ? applicationSpec.containerUser() : "1000:1000";
String logStreamPrefix = applicationSpec != null ? applicationSpec.applicationId() : "jenkins";
int appPort = applicationSpec != null ? applicationSpec.applicationPort() : 8080;
String containerPath = applicationSpec != null ? applicationSpec.containerDataPath() : "/var/jenkins_home";
String volumeName = applicationSpec != null ? applicationSpec.volumeName() : "jenkinsHome";
// Build container options - only set user if containerUser is not null
ContainerDefinitionOptions.Builder containerOptionsBuilder = ContainerDefinitionOptions.builder()
.containerName(getNode().getId())
.image(image)
.environment(environment.isEmpty() ? null : environment)
.secrets(ecsSecrets.isEmpty() ? null : ecsSecrets)
.logging(LogDriver.awsLogs(AwsLogDriverProps.builder()
.logGroup(logs)
.streamPrefix(logStreamPrefix).build()));
// Only set user if specified (some apps like GitLab need to run as root) -- and never for
// a privileged port (<1024), regardless of what the ApplicationSpec declares. Forcing a
// non-root user at the ECS container level applies from PID 1 onward, bypassing the
// image's own normal root-then-drop-privileges startup (Apache's docker-entrypoint starts
// as root specifically so its master process can bind a privileged port, then forks
// www-data workers) -- without this, an app whose port is privileged fails outright with
// "Permission denied: could not bind to address" the moment containerUser forces it to
// start as a non-root UID from the beginning. Granting the Linux capability that would
// close this gap (NET_BIND_SERVICE) isn't an option: AWS Fargate flatly rejects it at
// CreateTaskDefinition ("NET_BIND_SERVICE is not allowed on Fargate"), a hard platform
// ceiling with no capability-grant path around it on the launch type every app in this
// catalog actually uses. So for a privileged port, root is the only working option here --
// which is also just letting the image run the way its own Dockerfile already intends
// (root-then-drop), not a step down in security posture from some other achievable state.
boolean privilegedPort = appPort < 1024;
if (containerUser != null && !privilegedPort) {
containerOptionsBuilder.user(containerUser);
}
// Log container configuration
if (!ecsSecrets.isEmpty()) {
LOG.info("Container will have " + ecsSecrets.size() + " secret(s) mounted as environment variables");
}
// Set by the OIDC entrypoint-wrapper block below whenever it configures its own
// command — guards the general-purpose CmsSpec.containerCommand() hook further down
// from clobbering it. OIDC's wrapper is app-specific too; the two aren't composed.
boolean commandConfigured = false;
// Track whether we need an init container for SAML certificate
// Uses OidcIntegration interface methods for application-specific paths
boolean needsSamlCertInit = false;
String samlMetadataUrl = null;
String samlCertVolumeName = "saml-cert";
String samlCertMountPath = null;
String samlCertPath = null;
String samlCertEnvVar = null;
// Add entrypoint/command override for APPLICATION_OIDC mode
// This creates the OIDC config file before starting the application
if (authMode == AuthMode.APPLICATION_OIDC && applicationSpec != null && applicationSpec.supportsOidcIntegration()) {
LOG.info("ContainerFactory: Configuring OIDC entrypoint wrapper...");
if (applicationOidcConfig != null) {
OidcIntegration oidcIntegration = applicationSpec.getOidcIntegration();
if (oidcIntegration != null) {
String configFileContent = oidcIntegration.getConfigurationFile(applicationOidcConfig);
String configFilePath = oidcIntegration.getConfigurationFilePath();
String startupCommand = oidcIntegration.getContainerStartupCommand();
LOG.info(" Config file path: " + configFilePath);
LOG.info(" Startup command: " + startupCommand);
LOG.info(" Config file length: " + (configFileContent != null ? configFileContent.length() + " chars" : "NULL"));
LOG.info(" Is distroless: " + oidcIntegration.isDistroless());
// Check if we need to write a config file at startup
// Some apps (Metabase) use environment variables only - no config file needed
boolean needsConfigFile = configFileContent != null && configFilePath != null;
// Check if the container is distroless (no shell available) OR doesn't need config file
if (oidcIntegration.isDistroless() || !needsConfigFile) {
// Distroless containers have no /bin/sh - cannot use shell wrapper
// Or app uses environment variables only (like Metabase) - no config file to write
if (oidcIntegration.isDistroless()) {
LOG.info(" ⚠️ Distroless container detected - skipping shell wrapper");
} else {
LOG.info(" ⚠️ No config file needed - using environment variables only");
}
LOG.info(" Configuration will be done via environment variables only");
// For distroless, just set the startup command directly (no shell wrapper)
// All configuration is already in environment variables
if (startupCommand != null) {
List<String> command = List.of(startupCommand);
containerOptionsBuilder.command(command);
commandConfigured = true;
LOG.info("✅ Configured direct startup command for distroless " + applicationSpec.applicationId());
} else {
LOG.info("✅ Using default container entrypoint for " + applicationSpec.applicationId());
}
} else {
// Normal container with shell - use wrapper to write config file
// Create startup command that writes OIDC config and starts application
// Uses sh -c to execute multi-line script
// Note: No chown needed - container runs as containerUser so files created are already owned correctly
String fullCommand = String.format(
"mkdir -p $(dirname %s) && " +
"cat > %s <<'EOFCASC'\n%s\nEOFCASC\n" +
"%s",
configFilePath,
configFilePath,
configFileContent,
startupCommand
);
LOG.info(" Full command length: " + fullCommand.length() + " chars");
LOG.info(" Command preview (first 800 chars):");
LOG.info(fullCommand.substring(0, Math.min(800, fullCommand.length())));
List<String> command = List.of(
"/bin/sh",
"-c",
fullCommand
);
containerOptionsBuilder.command(command);
commandConfigured = true;
LOG.info("✅ Configured OIDC entrypoint wrapper for " + applicationSpec.applicationId());
}
} else {
LOG.severe("❌ OidcIntegration is NULL - cannot configure entrypoint!");
}
} else {
LOG.severe("❌ applicationOidcConfig NOT PRESENT - entrypoint wrapper NOT configured!");
}
}
// CmsSpec's own startup customization (e.g. phpBB reconfiguring Apache's listen port
// and downloading its source on first run) — independent of OIDC, so it applies
// regardless of authMode, but only when the OIDC branch above hasn't already claimed
// the container's command. Without a caller for this hook, an app implementing
// CmsSpec.containerCommand() (e.g. PhpBBApplicationSpec) would silently get the stock
// image entrypoint instead, starting Apache on its image's default port 80 while every
// other part of the stack (ALB target group, container port mappings, generated URLs)
// assumes the app spec's declared applicationPort().
if (!commandConfigured && applicationSpec instanceof CmsSpec cmsSpec) {
List<String> command = cmsSpec.containerCommand();
if (command != null && !command.isEmpty()) {
containerOptionsBuilder.command(command);
LOG.info("✅ Configured containerCommand() startup script for " + applicationSpec.applicationId());
}
}
// Check if we need SAML certificate init container
// Uses OidcIntegration.needsSamlCertificate() to determine if app needs SAML cert
if (authMode == AuthMode.APPLICATION_OIDC && applicationSpec != null && applicationSpec.supportsOidcIntegration()) {
OidcIntegration oidcIntegration = applicationSpec.getOidcIntegration();
if (oidcIntegration != null && oidcIntegration.needsSamlCertificate()) {
// Get the SAML metadata URL from annotated field (set by CognitoSamlFactory)
if (samlIdpMetadataUrl != null) {
samlMetadataUrl = samlIdpMetadataUrl;
needsSamlCertInit = true;
// Get application-specific SAML certificate paths from OidcIntegration
samlCertMountPath = oidcIntegration.getSamlCertificateMountPath();
samlCertPath = oidcIntegration.getSamlCertificateFilePath();
samlCertEnvVar = oidcIntegration.getSamlCertificateEnvVar();
LOG.info(applicationSpec.applicationId() + " SAML: Will create init container to fetch IdP certificate");
LOG.info(" Metadata URL: " + samlMetadataUrl);
LOG.info(" Certificate mount path: " + samlCertMountPath);
LOG.info(" Certificate file path: " + samlCertPath);
// Add the certificate file path to environment using app-specific env var
if (samlCertEnvVar != null) {
environment.put(samlCertEnvVar, samlCertPath);
LOG.info(" Certificate env var: " + samlCertEnvVar + "=" + samlCertPath);
}
// Add a volume for the SAML certificate
fargateTaskDef.addVolume(software.amazon.awscdk.services.ecs.Volume.builder()
.name(samlCertVolumeName)
.build());
LOG.info(" ✅ Added shared volume '" + samlCertVolumeName + "' for SAML certificate");
} else {
LOG.warning(applicationSpec.applicationId() + " SAML: No metadata URL available - certificate cannot be fetched");
}
}
}
// Create init container for SAML certificate if needed
final String finalSamlMetadataUrl = samlMetadataUrl;
if (needsSamlCertInit && finalSamlMetadataUrl != null) {
// Create init container that fetches SAML IdP certificate from metadata URL
// Uses alpine/curl image with xmllint to parse the metadata XML
String initCommand = String.format(
"set -e; " +
"echo 'Fetching SAML IdP certificate from metadata URL...'; " +
"curl -s '%s' > /tmp/metadata.xml; " +
"echo 'Extracting X509Certificate from metadata...'; " +
// Extract the certificate using grep and sed (simpler than xmllint)
"grep -oP '(?<=<ds:X509Certificate>)[^<]+' /tmp/metadata.xml | head -1 > /tmp/cert.b64; " +
"echo '-----BEGIN CERTIFICATE-----' > %s; " +
"cat /tmp/cert.b64 >> %s; " +
"echo '' >> %s; " + // Ensure newline before END
"echo '-----END CERTIFICATE-----' >> %s; " +
"chmod 644 %s; " +
"echo 'SAML IdP certificate saved to %s'; " +
"cat %s",
finalSamlMetadataUrl,
samlCertPath, samlCertPath, samlCertPath, samlCertPath, samlCertPath,
samlCertPath, samlCertPath
);
ContainerDefinition initContainer = fargateTaskDef.addContainer("SamlCertInit",
ContainerDefinitionOptions.builder()
.containerName("saml-cert-init")
.image(ContainerImage.fromRegistry("alpine/curl:latest"))
.essential(false) // Init container - not essential after completion
.command(List.of("/bin/sh", "-c", initCommand))
.logging(LogDriver.awsLogs(AwsLogDriverProps.builder()
.logGroup(logs)
.streamPrefix("saml-init")
.build()))
.build());
// Mount the shared volume for the certificate
initContainer.addMountPoints(MountPoint.builder()
.containerPath(samlCertMountPath)
.sourceVolume(samlCertVolumeName)
.readOnly(false)
.build());
LOG.info(" ✅ Created SAML certificate init container");
}
ContainerDefinition container = fargateTaskDef.addContainer(getNode().getId() + "Container",
containerOptionsBuilder.build());
// Add dependency on init container after main container is created
final String finalSamlCertMountPath = samlCertMountPath;
if (needsSamlCertInit && finalSamlMetadataUrl != null) {
// Get the init container we created earlier
ContainerDefinition initContainer = fargateTaskDef.findContainer("saml-cert-init");
if (initContainer != null) {
container.addContainerDependencies(ContainerDependency.builder()
.container(initContainer)
.condition(ContainerDependencyCondition.SUCCESS)
.build());
LOG.info(" ✅ Configured main container to depend on SAML init container");
// Also mount the shared volume on the main container
// Use the same mount path as init container
container.addMountPoints(MountPoint.builder()
.containerPath(finalSamlCertMountPath)
.sourceVolume(samlCertVolumeName)
.readOnly(true) // Main container only reads the certificate
.build());
LOG.info(" ✅ Mounted SAML certificate volume at " + finalSamlCertMountPath);
}
}
container.addPortMappings(PortMapping
.builder()
.containerPort(appPort)
.build());
// Add optional port mappings based on deployment configuration
// These ports are NOT exposed by default - must be explicitly enabled
if (applicationSpec != null) {
for (ApplicationSpec.OptionalPort optionalPort : applicationSpec.optionalPorts()) {
if (isOptionalPortEnabled(optionalPort.configKey())) {
container.addPortMappings(PortMapping.builder()
.containerPort(optionalPort.port())
.protocol(optionalPort.protocol().equals("udp") ? Protocol.UDP : Protocol.TCP)
.build());
LOG.info(" ✅ Added optional port mapping: " + optionalPort.port() + "/" +
optionalPort.protocol() + " (" + optionalPort.service() + ")");
}
}
}
container.addMountPoints(MountPoint
.builder()
.containerPath(containerPath)
.sourceVolume(volumeName)
.readOnly(false)
.build());
// Same-task sidecar containers (see ApplicationSpec.SidecarContainer's own javadoc) —
// empty by default, opt-in per application. Always-running, essential=true:
// unlike the SAML init container above, a sidecar has no exit condition, so its own
// death is treated the same as the main container's — ECS replaces the whole task.
if (applicationSpec != null) {
for (ApplicationSpec.SidecarContainer sidecar : applicationSpec.sidecarContainers()) {
ContainerDefinition sidecarContainer = fargateTaskDef.addContainer(sidecar.containerName(),
ContainerDefinitionOptions.builder()
.containerName(sidecar.containerName())
.image(ContainerImage.fromRegistry(sidecar.image()))
.essential(true)
.environment(sidecar.environment())
.healthCheck(HealthCheck.builder()
.command(sidecar.healthCheckCommand())
.interval(Duration.seconds(30))
.timeout(Duration.seconds(5))
.retries(3)
.startPeriod(Duration.seconds(10))
.build())
.logging(LogDriver.awsLogs(AwsLogDriverProps.builder()
.logGroup(logs)
.streamPrefix(sidecar.containerName())
.build()))
.build());
sidecarContainer.addPortMappings(PortMapping.builder()
.containerPort(sidecar.containerPort())
.build());
// Main container waits for the sidecar to report HEALTHY (not just running)
// before ECS starts it, since the main container's first request to it would
// otherwise race the sidecar's own JVM/HTTP-server startup.
container.addContainerDependencies(ContainerDependency.builder()
.container(sidecarContainer)
.condition(ContainerDependencyCondition.HEALTHY)
.build());
LOG.info(" ✅ Added sidecar container '" + sidecar.containerName() + "' on port " + sidecar.containerPort());
}
}
ctx.container.set(container);
}
/**
* Check if an optional port is enabled based on the config key.
* Maps config keys like "enableSmtp" to the corresponding annotated field.
*/
private boolean isOptionalPortEnabled(String configKey) {
return switch (configKey) {
case "enableAgents" -> Boolean.TRUE.equals(enableAgents);
case "enableSsh" -> Boolean.TRUE.equals(enableSsh);
case "enableSmtp" -> Boolean.TRUE.equals(enableSmtp);
case "enableSmtps" -> Boolean.TRUE.equals(enableSmtps);
case "enableClustering" -> Boolean.TRUE.equals(enableClustering);
case "enableDockerRegistry" -> Boolean.TRUE.equals(enableDockerRegistry);
case "enableMetrics" -> Boolean.TRUE.equals(enableMetrics);
case "enableNotary" -> Boolean.TRUE.equals(enableNotary);
case "enableTrivy" -> Boolean.TRUE.equals(enableTrivy);
case "enableSentinel" -> Boolean.TRUE.equals(enableSentinel);
case "enableCluster" -> Boolean.TRUE.equals(enableCluster);
default -> {
LOG.warning("Unknown optional port config key: " + configKey);
yield false;
}
};
}
}