CmsServiceTopologyConfiguration.java

package com.cloudforgeci.api.core.topology;

import com.cloudforgeci.api.core.SystemContext;
import com.cloudforge.core.enums.AuthMode;
import com.cloudforge.core.enums.RuntimeType;
import com.cloudforge.core.enums.TopologyType;
import com.cloudforge.core.interfaces.CmsSpec;
import com.cloudforgeci.api.interfaces.TopologyConfiguration;
import com.cloudforgeci.api.interfaces.Rule;

import software.amazon.awscdk.Duration;
import software.amazon.awscdk.services.applicationautoscaling.EnableScalingProps;
import software.amazon.awscdk.services.cloudfront.Distribution;
import software.amazon.awscdk.services.ecs.CpuUtilizationScalingProps;
import software.amazon.awscdk.services.ecs.ScalableTaskCount;
import software.amazon.awscdk.services.elasticache.CfnCacheCluster;
import software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationLoadBalancer;
import software.amazon.awscdk.services.route53.ARecord;
import software.amazon.awscdk.services.route53.AaaaRecord;
import software.amazon.awscdk.services.route53.ARecordProps;
import software.amazon.awscdk.services.route53.AaaaRecordProps;
import software.amazon.awscdk.services.route53.RecordTarget;
import software.amazon.awscdk.services.route53.targets.LoadBalancerTarget;
import software.amazon.awscdk.services.s3.Bucket;

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

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

/**
 * CMS-specific topology that auto-wires infrastructure from {@link CmsSpec} capabilities.
 *
 * <p>Unlike the generic {@link ApplicationServiceTopologyConfiguration}, this topology
 * reads the CMS plugin's declared capabilities and conditionally provisions:</p>
 *
 * <ul>
 *   <li><strong>S3 media bucket</strong> — when {@code CmsSpec.supportsS3MediaStorage() == true}</li>
 *   <li><strong>ElastiCache Redis</strong> — when {@code CmsSpec.supportsObjectCache() == true} and
 *       {@code preferredCacheBackend() == "redis"}</li>
 *   <li><strong>CloudFront CDN</strong> — when {@code CmsSpec.supportsCdnIntegration() == true}
 *       and either a custom domain or S3 media bucket is present</li>
 *   <li><strong>Route53 DNS records</strong> — when a hosted zone and ALB are available</li>
 *   <li><strong>ECS/EC2 auto-scaling</strong> — when min/max capacity is configured</li>
 * </ul>
 *
 * <h2>cdk.json example</h2>
 * <pre>{@code
 * {
 *   "context": {
 *     "cfc": {
 *       "topology": "cms-service",
 *       "applicationId": "wordpress",
 *       "runtime": "fargate",
 *       "env": "prod",
 *       "domain": "example.com",
 *       "subdomain": "blog",
 *       "enableSsl": true,
 *       "authMode": "alb-oidc"
 *     }
 *   }
 * }
 * }</pre>
 *
 * @since 3.1.0
 * @see CmsSpec
 * @see com.cloudforgeci.api.compute.CmsLoader
 */
public final class CmsServiceTopologyConfiguration implements TopologyConfiguration {

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

    @Override
    public TopologyType kind() { return TopologyType.CMS_SERVICE; }

    @Override
    public String id() { return "topology:CMS_SERVICE"; }

    // -------------------------------------------------------------------------
    // Rules
    // -------------------------------------------------------------------------

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

        // Requires Fargate or EC2
        r.add(ctx -> (ctx.runtime != RuntimeType.FARGATE && ctx.runtime != RuntimeType.EC2)
                ? List.of("CMS_SERVICE requires runtime = fargate or runtime = ec2")
                : List.of());

