@wtfalch/foundry 0.63.0 → 0.64.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/dist/apps/index.d.ts +1 -0
- package/dist/apps/index.js +1 -0
- package/dist/apps/server-entry.d.ts +43 -0
- package/dist/apps/server-entry.js +99 -0
- package/dist/build-json.d.ts +25 -0
- package/dist/build-json.js +61 -0
- package/dist/catalogue/copy-en.js +10 -0
- package/dist/catalogue/integrations.js +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/modules/customer-grants.d.ts +3 -0
- package/dist/modules/customer-grants.js +28 -0
- package/dist/modules/index.js +49 -1
- package/dist/modules/repository.d.ts +6 -0
- package/dist/modules/repository.js +40 -0
- package/dist/modules/types.d.ts +8 -0
- package/dist/org.d.ts +19 -1
- package/dist/org.js +45 -3
- package/dist/plan/diff.js +17 -2
- package/dist/plan/executor.js +65 -32
- package/dist/plan/platform-diff.d.ts +17 -1
- package/dist/plan/platform-diff.js +22 -0
- package/package.json +1 -1
package/dist/apps/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { type ApplyWorkerAppMigrationsDeps, type ApplyWorkerAppMigrationsInput,
|
|
|
7
7
|
export { type ReleaseAsset, ReleaseAssetError, fetchReleaseAsset, parseAppRepo, releaseAssetOf, } from './github.js';
|
|
8
8
|
export { type WorkerAppMigrationRunner, migrationsUrl } from '../worker-app-migrations.js';
|
|
9
9
|
export { APPS_CLOUDFLARE_TOKEN_PERMISSIONS, type AppsCloudflare, type HyperdriveConnection, } from './cloudflare.js';
|
|
10
|
+
export { type ServerEntryContext, ServerEntryError, processUrlEnvName, serverEntriesOf, } from './server-entry.js';
|
package/dist/apps/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { RELEASE_MIGRATION_SOURCE, applyWorkerAppMigrations, migrationChecksum,
|
|
|
7
7
|
export { ReleaseAssetError, fetchReleaseAsset, parseAppRepo, releaseAssetOf, } from './github.js';
|
|
8
8
|
export { migrationsUrl } from '../worker-app-migrations.js';
|
|
9
9
|
export { APPS_CLOUDFLARE_TOKEN_PERMISSIONS, } from './cloudflare.js';
|
|
10
|
+
export { ServerEntryError, processUrlEnvName, serverEntriesOf, } from './server-entry.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ServerBuildJson } from '../build-json.js';
|
|
2
|
+
import { type AppManifest, type AppModules } from '../modules/index.js';
|
|
3
|
+
import { type EnvValue, type HostingEntry } from '../org.js';
|
|
4
|
+
/** Thrown by {@link serverEntriesOf}. The message is plain words for the company and never carries a secret. */
|
|
5
|
+
export declare class ServerEntryError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/** What {@link serverEntriesOf} needs to know about the company and where the app runs. */
|
|
9
|
+
export interface ServerEntryContext {
|
|
10
|
+
/** The company's slug: an entry reaches another on the company network as `<slug>-<entry key>` (`<slug>` for `app`). */
|
|
11
|
+
slug: string;
|
|
12
|
+
/** The platform registry host (`registry.<platform domain>`), with a port when it has one. */
|
|
13
|
+
registryHost: string;
|
|
14
|
+
/** The app's repository, `<github owner>/<repo>`. The images must sit at `<registryHost>/<owner>/<repo>[-<process>]`, lowercased. */
|
|
15
|
+
repo: string;
|
|
16
|
+
/** The entry key of the app. Default `app`. A process entry is keyed `<appKey>-<process key>`. */
|
|
17
|
+
appKey?: string;
|
|
18
|
+
/** The names the app entry is served at (see `HostingEntry.hostnames`). Processes are internal. */
|
|
19
|
+
hostnames: string[];
|
|
20
|
+
/** The port the app listens on. */
|
|
21
|
+
port: number;
|
|
22
|
+
healthPath?: string | null;
|
|
23
|
+
/** The app copy's own public origin (`https://host`), which each process gets as `APP_URL` to call the app back. */
|
|
24
|
+
appUrl: string;
|
|
25
|
+
/** The manifest `values` as env, resolved by the caller. Every entry gets the same map. */
|
|
26
|
+
env?: Record<string, EnvValue>;
|
|
27
|
+
/** The modules with the company's answers applied (`checkManifestAnswers`). Default: the manifest's own. */
|
|
28
|
+
modules?: AppModules;
|
|
29
|
+
}
|
|
30
|
+
/** `PROCESS_<KEY>_URL`: the key uppercased, `-` as `_`. */
|
|
31
|
+
export declare const processUrlEnvName: (key: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* The hosting entries of one server app: the `app` entry and one internal entry per declared process, each
|
|
34
|
+
* running its image from `build`. Throws {@link ServerEntryError} in plain words when the build does not match
|
|
35
|
+
* the manifest, when an image is not at the app's own registry path (a company never points at another's
|
|
36
|
+
* image), or when a module refuses the entry.
|
|
37
|
+
*
|
|
38
|
+
* - The app entry is served at `ctx.hostnames`, gets the modules, `ctx.env`, and `PROCESS_<KEY>_URL` for each
|
|
39
|
+
* process that has a port (`http://<slug>-<entry key>:<port>`).
|
|
40
|
+
* - A process entry has no hostnames. It gets the same modules and `ctx.env`, and `APP_URL`. Its `database`
|
|
41
|
+
* stays false: foundry allows one database entry per company, so a process cannot hold a second one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function serverEntriesOf(manifest: AppManifest, build: ServerBuildJson, ctx: ServerEntryContext): HostingEntry[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { checkServerBuild } from '../build-json.js';
|
|
2
|
+
import { applyModules } from '../modules/index.js';
|
|
3
|
+
import { APP_ENTRY_KEY, ENTRY_IMAGE_REPOSITORY } from '../org.js';
|
|
4
|
+
/** Thrown by {@link serverEntriesOf}. The message is plain words for the company and never carries a secret. */
|
|
5
|
+
export class ServerEntryError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ServerEntryError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/;
|
|
12
|
+
const SLUG = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
13
|
+
const OWNER_REPO = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
|
|
14
|
+
const ENTRY_KEY_MAX = 63;
|
|
15
|
+
/** `PROCESS_<KEY>_URL`: the key uppercased, `-` as `_`. */
|
|
16
|
+
export const processUrlEnvName = (key) => `PROCESS_${key.toUpperCase().replace(/-/g, '_')}_URL`;
|
|
17
|
+
const refuse = (message) => {
|
|
18
|
+
throw new ServerEntryError(message);
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The hosting entries of one server app: the `app` entry and one internal entry per declared process, each
|
|
22
|
+
* running its image from `build`. Throws {@link ServerEntryError} in plain words when the build does not match
|
|
23
|
+
* the manifest, when an image is not at the app's own registry path (a company never points at another's
|
|
24
|
+
* image), or when a module refuses the entry.
|
|
25
|
+
*
|
|
26
|
+
* - The app entry is served at `ctx.hostnames`, gets the modules, `ctx.env`, and `PROCESS_<KEY>_URL` for each
|
|
27
|
+
* process that has a port (`http://<slug>-<entry key>:<port>`).
|
|
28
|
+
* - A process entry has no hostnames. It gets the same modules and `ctx.env`, and `APP_URL`. Its `database`
|
|
29
|
+
* stays false: foundry allows one database entry per company, so a process cannot hold a second one.
|
|
30
|
+
*/
|
|
31
|
+
export function serverEntriesOf(manifest, build, ctx) {
|
|
32
|
+
if (manifest.runtime !== 'server')
|
|
33
|
+
refuse('This app does not run on a server.');
|
|
34
|
+
const processes = manifest.processes ?? [];
|
|
35
|
+
const problem = checkServerBuild(build, processes);
|
|
36
|
+
if (problem)
|
|
37
|
+
refuse(`The build does not match the app: ${problem}`);
|
|
38
|
+
const appKey = ctx.appKey ?? APP_ENTRY_KEY;
|
|
39
|
+
if (!SLUG.test(ctx.slug) || !HOST.test(ctx.registryHost) || !OWNER_REPO.test(ctx.repo)) {
|
|
40
|
+
refuse('The company name, registry or repository for this app is not written the way it must be.');
|
|
41
|
+
}
|
|
42
|
+
if (!/^https:\/\/[^/\s]+$/.test(ctx.appUrl)) {
|
|
43
|
+
refuse('The app address must be an https address with no path.');
|
|
44
|
+
}
|
|
45
|
+
const [owner = '', name = ''] = ctx.repo.split('/');
|
|
46
|
+
const base = `${ctx.registryHost}/${owner.toLowerCase()}/${name.toLowerCase()}`;
|
|
47
|
+
const imageFor = (processKey, suffix) => {
|
|
48
|
+
const image = build.images.find((i) => i.process === processKey);
|
|
49
|
+
if (!image)
|
|
50
|
+
return refuse(`The build has no image for "${processKey}".`);
|
|
51
|
+
// Exactly the app's own path: another owner's, another repo's or another host's image is never run.
|
|
52
|
+
if (image.repository !== `${base}${suffix}` || !ENTRY_IMAGE_REPOSITORY.test(image.repository)) {
|
|
53
|
+
return refuse(`The image for "${processKey}" is not at this app's own place in the registry, so it is not used.`);
|
|
54
|
+
}
|
|
55
|
+
return { repository: image.repository, digest: image.digest };
|
|
56
|
+
};
|
|
57
|
+
const internalName = (entryKey) => entryKey === APP_ENTRY_KEY ? ctx.slug : `${ctx.slug}-${entryKey}`;
|
|
58
|
+
const modules = ctx.modules ?? manifest.modules;
|
|
59
|
+
const make = (key, image, port, hostnames, env, healthPath) => {
|
|
60
|
+
if (key.length > ENTRY_KEY_MAX)
|
|
61
|
+
refuse(`The name "${key}" is too long.`);
|
|
62
|
+
const base = {
|
|
63
|
+
key,
|
|
64
|
+
// The app's source, as metadata: the image is what runs, and `uses.repo` makes its deploy key from this.
|
|
65
|
+
repo: ctx.repo,
|
|
66
|
+
folder: null,
|
|
67
|
+
branch: 'main',
|
|
68
|
+
buildPack: 'nixpacks',
|
|
69
|
+
port,
|
|
70
|
+
healthPath,
|
|
71
|
+
hostnames,
|
|
72
|
+
database: false,
|
|
73
|
+
autoDeploy: false,
|
|
74
|
+
gates: [],
|
|
75
|
+
env: { ...(ctx.env ?? {}), ...env },
|
|
76
|
+
...(image ? { image } : {}),
|
|
77
|
+
};
|
|
78
|
+
return base;
|
|
79
|
+
};
|
|
80
|
+
const processEntryKey = (processKey) => `${appKey}-${processKey}`;
|
|
81
|
+
const processEnv = {};
|
|
82
|
+
for (const p of processes) {
|
|
83
|
+
if (p.port !== undefined) {
|
|
84
|
+
processEnv[processUrlEnvName(p.key)] =
|
|
85
|
+
`http://${internalName(processEntryKey(p.key))}:${p.port}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const draft = [
|
|
89
|
+
make(appKey, imageFor('app', ''), ctx.port, ctx.hostnames, processEnv, ctx.healthPath ?? null),
|
|
90
|
+
...processes.map((p) => make(processEntryKey(p.key), imageFor(p.key, `-${p.key}`), p.port ?? null, [], { APP_URL: ctx.appUrl }, null)),
|
|
91
|
+
];
|
|
92
|
+
return draft.map((entry, index) => {
|
|
93
|
+
const applied = applyModules(entry, modules, { runtime: 'server' });
|
|
94
|
+
if (!applied.ok)
|
|
95
|
+
return refuse(applied.message);
|
|
96
|
+
// One database entry per company: a process reaches the app's, never a second one.
|
|
97
|
+
return index === 0 ? applied.entry : { ...applied.entry, database: false };
|
|
98
|
+
});
|
|
99
|
+
}
|
package/dist/build-json.d.ts
CHANGED
|
@@ -26,3 +26,28 @@ export declare class BuildJsonError extends Error {
|
|
|
26
26
|
export declare const buildJsonSchema: z.ZodType<BuildJson>;
|
|
27
27
|
/** Parses a build.json. Throws {@link BuildJsonError} for the first problem. */
|
|
28
28
|
export declare function parseBuildJson(input: unknown): BuildJson;
|
|
29
|
+
/** One image of a server app's build: `process` is `app` or the key of a declared helper process. */
|
|
30
|
+
export interface ServerBuildImage {
|
|
31
|
+
process: string;
|
|
32
|
+
repository: string;
|
|
33
|
+
digest: `sha256:${string}`;
|
|
34
|
+
}
|
|
35
|
+
/** The `build.json` of a server app: the images its GitHub built, chosen by `runtime: 'server'`. */
|
|
36
|
+
export interface ServerBuildJson {
|
|
37
|
+
runtime: 'server';
|
|
38
|
+
repo: string;
|
|
39
|
+
commit: string;
|
|
40
|
+
images: ServerBuildImage[];
|
|
41
|
+
}
|
|
42
|
+
export declare const serverBuildJsonSchema: z.ZodType<ServerBuildJson>;
|
|
43
|
+
/** Parses a server build.json. Throws {@link BuildJsonError} for the first problem; never carries a value. */
|
|
44
|
+
export declare function parseServerBuildJson(input: unknown): ServerBuildJson;
|
|
45
|
+
/** Parses either shape: `runtime: 'server'` picks the server one, anything else the Worker one. */
|
|
46
|
+
export declare function parseAnyBuildJson(input: unknown): BuildJson | ServerBuildJson;
|
|
47
|
+
/**
|
|
48
|
+
* Null when the build holds exactly one `app` image and one image per declared process, no more.
|
|
49
|
+
* Else a plain message naming the process key, never an image value.
|
|
50
|
+
*/
|
|
51
|
+
export declare function checkServerBuild(build: ServerBuildJson, processes: readonly {
|
|
52
|
+
key: string;
|
|
53
|
+
}[]): string | null;
|
package/dist/build-json.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { ENTRY_IMAGE_REPOSITORY } from './org.js';
|
|
2
3
|
import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
|
|
3
4
|
/** Thrown by {@link parseBuildJson}. `path` is dotted into the build.json; the message never carries a value. */
|
|
4
5
|
export class BuildJsonError extends Error {
|
|
@@ -93,3 +94,63 @@ export function parseBuildJson(input) {
|
|
|
93
94
|
throw new BuildJsonError('missing_field', path);
|
|
94
95
|
throw new BuildJsonError('wrong_type', path);
|
|
95
96
|
}
|
|
97
|
+
const IMAGE_REPOSITORY = ENTRY_IMAGE_REPOSITORY;
|
|
98
|
+
export const serverBuildJsonSchema = z.strictObject({
|
|
99
|
+
runtime: z.literal('server'),
|
|
100
|
+
repo: text,
|
|
101
|
+
commit: text,
|
|
102
|
+
images: z.array(z.strictObject({
|
|
103
|
+
process: z.string().regex(/^[a-z][a-z0-9-]{0,30}$/, { message: 'wrong_type' }),
|
|
104
|
+
repository: z.string().regex(IMAGE_REPOSITORY, { message: 'wrong_type' }),
|
|
105
|
+
digest: z.string().regex(DIGEST, { message: 'wrong_type' }),
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
function refuseBuildJson(input, error) {
|
|
109
|
+
const issue = error.issues[0];
|
|
110
|
+
if (!issue)
|
|
111
|
+
throw new BuildJsonError('wrong_type', '');
|
|
112
|
+
if (issue.code === 'unrecognized_keys') {
|
|
113
|
+
const base = dotted(issue.path);
|
|
114
|
+
const key = issue.keys[0] ?? '';
|
|
115
|
+
throw new BuildJsonError('unknown_field', base ? `${base}.${key}` : key);
|
|
116
|
+
}
|
|
117
|
+
const path = dotted(issue.path);
|
|
118
|
+
if (valueAt(input, issue.path) === undefined)
|
|
119
|
+
throw new BuildJsonError('missing_field', path);
|
|
120
|
+
throw new BuildJsonError('wrong_type', path);
|
|
121
|
+
}
|
|
122
|
+
/** Parses a server build.json. Throws {@link BuildJsonError} for the first problem; never carries a value. */
|
|
123
|
+
export function parseServerBuildJson(input) {
|
|
124
|
+
const result = serverBuildJsonSchema.safeParse(input);
|
|
125
|
+
if (result.success)
|
|
126
|
+
return result.data;
|
|
127
|
+
return refuseBuildJson(input, result.error);
|
|
128
|
+
}
|
|
129
|
+
/** Parses either shape: `runtime: 'server'` picks the server one, anything else the Worker one. */
|
|
130
|
+
export function parseAnyBuildJson(input) {
|
|
131
|
+
const runtime = input !== null && typeof input === 'object'
|
|
132
|
+
? input.runtime
|
|
133
|
+
: undefined;
|
|
134
|
+
return runtime === 'server' ? parseServerBuildJson(input) : parseBuildJson(input);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Null when the build holds exactly one `app` image and one image per declared process, no more.
|
|
138
|
+
* Else a plain message naming the process key, never an image value.
|
|
139
|
+
*/
|
|
140
|
+
export function checkServerBuild(build, processes) {
|
|
141
|
+
const wanted = new Set(['app', ...processes.map((p) => p.key)]);
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
for (const image of build.images) {
|
|
144
|
+
if (!wanted.has(image.process)) {
|
|
145
|
+
return `the build has an image for "${image.process}", which the app does not declare.`;
|
|
146
|
+
}
|
|
147
|
+
if (seen.has(image.process))
|
|
148
|
+
return `the build has two images for "${image.process}".`;
|
|
149
|
+
seen.add(image.process);
|
|
150
|
+
}
|
|
151
|
+
for (const key of wanted) {
|
|
152
|
+
if (!seen.has(key))
|
|
153
|
+
return `the build has no image for "${key}".`;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
@@ -82,6 +82,16 @@ const INTEGRATION_COPY = {
|
|
|
82
82
|
summary: 'Companies sign their own people in to the app.',
|
|
83
83
|
ours: 'Run by us',
|
|
84
84
|
},
|
|
85
|
+
'customer-grants': {
|
|
86
|
+
name: 'Permissions for customers',
|
|
87
|
+
summary: 'Your team gives each customer the permissions they need in the app, and we keep them in step.',
|
|
88
|
+
ours: 'Run by us',
|
|
89
|
+
},
|
|
90
|
+
repository: {
|
|
91
|
+
name: 'Repository',
|
|
92
|
+
summary: 'The app reads or updates the files in its repository, such as the pages it keeps there.',
|
|
93
|
+
ours: 'Run by us',
|
|
94
|
+
},
|
|
85
95
|
};
|
|
86
96
|
const SHARED_OUTPUTS = {
|
|
87
97
|
app: { url: "The app's web address.", host: "The app's host name.", name: "The app's name." },
|
|
@@ -81,6 +81,16 @@ const EXTRA = {
|
|
|
81
81
|
price: { class: 'free', meters: [] },
|
|
82
82
|
advanced: [],
|
|
83
83
|
},
|
|
84
|
+
'customer-grants': {
|
|
85
|
+
providers: [ours('customer-grants')],
|
|
86
|
+
price: { class: 'free', meters: [] },
|
|
87
|
+
advanced: [],
|
|
88
|
+
},
|
|
89
|
+
repository: {
|
|
90
|
+
providers: [ours('repository')],
|
|
91
|
+
price: { class: 'free', meters: [] },
|
|
92
|
+
advanced: [],
|
|
93
|
+
},
|
|
84
94
|
};
|
|
85
95
|
/** Every integration, one per app module, in the order modules are applied. */
|
|
86
96
|
export function integrations() {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { type PinnedVersions } from './bootstrap/versions.js';
|
|
|
8
8
|
export type { PinnedVersions };
|
|
9
9
|
export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
|
|
10
10
|
export type { RequestableField, CallsChange, CustomersChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, AppChange, BuildChange, FailureCode, RequestedBy, } from './requested-changes.js';
|
|
11
|
-
export type { BuildJson, BuildJsonErrorCode } from './build-json.js';
|
|
12
|
-
export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
|
|
11
|
+
export type { BuildJson, BuildJsonErrorCode, ServerBuildImage, ServerBuildJson, } from './build-json.js';
|
|
12
|
+
export { buildJsonSchema, parseBuildJson, BuildJsonError, serverBuildJsonSchema, parseServerBuildJson, parseAnyBuildJson, checkServerBuild, } from './build-json.js';
|
|
13
13
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
14
14
|
export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
15
15
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, v
|
|
|
7
7
|
// object, not a copy.
|
|
8
8
|
import { VERSIONS } from './bootstrap/versions.js';
|
|
9
9
|
export const PINNED_VERSIONS = VERSIONS;
|
|
10
|
-
export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
|
|
10
|
+
export { buildJsonSchema, parseBuildJson, BuildJsonError, serverBuildJsonSchema, parseServerBuildJson, parseAnyBuildJson, checkServerBuild, } from './build-json.js';
|
|
11
11
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
12
12
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
13
13
|
export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, } from './native-build.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { problem, setUse } from './entry-parts.js';
|
|
2
|
+
/** `uses.customerGrants`: the company's team gives customers permissions in the app. */
|
|
3
|
+
export const customerGrants = {
|
|
4
|
+
view: {
|
|
5
|
+
id: 'customer-grants',
|
|
6
|
+
name: 'Permissions for customers',
|
|
7
|
+
line: 'Your team gives each customer the permissions they need in the app, and we keep them in step.',
|
|
8
|
+
category: 'People',
|
|
9
|
+
icon: 'shield',
|
|
10
|
+
runtimes: ['server'],
|
|
11
|
+
requires: ['sign-in'],
|
|
12
|
+
availability: 'ready',
|
|
13
|
+
fields: [],
|
|
14
|
+
adds: [
|
|
15
|
+
{
|
|
16
|
+
name: 'Permissions your team manages',
|
|
17
|
+
hint: 'Your team gives and takes back customer permissions without changing the app.',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
toEntry: (entry, values) => setUse(entry, 'customerGrants', values === null ? undefined : true),
|
|
22
|
+
fromEntry: (entry) => (entry.uses?.customerGrants === true ? {} : null),
|
|
23
|
+
check(_values, modules) {
|
|
24
|
+
if (modules['sign-in']?.users === 'customers')
|
|
25
|
+
return null;
|
|
26
|
+
return problem(null, 'Permissions for customers are for an app whose customers sign in. Set Sign-in to "Your customers".');
|
|
27
|
+
},
|
|
28
|
+
};
|
package/dist/modules/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { SECRET_LIKE_NAME, WORKER_APP_ENV_NAME, WORKER_APP_RESERVED_NAMES, } fro
|
|
|
2
2
|
import { ai } from './ai.js';
|
|
3
3
|
import { assistants } from './assistants.js';
|
|
4
4
|
import { audit } from './audit.js';
|
|
5
|
+
import { customerGrants } from './customer-grants.js';
|
|
5
6
|
import { database } from './database.js';
|
|
6
7
|
import { email } from './email.js';
|
|
7
8
|
import { clone, isProblem } from './entry-parts.js';
|
|
@@ -9,6 +10,7 @@ import { files } from './files.js';
|
|
|
9
10
|
import { jobs } from './jobs.js';
|
|
10
11
|
import { organisations } from './organisations.js';
|
|
11
12
|
import { SECRET_OUTPUTS, referenceProblem } from './outputs.js';
|
|
13
|
+
import { repository } from './repository.js';
|
|
12
14
|
import { signIn } from './sign-in.js';
|
|
13
15
|
import { signedRecords } from './signed-records.js';
|
|
14
16
|
import { workspaces } from './workspaces.js';
|
|
@@ -29,6 +31,8 @@ export const MODULES = [
|
|
|
29
31
|
ai,
|
|
30
32
|
organisations,
|
|
31
33
|
audit,
|
|
34
|
+
customerGrants,
|
|
35
|
+
repository,
|
|
32
36
|
];
|
|
33
37
|
const byId = new Map(MODULES.map((module) => [module.view.id, module]));
|
|
34
38
|
/** The views of every module, JSON-safe, for Boule to render. */
|
|
@@ -340,7 +344,7 @@ function manifestV1AsV2(v1) {
|
|
|
340
344
|
}
|
|
341
345
|
return { v: 2, runtime: v1.runtime, modules: v1.modules, values };
|
|
342
346
|
}
|
|
343
|
-
const MANIFEST_V2_KEYS = ['v', 'runtime', 'modules', 'values', 'access'];
|
|
347
|
+
const MANIFEST_V2_KEYS = ['v', 'runtime', 'modules', 'values', 'access', 'processes'];
|
|
344
348
|
const VALUE_KEYS = {
|
|
345
349
|
generate: ['source', 'kind'],
|
|
346
350
|
reference: ['source', 'to', 'secret'],
|
|
@@ -368,11 +372,21 @@ const COPY_KEY = /^[a-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/;
|
|
|
368
372
|
const REFERENCE = /^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9-]*$/;
|
|
369
373
|
const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
370
374
|
const HOST_MAX = 20;
|
|
375
|
+
const PROCESS_MAX = 4;
|
|
376
|
+
const PROCESS_KEY = /^[a-z][a-z0-9-]{0,30}$/;
|
|
371
377
|
const WHY_MAX = 200;
|
|
372
378
|
const CHOICES_MAX = 50;
|
|
373
379
|
const ASKABLE_KINDS = ['text', 'longtext', 'path', 'choice', 'list'];
|
|
374
380
|
const isText = (value, max) => typeof value === 'string' && value.trim() !== '' && value.length <= max;
|
|
375
381
|
/** A plain sentence when `copy` is not a good label, help or copy key, else null. */
|
|
382
|
+
function isProcessFolder(folder) {
|
|
383
|
+
return (typeof folder === 'string' &&
|
|
384
|
+
folder.length > 0 &&
|
|
385
|
+
folder.length <= 200 &&
|
|
386
|
+
!folder.startsWith('/') &&
|
|
387
|
+
!folder.startsWith('\\') &&
|
|
388
|
+
!folder.split(/[\\/]/).includes('..'));
|
|
389
|
+
}
|
|
376
390
|
function copyProblem(name, raw, labelRequired) {
|
|
377
391
|
const { label, help, labelKey, helpKey } = raw;
|
|
378
392
|
if (labelRequired
|
|
@@ -670,9 +684,43 @@ function parseManifestV2(json) {
|
|
|
670
684
|
}
|
|
671
685
|
hosts.push({ host, why });
|
|
672
686
|
}
|
|
687
|
+
const processes = [];
|
|
688
|
+
if (json.processes !== undefined) {
|
|
689
|
+
if (runtime !== 'server') {
|
|
690
|
+
return refuse('"processes" is only for an app that runs on a server, not a Worker app.');
|
|
691
|
+
}
|
|
692
|
+
const raw = json.processes;
|
|
693
|
+
if (!Array.isArray(raw) || raw.length > PROCESS_MAX) {
|
|
694
|
+
return refuse(`"processes" must list at most ${PROCESS_MAX} helper processes.`);
|
|
695
|
+
}
|
|
696
|
+
const keys = new Set();
|
|
697
|
+
for (const item of raw) {
|
|
698
|
+
if (!isRecord(item) ||
|
|
699
|
+
Object.keys(item).some((k) => k !== 'key' && k !== 'folder' && k !== 'port')) {
|
|
700
|
+
return refuse('every helper process needs a "key" and a "folder", and may have a "port".');
|
|
701
|
+
}
|
|
702
|
+
const { key, folder, port } = item;
|
|
703
|
+
if (typeof key !== 'string' || !PROCESS_KEY.test(key) || key === 'app') {
|
|
704
|
+
return refuse('every helper process "key" must be 1 to 31 lowercase letters, digits or dashes, start with a letter, and not be "app".');
|
|
705
|
+
}
|
|
706
|
+
if (keys.has(key))
|
|
707
|
+
return refuse(`"processes" lists the key "${key}" twice.`);
|
|
708
|
+
keys.add(key);
|
|
709
|
+
if (!isProcessFolder(folder)) {
|
|
710
|
+
return refuse(`the "folder" of helper process "${key}" must be a path inside the repository, without "..", and not start with "/" or "\\".`);
|
|
711
|
+
}
|
|
712
|
+
if (port !== undefined &&
|
|
713
|
+
!(Number.isInteger(port) && port >= 1 && port <= 65535)) {
|
|
714
|
+
return refuse(`the "port" of helper process "${key}" must be a whole number from 1 to 65535.`);
|
|
715
|
+
}
|
|
716
|
+
processes.push(port === undefined ? { key, folder } : { key, folder, port: port });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
673
719
|
const manifest = { v: 2, runtime, modules: clone(modules), values };
|
|
674
720
|
if (json.access !== undefined)
|
|
675
721
|
manifest.access = { hosts };
|
|
722
|
+
if (json.processes !== undefined)
|
|
723
|
+
manifest.processes = processes;
|
|
676
724
|
return { ok: true, manifest };
|
|
677
725
|
}
|
|
678
726
|
/** `company-app.json` (v1 or v2) read and checked: what the app's code asks for. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AppModule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.repo`: the app gets a key for its own repository. The entry's `repo` names the repository (it comes
|
|
4
|
+
* from the install, not from this module); `access` says whether the app may only read or also push.
|
|
5
|
+
*/
|
|
6
|
+
export declare const repository: AppModule;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { setUse } from './entry-parts.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.repo`: the app gets a key for its own repository. The entry's `repo` names the repository (it comes
|
|
4
|
+
* from the install, not from this module); `access` says whether the app may only read or also push.
|
|
5
|
+
*/
|
|
6
|
+
export const repository = {
|
|
7
|
+
view: {
|
|
8
|
+
id: 'repository',
|
|
9
|
+
name: 'Repository',
|
|
10
|
+
line: 'The app reads or updates the files in its repository, such as the pages it keeps there.',
|
|
11
|
+
category: 'Data',
|
|
12
|
+
icon: 'git-branch',
|
|
13
|
+
runtimes: ['server'],
|
|
14
|
+
requires: [],
|
|
15
|
+
availability: 'ready',
|
|
16
|
+
fields: [
|
|
17
|
+
{
|
|
18
|
+
key: 'access',
|
|
19
|
+
label: 'What the app may do',
|
|
20
|
+
kind: 'choice',
|
|
21
|
+
default: 'read',
|
|
22
|
+
choices: [
|
|
23
|
+
{ value: 'read', label: 'Read', help: 'The app reads the files.' },
|
|
24
|
+
{ value: 'write', label: 'Read and update', help: 'The app also saves changes.' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
adds: [
|
|
29
|
+
{
|
|
30
|
+
name: 'A key for the repository',
|
|
31
|
+
hint: 'Only this app holds it, and it opens only this repository.',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
toEntry: (entry, values) => setUse(entry, 'repo', values === null ? undefined : { access: values.access === 'write' ? 'write' : 'read' }),
|
|
36
|
+
fromEntry(entry) {
|
|
37
|
+
const used = entry.uses?.repo;
|
|
38
|
+
return used ? { access: used.access } : null;
|
|
39
|
+
},
|
|
40
|
+
};
|
package/dist/modules/types.d.ts
CHANGED
|
@@ -181,6 +181,12 @@ export interface ManifestHost {
|
|
|
181
181
|
export interface ManifestAccess {
|
|
182
182
|
hosts: ManifestHost[];
|
|
183
183
|
}
|
|
184
|
+
/** A helper process of a server app: built from `<folder>/Dockerfile`, next to the app itself. */
|
|
185
|
+
export interface ManifestProcess {
|
|
186
|
+
key: string;
|
|
187
|
+
folder: string;
|
|
188
|
+
port?: number;
|
|
189
|
+
}
|
|
184
190
|
/** `company-app.json` v2, and what every manifest (v1 too) is read as. */
|
|
185
191
|
export interface AppManifest {
|
|
186
192
|
v: 2;
|
|
@@ -189,6 +195,8 @@ export interface AppManifest {
|
|
|
189
195
|
values: Record<string, ManifestValue>;
|
|
190
196
|
/** Optional. Absent means the app may connect anywhere. */
|
|
191
197
|
access?: ManifestAccess;
|
|
198
|
+
/** Optional, server apps only: at most 4 helper processes. */
|
|
199
|
+
processes?: ManifestProcess[];
|
|
192
200
|
}
|
|
193
201
|
/** An answer to an `ask`: text, or a list of texts for `multiple`. */
|
|
194
202
|
export type ManifestAnswer = string | string[];
|
package/dist/org.d.ts
CHANGED
|
@@ -301,7 +301,7 @@ export type AccessRule = {
|
|
|
301
301
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
302
302
|
export declare const APP_ENTRY_KEY = "app";
|
|
303
303
|
/** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
|
|
304
|
-
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
304
|
+
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
305
305
|
/**
|
|
306
306
|
* One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
|
|
307
307
|
* (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
|
|
@@ -311,6 +311,19 @@ export interface OrgRefusal {
|
|
|
311
311
|
path: string;
|
|
312
312
|
message: string;
|
|
313
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* A built image a server entry runs instead of building from a repository: `repository` is
|
|
316
|
+
* `<host>[:port]/<owner>/<name>`, `digest` is `sha256:` and 64 lowercase hex digits. Same shapes as a server
|
|
317
|
+
* `build.json` (`build-json.ts`).
|
|
318
|
+
*/
|
|
319
|
+
export interface EntryImage {
|
|
320
|
+
repository: string;
|
|
321
|
+
digest: `sha256:${string}`;
|
|
322
|
+
}
|
|
323
|
+
/** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
|
|
324
|
+
export declare const ENTRY_IMAGE_REPOSITORY: RegExp;
|
|
325
|
+
/** The digest of an {@link EntryImage}. */
|
|
326
|
+
export declare const ENTRY_IMAGE_DIGEST: RegExp;
|
|
314
327
|
/**
|
|
315
328
|
* One app the org runs. `database` and `gates` are meaningless without
|
|
316
329
|
* `HostingConfig.enabled`, so `validateHosting` requires it before either is
|
|
@@ -334,6 +347,11 @@ export interface HostingEntry {
|
|
|
334
347
|
buildPack: HostingBuildPack;
|
|
335
348
|
/** The port the app listens on. `null` is Coolify's default. */
|
|
336
349
|
port: number | null;
|
|
350
|
+
/**
|
|
351
|
+
* A built image the app runs, pulled as `repository@digest`, instead of a build from `repo`. Then `folder` is `null`, `branch` and `buildPack` stay at their defaults;
|
|
352
|
+
* `validateEntryImage` states the rest. A new digest redeploys the app.
|
|
353
|
+
*/
|
|
354
|
+
image?: EntryImage;
|
|
337
355
|
/**
|
|
338
356
|
* The app's live address on the platform's apps domain: `<hostedName>.companyincluded.app`. A
|
|
339
357
|
* label {@link hostedLabelRefusal} accepts; unique within the org (the host checks it across orgs).
|
package/dist/org.js
CHANGED
|
@@ -16,6 +16,10 @@ export const APP_ENTRY_KEY = 'app';
|
|
|
16
16
|
function refusal(code, path, message) {
|
|
17
17
|
return { code, path, message };
|
|
18
18
|
}
|
|
19
|
+
/** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
|
|
20
|
+
export const ENTRY_IMAGE_REPOSITORY = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9][a-z0-9._-]*$/;
|
|
21
|
+
/** The digest of an {@link EntryImage}. */
|
|
22
|
+
export const ENTRY_IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
19
23
|
export const MAX_PRIVATE_PAGES = 20;
|
|
20
24
|
export const MAX_PRIVATE_PAGE_ROLES = 50;
|
|
21
25
|
export const MAX_PRIVATE_PAGE_PEOPLE = 200;
|
|
@@ -211,6 +215,9 @@ export function refuseHosting(hosting, names) {
|
|
|
211
215
|
return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
|
|
212
216
|
}
|
|
213
217
|
seenKeys.add(entry.key);
|
|
218
|
+
const invalidImage = validateEntryImage(entry);
|
|
219
|
+
if (invalidImage)
|
|
220
|
+
return invalidImage;
|
|
214
221
|
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys, hosting.github);
|
|
215
222
|
if (invalid)
|
|
216
223
|
return invalid;
|
|
@@ -513,8 +520,8 @@ export function companyGithubFor(hosting, repo) {
|
|
|
513
520
|
const github = hosting.github;
|
|
514
521
|
return github && repo !== null && repoUnderAccount(repo, github.account) ? github : undefined;
|
|
515
522
|
}
|
|
516
|
-
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
517
|
-
const FOUNDRY_ENV_NAME = /^(
|
|
523
|
+
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves. `APP_URL` is also set, but only on the main entry.
|
|
524
|
+
const FOUNDRY_ENV_NAME = /^(LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
518
525
|
// Set only on the colors of a blueGreen entry; any other entry may declare them.
|
|
519
526
|
const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVER_ACTIONS_ENCRYPTION_KEY)$/;
|
|
520
527
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
@@ -626,6 +633,39 @@ function validateEntryPrivatePages(entry, at, hostingEnabled) {
|
|
|
626
633
|
}
|
|
627
634
|
return null;
|
|
628
635
|
}
|
|
636
|
+
/** Why an entry's `image` is refused, or `null`. Read wherever hosting is read, so a stored entry is checked too. */
|
|
637
|
+
function validateEntryImage(entry) {
|
|
638
|
+
const value = entry.image;
|
|
639
|
+
if (value === undefined)
|
|
640
|
+
return null;
|
|
641
|
+
const path = `hosting.entries.${entry.key}.image`;
|
|
642
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
643
|
+
const invalid = (message) => refusal('entry_image_invalid', path, `${at} ${message}`);
|
|
644
|
+
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
645
|
+
const { repository, digest } = (isObject ? value : {});
|
|
646
|
+
if (!isObject ||
|
|
647
|
+
Object.keys(value).some((k) => k !== 'repository' && k !== 'digest') ||
|
|
648
|
+
typeof repository !== 'string' ||
|
|
649
|
+
!ENTRY_IMAGE_REPOSITORY.test(repository) ||
|
|
650
|
+
repository.split('/').some((part) => /^\.+$/.test(part)) ||
|
|
651
|
+
typeof digest !== 'string' ||
|
|
652
|
+
!ENTRY_IMAGE_DIGEST.test(digest)) {
|
|
653
|
+
return invalid('image must be { repository, digest }: a registry path like registry.example.com/owner/name, and a digest of sha256: and 64 lowercase letters a-f or digits');
|
|
654
|
+
}
|
|
655
|
+
if (entry.folder !== null || entry.branch !== 'main' || entry.buildPack !== 'nixpacks') {
|
|
656
|
+
return invalid("runs a built image, so it cannot also build from a repository: remove the image, or set folder to null and keep the default branch and build pack (repo may stay, as the app's source)");
|
|
657
|
+
}
|
|
658
|
+
if (entry.worker !== undefined || entry.workerApp !== undefined) {
|
|
659
|
+
return invalid('runs a built image on a server, so it cannot also be a Worker or a Worker app');
|
|
660
|
+
}
|
|
661
|
+
if (entry.blueGreen !== undefined ||
|
|
662
|
+
entry.workspace !== undefined ||
|
|
663
|
+
entry.live === false ||
|
|
664
|
+
entry.liveCommit !== undefined) {
|
|
665
|
+
return invalid('runs a built image, which does not support a workspace, blue/green releases or a promote yet: remove them, or build from a repository');
|
|
666
|
+
}
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
629
669
|
function validateHostingEntry(entry, enabled, reserveKeys, github) {
|
|
630
670
|
const at = `hosting.entries '${entry.key}'`;
|
|
631
671
|
if (!ENTRY_KEY.test(entry.key)) {
|
|
@@ -689,7 +729,9 @@ function validateHostingEntry(entry, enabled, reserveKeys, github) {
|
|
|
689
729
|
if (!name) {
|
|
690
730
|
return refusal('env_name_empty', `hosting.entries.${entry.key}.env`, `${at} env needs a non-empty env var name`);
|
|
691
731
|
}
|
|
692
|
-
if (FOUNDRY_ENV_NAME.test(name) ||
|
|
732
|
+
if (FOUNDRY_ENV_NAME.test(name) ||
|
|
733
|
+
(name === 'APP_URL' && isMainEntry(entry)) ||
|
|
734
|
+
(entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
|
|
693
735
|
return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
|
|
694
736
|
}
|
|
695
737
|
if (typeof value !== 'string') {
|
package/dist/plan/diff.js
CHANGED
|
@@ -43,7 +43,7 @@ import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY,
|
|
|
43
43
|
import { ownDnsHostnames } from './own-dns.js';
|
|
44
44
|
import { ownHostnames } from './own-hostnames.js';
|
|
45
45
|
import { platformMailMxHost } from './platform-diff.js';
|
|
46
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
46
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appImageAppliedScopeKey, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, entryImageRef, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
47
47
|
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
48
48
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
49
49
|
import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
@@ -3566,7 +3566,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
3566
3566
|
// #447: no shared default app any more. Migration 0029 wrote `repo: null`
|
|
3567
3567
|
// for every org without a bespoke app and nothing rewrites that data, so
|
|
3568
3568
|
// the entry stays declared but is blocked here and never provisioned.
|
|
3569
|
-
const noRepo = entry !== undefined && entry.repo === null;
|
|
3569
|
+
const noRepo = entry !== undefined && entry.repo === null && entry.image === undefined;
|
|
3570
3570
|
if (noRepo) {
|
|
3571
3571
|
blocked.push({
|
|
3572
3572
|
description: `${org.slug}'s ${label} repo is blocked, not planned: there is no shared default app any more (#447)`,
|
|
@@ -3819,6 +3819,21 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
3819
3819
|
blocked,
|
|
3820
3820
|
};
|
|
3821
3821
|
}
|
|
3822
|
+
// A new image digest (or repository): the app is pointed at it and redeployed. The line names no secret.
|
|
3823
|
+
if (entry.image &&
|
|
3824
|
+
recordedIds[appImageAppliedScopeKey(entryKey)] !== entryImageRef(entry.image)) {
|
|
3825
|
+
return {
|
|
3826
|
+
step: {
|
|
3827
|
+
action,
|
|
3828
|
+
step: {
|
|
3829
|
+
description: `run ${org.slug}'s ${label} on image ${entry.image.repository} at ${entry.image.digest.slice(0, 19)} and redeploy the app`,
|
|
3830
|
+
destroys: false,
|
|
3831
|
+
migrates: false,
|
|
3832
|
+
},
|
|
3833
|
+
},
|
|
3834
|
+
blocked,
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3822
3837
|
// A changed `folder`, `port` or `autoDeploy` reaches an app that is already live.
|
|
3823
3838
|
const want = appSettingsOf(entry);
|
|
3824
3839
|
const have = appliedAppSettings(recordedIds, entryKey);
|
package/dist/plan/executor.js
CHANGED
|
@@ -68,7 +68,7 @@ import { LIMITS, limited } from './own-limits.js';
|
|
|
68
68
|
import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformTarget, scopeFromOrg, } from './own-services.js';
|
|
69
69
|
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
70
70
|
import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
71
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
71
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appImageAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, entryImageFields, entryImageRef, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
72
72
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
73
73
|
import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
74
74
|
import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
@@ -3963,7 +3963,7 @@ export class FoundryStepExecutor {
|
|
|
3963
3963
|
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
3964
3964
|
// `planApp` blocks a `repo: null` entry at plan time; reaching here with
|
|
3965
3965
|
// one means a stale or hand-built plan.
|
|
3966
|
-
if (entry.repo === null) {
|
|
3966
|
+
if (entry.repo === null && entry.image === undefined) {
|
|
3967
3967
|
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
|
|
3968
3968
|
}
|
|
3969
3969
|
const appRepo = entry.repo;
|
|
@@ -4052,35 +4052,55 @@ export class FoundryStepExecutor {
|
|
|
4052
4052
|
}
|
|
4053
4053
|
else {
|
|
4054
4054
|
const server = await this.resolveServer(org, context);
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4055
|
+
if (entry.image) {
|
|
4056
|
+
// A built image: pulled by digest, nothing to build. Coolify never deploys it by itself on create.
|
|
4057
|
+
const created = await this.ports.coolify.createDockerImageApplication({
|
|
4058
|
+
project_uuid: projectUuid,
|
|
4059
|
+
server_uuid: server.uuid,
|
|
4060
|
+
environment_name: environment.name,
|
|
4061
|
+
environment_uuid: environment.uuid,
|
|
4062
|
+
...entryImageFields(entry.image),
|
|
4063
|
+
// Coolify needs a port to create the app; a helper with none listens on none, so 80 is only its label.
|
|
4064
|
+
ports_exposes: String(entry.port ?? 80),
|
|
4065
|
+
name: appName,
|
|
4066
|
+
description: `${org.slug}'s ${entry.key} app, run from a built image`,
|
|
4067
|
+
instant_deploy: false,
|
|
4068
|
+
});
|
|
4069
|
+
appId = created.uuid;
|
|
4070
|
+
have = appSettingsOf(entry);
|
|
4071
|
+
haveDomains = wantDomains;
|
|
4072
|
+
}
|
|
4073
|
+
else {
|
|
4074
|
+
const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
|
|
4075
|
+
if (isServerWorkspace(entry)) {
|
|
4076
|
+
pinnedCommit =
|
|
4077
|
+
entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
|
|
4078
|
+
}
|
|
4079
|
+
const created = await this.ports.coolify.createGithubAppApplication({
|
|
4080
|
+
is_preview_deployments_enabled: false,
|
|
4081
|
+
project_uuid: projectUuid,
|
|
4082
|
+
server_uuid: server.uuid,
|
|
4083
|
+
environment_name: environment.name,
|
|
4084
|
+
environment_uuid: environment.uuid,
|
|
4085
|
+
github_app_uuid: githubAppUuid,
|
|
4086
|
+
git_repository: appRepo,
|
|
4087
|
+
git_branch: entry.branch,
|
|
4088
|
+
build_pack: entry.buildPack,
|
|
4089
|
+
name: appName,
|
|
4090
|
+
autogenerate_domain: false,
|
|
4091
|
+
// A static site is served by Coolify's own web server on port 80.
|
|
4092
|
+
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
4093
|
+
is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
|
|
4094
|
+
// Also the folder a static entry serves: Coolify's static build
|
|
4095
|
+
// copies `base_directory`, and reads `publish_directory` only for a
|
|
4096
|
+
// built app.
|
|
4097
|
+
base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
4098
|
+
});
|
|
4099
|
+
appId = created.uuid;
|
|
4100
|
+
// Create sent every setting, so nothing is left to patch.
|
|
4101
|
+
have = appSettingsOf(entry);
|
|
4102
|
+
haveDomains = wantDomains;
|
|
4059
4103
|
}
|
|
4060
|
-
const created = await this.ports.coolify.createGithubAppApplication({
|
|
4061
|
-
is_preview_deployments_enabled: false,
|
|
4062
|
-
project_uuid: projectUuid,
|
|
4063
|
-
server_uuid: server.uuid,
|
|
4064
|
-
environment_name: environment.name,
|
|
4065
|
-
environment_uuid: environment.uuid,
|
|
4066
|
-
github_app_uuid: githubAppUuid,
|
|
4067
|
-
git_repository: appRepo,
|
|
4068
|
-
git_branch: entry.branch,
|
|
4069
|
-
build_pack: entry.buildPack,
|
|
4070
|
-
name: appName,
|
|
4071
|
-
autogenerate_domain: false,
|
|
4072
|
-
// A static site is served by Coolify's own web server on port 80.
|
|
4073
|
-
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
4074
|
-
is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
|
|
4075
|
-
// Also the folder a static entry serves: Coolify's static build
|
|
4076
|
-
// copies `base_directory`, and reads `publish_directory` only for a
|
|
4077
|
-
// built app.
|
|
4078
|
-
base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
4079
|
-
});
|
|
4080
|
-
appId = created.uuid;
|
|
4081
|
-
// Create sent every setting, so nothing is left to patch.
|
|
4082
|
-
have = appSettingsOf(entry);
|
|
4083
|
-
haveDomains = wantDomains;
|
|
4084
4104
|
}
|
|
4085
4105
|
// Patch what differs from what the app has. A crash before the marker is
|
|
4086
4106
|
// recorded below leaves the old one, so the retry patches the same
|
|
@@ -4100,6 +4120,13 @@ export class FoundryStepExecutor {
|
|
|
4100
4120
|
patch.is_preview_deployments_enabled = false;
|
|
4101
4121
|
if (pinnedCommit)
|
|
4102
4122
|
patch.git_commit_sha = pinnedCommit;
|
|
4123
|
+
// A new image reference reaches a live app: the digest is patched and the app deploys below.
|
|
4124
|
+
const imageRef = entry.image ? entryImageRef(entry.image) : null;
|
|
4125
|
+
const imageChanged = entry.image !== undefined &&
|
|
4126
|
+
existing !== null &&
|
|
4127
|
+
context.recordedIds()[appImageAppliedScopeKey(entry.key)] !== imageRef;
|
|
4128
|
+
if (entry.image && imageChanged)
|
|
4129
|
+
Object.assign(patch, entryImageFields(entry.image));
|
|
4103
4130
|
// #543: the health-check quartet too, every run -- the same reason
|
|
4104
4131
|
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
4105
4132
|
// has nothing to wait on and stops the old container immediately). The
|
|
@@ -4148,7 +4175,8 @@ export class FoundryStepExecutor {
|
|
|
4148
4175
|
if (Object.keys(patch).length > 0)
|
|
4149
4176
|
await this.ports.coolify.updateApplication(appId, patch);
|
|
4150
4177
|
// The container builds and listens from these only at its next deploy.
|
|
4151
|
-
const settingsNeedDeploy = Boolean(
|
|
4178
|
+
const settingsNeedDeploy = Boolean(imageChanged ||
|
|
4179
|
+
patch.base_directory ||
|
|
4152
4180
|
patch.ports_exposes ||
|
|
4153
4181
|
patch.domains !== undefined ||
|
|
4154
4182
|
patch.custom_internal_name ||
|
|
@@ -4218,7 +4246,10 @@ export class FoundryStepExecutor {
|
|
|
4218
4246
|
entryRoutesMarker(org, context.recordedIds())
|
|
4219
4247
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
4220
4248
|
: { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
|
|
4221
|
-
|
|
4249
|
+
// An image app has no commit to read: its digest is the build.
|
|
4250
|
+
if (!entry.image) {
|
|
4251
|
+
await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
|
|
4252
|
+
}
|
|
4222
4253
|
}
|
|
4223
4254
|
else if ((appEnv.changed || dbNeedsRestart) && existing) {
|
|
4224
4255
|
await this.ports.coolify.restart(appId);
|
|
@@ -4230,6 +4261,8 @@ export class FoundryStepExecutor {
|
|
|
4230
4261
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
4231
4262
|
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
4232
4263
|
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
4264
|
+
if (imageRef)
|
|
4265
|
+
context.recordScopeId(appImageAppliedScopeKey(entry.key), imageRef);
|
|
4233
4266
|
if (pinnedCommit && pinnedCommit === entry.liveCommit) {
|
|
4234
4267
|
context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
|
|
4235
4268
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
|
|
1
|
+
import type { DeclaredOrg, EntryImage, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
|
|
2
2
|
import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import { type BlueGreenServer } from './blue-green.js';
|
|
@@ -725,6 +725,22 @@ export declare const DEFAULT_APP_SETTINGS: AppSettings;
|
|
|
725
725
|
export declare function appSettingsAppliedScopeKey(entryKey: string): string;
|
|
726
726
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
727
727
|
export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
|
|
728
|
+
/**
|
|
729
|
+
* The image an entry's app last ran, as `repository@digest` (no secret). `planApp` plans the step again, with a
|
|
730
|
+
* redeploy, when the entry's `image` differs from it.
|
|
731
|
+
*/
|
|
732
|
+
export declare function appImageAppliedScopeKey(entryKey: string): string;
|
|
733
|
+
/** An entry image as one pull reference. */
|
|
734
|
+
export declare function entryImageRef(image: EntryImage): string;
|
|
735
|
+
/**
|
|
736
|
+
* The two image fields Coolify accepts for an entry image. Its tag rule has no `@` or `:`, so the digest goes in
|
|
737
|
+
* the name as `<repository>@sha256` and the tag holds the bare 64 hex digits (Coolify pulls `<name>:<tag>`),
|
|
738
|
+
* the same way `coolifyImageFor` writes a pinned platform image.
|
|
739
|
+
*/
|
|
740
|
+
export declare function entryImageFields(image: EntryImage): {
|
|
741
|
+
docker_registry_image_name: string;
|
|
742
|
+
docker_registry_image_tag: string;
|
|
743
|
+
};
|
|
728
744
|
/**
|
|
729
745
|
* The URLs `coolifyApp` last put in the entry's Coolify `domains`
|
|
730
746
|
* (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
|
|
@@ -812,6 +812,28 @@ export function appliedAppSettings(recorded, entryKey) {
|
|
|
812
812
|
const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
|
|
813
813
|
return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
|
|
814
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* The image an entry's app last ran, as `repository@digest` (no secret). `planApp` plans the step again, with a
|
|
817
|
+
* redeploy, when the entry's `image` differs from it.
|
|
818
|
+
*/
|
|
819
|
+
export function appImageAppliedScopeKey(entryKey) {
|
|
820
|
+
return `app_image_applied.${entryKey}`;
|
|
821
|
+
}
|
|
822
|
+
/** An entry image as one pull reference. */
|
|
823
|
+
export function entryImageRef(image) {
|
|
824
|
+
return `${image.repository}@${image.digest}`;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* The two image fields Coolify accepts for an entry image. Its tag rule has no `@` or `:`, so the digest goes in
|
|
828
|
+
* the name as `<repository>@sha256` and the tag holds the bare 64 hex digits (Coolify pulls `<name>:<tag>`),
|
|
829
|
+
* the same way `coolifyImageFor` writes a pinned platform image.
|
|
830
|
+
*/
|
|
831
|
+
export function entryImageFields(image) {
|
|
832
|
+
return {
|
|
833
|
+
docker_registry_image_name: `${image.repository}@sha256`,
|
|
834
|
+
docker_registry_image_tag: image.digest.replace(/^sha256:/, ''),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
815
837
|
/**
|
|
816
838
|
* The URLs `coolifyApp` last put in the entry's Coolify `domains`
|
|
817
839
|
* (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
|