Ec2RuntimeConfiguration.java

package com.cloudforgeci.api.core.runtime;

import com.cloudforgeci.api.core.SystemContext;
import com.cloudforgeci.api.core.rules.AwsConfigRule;
import com.cloudforge.core.enums.RuntimeType;
import com.cloudforge.core.enums.TopologyType;
import com.cloudforge.core.enums.SecurityProfile;
import com.cloudforgeci.api.interfaces.RuntimeConfiguration;
import com.cloudforgeci.api.interfaces.Rule;
import software.amazon.awscdk.services.acmpca.CertificateAuthority;
import software.amazon.awscdk.services.acmpca.CfnCertificate;
import software.amazon.awscdk.services.acmpca.CfnCertificateAuthority;
import software.amazon.awscdk.services.acmpca.CfnCertificateAuthorityActivation;
import software.amazon.awscdk.services.certificatemanager.Certificate;
import software.amazon.awscdk.services.certificatemanager.CertificateValidation;
import software.amazon.awscdk.services.certificatemanager.PrivateCertificate;
import software.amazon.awscdk.services.ec2.ISecurityGroup;
import software.amazon.awscdk.services.ec2.Peer;
import software.amazon.awscdk.services.ec2.Port;
import software.amazon.awscdk.services.elasticloadbalancingv2.AddApplicationTargetGroupsProps;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationListener;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationProtocol;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationTargetGroup;
import software.amazon.awscdk.services.elasticloadbalancingv2.BaseApplicationListenerProps;
import software.amazon.awscdk.services.elasticloadbalancingv2.CfnListener;
import software.amazon.awscdk.services.elasticloadbalancingv2.FixedResponseOptions;
import software.amazon.awscdk.services.elasticloadbalancingv2.HealthCheck;
import software.amazon.awscdk.services.elasticloadbalancingv2.ListenerAction;
import software.amazon.awscdk.services.elasticloadbalancingv2.ListenerCertificate;
import software.amazon.awscdk.services.elasticloadbalancingv2.SslPolicy;
import software.amazon.awscdk.services.elasticloadbalancingv2.TargetType;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.logging.Logger;

import static com.cloudforgeci.api.core.rules.RuleKit.forbid;
import static com.cloudforgeci.api.core.rules.RuleKit.require;
import static com.cloudforgeci.api.core.rules.RuleKit.whenAll;
import static com.cloudforgeci.api.core.rules.RuleKit.whenBoth;


public final class Ec2RuntimeConfiguration implements RuntimeConfiguration {

  private static final Logger LOG = Logger.getLogger(Ec2RuntimeConfiguration.class.getName());

  @Override
  public RuntimeType kind() { return RuntimeType.EC2; }

  @Override
  public String id() { return "runtime:EC2"; }

  @Override
  public List<Rule> rules(SystemContext c) {
    var rules = new ArrayList<Rule>();

    // Always required
    rules.add(require("vpc", x -> x.vpc));
    rules.add(require("alb", x -> x.alb));
    rules.add(require("instanceSg", x -> x.instanceSg));
    rules.add(forbid("fargate", x -> x.fargateService));

    // Target group is required UNLESS HTTPS_STRICT mode is enabled
    // In HTTPS_STRICT mode, target group is created in wire() after HTTPS listener
    boolean httpsStrict = c.securityProfileConfig.get()
        .map(config -> config.isHttpsStrictEnabled())
        .orElse(false);
    boolean sslEnabled = Boolean.TRUE.equals(c.cfc.enableSsl());

    if (!(httpsStrict && sslEnabled)) {
      // Standard mode: target group created by SystemContext.createTargetGroups()
      rules.add(require("targetGroup", x -> x.albTargetGroup));
    }

    // AutoScalingGroup is only required for JENKINS_SERVICE topology when maxInstanceCapacity > 1
    // When maxInstanceCapacity <= 1, JenkinsFactory creates a single instance instead of ASG
    if (c.topology == TopologyType.JENKINS_SERVICE && c.cfc.maxInstanceCapacity() != null && c.cfc.maxInstanceCapacity() > 1) {
        rules.add(require("asg", x -> x.asg));
    }

    return rules;
  }

