@sanoma-ai/workflows 0.11.3 → 0.11.5
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/KMS-SECRETS.md +3 -1
- package/README.md +33 -31
- package/dist/apply-steps.d.ts +4 -2
- package/dist/apply-steps.js +5 -3
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +6 -1
- package/dist/apply.js +55 -20
- package/dist/apply.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +52 -5
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +1 -1
- package/dist/definitions.d.ts +5 -0
- package/dist/definitions.js.map +1 -1
- package/dist/describe.d.ts +1 -1
- package/dist/describe.js +16 -11
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +7 -0
- package/dist/drift.js +15 -3
- package/dist/drift.js.map +1 -1
- package/dist/host-derive.d.ts +145 -0
- package/dist/host-derive.js +403 -0
- package/dist/host-derive.js.map +1 -0
- package/dist/host.d.ts +70 -5
- package/dist/host.js +56 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.js +24 -4
- package/dist/kms-secrets.js.map +1 -1
- package/dist/resource.d.ts +10 -2
- package/dist/resource.js +19 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.js +1 -1
- package/dist/resources.js.map +1 -1
- package/dist/sites.d.ts +6 -0
- package/dist/sites.js +28 -4
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +12 -5
- package/dist/source.js +32 -13
- package/dist/source.js.map +1 -1
- package/dist/wakeup-type.d.ts +1 -1
- package/dist/wakeup-type.js +1 -1
- package/dist/wakeup-type.js.map +1 -1
- package/package.json +1 -1
- package/src/apply-steps.ts +5 -4
- package/src/apply.ts +87 -24
- package/src/config.ts +59 -5
- package/src/definitions.ts +5 -0
- package/src/describe.ts +23 -10
- package/src/drift.ts +16 -3
- package/src/host-derive.ts +492 -0
- package/src/host.ts +101 -5
- package/src/index.ts +20 -2
- package/src/kms-secrets.ts +34 -5
- package/src/resource.ts +45 -7
- package/src/resources.ts +1 -1
- package/src/sites.ts +33 -3
- package/src/source.ts +35 -12
- package/src/wakeup-type.ts +1 -1
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { findPackageJSON } from "node:module";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { databaseUrlSecret } from "./database.ts";
|
|
7
|
+
import { statusesOf } from "./env.ts";
|
|
8
|
+
import type { Driver } from "./op.ts";
|
|
9
|
+
import type { DeclaredResource, ResourceProblem } from "./description.ts";
|
|
10
|
+
import { runtimeImage, sourceKeyId } from "./host.ts";
|
|
11
|
+
import type { Bindings } from "./lockfile.ts";
|
|
12
|
+
import { type Resource, valueAt } from "./resource.ts";
|
|
13
|
+
import { secret, type Secret, type Secrets } from "./secret.ts";
|
|
14
|
+
import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
|
|
15
|
+
import { mintInstanceToken, WAKEUP_TOKEN } from "./wakeup-service.ts";
|
|
16
|
+
|
|
17
|
+
// What a host that derives (`HostSpec.derives`) is given beyond its decisions: a data file says
|
|
18
|
+
// where it runs, its name, its hostname and the database it runs on, and apply fills the rest
|
|
19
|
+
// before it plans, as it fills a host's revision. Nothing here is ever a secret's value: the
|
|
20
|
+
// secrets a host is given are markers (`secret("NAME")`), resolved by its driver just before the
|
|
21
|
+
// vendor call, so a declaration, a plan, a ledger entry or a page holds names only.
|
|
22
|
+
|
|
23
|
+
/** The private half of the key a host clones a private GitHub repository with: minted at its first apply, held in the store. */
|
|
24
|
+
export const SOURCE_KEY = "SANOMA_SOURCE_KEY";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the company's GitHub App's keys are stored, by what GitHub's manifest conversion calls
|
|
28
|
+
* them: the app's Connect page writes them, the staff login reads the client's, and a host carries
|
|
29
|
+
* the ones that are stored. The one list of them (a connection of its own is later).
|
|
30
|
+
*/
|
|
31
|
+
export const GITHUB_APP = {
|
|
32
|
+
id: "SANOMA_GITHUB_APP_ID",
|
|
33
|
+
slug: "SANOMA_GITHUB_APP_SLUG",
|
|
34
|
+
client_id: "SANOMA_GITHUB_CLIENT_ID",
|
|
35
|
+
client_secret: "SANOMA_GITHUB_CLIENT_SECRET",
|
|
36
|
+
webhook_secret: "SANOMA_GITHUB_WEBHOOK_SECRET",
|
|
37
|
+
pem: "SANOMA_GITHUB_APP_PEM",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/** The signing key a host is given when the config binds none, generated at its create and kept in the store. */
|
|
41
|
+
export const HOST_SIGNING_KEY = "SANOMA_HOST_SIGNING_KEY";
|
|
42
|
+
|
|
43
|
+
/** The wakeup service's URL, which a host is given beside its token when `sanoma.wakeup` wakes it. */
|
|
44
|
+
const WAKEUP_URL = "SANOMA_WAKEUP_URL";
|
|
45
|
+
|
|
46
|
+
/** The type of the key that lets a host clone its GitHub repository, which apply declares on it. */
|
|
47
|
+
export const DEPLOY_KEY_TYPE = "github.repository_deploy_key";
|
|
48
|
+
|
|
49
|
+
/** The title a host's deploy key has on GitHub. */
|
|
50
|
+
export const DEPLOY_KEY_TITLE = "Sanoma host";
|
|
51
|
+
|
|
52
|
+
/** What the runtime knows that a host derives from, read once per apply or drift run (`hostFacts`). */
|
|
53
|
+
export interface HostFacts {
|
|
54
|
+
/** The `sanoma` version the repository installs: the runtime image's tag. Undefined when it is not installed. */
|
|
55
|
+
version?: string;
|
|
56
|
+
/** The clone URL of the repository the app runs from (`origin`), with no credentials. */
|
|
57
|
+
origin?: string;
|
|
58
|
+
/** Each public site's domain, by site name (`siteDomains`). */
|
|
59
|
+
domains: Readonly<Record<string, string>>;
|
|
60
|
+
/** True when the config keeps its secrets in the built-in store, which `SANOMA_SECRETS_KEY` unlocks. */
|
|
61
|
+
builtInStore: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* The secrets a host carries for each vendor it deploys itself with (its data file's) or signs
|
|
64
|
+
* people in through (the login's sender), by vendor: the variables the vendor's driver reads
|
|
65
|
+
* that the store holds (one only in this machine's environment is not given to the host).
|
|
66
|
+
*/
|
|
67
|
+
carried: ReadonlyMap<string, readonly string[]>;
|
|
68
|
+
/** The login sender's vendor, whose credentials a host carries so it sends sign-in codes. */
|
|
69
|
+
sender?: string;
|
|
70
|
+
/** The company's GitHub App's keys that are stored (`GITHUB_APP`), which a host carries for sign-in and webhooks. */
|
|
71
|
+
githubApp: readonly string[];
|
|
72
|
+
/** The public half of the stored `SANOMA_SOURCE_KEY`, when one is: what apply declares on the repository. */
|
|
73
|
+
sourceKey?: string;
|
|
74
|
+
/**
|
|
75
|
+
* By vendor, the variables its driver reads that are set in this machine's environment but
|
|
76
|
+
* neither bound nor stored: the driver runs here, and a host would not be given them.
|
|
77
|
+
*/
|
|
78
|
+
unbound?: ReadonlyMap<string, readonly string[]>;
|
|
79
|
+
/** True when the config binds `SANOMA_SIGNING_KEY`: the host is given that key, else its own (`HOST_SIGNING_KEY`). */
|
|
80
|
+
signingKeyBound?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* While the config has no deploy-key type, the GitHub repositories a host may clone (`origin`'s,
|
|
83
|
+
* a declared `source`'s) that GitHub shows to no one: private (or not there). One GitHub does
|
|
84
|
+
* not answer for is not among them.
|
|
85
|
+
*/
|
|
86
|
+
privateRepositories?: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const run = promisify(execFile);
|
|
90
|
+
|
|
91
|
+
/** The version of `sanoma` the repository at `root` installs (a hoisted install counts); undefined when none does. */
|
|
92
|
+
export async function installedSanoma(root: string): Promise<string | undefined> {
|
|
93
|
+
try {
|
|
94
|
+
const at = findPackageJSON("sanoma", join(root, "package.json"));
|
|
95
|
+
const version = at && (JSON.parse(await readFile(at, "utf8")) as { version?: unknown }).version;
|
|
96
|
+
return typeof version === "string" ? version : undefined;
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A clone URL as a host takes it: GitHub's SSH forms (`git@github.com:o/n.git`,
|
|
104
|
+
* `ssh://git@github.com/o/n`) as `https://github.com/o/n.git`, and any credentials an https URL
|
|
105
|
+
* carries left out (an `origin` may hold a token). Undefined for anything that is not a URL.
|
|
106
|
+
*/
|
|
107
|
+
export function cloneUrl(url: string): string | undefined {
|
|
108
|
+
const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
109
|
+
if (ssh) return `https://github.com/${ssh[1]}/${ssh[2]}.git`;
|
|
110
|
+
if (!URL.canParse(url)) return undefined;
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
parsed.username = "";
|
|
113
|
+
parsed.password = "";
|
|
114
|
+
if (parsed.hostname === "github.com" && !parsed.pathname.endsWith(".git")) parsed.pathname += ".git";
|
|
115
|
+
return parsed.href;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The URL of `origin` in the git repository at `root`, as a host clones it (`cloneUrl`); undefined when it has none. */
|
|
119
|
+
export async function originUrl(root: string): Promise<string | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], { timeout: 10_000 });
|
|
122
|
+
return cloneUrl(stdout.trim());
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The GitHub repository a clone URL names, `owner/name`; undefined for one of another host. */
|
|
129
|
+
export function githubRepository(url: unknown): string | undefined {
|
|
130
|
+
if (typeof url !== "string") return undefined;
|
|
131
|
+
const at = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(cloneUrl(url) ?? "");
|
|
132
|
+
return at ? `${at[1]}/${at[2]}` : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The public half of the source key in `secrets`; with `mint`, a new pair made first when none is
|
|
137
|
+
* stored (its private half stored, never returned), as `sanoma.secret.generate`'s `ssh-rsa` does.
|
|
138
|
+
* Undefined when none is stored and none is minted, or the stored value is no RSA key.
|
|
139
|
+
*/
|
|
140
|
+
export async function sourceKeyOf(secrets: Secrets, mint: boolean): Promise<string | undefined> {
|
|
141
|
+
let stored = await secrets.get(SOURCE_KEY);
|
|
142
|
+
if (stored === undefined && mint) {
|
|
143
|
+
const pair = await sourceKeyPair();
|
|
144
|
+
stored = await secrets.ensure(SOURCE_KEY, () => pair.privateKey);
|
|
145
|
+
}
|
|
146
|
+
return stored === undefined ? undefined : sshPublicKey(stored);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Mints the instance token a host's wakeup checks it by, once, when none is stored. */
|
|
150
|
+
export const mintWakeupToken = (secrets: Secrets) => secrets.ensure(WAKEUP_TOKEN, mintInstanceToken);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The declared repository a host that leaves out its `source` runs from: of the types its
|
|
154
|
+
* `source` reference names, the one `origin` is, else the one the host's own data file declares.
|
|
155
|
+
*/
|
|
156
|
+
export function sourceRepository(
|
|
157
|
+
host: DeclaredResource,
|
|
158
|
+
type: Resource,
|
|
159
|
+
resources: readonly DeclaredResource[],
|
|
160
|
+
origin: string | undefined,
|
|
161
|
+
): DeclaredResource | undefined {
|
|
162
|
+
const named = [type.fields.references?.[type.host!.inputs.source]?.type ?? []].flat();
|
|
163
|
+
const repositories = resources.filter((o) => named.includes(`${o.vendor}.${o.type}`));
|
|
164
|
+
const at = githubRepository(origin);
|
|
165
|
+
const beside = repositories.filter((o) => o.file === host.file);
|
|
166
|
+
return (
|
|
167
|
+
repositories.find((o) => at !== undefined && o.identity === at) ?? (beside.length === 1 ? beside[0] : undefined)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The declared wakeup that names `host` (its `inputs.host` refers to it), if any. */
|
|
172
|
+
export function wokenBy(
|
|
173
|
+
host: Pick<DeclaredResource, "id">,
|
|
174
|
+
resources: readonly DeclaredResource[],
|
|
175
|
+
types: ReadonlyMap<string, Pick<Resource, "wakeup">>,
|
|
176
|
+
): DeclaredResource | undefined {
|
|
177
|
+
return resources.find((o) => {
|
|
178
|
+
const wakeup = types.get(`${o.vendor}.${o.type}`)?.wakeup;
|
|
179
|
+
return wakeup !== undefined && o.refs[wakeup.inputs.host] === host.id;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Sets `value` at the dotted `path` of `into`, making the objects on the way. */
|
|
184
|
+
function setAt(into: Record<string, unknown>, path: string, value: unknown) {
|
|
185
|
+
const keys = path.split(".");
|
|
186
|
+
let at = into;
|
|
187
|
+
for (const key of keys.slice(0, -1)) at = (at[key] ??= {}) as Record<string, unknown>;
|
|
188
|
+
at[keys.at(-1)!] = value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A declaration's export: `host` of `resources/sanoma/infra.ts#host`. */
|
|
192
|
+
const exportOf = (id: string) => id.slice(id.indexOf("#") + 1);
|
|
193
|
+
|
|
194
|
+
/** A repository's name as a host's (`HostSpec.inputs.name`): lower-case letters, digits and dashes. */
|
|
195
|
+
const nameOf = (repository: string) =>
|
|
196
|
+
repository
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.replaceAll(/[^a-z0-9-]+/g, "-")
|
|
199
|
+
.replaceAll(/^-+|-+$/g, "");
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fills, in place, each declared host that derives (`HostSpec.derives`) with what it is not told,
|
|
203
|
+
* and puts the deploy key it clones with before it in `resources`:
|
|
204
|
+
*
|
|
205
|
+
* - `name`, left out (when its type takes `inputs.name`): the name it is bound by, else its
|
|
206
|
+
* repository's (`nameOf`), unless another host of its type has that name;
|
|
207
|
+
* - `image`, left out: the runtime image of the installed `sanoma` (`runtimeImage`);
|
|
208
|
+
* - `source`, left out: the one repository its own data file declares (a reference to it, of a
|
|
209
|
+
* type its `source` names), else the repository the app runs from (`origin`);
|
|
210
|
+
* - `domains`: each public site's domain (`siteDomains`);
|
|
211
|
+
* - `secrets` (unless it binds its own bootstrap), markers only: the declared `database`'s URL as
|
|
212
|
+
* `SANOMA_DATABASE_URL`, the runtime's keys (the signing key the config binds, else the host's
|
|
213
|
+
* own, generated at its create; the encryption key with the built-in store), the source key for
|
|
214
|
+
* a GitHub repository it can declare a deploy key on, the wakeup's token (and URL, for
|
|
215
|
+
* `sanoma.wakeup`) when a wakeup names it, the credentials of its own vendor, its repository's
|
|
216
|
+
* and the login sender's, and the company's GitHub App's keys that are stored;
|
|
217
|
+
* - for a GitHub repository, a `github.repository_deploy_key` (`sourceKeyId`), read-only, whose
|
|
218
|
+
* key is the stored source key's public half, when one is and the config has the type; found by
|
|
219
|
+
* its type's identity (the repository and the key), so a key GitHub holds already is bound, not
|
|
220
|
+
* made again.
|
|
221
|
+
*
|
|
222
|
+
* `ref` is resolved by apply (and drift) with the revision: the repository's default branch.
|
|
223
|
+
* `out.keep` takes the deploy-key ids not declared this read (no source key stored yet, or its
|
|
224
|
+
* host left out), which the caller must not orphan; `out.leftOut` the hosts with a problem, which
|
|
225
|
+
* the caller leaves out, with what names them, so apply changes nothing it cannot give all it needs.
|
|
226
|
+
* Problems: a host with no `database`, a name (none to tell, or another host's), an image or a
|
|
227
|
+
* source that cannot be told, a credential its vendor's driver reads from this machine's
|
|
228
|
+
* environment that nothing binds, a private repository it has no deploy key for, two hosts that
|
|
229
|
+
* would attach the sites' domains.
|
|
230
|
+
*/
|
|
231
|
+
export function deriveHosts(
|
|
232
|
+
resources: DeclaredResource[],
|
|
233
|
+
types: ReadonlyMap<string, Resource>,
|
|
234
|
+
bindings: Bindings,
|
|
235
|
+
facts: HostFacts,
|
|
236
|
+
out: { keep?: Set<string>; leftOut?: Set<string> } = {},
|
|
237
|
+
): ResourceProblem[] {
|
|
238
|
+
const problems: ResourceProblem[] = [];
|
|
239
|
+
// One host attaches the sites' domains: two would each try to attach them.
|
|
240
|
+
const attaching = resources.filter((r) => {
|
|
241
|
+
const host = types.get(`${r.vendor}.${r.type}`)?.host;
|
|
242
|
+
return host?.derives && host.inputs.domains !== undefined;
|
|
243
|
+
});
|
|
244
|
+
if (attaching.length > 1 && Object.keys(facts.domains).length) {
|
|
245
|
+
for (const r of attaching) {
|
|
246
|
+
out.leftOut?.add(r.id);
|
|
247
|
+
problems.push({
|
|
248
|
+
...r.at,
|
|
249
|
+
message: `${r.at.export}: ${attaching.length} hosts would attach the public sites' domains: declare one host that derives, the production one`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const deployKey = types.get(DEPLOY_KEY_TYPE);
|
|
254
|
+
// A copy: the deploy keys are put in `resources` as it goes.
|
|
255
|
+
for (const r of resources.slice()) {
|
|
256
|
+
const type = types.get(`${r.vendor}.${r.type}`);
|
|
257
|
+
if (!type?.host?.derives) continue;
|
|
258
|
+
const spec = type.host;
|
|
259
|
+
const { inputs } = spec;
|
|
260
|
+
let blocked = out.leftOut?.has(r.id) ?? false;
|
|
261
|
+
const problem = (message: string) => {
|
|
262
|
+
blocked = true;
|
|
263
|
+
problems.push({ ...r.at, message: `${r.at.export}: ${message}` });
|
|
264
|
+
};
|
|
265
|
+
const { desired, refs } = r;
|
|
266
|
+
|
|
267
|
+
if (inputs.image !== undefined && valueAt(desired, inputs.image) == null) {
|
|
268
|
+
if (facts.version) setAt(desired, inputs.image, runtimeImage(facts.version));
|
|
269
|
+
else
|
|
270
|
+
problem(
|
|
271
|
+
"its runtime image is the installed sanoma's, and sanoma is not installed: install it, or declare `image`",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (valueAt(desired, inputs.source) == null) {
|
|
276
|
+
const declared = sourceRepository(r, type, resources, facts.origin);
|
|
277
|
+
if (declared) {
|
|
278
|
+
setAt(desired, inputs.source, declared.name);
|
|
279
|
+
refs[inputs.source] = declared.id;
|
|
280
|
+
} else if (facts.origin) setAt(desired, inputs.source, facts.origin);
|
|
281
|
+
else
|
|
282
|
+
problem(
|
|
283
|
+
"its source is the repository the app runs from, and the repository has no origin: push it, or declare `source`",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
// The GitHub repository it clones, `owner/name`: a declared one's identity, else its URL's.
|
|
287
|
+
const repositoryId = refs[inputs.source];
|
|
288
|
+
const repository = repositoryId
|
|
289
|
+
? resources.find((o) => o.id === repositoryId && o.vendor === "github")?.identity
|
|
290
|
+
: githubRepository(valueAt(desired, inputs.source));
|
|
291
|
+
|
|
292
|
+
if (inputs.name !== undefined && valueAt(desired, inputs.name) == null) {
|
|
293
|
+
const source = valueAt(desired, inputs.source);
|
|
294
|
+
const named = repository ?? (typeof source === "string" ? cloneUrl(source) : undefined);
|
|
295
|
+
// A bound host keeps the name it is bound by: a renamed repository would otherwise replace it.
|
|
296
|
+
const bound = type.identity === inputs.name ? bindings[r.id]?.id : undefined;
|
|
297
|
+
const name =
|
|
298
|
+
bound ??
|
|
299
|
+
(named &&
|
|
300
|
+
nameOf(
|
|
301
|
+
named
|
|
302
|
+
.replace(/\.git$/, "")
|
|
303
|
+
.split("/")
|
|
304
|
+
.at(-1)!,
|
|
305
|
+
));
|
|
306
|
+
if (name) {
|
|
307
|
+
setAt(desired, inputs.name, name);
|
|
308
|
+
const identity = type.find?.(desired);
|
|
309
|
+
const twin =
|
|
310
|
+
identity &&
|
|
311
|
+
resources.find((o) => o !== r && o.vendor === r.vendor && o.type === r.type && o.identity === identity);
|
|
312
|
+
if (twin) problem(`its name, its repository's, is ${twin.at.export}'s too, ${name}: declare \`name\``);
|
|
313
|
+
else if (identity) Object.assign(r, { identity, name: identity });
|
|
314
|
+
} else problem("its name is its repository's, and it has none to name it by: declare `name`");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (inputs.domains !== undefined && Object.keys(facts.domains).length) {
|
|
318
|
+
setAt(desired, inputs.domains, { ...facts.domains });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (spec.bindsBootstrap || inputs.secrets === undefined) {
|
|
322
|
+
if (blocked) out.leftOut?.add(r.id);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// A deploy key is declared for a GitHub repository only where the config has the type.
|
|
326
|
+
const keyed = repository !== undefined && deployKey !== undefined;
|
|
327
|
+
if (repository && !keyed && facts.privateRepositories?.includes(repository)) {
|
|
328
|
+
problem(
|
|
329
|
+
`${repository} is private, and the config has no ${DEPLOY_KEY_TYPE} to give the host a key to clone it with: add the GitHub connector (@sanoma-ai/connector-github) to sanoma.config.ts`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const secrets: Record<string, Secret> = {};
|
|
333
|
+
const database = inputs.database === undefined ? undefined : refs[inputs.database];
|
|
334
|
+
if (database) secrets.SANOMA_DATABASE_URL = secret(databaseUrlSecret(exportOf(database)));
|
|
335
|
+
else if (inputs.database !== undefined)
|
|
336
|
+
problem("declare the database it runs on, `database: store`: the host is given its URL");
|
|
337
|
+
secrets.SANOMA_SIGNING_KEY = facts.signingKeyBound
|
|
338
|
+
? secret("SANOMA_SIGNING_KEY")
|
|
339
|
+
: secret(HOST_SIGNING_KEY, { generate: true });
|
|
340
|
+
if (facts.builtInStore) secrets.SANOMA_SECRETS_KEY = secret("SANOMA_SECRETS_KEY");
|
|
341
|
+
if (keyed) secrets[SOURCE_KEY] = secret(SOURCE_KEY);
|
|
342
|
+
const wakeup = wokenBy(r, resources, types);
|
|
343
|
+
if (wakeup) {
|
|
344
|
+
secrets[WAKEUP_TOKEN] = secret(WAKEUP_TOKEN);
|
|
345
|
+
if (wakeup.vendor === "sanoma") secrets[WAKEUP_URL] = secret(WAKEUP_URL);
|
|
346
|
+
}
|
|
347
|
+
// What it deploys itself with after a push: the vendors of its data file, its repository's, the sender's.
|
|
348
|
+
const vendors = new Set([
|
|
349
|
+
...resources.filter((o) => o.file === r.file).map((o) => o.vendor),
|
|
350
|
+
...(repository ? ["github"] : []),
|
|
351
|
+
...(facts.sender ? [facts.sender] : []),
|
|
352
|
+
]);
|
|
353
|
+
const carried = [...vendors].flatMap((vendor) => facts.carried.get(vendor) ?? []);
|
|
354
|
+
for (const name of [...carried, ...facts.githubApp]) secrets[name] = secret(name);
|
|
355
|
+
// One the driver reads here from the environment alone would be left off the host, and a
|
|
356
|
+
// Worker updated without it would lose it.
|
|
357
|
+
for (const vendor of vendors) {
|
|
358
|
+
for (const name of facts.unbound?.get(vendor) ?? []) {
|
|
359
|
+
problem(
|
|
360
|
+
`${name} is read from the environment here, and has no binding, so the host would not be given it: bind it in sanoma.config.ts, \`secrets: { bindings: { ${name}: { env: "${name}" } } }\`, or store it on the ${vendor} connector page`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
setAt(desired, inputs.secrets, secrets);
|
|
365
|
+
|
|
366
|
+
const id = sourceKeyId(r.id);
|
|
367
|
+
// Kept from orphaning while it cannot be declared: no source key stored yet, or the host left out.
|
|
368
|
+
if (keyed && (!facts.sourceKey || blocked)) out.keep?.add(id);
|
|
369
|
+
if (blocked) out.leftOut?.add(r.id);
|
|
370
|
+
// Not twice: a data file of before may declare the same key itself (GitHub takes a key once).
|
|
371
|
+
const declaredKey = resources.some(
|
|
372
|
+
(o) => `${o.vendor}.${o.type}` === DEPLOY_KEY_TYPE && o.desired.key === facts.sourceKey,
|
|
373
|
+
);
|
|
374
|
+
if (keyed && facts.sourceKey && !blocked && !declaredKey) {
|
|
375
|
+
const binding = bindings[id]?.id;
|
|
376
|
+
const key = { repository, title: DEPLOY_KEY_TITLE, key: facts.sourceKey, read_only: true };
|
|
377
|
+
const identity = deployKey.find?.(key);
|
|
378
|
+
resources.splice(resources.indexOf(r), 0, {
|
|
379
|
+
id,
|
|
380
|
+
file: r.file,
|
|
381
|
+
vendor: "github",
|
|
382
|
+
type: "repository_deploy_key",
|
|
383
|
+
name: exportOf(id),
|
|
384
|
+
...(identity && { identity }),
|
|
385
|
+
...(binding !== undefined && { binding }),
|
|
386
|
+
at: { file: r.at.file, export: exportOf(id) },
|
|
387
|
+
desired: key,
|
|
388
|
+
refs: repositoryId ? { repository: repositoryId } : {},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return problems;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Whether GitHub shows `repository` (`owner/name`) to anyone, asked with no token: false for one
|
|
397
|
+
* that is private (or not there), undefined when GitHub does not say.
|
|
398
|
+
*/
|
|
399
|
+
async function publicOnGithub(repository: string): Promise<boolean | undefined> {
|
|
400
|
+
try {
|
|
401
|
+
const res = await fetch(`https://api.github.com/repos/${repository}`, {
|
|
402
|
+
method: "HEAD",
|
|
403
|
+
headers: { accept: "application/vnd.github+json" },
|
|
404
|
+
signal: AbortSignal.timeout(10_000),
|
|
405
|
+
});
|
|
406
|
+
return res.status === 200 ? true : res.status === 404 ? false : undefined;
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What the declared hosts that derive (`deriveHosts`) are filled from, read now: the installed
|
|
414
|
+
* `sanoma`, `origin`, the public sites' domains, and of `secrets` (none outside a run) which
|
|
415
|
+
* optional credentials, App keys and source key are stored, and which credentials the drivers
|
|
416
|
+
* read from this machine's environment alone (`unbound`). With `mint` (apply, never drift or a
|
|
417
|
+
* sandbox run), the source key (where the config has the deploy-key type) and, when a wakeup is
|
|
418
|
+
* declared, the wakeup's token are minted first when none is stored, so the host is given them
|
|
419
|
+
* from its first apply. With no deploy-key type, GitHub is asked which repositories a host may clone are private.
|
|
420
|
+
* Undefined when no declared host derives.
|
|
421
|
+
*/
|
|
422
|
+
export async function hostFacts(options: {
|
|
423
|
+
root: string;
|
|
424
|
+
resources: readonly DeclaredResource[];
|
|
425
|
+
types: ReadonlyMap<string, Resource>;
|
|
426
|
+
drivers: ReadonlyMap<string, Driver>;
|
|
427
|
+
secrets?: Secrets;
|
|
428
|
+
builtInStore: boolean;
|
|
429
|
+
/** True when the config binds `SANOMA_SIGNING_KEY` (`{ env }` or `{ bootstrap }`). */
|
|
430
|
+
signingKeyBound?: boolean;
|
|
431
|
+
sender?: string;
|
|
432
|
+
mint: boolean;
|
|
433
|
+
}): Promise<HostFacts | undefined> {
|
|
434
|
+
const { root, resources, types, drivers, secrets, mint } = options;
|
|
435
|
+
const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`);
|
|
436
|
+
const deriving = resources.filter((r) => typeOf(r)?.host?.derives);
|
|
437
|
+
if (!deriving.length) return undefined;
|
|
438
|
+
const hosts = deriving.filter((r) => !typeOf(r)?.host?.bindsBootstrap);
|
|
439
|
+
// Which optional credentials and App keys are stored: read at once, and only for a host given secrets.
|
|
440
|
+
const stored = async (names: readonly string[]) =>
|
|
441
|
+
secrets && hosts.length
|
|
442
|
+
? new Set((await Promise.all(names.map(async (n) => ((await secrets.get(n)) === undefined ? [] : [n])))).flat())
|
|
443
|
+
: new Set<string>();
|
|
444
|
+
const statuses = [...drivers].flatMap(([vendor, { env }]) =>
|
|
445
|
+
env ? statusesOf(env, () => undefined).map((c) => ({ vendor, ...c })) : [],
|
|
446
|
+
);
|
|
447
|
+
// Only what the store holds: a marker for a name it does not would fail the host's deploy.
|
|
448
|
+
const held = await stored(statuses.map((c) => c.name));
|
|
449
|
+
const carried = new Map<string, string[]>();
|
|
450
|
+
const unbound = new Map<string, string[]>();
|
|
451
|
+
for (const c of statuses) {
|
|
452
|
+
// The driver reads the environment first: set there alone, it runs here and the host is not given it.
|
|
453
|
+
const into = held.has(c.name) ? carried : secrets && hosts.length && process.env[c.name] ? unbound : undefined;
|
|
454
|
+
into?.set(c.vendor, [...(into.get(c.vendor) ?? []), c.name]);
|
|
455
|
+
}
|
|
456
|
+
const githubApp = [...(await stored(Object.values(GITHUB_APP)))];
|
|
457
|
+
if (mint && secrets && hosts.some((h) => wokenBy(h, resources, types))) await mintWakeupToken(secrets);
|
|
458
|
+
const keyed = types.has(DEPLOY_KEY_TYPE);
|
|
459
|
+
const { siteDomains } = await import("./sites.ts");
|
|
460
|
+
const [version, origin, sourceKey] = await Promise.all([
|
|
461
|
+
installedSanoma(root),
|
|
462
|
+
originUrl(root),
|
|
463
|
+
secrets && hosts.length && keyed ? sourceKeyOf(secrets, mint) : undefined,
|
|
464
|
+
]);
|
|
465
|
+
// What a host may clone: `origin`, else the `source` it declares (with no deploy-key type, no
|
|
466
|
+
// GitHub repository is declared to be one).
|
|
467
|
+
const repositories = keyed
|
|
468
|
+
? []
|
|
469
|
+
: [
|
|
470
|
+
...new Set(
|
|
471
|
+
[
|
|
472
|
+
githubRepository(origin),
|
|
473
|
+
...hosts.map((h) => githubRepository(valueAt(h.desired, typeOf(h)!.host!.inputs.source))),
|
|
474
|
+
].filter((r) => r !== undefined),
|
|
475
|
+
),
|
|
476
|
+
];
|
|
477
|
+
const open = await Promise.all(repositories.map(publicOnGithub));
|
|
478
|
+
const privateRepositories = repositories.filter((_, i) => open[i] === false);
|
|
479
|
+
return {
|
|
480
|
+
...(version !== undefined && { version }),
|
|
481
|
+
...(origin !== undefined && { origin }),
|
|
482
|
+
domains: siteDomains(root),
|
|
483
|
+
builtInStore: options.builtInStore,
|
|
484
|
+
carried,
|
|
485
|
+
...(unbound.size > 0 && { unbound }),
|
|
486
|
+
...(options.signingKeyBound && { signingKeyBound: true }),
|
|
487
|
+
...(options.sender !== undefined && { sender: options.sender }),
|
|
488
|
+
githubApp,
|
|
489
|
+
...(sourceKey !== undefined && { sourceKey }),
|
|
490
|
+
...(privateRepositories.length > 0 && { privateRepositories }),
|
|
491
|
+
};
|
|
492
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -47,6 +47,15 @@ export const wakeUrlOf = (outputs: HostOutputs): string => outputs.wakeUrl ?? ou
|
|
|
47
47
|
* A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
|
|
48
48
|
* declared fields, and how its state reads as `HostOutputs`. A host is drifted when
|
|
49
49
|
* `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
|
|
50
|
+
*
|
|
51
|
+
* A host that `derives` (`cloudflare.host`, `cloud.host`) is declared with the decisions only: its
|
|
52
|
+
* name, its `hostname` and the `database` it runs on. Apply fills the rest before it plans
|
|
53
|
+
* (`deriveHosts`): `image` from the installed `sanoma`, `source` and `ref` from the repository the
|
|
54
|
+
* app runs from and its default branch (each declarable as an override), `secrets` (markers only,
|
|
55
|
+
* never a value) and `domains` from the decisions, the config and the sites, which a data file may
|
|
56
|
+
* not declare; the port is the image's (`RUNTIME_PORT`). One that does not derive is declared
|
|
57
|
+
* whole, its `secrets` and `port` named in its data file (`docker.container`, the deprecated
|
|
58
|
+
* `cloudflare.container`).
|
|
50
59
|
*/
|
|
51
60
|
export interface HostSpec {
|
|
52
61
|
capabilities: HostCapabilities;
|
|
@@ -57,12 +66,16 @@ export interface HostSpec {
|
|
|
57
66
|
ref: string;
|
|
58
67
|
/**
|
|
59
68
|
* The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only
|
|
60
|
-
* field. Absent for a host that binds its own bootstrap (`bindsBootstrap`).
|
|
69
|
+
* field. Absent for a host that binds its own bootstrap (`bindsBootstrap`). A host that derives
|
|
70
|
+
* has it filled by apply, never declared.
|
|
61
71
|
*/
|
|
62
72
|
secrets?: string;
|
|
63
|
-
/** The port the app listens on
|
|
64
|
-
port
|
|
65
|
-
/**
|
|
73
|
+
/** The port the app listens on, for a host that does not derive: one that does runs the image's, `RUNTIME_PORT`. */
|
|
74
|
+
port?: string;
|
|
75
|
+
/**
|
|
76
|
+
* A public site's domain, by site name: the app serves each site at the root of its domain
|
|
77
|
+
* (`SANOMA_DOMAINS`). A host that derives has it filled from the sites' `domain`, never declared.
|
|
78
|
+
*/
|
|
66
79
|
domains?: string;
|
|
67
80
|
/**
|
|
68
81
|
* The hostname the app is served at. A host with one reports `url` as `https://<hostname>` (its
|
|
@@ -70,6 +83,22 @@ export interface HostSpec {
|
|
|
70
83
|
* as `wakeUrl`, where its wakeup reaches it. It is never one of `domains`' values.
|
|
71
84
|
*/
|
|
72
85
|
hostname?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The runtime image, for a host that derives: the installed `sanoma`'s (`runtimeImage`) when a
|
|
88
|
+
* data file leaves it out, an override otherwise.
|
|
89
|
+
*/
|
|
90
|
+
image?: string;
|
|
91
|
+
/**
|
|
92
|
+
* A reference to the declared database the host runs the company on (`database: store`), for a
|
|
93
|
+
* host that derives: apply gives the host that database's URL, as `SANOMA_DATABASE_URL`.
|
|
94
|
+
*/
|
|
95
|
+
database?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The host's name, for a host that derives: the repository's name (`owner/<name>`, made a
|
|
98
|
+
* name of lower-case letters, digits and dashes) when a data file leaves it out. Its type's
|
|
99
|
+
* `find` gives `UNNAMED` until apply fills it.
|
|
100
|
+
*/
|
|
101
|
+
name?: string;
|
|
73
102
|
};
|
|
74
103
|
/**
|
|
75
104
|
* The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
|
|
@@ -81,9 +110,26 @@ export interface HostSpec {
|
|
|
81
110
|
* tokens) and binds it itself, as Sanoma Cloud's does: such a type takes no `secrets`.
|
|
82
111
|
*/
|
|
83
112
|
bindsBootstrap?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* True for a host whose declaration says only the decisions (see above): apply derives its
|
|
115
|
+
* image, source, ref, secrets and domains, and a data file may not declare `secrets` or `domains`.
|
|
116
|
+
*/
|
|
117
|
+
derives?: boolean;
|
|
84
118
|
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
85
119
|
}
|
|
86
120
|
|
|
121
|
+
/** The runtime image a host runs, tagged with the installed `sanoma`'s version: GitHub's registry. */
|
|
122
|
+
export const RUNTIME_IMAGE = "ghcr.io/sanoma-ai/runtime";
|
|
123
|
+
|
|
124
|
+
/** The runtime image of `sanoma` at `version`: `ghcr.io/sanoma-ai/runtime:<version>`. */
|
|
125
|
+
export const runtimeImage = (version: string): string => `${RUNTIME_IMAGE}:${version}`;
|
|
126
|
+
|
|
127
|
+
/** A deriving host's identity before apply names it after its repository (`HostSpec.inputs.name`): never sent to a vendor. */
|
|
128
|
+
export const UNNAMED = "(the repository's name)";
|
|
129
|
+
|
|
130
|
+
/** The port the runtime image's app listens on (its `PORT`): a host that derives runs it there. */
|
|
131
|
+
export const RUNTIME_PORT = 3000;
|
|
132
|
+
|
|
87
133
|
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
|
|
88
134
|
|
|
89
135
|
/** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
|
|
@@ -108,7 +154,9 @@ function unwrap(schema: z.ZodType): z.ZodType {
|
|
|
108
154
|
* Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
|
|
109
155
|
* `secrets` that is not write-only, missing (unless the host binds its own bootstrap) or given
|
|
110
156
|
* beside `bindsBootstrap`, a `revision` that is write-only, an unknown lifetime, a type apply
|
|
111
|
-
* cannot change (not `writable`)
|
|
157
|
+
* cannot change (not `writable`); for a host that does not derive, no `port`, or an `image`, a
|
|
158
|
+
* `database` or a `name` (inputs of a host that derives); for one that does, a `port` (it runs the image's),
|
|
159
|
+
* or a `database` that is not write-only (it stands for a URL, and is never compared).
|
|
112
160
|
*/
|
|
113
161
|
export function checkHost(
|
|
114
162
|
what: string,
|
|
@@ -131,7 +179,55 @@ export function checkHost(
|
|
|
131
179
|
} else if (secrets === undefined || !type.fields.writeOnly.includes(secrets)) {
|
|
132
180
|
problems.push("inputs.secrets must be a write-only field, unless the host binds its own bootstrap");
|
|
133
181
|
}
|
|
182
|
+
if (host.derives) {
|
|
183
|
+
if (host.inputs?.port !== undefined)
|
|
184
|
+
problems.push("inputs.port is not taken by a host that derives: it runs the image's");
|
|
185
|
+
const database = host.inputs?.database;
|
|
186
|
+
if (database !== undefined && !type.fields.writeOnly.includes(database)) {
|
|
187
|
+
problems.push("inputs.database must be a write-only field: it stands for the database's URL, never compared");
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
if (host.inputs?.port === undefined) problems.push("inputs.port names the port, unless the host derives");
|
|
191
|
+
for (const input of ["image", "database", "name"] as const) {
|
|
192
|
+
if (host.inputs?.[input] !== undefined) problems.push(`inputs.${input} is taken by a host that derives only`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
134
195
|
if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
|
|
135
196
|
if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
136
197
|
if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
|
|
137
198
|
}
|
|
199
|
+
|
|
200
|
+
/** The id apply gives the deploy key it declares for the host `hostId`: `resources/sanoma/infra.ts#host.sourceKey`. */
|
|
201
|
+
export const sourceKeyId = (hostId: string) => `${hostId}.sourceKey`;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The lockfile keys that are a deriving host's deploy key (`sourceKeyId`) among `resources`: never
|
|
205
|
+
* an orphan, whether or not apply declares the key this read (before its source key is stored, or
|
|
206
|
+
* where the store does not hold it), so apply never deletes the key a host clones with.
|
|
207
|
+
*/
|
|
208
|
+
export function sourceKeyIds(
|
|
209
|
+
resources: readonly { id: string; vendor: string; type: string }[],
|
|
210
|
+
types: ReadonlyMap<string, { host?: HostSpec }>,
|
|
211
|
+
): Set<string> {
|
|
212
|
+
return new Set(
|
|
213
|
+
resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host?.derives).map((r) => sourceKeyId(r.id)),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* For a host that derives its domains (`inputs.domains`) from the sites: the domains its state
|
|
219
|
+
* says are attached, `<domain> (<site>)`, when no site declares one, which a change would detach
|
|
220
|
+
* (a data file of before declared them on the host). Empty otherwise: once a site declares a
|
|
221
|
+
* domain, the declared ones are attached and the dropped ones detached.
|
|
222
|
+
*/
|
|
223
|
+
export function detaching(host: HostSpec, desired: Record<string, unknown>, state: Record<string, unknown>): string[] {
|
|
224
|
+
const path = host.inputs.domains;
|
|
225
|
+
if (!host.derives || path === undefined) return [];
|
|
226
|
+
const at = (value: Record<string, unknown>) =>
|
|
227
|
+
path.split(".").reduce<unknown>((o, key) => (o as Record<string, unknown> | null | undefined)?.[key], value);
|
|
228
|
+
const declared = at(desired);
|
|
229
|
+
if (declared && typeof declared === "object" && Object.keys(declared).length) return [];
|
|
230
|
+
const attached = at(state);
|
|
231
|
+
if (!attached || typeof attached !== "object") return [];
|
|
232
|
+
return Object.entries(attached as Record<string, unknown>).map(([site, domain]) => `${String(domain)} (${site})`);
|
|
233
|
+
}
|