FargateRuntimeConfiguration.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.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.ecs.CfnService;
import software.amazon.awscdk.services.elasticloadbalancingv2.AddApplicationTargetGroupsProps;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationListener;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationTargetGroup;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationProtocol;
import software.amazon.awscdk.services.elasticloadbalancingv2.BaseApplicationListenerProps;
import software.amazon.awscdk.services.elasticloadbalancingv2.CfnListener;
import software.amazon.awscdk.services.elasticloadbalancingv2.CfnListenerRule;
import software.amazon.awscdk.services.elasticloadbalancingv2.HealthCheck;
import software.amazon.awscdk.services.elasticloadbalancingv2.ListenerCertificate;
import software.amazon.awscdk.services.elasticloadbalancingv2.SslPolicy;
import software.constructs.IConstruct;

import java.util.List;
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 FargateRuntimeConfiguration implements RuntimeConfiguration {

    private static final Logger LOG = Logger.getLogger(FargateRuntimeConfiguration.class.getName());
  @Override public RuntimeType kind() { return RuntimeType.FARGATE; }
  @Override public String id() { return "runtime:FARGATE"; }



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

    // Always required
    rules.add(require("vpc", x -> x.vpc));
    rules.add(require("alb", x -> x.alb));
    rules.add(require("fargate service", x -> x.fargateService));
    rules.add(require("fargate container", x -> x.container));
    rules.add(forbid("asg", x -> x.asg));
    // Note: albTargetGroup is now allowed for OIDC authentication support
    rules.add(forbid("instanceSg", x -> x.instanceSg));  // EC2-specific

    // HTTP listener is NOT required when HTTPS_STRICT mode is enabled (PCI-DSS requirement 4.1)
    // In HTTPS_STRICT mode, only HTTPS listener is created - no HTTP listener at all
    boolean httpsStrict = c.securityProfileConfig.get()
        .map(config -> config.isHttpsStrictEnabled())
        .orElse(false);
    boolean sslEnabled = Boolean.TRUE.equals(c.cfc.enableSsl());

    if (!(httpsStrict && sslEnabled)) {
      // Standard mode: HTTP listener is required
      rules.add(require("http listener", x -> x.http));
    } else {
      // HTTPS_STRICT mode: HTTPS listener is required instead
      rules.add(require("https listener", x -> x.https));
    }

    return rules;
  }

  @Override
  public void wire(SystemContext c) {
    // Ensure this configuration only runs for FARGATE runtime
    if (c.runtime != RuntimeType.FARGATE) {
      return;
    }

    // Add explicit guard to prevent duplicate execution
    if (c.wired.get().isPresent()) {
      return;
    }
    c.wired.set(true);

    // Track if HTTP and HTTPS listeners have been configured to prevent duplicates
    final boolean[] httpConfigured = {false};
    final boolean[] httpsConfigured = {false};

    try {
      // 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 wantDns    = haveHost;           // publish A/AAAA when a host is provided
      final boolean wantSslDns = ssl && haveHost;    // SSL mode with host → cert + HTTPS + redirect

    // ── 0) DNS A/AAAA for ALB (works with or without SSL) ──────────────────────
    // Note: DNS record creation is handled by topology configurations to avoid conflicts
    // if (wantDns) {
    //   System.out.println("FargateRuntimeConfiguration: Creating DNS records for domain: " + domain + ", fqdn: " + fqdn);
    //   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);
    //
    //     System.out.println("FargateRuntimeConfiguration: Zone: " + zoneName + ", Host: " + host);
    //
    //     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)); // "jkns"
    //
    //     System.out.println("FargateRuntimeConfiguration: Record name: " + recordName);
    //
    //     RecordTarget target = RecordTarget.fromAlias(
    //             new LoadBalancerTarget(alb));
    //     Stack stack = Stack.of(alb);
    //
    //     // A record only (ALBs typically only support IPv4)
    //     ARecordProps.Builder aProps = ARecordProps.builder()
    //             .zone(zone).target(target);
    //     if (recordName != null && !recordName.isBlank()) aProps.recordName(recordName);
    //     new ARecord(stack, "AlbAliasA", aProps.build());
    //   });
    // }

    // ── 1) DOMAIN + NO SSL → HTTP only (single TG), NO cert/https/redirect ─────
    if (!ssl) {
      whenBoth(c.http, c.fargateService, (http, svc) -> {
        if (httpConfigured[0]) {
          return;
        }
        httpConfigured[0] = true;

        // Create a target group for the Fargate service with configurable health check settings
        int interval = c.cfc.healthCheckInterval() != null ? c.cfc.healthCheckInterval() : 30;
        int timeout = c.cfc.healthCheckTimeout() != null ? c.cfc.healthCheckTimeout() : 5;
        int healthyThreshold = c.cfc.healthyThreshold() != null ? c.cfc.healthyThreshold() : 2;
        int unhealthyThreshold = c.cfc.unhealthyThreshold() != null ? c.cfc.unhealthyThreshold() : 3;

        // Get application-specific configuration from ApplicationSpec
        int applicationPort = c.applicationSpec.get().map(spec -> spec.applicationPort()).orElse(8080);
        String healthCheckPath = c.applicationSpec.get().map(spec -> spec.healthCheckPath()).orElse("/");

        ApplicationTargetGroup targetGroup = ApplicationTargetGroup.Builder.create(c, "FargateHttpTargetGroup")
                .vpc(c.vpc.get().orElseThrow())
                .port(applicationPort)
                .protocol(ApplicationProtocol.HTTP)
                .targets(java.util.List.of(svc))
                .healthCheck(HealthCheck.builder()
                        .path(healthCheckPath).healthyHttpCodes("200-399")
                        .interval(software.amazon.awscdk.Duration.seconds(interval))
                        .timeout(software.amazon.awscdk.Duration.seconds(timeout))
                        .healthyThresholdCount(healthyThreshold).unhealthyThresholdCount(unhealthyThreshold)
                        .build())
                .build();

        // Update the HTTP listener's default action to forward to the target group
        // Note: This replaces the fixed response with a forward action
        http.addTargetGroups("HttpTargetGroup", AddApplicationTargetGroupsProps.builder()
                .targetGroups(java.util.List.of(targetGroup))
                .build());


        // Make ECS wait for HTTP listener & rules
        CfnService cfnSvc  = (CfnService)  svc.getNode().getDefaultChild();
        CfnListener cfnHttp = (CfnListener) http.getNode().getDefaultChild();
        if (cfnHttp != null) cfnSvc.addDependency(cfnHttp);
        for (IConstruct child : http.getNode().getChildren()) {
          IConstruct def = child.getNode().getDefaultChild();
          if (def instanceof CfnListenerRule rule) {
            cfnSvc.addDependency(rule);
          }
        }
      });
      return; // ← CRITICAL: prevents creating cert/https/redirect that detach the HTTP TG
    }

    // ── 2) SSL MODE ─────────────────────────────────────────────────────────────
    // Two paths:
    // A) SSL + custom domain → Use ACM public certificate with DNS validation
    // B) SSL + no domain → Use AWS Private CA certificate for ALB DNS name
    //    (Required for application-oidc/alb-oidc without custom domain)

    if (!ssl) {
      return; // No SSL requested
    }

    // 2a) 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;
        if (certDomain == null || certDomain.isBlank()) {
          LOG.warning("*** FargateRuntimeConfiguration: Certificate creation skipped - no domain available ***");
          return;
        }

        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
        // Note: PrivateCertificate requires the CA ARN
        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.");
        LOG.warning("For Cognito OIDC, ensure your IdP is configured to trust this Private CA.");
      });
    }

    // 2b) Create HTTPS listener with certificate
    whenBoth(c.cert, c.alb, (cert, alb) -> {
      if (c.https.get().isPresent()) return;

      // Use TLS 1.2+ policy for PCI-DSS compliance (Requirement 4.1)
      // RECOMMENDED_TLS enforces TLS 1.2 minimum with strong cipher suites
      ApplicationListener https = alb.addListener("Https",
              BaseApplicationListenerProps.builder()
                      .port(443)
                      .certificates(java.util.List.of(ListenerCertificate.fromCertificateManager(cert)))
                      .sslPolicy(SslPolicy.RECOMMENDED_TLS)
                      .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);
    });

    // 2c) Service behind HTTPS - wait for HTTPS listener, Fargate service, AND certificate to be ready
    whenAll(c.https, c.fargateService, c.cert, (https, svc, cert) -> {
      if (httpsConfigured[0]) {
        return;
      }
      httpsConfigured[0] = true;

      // Create a target group for the Fargate service with configurable health check settings
      int interval = c.cfc.healthCheckInterval() != null ? c.cfc.healthCheckInterval() : 30;
      int timeout = c.cfc.healthCheckTimeout() != null ? c.cfc.healthCheckTimeout() : 5;
      int healthyThreshold = c.cfc.healthyThreshold() != null ? c.cfc.healthyThreshold() : 2;
      int unhealthyThreshold = c.cfc.unhealthyThreshold() != null ? c.cfc.unhealthyThreshold() : 3;

      // Get application-specific configuration from ApplicationSpec
      int applicationPort = c.applicationSpec.get().map(spec -> spec.applicationPort()).orElse(8080);
      String healthCheckPath = c.applicationSpec.get().map(spec -> spec.healthCheckPath()).orElse("/");

      ApplicationTargetGroup targetGroup = ApplicationTargetGroup.Builder.create(c, "FargateHttpsTargetGroup")
              .vpc(c.vpc.get().orElseThrow())
              .port(applicationPort)
              .protocol(ApplicationProtocol.HTTP)
              .targets(java.util.List.of(svc))
                .healthCheck(HealthCheck.builder()
                        .path(healthCheckPath).healthyHttpCodes("200-399")
                        .interval(software.amazon.awscdk.Duration.seconds(interval))
                        .timeout(software.amazon.awscdk.Duration.seconds(timeout))
                        .healthyThresholdCount(healthyThreshold).unhealthyThresholdCount(unhealthyThreshold)
                        .build())
              .build();

      // Store target group in context for other factories to reference (e.g., OIDC)
      c.albTargetGroup.set(targetGroup);

      // Update the HTTPS listener's default action to forward to the target group
      https.addTargetGroups("HttpsTargetGroup", AddApplicationTargetGroupsProps.builder()
              .targetGroups(java.util.List.of(targetGroup))
              .build());


      CfnService cfnSvc  = (CfnService)  svc.getNode().getDefaultChild();
      CfnListener cfnHttps= (CfnListener) https.getNode().getDefaultChild();
      if (cfnHttps != null) cfnSvc.addDependency(cfnHttps);
      for (IConstruct child : https.getNode().getChildren()) {
        IConstruct def = child.getNode().getDefaultChild();
        if (def instanceof CfnListenerRule rule) {
          cfnSvc.addDependency(rule);
        }
      }
    });

    // 2d) 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(java.util.List.of(
                CfnListener.ActionProperty.builder()
                        .type("redirect")
                        .redirectConfig(
                                CfnListener.RedirectConfigProperty.builder()
                                        .protocol("HTTPS").port("443").statusCode("HTTP_301").build())
                        .build()
        ));
        LOG.info("HTTP → HTTPS redirect configured for Fargate");
      }
    });

    } catch (Exception e) {
      throw e;
    }
  }

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


}