ServiceCatalogDeployer.java

package com.cloudforgeci.api.deploy.catalog;

import com.cloudforge.core.local.DeploymentTarget;
import com.cloudforge.core.manager.ManagerEndpointSupport;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.servicecatalog.ServiceCatalogClient;
import software.amazon.awssdk.services.servicecatalog.model.DescribeRecordRequest;
import software.amazon.awssdk.services.servicecatalog.model.DescribeRecordResponse;
import software.amazon.awssdk.services.servicecatalog.model.ProvisionProductRequest;
import software.amazon.awssdk.services.servicecatalog.model.ProvisionProductResponse;
import software.amazon.awssdk.services.servicecatalog.model.ProvisioningParameter;
import software.amazon.awssdk.services.servicecatalog.model.RecordDetail;
import software.amazon.awssdk.services.servicecatalog.model.RecordError;
import software.amazon.awssdk.services.servicecatalog.model.RecordStatus;
import software.amazon.awssdk.services.servicecatalog.model.TerminateProvisionedProductRequest;
import software.amazon.awssdk.services.servicecatalog.model.TerminateProvisionedProductResponse;
import software.amazon.awssdk.services.servicecatalog.model.UpdateProvisionedProductRequest;
import software.amazon.awssdk.services.servicecatalog.model.UpdateProvisionedProductResponse;
import software.amazon.awssdk.services.servicecatalog.model.UpdateProvisioningParameter;