        // Must specify an application ID that resolves to a registered CmsSpec
        r.add(ctx -> {
            String appId = ctx.cfc.applicationId();
            if (appId == null || appId.isBlank()) {
                return List.of(
                    "CMS_SERVICE requires applicationId in the deployment context (e.g., \"applicationId\": \"wordpress\"). " +
                    "Available CMS IDs: wordpress, woocommerce, magento, drupal, joomla, prestashop, " +
                    "opencart, sylius, bagisto, phpbb, flarum, mybb, mediawiki, moodle, dolphin-una, " +
                    "concrete-cms, october-cms, typo3, suitecrm"
                );
            }
            Optional<CmsSpec> resolved = ctx.cfc.cmsSpec();
            if (resolved.isEmpty()) {
                return List.of(
                    "CMS_SERVICE: unknown application '" + appId + "'. " +
                    "Ensure the CmsSpec is registered in META-INF/services/com.cloudforge.core.interfaces.ApplicationSpec."
                );
            }
            return List.of();
        });

        // ALB-OIDC requires TLS
        r.add(ctx -> {
            String mode = ctx.authMode.get().orElse(null);
            Boolean sslEnabled = ctx.sslEnabled.get().orElse(false);
            if (AuthMode.ALB_OIDC == AuthMode.fromString(mode) && !sslEnabled) {
                return List.of("authMode = alb-oidc requires enableSsl = true");
            }
            return List.of();
        });

        // SSL requires FQDN
        r.add(ctx -> {
            Boolean sslEnabled = ctx.sslEnabled.get().orElse(false);
            if (!sslEnabled) return List.of();
            String fqdn = ctx.fqdn.get().orElse(null);
            boolean hasFqdn = fqdn != null && !fqdn.isBlank();
            String subdomain = ctx.subdomain.get().orElse(null);
            String domain = ctx.domain.get().orElse(null);
            boolean canCompute = subdomain != null && domain != null;
            return (hasFqdn || canCompute)
                    ? List.of()
                    : List.of("enableSsl = true requires fqdn OR (subdomain + domain)");
        });

        // No ASG on Fargate
        boolean isFargate = c.runtime.equals(RuntimeType.FARGATE);
        r.add(when(isFargate, forbid("AutoScalingGroup", x -> x.asg)));

