@sanoma-ai/workflows 0.11.2 → 0.11.4

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.
Files changed (85) hide show
  1. package/README.md +32 -30
  2. package/dist/apply-steps.d.ts +4 -2
  3. package/dist/apply-steps.js +5 -3
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +6 -1
  6. package/dist/apply.js +55 -20
  7. package/dist/apply.js.map +1 -1
  8. package/dist/bundled.d.ts +29 -0
  9. package/dist/bundled.js +16 -0
  10. package/dist/bundled.js.map +1 -0
  11. package/dist/cedar.d.ts +4 -1
  12. package/dist/cedar.js +8 -1
  13. package/dist/cedar.js.map +1 -1
  14. package/dist/config.d.ts +12 -1
  15. package/dist/config.js +75 -9
  16. package/dist/config.js.map +1 -1
  17. package/dist/constructors.d.ts +1 -1
  18. package/dist/db.d.ts +7 -0
  19. package/dist/db.js +30 -1
  20. package/dist/db.js.map +1 -1
  21. package/dist/define.d.ts +2 -1
  22. package/dist/define.js +4 -2
  23. package/dist/define.js.map +1 -1
  24. package/dist/definitions.d.ts +5 -0
  25. package/dist/definitions.js.map +1 -1
  26. package/dist/describe.d.ts +1 -1
  27. package/dist/describe.js +16 -11
  28. package/dist/describe.js.map +1 -1
  29. package/dist/drift.d.ts +7 -0
  30. package/dist/drift.js +15 -3
  31. package/dist/drift.js.map +1 -1
  32. package/dist/host-derive.d.ts +145 -0
  33. package/dist/host-derive.js +403 -0
  34. package/dist/host-derive.js.map +1 -0
  35. package/dist/host.d.ts +70 -5
  36. package/dist/host.js +56 -1
  37. package/dist/host.js.map +1 -1
  38. package/dist/index.d.ts +9 -5
  39. package/dist/index.js +11 -5
  40. package/dist/index.js.map +1 -1
  41. package/dist/load.js +3 -2
  42. package/dist/load.js.map +1 -1
  43. package/dist/resource.d.ts +8 -1
  44. package/dist/resource.js +11 -0
  45. package/dist/resource.js.map +1 -1
  46. package/dist/resources.d.ts +1 -1
  47. package/dist/resources.js +6 -2
  48. package/dist/resources.js.map +1 -1
  49. package/dist/schema.d.ts +12 -2
  50. package/dist/schema.js +12 -6
  51. package/dist/schema.js.map +1 -1
  52. package/dist/shared.d.ts +25 -0
  53. package/dist/shared.js +7 -0
  54. package/dist/shared.js.map +1 -1
  55. package/dist/sites.d.ts +6 -0
  56. package/dist/sites.js +28 -4
  57. package/dist/sites.js.map +1 -1
  58. package/dist/source.d.ts +12 -5
  59. package/dist/source.js +32 -13
  60. package/dist/source.js.map +1 -1
  61. package/dist/wakeup-type.d.ts +1 -1
  62. package/dist/wakeup-type.js +1 -1
  63. package/dist/wakeup-type.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/apply-steps.ts +5 -4
  66. package/src/apply.ts +87 -24
  67. package/src/bundled.ts +42 -0
  68. package/src/cedar.ts +7 -1
  69. package/src/config.ts +81 -10
  70. package/src/db.ts +35 -1
  71. package/src/define.ts +4 -2
  72. package/src/definitions.ts +5 -0
  73. package/src/describe.ts +23 -10
  74. package/src/drift.ts +16 -3
  75. package/src/host-derive.ts +492 -0
  76. package/src/host.ts +101 -5
  77. package/src/index.ts +26 -2
  78. package/src/load.ts +5 -2
  79. package/src/resource.ts +33 -2
  80. package/src/resources.ts +5 -2
  81. package/src/schema.ts +17 -4
  82. package/src/shared.ts +20 -0
  83. package/src/sites.ts +33 -3
  84. package/src/source.ts +35 -12
  85. package/src/wakeup-type.ts +1 -1
package/src/describe.ts CHANGED
@@ -41,7 +41,8 @@ import {
41
41
  problemLine,
42
42
  siteAtHostname,
43
43
  } from "./shared.ts";
44
- import { readSites, type SiteEntry } from "./sites.ts";
44
+ import { readSites, type SiteEntry, siteDomains } from "./sites.ts";
45
+ import { sourceKeyIds } from "./host.ts";
45
46
 
46
47
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
47
48
  // What `describeConfig` answers, as zod schemas and the types they infer (`./description`).