import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Provisions/terminates AWS Service Catalog products — the {@code deploy:catalog} path (backing
 * the {@code SC_PROVISION} capability). Deliberately simpler than {@code AwsDirectDeployer}: no
 * CDK synthesis, no template of any kind travels through this class — the product's publisher
 * already registered a provisioning artifact (a CloudFormation template) with Service Catalog,
 * so this only ever calls {@code ProvisionProduct}/{@code TerminateProvisionedProduct} with
 * parameters, then polls {@code DescribeRecord} until Service Catalog's own provisioning engine
 * (which itself just runs a CloudFormation stack update behind the scenes, on a launch-
 * constraint role that is <em>not</em> Manager's) reaches a terminal state — same "poll until
 * terminal" shape {@code AwsDirectDeployer}'s change-set wait already uses, just against a
 * different AWS API with no built-in SDK waiter to reuse.
 *
 * <p><b>Idempotency is native here, unlike the CFN path</b>: {@code ProvisionProductRequest
 * .provisionToken()}/{@code TerminateProvisionedProductRequest.terminateToken()} are real AWS API
 * idempotency tokens — passing the same token for a retried call returns the same record instead
 * of provisioning twice. Callers should still pass a real token (this class doesn't generate one
 * itself) so that guarantee actually applies.</p>
 *
 * <p><b>{@code update} only supports explicit new parameter values</b>, not Service Catalog's
 * {@code UpdateProvisioningParameter.usePreviousValue} — see {@link UpdateProvisionedProductInput}'s
 * javadoc for what that means for parameters an update call omits.</p>
 *
 * <p><b>Targets real AWS by default, but redirects to a local emulator when this Manager instance
 * is itself running inside one</b> — same {@code LOCALSTACK_ENDPOINT}/{@code AWS_ENDPOINT_URL}
 * detection {@code AwsDirectDeployer} uses, mirrored here for the same reason: this class
 * originally always used the default credential chain with no endpoint override, which would have
 * hit the exact same "calls real AWS from inside LocalStack" bug {@code AwsDirectDeployer} was
 * caught doing, the moment Service Catalog is ever exposed by a local emulator this deploys
 * against (it isn't yet on this repo's LocalStack setup — see the plan's 2026-08-11 note — so this
 * specific redirect is currently unexercised, but is not silently wrong the day that changes).</p>
 */
public final class ServiceCatalogDeployer implements AutoCloseable {

    private static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(30);
    private static final Duration POLL_INTERVAL = Duration.ofSeconds(2);

    private final ServiceCatalogClient client;

    /**
     * Defaults {@code target} to {@link DeploymentTarget#AWS} — this constructor predates a
     * target parameter existing at all, and every caller today is a real-AWS Service Catalog
     * operation; prefer the 2-arg overload for any new caller so the target is explicit rather
     * than assumed.
     */
    public ServiceCatalogDeployer(String region) {
        this(region, DeploymentTarget.AWS);
    }

    /**
     * Real AWS by default; redirects to a local emulator instead when {@code target} resolves to
     * one via {@link ManagerEndpointSupport#resolveLocalEmulatorEndpoint} — see class javadoc.
     * {@code target} must be the caller's own already-known, validated target (never re-derived
     * from env vars here — see that method's own javadoc for why).
     */
    public ServiceCatalogDeployer(String region, DeploymentTarget target) {
        this(client(region, target));
    }

    private static ServiceCatalogClient client(String region, DeploymentTarget target) {
        Region resolvedRegion = Region.of(region == null || region.isBlank() ? "us-east-1" : region);
        String localEndpoint = ManagerEndpointSupport.resolveLocalEmulatorEndpoint(target);
        if (localEndpoint == null) {
            return ServiceCatalogClient.builder()
                .region(resolvedRegion)
                .credentialsProvider(DefaultCredentialsProvider.create())
                .build();
        }
        return ServiceCatalogClient.builder()
            .region(resolvedRegion)
            .endpointOverride(URI.create(localEndpoint))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create("test", "test")))
            .build();
    }

    /** Visible for tests — inject a pre-built client (mock, or pointed at a test endpoint). */
    ServiceCatalogDeployer(ServiceCatalogClient client) {
        this.client = client;
    }

    /**
     * Calls {@code ProvisionProduct} and polls until the record reaches {@code SUCCEEDED} or
     * {@code FAILED}.
     *
     * @param idempotencyToken passed as {@code provisionToken} — see class javadoc
     * @throws IOException if the record reaches {@code FAILED}, or polling times out after 30
     *     minutes
     */
    public ProvisionProductResult provision(ProvisionProductInput input, String idempotencyToken)
            throws IOException {
        List<ProvisioningParameter> parameters = input.parameters().entrySet().stream()
            .map(entry -> ProvisioningParameter.builder().key(entry.getKey()).value(entry.getValue()).build())
            .toList();

        ProvisionProductResponse response = client.provisionProduct(ProvisionProductRequest.builder()
            .productId(input.productId())
            .provisioningArtifactId(input.provisioningArtifactId())
            .provisionedProductName(input.provisionedProductName())
            .provisioningParameters(parameters)
            .provisionToken(idempotencyToken)
            .build());

        return waitForTerminal(response.recordDetail().recordId());
    }

    /**
     * Calls {@code TerminateProvisionedProduct} and polls until the record reaches {@code
     * SUCCEEDED} or {@code FAILED}.
     *
     * @param idempotencyToken passed as {@code terminateToken} — see class javadoc
     */
    public ProvisionProductResult terminate(String provisionedProductId, String idempotencyToken)
            throws IOException {
        TerminateProvisionedProductResponse response = client.terminateProvisionedProduct(
            TerminateProvisionedProductRequest.builder()
                .provisionedProductId(provisionedProductId)
                .terminateToken(idempotencyToken)
                .build());

        return waitForTerminal(response.recordDetail().recordId());
    }

    /**
     * Calls {@code UpdateProvisionedProduct} and polls until the record reaches {@code
     * SUCCEEDED} or {@code FAILED}. Only explicit new parameter values are supported — see
     * {@link UpdateProvisionedProductInput}'s javadoc.
     *
     * @param idempotencyToken passed as {@code updateToken} — see class javadoc
     */
    public ProvisionProductResult update(UpdateProvisionedProductInput input, String idempotencyToken)
            throws IOException {
        List<UpdateProvisioningParameter> parameters = input.parameters().entrySet().stream()
            .map(entry -> UpdateProvisioningParameter.builder()
                .key(entry.getKey())
                .value(entry.getValue())
                .build())
            .toList();

        UpdateProvisionedProductRequest.Builder builder = UpdateProvisionedProductRequest.builder()
            .provisionedProductId(input.provisionedProductId())
            .provisioningParameters(parameters)
            .updateToken(idempotencyToken);
        if (input.provisioningArtifactId() != null && !input.provisioningArtifactId().isBlank()) {
            builder.provisioningArtifactId(input.provisioningArtifactId());
        }

        UpdateProvisionedProductResponse response = client.updateProvisionedProduct(builder.build());
        return waitForTerminal(response.recordDetail().recordId());
    }

    private ProvisionProductResult waitForTerminal(String recordId) throws IOException {
        Instant deadline = Instant.now().plus(OPERATION_TIMEOUT);
        DescribeRecordRequest request = DescribeRecordRequest.builder().id(recordId).build();
        while (Instant.now().isBefore(deadline)) {
            DescribeRecordResponse response = client.describeRecord(request);
            RecordStatus status = response.recordDetail().status();
            if (status == RecordStatus.SUCCEEDED) {
                return toResult(response);
            }
            if (status == RecordStatus.FAILED) {
                throw new IOException("Service Catalog record " + recordId + " failed: "
                    + describeErrors(response.recordDetail()));
            }
            try {
                Thread.sleep(POLL_INTERVAL);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new IOException("Interrupted while waiting for Service Catalog record " + recordId, e);
            }
        }
        throw new IOException("Timed out waiting for Service Catalog record " + recordId);
    }

    private static ProvisionProductResult toResult(DescribeRecordResponse response) {
        RecordDetail detail = response.recordDetail();
        Map<String, String> outputs = new LinkedHashMap<>();
        response.recordOutputs().forEach(output -> outputs.put(output.outputKey(), output.outputValue()));
        List<String> messages = List.of(); // no error messages on a SUCCEEDED record
        return new ProvisionProductResult(detail.recordId(), detail.provisionedProductId(), outputs, messages);
    }

    private static String describeErrors(RecordDetail detail) {
        List<RecordError> errors = detail.recordErrors();
        if (errors == null || errors.isEmpty()) {
            return "no error detail returned";
        }
        return errors.stream()
            .map(error -> error.code() + ": " + error.description())
            .reduce((a, b) -> a + "; " + b)
            .orElse("no error detail returned");
    }

    @Override
    public void close() {
        client.close();
    }
}