MetabaseApplicationSpec.java
package com.cloudforgeci.api.application.analytics;
import com.cloudforge.core.annotation.ApplicationPlugin;
import com.cloudforge.core.interfaces.ApplicationSpec;
import com.cloudforge.core.interfaces.DatabaseSpec;
import com.cloudforge.core.interfaces.DatabaseSpec.DatabaseConnection;
import com.cloudforge.core.interfaces.DatabaseSpec.DatabaseRequirement;
import com.cloudforge.core.interfaces.Ec2Context;
import com.cloudforge.core.interfaces.OidcIntegration;
import com.cloudforge.core.interfaces.UserDataBuilder;
import com.cloudforge.core.oidc.MetabaseSamlIntegration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Metabase Business Intelligence ApplicationSpec implementation.
*
* <p>Metabase is an open-source business intelligence tool for interactive dashboards
* and data visualization.</p>
*
* <p><b>Database Configuration:</b></p>
* <ul>
* <li><b>Development/Staging:</b> H2 embedded database (single instance only)</li>
* <li><b>Production:</b> Use RDS PostgreSQL/MySQL for multi-instance deployments</li>
* </ul>
*
* <p><b>IMPORTANT:</b> H2 database cannot support multiple instances due to file locking.
* For high availability, configure {@code maxInstanceCapacity=1} or use external RDS database.</p>
*
* <p><b>RDS Configuration (Production):</b></p>
* Set environment variables:
* <ul>
* <li>MB_DB_TYPE=postgres</li>
* <li>MB_DB_HOST=your-rds-endpoint.rds.amazonaws.com</li>
* <li>MB_DB_PORT=5432</li>
* <li>MB_DB_DBNAME=metabase</li>
* <li>MB_DB_USER=metabase_user</li>
* <li>MB_DB_PASS=from-secrets-manager</li>
* </ul>
*
* <p><b>Use Cases:</b></p>
* <ul>
* <li>SOC2: Audit log analytics and security metrics</li>
* <li>GDPR: Data subject access request reporting</li>
* <li>PCI-DSS: Transaction monitoring dashboards</li>
* <li>Fintech: Fraud detection, revenue metrics, compliance reporting</li>
* </ul>
*
* @see <a href="https://www.metabase.com/docs/latest/">Metabase Documentation</a>
*/
@ApplicationPlugin(
value = "metabase",
category = "analytics",
displayName = "Metabase",
description = "Business intelligence and analytics platform",
defaultCpu = 1024,
defaultMemory = 2048,
defaultInstanceType = "t3.small",
supportsFargate = true,
supportsEc2 = true,
supportsOidc = true,
supportsDatabase = true,
requiresDatabase = false
)
public class MetabaseApplicationSpec implements ApplicationSpec, DatabaseSpec {
private static final String APPLICATION_ID = "metabase";
// Use Enterprise image - defaults to OSS features without license token
// License token unlocks Pro/Enterprise features (SAML, advanced permissions, audit logging)
private static final String DEFAULT_IMAGE = "metabase/metabase-enterprise:latest";
private static final int APPLICATION_PORT = 3000;
/**
* The Secrets Manager secret name for Metabase Pro/Enterprise license token.
* Store the license token in this secret to unlock SAML, advanced permissions, audit logging, etc.
* The secret can be updated in AWS Console without redeploying the stack.
*/
public static final String LICENSE_SECRET_SUFFIX = "/metabase/license-token";
private static final String CONTAINER_DATA_PATH = "/metabase-data";
private static final String EFS_DATA_PATH = "/metabase";
private static final String VOLUME_NAME = "metabaseData";
private static final String CONTAINER_USER = "2000:2000"; // metabase user
private static final String EFS_PERMISSIONS = "755";
private static final String EBS_DEVICE_NAME = "/dev/xvdh";
private static final String EC2_DATA_PATH = "/opt/metabase/data";
private static final List<String> EC2_LOG_PATHS = List.of(
"/opt/metabase/logs/metabase.log",
"/var/log/userdata.log"
);
@Override
public String applicationId() {
return APPLICATION_ID;
}
@Override
public String defaultContainerImage() {
return DEFAULT_IMAGE;
}
@Override
public int applicationPort() {
return APPLICATION_PORT;
}
@Override
public String containerDataPath() {
return CONTAINER_DATA_PATH;
}
@Override
public String efsDataPath() {
return EFS_DATA_PATH;
}
@Override
public String volumeName() {
return VOLUME_NAME;
}
@Override
public String containerUser() {
return CONTAINER_USER;
}
@Override
public DatabaseRequirement databaseRequirement() {
// Metabase can use H2 (embedded) OR PostgreSQL/MySQL (RDS)
return DatabaseRequirement.optional("postgres", "15")
.withInstanceClass("db.t3.small")
.withStorage(20)
.withDatabaseName("metabase");
}
@Override
public java.util.Map<String, String> containerEnvironmentVariables(String fqdn, boolean sslEnabled, String authMode) {
// Delegate to new method with null database connection for backward compatibility
return containerEnvironmentVariables(fqdn, sslEnabled, authMode, null);
}
/**
* Container environment variables with database connection support.
*
* <p>If database connection is provided, configures Metabase to use RDS PostgreSQL.
* Otherwise, falls back to H2 embedded database (single instance only).</p>
*/
public java.util.Map<String, String> containerEnvironmentVariables(
String fqdn, boolean sslEnabled, String authMode, DatabaseConnection dbConn) {
java.util.Map<String, String> environment = new HashMap<>();
// MB_SITE_URL is CRITICAL for Metabase to work properly behind a reverse proxy
// Without this, Metabase generates incorrect URLs for emails, embeds, OAuth redirects, etc.
if (fqdn != null && !fqdn.isBlank()) {
String siteUrl = (sslEnabled ? "https://" : "http://") + fqdn;
environment.put("MB_SITE_URL", siteUrl);
}
// Proxy/Load Balancer configuration - CRITICAL for ALB deployments
// Trust X-Forwarded-* headers from ALB for proper IP logging and HTTPS detection
environment.put("MB_JETTY_HOST", "0.0.0.0"); // Listen on all interfaces for ALB health checks
// Metabase automatically trusts X-Forwarded-* headers when MB_SITE_URL is set
// Database configuration
if (dbConn != null) {
// Use RDS PostgreSQL for production multi-instance deployments
environment.put("MB_DB_TYPE", "postgres");
environment.put("MB_DB_HOST", dbConn.endpoint());
environment.put("MB_DB_PORT", String.valueOf(dbConn.port()));
environment.put("MB_DB_DBNAME", dbConn.databaseName());
environment.put("MB_DB_USER", dbConn.username());
// Password is injected via ECS secret as METABASE_DATABASE_PASSWORD
// which maps to MB_DB_PASS environment variable
// Don't set it here - ContainerFactory adds it as an ECS secret
} else {
// Fallback to H2 embedded database (single instance only)
// NOTE: H2 is embedded and cannot support multiple instances - use RDS for production
environment.put("MB_DB_TYPE", "h2");
environment.put("MB_DB_FILE", CONTAINER_DATA_PATH + "/metabase.db");
}
return environment;
}
@Override
public String efsPermissions() {
return EFS_PERMISSIONS;
}
@Override
public String ebsDeviceName() {
return EBS_DEVICE_NAME;
}
@Override
public String ec2DataPath() {
return EC2_DATA_PATH;
}
@Override
public List<String> ec2LogPaths() {
return EC2_LOG_PATHS;
}
@Override
public void configureUserData(UserDataBuilder builder, Ec2Context context) {
builder.addSystemUpdate();
// Install Docker
builder.addCommands(
"# Install Docker",
"yum install -y docker",
"systemctl enable docker",
"systemctl start docker",
"echo 'Docker installed' >> /var/log/userdata.log"
);
// Install CloudWatch Agent
String logGroupName = String.format("/aws/%s/%s/%s",
context.stackName(),
context.runtimeType(),
context.securityProfile());
builder.installCloudWatchAgent(logGroupName, ec2LogPaths());
// Mount storage
String[] userParts = containerUser().split(":");
String uid = userParts[0];
String gid = userParts[1];
if (context.hasEfs()) {
builder.mountEfs(
context.efsId().orElseThrow(),
context.accessPointId().orElseThrow(),
ec2DataPath(),
uid,
gid
);
} else {
builder.mountEbs(
ebsDeviceName(),
ec2DataPath(),
uid,
gid
);
}
// Create directory structure
builder.addCommands(
"# Create Metabase directories",
"mkdir -p /opt/metabase/logs",
"mkdir -p /opt/metabase/plugins",
"",
"# Set ownership",
"chown -R " + uid + ":" + gid + " /opt/metabase"
);
// Run Metabase container
builder.addCommands(
"# Run Metabase container",
"docker run -d \\",
" --name metabase \\",
" -p 3000:3000 \\",
" -v " + ec2DataPath() + ":/metabase-data \\",
" -v /opt/metabase/plugins:/plugins \\",
" -e MB_DB_TYPE=h2 \\",
" -e MB_DB_FILE=/metabase-data/metabase.db \\",
" -e MB_JETTY_HOST=0.0.0.0 \\",
" -e MB_JETTY_PORT=3000 \\",
" " + DEFAULT_IMAGE,
"echo 'Metabase container started' >> /var/log/userdata.log",
"",
"# Wait for Metabase to start",
"sleep 30",
"echo 'Metabase should be available on port 3000' >> /var/log/userdata.log",
"",
"cat >> /var/log/userdata.log <<'INSTRUCTIONS'",
"================================================================================",
"METABASE POST-DEPLOYMENT SETUP",
"================================================================================",
"",
"1. Complete initial setup:",
" - Navigate to http://metabase.example.com:3000",
" - Create admin account",
" - Configure language and region",
"",
"2. Connect to databases:",
" - Add PostgreSQL, MySQL, or other data sources",
" - Use read-only credentials for security",
"",
"3. Configure SAML/OIDC (Enterprise Edition):",
" - Admin > Settings > Authentication",
" - Or use JWT for embedding",
"",
"4. Security hardening:",
" - Enable SSL/TLS",
" - Configure session timeout",
" - Set up row-level permissions",
" - Enable audit logging (Enterprise)",
"",
"5. For production, use PostgreSQL instead of H2:",
" docker run -d \\",
" --name metabase \\",
" -e MB_DB_TYPE=postgres \\",
" -e MB_DB_DBNAME=metabase \\",
" -e MB_DB_PORT=5432 \\",
" -e MB_DB_USER=metabase \\",
" -e MB_DB_PASS=$MB_DB_PASS \\",
" -e MB_DB_HOST=postgres.example.com \\",
" metabase/metabase",
"================================================================================",
"INSTRUCTIONS"
);
}
@Override
public boolean supportsOidcIntegration() {
return true;
}
@Override
public OidcIntegration getOidcIntegration() {
// Metabase does NOT support native OIDC - uses SAML instead
// SAML is only available in Metabase Pro/Enterprise editions
// Open-source version supports JWT for embedding only
return new MetabaseSamlIntegration();
}
/**
* Get the Secrets Manager secret name for the Metabase license token.
*
* <p>The license token enables Pro/Enterprise features:</p>
* <ul>
* <li>SAML/SSO authentication</li>
* <li>Advanced permissions and sandboxing</li>
* <li>Audit logging</li>
* <li>Row-level permissions</li>
* <li>Interactive embedding</li>
* </ul>
*
* <p><b>To add a license:</b></p>
* <ol>
* <li>Go to AWS Secrets Manager in the AWS Console</li>
* <li>Find the secret: {stackName}/metabase/license-token</li>
* <li>Update the secret value with your Metabase license token</li>
* <li>Restart the ECS task or wait for next deployment</li>
* </ol>
*
* @param stackName the CloudFormation stack name
* @return the full secret name for the license token
*/
public String getLicenseSecretName(String stackName) {
return stackName + LICENSE_SECRET_SUFFIX;
}
/**
* Get the environment variable name for the Metabase license token.
* @return MB_PREMIUM_EMBEDDING_TOKEN
*/
public String getLicenseEnvVarName() {
return "MB_PREMIUM_EMBEDDING_TOKEN";
}
@Override
public String toString() {
return "MetabaseApplicationSpec{" +
"applicationId='" + APPLICATION_ID + '\'' +
", defaultImage='" + DEFAULT_IMAGE + '\'' +
", applicationPort=" + APPLICATION_PORT +
'}';
}
}