diff --git a/apps/wing/src/cli.ts b/apps/wing/src/cli.ts index 0807e3aa7ae..7083289cda8 100644 --- a/apps/wing/src/cli.ts +++ b/apps/wing/src/cli.ts @@ -181,6 +181,23 @@ async function main() { .hook("preAction", collectAnalyticsHook) .action(runSubCommand("compile")); + program + .command("secrets") + .description("Manage secrets") + .argument("[entrypoint]", "program .w entrypoint") + .option( + "-t, --platform --platform ", + "Target platform provider (builtin: sim, tf-aws, tf-azure, tf-gcp, awscdk)", + collectPlatformVariadic, + DEFAULT_PLATFORM + ) + .option("-v, --value ", "Platform-specific value in the form KEY=VALUE", addValue, []) + .option("--values ", "File with platform-specific values (TOML|YAML|JSON)") + .addOption(new Option("--list", "List required application secrets")) + .hook("preAction", progressHook) + .hook("preAction", collectAnalyticsHook) + .action(runSubCommand("secrets")); + program .command("test") .description( diff --git a/apps/wing/src/commands/secrets.test.ts b/apps/wing/src/commands/secrets.test.ts new file mode 100644 index 00000000000..b2c12f4af3e --- /dev/null +++ b/apps/wing/src/commands/secrets.test.ts @@ -0,0 +1,61 @@ +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { BuiltinPlatform } from "@winglang/compiler"; +import { describe, expect, test, vitest, beforeEach, afterEach, vi } from "vitest"; +import { secrets } from "../commands/secrets"; +import { generateTmpDir } from "../util"; + +vitest.mock("inquirer"); + +describe("secrets", () => { + let log: any; + + beforeEach(() => { + log = console.log; + console.log = vi.fn(); + }); + + afterEach(() => { + console.log = log; + }); + + test("no secrets found", async () => { + const workdir = await generateTmpDir(); + process.chdir(workdir); + + const wingCode = ` + bring cloud; + `; + + await writeFile(join(workdir, "main.w"), wingCode); + + await secrets("main.w", { + platform: [BuiltinPlatform.SIM], + targetDir: workdir, + }); + + expect(console.log).toHaveBeenCalledWith("0 secret(s) found\n"); + }); + + test("secrets found", async () => { + const workdir = await generateTmpDir(); + process.chdir(workdir); + + const wingCode = ` + bring cloud; + + let s1 = new cloud.Secret(name: "my-secret") as "s1"; + let s2 = new cloud.Secret(name: "other-secret") as "s2"; + `; + + await writeFile(join(workdir, "main.w"), wingCode); + + await secrets("main.w", { + platform: [BuiltinPlatform.SIM], + targetDir: workdir, + list: true, + }); + + expect(console.log).toHaveBeenCalledWith("2 secret(s) found\n"); + }); +}); diff --git a/apps/wing/src/commands/secrets.ts b/apps/wing/src/commands/secrets.ts new file mode 100644 index 00000000000..b9fae26ddf0 --- /dev/null +++ b/apps/wing/src/commands/secrets.ts @@ -0,0 +1,47 @@ +import fs from "fs"; +import path from "path"; +import { PlatformManager, SECRETS_FILE_NAME } from "@winglang/sdk/lib/platform"; +import inquirer from "inquirer"; +import { CompileOptions, compile } from "./compile"; + +export interface SecretsOptions extends CompileOptions { + readonly list?: boolean; +} + +export async function secrets(entrypoint?: string, options?: SecretsOptions): Promise { + // Compile the program to generate secrets file + const outdir = await compile(entrypoint, options); + const secretsFile = path.join(outdir, SECRETS_FILE_NAME); + + let secretNames = fs.existsSync(secretsFile) + ? JSON.parse(fs.readFileSync(secretsFile, "utf-8")) + : []; + + process.env.WING_SOURCE_DIR = path.resolve(path.dirname(entrypoint ?? "main.w")); + + let secretValues: Record = {}; + console.log(`${secretNames.length} secret(s) found\n`); + + if (options?.list) { + console.log("- " + secretNames.join("\n- ")); + return; + } + + if (secretNames.length === 0) { + return; + } + + for (const secret of secretNames) { + const response = await inquirer.prompt([ + { + type: "password", + name: "value", + message: `Enter the secret value for ${secret}:`, + }, + ]); + secretValues[secret] = response.value; + } + + const plaformManager = new PlatformManager({ platformPaths: options?.platform }); + await plaformManager.storeSecrets(secretValues); +} diff --git a/docs/docs/04-standard-library/cloud/secret.md b/docs/docs/04-standard-library/cloud/secret.md index 64cf3f573ac..2dc62f2e8e2 100644 --- a/docs/docs/04-standard-library/cloud/secret.md +++ b/docs/docs/04-standard-library/cloud/secret.md @@ -52,15 +52,14 @@ new cloud.Function(inflight () => { ### Simulator (`sim`) -When using a secret in Wing's simulator, a secrets file must be added to your home directory at `~/.wing/secrets.json`. +When using a secret in Wing's simulator, a secrets file must be added to your project in a file called: `.env`. The simulator will look up secrets in this file by their `name`. -Secrets should be saved in a JSON format: +Secrets should be saved in a key=value format: ```json -// secrets.json -{ - "my-api-key": "1234567890" -} +// .env +my-api-key=1234567890 +secret-key=secret-value ``` ### AWS (`tf-aws` and `awscdk`) @@ -189,6 +188,7 @@ other capabilities to the inflight host. | **Name** | **Type** | **Description** | | --- | --- | --- | | node | constructs.Node | The tree node. | +| name | str | Get secret name. | --- @@ -204,6 +204,18 @@ The tree node. --- +##### `name`Optional + +```wing +name: str; +``` + +- *Type:* str + +Get secret name. + +--- + ## Structs diff --git a/docs/docs/06-tools/01-cli.md b/docs/docs/06-tools/01-cli.md index 4ee92994d18..415d631e82b 100644 --- a/docs/docs/06-tools/01-cli.md +++ b/docs/docs/06-tools/01-cli.md @@ -239,6 +239,31 @@ This will compile your current Wing directory, and bundle it as a tarball that c See [Libraries](../05-libraries.md) for more details on packaging and consuming Wing libraries. +::: + +## Store Secrets: `wing secrets` + +The `wing secrets` command can be used to store secrets needed by your application. The method of storing secrets depends on the target platform. + +Take the following Wing application: + +```js +// main.w +bring cloud; + +let secret = new cloud.Secret(name: "slack-token"); +``` + +Usage: + +```sh +$ wing secrets main.w + +1 secret(s) found + +? Enter the secret value for slack-token: [hidden] +``` + ## Environment Variables For development and testing, Wing can automatically read environment variables from `.env` files in your current working directory. These environment variables can be accessed in Wing code using `util.env` and `util.tryEnv` in both preflight. In inflight these functions can also be used but note that the variables are not automatically available, if desired they must be passed explicitly when used like in `cloud.Function`. diff --git a/libs/wingc/src/lsp/snapshots/completions/incomplete_inflight_namespace.snap b/libs/wingc/src/lsp/snapshots/completions/incomplete_inflight_namespace.snap index 1fd2aa61a8f..5a7476b0c1c 100644 --- a/libs/wingc/src/lsp/snapshots/completions/incomplete_inflight_namespace.snap +++ b/libs/wingc/src/lsp/snapshots/completions/incomplete_inflight_namespace.snap @@ -59,7 +59,7 @@ source: libs/wingc/src/lsp/completions.rs kind: 7 documentation: kind: markdown - value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." + value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n- `name?` — `str?` — Get secret name.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." sortText: gg|Secret - label: Service kind: 7 diff --git a/libs/wingc/src/lsp/snapshots/completions/namespace_middle_dot.snap b/libs/wingc/src/lsp/snapshots/completions/namespace_middle_dot.snap index 1fd2aa61a8f..5a7476b0c1c 100644 --- a/libs/wingc/src/lsp/snapshots/completions/namespace_middle_dot.snap +++ b/libs/wingc/src/lsp/snapshots/completions/namespace_middle_dot.snap @@ -59,7 +59,7 @@ source: libs/wingc/src/lsp/completions.rs kind: 7 documentation: kind: markdown - value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." + value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n- `name?` — `str?` — Get secret name.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." sortText: gg|Secret - label: Service kind: 7 diff --git a/libs/wingc/src/lsp/snapshots/completions/new_expression_nested.snap b/libs/wingc/src/lsp/snapshots/completions/new_expression_nested.snap index bfe3e0f0bf6..ba3a81288cf 100644 --- a/libs/wingc/src/lsp/snapshots/completions/new_expression_nested.snap +++ b/libs/wingc/src/lsp/snapshots/completions/new_expression_nested.snap @@ -104,7 +104,7 @@ source: libs/wingc/src/lsp/completions.rs kind: 7 documentation: kind: markdown - value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." + value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n- `name?` — `str?` — Get secret name.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." sortText: gg|Secret insertText: Secret($1) insertTextFormat: 2 diff --git a/libs/wingc/src/lsp/snapshots/completions/partial_type_reference_annotation.snap b/libs/wingc/src/lsp/snapshots/completions/partial_type_reference_annotation.snap index 1fd2aa61a8f..5a7476b0c1c 100644 --- a/libs/wingc/src/lsp/snapshots/completions/partial_type_reference_annotation.snap +++ b/libs/wingc/src/lsp/snapshots/completions/partial_type_reference_annotation.snap @@ -59,7 +59,7 @@ source: libs/wingc/src/lsp/completions.rs kind: 7 documentation: kind: markdown - value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." + value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n- `name?` — `str?` — Get secret name.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." sortText: gg|Secret - label: Service kind: 7 diff --git a/libs/wingc/src/lsp/snapshots/completions/variable_type_annotation_namespace.snap b/libs/wingc/src/lsp/snapshots/completions/variable_type_annotation_namespace.snap index 1fd2aa61a8f..5a7476b0c1c 100644 --- a/libs/wingc/src/lsp/snapshots/completions/variable_type_annotation_namespace.snap +++ b/libs/wingc/src/lsp/snapshots/completions/variable_type_annotation_namespace.snap @@ -59,7 +59,7 @@ source: libs/wingc/src/lsp/completions.rs kind: 7 documentation: kind: markdown - value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." + value: "```wing\nclass Secret\n```\n---\nA cloud secret.\n\n### Initializer\n- `...props` — `SecretProps?`\n \n - `name?` — `str?` — The secret's name.\n### Fields\n- `node` — `Node` — The tree node.\n- `name?` — `str?` — Get secret name.\n### Methods\n- `isConstruct` — `preflight (x: any): bool` — Checks if `x` is a construct.\n- `onLift` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight.\n- `onLiftType` — `preflight (host: IInflightHost, ops: Array): void` — A hook called by the Wing compiler once for each inflight host that needs to use this type inflight.\n- `toString` — `preflight (): str` — Returns a string representation of this construct.\n- `value` — `inflight (options: GetSecretValueOptions?): str` — Retrieve the value of the secret.\n- `valueJson` — `inflight (options: GetSecretValueOptions?): Json` — Retrieve the Json value of the secret." sortText: gg|Secret - label: Service kind: 7 diff --git a/libs/wingsdk/src/cloud/secret.md b/libs/wingsdk/src/cloud/secret.md index f0a321a3f3d..ddd8a5129f1 100644 --- a/libs/wingsdk/src/cloud/secret.md +++ b/libs/wingsdk/src/cloud/secret.md @@ -52,15 +52,14 @@ new cloud.Function(inflight () => { ### Simulator (`sim`) -When using a secret in Wing's simulator, a secrets file must be added to your home directory at `~/.wing/secrets.json`. +When using a secret in Wing's simulator, a secrets file must be added to your project in a file called: `.env`. The simulator will look up secrets in this file by their `name`. -Secrets should be saved in a JSON format: +Secrets should be saved in a key=value format: ```json -// secrets.json -{ - "my-api-key": "1234567890" -} +// .env +my-api-key=1234567890 +secret-key=secret-value ``` ### AWS (`tf-aws` and `awscdk`) diff --git a/libs/wingsdk/src/cloud/secret.ts b/libs/wingsdk/src/cloud/secret.ts index 75efc9ec633..51da9bedd43 100644 --- a/libs/wingsdk/src/cloud/secret.ts +++ b/libs/wingsdk/src/cloud/secret.ts @@ -1,6 +1,6 @@ import { Construct } from "constructs"; import { fqnForType } from "../constants"; -import { INFLIGHT_SYMBOL } from "../core/types"; +import { INFLIGHT_SYMBOL, SECRET_SYMBOL } from "../core/types"; import { Json, Node, Resource } from "../std"; /** @@ -33,6 +33,11 @@ export interface SecretProps { export class Secret extends Resource { /** @internal */ public [INFLIGHT_SYMBOL]?: ISecretClient; + /** @internal */ + public [SECRET_SYMBOL] = true; + + /** @internal */ + protected _name?: string; constructor(scope: Construct, id: string, props: SecretProps = {}) { if (new.target === Secret) { @@ -44,7 +49,12 @@ export class Secret extends Resource { Node.of(this).title = "Secret"; Node.of(this).description = "A cloud secret"; - props; + this._name = props.name; + } + + /** Get secret name */ + public get name(): string | undefined { + return this._name; } } diff --git a/libs/wingsdk/src/core/types.ts b/libs/wingsdk/src/core/types.ts index bdf0a22c7cd..a65e1950fa2 100644 --- a/libs/wingsdk/src/core/types.ts +++ b/libs/wingsdk/src/core/types.ts @@ -8,6 +8,12 @@ export { Construct } from "constructs"; /** Flag to signify the `inflight` side of a `preflight` object */ export const INFLIGHT_SYMBOL: unique symbol = Symbol("@winglang/sdk.inflight"); +/** This symbol is not defined in cloud/secrets.ts due to circular dependencies + * between cloud/secrets.ts and platform/platform-manager.ts, which need to be revisited + * in the meantime this dependency inversion is used to avoid the circular dependency + */ +export const SECRET_SYMBOL = Symbol("@winglang/sdk.cloud.Secret"); + /** `preflight` representation of an `inflight` */ export type Inflight = IInflight & { /** Note: This is not actually callable, diff --git a/libs/wingsdk/src/platform/platform-manager.ts b/libs/wingsdk/src/platform/platform-manager.ts index 57f370de2a8..f4c957272c3 100644 --- a/libs/wingsdk/src/platform/platform-manager.ts +++ b/libs/wingsdk/src/platform/platform-manager.ts @@ -1,10 +1,11 @@ -import { readFileSync } from "fs"; +import { readFileSync, writeFileSync } from "fs"; import { basename, dirname, join } from "path"; import { cwd } from "process"; import * as vm from "vm"; import { IPlatform } from "./platform"; import { scanDirForPlatformFile } from "./util"; import { App, AppProps, SynthHooks } from "../core"; +import { SECRET_SYMBOL } from "../core/types"; interface PlatformManagerOptions { /** @@ -15,14 +16,18 @@ interface PlatformManagerOptions { const BUILTIN_PLATFORMS = ["tf-aws", "tf-azure", "tf-gcp", "sim"]; +/** @internal */ +export const SECRETS_FILE_NAME = "secrets.json"; + /** @internal */ export class PlatformManager { private readonly platformPaths: string[]; - private readonly platformInstances: IPlatform[] = []; + private platformInstances: IPlatform[] = []; constructor(options: PlatformManagerOptions) { this.platformPaths = options.platformPaths ?? []; this.retrieveImplicitPlatforms(); + this.createPlatformInstances(); } /** @@ -92,6 +97,7 @@ export class PlatformManager { } private createPlatformInstances() { + this.platformInstances = []; this.platformPaths.forEach((platformPath) => { this.loadPlatformPath(platformPath); }); @@ -101,7 +107,6 @@ export class PlatformManager { // that can be synthesized public createApp(appProps: AppProps): App { this.createPlatformInstances(); - let appCall = this.platformInstances[0].newApp; if (!appCall) { @@ -110,52 +115,47 @@ export class PlatformManager { ); } - let synthHooks: SynthHooks = { - preSynthesize: [], - postSynthesize: [], - validate: [], - }; - - let newInstanceOverrides: any[] = []; - - let parameterSchemas: any[] = []; - - this.platformInstances.forEach((instance) => { - if (instance.parameters) { - parameterSchemas.push(instance.parameters); - } - - if (instance.preSynth) { - synthHooks.preSynthesize!.push(instance.preSynth.bind(instance)); - } - - if (instance.postSynth) { - synthHooks.postSynthesize!.push(instance.postSynth.bind(instance)); - } - - if (instance.validate) { - synthHooks.validate!.push(instance.validate.bind(instance)); - } - - if (instance.newInstance) { - newInstanceOverrides.push(instance.newInstance.bind(instance)); - } - }); + let hooks = collectHooks(this.platformInstances); const app = appCall!({ ...appProps, - synthHooks, - newInstanceOverrides, + synthHooks: hooks.synthHooks, + newInstanceOverrides: hooks.newInstanceOverrides, }) as App; + let secretNames = []; + for (const c of app.node.findAll()) { + if ((c as any)[SECRET_SYMBOL]) { + const secret = c as any; + secretNames.push(secret.name); + } + } + + if (secretNames.length > 0) { + writeFileSync( + join(app.outdir, SECRETS_FILE_NAME), + JSON.stringify(secretNames) + ); + } + let registrar = app.parameters; - parameterSchemas.forEach((schema) => { + hooks.parameterSchemas.forEach((schema) => { registrar.addSchema(schema); }); return app; } + + public async storeSecrets(secrets: Record): Promise { + const hooks = collectHooks(this.platformInstances); + if (!hooks.storeSecretsHook) { + throw new Error( + `Cannot find a platform or platform extension that supports storing secrets` + ); + } + await hooks.storeSecretsHook(secrets); + } } /** @@ -238,3 +238,51 @@ export function _loadCustomPlatform(customPlatformPath: string): any { ); } } + +interface CollectHooksResult { + synthHooks: SynthHooks; + newInstanceOverrides: any[]; + parameterSchemas: any[]; + storeSecretsHook?: any; +} + +function collectHooks(platformInstances: IPlatform[]) { + let result: CollectHooksResult = { + synthHooks: { + preSynthesize: [], + postSynthesize: [], + validate: [], + }, + newInstanceOverrides: [], + parameterSchemas: [], + storeSecretsHook: undefined, + }; + + platformInstances.forEach((instance) => { + if (instance.parameters) { + result.parameterSchemas.push(instance.parameters); + } + + if (instance.preSynth) { + result.synthHooks.preSynthesize!.push(instance.preSynth.bind(instance)); + } + + if (instance.postSynth) { + result.synthHooks.postSynthesize!.push(instance.postSynth.bind(instance)); + } + + if (instance.validate) { + result.synthHooks.validate!.push(instance.validate.bind(instance)); + } + + if (instance.newInstance) { + result.newInstanceOverrides.push(instance.newInstance.bind(instance)); + } + + if (instance.storeSecrets) { + result.storeSecretsHook = instance.storeSecrets.bind(instance); + } + }); + + return result; +} diff --git a/libs/wingsdk/src/platform/platform.ts b/libs/wingsdk/src/platform/platform.ts index b5466b84ce9..3d08bf1388e 100644 --- a/libs/wingsdk/src/platform/platform.ts +++ b/libs/wingsdk/src/platform/platform.ts @@ -54,4 +54,9 @@ export interface IPlatform { * @param config generated config */ validate?(config: any): any; + + /** + * Hook for creating and storing secrets + */ + storeSecrets?(secrets: { [name: string]: string }): Promise; } diff --git a/libs/wingsdk/src/target-sim/platform.ts b/libs/wingsdk/src/target-sim/platform.ts index 076d917f24e..112fcd8c869 100644 --- a/libs/wingsdk/src/target-sim/platform.ts +++ b/libs/wingsdk/src/target-sim/platform.ts @@ -1,3 +1,5 @@ +import fs from "fs"; +import { join } from "path"; import { App } from "./app"; import { IPlatform } from "../platform"; @@ -11,4 +13,34 @@ export class Platform implements IPlatform { public newApp(appProps: any): any { return new App(appProps); } + + public async storeSecrets(secrets: Record): Promise { + let existingSecretsContent = ""; + const envFile = join(process.env.WING_SOURCE_DIR!, ".env"); + + try { + existingSecretsContent = fs.readFileSync(envFile, "utf8"); + } catch (error) {} + + const existingSecrets = existingSecretsContent + .split("\n") + .filter((line) => line.trim() !== "") + .reduce((s, line) => { + const [key, value] = line.split("=", 2); + s[key] = value; + return s; + }, {} as { [key: string]: string }); + + for (const key in secrets) { + existingSecrets[key] = secrets[key]; + } + + const updatedContent = Object.entries(existingSecrets) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + fs.writeFileSync(envFile, updatedContent); + + console.log(`${Object.keys(secrets).length} secret(s) stored in .env`); + } } diff --git a/libs/wingsdk/src/target-sim/secret.inflight.ts b/libs/wingsdk/src/target-sim/secret.inflight.ts index 73c989c5cad..c6521dca11e 100644 --- a/libs/wingsdk/src/target-sim/secret.inflight.ts +++ b/libs/wingsdk/src/target-sim/secret.inflight.ts @@ -1,5 +1,4 @@ import * as fs from "fs"; -import * as os from "os"; import * as path from "path"; import { SecretAttributes, SecretSchema } from "./schema-resources"; import { ISecretClient, SECRET_FQN } from "../cloud"; @@ -16,7 +15,8 @@ export class Secret implements ISecretClient, ISimulatorResourceInstance { private readonly name: string; constructor(props: SecretSchema) { - this.secretsFile = path.join(os.homedir(), ".wing", "secrets.json"); + this.secretsFile = path.join(process.cwd(), ".env"); + if (!fs.existsSync(this.secretsFile)) { throw new Error( `No secrets file found at ${this.secretsFile} while looking for secret ${props.name}` @@ -57,13 +57,13 @@ export class Secret implements ISecretClient, ISimulatorResourceInstance { timestamp: new Date().toISOString(), }); - const secrets = JSON.parse(fs.readFileSync(this.secretsFile, "utf-8")); + const secretValue = process.env[this.name]; - if (!secrets[this.name]) { + if (!secretValue) { throw new Error(`No secret value for secret ${this.name}`); } - return secrets[this.name]; + return secretValue; } public async valueJson(): Promise { diff --git a/libs/wingsdk/src/target-sim/secret.ts b/libs/wingsdk/src/target-sim/secret.ts index 1e315c7b773..63b3fe92414 100644 --- a/libs/wingsdk/src/target-sim/secret.ts +++ b/libs/wingsdk/src/target-sim/secret.ts @@ -13,11 +13,10 @@ import { IInflightHost } from "../std"; * @inflight `@winglang/sdk.cloud.ISecretClient` */ export class Secret extends cloud.Secret implements ISimulatorResource { - private readonly name: string; constructor(scope: Construct, id: string, props: cloud.SecretProps = {}) { super(scope, id, props); - this.name = + this._name = props.name ?? ResourceNames.generateName(this, { disallowedRegex: /[^\w]/g }); } @@ -42,7 +41,7 @@ export class Secret extends cloud.Secret implements ISimulatorResource { public toSimulator(): ToSimulatorOutput { const props: SecretSchema = { - name: this.name, + name: this.name!, }; return { type: cloud.SECRET_FQN, diff --git a/libs/wingsdk/src/target-tf-aws/platform.ts b/libs/wingsdk/src/target-tf-aws/platform.ts index 24a2bf8e545..1163e679db9 100644 --- a/libs/wingsdk/src/target-tf-aws/platform.ts +++ b/libs/wingsdk/src/target-tf-aws/platform.ts @@ -67,4 +67,48 @@ export class Platform implements IPlatform { public newApp?(appProps: any): any { return new App(appProps); } + + public async storeSecrets(secrets: Record): Promise { + const { + SecretsManagerClient, + GetSecretValueCommand, + CreateSecretCommand, + UpdateSecretCommand, + } = await import("@aws-sdk/client-secrets-manager"); + + console.log("Storing secrets in AWS Secrets Manager"); + const client = new SecretsManagerClient({}); + + for (const [name, value] of Object.entries(secrets)) { + try { + // Attempt to retrieve the secret to check if it exists + await client.send(new GetSecretValueCommand({ SecretId: name })); + console.log(`Secret ${name} exists, updating it.`); + await client.send( + new UpdateSecretCommand({ + SecretId: name, + SecretString: JSON.stringify({ [name]: value }), + }) + ); + } catch (error: any) { + if (error.name === "ResourceNotFoundException") { + // If the secret does not exist, create it + console.log(`Secret ${name} does not exist, creating it.`); + await client.send( + new CreateSecretCommand({ + Name: name, + SecretString: JSON.stringify({ [name]: value }), + }) + ); + } else { + console.error(`Failed to store secret ${name}:`, error); + throw error; + } + } + } + + console.log( + `${Object.keys(secrets).length} secret(s) stored AWS Secrets Manager` + ); + } } diff --git a/libs/wingsdk/src/target-tf-aws/secret.ts b/libs/wingsdk/src/target-tf-aws/secret.ts index b8535b1e99d..97c3bc1f02c 100644 --- a/libs/wingsdk/src/target-tf-aws/secret.ts +++ b/libs/wingsdk/src/target-tf-aws/secret.ts @@ -32,9 +32,10 @@ export class Secret extends cloud.Secret { name: props.name, }); } else { - this.secret = new SecretsmanagerSecret(this, "Default", { - name: ResourceNames.generateName(this, NAME_OPTS), - }); + (this._name = ResourceNames.generateName(this, NAME_OPTS)), + (this.secret = new SecretsmanagerSecret(this, "Default", { + name: this._name, + })); new TerraformOutput(this, "SecretArn", { value: this.secret.arn, diff --git a/libs/wingsdk/test/target-sim/__snapshots__/secret.test.ts.snap b/libs/wingsdk/test/target-sim/__snapshots__/secret.test.ts.snap index 6fc96fd4e1f..163b6ef22f0 100644 --- a/libs/wingsdk/test/target-sim/__snapshots__/secret.test.ts.snap +++ b/libs/wingsdk/test/target-sim/__snapshots__/secret.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`create a secret 1`] = ` +exports[`secrets > create a secret 1`] = ` { "connections.json": { "connections": [], diff --git a/libs/wingsdk/test/target-sim/secret.test.ts b/libs/wingsdk/test/target-sim/secret.test.ts index c17b68d41d9..a0a94ceb83b 100644 --- a/libs/wingsdk/test/target-sim/secret.test.ts +++ b/libs/wingsdk/test/target-sim/secret.test.ts @@ -1,65 +1,67 @@ -import * as os from "os"; import * as path from "path"; import * as fs from "fs-extra"; -import { test, expect } from "vitest"; +import { test, expect, beforeEach, afterEach, describe } from "vitest"; import * as cloud from "../../src/cloud"; import { SimApp } from "../sim-app"; -const SECRETS_FILE = path.join(os.homedir(), ".wing", "secrets.json"); +const SECRETS_FILE = path.join(process.cwd(), ".env"); +describe("secrets", () => { + beforeEach(() => { + fs.createFileSync(SECRETS_FILE); + }); -test("create a secret", async () => { - // GIVEN - const app = new SimApp(); - new cloud.Secret(app, "my_secret"); + afterEach(() => { + fs.removeSync(SECRETS_FILE); + }); - await fs.ensureFile(SECRETS_FILE); + test("create a secret", async () => { + // GIVEN + const app = new SimApp(); + new cloud.Secret(app, "my_secret"); - const s = await app.startSimulator(); + const s = await app.startSimulator(); - // THEN - expect(s.getResourceConfig("/my_secret")).toEqual({ - attrs: { - handle: expect.any(String), - }, - path: "root/my_secret", - addr: expect.any(String), - policy: [], - props: { - name: "my_secret-c84793b7", - }, - type: cloud.SECRET_FQN, - }); - await s.stop(); + // THEN + expect(s.getResourceConfig("/my_secret")).toEqual({ + attrs: { + handle: expect.any(String), + }, + path: "root/my_secret", + addr: expect.any(String), + policy: [], + props: { + name: "my_secret-c84793b7", + }, + type: cloud.SECRET_FQN, + }); + await s.stop(); - expect(app.snapshot()).toMatchSnapshot(); -}); - -test("can get the secret value", async () => { - // GIVEN - const app = new SimApp(); - new cloud.Secret(app, "my_secret", { - name: "wing-sim-test-my-secret", + expect(app.snapshot()).toMatchSnapshot(); }); - await fs.ensureFile(SECRETS_FILE); - const secretsContent = await fs.readFile(SECRETS_FILE, "utf-8"); + test("can get the secret value", async () => { + // GIVEN + const app = new SimApp(); + new cloud.Secret(app, "my_secret", { + name: "wing-sim-test-my-secret", + }); + + await fs.writeFile(SECRETS_FILE, "wing-sim-test-my-secret=secret-value"); - const secrets = tryParse(secretsContent) ?? {}; - await fs.writeFile( - SECRETS_FILE, - JSON.stringify({ - ...secrets, - "wing-sim-test-my-secret": "secret-value", - }) - ); + const secretsContent = fs.readFileSync(SECRETS_FILE, "utf-8"); + secretsContent.split("\n").forEach((line) => { + const [key, value] = line.split("="); + process.env[key] = value; + }); - const s = await app.startSimulator(); - const secretClient = s.getResource("/my_secret") as cloud.ISecretClient; + const s = await app.startSimulator(); + const secretClient = s.getResource("/my_secret") as cloud.ISecretClient; - // THEN - const secretValue = await secretClient.value(); - expect(secretValue).toBe("secret-value"); - await s.stop(); + // THEN + const secretValue = await secretClient.value(); + expect(secretValue).toBe("secret-value"); + await s.stop(); + }); }); function tryParse(content: string): string | undefined {