@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.
- package/dist/bin/cli.js +615 -613
- package/dist/deploy/serverless-app.d.ts +20 -0
- package/dist/index.js +105 -29
- package/package.json +3 -3
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
26191
|
-
|
|
26192
|
-
for (
|
|
26193
|
-
const
|
|
26194
|
-
|
|
26195
|
-
|
|
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
|
-
|
|
26198
|
-
|
|
26199
|
-
|
|
26200
|
-
|
|
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.
|
|
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.
|
|
93
|
-
"@ts-cloud/core": "0.5.
|
|
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": {
|