@stacksjs/ts-cloud 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,8 @@ export declare function resolveServerlessFunctions(config: CloudConfig, environm
43
43
  cli: string;
44
44
  };
45
45
  };
46
+ /** Resolve secrets from Secrets Manager into a flat env map. */
47
+ export declare function resolveSecrets(app: ServerlessAppConfig, region: string): Promise<Record<string, string>>;
46
48
  /**
47
49
  * Derive framework env vars (DB/Redis hosts) from the deployed stack outputs so
48
50
  * a Laravel app connects to the Aurora/RDS-Proxy/ElastiCache resources the
@@ -72,6 +74,24 @@ export declare function rollbackServerlessApp(config: CloudConfig, environment:
72
74
  export declare function setMaintenance(config: CloudConfig, environment: EnvironmentType, enabled: boolean, bypassSecret?: string): Promise<void>;
73
75
  /** Invoke the CLI function with an arbitrary command (e.g. `cloud command "migrate"`). */
74
76
  export declare function runRemoteCommand(config: CloudConfig, environment: EnvironmentType, command: string): Promise<string>;
77
+ /** One recorded `cloud command` invocation. */
78
+ export interface CommandRecord {
79
+ id: number;
80
+ command: string;
81
+ timestamp: string;
82
+ status: 'ok' | 'error';
83
+ output: string;
84
+ }
85
+ /** Run a command on the CLI function AND record it to the history log (best-effort). */
86
+ export declare function runAndRecordCommand(config: CloudConfig, environment: EnvironmentType, command: string): Promise<{
87
+ id: number;
88
+ status: 'ok' | 'error';
89
+ output: string;
90
+ }>;
91
+ /** Return the recorded command history (most-recent last). */
92
+ export declare function listCommandHistory(config: CloudConfig, environment: EnvironmentType): Promise<CommandRecord[]>;
93
+ /** Look up one history record by id (or the most recent when id is omitted). */
94
+ export declare function getCommandRecord(config: CloudConfig, environment: EnvironmentType, id?: number): Promise<CommandRecord | undefined>;
75
95
  /**
76
96
  * Run a SQL statement against a (private, in-VPC) serverless database via the CLI
77
97
  * function — no bastion needed. Requires the `tscloud/serverless` PHP bridge
package/dist/index.js CHANGED
@@ -26026,6 +26026,28 @@ function composeServerlessAppTemplate(opts) {
26026
26026
  addFunction("CliFunction", functionNames.cli, handlers.cli, "cli", app.cliMemory ?? 1024, app.cliTimeout ?? 900, undefined, app.cliTmpStorage ?? tmpStorage);
26027
26027
  if (hasQueue)
26028
26028
  addFunction("QueueFunction", functionNames.queue, handlers.queue, "queue", app.queueMemory ?? 1024, app.queueTimeout ?? 120, undefined, app.queueTmpStorage ?? tmpStorage);
26029
+ const pc = (app.provisionedConcurrency ?? 0) > 0 ? app.provisionedConcurrency : 0;
26030
+ const fnLogicalIds = ["HttpFunction", "CliFunction", ...hasQueue ? ["QueueFunction"] : []];
26031
+ if (pc) {
26032
+ for (const L of fnLogicalIds) {
26033
+ resources[`${L}Version`] = {
26034
+ Type: "AWS::Lambda::Version",
26035
+ DeletionPolicy: "Retain",
26036
+ Properties: { FunctionName: Fn2.ref(L) }
26037
+ };
26038
+ resources[`${L}Alias`] = {
26039
+ Type: "AWS::Lambda::Alias",
26040
+ Properties: {
26041
+ FunctionName: Fn2.ref(L),
26042
+ Name: "live",
26043
+ FunctionVersion: Fn2.getAtt(`${L}Version`, "Version"),
26044
+ ProvisionedConcurrencyConfig: { ProvisionedConcurrentExecutions: pc }
26045
+ }
26046
+ };
26047
+ }
26048
+ }
26049
+ const invokeArn = (L) => pc ? Fn2.ref(`${L}Alias`) : Fn2.getAtt(L, "Arn");
26050
+ const invokeName = (L) => pc ? Fn2.ref(`${L}Alias`) : Fn2.ref(L);
26029
26051
  resources.HttpApi = {
26030
26052
  Type: "AWS::ApiGatewayV2::Api",
26031
26053
  Properties: {
@@ -26038,7 +26060,7 @@ function composeServerlessAppTemplate(opts) {
26038
26060
  Properties: {
26039
26061
  ApiId: Fn2.ref("HttpApi"),
26040
26062
  IntegrationType: "AWS_PROXY",
26041
- IntegrationUri: Fn2.getAtt("HttpFunction", "Arn"),
26063
+ IntegrationUri: invokeArn("HttpFunction"),
26042
26064
  PayloadFormatVersion: "2.0"
26043
26065
  }
26044
26066
  };
@@ -26061,7 +26083,7 @@ function composeServerlessAppTemplate(opts) {
26061
26083
  resources.HttpPermission = {
26062
26084
  Type: "AWS::Lambda::Permission",
26063
26085
  Properties: {
26064
- FunctionName: Fn2.ref("HttpFunction"),
26086
+ FunctionName: invokeName("HttpFunction"),
26065
26087
  Action: "lambda:InvokeFunction",
26066
26088
  Principal: "apigateway.amazonaws.com",
26067
26089
  SourceArn: Fn2.sub("arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*")
@@ -26152,7 +26174,7 @@ function composeServerlessAppTemplate(opts) {
26152
26174
  Type: "AWS::Lambda::EventSourceMapping",
26153
26175
  Properties: {
26154
26176
  EventSourceArn: Fn2.getAtt(qId, "Arn"),
26155
- FunctionName: Fn2.ref("QueueFunction"),
26177
+ FunctionName: invokeName("QueueFunction"),
26156
26178
  BatchSize: 1,
26157
26179
  FunctionResponseTypes: ["ReportBatchItemFailures"],
26158
26180
  ...concurrency ? { ScalingConfig: { MaximumConcurrency: Math.max(2, concurrency) } } : {}
@@ -26170,7 +26192,7 @@ function composeServerlessAppTemplate(opts) {
26170
26192
  State: "ENABLED",
26171
26193
  Targets: [{
26172
26194
  Id: "cli",
26173
- Arn: Fn2.getAtt("CliFunction", "Arn"),
26195
+ Arn: invokeArn("CliFunction"),
26174
26196
  Input: JSON.stringify({ command: "schedule:run" })
26175
26197
  }]
26176
26198
  }
@@ -26178,7 +26200,7 @@ function composeServerlessAppTemplate(opts) {
26178
26200
  resources.SchedulerPermission = {
26179
26201
  Type: "AWS::Lambda::Permission",
26180
26202
  Properties: {
26181
- FunctionName: Fn2.ref("CliFunction"),
26203
+ FunctionName: invokeName("CliFunction"),
26182
26204
  Action: "lambda:InvokeFunction",
26183
26205
  Principal: "events.amazonaws.com",
26184
26206
  SourceArn: Fn2.getAtt("SchedulerRule", "Arn")
@@ -26187,34 +26209,39 @@ function composeServerlessAppTemplate(opts) {
26187
26209
  }
26188
26210
  if (app.warm && app.warm > 0) {
26189
26211
  const TARGETS_PER_RULE = 5;
26190
- const ruleCount = Math.ceil(app.warm / TARGETS_PER_RULE);
26191
- let warmed = 0;
26192
- for (let r = 0;r < ruleCount; r++) {
26193
- const targets = Math.min(TARGETS_PER_RULE, app.warm - warmed);
26194
- resources[`WarmerRule${r}`] = {
26195
- Type: "AWS::Events::Rule",
26212
+ const fnResource = { http: "HttpFunction", queue: "QueueFunction", cli: "CliFunction" };
26213
+ const warmModes = (app.warmFunctions ?? ["http"]).filter((m) => m !== "queue" || hasQueue);
26214
+ for (const mode of warmModes) {
26215
+ const cap = mode.charAt(0).toUpperCase() + mode.slice(1);
26216
+ const ruleCount = Math.ceil(app.warm / TARGETS_PER_RULE);
26217
+ let warmed = 0;
26218
+ for (let r = 0;r < ruleCount; r++) {
26219
+ const targets = Math.min(TARGETS_PER_RULE, app.warm - warmed);
26220
+ resources[`Warmer${cap}Rule${r}`] = {
26221
+ Type: "AWS::Events::Rule",
26222
+ Properties: {
26223
+ Name: `${slug}-${environment}-warmer-${mode}-${r}`,
26224
+ ScheduleExpression: "rate(5 minutes)",
26225
+ State: "ENABLED",
26226
+ Targets: Array.from({ length: targets }, (_, i) => ({
26227
+ Id: `warm-${mode}-${r}-${i}`,
26228
+ Arn: Fn2.getAtt(fnResource[mode], "Arn"),
26229
+ Input: JSON.stringify({ warmer: true })
26230
+ }))
26231
+ }
26232
+ };
26233
+ warmed += targets;
26234
+ }
26235
+ resources[`Warmer${cap}Permission`] = {
26236
+ Type: "AWS::Lambda::Permission",
26196
26237
  Properties: {
26197
- Name: `${slug}-${environment}-warmer-${r}`,
26198
- ScheduleExpression: "rate(5 minutes)",
26199
- State: "ENABLED",
26200
- Targets: Array.from({ length: targets }, (_, i) => ({
26201
- Id: `warm-${r}-${i}`,
26202
- Arn: Fn2.getAtt("HttpFunction", "Arn"),
26203
- Input: JSON.stringify({ warmer: true })
26204
- }))
26238
+ FunctionName: Fn2.ref(fnResource[mode]),
26239
+ Action: "lambda:InvokeFunction",
26240
+ Principal: "events.amazonaws.com",
26241
+ SourceArn: Fn2.sub(`arn:aws:events:\${AWS::Region}:\${AWS::AccountId}:rule/${slug}-${environment}-warmer-${mode}-*`)
26205
26242
  }
26206
26243
  };
26207
- warmed += targets;
26208
26244
  }
26209
- resources.WarmerPermission = {
26210
- Type: "AWS::Lambda::Permission",
26211
- Properties: {
26212
- FunctionName: Fn2.ref("HttpFunction"),
26213
- Action: "lambda:InvokeFunction",
26214
- Principal: "events.amazonaws.com",
26215
- SourceArn: Fn2.sub(`arn:aws:events:\${AWS::Region}:\${AWS::AccountId}:rule/${slug}-${environment}-warmer-*`)
26216
- }
26217
- };
26218
26245
  }
26219
26246
  if (cacheEnabled) {
26220
26247
  resources.CacheTable = {
@@ -26807,6 +26834,8 @@ function parseRecordBody(body) {
26807
26834
  function createQueueHandler(handler8) {
26808
26835
  return async (event) => {
26809
26836
  const batchItemFailures = [];
26837
+ if (event.warmer)
26838
+ return { batchItemFailures };
26810
26839
  if (!handler8)
26811
26840
  return { batchItemFailures };
26812
26841
  for (const record of event.Records ?? []) {
@@ -26821,6 +26850,8 @@ function createQueueHandler(handler8) {
26821
26850
  }
26822
26851
  function createCliHandler(handler8) {
26823
26852
  return async (event) => {
26853
+ if (event.warmer)
26854
+ return { statusCode: 0, output: "warm" };
26824
26855
  if (!handler8)
26825
26856
  return { statusCode: 501, output: "No CLI handler configured" };
26826
26857
  return handler8(event);
@@ -58022,6 +58053,9 @@ function colorize(text, color) {
58022
58053
  function success(message) {
58023
58054
  console.log(`${colors.green}✓${colors.reset} ${message}`);
58024
58055
  }
58056
+ function error(message) {
58057
+ console.error(`${colors.red}✗${colors.reset} ${message}`);
58058
+ }
58025
58059
  function warn(message) {
58026
58060
  console.warn(`${colors.yellow}⚠${colors.reset} ${message}`);
58027
58061
  }
@@ -83301,6 +83335,16 @@ async function resolveSecrets(app, region) {
83301
83335
  const sm = new SecretsManagerClient(region);
83302
83336
  const out = {};
83303
83337
  const entries = Array.isArray(app.secrets) ? app.secrets.map((name) => ({ envName: name.split("/").pop().toUpperCase().replace(/[^A-Z0-9_]/g, "_"), secretId: name })) : Object.entries(app.secrets).map(([envName, secretId]) => ({ envName, secretId }));
83338
+ if (Array.isArray(app.secrets)) {
83339
+ const byName = new Map;
83340
+ for (const { envName, secretId } of entries)
83341
+ byName.set(envName, [...byName.get(envName) ?? [], secretId]);
83342
+ const clashes = [...byName.entries()].filter(([, ids]) => ids.length > 1);
83343
+ if (clashes.length) {
83344
+ const detail = clashes.map(([name, ids]) => `${name} ← ${ids.join(", ")}`).join("; ");
83345
+ throw new Error(`serverless secrets: multiple secrets map to the same env var (${detail}). Use the map form { ENV_NAME: 'secret/id' } to disambiguate.`);
83346
+ }
83347
+ }
83304
83348
  for (const { envName, secretId } of entries) {
83305
83349
  const value = await sm.getSecretValue({ SecretId: secretId });
83306
83350
  const str = value.SecretString ?? "";
@@ -83417,6 +83461,14 @@ async function applyFunction(lambda2, name, env2, code) {
83417
83461
  await withConflictRetry(() => lambda2.updateFunctionCode(codeParams));
83418
83462
  await lambda2.waitForFunctionActive(name, 120);
83419
83463
  }
83464
+ async function publishAndFlip(lambda2, name) {
83465
+ const published = await withConflictRetry(() => lambda2.publishVersion({ FunctionName: name }));
83466
+ const version2 = published.Version;
83467
+ if (!version2)
83468
+ throw new Error(`publishVersion returned no version for ${name}`);
83469
+ await withConflictRetry(() => lambda2.updateAlias({ FunctionName: name, Name: "live", FunctionVersion: version2 }));
83470
+ return version2;
83471
+ }
83420
83472
  async function deployServerlessApp(config6, environment, opts = {}) {
83421
83473
  const ctx = resolveContext(config6, environment);
83422
83474
  const { app, slug, region, stackName, artifactBucket } = ctx;
@@ -83560,12 +83612,22 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83560
83612
  functionEnv.queue = buildFunctionEnv(app, ctx, environment, "queue", secrets, assetUrl, primaryQueue, infraEnv);
83561
83613
  await applyFunction(lambda2, composed.functionNames.queue, functionEnv.queue, codeSource);
83562
83614
  }
83615
+ const usesProvisionedConcurrency = (app.provisionedConcurrency ?? 0) > 0;
83616
+ const functionVersions = {};
83617
+ if (usesProvisionedConcurrency) {
83618
+ step("Publishing versions + flipping live alias");
83619
+ functionVersions.http = await publishAndFlip(lambda2, composed.functionNames.http);
83620
+ functionVersions.cli = await publishAndFlip(lambda2, composed.functionNames.cli);
83621
+ if (composed.queueNames.length)
83622
+ functionVersions.queue = await publishAndFlip(lambda2, composed.functionNames.queue);
83623
+ }
83563
83624
  await writeRelease(s32, artifactBucket, slug, environment, {
83564
83625
  sha: artifactSha,
83565
83626
  code: codeSource,
83566
83627
  previousSha: prior?.sha,
83567
83628
  previousCode: prior?.code,
83568
83629
  previousFunctionEnv: prior?.functionEnv,
83630
+ ...usesProvisionedConcurrency ? { functionVersions, previousFunctionVersions: prior?.functionVersions } : {},
83569
83631
  functionEnv,
83570
83632
  functionNames: {
83571
83633
  http: composed.functionNames.http,
@@ -83586,6 +83648,8 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83586
83648
  LogType: "Tail"
83587
83649
  });
83588
83650
  if (res.FunctionError) {
83651
+ error("Deploy hook failed — the new build is ALREADY LIVE.");
83652
+ info(` Roll back with: cloud serverless:rollback --env ${environment}`);
83589
83653
  throw new Error(`Deploy hook failed (${command}): ${res.Payload}`);
83590
83654
  }
83591
83655
  }
@@ -83622,6 +83686,9 @@ async function redeployServerlessApp(config6, environment) {
83622
83686
  continue;
83623
83687
  step(`Re-activating ${mode} (${name})`);
83624
83688
  await applyFunction(lambda2, name, release.functionEnv[mode] ?? {}, release.code);
83689
+ const version2 = release.functionVersions?.[mode];
83690
+ if (version2)
83691
+ await withConflictRetry(() => lambda2.updateAlias({ FunctionName: name, Name: "live", FunctionVersion: version2 }));
83625
83692
  }
83626
83693
  success("Redeploy complete");
83627
83694
  }
@@ -83639,15 +83706,20 @@ async function rollbackServerlessApp(config6, environment) {
83639
83706
  step(`Restoring ${mode} (${name})`);
83640
83707
  const env2 = release.previousFunctionEnv?.[mode] ?? release.functionEnv[mode] ?? {};
83641
83708
  await applyFunction(lambda2, name, env2, release.previousCode);
83709
+ const prevVersion = release.previousFunctionVersions?.[mode];
83710
+ if (prevVersion)
83711
+ await withConflictRetry(() => lambda2.updateAlias({ FunctionName: name, Name: "live", FunctionVersion: prevVersion }));
83642
83712
  }
83643
83713
  await writeRelease(s32, ctx.artifactBucket, ctx.slug, environment, {
83644
83714
  ...release,
83645
83715
  sha: release.previousSha ?? release.sha,
83646
83716
  code: release.previousCode,
83647
83717
  functionEnv: release.previousFunctionEnv ?? release.functionEnv,
83718
+ functionVersions: release.previousFunctionVersions ?? release.functionVersions,
83648
83719
  previousSha: undefined,
83649
83720
  previousCode: undefined,
83650
83721
  previousFunctionEnv: undefined,
83722
+ previousFunctionVersions: undefined,
83651
83723
  timestamp: new Date().toISOString()
83652
83724
  });
83653
83725
  success("Rollback complete");
@@ -83664,6 +83736,10 @@ async function setMaintenance(config6, environment, enabled, bypassSecret) {
83664
83736
  if (!enabled)
83665
83737
  delete env2.MAINTENANCE_BYPASS_SECRET;
83666
83738
  await withConflictRetry(() => lambda2.updateFunctionConfiguration({ FunctionName: httpName, Environment: { Variables: env2 } }));
83739
+ if ((ctx.app.provisionedConcurrency ?? 0) > 0) {
83740
+ await lambda2.waitForFunctionActive(httpName, 120);
83741
+ await publishAndFlip(lambda2, httpName);
83742
+ }
83667
83743
  success(enabled ? "Application is now in maintenance mode (503)" : "Application is live");
83668
83744
  }
83669
83745
  async function runRemoteCommand(config6, environment, command) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.5.7",
4
+ "version": "0.5.9",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -89,8 +89,8 @@
89
89
  "test": "bun test"
90
90
  },
91
91
  "dependencies": {
92
- "@ts-cloud/aws-types": "0.5.7",
93
- "@ts-cloud/core": "0.5.7",
92
+ "@ts-cloud/aws-types": "0.5.9",
93
+ "@ts-cloud/core": "0.5.9",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {