diff --git a/deno.lock b/deno.lock index b4fe51f..eb24e09 100644 --- a/deno.lock +++ b/deno.lock @@ -13,6 +13,7 @@ "jsr:@std/fmt@^1.0.8": "1.0.8", "jsr:@std/fmt@~1.0.2": "1.0.8", "jsr:@std/fs@^1.0.14": "1.0.15", + "jsr:@std/json@1": "1.0.2", "jsr:@std/jsonc@^1.0.1": "1.0.1", "jsr:@std/path@^1.0.8": "1.0.8", "jsr:@std/streams@^1.0.9": "1.0.9", @@ -20,6 +21,7 @@ "jsr:@std/text@~1.0.7": "1.0.14", "npm:@trpc/client@^11.0.2": "11.0.2_@trpc+server@11.0.2__typescript@5.8.2_typescript@5.8.2", "npm:@trpc/server@^11.0.2": "11.0.2_typescript@5.8.2", + "npm:@types/prompts@2.4.9": "2.4.9", "npm:jsonc-parser@^3.3.1": "3.3.1", "npm:keychain@1.5.0": "1.5.0", "npm:open@^10.1.0": "10.1.0", @@ -83,8 +85,14 @@ "jsr:@std/path" ] }, + "@std/json@1.0.2": { + "integrity": "d9e5497801c15fb679f55a2c01c7794ad7a5dfda4dd1bebab5e409cb5e0d34d4" + }, "@std/jsonc@1.0.1": { - "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda" + "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda", + "dependencies": [ + "jsr:@std/json" + ] }, "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" @@ -116,6 +124,19 @@ "typescript" ] }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "@types/prompts@2.4.9": { + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dependencies": [ + "@types/node", + "kleur" + ] + }, "bundle-name@4.1.0": { "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dependencies": [ @@ -201,6 +222,9 @@ "typescript@5.8.2": { "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "bin": true + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" } }, "workspace": { diff --git a/main.ts b/main.ts index 79a6c6e..019de58 100644 --- a/main.ts +++ b/main.ts @@ -3,7 +3,7 @@ import { publish } from "./publish.ts"; import { red } from "@std/fmt/colors"; import { create } from "./create.ts"; import { withApp } from "./util.ts"; -import { setupAws } from "./setup-cloud.ts"; +import { setupAws, setupGcp } from "./setup-cloud.ts"; import { getAppFromConfig, readConfig } from "./config.ts"; const createCommand = new Command<{ endpoint: string }>() @@ -39,6 +39,20 @@ const setupAWSCommand = new Command() await setupAws(options.org, options.app, contextList); }); +const setupGCPCommand = new Command() + .option("--org ", "The name of the org", { required: true }) + .option("--app ", "The name of the app", { required: true }) + .arguments("[contexts:string]") + .action(async (options, contexts) => { + const contextList = contexts + ? contexts.split(",").map((c) => + c.trim().toLowerCase().replaceAll(" ", "-") + ) + : []; + + await setupGcp(options.org, options.app, contextList); + }); + await new Command() .globalOption("--endpoint [endpoint:string]", "the endpoint", { default: "https://app.deno.com", @@ -73,4 +87,5 @@ await new Command() ) .command("create", createCommand) .command("setup-aws", setupAWSCommand) + .command("setup-gcp", setupGCPCommand) .parse(Deno.args); diff --git a/setup-cloud.ts b/setup-cloud.ts index a692dee..8fede7a 100644 --- a/setup-cloud.ts +++ b/setup-cloud.ts @@ -44,12 +44,63 @@ async function runAwsCommand(args: string[]): Promise { } } +async function runGcloudCommand(args: string[]): Promise { + try { + const output = await new Deno.Command("gcloud", { + args: [...args, "--format=json"], + stdout: "piped", + stderr: "inherit", + stdin: "inherit", + }).output(); + if (!output.success) Deno.exit(output.code); + if (output.stdout.length === 0) return {} as T; + const decoder = new TextDecoder(); + const json = decoder.decode(output.stdout); + try { + return JSON.parse(json) as T; + } catch (_) { + console.error( + "%cError%c Failed to parse JSON output from gcloud CLI command:", + "color: red;", + "color: reset;", + json, + ); + Deno.exit(1); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.error( + "%cError%c gcloud CLI is not installed or not found in PATH.\n\n" + + "Please install the gcloud CLI before running this command:\n" + + " • Visit: https://cloud.google.com/sdk/docs/install\n", + "color: red; font-weight: bold;", + "color: reset;", + ); + Deno.exit(1); + } + throw error; + } +} + interface AwsInfo { Account: string; UserId: string; Arn: string; } +interface GcpProjectInfo { + projectId: string; + name: string; + projectNumber: string; +} + +interface GcpService { + config: { + name: string; + title: string; + }; +} + function log(string: string) { Deno.stdout.writeSync(new TextEncoder().encode(string)); } @@ -327,3 +378,428 @@ export async function setupAws(org: string, app: string, contexts: string[]) { ), ); } + +export async function setupGcp(org: string, app: string, contexts: string[]) { + // Print out "GCP Setup Wizard for Deno Deploy" in a blue box + console.log( + "%c %c\n%c GCP Setup Wizard for Deno Deploy %c\n%c %c", + "background-color: blue; color: white; font-weight: bold;", + "background-color: reset; color: reset; font-weight: normal;", + "background-color: blue; color: white; font-weight: bold;", + "background-color: reset; color: reset; font-weight: normal;", + "background-color: blue; color: white; font-weight: bold;", + "background-color: reset; color: reset; font-weight: normal;", + ); + console.log(); + + // Check if gcloud CLI is installed and that the user is authenticated + log(gray(" Checking GCP account configuration...")); + const accountList = await runGcloudCommand< + Array<{ account: string; status: string }> + >(["auth", "list", "--filter=status:ACTIVE"]); + if (!accountList || accountList.length === 0) { + console.error( + "%cError%c No active GCP account found. Please run 'gcloud auth login' first.", + "color: red; font-weight: bold;", + "color: reset;", + ); + Deno.exit(1); + } + const accountInfo = accountList[0]; + + const projectId = await runGcloudCommand([ + "config", + "get-value", + "project", + ]); + if (!projectId) { + console.error( + "%cError%c No GCP project set. Please run 'gcloud config set project PROJECT_ID' first.", + "color: red; font-weight: bold;", + "color: reset;", + ); + Deno.exit(1); + } + + // Get project details including project number + const projectInfo = await runGcloudCommand([ + "projects", + "describe", + projectId, + ]); + + log( + `\r${green("✔ Authenticated")} to GCP project ${ + yellow(projectInfo.projectId) + } with account ${yellow(accountInfo.account)}\n`, + ); + + // Check if required APIs are enabled + log(gray(" Checking required APIs...")); + const requiredApis = [ + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "sts.googleapis.com", + ]; + + const missingApis = []; + const services = await runGcloudCommand< + Array + >( + [ + "services", + "list", + "--enabled", + "--filter=name:(" + requiredApis.join() + ")", + ], + ); + const enabledApis = new Set(services.map((s) => s.config.name)); + for (const api of requiredApis) { + if (!enabledApis.has(api)) missingApis.push(api); + } + + if (missingApis.length > 0) { + console.log(`\r${yellow("⚠ Missing APIs")} detected `); + console.log(""); + console.log("The following APIs need to be enabled:"); + for (const api of missingApis) { + console.log(` • ${api}`); + } + console.log(""); + + const { enableApis } = await prompt({ + type: "confirm", + name: "enableApis", + message: "Do you want to enable these APIs now?", + initial: true, + }); + + if (!enableApis) { + console.log( + "%c APIs are required for GCP integration. Exiting setup.", + "color: yellow;", + ); + Deno.exit(1); + } + + log(gray(" Enabling required APIs...")); + for (const api of missingApis) { + await runGcloudCommand([ + "services", + "enable", + api, + "--no-user-output-enabled", + ]); + } + console.log(`\r${green("✔ Enabled")} required APIs `); + } else { + console.log(`\r${green("✔ APIs")} are enabled `); + } + + const gcpWorkloadIdentityId = OIDC_PROVIDER_DOMAIN.replace(/\./g, "-"); + + // Check if the Workload Identity Pool already exists + log(gray(" Checking workload identity pool...")); + const pools = await runGcloudCommand<{ name: string; displayName: string }[]>( + [ + "iam", + "workload-identity-pools", + "list", + "--filter=name:" + gcpWorkloadIdentityId, + "--location=global", + ], + ); + const workloadIdentityPoolExists = pools.some((pool) => + pool.name.endsWith(`/` + gcpWorkloadIdentityId) + ); + log(gray("\r Checking workload identity provider...")); + const providers = await runGcloudCommand<{ + name: string; + displayName: string; + }[]>( + [ + "iam", + "workload-identity-pools", + "providers", + "list", + "--workload-identity-pool=" + gcpWorkloadIdentityId, + "--location=global", + ], + ); + const workloadIdentityProviderExists = providers.some((provider) => + provider.name.endsWith(`/${gcpWorkloadIdentityId}`) + ); + console.log("\r "); + + log( + gray( + " To set up GCP with Deno Deploy, a workload identity pool and service\n account need to be created. The service account will be granted\n permissions to access GCP resources.\n\n", + ), + ); + + // List available IAM roles for selection + log(gray(" Loading IAM roles...")); + const roles = await runGcloudCommand>( + ["iam", "roles", "list", "--filter=stage:GA"], + ); + log("\r"); + + const roleChoices = roles.map((role) => ({ + title: `${role.title} (${role.name.split("/").pop()})`, + value: role.name, + })); + + const { selectedRoles } = await prompt({ + type: "autocompleteMultiselect", + name: "selectedRoles", + message: "Select IAM roles you want to grant to the service account", + choices: roleChoices, + hint: "- Space to select. Return to submit", + instructions: false, + }); + + if (selectedRoles === undefined) { + console.log("%c Exiting setup.", "color: yellow;"); + Deno.exit(1); + } + + if (selectedRoles.length === 0) { + console.log( + "%c No roles selected. You can grant roles later through the GCP Console.", + "color: yellow;", + ); + } + + // service account name must be between 6 and 30 characters, lowercase, and can contain letters, numbers, and dashes + let serviceAccountName = "deno-"; + const orgPart = org.slice(0, 8).replaceAll(/-+$/g, ""); + const appPart = app.slice(0, 17 - orgPart.length).replaceAll(/-+$/g, ""); + serviceAccountName += `${orgPart}-${appPart}-${ + Math.random().toString(36).substring(2, 8) + }`; + + const serviceAccountEmail = + `${serviceAccountName}@${projectId}.iam.gserviceaccount.com`; + + console.log( + "\n%cThe following resources will be created:\n", + "color: gray;", + ); + + if (!workloadIdentityPoolExists) { + console.log( + ` %c+ create%c workload identity pool %c${gcpWorkloadIdentityId}`, + "color: green;", + "color: gray;", + "color: blue;", + ); + } else { + console.log( + ` %c~ no modification to the existing workload identity pool %c${gcpWorkloadIdentityId}`, + "color: gray;", + "color: blue;", + ); + } + + if (!workloadIdentityProviderExists) { + console.log( + ` %c+ create%c workload identity provider %c${gcpWorkloadIdentityId}%c for %chttps://${OIDC_PROVIDER_DOMAIN}`, + "color: green;", + "color: gray;", + "color: blue;", + "color: gray;", + "color: blue;", + ); + } else { + console.log( + ` %c~ no modification to the existing workload identity provider %c${gcpWorkloadIdentityId}`, + "color: gray;", + "color: blue;", + ); + } + console.log( + ` %c+ create%c workload identity provider %c${gcpWorkloadIdentityId}%c for %chttps://${OIDC_PROVIDER_DOMAIN}`, + "color: green;", + "color: gray;", + "color: blue;", + "color: gray;", + "color: blue;", + ); + + console.log( + ` %c+ create%c service account %c${serviceAccountEmail}`, + "color: green;", + "color: gray;", + "color: blue;", + ); + + console.log( + ` %c+ allow%c workload identity for Deno Deploy project %c${org}/${app}%c in ${ + contexts.length === 0 ? "%call%c " : "%c%c" + }context${contexts.length === 1 ? "" : "s"} %c${ + new Intl.ListFormat("en-US").format(contexts) + }%c`, + "color: green;", + "color: gray;", + "color: blue;", + "color: gray;", + "color: blue;", + "color: gray;", + "color: blue;", + "color: gray;", + ); + + for (const role of selectedRoles) { + const roleName = role.split("/").pop(); + console.log( + ` %c+ grant%c role %c${roleName}%c to the service account`, + "color: green;", + "color: gray;", + "color: blue;", + "color: gray;", + ); + } + + console.log(""); + + const { confirm } = await prompt({ + type: "confirm", + name: "confirm", + message: "Do you want to apply these changes?", + initial: true, + }); + + if (!confirm) { + console.log("%c Exiting setup.", "color: yellow;"); + Deno.exit(1); + } + + if (!workloadIdentityPoolExists) { + log(gray(" Creating workload identity pool...")); + await runGcloudCommand([ + "iam", + "workload-identity-pools", + "create", + gcpWorkloadIdentityId, + "--location=global", + "--display-name=Deno Deploy Workload Identity Pool", + "--description=Workload Identity Pool for Deno Deploy integration", + "--no-user-output-enabled", + ]); + console.log( + `\r${ + green("✔ Created") + } workload identity pool %c${gcpWorkloadIdentityId}`, + "color: blue;", + ); + } + + if (!workloadIdentityProviderExists) { + log(gray(" Creating workload identity provider...")); + await runGcloudCommand([ + "iam", + "workload-identity-pools", + "providers", + "create-oidc", + gcpWorkloadIdentityId, + "--workload-identity-pool=" + gcpWorkloadIdentityId, + "--location=global", + "--issuer-uri=https://" + OIDC_PROVIDER_DOMAIN, + "--attribute-mapping=google.subject=assertion.sub,attribute.org_id=assertion.org_id,attribute.org_slug=assertion.org_slug,attribute.app_id=assertion.app_id,attribute.app_slug=assertion.app_slug,attribute.full_slug=assertion.org_slug+\"/\"+assertion.app_slug,attribute.context_id=assertion.context_id,attribute.context_name=assertion.context_name", + "--no-user-output-enabled", + ]); + console.log( + `\r${ + green("✔ Created") + } workload identity provider %c${gcpWorkloadIdentityId}`, + "color: blue;", + ); + } + + // Create service account + log(gray(" Creating service account...")); + await runGcloudCommand([ + "iam", + "service-accounts", + "create", + serviceAccountName, + "--display-name=" + `Deno Deploy ${org}/${app}`, + "--description=" + + `Service account for Deno Deploy project ${org}/${app}`, + "--no-user-output-enabled", + ]); + console.log( + `\r${green("✔ Created")} service account %c${serviceAccountEmail}`, + "color: blue;", + ); + + // Configure workload identity binding + log(gray(" Configuring workload identity binding...")); + const principalSet = contexts.length > 0 + ? contexts.map((context) => + `principal://iam.googleapis.com/projects/${projectInfo.projectNumber}/locations/global/workloadIdentityPools/${gcpWorkloadIdentityId}/subject/deployment:${org}/${app}/${context}` + ).join(",") + : `principal://iam.googleapis.com/projects/${projectInfo.projectNumber}/locations/global/workloadIdentityPools/${gcpWorkloadIdentityId}/attribute.full_slug/${org}/${app}`; + + await runGcloudCommand([ + "iam", + "service-accounts", + "add-iam-policy-binding", + serviceAccountEmail, + "--role=roles/iam.workloadIdentityUser", + "--member=" + principalSet, + "--no-user-output-enabled", + ]); + + // Grant selected roles to service account + log(gray("\r Granting roles to service account... ")); + for (const role of selectedRoles) { + await runGcloudCommand([ + "projects", + "add-iam-policy-binding", + projectId, + "--member=serviceAccount:" + serviceAccountEmail, + "--role=" + role, + "--no-user-output-enabled", + ]); + } + + console.log( + `\r${green("✔ Configured")} workload identity and granted roles`, + ); + + const workloadProviderId = + `projects/${projectInfo.projectNumber}/locations/global/workloadIdentityPools/${gcpWorkloadIdentityId}/providers/${gcpWorkloadIdentityId}`; + + console.log(""); + console.log( + "%cGCP Configuration Complete!%c", + "color: green; font-weight: bold;", + "color: reset;", + ); + console.log(""); + console.log("Copy these values for Deno Deploy GCP integration setup:"); + console.log(""); + console.log( + `%cGCP_WORKLOAD_PROVIDER_ID:%c`, + "color: blue; font-weight: bold;", + "color: reset;", + ); + console.log( + ` %c${workloadProviderId}%c`, + "color: blue;", + "color: reset;", + ); + console.log(""); + console.log( + `%cGCP_SERVICE_ACCOUNT_EMAIL:%c`, + "color: blue; font-weight: bold;", + "color: reset;", + ); + console.log( + ` %c${serviceAccountEmail}%c`, + "color: blue;", + "color: reset;", + ); + console.log(""); +}