JenkinsApplicationSpec.java

package com.cloudforgeci.api.application;

import com.cloudforge.core.annotation.ApplicationPlugin;
import com.cloudforge.core.enums.AuthMode;
import com.cloudforge.core.interfaces.ApplicationSpec;
import com.cloudforge.core.interfaces.Ec2Context;
import com.cloudforge.core.interfaces.OidcIntegration;
import com.cloudforge.core.interfaces.UserDataBuilder;
import com.cloudforge.core.oidc.JenkinsOidcIntegration;

import java.util.List;

/**
 * Jenkins ApplicationSpec implementation.
 *
 * <p>Defines all Jenkins-specific configuration including:</p>
 * <ul>
 *   <li>Container image (jenkins/jenkins:lts)</li>
 *   <li>Application port (8080)</li>
 *   <li>Data persistence paths (container and EC2)</li>
 *   <li>Container user and permissions (1000:1000)</li>
 *   <li>EC2 UserData installation and configuration</li>
 * </ul>
 *
 * <p>This spec is used by the universal application framework to deploy Jenkins
 * using either Fargate or EC2 runtime with appropriate infrastructure (EFS or EBS).</p>
 *
 * <p>CloudForge 3.0.0: Extracted from hardcoded Jenkins implementation</p>
 */
@ApplicationPlugin(
    value = "jenkins",
    category = "cicd",
    displayName = "Jenkins",
    description = "Open-source automation server for CI/CD",
    defaultCpu = 1024,
    defaultMemory = 2048,
    defaultInstanceType = "t3.small",
    supportsFargate = true,
    supportsEc2 = true,
    supportsOidc = true
)
public class JenkinsApplicationSpec implements ApplicationSpec {

    // Application Identity
    private static final String APPLICATION_ID = "jenkins";

    // Container Configuration
    private static final String DEFAULT_IMAGE = "jenkins/jenkins:lts";
    private static final int APPLICATION_PORT = 8080;
    private static final String CONTAINER_DATA_PATH = "/var/jenkins_home";
    private static final String EFS_DATA_PATH = "/jenkins";
    private static final String VOLUME_NAME = "jenkinsHome";
    private static final String CONTAINER_USER = "1000:1000";
    private static final String EFS_PERMISSIONS = "750";

    // EC2 Configuration
    private static final String EBS_DEVICE_NAME = "/dev/xvdh";
    private static final String EC2_DATA_PATH = "/var/lib/jenkins";
    private static final List<String> EC2_LOG_PATHS = List.of(
        "/var/log/jenkins/jenkins.log",
        "/var/log/userdata.log",
        "/var/log/messages"
    );

    // ========== Application Identity ==========

    @Override
    public String applicationId() {
        return APPLICATION_ID;
    }

    // ========== Container Configuration ==========

    @Override
    public String defaultContainerImage() {
        return DEFAULT_IMAGE;
    }

    @Override
    public int applicationPort() {
        return APPLICATION_PORT;
    }

    @Override
    public java.util.List<OptionalPort> optionalPorts() {
        return java.util.List.of(
            // Build agents - inbound connections from Jenkins agents
            OptionalPort.inboundTcp(50000, "enableAgents", "JNLP Build Agents")
        );
    }

    @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 String efsPermissions() {
        return EFS_PERMISSIONS;
    }

    @Override
    public String healthCheckPath() {
        return "/login";
    }

