MarketplaceParameterSupport.java

package com.cloudforgeci.api.launch;

import com.cloudforgeci.api.core.DeploymentContext;
import com.cloudforgeci.api.core.iam.ManagerOperatorIamSupport;
import com.cloudforge.core.interfaces.ApplicationSpec;
import com.cloudforge.core.manager.ManagerEnvKeys;

import software.amazon.awscdk.CfnParameter;
import software.amazon.awscdk.Stack;
import software.constructs.Construct;

import java.util.HashMap;
import java.util.Map;

/**
 * For an AWS Marketplace CloudFormation listing of CloudForge Manager (the only application this
 * applies to -- see {@link ManagerOperatorIamSupport#APPLICATION_ID}), replaces the two values a
 * buyer must supply at launch -- {@code cognitoInitialAdminEmail}, {@code managerLicenseKey} --
 * with real {@link CfnParameter}s the AWS Console's CloudFormation launch form renders, instead of
 * requiring them via CDK {@code --context}/cdk.json (meaningless to a buyer who never runs the CDK
 * CLI at all).
 *
 * <p>Whether Marketplace mode applies is never sourced from {@code DeploymentConfig}/{@code
 * --context} -- a field there would be just another value in a user-editable JSON file, settable
 * on a normal deploy by anyone, not something only an actual Marketplace listing build controls.
 * The caller (see {@link com.cloudforgeci.api.deploy.CloudForgeSynthesizer#synthesizeForMarketplace})
 * passes it as a plain method parameter instead, hardcoded {@code true} at that one dedicated call
 * site and never exposed as a setting anywhere else.</p>
 *
 * <p>LicenseSeat is the single licensing model across every deployment channel, including
 * Marketplace (BYOL -- Bring Your Own License -- is an AWS Marketplace-supported pricing model
 * under which the seller's own licensing system, not Marketplace's native entitlement mechanism,
 * governs the license), so {@code managerLicenseKey} belongs in the launch form the same as
 * {@code cognitoInitialAdminEmail}.</p>
 *
 * <p>Deliberately value-only, not structural: both existing consumers ({@code
 * CognitoAuthenticationFactory}/{@code ContainerFactory} for the admin email, {@code
 * ApplicationFactory} for the license key) already gate resource creation on "is this
 * non-null/non-empty" -- a CDK Token string (what {@code CfnParameter#getValueAsString()} returns
 * at synth time, before the buyer's real value exists) is always non-null and non-empty, so those
 * synth-time structural decisions are completely unaffected; only the literal value each resource
 * ends up holding changes, resolved by CloudFormation at deploy time from what the buyer actually
 * typed in.</p>
 *
 * <p>Can't override the stack's own node context: CDK's {@code Node.setContext} refuses once the
 * node already has children, and the two {@link CfnParameter}s this creates are themselves
 * children of the stack the moment they're built. Returns a small nested scope instead -- created
 * fresh, so it has no children yet when its context is set -- for the caller to build the
 * application's factories under in place of the stack directly. Each factory resolves its own
 * {@link DeploymentContext} independently via {@code DeploymentContext.from(this)} in {@code
 * BaseFactory}'s constructor (not a shared instance), so building them under this scope is enough
 * for the existing {@code @DeploymentContext} injection to pick up the overridden values for
 * free, with no change needed in any of those factories. This does shift every resource's logical
 * id by one construct-path segment relative to a non-Marketplace deploy of the same app -- fine
 * for a brand new feature with no prior deployment to preserve continuity with, but would be a
 * breaking change (CloudFormation replaces rather than updates) for anything already deployed, so
 * never make this unconditional.</p>
 *
 * <p>Never validates the key's content -- this library has no way to check a LicenseSeat key is
 * real, only that some value was supplied, identical to how {@code --context
 * managerLicenseKey=...} already works today. Actual license verification happens at runtime
 * inside cloudforge-manager's own compiled binary against LicenseSeat's servers, not here.</p>
 */
final class MarketplaceParameterSupport {

    private static final String SSO_DESCRIPTION =
        "CloudForge Manager with Cognito sign-in. Enter the administrator's email address and a "
            + "unique Cognito domain prefix; Cognito emails that address a temporary password for "
            + "the first login.";

    private static final String TOKEN_DESCRIPTION =
        "CloudForge Manager with setup-token sign-in (no Cognito). After launch, Manager writes a "
            + "one-time setup token to its CloudWatch log at startup. Open the Manager URL from the "
            + "stack Outputs and enter that token (X-CFC-TOKEN) in the setup wizard to create the "
            + "first administrator.";