  @Override
  public void wire(SystemContext c) {
    LOG.info("=== Ec2RuntimeConfiguration.wire() called == = ");

    // Ensure this configuration only runs for EC2 runtime
    if (c.runtime != RuntimeType.EC2) {
      LOG.info("Skipping EC2 runtime configuration (runtime = " + c.runtime + ")");
      return;
    }

    LOG.info("Configuring EC2 runtime for " + c.topology + " topology, " + c.security + " profile");

    // Inputs & flags
    final boolean ssl = c.cfc != null && Boolean.TRUE.equals(c.cfc.enableSsl());
    final String domain = norm(c.cfc != null ? c.cfc.domain() : null);
    final String fqdn = norm(c.cfc != null ? c.cfc.fqdn() : null);

    final boolean haveHost = (domain != null && !domain.isBlank()) || (fqdn != null && !fqdn.isBlank());
    final boolean wantSslDns = ssl && haveHost; // SSL mode with host → cert + HTTPS + redirect

    // Check if HTTPS strict mode is enabled (for PCI-DSS compliance)
    final boolean httpsStrict = c.securityProfileConfig.get()
        .map(config -> config.isHttpsStrictEnabled())
        .orElse(false);

    // ── 0a) HTTPS_STRICT: Create target group here (deferred from SystemContext) ────
    // In HTTPS_STRICT mode, no HTTP listener exists, so target group wasn't created earlier
    if (httpsStrict && ssl && c.albTargetGroup.get().isEmpty()) {
      c.alb.onSet(alb -> {
        if (c.albTargetGroup.get().isPresent()) return; // Already created

        LOG.info("HTTPS_STRICT mode: Creating target group for HTTPS-only deployment");

        int appPort = c.applicationSpec.get().map(spec -> spec.applicationPort()).orElse(8080);
        String healthPath = c.applicationSpec.get().map(spec -> spec.healthCheckPath()).orElse("/login");

        ApplicationTargetGroup targetGroup = ApplicationTargetGroup.Builder.create(c, "HttpsStrictTg")
            .vpc(c.vpc.get().orElseThrow())
            .port(appPort)
            .protocol(ApplicationProtocol.HTTP)
            .targetType(TargetType.INSTANCE)
            .healthCheck(HealthCheck.builder()
                .path(healthPath)
                .healthyHttpCodes("200-299")
                .interval(software.amazon.awscdk.Duration.seconds(
                    c.cfc.healthCheckInterval() != null ? c.cfc.healthCheckInterval() : 30))
                .timeout(software.amazon.awscdk.Duration.seconds(
                    c.cfc.healthCheckTimeout() != null ? c.cfc.healthCheckTimeout() : 5))
                .healthyThresholdCount(
                    c.cfc.healthyThreshold() != null ? c.cfc.healthyThreshold() : 2)
                .unhealthyThresholdCount(
                    c.cfc.unhealthyThreshold() != null ? c.cfc.unhealthyThreshold() : 3)
                .build())
            .build();

        c.albTargetGroup.set(targetGroup);
        LOG.info("HTTPS_STRICT target group created for port " + appPort);
      });
    }

    // ── 0b) DNS A record for ALB (works with or without SSL) ──────────────────────
    // Note: DNS record creation is handled by topology configurations to avoid conflicts
    // if (wantDns) {
    //   whenBoth(c.zone, c.alb, (zone, alb) -> {
    //     final String zoneName = norm(zone.getZoneName());
    //     final String host = (fqdn != null && !fqdn.isBlank()) ? fqdn : (domain != null ? domain : zoneName);
    //
    //     if (!host.equals(zoneName) && !host.endsWith("." + zoneName)) {
    //       throw new IllegalArgumentException("Host '" + host + "' is not within zone '" + zoneName + "'");
    //     }
    //     final String recordName = host.equals(zoneName) ? null
    //             : host.substring(0, host.length() - (zoneName.length() + 1)); // "jenkins"
    //
    //     RecordTarget target = RecordTarget.fromAlias(new LoadBalancerTarget(alb));
    //     ARecordProps.Builder aProps = ARecordProps.builder()
    //             .zone(zone).target(target);
    //     if (recordName != null && !recordName.isBlank()) aProps.recordName(recordName);
    //     new ARecord(c, "AlbAliasA", aProps.build());
    //   });
    // }

    // ── 1) ASG -> TargetGroup wiring is now handled by JenkinsServiceTopologyConfiguration ─────
    // This prevents duplicate target group additions and centralizes the logic
    // whenBoth(c.asg, c.albTargetGroup, (asg, tg) -> tg.addTarget(asg)); // REMOVED - handled by topology configuration

    // ── 2) ALB SG -> Instance SG (application port) ──────────────────────────────────────────
    whenBoth(c.alb, c.instanceSg, (alb, isg) -> {
      ISecurityGroup albSg = alb.getConnections().getSecurityGroups().get(0);
      // Use application-specific port from ApplicationSpec, fallback to 8080 for legacy Jenkins
      int appPort = c.applicationSpec.get().map(spec -> spec.applicationPort()).orElse(8080);
      String appId = c.applicationSpec.get().map(spec -> spec.applicationId()).orElse("app");
      isg.addIngressRule(Peer.securityGroupId(albSg.getSecurityGroupId()),
              Port.tcp(appPort), "ALB_to_" + appId + "_" + appPort, false);
    });

    // ── 2a) Auto Scaling Configuration - EC2 runtime (when ASG is available) ────
    // Apply scaling policies to Auto Scaling Group ONLY for PRODUCTION security profile
    // DEV and STAGING profiles have auto-scaling disabled in their security configurations
    boolean isProduction = c.security == SecurityProfile.PRODUCTION;

    if (isProduction) {
      LOG.info("PRODUCTION profile - setting up scaling policy callback");

      // Use whenBoth to wait for ASG to be created, with guard to prevent duplicate execution
      whenBoth(c.asg, c.albTargetGroup, (asg, tg) -> {
        // Check if scaling policies have already been applied
        if (c.scalingPoliciesApplied.get().isPresent()) {
          return; // Already applied, skip
        }

        LOG.info("ASG and target group ready - applying scaling policies");

        if (c.cfc.maxInstanceCapacity() != null && c.cfc.maxInstanceCapacity() > 1) {
          // Apply scaling policies using ScalingFactory
          com.cloudforgeci.api.scaling.ScalingFactory scalingFactory =
              new com.cloudforgeci.api.scaling.ScalingFactory(c, "Ec2ScalingPolicy");
          scalingFactory.scale(asg);
          c.scalingPoliciesApplied.set(true);
          LOG.info("Scaling policies applied successfully");
        }
      });
    }

    // ── 3) DOMAIN + NO SSL → HTTP only (single TG), NO cert/https/redirect ─────
    if (!ssl) {
      whenBoth(c.http, c.albTargetGroup, (http, tg) -> {
        // HTTP listener already has the target group as default action from AlbFactory
        // No additional wiring needed for HTTP-only mode
      });
      return; // ← CRITICAL: prevents creating cert/https/redirect
    }

    // ── 4) SSL → ACM certificate (public with domain) OR Private CA (without domain) ─────────────
    // 4a) Create certificate - either public (with domain) or private (without domain)
    if (wantSslDns) {
      // Path A: Public ACM certificate with DNS validation
      whenBoth(c.zone, c.alb, (zone, alb) -> {
        if (c.cert.get().isPresent()) return;

        String certDomain = fqdn != null ? fqdn : domain;

        Certificate cert = Certificate.Builder
                .create(c, "HttpsCert")
                .domainName(certDomain)
                .validation(CertificateValidation.fromDns(zone))
                .build();

        cert.applyRemovalPolicy(software.amazon.awscdk.RemovalPolicy.DESTROY);
        c.cert.set(cert);
      });
    } else {
      // Path B: Private CA certificate for ALB DNS name (no custom domain required)
      // This enables HTTPS for application-oidc/alb-oidc without a custom domain
      c.alb.onSet(alb -> {
        if (c.cert.get().isPresent()) return;

        LOG.info("Creating Private CA certificate for ALB DNS name (no custom domain configured)");
        String albDnsName = alb.getLoadBalancerDnsName();

        // Create a Private Certificate Authority (short-lived, for this stack only)
        CfnCertificateAuthority privateCa = CfnCertificateAuthority.Builder.create(c, "PrivateCA")
                .type("ROOT")
                .keyAlgorithm("RSA_2048")
                .signingAlgorithm("SHA256WITHRSA")
                .subject(CfnCertificateAuthority.SubjectProperty.builder()
                        .commonName("CloudForge Private CA")
                        .organization("CloudForge")
                        .organizationalUnit("Infrastructure")
                        .build())
                .build();
        privateCa.applyRemovalPolicy(software.amazon.awscdk.RemovalPolicy.DESTROY);

        // Issue a ROOT CA certificate using the CA's own CSR
        // This creates the self-signed root certificate for CA activation
        CfnCertificate rootCaCert = CfnCertificate.Builder.create(c, "RootCACertificate")
                .certificateAuthorityArn(privateCa.getAttrArn())
                .certificateSigningRequest(privateCa.getAttrCertificateSigningRequest())
                .signingAlgorithm("SHA256WITHRSA")
                .templateArn("arn:aws:acm-pca:::template/RootCACertificate/V1")
                .validity(CfnCertificate.ValidityProperty.builder()
                        .type("YEARS")
                        .value(10)
                        .build())
                .build();
        rootCaCert.applyRemovalPolicy(software.amazon.awscdk.RemovalPolicy.DESTROY);

        // Activate the CA with the self-signed root certificate
        CfnCertificateAuthorityActivation caActivation = CfnCertificateAuthorityActivation.Builder.create(c, "PrivateCAActivation")
                .certificateAuthorityArn(privateCa.getAttrArn())
                .certificate(rootCaCert.getAttrCertificate())
                .status("ACTIVE")
                .build();
        caActivation.addDependency(rootCaCert);

        // Create a private certificate for the ALB DNS name
        PrivateCertificate privateCert = PrivateCertificate.Builder.create(c, "PrivateHttpsCert")
                .domainName(albDnsName)
                .certificateAuthority(CertificateAuthority.fromCertificateAuthorityArn(
                        c, "ImportedPrivateCA", privateCa.getAttrArn()))
                .build();

        // Ensure proper dependency ordering
        privateCert.getNode().addDependency(caActivation);
        privateCert.applyRemovalPolicy(software.amazon.awscdk.RemovalPolicy.DESTROY);

        c.cert.set(privateCert);
        c.privateCa.set(privateCa);

        LOG.info("Private CA certificate created for: " + albDnsName);
        LOG.warning("NOTE: Private CA certificates are NOT trusted by browsers. Users will see certificate warnings.");
      });
    }

    // 4b) Create HTTPS listener with certificate
    // Use TLS 1.2+ policy for PCI-DSS compliance (Requirement 4.1)
    whenBoth(c.cert, c.alb, (cert, alb) -> {
      if (c.https.get().isPresent()) return;

      ApplicationListener https;
      if (c.albTargetGroup.get().isPresent()) {
        // Target group is available, create listener with target group and certificate
        https = alb.addListener("Https",
                BaseApplicationListenerProps.builder()
                        .port(443)
                        .certificates(List.of(ListenerCertificate.fromCertificateManager(cert)))
                        .sslPolicy(SslPolicy.RECOMMENDED_TLS)
                        .defaultAction(ListenerAction.forward(List.of(c.albTargetGroup.get().orElseThrow())))
                        .build());
      } else {
        // Target group is not available, create listener with fixed response and certificate
        https = alb.addListener("Https",
                BaseApplicationListenerProps.builder()
                        .port(443)
                        .certificates(List.of(ListenerCertificate.fromCertificateManager(cert)))
                        .sslPolicy(SslPolicy.RECOMMENDED_TLS)
                        .defaultAction(ListenerAction.fixedResponse(200, FixedResponseOptions.builder()
                                .contentType("text/plain")
                                .messageBody("Jenkins is starting up...")
                                .build()))
                        .build());
      }

      // Add explicit dependency: HTTPS listener depends on certificate
      // This ensures CloudFormation deletes the listener BEFORE the certificate during stack deletion
      CfnListener cfnHttps = (CfnListener) https.getNode().getDefaultChild();
      software.amazon.awscdk.services.certificatemanager.CfnCertificate cfnCert =
              (software.amazon.awscdk.services.certificatemanager.CfnCertificate) cert.getNode().getDefaultChild();
      if (cfnHttps != null && cfnCert != null) {
        cfnHttps.addDependency(cfnCert);
      }

      c.https.set(https);

      // Register AWS Config rules for HTTPS/TLS compliance
      c.requireConfigRule(AwsConfigRule.ALB_HTTPS_ONLY);
      c.requireConfigRule(AwsConfigRule.ELB_TLS_HTTPS_LISTENERS);
    });

    // 4c) Service behind HTTPS - wait for all components to be ready
    // Handle both AutoScalingGroup (multi-instance) and single EC2 instance cases
    whenBoth(c.https, c.albTargetGroup, (https, tg) -> {
      https.addTargetGroups("SvcHttps",
              AddApplicationTargetGroupsProps.builder()
                      .targetGroups(List.of(tg))
                      .build());
    });

    // 4d) Make HTTP's DEFAULT action a redirect to HTTPS (don't leave any TG on HTTP)
    whenBoth(c.http, c.https, (http, https) -> {
      CfnListener cfnHttp = (CfnListener) http.getNode().getDefaultChild();
      if (cfnHttp != null) {
        cfnHttp.setDefaultActions(List.of(
                CfnListener.ActionProperty.builder()
                        .type("redirect")
                        .redirectConfig(
                                CfnListener.RedirectConfigProperty.builder()
                                        .protocol("HTTPS").port("443").statusCode("HTTP_301").build())
                        .build()
        ));
      }
    });

    LOG.info("=== Ec2RuntimeConfiguration.wire() completed == = ");
  }

  private static String norm(String s) {
    return s == null ? null : s.trim().replaceAll("\\.$", "").toLowerCase(Locale.ROOT);
  }
}