    @Override
    public java.util.Map<String, String> containerEnvironmentVariables(String fqdn, boolean sslEnabled, String authMode) {
        java.util.Map<String, String> environment = new java.util.HashMap<>();

        // Configure JAVA_OPTS for Jenkins
        StringBuilder javaOpts = new StringBuilder();

        // Configure Jenkins reverse proxy settings for ALB
        // This is CRITICAL to fix 403 CSRF errors when behind a load balancer

        // Allow Jenkins to trust X-Forwarded-* headers from ALB
        javaOpts.append("-Dorg.eclipse.jetty.server.Request.maxFormContentSize=1000000 ");

        // Disable CSP that can interfere with reverse proxy
        javaOpts.append("-Dhudson.model.DirectoryBrowserSupport.CSP=\"\" ");

        // Configure Jenkins to properly handle reverse proxy headers
        // This ensures Jenkins knows the correct external URL for CSRF token validation
        if (fqdn != null && !fqdn.isBlank()) {
            javaOpts.append("-Dhudson.TcpSlaveAgentListener.hostName=").append(fqdn).append(" ");

            // Set Jenkins root URL directly via system property
            // This fixes "reverse proxy setup is broken" errors
            String jenkinsRootUrl = (sslEnabled ? "https://" : "http://") + fqdn;
            javaOpts.append("-Djenkins.model.Jenkins.rootUrl=").append(jenkinsRootUrl).append(" ");
        }

        // Skip setup wizard when using APPLICATION_OIDC mode
        // OIDC configuration will be applied via JenkinsOidcIntegration
        if (AuthMode.APPLICATION_OIDC == AuthMode.fromString(authMode)) {
            javaOpts.append("-Djenkins.install.runSetupWizard=false ");
        }

        // Configure JENKINS_OPTS for reverse proxy support
        StringBuilder jenkinsOpts = new StringBuilder();
        jenkinsOpts.append("--httpListenAddress=0.0.0.0 ");
        jenkinsOpts.append("--httpsPort=-1 ");  // Disable direct HTTPS on Jenkins (ALB handles SSL)

        // Set Jenkins URL for proper CSRF token generation
        if (fqdn != null && !fqdn.isBlank()) {
            String jenkinsUrl = (sslEnabled ? "https://" : "http://") + fqdn;
            environment.put("JENKINS_URL", jenkinsUrl);
        }

        environment.put("JENKINS_OPTS", jenkinsOpts.toString().trim());

        if (javaOpts.length() > 0) {
            environment.put("JAVA_OPTS", javaOpts.toString().trim());
        }

        return environment;
    }

    // ========== EC2 Configuration ==========

    @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) {
        // System updates
        builder.addSystemUpdate();

        // Install Java 17 (required for Jenkins)
        builder.addCommands(
            "# Install Java 17",
            "command -v dnf >/dev/null && dnf -y install java-17-amazon-corretto-headless || yum -y install java-17-amazon-corretto-headless",
            "echo 'Java installed' >> /var/log/userdata.log"
        );

        // Install Jenkins
        builder.addCommands(
            "# Install Jenkins",
            "curl -fsSL https://pkg.jenkins.io/redhat-stable/jenkins.repo -o /etc/yum.repos.d/jenkins.repo",
            "rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io-2023.key",
            "command -v dnf >/dev/null && dnf -y install jenkins || yum -y install jenkins",
            "echo 'Jenkins installed' >> /var/log/userdata.log"
        );

        // Install and configure CloudWatch Agent
        String logGroupName = String.format("/aws/%s/%s/%s",
            context.stackName(),
            context.runtimeType(),
            context.securityProfile());
        builder.installCloudWatchAgent(logGroupName, ec2LogPaths());

        // Mount storage (EFS or EBS based on availability)
        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
            );
        }

        // Configure and start Jenkins
        builder.addCommands(
            "# Start Jenkins",
            "systemctl enable jenkins",
            "systemctl start jenkins",
            "echo 'Jenkins started' >> /var/log/userdata.log",
            "",
            "# Wait for Jenkins to generate admin password",
            "echo 'Waiting for Jenkins admin password...' >> /var/log/userdata.log",
            "sleep 60",
            "if [ -f " + ec2DataPath() + "/secrets/initialAdminPassword ]; then",
            "  echo 'Jenkins Admin Password:' >> /var/log/userdata.log",
            "  cat " + ec2DataPath() + "/secrets/initialAdminPassword >> /var/log/userdata.log",
            "else",
            "  echo 'Jenkins admin password file not found yet' >> /var/log/userdata.log",
            "fi"
        );
    }

    @Override
    public boolean supportsOidcIntegration() {
        return true;
    }

    @Override
    public OidcIntegration getOidcIntegration() {
        return new JenkinsOidcIntegration();
    }

    @Override
    public String toString() {
        return "JenkinsApplicationSpec{" +
                "applicationId='" + APPLICATION_ID + '\'' +
                ", defaultImage='" + DEFAULT_IMAGE + '\'' +
                ", applicationPort=" + APPLICATION_PORT +
                ", containerDataPath='" + CONTAINER_DATA_PATH + '\'' +
                ", ec2DataPath='" + EC2_DATA_PATH + '\'' +
                ", efsDataPath='" + EFS_DATA_PATH + '\'' +
                '}';
    }
}