@@ -85,7 +86,7 @@ export {
85
86
  // The apps under `apps/`, with what their `app.ts` declares.
86
87
  export { crossAppImports, readApps } from "./apps.ts";
87
88
  // What the sites call, read from their source: a workflow's Used by.
88
- export { readSites, siteContentPages, siteIcons, siteLocales, sitesUnder } from "./sites.ts";
89
+ export { readSites, siteContentPages, siteDomains, siteIcons, siteLocales, sitesUnder } from "./sites.ts";
89
90
  // The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
90
91
  export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
91
92
  // The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
@@ -193,6 +194,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
193
194
  .toSorted((a, b) => a.id.localeCompare(b.id));
194
195
 
195
196
  const dataFiles = loadDataFiles(resolved.root, connectorsOf(config), resources);
197
+ const sourceKeys = sourceKeyIds(dataFiles.resources, resources);
196
198
  const declared = new Map(dataFiles.collections.map((d) => [d.name, d]));
197
199
  const collections: CollectionEntry[] = [...resolved.collections.values()]
198
200
  .map((c) => ({
@@ -289,12 +291,18 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
289
291
  collections,
290
292
  // An app's people are refused (`readPeople`), so the grid and the People page never show them.
291
293
  resources: dataFiles.resources.filter((r) => !inAppPeople(r)),
292
- orphans: dataFiles.orphans,
294
+ // A host's deploy key, which apply declares, is no orphan.
295
+ orphans: dataFiles.orphans.filter((o) => !sourceKeys.has(o.key)),
293
296
  problems: [
294
297
  ...dataFiles.problems,
295
298
  ...appProblems,
296
299
  ...appClashes(apps, collections, sites),
297
- ...domainProblems(dataFiles.resources, resources, sites),
300
+ ...domainProblems(
301
+ dataFiles.resources,
302
+ resources,
303
+ sites,
304
+ resolved.root === undefined ? {} : siteDomains(resolved.root),
305
+ ),
298
306
  ...loginProblems(resolved, dataFiles.resources),
299
307
  ],
300
308
  apps,
@@ -361,15 +369,17 @@ function appClashes(
361
369
  }
362
370
 
363
371
  /**
364
- * Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
365
- * site, each domain it gives more than one site, and a domain that is the host's `hostname` too,
366
- * or another host's hostname or domain, at the host's declaration: the app serves only a public
367
- * site at a domain, one site at each, and itself at another, and one host holds each.
372
+ * Each site a host's `domains` (its type's `host.inputs.domains`; for a host that derives, the
373
+ * public sites' own, `declaredDomains`) names that is not a public site, each domain it gives more
374
+ * than one site, and a domain that is the host's `hostname` too, or another host's hostname or
375
+ * domain, at the host's declaration: the app serves only a public site at a domain, one site at
376
+ * each, and itself at another, and one host holds each.
368
377
  */
369
378
  function domainProblems(
370
379
  declared: readonly DeclaredResource[],
371
380
  types: ReadonlyMap<string, Resource>,
372
381
  sites: readonly SiteEntry[],
382
+ declaredDomains: Readonly<Record<string, string>>,
373
383
  ): ResourceProblem[] {
374
384
  const isPublic = new Set(sites.filter((site) => site.public).map((site) => site.name));
375
385
  const problems: ResourceProblem[] = [];
@@ -379,9 +389,12 @@ function domainProblems(
379
389
  const hold = (name: string, resource: DeclaredResource) =>
380
390
  held.set(name.toLowerCase(), (held.get(name.toLowerCase()) ?? new Set()).add(resource));
381
391
  for (const resource of declared) {
382
- const inputs = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs;
392
+ const host = types.get(`${resource.vendor}.${resource.type}`)?.host;
393
+ const inputs = host?.inputs;
383
394
  const path = inputs?.domains;
384
- const domains = (path === undefined ? {} : (valueAt(resource.desired, path) ?? {})) as Record<string, unknown>;
395
+ const domains = (
396
+ path === undefined ? {} : host?.derives ? declaredDomains : (valueAt(resource.desired, path) ?? {})
397
+ ) as Record<string, unknown>;
385
398
  const hostname = inputs?.hostname === undefined ? undefined : valueAt(resource.desired, inputs.hostname);
386
399
  const messages = Object.keys(domains)
387
400
  .filter((name) => !isPublic.has(name))
package/src/drift.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
3
  import { isRunControlError } from "./errors.ts";
4
+ import type { HostSpec } from "./host.ts";
4
5
  import type { Op } from "./op.ts";
5
6
  import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt, withFrom } from "./resource.ts";
6
7
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
@@ -240,10 +241,11 @@ export function driftWorkflow(
240
241
  const resource = types.get(`${vendor}.${type}`);
241
242
  const host = resource?.host;
242
243
  const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
243
- const ref = host && valueAt(desired, host.inputs.ref);
244
- if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
244
+ const ref = host && refOf(desired, host);
245
+ if (host && "state" in found.get(id)! && source !== undefined && ref !== undefined) {
245
246
  try {
246
- revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
247
+ const { revision } = await ctx.sanoma!.source!.resolve({ url: source, ...(ref ? { ref } : {}) });
248
+ revisions.set(id, { revision });
247
249
  } catch (err) {
248
250
  if (isRunControlError(err)) throw err;
249
251
  revisions.set(id, { error: errorMessage(err) });
@@ -256,6 +258,17 @@ export function driftWorkflow(
256
258
  });
257
259
  }
258
260
 
261
+ /**
262
+ * The ref a host runs: the one its data file gives, or `""` for a host that derives and names
263
+ * none (its repository's default branch, which `sanoma.source.resolve` tells); undefined when it
264
+ * has none to resolve.
265
+ */
266
+ export function refOf(desired: State, host: HostSpec): string | undefined {
267
+ const ref = valueAt(desired, host.inputs.ref);
268
+ if (typeof ref === "string" && ref) return ref;
269
+ return host.derives && ref == null ? "" : undefined;
270
+ }
271
+
259
272
  /**
260
273
  * A host's source: the clone URL its data file gives, or, when it names a declared repository
261
274
  * (`target`), the first of the reference's `by` fields that holds one in that repository's state,
@@ -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
+ }