otavia 0.1.0
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/bun.lock +589 -0
- package/package.json +35 -0
- package/src/cli.ts +153 -0
- package/src/commands/__tests__/aws-auth.test.ts +32 -0
- package/src/commands/__tests__/cell.test.ts +44 -0
- package/src/commands/__tests__/dev.test.ts +49 -0
- package/src/commands/__tests__/init.test.ts +47 -0
- package/src/commands/__tests__/setup.test.ts +263 -0
- package/src/commands/aws-auth.ts +32 -0
- package/src/commands/aws.ts +59 -0
- package/src/commands/cell.ts +33 -0
- package/src/commands/clean.ts +32 -0
- package/src/commands/deploy.ts +508 -0
- package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
- package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
- package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
- package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
- package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
- package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
- package/src/commands/dev/__tests__/well-known.test.ts +88 -0
- package/src/commands/dev/forward-url.ts +7 -0
- package/src/commands/dev/gateway.ts +421 -0
- package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
- package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
- package/src/commands/dev/mount-selection.ts +9 -0
- package/src/commands/dev/tunnel.ts +176 -0
- package/src/commands/dev/vite-dev.ts +382 -0
- package/src/commands/dev/well-known.ts +76 -0
- package/src/commands/dev.ts +107 -0
- package/src/commands/init.ts +69 -0
- package/src/commands/lint.ts +49 -0
- package/src/commands/setup.ts +887 -0
- package/src/commands/test.ts +331 -0
- package/src/commands/typecheck.ts +36 -0
- package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
- package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
- package/src/config/__tests__/ports.test.ts +48 -0
- package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
- package/src/config/__tests__/resolve-params.test.ts +137 -0
- package/src/config/__tests__/resource-names.test.ts +62 -0
- package/src/config/cell-yaml-schema.ts +115 -0
- package/src/config/load-cell-yaml.ts +87 -0
- package/src/config/load-otavia-yaml.ts +256 -0
- package/src/config/otavia-yaml-schema.ts +49 -0
- package/src/config/ports.ts +57 -0
- package/src/config/resolve-cell-dir.ts +55 -0
- package/src/config/resolve-params.ts +160 -0
- package/src/config/resource-names.ts +60 -0
- package/src/deploy/__tests__/template.test.ts +137 -0
- package/src/deploy/api-gateway.ts +96 -0
- package/src/deploy/cloudflare-dns.ts +261 -0
- package/src/deploy/cloudfront.ts +228 -0
- package/src/deploy/dynamodb.ts +68 -0
- package/src/deploy/lambda.ts +121 -0
- package/src/deploy/s3.ts +57 -0
- package/src/deploy/template.ts +264 -0
- package/src/deploy/types.ts +16 -0
- package/src/local/docker.ts +175 -0
- package/src/local/dynamodb-local.ts +124 -0
- package/src/local/minio-local.ts +44 -0
- package/src/utils/env.test.ts +74 -0
- package/src/utils/env.ts +79 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema types for otavia.yaml
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface OtaviaYamlDns {
|
|
6
|
+
provider?: string;
|
|
7
|
+
zone?: string;
|
|
8
|
+
zoneId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OtaviaYamlDomain {
|
|
12
|
+
host: string;
|
|
13
|
+
dns?: OtaviaYamlDns;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OtaviaYamlOAuthCallback {
|
|
17
|
+
/** Cell name mounted in otavia paths, e.g. "sso". */
|
|
18
|
+
cell: string;
|
|
19
|
+
/** Callback path inside that cell, e.g. "/oauth/callback". */
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OtaviaYamlOAuth {
|
|
24
|
+
callback?: OtaviaYamlOAuthCallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One cell in the stack: mount path segment (e.g. "sso") and its package (e.g. "@otavia/sso"). */
|
|
28
|
+
export interface CellEntry {
|
|
29
|
+
mount: string;
|
|
30
|
+
package: string;
|
|
31
|
+
params?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* cellsList is the canonical form: ordered list of { package, mount, params? }.
|
|
36
|
+
* cells keeps a compatibility map mount -> package for display/legacy behavior.
|
|
37
|
+
*/
|
|
38
|
+
export interface OtaviaYaml {
|
|
39
|
+
stackName: string;
|
|
40
|
+
/** Optional default cell for "/" redirect (fallback: first cell mount). */
|
|
41
|
+
defaultCell?: string;
|
|
42
|
+
/** mount -> package (for display/serialization). Use cellsList for iteration. */
|
|
43
|
+
cells: Record<string, string>;
|
|
44
|
+
/** Ordered list of { mount, package }; first is default/first cell. */
|
|
45
|
+
cellsList: CellEntry[];
|
|
46
|
+
domain: OtaviaYamlDomain;
|
|
47
|
+
params?: Record<string, unknown>;
|
|
48
|
+
oauth?: OtaviaYamlOAuth;
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type PortStage = "dev" | "test";
|
|
2
|
+
|
|
3
|
+
export type StagePorts = {
|
|
4
|
+
portBase: number;
|
|
5
|
+
frontend: number;
|
|
6
|
+
backend: number;
|
|
7
|
+
dynamodb: number;
|
|
8
|
+
minio: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type PortOffsets = {
|
|
12
|
+
frontend: number;
|
|
13
|
+
backend: number;
|
|
14
|
+
dynamodb: number;
|
|
15
|
+
minio: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const OFFSETS: Record<PortStage, PortOffsets> = {
|
|
19
|
+
dev: {
|
|
20
|
+
frontend: 100,
|
|
21
|
+
backend: 1900,
|
|
22
|
+
dynamodb: 2001,
|
|
23
|
+
minio: 2000,
|
|
24
|
+
},
|
|
25
|
+
test: {
|
|
26
|
+
frontend: 100,
|
|
27
|
+
backend: 910,
|
|
28
|
+
dynamodb: 12,
|
|
29
|
+
minio: 1014,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function resolvePortsFromPortBase(stage: PortStage, portBase: number): StagePorts {
|
|
34
|
+
const offsets = OFFSETS[stage];
|
|
35
|
+
return {
|
|
36
|
+
portBase,
|
|
37
|
+
frontend: portBase + offsets.frontend,
|
|
38
|
+
backend: portBase + offsets.backend,
|
|
39
|
+
dynamodb: portBase + offsets.dynamodb,
|
|
40
|
+
minio: portBase + offsets.minio,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolvePortsFromEnv(
|
|
45
|
+
stage: PortStage,
|
|
46
|
+
env: Record<string, string | undefined> = process.env
|
|
47
|
+
): StagePorts {
|
|
48
|
+
const raw = env.PORT_BASE?.trim();
|
|
49
|
+
if (!raw) {
|
|
50
|
+
throw new Error(`Missing PORT_BASE for stage "${stage}". Define it in .env.dev/.env.test or process env.`);
|
|
51
|
+
}
|
|
52
|
+
const portBase = Number.parseInt(raw, 10);
|
|
53
|
+
if (!Number.isFinite(portBase)) {
|
|
54
|
+
throw new Error(`Invalid PORT_BASE for stage "${stage}": "${raw}"`);
|
|
55
|
+
}
|
|
56
|
+
return resolvePortsFromPortBase(stage, portBase);
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve cell package root directory from project root by package name (e.g. @otavia/sso) or mount slug.
|
|
6
|
+
*
|
|
7
|
+
* **Default layout:** `cells/<name>/cell.yaml` (name = mount or last segment of package name).
|
|
8
|
+
* For scoped packages, `cells/<slug>` is checked before `apps/<slug>` and before `node_modules`.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveCellDir(rootDir: string, packageOrMount: string): string {
|
|
11
|
+
const isPackageName = packageOrMount.includes("/") || packageOrMount.startsWith("@");
|
|
12
|
+
if (isPackageName) {
|
|
13
|
+
const slug = packageOrMount.split("/").pop() ?? packageOrMount;
|
|
14
|
+
const cellsSlug = resolve(rootDir, "cells", slug);
|
|
15
|
+
if (existsSync(resolve(cellsSlug, "cell.yaml"))) return cellsSlug;
|
|
16
|
+
const appsSlug = resolve(rootDir, "apps", slug);
|
|
17
|
+
if (existsSync(resolve(appsSlug, "cell.yaml"))) return appsSlug;
|
|
18
|
+
const dir = resolveCellDirByPackage(rootDir, packageOrMount);
|
|
19
|
+
if (dir) return dir;
|
|
20
|
+
return cellsSlug;
|
|
21
|
+
}
|
|
22
|
+
const cellsSubdir = resolve(rootDir, "cells", packageOrMount);
|
|
23
|
+
if (existsSync(resolve(cellsSubdir, "cell.yaml"))) {
|
|
24
|
+
return cellsSubdir;
|
|
25
|
+
}
|
|
26
|
+
const appsSubdir = resolve(rootDir, "apps", packageOrMount);
|
|
27
|
+
if (existsSync(resolve(appsSubdir, "cell.yaml"))) {
|
|
28
|
+
return appsSubdir;
|
|
29
|
+
}
|
|
30
|
+
const sibling = resolve(rootDir, "..", packageOrMount);
|
|
31
|
+
if (existsSync(resolve(sibling, "cell.yaml"))) {
|
|
32
|
+
return sibling;
|
|
33
|
+
}
|
|
34
|
+
return cellsSubdir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find package root (directory containing cell.yaml) by walking node_modules from rootDir upward.
|
|
39
|
+
*/
|
|
40
|
+
function resolveCellDirByPackage(rootDir: string, packageName: string): string | null {
|
|
41
|
+
let dir = resolve(rootDir);
|
|
42
|
+
const parts = packageName.split("/");
|
|
43
|
+
const pkgPath = parts.length === 2 && packageName.startsWith("@")
|
|
44
|
+
? `${parts[0]}/${parts[1]}`
|
|
45
|
+
: parts[0];
|
|
46
|
+
while (dir !== resolve(dir, "..")) {
|
|
47
|
+
const candidate = resolve(dir, "node_modules", pkgPath);
|
|
48
|
+
const cellYaml = resolve(candidate, "cell.yaml");
|
|
49
|
+
if (existsSync(cellYaml)) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
dir = resolve(dir, "..");
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { isEnvRef, isParamRef, isSecretRef } from "./cell-yaml-schema.js";
|
|
2
|
+
|
|
3
|
+
/** Thrown when a required !Env or !Secret is missing from envMap and onMissingParam is "throw". */
|
|
4
|
+
export class MissingParamsError extends Error {
|
|
5
|
+
readonly missingKeys: string[];
|
|
6
|
+
|
|
7
|
+
constructor(missingKeys: string[]) {
|
|
8
|
+
const message = [
|
|
9
|
+
"",
|
|
10
|
+
"Missing required params:",
|
|
11
|
+
...missingKeys.map((k) => ` ${k}`),
|
|
12
|
+
"",
|
|
13
|
+
"Add them to your .env files, then retry.",
|
|
14
|
+
"",
|
|
15
|
+
].join("\n");
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "MissingParamsError";
|
|
18
|
+
this.missingKeys = missingKeys;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thrown when a cell declares required params but otavia.yaml does not provide values. */
|
|
23
|
+
export class MissingDeclaredParamsError extends Error {
|
|
24
|
+
readonly missingKeys: string[];
|
|
25
|
+
|
|
26
|
+
constructor(missingKeys: string[], context?: string) {
|
|
27
|
+
const header = context ? `Missing required params for ${context}:` : "Missing required params:";
|
|
28
|
+
const message = ["", header, ...missingKeys.map((k) => ` ${k}`), ""].join("\n");
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "MissingDeclaredParamsError";
|
|
31
|
+
this.missingKeys = missingKeys;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True if value is a plain object (nested config), not EnvRef/SecretRef. */
|
|
36
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
37
|
+
return (
|
|
38
|
+
typeof v === "object" &&
|
|
39
|
+
v !== null &&
|
|
40
|
+
!Array.isArray(v) &&
|
|
41
|
+
!isEnvRef(v) &&
|
|
42
|
+
!isSecretRef(v)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merge stack and cell params; top-level key override: cell wins over stack.
|
|
48
|
+
*/
|
|
49
|
+
export function mergeParams(
|
|
50
|
+
stackParams?: Record<string, unknown>,
|
|
51
|
+
cellParams?: Record<string, unknown>
|
|
52
|
+
): Record<string, unknown> {
|
|
53
|
+
const stack = stackParams ?? {};
|
|
54
|
+
const cell = cellParams ?? {};
|
|
55
|
+
|
|
56
|
+
function resolveCellValue(value: unknown): unknown {
|
|
57
|
+
if (isParamRef(value)) {
|
|
58
|
+
return stack[value.param];
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.map((v) => resolveCellValue(v));
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "object" && value !== null) {
|
|
64
|
+
const out: Record<string, unknown> = {};
|
|
65
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
66
|
+
out[k] = resolveCellValue(v);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolvedCell: Record<string, unknown> = {};
|
|
74
|
+
for (const [k, v] of Object.entries(cell)) {
|
|
75
|
+
resolvedCell[k] = resolveCellValue(v);
|
|
76
|
+
}
|
|
77
|
+
return { ...stack, ...resolvedCell };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate that all declared param keys exist in provided params.
|
|
82
|
+
* Value `undefined` is treated as missing.
|
|
83
|
+
*/
|
|
84
|
+
export function assertDeclaredParamsProvided(
|
|
85
|
+
declaredParams: string[] | undefined,
|
|
86
|
+
providedParams: Record<string, unknown>,
|
|
87
|
+
context?: string
|
|
88
|
+
): void {
|
|
89
|
+
if (!declaredParams || declaredParams.length === 0) return;
|
|
90
|
+
const missing: string[] = [];
|
|
91
|
+
for (const key of declaredParams) {
|
|
92
|
+
if (!Object.prototype.hasOwnProperty.call(providedParams, key) || providedParams[key] === undefined) {
|
|
93
|
+
missing.push(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (missing.length > 0) {
|
|
97
|
+
throw new MissingDeclaredParamsError(missing, context);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const PLACEHOLDER = "[missing]";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve !Env and !Secret in merged params using envMap.
|
|
105
|
+
* Recurses into nested objects; only leaf EnvRef/SecretRef are replaced.
|
|
106
|
+
* - onMissingParam "throw": throw MissingParamsError with list of missing keys.
|
|
107
|
+
* - onMissingParam "placeholder": use PLACEHOLDER for missing values.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveParams(
|
|
110
|
+
mergedParams: Record<string, unknown>,
|
|
111
|
+
envMap: Record<string, string>,
|
|
112
|
+
options?: { onMissingParam?: "throw" | "placeholder" }
|
|
113
|
+
): Record<string, string | unknown> {
|
|
114
|
+
const onMissing = options?.onMissingParam ?? "throw";
|
|
115
|
+
const missingKeys: string[] = [];
|
|
116
|
+
|
|
117
|
+
function resolveValue(value: unknown): string | unknown {
|
|
118
|
+
if (isEnvRef(value)) {
|
|
119
|
+
const v = envMap[value.env];
|
|
120
|
+
if (v === undefined) {
|
|
121
|
+
if (onMissing === "throw") {
|
|
122
|
+
missingKeys.push(value.env);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
return PLACEHOLDER;
|
|
126
|
+
}
|
|
127
|
+
return v;
|
|
128
|
+
}
|
|
129
|
+
if (isSecretRef(value)) {
|
|
130
|
+
const v = envMap[value.secret];
|
|
131
|
+
if (v === undefined) {
|
|
132
|
+
if (onMissing === "throw") {
|
|
133
|
+
missingKeys.push(value.secret);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
return PLACEHOLDER;
|
|
137
|
+
}
|
|
138
|
+
return v;
|
|
139
|
+
}
|
|
140
|
+
if (isPlainObject(value)) {
|
|
141
|
+
const out: Record<string, unknown> = {};
|
|
142
|
+
for (const [k, v] of Object.entries(value)) {
|
|
143
|
+
out[k] = resolveValue(v);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result: Record<string, string | unknown> = {};
|
|
151
|
+
for (const [key, value] of Object.entries(mergedParams)) {
|
|
152
|
+
result[key] = resolveValue(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (onMissing === "throw" && missingKeys.length > 0) {
|
|
156
|
+
throw new MissingParamsError([...new Set(missingKeys)]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const S3_BUCKET_MAX_LENGTH = 63;
|
|
4
|
+
const HASH_SUFFIX_LENGTH = 8;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a segment for use in physical resource names: lowercase, hyphens only.
|
|
8
|
+
* S3 bucket names allow only lowercase, digits, and hyphens (no underscore).
|
|
9
|
+
*/
|
|
10
|
+
function normalizeSegment(s: string): string {
|
|
11
|
+
return s.toLowerCase().replace(/_/g, "-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the base physical name: `<stackName>-<cellId>-<key>` normalized.
|
|
16
|
+
*/
|
|
17
|
+
function basePhysicalName(
|
|
18
|
+
stackName: string,
|
|
19
|
+
cellId: string,
|
|
20
|
+
key: string,
|
|
21
|
+
): string {
|
|
22
|
+
const s = `${normalizeSegment(stackName)}-${normalizeSegment(cellId)}-${normalizeSegment(key)}`;
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Table physical name for DynamoDB: `<stackName>-<cellId>-<tableKey>`.
|
|
28
|
+
* Normalized to lowercase with hyphens (any uppercase or underscore is normalized).
|
|
29
|
+
*/
|
|
30
|
+
export function tablePhysicalName(
|
|
31
|
+
stackName: string,
|
|
32
|
+
cellId: string,
|
|
33
|
+
tableKey: string,
|
|
34
|
+
): string {
|
|
35
|
+
return basePhysicalName(stackName, cellId, tableKey);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bucket physical name for S3: same pattern as table, but length must be ≤63.
|
|
40
|
+
* S3 rules: only lowercase, digits, hyphens (no underscore).
|
|
41
|
+
*
|
|
42
|
+
* Truncation rule when length > 63: take the first (63 - 1 - 8) = 54 characters
|
|
43
|
+
* of the full normalized name (so we truncate proportionally across stackName,
|
|
44
|
+
* cellId, and bucketKey), then append a hyphen and the first 8 hex chars of
|
|
45
|
+
* SHA256(full normalized string) so the result is unique and deterministic.
|
|
46
|
+
*/
|
|
47
|
+
export function bucketPhysicalName(
|
|
48
|
+
stackName: string,
|
|
49
|
+
cellId: string,
|
|
50
|
+
bucketKey: string,
|
|
51
|
+
): string {
|
|
52
|
+
const full = basePhysicalName(stackName, cellId, bucketKey);
|
|
53
|
+
if (full.length <= S3_BUCKET_MAX_LENGTH) {
|
|
54
|
+
return full;
|
|
55
|
+
}
|
|
56
|
+
const truncateTo = S3_BUCKET_MAX_LENGTH - 1 - HASH_SUFFIX_LENGTH; // 54
|
|
57
|
+
const truncated = full.slice(0, truncateTo);
|
|
58
|
+
const hash = createHash("sha256").update(full).digest("hex").slice(0, HASH_SUFFIX_LENGTH);
|
|
59
|
+
return `${truncated}-${hash}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { generateTemplate } from "../template.js";
|
|
6
|
+
|
|
7
|
+
function createMinimalOtaviaRoot(): string {
|
|
8
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-deploy-test-"));
|
|
9
|
+
fs.writeFileSync(
|
|
10
|
+
path.join(tmp, "otavia.yaml"),
|
|
11
|
+
`
|
|
12
|
+
stackName: test-stack
|
|
13
|
+
cells:
|
|
14
|
+
- sso
|
|
15
|
+
domain:
|
|
16
|
+
host: example.com
|
|
17
|
+
params:
|
|
18
|
+
FOO: bar
|
|
19
|
+
`,
|
|
20
|
+
"utf-8"
|
|
21
|
+
);
|
|
22
|
+
const ssoDir = path.join(tmp, "apps", "sso");
|
|
23
|
+
fs.mkdirSync(ssoDir, { recursive: true });
|
|
24
|
+
fs.writeFileSync(
|
|
25
|
+
path.join(ssoDir, "cell.yaml"),
|
|
26
|
+
`
|
|
27
|
+
name: sso
|
|
28
|
+
params:
|
|
29
|
+
- FOO
|
|
30
|
+
tables:
|
|
31
|
+
users:
|
|
32
|
+
keys: { pk: S, sk: S }
|
|
33
|
+
backend:
|
|
34
|
+
runtime: nodejs20.x
|
|
35
|
+
entries:
|
|
36
|
+
api:
|
|
37
|
+
handler: index.ts
|
|
38
|
+
timeout: 30
|
|
39
|
+
memory: 256
|
|
40
|
+
routes:
|
|
41
|
+
- /api/*
|
|
42
|
+
`,
|
|
43
|
+
"utf-8"
|
|
44
|
+
);
|
|
45
|
+
return tmp;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("generateTemplate", () => {
|
|
49
|
+
test("produces valid YAML with AWS::DynamoDB::Table, AWS::Lambda::Function, and AWS::ApiGatewayV2::Api", () => {
|
|
50
|
+
const rootDir = createMinimalOtaviaRoot();
|
|
51
|
+
try {
|
|
52
|
+
const yaml = generateTemplate(rootDir);
|
|
53
|
+
expect(typeof yaml).toBe("string");
|
|
54
|
+
expect(yaml).toContain("AWSTemplateFormatVersion");
|
|
55
|
+
expect(yaml).toContain("Resources:");
|
|
56
|
+
|
|
57
|
+
expect(yaml).toContain("AWS::DynamoDB::Table");
|
|
58
|
+
expect(yaml).toContain("AWS::Lambda::Function");
|
|
59
|
+
expect(yaml).toContain("AWS::ApiGatewayV2::Api");
|
|
60
|
+
|
|
61
|
+
expect(yaml).toContain("SsoUsersTable");
|
|
62
|
+
expect(yaml).toContain("SsoApiFunction");
|
|
63
|
+
expect(yaml).toContain("SsoHttpApi");
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(rootDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("includes frontend bucket and CloudFront when domain.host is set", () => {
|
|
70
|
+
const rootDir = createMinimalOtaviaRoot();
|
|
71
|
+
try {
|
|
72
|
+
const yaml = generateTemplate(rootDir);
|
|
73
|
+
expect(yaml).toContain("FrontendBucket");
|
|
74
|
+
expect(yaml).toContain("AWS::CloudFront::Distribution");
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(rootDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("derives CloudFront API behaviors from backend entry routes", () => {
|
|
81
|
+
const rootDir = createMinimalOtaviaRoot();
|
|
82
|
+
try {
|
|
83
|
+
const yaml = generateTemplate(rootDir);
|
|
84
|
+
expect(yaml).toContain("PathPattern: /sso/api/*");
|
|
85
|
+
expect(yaml).not.toContain("PathPattern: /sso/*");
|
|
86
|
+
} finally {
|
|
87
|
+
fs.rmSync(rootDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses defaultCell as CloudFront root redirect target", () => {
|
|
92
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "otavia-deploy-test-"));
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(tmp, "otavia.yaml"),
|
|
95
|
+
`
|
|
96
|
+
stackName: test-stack
|
|
97
|
+
defaultCell: drive
|
|
98
|
+
cells:
|
|
99
|
+
sso: "@otavia/sso"
|
|
100
|
+
drive: "@otavia/drive"
|
|
101
|
+
domain:
|
|
102
|
+
host: example.com
|
|
103
|
+
`,
|
|
104
|
+
"utf-8"
|
|
105
|
+
);
|
|
106
|
+
const ssoDir = path.join(tmp, "apps", "sso");
|
|
107
|
+
fs.mkdirSync(ssoDir, { recursive: true });
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
path.join(ssoDir, "cell.yaml"),
|
|
110
|
+
`
|
|
111
|
+
name: sso
|
|
112
|
+
backend:
|
|
113
|
+
runtime: nodejs20.x
|
|
114
|
+
entries:
|
|
115
|
+
api:
|
|
116
|
+
handler: index.ts
|
|
117
|
+
timeout: 30
|
|
118
|
+
memory: 256
|
|
119
|
+
routes:
|
|
120
|
+
- /api/*
|
|
121
|
+
`,
|
|
122
|
+
"utf-8"
|
|
123
|
+
);
|
|
124
|
+
const driveDir = path.join(tmp, "apps", "drive");
|
|
125
|
+
fs.mkdirSync(driveDir, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(driveDir, "cell.yaml"), "name: drive\n", "utf-8");
|
|
127
|
+
try {
|
|
128
|
+
const yaml = generateTemplate(tmp);
|
|
129
|
+
expect(yaml).toContain('var rootRedirectPath = "/drive/";');
|
|
130
|
+
expect(yaml).toContain("statusCode: 302");
|
|
131
|
+
expect(yaml).toContain("location: { value: rootRedirectPath }");
|
|
132
|
+
expect(yaml).not.toContain("event.request.uri = '/index.html';");
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(tmp, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CfnFragment } from "./types.js";
|
|
2
|
+
import { toPascalCase } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface HttpApiRoute {
|
|
5
|
+
/** Logical id of the Lambda function (e.g. SsoApiFunction) */
|
|
6
|
+
functionLogicalId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate API Gateway HTTP API with integrations to Lambda.
|
|
11
|
+
* One route per backend entry; path stripping: /<cellId>/api -> Lambda receives /api (handled by gateway/integration).
|
|
12
|
+
*/
|
|
13
|
+
export function generateHttpApi(
|
|
14
|
+
logicalIdPrefix: string,
|
|
15
|
+
apiName: string,
|
|
16
|
+
routes: HttpApiRoute[]
|
|
17
|
+
): CfnFragment {
|
|
18
|
+
const resources: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
const apiLogicalId = `${logicalIdPrefix}HttpApi`;
|
|
21
|
+
resources[apiLogicalId] = {
|
|
22
|
+
Type: "AWS::ApiGatewayV2::Api",
|
|
23
|
+
Properties: {
|
|
24
|
+
Name: apiName,
|
|
25
|
+
ProtocolType: "HTTP",
|
|
26
|
+
CorsConfiguration: {
|
|
27
|
+
AllowOrigins: ["*"],
|
|
28
|
+
AllowMethods: ["*"],
|
|
29
|
+
AllowHeaders: ["*"],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < routes.length; i++) {
|
|
35
|
+
const route = routes[i];
|
|
36
|
+
const pascal = toPascalCase(route.functionLogicalId.replace(/Function$/, "") || "Default");
|
|
37
|
+
const integrationId = `${logicalIdPrefix}${pascal}Integration`;
|
|
38
|
+
const routeId = `${logicalIdPrefix}${pascal}Route`;
|
|
39
|
+
const permId = `${logicalIdPrefix}${pascal}LambdaPermission`;
|
|
40
|
+
|
|
41
|
+
resources[integrationId] = {
|
|
42
|
+
Type: "AWS::ApiGatewayV2::Integration",
|
|
43
|
+
Properties: {
|
|
44
|
+
ApiId: { Ref: apiLogicalId },
|
|
45
|
+
IntegrationType: "AWS_PROXY",
|
|
46
|
+
IntegrationUri: { "Fn::GetAtt": [route.functionLogicalId, "Arn"] },
|
|
47
|
+
PayloadFormatVersion: "2.0",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
resources[routeId] = {
|
|
52
|
+
Type: "AWS::ApiGatewayV2::Route",
|
|
53
|
+
Properties: {
|
|
54
|
+
ApiId: { Ref: apiLogicalId },
|
|
55
|
+
RouteKey: "$default",
|
|
56
|
+
Target: {
|
|
57
|
+
"Fn::Sub": `integrations/\${${integrationId}}`,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
resources[permId] = {
|
|
63
|
+
Type: "AWS::Lambda::Permission",
|
|
64
|
+
Properties: {
|
|
65
|
+
FunctionName: { Ref: route.functionLogicalId },
|
|
66
|
+
Action: "lambda:InvokeFunction",
|
|
67
|
+
Principal: "apigateway.amazonaws.com",
|
|
68
|
+
SourceArn: {
|
|
69
|
+
"Fn::Sub": `arn:aws:execute-api:\${AWS::Region}:\${AWS::AccountId}:\${${apiLogicalId}}/*`,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const stageLogicalId = `${logicalIdPrefix}HttpApiStage`;
|
|
76
|
+
resources[stageLogicalId] = {
|
|
77
|
+
Type: "AWS::ApiGatewayV2::Stage",
|
|
78
|
+
Properties: {
|
|
79
|
+
ApiId: { Ref: apiLogicalId },
|
|
80
|
+
StageName: "$default",
|
|
81
|
+
AutoDeploy: true,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
Resources: resources,
|
|
87
|
+
Outputs: {
|
|
88
|
+
[`${logicalIdPrefix}HttpApiId`]: { Value: { Ref: apiLogicalId } },
|
|
89
|
+
[`${logicalIdPrefix}HttpApiEndpoint`]: {
|
|
90
|
+
Value: {
|
|
91
|
+
"Fn::Sub": `https://\${${apiLogicalId}}.execute-api.\${AWS::Region}.amazonaws.com`,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|