ServiceCatalogProductPublisher.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.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.BucketAlreadyExistsException;
import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.servicecatalog.ServiceCatalogClient;
import software.amazon.awssdk.services.servicecatalog.model.AssociateProductWithPortfolioRequest;
import software.amazon.awssdk.services.servicecatalog.model.CreateConstraintRequest;
import software.amazon.awssdk.services.servicecatalog.model.CreatePortfolioRequest;
import software.amazon.awssdk.services.servicecatalog.model.CreateProductRequest;
import software.amazon.awssdk.services.servicecatalog.model.CreateProvisioningArtifactRequest;
import software.amazon.awssdk.services.servicecatalog.model.ListPortfoliosRequest;
import software.amazon.awssdk.services.servicecatalog.model.PortfolioDetail;
import software.amazon.awssdk.services.servicecatalog.model.ProductType;
import software.amazon.awssdk.services.servicecatalog.model.ProductViewDetail;
import software.amazon.awssdk.services.servicecatalog.model.ProvisioningArtifactProperties;
import software.amazon.awssdk.services.servicecatalog.model.ProvisioningArtifactType;
import software.amazon.awssdk.services.servicecatalog.model.SearchProductsAsAdminRequest;
import software.amazon.awssdk.services.servicecatalog.model.Tag;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Publishes a CloudForge application's synthesized CloudFormation template as an AWS Service
* Catalog product — the missing counterpart to {@code ServiceCatalogDeployer}, which only ever
* <em>provisions</em> a product someone else already published. Nothing in this codebase had a
* "someone else" until this class: CloudForge's 37 {@code ApplicationSpec}s were deployable via
* {@code deploy:create} but never packageable for {@code deploy:catalog}'s more constrained lane.
*
* <p><b>Not exercised against real AWS or a real Service Catalog backend.</b> LocalStack's own
* {@code _localstack/health} service list has no {@code servicecatalog} entry, unlike
* {@code AwsDirectDeployer}/{@code ServiceCatalogDeployer}, which run against a live LocalStack
* CloudFormation endpoint — so this class has never called any of these APIs against a running
* backend, only been written to match the documented Service Catalog API shape. Treat a real
* publish as the first true validation.</p>
*
* <p><b>Launch constraints are the caller's responsibility, not auto-created here</b>: Service
* Catalog provisions under its own IAM role (a "launch constraint"), separate from whichever
* principal calls {@code ProvisionProduct}. Silently minting a new, broadly-scoped IAM role as a
* side effect of a "publish" click felt like exactly the kind of infrastructure decision that
* deserves an explicit human choice, not an auto-generated default — so {@link #publishProduct}
* accepts an optional {@code launchConstraintRoleArn} and only wires the constraint when one is
* given. A product published without one is real and browsable but not launchable by anyone
* lacking their own sufficient permissions.</p>
*
* <p>Every provisioned product this eventually leads to is tagged with the same {@code
* cloudforge:managed}/{@code cloudforge:application}/{@code cloudforge:runtime} convention {@code
* AwsDirectDeployer} already applies (passed as {@code Tags} on {@code CreateProduct}, which
* Service Catalog propagates onto the CloudFormation stack it creates at provision time) — so a
* catalog-provisioned stack is visible in Manager's Instances list the same way a deploy:create
* one now is, not a second invisible-by-default class of stack.</p>
*/
public final class ServiceCatalogProductPublisher implements AutoCloseable {
private final ServiceCatalogClient serviceCatalog;
private final S3Client s3;
private final String templateBucket;
/**
* Defaults {@code target} to {@link DeploymentTarget#AWS} — no current caller exists (see
* class javadoc's own history), kept as a safe default for whenever one is wired up; prefer
* the 2-arg overload for any new caller so the target is explicit rather than assumed.
*/
public ServiceCatalogProductPublisher(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 ServiceCatalogProductPublisher(String region, DeploymentTarget target) {
this(serviceCatalogClient(region, target), s3Client(region, target), templateBucketName(region));
}
/** Visible for tests — inject pre-built clients. */
ServiceCatalogProductPublisher(ServiceCatalogClient serviceCatalog, S3Client s3, String templateBucket) {
this.serviceCatalog = serviceCatalog;
this.s3 = s3;
this.templateBucket = templateBucket;
}
private static ServiceCatalogClient serviceCatalogClient(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();
}
private static S3Client s3Client(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 S3Client.builder()
.region(resolvedRegion)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
return S3Client.builder()
.region(resolvedRegion)
.endpointOverride(URI.create(localEndpoint))
.forcePathStyle(true)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("test", "test")))
.build();
}
private static String templateBucketName(String region) {
return "cfc-catalog-templates-" + (region == null || region.isBlank() ? "us-east-1" : region);
}
public record PublishResult(
String portfolioId,
String productId,
String provisioningArtifactId,
String templateUrl,
boolean launchConstraintCreated) {
}
/**
* Ensures a portfolio named {@code portfolioName} exists (searches first — {@code
* CreatePortfolio} is not idempotent on its own), and publishes {@code templateBody} as a new
* product (or a new provisioning artifact on an existing product of the same name — Service
* Catalog products are versioned, not replaced).
*
* @param applicationId used only for the {@code cloudforge:application} tag on the resulting
* product/stack — has no other Service Catalog meaning, same role it plays elsewhere
* @param runtimeTag the {@code cloudforge:runtime} tag value (e.g. {@code "fargate"})
*/
public PublishResult publishProduct(
String portfolioName,
String productName,
String applicationId,
String runtimeTag,
String templateBody,
String launchConstraintRoleArn) throws IOException {
String portfolioId = ensurePortfolio(portfolioName);
String templateUrl = uploadTemplate(productName, templateBody);
List<Tag> tags = List.of(
Tag.builder().key("cloudforge:managed").value("true").build(),
Tag.builder().key("cloudforge:application").value(
applicationId == null || applicationId.isBlank() ? "unknown" : applicationId).build(),
Tag.builder().key("cloudforge:runtime").value(
runtimeTag == null || runtimeTag.isBlank() ? "unknown" : runtimeTag).build());
var existingProduct = findProductByName(productName);
String productId;
String provisioningArtifactId;
if (existingProduct.isPresent()) {
productId = existingProduct.get().productViewSummary().productId();
var artifact = serviceCatalog.createProvisioningArtifact(
CreateProvisioningArtifactRequest.builder()
.productId(productId)
.parameters(ProvisioningArtifactProperties.builder()
.name(productName + "-" + System.currentTimeMillis())
.type(ProvisioningArtifactType.CLOUD_FORMATION_TEMPLATE)
.info(Map.of("LoadTemplateFromURL", templateUrl))
.build())
.build());
provisioningArtifactId = artifact.provisioningArtifactDetail().id();
} else {
var created = serviceCatalog.createProduct(CreateProductRequest.builder()
.name(productName)
.owner("CloudForge")
.productType(ProductType.CLOUD_FORMATION_TEMPLATE)
.provisioningArtifactParameters(ProvisioningArtifactProperties.builder()
.name(productName + "-1")
.type(ProvisioningArtifactType.CLOUD_FORMATION_TEMPLATE)
.info(Map.of("LoadTemplateFromURL", templateUrl))
.build())
.tags(tags)
.build());
productId = created.productViewDetail().productViewSummary().productId();
provisioningArtifactId = created.provisioningArtifactDetail().id();
serviceCatalog.associateProductWithPortfolio(AssociateProductWithPortfolioRequest.builder()
.productId(productId)
.portfolioId(portfolioId)
.build());
}
boolean launchConstraintCreated = false;
if (launchConstraintRoleArn != null && !launchConstraintRoleArn.isBlank()) {
Map<String, String> params = new LinkedHashMap<>();
params.put("RoleArn", launchConstraintRoleArn);
serviceCatalog.createConstraint(CreateConstraintRequest.builder()
.portfolioId(portfolioId)
.productId(productId)
.type("LAUNCH")
.parameters(toJson(params))
.build());
launchConstraintCreated = true;
}
return new PublishResult(
portfolioId, productId, provisioningArtifactId, templateUrl, launchConstraintCreated);
}
/** Idempotent: reuses an existing portfolio with the exact same display name if one exists. */
String ensurePortfolio(String portfolioName) {
String nextToken = null;
do {
var response = serviceCatalog.listPortfolios(ListPortfoliosRequest.builder()
.pageToken(nextToken)
.build());
for (PortfolioDetail detail : response.portfolioDetails()) {
if (portfolioName.equals(detail.displayName())) {
return detail.id();
}
}
nextToken = response.nextPageToken();
} while (nextToken != null);
return serviceCatalog.createPortfolio(CreatePortfolioRequest.builder()
.displayName(portfolioName)
.providerName("CloudForge")
.description("CloudForge-published application products")
.build())
.portfolioDetail()
.id();
}
private java.util.Optional<ProductViewDetail> findProductByName(String productName) {
var response = serviceCatalog.searchProductsAsAdmin(SearchProductsAsAdminRequest.builder()
.build());
return response.productViewDetails().stream()
.filter(detail -> productName.equals(detail.productViewSummary().name()))
.findFirst();
}
public record LookupResult(String portfolioId, String productId, String provisioningArtifactId) {
}
/**
* Looks up an already-published product by the exact deterministic name {@link
* com.cloudforgeci.api.compute.ApplicationLoader}'s callers construct from an application's
* own {@code displayName}/{@code applicationId} ({@code "<displayName> (<applicationId>)"} —
* see {@code CatalogPublishService.publishOne}) — reconstructing that same name is how the
* caller asks "has this application been packaged yet?" without needing a separate index.
* Returns empty when no product with that name exists — "not published yet," not an error.
*/
public java.util.Optional<LookupResult> lookupProduct(String portfolioName, String productName) {
var product = findProductByName(productName);
if (product.isEmpty()) {
return java.util.Optional.empty();
}
String productId = product.get().productViewSummary().productId();
String portfolioId = ensurePortfolio(portfolioName);
String artifactId = latestProvisioningArtifactId(productId);
if (artifactId == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new LookupResult(portfolioId, productId, artifactId));
}
/** Most recently created provisioning artifact — the version a fresh provision should use. */
private String latestProvisioningArtifactId(String productId) {
var response = serviceCatalog.describeProductAsAdmin(
software.amazon.awssdk.services.servicecatalog.model.DescribeProductAsAdminRequest.builder()
.id(productId)
.build());
return response.provisioningArtifactSummaries().stream()
.max(java.util.Comparator.comparing(
software.amazon.awssdk.services.servicecatalog.model.ProvisioningArtifactSummary::createdTime))
.map(software.amazon.awssdk.services.servicecatalog.model.ProvisioningArtifactSummary::id)
.orElse(null);
}
private String uploadTemplate(String productName, String templateBody) throws IOException {
ensureTemplateBucket();
String key = productName + "/" + System.currentTimeMillis() + ".template.json";
// Service Catalog's LoadTemplateFromURL only accepts a template it can fetch over HTTP(S)
// — no inline-body option the way CreateChangeSet has, so every publish uploads to S3
// regardless of template size (unlike AwsDirectDeployer, which only uploads when the
// inline limit is exceeded).
s3.putObject(
PutObjectRequest.builder()
.bucket(templateBucket)
.key(key)
.contentType("application/json")
.build(),
RequestBody.fromString(templateBody, StandardCharsets.UTF_8));
return s3.utilities().getUrl(builder -> builder.bucket(templateBucket).key(key)).toString();
}
private void ensureTemplateBucket() {
try {
s3.headBucket(HeadBucketRequest.builder().bucket(templateBucket).build());
} catch (NoSuchBucketException e) {
try {
s3.createBucket(CreateBucketRequest.builder().bucket(templateBucket).build());
} catch (BucketAlreadyExistsException | BucketAlreadyOwnedByYouException ignored) {
// concurrent create
}
}
}
private static String toJson(Map<String, String> params) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (var entry : params.entrySet()) {
if (!first) {
sb.append(',');
}
first = false;
sb.append('"').append(entry.getKey()).append("\":\"").append(entry.getValue()).append('"');
}
return sb.append('}').toString();
}
@Override
public void close() {
serviceCatalog.close();
s3.close();
}
}