        return r;
    }

    // -------------------------------------------------------------------------
    // Wiring
    // -------------------------------------------------------------------------

    @Override
    public void wire(SystemContext c) {
        Optional<CmsSpec> specOpt = c.cfc.cmsSpec();
        if (specOpt.isEmpty()) {
            LOG.warning("CmsServiceTopologyConfiguration.wire() called but no CmsSpec resolved — skipping CMS wiring");
            wireBaseAutoscalingAndDns(c);
            return;
        }

        CmsSpec spec = specOpt.get();
        LOG.info("CMS_SERVICE topology wiring for: " + spec.displayName() + " (" + spec.applicationId() + ")");

        // ------------------------------------------------------------------
        // 1. S3 media bucket — created eagerly (no AWS resource dependency)
        // ------------------------------------------------------------------
        if (spec.supportsS3MediaStorage()) {
            LOG.info("  [CMS] Provisioning S3 media bucket for " + spec.displayName());
            Bucket mediaBucket = CmsMediaStorageConfiguration.createMediaBucket(c, spec);
            LOG.info("  [CMS] S3 media bucket created");

            // ------------------------------------------------------------------
            // 2. CloudFront CDN — requires ALB (deferred until ALB slot is set)
            // ------------------------------------------------------------------
            if (spec.supportsCdnIntegration()) {
                c.alb.onSet((ApplicationLoadBalancer alb) -> {
                    LOG.info("  [CMS] Provisioning CloudFront CDN for " + spec.displayName());
                    Distribution distribution = CmsCdnConfiguration.createCmsDistribution(
                            c, spec, mediaBucket, alb.getLoadBalancerDnsName());
                    CmsCdnConfiguration.createDnsRecords(c, distribution);
                    LOG.info("  [CMS] CloudFront CDN wired");
                });
            }
        }

        // ------------------------------------------------------------------
        // 3. ElastiCache Redis — created eagerly once VPC is present
        //    (VPC is provisioned before topology wiring runs)
        // ------------------------------------------------------------------
        if (spec.supportsObjectCache() && "redis".equals(spec.preferredCacheBackend())) {
            LOG.info("  [CMS] Provisioning ElastiCache Redis for " + spec.displayName());
            try {
                CfnCacheCluster redis = CmsObjectCacheConfiguration.createRedisCluster(c, spec);
                LOG.info("  [CMS] ElastiCache Redis provisioned: " + redis.getClusterName());
            } catch (IllegalStateException e) {
                // VPC not yet available — this can happen if VPC is provisioned lazily.
                // Log and skip; callers should ensure VPC is ready before wiring.
                LOG.warning("  [CMS] Skipping Redis — VPC not yet available: " + e.getMessage());
            }
        }

        // ------------------------------------------------------------------
        // 4. Auto-scaling — same pattern as ApplicationServiceTopologyConfiguration
        // ------------------------------------------------------------------
        wireBaseAutoscalingAndDns(c);
    }

    // -------------------------------------------------------------------------
    // Shared helpers (mirrors ApplicationServiceTopologyConfiguration)
    // -------------------------------------------------------------------------

    /**
     * Wires ECS/EC2 auto-scaling and ALB DNS records.
     *
     * <p>Identical behaviour to {@link ApplicationServiceTopologyConfiguration}
     * so CMS deployments get the same scaling and DNS wiring.</p>
     */
    private void wireBaseAutoscalingAndDns(SystemContext c) {
        Integer maxCap = c.maxInstanceCapacity.get().orElse(null);
        Integer minCap = c.minInstanceCapacity.get().orElse(null);
        // Respect an explicit enableAutoScaling=false even when the capacity range would otherwise enable it.
        boolean scale = maxCap != null && minCap != null && minCap > 0 && maxCap > 1
            && !Boolean.FALSE.equals(c.cfc.enableAutoScaling());

        if (scale && !c.fargateAutoscalingCallbackRegistered.get().isPresent()) {
            whenBoth(c.fargateService, c.alb, (service, alb) -> {
                if (c.fargateAutoscalingConfigured.get().isPresent()) return;

                Integer min = c.minInstanceCapacity.get().orElse(1);
                Integer max = c.maxInstanceCapacity.get().orElse(1);
                Integer cpuTarget = c.cpuTargetUtilization.get().orElse(60);

                ScalableTaskCount scalable = service.autoScaleTaskCount(
                        EnableScalingProps.builder().minCapacity(min).maxCapacity(max).build());
                scalable.scaleOnCpuUtilization("CmsCpuScale",
                        CpuUtilizationScalingProps.builder()
                                .targetUtilizationPercent(cpuTarget)
                                .scaleInCooldown(Duration.minutes(3))
                                .scaleOutCooldown(Duration.minutes(2))
                                .build());

                c.fargateAutoscalingConfigured.set(true);
                LOG.info("  [CMS] Fargate auto-scaling configured: min=" + min + " max=" + max);
            });
            c.fargateAutoscalingCallbackRegistered.set(true);
        }

        if (scale && !c.ec2AutoscalingCallbackRegistered.get().isPresent()) {
            whenBoth(c.asg, c.albTargetGroup, (asg, tg) -> {
                if (c.asgAddedToTargetGroup.get().isPresent()) return;
                tg.addTarget(asg);
                c.asgAddedToTargetGroup.set(true);
            });
            c.ec2AutoscalingCallbackRegistered.set(true);
        }

        // DNS A + AAAA records pointing to the ALB
        if (c.dnsRecordsCallbackRegistered.get().isPresent()) return;

        whenBoth(c.zone, c.alb, (zone, alb) -> {
            if (c.dnsRecordsCreated.get().isPresent()) return;

            String record = c.subdomain.get().orElse(null);
            if (record == null || record.isBlank()) {
                record = c.domain.get().orElse(null);
                if (record == null || record.isBlank()) return;
            }

            var target = RecordTarget.fromAlias(new LoadBalancerTarget(alb));
            String prefix = "CmsAlbAlias_" + c.stackName + "_" + c.topology + "_" + c.runtime;

            new ARecord(c, prefix + "A",
                    ARecordProps.builder().zone(zone).recordName(record).target(target).build());
            new AaaaRecord(c, prefix + "AAAA",
                    AaaaRecordProps.builder().zone(zone).recordName(record).target(target).build());

            c.dnsRecordsCreated.set(true);
        });

        c.dnsRecordsCallbackRegistered.set(true);
    }
}