DeploymentContextCatalog.java

package com.cloudforgeci.api.deploy;

import com.cloudforge.core.config.DeploymentConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Persists per-stack deployment context snippets for Manager multi-stack enrichment.
 */
public final class DeploymentContextCatalog {
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private DeploymentContextCatalog() {
    }

    /**
     * Write {@code deployment-contexts/{stackName}.json} and optional Manager volume copies.
     *
     * @return paths written (primary catalog first)
     */
    public static List<Path> persist(
            DeploymentConfig config,
            Path catalogDirectory,
            List<String> managerVolumeRoots) throws IOException {
        if (config == null || config.stackName == null || config.stackName.isBlank()) {
            return List.of();
        }

        Map<String, Object> body = catalogBody(config);
        List<Path> written = new ArrayList<>();

        Files.createDirectories(catalogDirectory);
        Path primary = catalogDirectory.resolve(config.stackName + ".json");
        MAPPER.writeValue(primary.toFile(), body);
        written.add(primary);
        registerKnownStack(catalogDirectory.resolveSibling("panel-stacks.json"), config.stackName);

        if (managerVolumeRoots != null) {
            for (String volumeRoot : managerVolumeRoots) {
                if (volumeRoot == null || volumeRoot.isBlank()) {
                    continue;
                }
                Path managerCatalogDir = Path.of(volumeRoot)
                    .resolve("CloudForgeManager-Dev")
                    .resolve("managerData")
                    .resolve("deployment-contexts");
                try {
                    Files.createDirectories(managerCatalogDir);
                    Path managerFile = managerCatalogDir.resolve(config.stackName + ".json");
                    MAPPER.writeValue(managerFile.toFile(), body);
                    written.add(managerFile);
                    registerKnownStack(managerCatalogDir.resolveSibling("panel-stacks.json"), config.stackName);
                } catch (IOException ignored) {
                    // volume may not exist until Manager itself is deployed
                }
            }
        }
        return List.copyOf(written);
    }

    /**
     * Upserts {@code stackName} into {@code panel-stacks.json} (the AWS-target inventory
     * allow-list {@code StackListingPolicy}/{@code CatalogService} read — see
     * {@code cloudforge-manager}'s {@code StackListingPolicy.loadPanelStacksAllowList} and
     * {@code CatalogService.resolvePanelStacksPath}, which both expect it as a sibling of the
     * {@code deployment-contexts/} directory). Nothing wrote this file before — it was a
     * pure manual allow-list, so any stack not carrying {@code cloudforge:managed}/
     * {@code cloudforge:application} tags (the primary mechanism — see
     * {@code ApplicationFargateStack}/{@code ApplicationEc2Stack}) stayed invisible in AWS
     * inventory until someone hand-edited this file. Best-effort: a merge race with another
     * concurrent deploy can only ever lose an *addition*, never corrupt the file or drop an
     * existing entry — worst case that other stack is picked up on the next deploy/write.
     */
    private static void registerKnownStack(Path panelStacksFile, String stackName) {
        try {
            Set<String> stacks = new LinkedHashSet<>();
            if (Files.isRegularFile(panelStacksFile)) {
                JsonNode root = MAPPER.readTree(panelStacksFile.toFile());
                JsonNode arr = root.isArray() ? root : root.path("stacks");
                if (arr.isArray()) {
                    for (JsonNode node : arr) {
                        if (node.isTextual() && !node.asText().isBlank()) {
                            stacks.add(node.asText().trim());
                        }
                    }
                }
            }
            if (!stacks.add(stackName)) {
                return; // already known — avoid an unnecessary write
            }
            // getParent() is null when panelStacksFile is itself a bare single-segment relative
            // path (e.g. catalogDirectory == Path.of("deployment-contexts"), whose resolveSibling
            // is just Path.of("panel-stacks.json") — no parent to create; write directly into the
            // process's working directory in that case.
            Path parent = panelStacksFile.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }
            MAPPER.writeValue(panelStacksFile.toFile(), Map.of("stacks", List.copyOf(stacks)));
        } catch (IOException | RuntimeException ignored) {
            // Best-effort, deliberately broad: this must never propagate. It previously only
            // caught IOException, and a null-parent NullPointerException (the case this method
            // now guards against above) escaped uncaught all the way out of
            // CloudForgeDeployment.finalizeResult — aborting the deploy's emulator-edge
            // reconciliation that runs *after* the catalog-persist block, not just this file
            // write. Tag-based AWS inventory matching remains the primary mechanism regardless.
        }
    }

    static Map<String, Object> catalogBody(DeploymentConfig config) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("stackName", config.stackName);
        body.put("applicationId", config.applicationId);
        body.put("applicationName", config.applicationName != null
            ? config.applicationName
            : config.applicationId);
        body.put("environment", config.environment);
        body.put("runtime", config.runtime == null ? null : config.runtime.name());
        body.put("authMode", config.authMode == null ? null : config.authMode.getValue());

        // Compliance/security posture, so a not-yet-deployed catalog entry (source: "local",
        // no CFN outputs to read from yet) carries the same fields a live instance gets from
        // FargateFactory.createDeploymentMetadataOutputs() — see CatalogService.applyContext /
        // applyCloudFormationMetadata on the Manager side, which read these under matching keys.
        body.put("topology", config.topology == null ? null : config.topology.name());
        body.put("securityProfile", config.securityProfile == null ? null : config.securityProfile.name());
        body.put("complianceMode", config.complianceMode == null ? null : config.complianceMode.name());
        body.put("complianceFrameworks", config.complianceFrameworks == null ? List.of()
            : config.complianceFrameworks.stream().map(Enum::name).toList());
        body.put("region", config.region);
        body.put("domain", config.domain);
        body.put("fqdn", config.fqdn);
        return body;
    }
}