diff --git a/.projen/tasks.json b/.projen/tasks.json index f3afaca4..f887fc10 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -373,6 +373,9 @@ "workspace:bin:link": { "name": "workspace:bin:link", "steps": [ + { + "exec": "ln -s $PWD/packages/galileo-cli/bin/.cache $(pnpm bin)/.cache &>/dev/null; exit 0;" + }, { "exec": "ln -s $PWD/packages/galileo-cli/bin/galileo-cli.ts $(pnpm bin)/galileo-cli-experimental &>/dev/null; exit 0;" } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2075d9c9..78d66775 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,7 +22,7 @@ ], // disable eslint extension until can align with monorepo // it just adds noise as commit hook will auto eslint --fix anyways - "eslint.enable": false, + "eslint.enable": true, "editor.codeActionsOnSave": { "source.fixAll": false, "source.organizeImports": false diff --git a/demo/api/generated/runtime/python/poetry.lock b/demo/api/generated/runtime/python/poetry.lock index 3078db68..320e97cf 100644 --- a/demo/api/generated/runtime/python/poetry.lock +++ b/demo/api/generated/runtime/python/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aenum" diff --git a/packages/galileo-cli/.projen/deps.json b/packages/galileo-cli/.projen/deps.json index 0bb79142..ac9d409d 100644 --- a/packages/galileo-cli/.projen/deps.json +++ b/packages/galileo-cli/.projen/deps.json @@ -130,6 +130,11 @@ "version": "^3.391.0", "type": "runtime" }, + { + "name": "@aws-sdk/client-sts", + "version": "^3.391.0", + "type": "runtime" + }, { "name": "@aws-sdk/credential-providers", "version": "^3.391.0", diff --git a/packages/galileo-cli/.projen/tasks.json b/packages/galileo-cli/.projen/tasks.json index 6c125f7f..6528778e 100644 --- a/packages/galileo-cli/.projen/tasks.json +++ b/packages/galileo-cli/.projen/tasks.json @@ -118,13 +118,13 @@ "exec": "pnpm update npm-check-updates" }, { - "exec": "npm-check-updates --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@aws-sdk/types,@oclif/test,@types/chalk,@types/clear,@types/execa,@types/jest,@types/lodash,@types/node-localstorage,@types/node,@types/prompts,@typescript-eslint/eslint-plugin,@typescript-eslint/parser,eslint-config-prettier,eslint-import-resolver-node,eslint-import-resolver-typescript,eslint-plugin-header,eslint-plugin-import,eslint-plugin-prettier,eslint,jest,jest-junit,npm-check-updates,prettier,projen,ts-jest,ts-node,typescript,@aws-sdk/client-s3,@aws-sdk/client-sfn,@aws-sdk/client-ssm,@aws-sdk/credential-providers,@aws-sdk/lib-storage,@oclif/core,@oclif/errors,@oclif/plugin-autocomplete,@oclif/plugin-commands,@oclif/plugin-help,@oclif/plugin-not-found,@oclif/plugin-plugins,@oclif/plugin-update,@oclif/plugin-warn-if-update-available,chalk,clear,execa,figlet,ink,lodash,node-localstorage,prompts" + "exec": "npm-check-updates --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@aws-sdk/types,@oclif/test,@types/chalk,@types/clear,@types/execa,@types/jest,@types/lodash,@types/node-localstorage,@types/node,@types/prompts,@typescript-eslint/eslint-plugin,@typescript-eslint/parser,eslint-config-prettier,eslint-import-resolver-node,eslint-import-resolver-typescript,eslint-plugin-header,eslint-plugin-import,eslint-plugin-prettier,eslint,jest,jest-junit,npm-check-updates,prettier,projen,ts-jest,ts-node,typescript,@aws-sdk/client-s3,@aws-sdk/client-sfn,@aws-sdk/client-ssm,@aws-sdk/client-sts,@aws-sdk/credential-providers,@aws-sdk/lib-storage,@oclif/core,@oclif/errors,@oclif/plugin-autocomplete,@oclif/plugin-commands,@oclif/plugin-help,@oclif/plugin-not-found,@oclif/plugin-plugins,@oclif/plugin-update,@oclif/plugin-warn-if-update-available,chalk,clear,execa,figlet,ink,lodash,node-localstorage,prompts" }, { "exec": "pnpm i --no-frozen-lockfile" }, { - "exec": "pnpm update @aws-sdk/types @oclif/test @types/chalk @types/clear @types/execa @types/jest @types/lodash @types/node-localstorage @types/node @types/prompts @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-import-resolver-node eslint-import-resolver-typescript eslint-plugin-header eslint-plugin-import eslint-plugin-prettier eslint jest jest-junit npm-check-updates prettier projen ts-jest ts-node typescript @aws-sdk/client-s3 @aws-sdk/client-sfn @aws-sdk/client-ssm @aws-sdk/credential-providers @aws-sdk/lib-storage @oclif/core @oclif/errors @oclif/plugin-autocomplete @oclif/plugin-commands @oclif/plugin-help @oclif/plugin-not-found @oclif/plugin-plugins @oclif/plugin-update @oclif/plugin-warn-if-update-available chalk clear execa figlet ink lodash node-localstorage prompts" + "exec": "pnpm update @aws-sdk/types @oclif/test @types/chalk @types/clear @types/execa @types/jest @types/lodash @types/node-localstorage @types/node @types/prompts @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-import-resolver-node eslint-import-resolver-typescript eslint-plugin-header eslint-plugin-import eslint-plugin-prettier eslint jest jest-junit npm-check-updates prettier projen ts-jest ts-node typescript @aws-sdk/client-s3 @aws-sdk/client-sfn @aws-sdk/client-ssm @aws-sdk/client-sts @aws-sdk/credential-providers @aws-sdk/lib-storage @oclif/core @oclif/errors @oclif/plugin-autocomplete @oclif/plugin-commands @oclif/plugin-help @oclif/plugin-not-found @oclif/plugin-plugins @oclif/plugin-update @oclif/plugin-warn-if-update-available chalk clear execa figlet ink lodash node-localstorage prompts" }, { "exec": "npx projen" diff --git a/packages/galileo-cli/package.json b/packages/galileo-cli/package.json index 37b95263..7d2e4361 100644 --- a/packages/galileo-cli/package.json +++ b/packages/galileo-cli/package.json @@ -1,6 +1,7 @@ { "name": "@aws-galileo/cli", "bin": { + ".cache": "bin/.cache", "galileo-cli-experimental": "bin/galileo-cli.ts" }, "scripts": { @@ -57,6 +58,7 @@ "@aws-sdk/client-s3": "^3.391.0", "@aws-sdk/client-sfn": "^3.391.0", "@aws-sdk/client-ssm": "^3.391.0", + "@aws-sdk/client-sts": "^3.391.0", "@aws-sdk/credential-providers": "^3.391.0", "@aws-sdk/lib-storage": "^3.391.0", "@oclif/core": "^2.15.0", diff --git a/packages/galileo-cli/src/commands/deploy/flags.ts b/packages/galileo-cli/src/commands/deploy/flags.ts new file mode 100644 index 00000000..ab5e31ed --- /dev/null +++ b/packages/galileo-cli/src/commands/deploy/flags.ts @@ -0,0 +1,80 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +import { Flags } from "@oclif/core"; +import { FlagInput } from "@oclif/core/lib/interfaces/parser"; + +export interface DeployCommandFlags { + name: string; + projen: boolean; + profile?: string; + appRegion?: string; + llmRegion?: string; + skipConfirmations: boolean; + cdkCommand: string; + cdkRequireApproval: string; + build: boolean; + saveExec: boolean; + dryRun: boolean; + replay: boolean; +} + +export const deployCommandFlags: FlagInput = { + name: Flags.string({ + description: "Application name", + default: "Galileo", + }), + projen: Flags.boolean({ + description: "Run projen to synth project", + default: true, + }), + profile: Flags.string({ + aliases: ["p"], + description: + "The profile set up for your AWS CLI (associated with your AWS account)", + }), + appRegion: Flags.string({ + aliases: ["app-region"], + description: "The region you want to deploy your application", + required: false, + }), + llmRegion: Flags.string({ + aliases: ["llm-region"], + description: "The region you want to deploy/activate your LLM", + required: false, + }), + skipConfirmations: Flags.boolean({ + aliases: ["yes"], + description: "Skip prompt confirmations (always yes)", + default: false, + }), + cdkCommand: Flags.string({ + aliases: ["cdk-cmd"], + description: "CDK command to run", + default: "deploy", + }), + cdkRequireApproval: Flags.string({ + aliases: ["require-approval"], + description: "CDK approval level", + default: "never", + }), + build: Flags.boolean({ + description: "Perform build", + default: true, + }), + saveExec: Flags.boolean({ + aliases: ["save"], + description: "Save successful task(s) execution to enable replay", + default: true, + }), + dryRun: Flags.boolean({ + aliases: ["dry-run"], + description: "Only log commands but don't execute them", + default: false, + }), + replay: Flags.boolean({ + aliases: ["last"], + description: "Replay last successful task(s) execution", + default: false, + }), +}; diff --git a/packages/galileo-cli/src/commands/deploy/index.ts b/packages/galileo-cli/src/commands/deploy/index.ts new file mode 100644 index 00000000..5004f2b5 --- /dev/null +++ b/packages/galileo-cli/src/commands/deploy/index.ts @@ -0,0 +1,450 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ +import fs from "node:fs"; +import path from "node:path"; +import { Command } from "@oclif/core"; +import chalk from "chalk"; +import prompts from "prompts"; +import { deployCommandFlags } from "./flags"; +import { helpers } from "../../internals"; +import { accountUtils } from "../../lib/account-utils"; +import context from "../../lib/context"; +import galileoPrompts from "../../lib/prompts"; +import { DeployModelOptions, ExecaTask } from "../../lib/types"; + +const ROOT = path.resolve(__dirname, "..", "..", "..", "..", ".."); + +export default class DeployCommand extends Command { + static description = "Deploy Galileo into your AWS account"; + static examples = [ + "galileo-cli-experimental deploy --profile=myProfile --appRegion=ap-southeast-1 --llmRegion=us-west-2 --build --saveExec --skipConfirmations", + "galileo-cli-experimental deploy --dryRun", + "galileo-cli-experimental deploy --replay --skipConfirmations", + ]; + static flags = deployCommandFlags; + + private onPromptCancel() { + this.exit(); + } + + async run(): Promise { + const { flags } = await this.parse(DeployCommand); + const applicationName = flags.name; + + this.log("deploy start", flags); + + if (flags.dryRun) { + context.dryRun = true; + } + + // check if replay requested + if (flags.replay) { + await this.executeReplay(flags.skipConfirmations); + } + + // check all prerequisities for the successful run + await this.ensurePrerequisites({ build: flags.build }); + + // basic info for the deployment + const { + profile, + appRegion, + adminEmail, + adminUsername, + deployApp, + deploySample, + foundationModels, + } = context.cachedAnswers( + await prompts( + [ + galileoPrompts.profile(flags.profile), + galileoPrompts.awsRegion({ + regionType: "app", + initialVal: flags.appRegion, + }), + ...galileoPrompts.adminEmailAndUsername, + galileoPrompts.confirmDeployApp, + galileoPrompts.confirmDeploySample, + galileoPrompts.foundationModels(), + ], + { onCancel: this.onPromptCancel } + ) + ); + + // bedrock-related info + const includesBedrock = helpers.includesBedrock(foundationModels); + const { bedrockModelIds, bedrockRegion, bedrockEndpointUrl } = + includesBedrock + ? context.cachedAnswers( + await prompts( + [ + galileoPrompts.bedrockModelIds(), + galileoPrompts.bedrockRegion, + galileoPrompts.bedrockEndpointUrl, + ], + { onCancel: this.onPromptCancel } + ) + ) + : ({} as any); + + // foundational models -related info + const availableModelIds = helpers.availableModelIds( + foundationModels, + bedrockModelIds + ); + const { deployModels, defaultModelId } = context.cachedAnswers( + await prompts( + [ + galileoPrompts.deployModelId(availableModelIds), + galileoPrompts.deployModels, + ], + { onCancel: this.onPromptCancel } + ) + ); + + const account = await accountUtils.retrieveAccount(profile); + + // collect information for cdkContext and deployStacks + if (adminEmail?.length && adminUsername?.length) { + context.cdkContext.set("AdminEmail", adminEmail); + context.cdkContext.set("AdminUsername", adminUsername); + } + if (deployApp) { + context.deployStacks.push(`Dev/${applicationName}`); + } + if (deploySample) { + context.deployStacks.push(`Dev/${applicationName}-SampleDataset`); + } + context.cdkContext.set("IncludeSampleDataset", deploySample as boolean); + + context.cdkContext.set("FoundationModels", foundationModels.join(",")); + defaultModelId && context.cdkContext.set("DefaultModelId", defaultModelId); + + if (includesBedrock) { + context.cdkContext.set("BedrockModelIds", bedrockModelIds.join(",")); + context.cdkContext.set("BedrockRegion", bedrockRegion); + if (bedrockEndpointUrl && bedrockEndpointUrl.length) { + context.cdkContext.set("BedrockEndpointUrl", bedrockEndpointUrl); + } + } + + // set deploy strategy + await this.setDeployModelStrategy({ + applicationName, + appRegion, + deployModels, + }); + + if (flags.projen) { + console.log(chalk.gray("Synthesizing project repository...")); + context.execCommand("pnpm projen", { cwd: ROOT }); + } + + const modelRegion = + context.cdkContext.get("FoundationModelRegion") || appRegion; + + const regionsToBootstrap = new Set(); + new Set([appRegion, modelRegion]).forEach(async (_region) => { + const bootstapInfo = await accountUtils.retrieveCdkBootstrapInfo({ + profile, + region: _region, + }); + + if (bootstapInfo == null) { + regionsToBootstrap.add(_region); + } + }); + + if (regionsToBootstrap.size > 0) { + if (!flags.skipConfirmations) { + const { bootstrapRegions } = context.cachedAnswers( + await prompts( + galileoPrompts.confirmBootstrapRegions({ + regions: Array.from(regionsToBootstrap), + account, + }) + ) + ); + + if (!bootstrapRegions) { + console.error( + chalk.redBright( + "Account must be bootstrapped in all regions to be used, before deployment. Quitting..." + ) + ); + this.exit(); + } + } + + await this.executeCdkBootstrap({ + account, + profile, + regionsToBootstrap, + }); + } + + const cmdDeploy = this.getDeploymentCommand({ + appRegion, + profile, + cdkCommand: flags.cdkCommand, + cdkRequireApproval: flags.cdkRequireApproval, + }); + + if ( + flags.skipConfirmations || + ( + await prompts( + galileoPrompts.confirmExecCommand({ + ctx: `CDK ${flags.cdkCommand.toUpperCase()}`, + description: `Execute the following command in ${account}?`, + cmd: cmdDeploy, + }), + { onCancel: this.onPromptCancel } + ) + ).confirmed + ) { + this.executeBuild(flags.build); + this.executeCdkDeploy(cmdDeploy, flags.skipConfirmations); + flags.saveExec && context.saveExecTasks(); + } + } + + /** + * General pre-requisites check. + * * check if dependecies have been installed + * * check if docker is running for the build operation + */ + async ensurePrerequisites(options: { build: boolean }) { + const { build } = options; + + if (!fs.existsSync(path.join(ROOT, "node_modules"))) { + const { installDeps } = context.cachedAnswers( + await prompts(galileoPrompts.installDeps) + ); + + if (!installDeps) { + console.error( + chalk.redBright("Project dependencies must be installed. Quitting...") + ); + this.exit(); + } + + context.execCommand("pnpm install --frozen-lockfile", { + cwd: ROOT, + stdio: "inherit", + }); + } + + if (build) { + // make sure docker is running + try { + context.execCommand("docker info"); + } catch (error) { + console.error( + chalk.redBright( + "Docker must be running - please start docker and retry" + ) + ); + this.exit(); + } + } + } + + /** + * Checks if there is any replay commands exist from the previous successful + * run and executes those. + * @param skipConfirmations whether to skip confirmation prompt + */ + async executeReplay(skipConfirmations: boolean) { + const replayTasks: ExecaTask[] | undefined = + context.cache.getItem("replayTasks"); + + if (replayTasks == null) { + this.log(chalk.redBright("No last tasks stored to execute. Quitting...")); + this.exit(); + } + + console.info( + helpers.commandMessage( + "LAST", + "Replay the last task(s):", + replayTasks + .map((_task: ExecaTask) => { + return chalk.gray("→ ") + _task[0]; + }) + .join("\n") + ) + ); + + if ( + skipConfirmations || + ( + await prompts( + galileoPrompts.confirmExec({ + ctx: "REPLAY", + message: "Execute?", + }), + { onCancel: this.onPromptCancel } + ) + ).confirmed + ) { + for (const task of replayTasks) { + context.execCommand(...task); + } + } + this.exit(0); + } + + /** + * Set the deployment strategy for app, foundational models and bedrock. + * @param options params + */ + async setDeployModelStrategy(options: { + readonly applicationName: string; + readonly appRegion: string; + readonly deployModels: any; + }) { + const { applicationName, appRegion, deployModels } = options; + + switch (deployModels) { + case DeployModelOptions.SAME_REGION: { + context.cdkContext.set("FoundationModelRegion", appRegion); + context.deployStacks.push( + `Dev/${applicationName}/FoundationModelStack` + ); + break; + } + case DeployModelOptions.DIFFERENT_REGION: { + const { foundationModelRegion } = context.cachedAnswers( + await prompts( + galileoPrompts.awsRegion({ + regionType: "foundationModel", + message: + "What region do you want to deploy Foundation Models to?", + }), + { onCancel: this.onPromptCancel } + ) + ); + context.cdkContext.set("FoundationModelRegion", foundationModelRegion); + context.deployStacks.push( + `Dev/${applicationName}/FoundationModelStack` + ); + break; + } + case DeployModelOptions.ALREADY_DEPLOYED: { + const { foundationModelRegion } = context.cachedAnswers( + await prompts( + galileoPrompts.awsRegion({ + regionType: "foundationModel", + message: + "What region was the Foundation Model stack deployed to?", + }), + { onCancel: this.onPromptCancel } + ) + ); + context.cdkContext.set("DecoupleStacks", true); + context.cdkContext.set("FoundationModelRegion", foundationModelRegion); + break; + } + case DeployModelOptions.CROSS_ACCOUNT: { + const { foundationModelRegion, crossRegionRoleArn } = + context.cachedAnswers( + await prompts( + [ + galileoPrompts.awsRegion({ + regionType: "foundationModel", + message: + "What region was the Foundation Model stack deployed to in other account?", + initialVal: + context.cache.getItem("foundationModelRegion") ?? + "us-east-1", + }), + galileoPrompts.crossRegionRoleArn(applicationName), + ], + { onCancel: this.onPromptCancel } + ) + ); + context.cdkContext.set("DecoupleStacks", true); + context.cdkContext.set("FoundationModelRegion", foundationModelRegion); + context.cdkContext.set( + "FoundationModelCrossAccountRoleArn", + crossRegionRoleArn + ); + break; + } + case DeployModelOptions.NO: { + context.cdkContext.set("FoundationModelRegion", appRegion); + context.cdkContext.set("DecoupleStacks", true); + break; + } + } + } + + /** + * Run project build + */ + executeBuild(build: boolean) { + build && + context.execCommand("pnpm build", { + cwd: path.join(ROOT), + stdio: "inherit", + }); + } + + getDeploymentCommand(options: { + appRegion: string; + profile: string; + cdkCommand: string; + cdkRequireApproval: string; + }): string { + const { appRegion, profile, cdkCommand, cdkRequireApproval } = options; + + let cmd = `cdk ${cdkCommand} --require-approval ${cdkRequireApproval} --region ${appRegion} --profile ${profile}`; + // TODO: causing intermittent deploy inconsistencies, until resolve consistency just re-synth + // if (flags.build) { + // // No need to synth cdk if build is run, which already runs synth + // cmd += " --app cdk.out"; + // } + for (const [key, value] of context.cdkContext.entries()) { + cmd += ` -c "${key}=${value}"`; + } + cmd += " " + context.deployStacks.join(" "); + + return cmd; + } + + executeCdkDeploy(cmd: string, skipConfirmations: boolean) { + skipConfirmations && console.info(`Executing \`${cmd}\``); + + context.execCommand(`pnpm exec ${cmd}`, { + cwd: path.join(ROOT, "demo/infra"), + stdio: "inherit", + }); + } + + async executeCdkBootstrap(options: { + account: string; + profile: string; + regionsToBootstrap: Set; + }) { + const { account, profile, regionsToBootstrap } = options; + const { cloudformationExecutionPolicies } = context.cachedAnswers( + await prompts(galileoPrompts.cloudformationExecutionPolicies, { + onCancel: this.onPromptCancel, + }) + ); + + const bootstrapCmd = `cdk bootstrap --profile ${profile} ${[ + ...regionsToBootstrap, + ] + .map((r) => `aws://${account}/${r}`) + .join( + " " + )} --cloudformation-execution-policies "${cloudformationExecutionPolicies}"`; + + context.execCommand(`pnpm exec ${bootstrapCmd} --app cdk.out`, { + cwd: path.join(ROOT, "demo/infra"), + stdio: "inherit", + }); + } +} diff --git a/packages/galileo-cli/src/internals/index.ts b/packages/galileo-cli/src/internals/index.ts new file mode 100644 index 00000000..bc5344b3 --- /dev/null +++ b/packages/galileo-cli/src/internals/index.ts @@ -0,0 +1,55 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +export const PredefinedModels = [ + "bedrock::amazon.titan-tg1-large", + "bedrock::amazon.titan-tg1-xlarge", + "bedrock::anthropic.claude-v2", + "bedrock::anthropic.claude-v2-100k", +]; + +export * from "../../../../demo/infra/src/application/ai/foundation-models/ids"; +export * from "../../../../demo/infra/src/galileo/ai/llms/framework/bedrock/ids"; +export * from "../../../../demo/infra/src/galileo/ai/llms/framework/bedrock/utils"; +export * from "../../../../demo/infra/src/application/context"; + +import chalk from "chalk"; +import { formatBedrockModelUUID, FoundationModelIds } from "."; + +// helpers +export namespace helpers { + export const includesBedrock = (foundationModels: string[]): boolean => { + return foundationModels.includes(FoundationModelIds.BEDROCK); + }; + + export const availableModelIds = ( + foundationModels: string[], + bedrockModelIds: string[] + ) => { + const _includesBedrock = helpers.includesBedrock(foundationModels); + + return _includesBedrock + ? [ + ...(foundationModels as string[]).filter( + (v) => v !== FoundationModelIds.BEDROCK + ), + ...(bedrockModelIds as string[]).map(formatBedrockModelUUID), + ] + : (foundationModels as string[]); + }; + + export const contextMessage = (_context: string, message: string): string => { + return `${chalk.cyanBright(`[${_context}]`)} ${message}`; + }; + + export const commandMessage = ( + _context: string, + description: string, + cmd?: string + ): string => { + return contextMessage( + _context, + `${description}\n${chalk.magentaBright(cmd)}\n` + ); + }; +} diff --git a/packages/galileo-cli/src/lib/account-utils/get-aws-account-id.ts b/packages/galileo-cli/src/lib/account-utils/get-aws-account-id.ts new file mode 100644 index 00000000..2395a45e --- /dev/null +++ b/packages/galileo-cli/src/lib/account-utils/get-aws-account-id.ts @@ -0,0 +1,18 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ +import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; +import { fromIni } from "@aws-sdk/credential-providers"; +import { CredentialsParams } from "../types"; + +export const getAWSAccountId = async ( + options: CredentialsParams +): Promise => { + const { profile } = options; + + const client = new STSClient({ + credentials: fromIni({ profile }), + }); + + const callerIdentity = await client.send(new GetCallerIdentityCommand({})); + return callerIdentity.Account!; +}; diff --git a/packages/galileo-cli/src/lib/account-utils/get-bootstrap-info.ts b/packages/galileo-cli/src/lib/account-utils/get-bootstrap-info.ts new file mode 100644 index 00000000..8b33bb13 --- /dev/null +++ b/packages/galileo-cli/src/lib/account-utils/get-bootstrap-info.ts @@ -0,0 +1,56 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; +import { fromIni } from "@aws-sdk/credential-providers"; + +export interface CdkBootstrapInfoRequestOptions { + readonly profile: string; + readonly region: string; + + /** + * The CDK Boostrap qualifier. + * Use this parameter if you bootstrapped CDK with a custom qualifier. + * @default "hnb659fds" + */ + readonly qualifier?: string; +} + +export interface CdkBootstrapInfo { + readonly version: string; + readonly lastUpdated: Date; +} + +const DEFAULT_QUALIFIER = "hnb659fds"; + +/** + * Gets the CDK bootstrap info for an AWS account used by the passed profile + * in the specified region. + * + * @returns The bootstrap info, or `undefined` if the account is not bootstrapped. + */ +export const getCdkBootstrapInfo = async ( + options: CdkBootstrapInfoRequestOptions +): Promise => { + const { profile, region } = options; + const client = new SSMClient({ + credentials: fromIni({ + profile, + }), + region, + }); + + const ssmParamResp = await client.send( + new GetParameterCommand({ + Name: `/cdk-bootstrap/${options.qualifier ?? DEFAULT_QUALIFIER}/version`, + }) + ); + + if (ssmParamResp.Parameter == null) { + return; + } + + return { + version: ssmParamResp.Parameter!.Value!, + lastUpdated: ssmParamResp.Parameter!.LastModifiedDate!, + }; +}; diff --git a/packages/galileo-cli/src/lib/account-utils/index.ts b/packages/galileo-cli/src/lib/account-utils/index.ts new file mode 100644 index 00000000..8162aebe --- /dev/null +++ b/packages/galileo-cli/src/lib/account-utils/index.ts @@ -0,0 +1,65 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +import { getAWSAccountId } from "./get-aws-account-id"; +import { + CdkBootstrapInfo, + CdkBootstrapInfoRequestOptions, + getCdkBootstrapInfo, +} from "./get-bootstrap-info"; +import context from "../context"; + +const CACHE_KEYS = { + ACCOUNTID: "awsAccountId", +}; + +export namespace accountUtils { + /** + * Retrieves the AWS Account ID. + * @param profile The AWS profile setup for AWS CLI. + * @returns The 10-digit AWS Account ID. + */ + export const retrieveAccount = async (profile: string) => { + if (profile !== "default") { + const accountId = context.cache.getItem(CACHE_KEYS.ACCOUNTID) as string; + if (accountId != null) { + return accountId; + } + } + + const accountId = await getAWSAccountId({ profile }); + context.cache.setItem(CACHE_KEYS.ACCOUNTID, accountId); + + return accountId; + }; + + export const retrieveCdkBootstrapInfo = async ( + options: CdkBootstrapInfoRequestOptions + ) => { + const CACHE_KEY = `bootstrapinfo-${options.region}`; + // if profile is default --> always check + // otherwise - cache + if (options.profile !== "default") { + const regionBootstrapCached = context.cache.getItem( + CACHE_KEY + ) as CdkBootstrapInfo; + if (regionBootstrapCached != null) { + return regionBootstrapCached; + } + } + + const cdkBootstrapInfo = await getCdkBootstrapInfo(options); + if (cdkBootstrapInfo != null) { + context.cache.setItem(CACHE_KEY, cdkBootstrapInfo); + } + + return cdkBootstrapInfo; + }; +} + +export default accountUtils; + +export { + CdkBootstrapInfo, + CdkBootstrapInfoRequestOptions, +} from "./get-bootstrap-info"; diff --git a/packages/galileo-cli/src/lib/context/index.ts b/packages/galileo-cli/src/lib/context/index.ts new file mode 100644 index 00000000..f4740da1 --- /dev/null +++ b/packages/galileo-cli/src/lib/context/index.ts @@ -0,0 +1,75 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +import path from "node:path"; +import execa from "execa"; +import { JSONStorage } from "node-localstorage"; +import { IApplicationContextKey, helpers } from "../../internals"; +import { CdkContextValue, ExecaCommandReturn, ExecaTask } from "../types"; + +class Context { + // singleton --- + public static getInstance() { + if (this.INSTANCE == null) { + this.INSTANCE = new Context(); + } + + return this.INSTANCE; + } + private static INSTANCE: Context; + // --- + + private execTasks: ExecaTask[] = []; + + /** + * The local storage cache. + */ + public readonly cache: JSONStorage; + + public readonly cdkContext: Map; + + public readonly deployStacks: string[] = []; + + public dryRun: boolean = false; + + private constructor() { + this.cache = new JSONStorage( + path.join(__dirname, "..", "..", "..", "bin", ".cache", "localstorage") + ); + + this.cdkContext = new Map(); + } + + /** + * Auto-cache for `prompts` answers. + * @param answers Prompts answers + * @param prefix Prefix to store keys in local storage + * @returns The passed answers + */ + cachedAnswers>(answers: T, prefix?: string): T { + Object.entries(answers).forEach(([key, value]) => { + if (value != null) { + if (prefix) key = prefix + key; + this.cache.setItem(key, value); + } + }); + return answers; + } + + execCommand(...args: ExecaTask): ExecaCommandReturn | undefined { + this.execTasks.push(args); + + if (this.dryRun) { + console.log(helpers.contextMessage("DRYRUN", args[0])); + return; + } + return execa.commandSync(...args); + } + + saveExecTasks() { + this.cache.setItem("replayTasks", this.execTasks); + } +} + +const context: Context = Context.getInstance(); +export default context; diff --git a/packages/galileo-cli/src/lib/prompts/index.ts b/packages/galileo-cli/src/lib/prompts/index.ts new file mode 100644 index 00000000..d631fb2e --- /dev/null +++ b/packages/galileo-cli/src/lib/prompts/index.ts @@ -0,0 +1,278 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +// import * as path from "node:path"; +import chalk from "chalk"; +import { PromptObject } from "prompts"; +import { + BEDROCK_DEFAULT_MODEL, + BEDROCK_REGION, + BedrockModelIds, + DEFAULT_FOUNDATION_MODEL_ID, + DEFAULT_PREDEFINED_FOUNDATION_MODEL_LIST, + FoundationModelIds, + helpers, +} from "../../internals"; +import context from "../context"; +import { DeployModelOptions } from "../types"; + +namespace galileoPrompts { + export const installDeps: PromptObject = { + type: "confirm", + name: "installDeps", + message: chalk.yellowBright("Project dependencies not found. Install?"), + initial: true, + }; + + export const confirmExec = (options: { + ctx: string; + message: string; + }): PromptObject => { + const { ctx, message } = options; + + return { + type: "confirm", + name: "confirmed", + message: helpers.contextMessage(ctx, message), + initial: true, + }; + }; + + export const confirmExecCommand = (options: { + ctx: string; + description: string; + cmd: string; + }): PromptObject => { + const { ctx, description, cmd } = options; + + return { + type: "confirm", + name: "confirmed", + message: helpers.commandMessage(ctx, description, cmd), + initial: true, + }; + }; + + export const profile = (initialVal?: string): PromptObject => ({ + type: "text", + name: "profile", + message: "AWS Profile", + initial: + initialVal || + context.cache.getItem("profile") || + process.env.AWS_PROFILE || + "default", + validate: async (value: string) => + (value && value.length > 0) || "Profile is required", + }); + + export const awsRegion = (options: { + regionType: "app" | "foundationModel" | "bedrock"; + message?: string; + initialVal?: string; + }): PromptObject => ({ + type: "text", + name: `${options.regionType}Region`, + message: `AWS Region (${options.regionType})`, + initial: + options.initialVal || + context.cache.getItem(`${options.regionType}Region`) || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION, + validate: async (value: string) => + (value && value.length > 0) || + `"${options.regionType}" region is required`, + }); + + export const adminEmailAndUsername: PromptObject[] = [ + { + type: "text", + name: "adminEmail", + message: + "Administrator email address" + + chalk.reset.grey( + " Enter email address to automatically create Cognito admin user, otherwise leave blank\n" + ), + initial: context.cache.getItem("adminEmail"), + }, + { + type: (prev) => (prev == null ? false : "text"), + name: "adminUsername", + message: "Administrator username", + initial: context.cache.getItem("adminUsername") ?? "admin", + }, + ]; + + export const confirmDeployApp: PromptObject = { + type: "confirm", + name: "deployApp", + message: "Deploy main application stack?", + initial: context.cache.getItem("deployApp") ?? true, + }; + + // TODO: remove this and move sample deployment to upload data command + export const confirmDeploySample: PromptObject = { + type: "confirm", + name: "deploySample", + message: "Deploy sample dataset?", + initial: context.cache.getItem("deploySample") ?? true, + }; + + export const foundationModels = (): PromptObject => { + const selectedFoundationModels = new Set( + context.cache.getItem("foundationModels") || + DEFAULT_PREDEFINED_FOUNDATION_MODEL_LIST + ); + + const q: PromptObject = { + type: "multiselect", + name: "foundationModels", + message: "Choose the foundation models to support", + instructions: chalk.gray( + "\n ↑/↓: Highlight option, ←/→/[space]: Toggle selection, a: Toggle all, enter/return: Complete answer" + ), + choices: Object.values(FoundationModelIds).map((_id) => ({ + title: _id, + value: _id, + selected: selectedFoundationModels.has(_id), + })), + min: 1, + }; + + return q; + }; + + export const bedrockModelIds = (): PromptObject => { + const selectedBedrockModels = new Set( + context.cache.getItem("bedrockModelIds") || [BEDROCK_DEFAULT_MODEL] + ); + return { + type: "autocompleteMultiselect", + name: "bedrockModelIds", + message: "Bedrock model ids", + min: 1, + instructions: chalk.gray( + "↑/↓: Highlight option, ←/→/[space]: Toggle selection, Return to submit" + ), + choices: Object.values(BedrockModelIds) + .sort() + .map((_id) => ({ + title: _id, + value: _id, + selected: selectedBedrockModels.has(_id), + })), + }; + }; + + export const bedrockRegion: PromptObject = { + type: "text", + name: "bedrockRegion", + message: "Bedrock region", + initial: context.cache.getItem("bedrockRegion") ?? BEDROCK_REGION, + }; + + export const bedrockEndpointUrl: PromptObject = { + type: "text", + name: "bedrockEndpointUrl", + message: `Bedrock endpoint url ${chalk.gray("(optional)")}`, + initial: context.cache.getItem("bedrockEndpointUrl") ?? undefined, + }; + + export const deployModelId = (availableModelIds: string[]): PromptObject => { + return { + type: "select", + name: "defaultModelId", + message: "Choose the default foundation model", + hint: "This will be the default model used in inference engine.", + choices: availableModelIds.map((x) => ({ + title: x, + value: x, + })), + initial: () => { + const _initial = + context.cache.getItem("defaultModelId") ?? + DEFAULT_FOUNDATION_MODEL_ID; + if (availableModelIds.includes(_initial)) { + return availableModelIds.indexOf(_initial); + } else { + return 0; + } + }, + }; + }; + + export const deployModels: PromptObject = { + type: "select", + name: "deployModels", + message: "Deploy Foundation Models?", + initial: + context.cache.getItem("deployModels") ?? DeployModelOptions.SAME_REGION, + choices: [ + { + title: "Yes, in same region as application", + value: DeployModelOptions.SAME_REGION, + }, + { + title: "Yes, but in different region", + value: DeployModelOptions.DIFFERENT_REGION, + }, + { + title: "No, already deployed", + value: DeployModelOptions.ALREADY_DEPLOYED, + }, + { + title: "No, but link to cross-account stack", + value: DeployModelOptions.CROSS_ACCOUNT, + }, + { title: "No", value: DeployModelOptions.NO }, + ], + }; + + export const crossRegionRoleArn = (applicationName: string): PromptObject => { + const crossAccountRegex = new RegExp( + `arn:aws:iam::\\d{10,12}:role\\/${applicationName}-FoundationModel-CrossAccount-\\w+` + ); + + return { + type: "text", + name: "crossRegionRoleArn", + message: "What is the cross-account role arn for Foundation Model stack?", + initial: context.cache.getItem("crossRegionRoleArn"), + validate: async (value: string) => + (value && crossAccountRegex.test(value)) || + `Invalid cross-account arn - expected "${crossAccountRegex.source}"`, + }; + }; + + export const confirmBootstrapRegions = (options: { + regions: string[]; + account: string; + }): PromptObject => { + const { regions, account } = options; + return { + type: "confirm", + name: "bootstrapRegions", + message: `Region${regions.length > 1 ? "s" : ""} ${regions + .map((r) => `"${r}"`) + .join(", ")} ${ + regions.length > 1 ? "are" : "is" + } not bootstrapped in account "${account}". Do you want to bootstrap ${ + regions.length > 1 ? "them" : "it" + }?`, + initial: true, + }; + }; + + export const cloudformationExecutionPolicies: PromptObject = { + type: "text", + name: "cloudformationExecutionPolicies", + message: + "What managed polices should be attached to bootstrap deployment role?", + hint: "https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html#bootstrapping-customizing", + initial: + context.cache.getItem("cloudformationExecutionPolicies") ?? + "arn:aws:iam::aws:policy/PowerUserAccess,arn:aws:iam::aws:policy/IAMFullAccess", + }; +} + +export default galileoPrompts; diff --git a/packages/galileo-cli/src/lib/types/index.ts b/packages/galileo-cli/src/lib/types/index.ts new file mode 100644 index 00000000..7a152e69 --- /dev/null +++ b/packages/galileo-cli/src/lib/types/index.ts @@ -0,0 +1,21 @@ +/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved. +PDX-License-Identifier: Apache-2.0 */ + +import execa from "execa"; + +export type ExecaTask = Parameters; +export type ExecaCommandReturn = ReturnType; +export type CdkContextValue = string | string[] | number | boolean; + +export interface CredentialsParams { + readonly profile: string; + readonly region?: string; +} + +export enum DeployModelOptions { + SAME_REGION = 0, + DIFFERENT_REGION = 1, + ALREADY_DEPLOYED = 2, + CROSS_ACCOUNT = 3, + NO = 4, +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad40d866..f0bee0a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1017,6 +1017,9 @@ importers: '@aws-sdk/client-ssm': specifier: ^3.391.0 version: 3.421.0 + '@aws-sdk/client-sts': + specifier: ^3.391.0 + version: 3.421.0 '@aws-sdk/credential-providers': specifier: ^3.391.0 version: 3.391.0 diff --git a/projenrc/framework/galileo-cli.ts b/projenrc/framework/galileo-cli.ts index e50a38ad..3bbfe47b 100644 --- a/projenrc/framework/galileo-cli.ts +++ b/projenrc/framework/galileo-cli.ts @@ -41,6 +41,7 @@ export class GalileoCli extends TypeScriptAppProject { `@aws-sdk/client-s3@^${AWS_SDK_VERSION}`, `@aws-sdk/client-sfn@^${AWS_SDK_VERSION}`, `@aws-sdk/client-ssm@^${AWS_SDK_VERSION}`, + `@aws-sdk/client-sts@^${AWS_SDK_VERSION}`, `@aws-sdk/lib-storage@^${AWS_SDK_VERSION}`, `@aws-sdk/credential-providers@^${AWS_SDK_VERSION}`, "@oclif/core",