    private MarketplaceParameterSupport() {
    }

    /**
     * Returns {@code stack} unchanged unless {@code variant} is non-null and {@code
     * applicationSpec} is CloudForge Manager -- every other application, and every non-Marketplace
     * CloudForge Manager deployment, keeps sourcing these values from CDK context exactly as
     * before, with the caller building the application directly under the stack as usual.
     * {@link MarketplaceVariant#SSO} adds required {@code AdminEmail}/{@code CognitoDomainPrefix}
     * parameters and provisions Cognito; {@link MarketplaceVariant#TOKEN} adds neither and
     * disables auth, so the first administrator is created with Manager's one-time setup token.
     */
    static Construct applyIfApplicable(
            Stack stack, DeploymentContext cfc, ApplicationSpec applicationSpec, MarketplaceVariant variant) {
        if (variant == null) {
            return stack;
        }
        if (!ManagerOperatorIamSupport.APPLICATION_ID.equals(applicationSpec.applicationId())) {
            return stack;
        }

        // No minLength(1), and defaultValue("") so CloudFormation doesn't treat this as a required
        // parameter: a buyer may launch the stack before they have a key in hand and activate
        // licensing later from the Settings -> License page. ApplicationFactory's
        // LicenseKeySecret guards the deploy-time-resolved value too (an Fn::If against a
        // CfnCondition, not just the Java-side isBlank() check, which can't see an empty
        // CfnParameter token at synth time) -- see that block's own comment for why both guards
        // are needed.
        CfnParameter licenseKey = CfnParameter.Builder.create(stack, "LicenseKey")
            .type("String")
            .description("LicenseSeat license key (CFC-XXXX-XXXX-XXXX-XXXX) from your AWS Marketplace "
                + "purchase. Optional at launch -- activate it later from Settings -> License if you "
                + "don't have one yet.")
            .defaultValue("")
            .noEcho(true)
            .build();

        Map<String, Object> overridden = new HashMap<>(cfc.raw());
        overridden.put("managerLicenseKey", licenseKey.getValueAsString());

        if (variant == MarketplaceVariant.SSO) {
            stack.getTemplateOptions().setDescription(SSO_DESCRIPTION);

            CfnParameter adminEmail = CfnParameter.Builder.create(stack, "AdminEmail")
                .type("String")
                .description("Email address for the initial CloudForge Manager administrator. "
                    + "Cognito emails a temporary password to this address.")
                .minLength(3)
                .allowedPattern("^.+@.+$")
                .constraintDescription("Must be a valid email address")
                .build();

            CfnParameter cognitoDomainPrefix = CfnParameter.Builder.create(stack, "CognitoDomainPrefix")
                .type("String")
                .description("Globally unique prefix for the Cognito sign-in domain "
                    + "(lowercase letters, numbers and hyphens).")
                .minLength(1)
                .maxLength(63)
                .allowedPattern("^[a-z0-9][a-z0-9-]*$")
                .constraintDescription("Must be lowercase letters, numbers and hyphens, "
                    + "starting with a letter or number")
                .build();

            overridden.put("authMode", "application-oidc");
            overridden.put("cognitoAutoProvision", true);
            overridden.put("cognitoInitialAdminEmail", adminEmail.getValueAsString());
            overridden.put("cognitoAdminGroupName", ManagerEnvKeys.DEFAULT_OIDC_ADMIN_GROUP);
            overridden.put("cognitoUserGroupName", ManagerEnvKeys.DEFAULT_OIDC_MANAGER_GROUP);
            overridden.put("cognitoDomainPrefix", cognitoDomainPrefix.getValueAsString());
        } else {
            stack.getTemplateOptions().setDescription(TOKEN_DESCRIPTION);

            overridden.put("authMode", "none");
            overridden.put("cognitoAutoProvision", false);
        }

        // Defaults to true for this Marketplace listing only -- DeploymentConfig#managerDirectDeployEnabled
        // is REQUIRES_APPROVAL/EXPERIMENTAL and false elsewhere, since it grants Manager's task role
        // real CFN CreateStack/UpdateStack + Service Catalog ProvisionProduct permissions. A
        // Marketplace buyer is specifically subscribing to use Manager's deploy capability, so the
        // approval this flag normally requires is implicit in the purchase itself.
        overridden.put("managerDirectDeployEnabled", true);

        Construct appScope = new Construct(stack, "App");
        appScope.getNode().setContext("cfc", overridden);
        return appScope;
    }
}