ninedeploy 0.18.25 → 0.18.27
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/commands/databaseUpgrade.d.ts +25 -0
- package/dist/commands/databaseUpgrade.d.ts.map +1 -0
- package/dist/commands/databaseUpgrade.js +136 -0
- package/dist/commands/databaseUpgrade.js.map +1 -0
- package/dist/commands/importApply.d.ts +114 -0
- package/dist/commands/importApply.d.ts.map +1 -0
- package/dist/commands/importApply.js +403 -0
- package/dist/commands/importApply.js.map +1 -0
- package/dist/commands/importCutover.d.ts +95 -0
- package/dist/commands/importCutover.d.ts.map +1 -0
- package/dist/commands/importCutover.js +293 -0
- package/dist/commands/importCutover.js.map +1 -0
- package/dist/commands/importData.d.ts +60 -0
- package/dist/commands/importData.d.ts.map +1 -0
- package/dist/commands/importData.js +286 -0
- package/dist/commands/importData.js.map +1 -0
- package/dist/commands/importPlan.d.ts +32 -0
- package/dist/commands/importPlan.d.ts.map +1 -0
- package/dist/commands/importPlan.js +138 -0
- package/dist/commands/importPlan.js.map +1 -0
- package/dist/commands/importVolumes.d.ts +38 -0
- package/dist/commands/importVolumes.d.ts.map +1 -0
- package/dist/commands/importVolumes.js +161 -0
- package/dist/commands/importVolumes.js.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/importers/caprover.d.ts +13 -0
- package/dist/lib/importers/caprover.d.ts.map +1 -0
- package/dist/lib/importers/caprover.js +150 -0
- package/dist/lib/importers/caprover.js.map +1 -0
- package/dist/lib/importers/connections.d.ts +39 -0
- package/dist/lib/importers/connections.d.ts.map +1 -0
- package/dist/lib/importers/connections.js +119 -0
- package/dist/lib/importers/connections.js.map +1 -0
- package/dist/lib/importers/coolify.d.ts +10 -0
- package/dist/lib/importers/coolify.d.ts.map +1 -0
- package/dist/lib/importers/coolify.js +174 -0
- package/dist/lib/importers/coolify.js.map +1 -0
- package/dist/lib/importers/dokploy.d.ts +13 -0
- package/dist/lib/importers/dokploy.d.ts.map +1 -0
- package/dist/lib/importers/dokploy.js +183 -0
- package/dist/lib/importers/dokploy.js.map +1 -0
- package/dist/lib/importers/heroku.d.ts +50 -0
- package/dist/lib/importers/heroku.d.ts.map +1 -0
- package/dist/lib/importers/heroku.js +159 -0
- package/dist/lib/importers/heroku.js.map +1 -0
- package/dist/lib/importers/plan.d.ts +137 -0
- package/dist/lib/importers/plan.d.ts.map +1 -0
- package/dist/lib/importers/plan.js +110 -0
- package/dist/lib/importers/plan.js.map +1 -0
- package/dist/lib/importers/shell.d.ts +24 -0
- package/dist/lib/importers/shell.d.ts.map +1 -0
- package/dist/lib/importers/shell.js +45 -0
- package/dist/lib/importers/shell.js.map +1 -0
- package/dist/lib/importers/sources.d.ts +26 -0
- package/dist/lib/importers/sources.d.ts.map +1 -0
- package/dist/lib/importers/sources.js +100 -0
- package/dist/lib/importers/sources.js.map +1 -0
- package/package.json +3 -3
- package/src/commands/databaseUpgrade.ts +124 -0
- package/src/commands/importApply.ts +445 -0
- package/src/commands/importCutover.ts +329 -0
- package/src/commands/importData.ts +302 -0
- package/src/commands/importPlan.ts +143 -0
- package/src/commands/importVolumes.ts +168 -0
- package/src/index.ts +61 -0
- package/src/lib/importers/caprover.ts +141 -0
- package/src/lib/importers/connections.ts +133 -0
- package/src/lib/importers/coolify.ts +164 -0
- package/src/lib/importers/dokploy.ts +167 -0
- package/src/lib/importers/heroku.ts +168 -0
- package/src/lib/importers/plan.ts +209 -0
- package/src/lib/importers/shell.ts +56 -0
- package/src/lib/importers/sources.ts +96 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A1: environment values copied from the source that still point at the
|
|
3
|
+
* source's databases.
|
|
4
|
+
*
|
|
5
|
+
* A .env file passed to `import apply --dotenv` is read here, on the person's
|
|
6
|
+
* machine; what comes out holds variable names, database names and host
|
|
7
|
+
* names, never a value. Each hit is either:
|
|
8
|
+
* - `attach`: the value is a connection string the panel's own one can
|
|
9
|
+
* replace exactly, so the new database is attached under that variable's
|
|
10
|
+
* name (the attachment sets it at every deploy);
|
|
11
|
+
* - `manual`: anything else, with the reason. The panel's string has a fixed
|
|
12
|
+
* form per engine (scheme, no query options, no Mongo database name, no
|
|
13
|
+
* Redis database index), and replacing a value that differs would change
|
|
14
|
+
* what the app connects to without anyone seeing it.
|
|
15
|
+
*/
|
|
16
|
+
import type { ImportPlan, PlanDatabase, PlanEngine, SourceKind } from './plan.js';
|
|
17
|
+
|
|
18
|
+
/** The scheme each engine's panel connection string starts with (server engine/database.ts). */
|
|
19
|
+
const PANEL_SCHEME: Record<PlanEngine, string> = {
|
|
20
|
+
postgres: 'postgres:', mysql: 'mysql:', mariadb: 'mariadb:', mongo: 'mongodb:',
|
|
21
|
+
redis: 'redis:', keydb: 'redis:', dragonfly: 'redis:', clickhouse: 'clickhouse:',
|
|
22
|
+
};
|
|
23
|
+
const REDIS_FAMILY = new Set<PlanEngine>(['redis', 'keydb', 'dragonfly']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The names the source's apps reach a database by, from where it runs.
|
|
27
|
+
* CapRover: both the app name and the `srv-captain--` alias (1.16 names new
|
|
28
|
+
* apps' Swarm services plainly, older ones with the prefix). Coolify: the
|
|
29
|
+
* container name (its uuid). Dokploy: the Swarm service (appName).
|
|
30
|
+
*/
|
|
31
|
+
export function sourceHosts(kind: SourceKind, db: PlanDatabase): string[] {
|
|
32
|
+
const name = db.runtime?.name?.toLowerCase();
|
|
33
|
+
if (!name) return [];
|
|
34
|
+
switch (db.runtime!.kind) {
|
|
35
|
+
case 'heroku-app':
|
|
36
|
+
// The name is the Heroku app, not a host: add-on URLs name Heroku's own hosts.
|
|
37
|
+
return [];
|
|
38
|
+
case 'container':
|
|
39
|
+
case 'swarm-service':
|
|
40
|
+
break;
|
|
41
|
+
default: {
|
|
42
|
+
const never: never = db.runtime!.kind;
|
|
43
|
+
return never;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'caprover') {
|
|
47
|
+
const app = name.replace(/^srv-captain--/, '');
|
|
48
|
+
return [app, `srv-captain--${app}`];
|
|
49
|
+
}
|
|
50
|
+
return [name];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `KEY=value` pairs of a .env file: quotes removed, `# comments` after an unquoted value dropped. */
|
|
54
|
+
export function envFileEntries(content: string): Array<{ key: string; value: string }> {
|
|
55
|
+
const out: Array<{ key: string; value: string }> = [];
|
|
56
|
+
for (const line of content.split(/\r?\n/)) {
|
|
57
|
+
const m = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)$/.exec(line);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
let value = m[2]!;
|
|
60
|
+
const q = value[0];
|
|
61
|
+
if ((q === '"' || q === "'") && value.indexOf(q, 1) > 0) value = value.slice(1, value.indexOf(q, 1));
|
|
62
|
+
else value = value.replace(/[ \t]+#.*$/, '').trim();
|
|
63
|
+
out.push({ key: m[1]!, value });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConnectionHit {
|
|
69
|
+
key: string;
|
|
70
|
+
/** The plan database it names (null: the source platform's own host). */
|
|
71
|
+
database: PlanDatabase | null;
|
|
72
|
+
host: string;
|
|
73
|
+
action: 'attach' | 'manual';
|
|
74
|
+
reason: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseUrl(value: string): URL | null {
|
|
78
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null;
|
|
79
|
+
try {
|
|
80
|
+
return new URL(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Why the panel's string cannot replace this one, or null when it can. */
|
|
87
|
+
function attachProblem(engine: PlanEngine, url: URL): string | null {
|
|
88
|
+
const want = PANEL_SCHEME[engine];
|
|
89
|
+
if (engine === 'mongo') return 'the panel\'s Mongo string names no database, so the app would use the default one; set the new host and credentials here, keeping the database name';
|
|
90
|
+
if (engine === 'clickhouse') return 'ClickHouse is not attached by the import; set the new host and credentials here';
|
|
91
|
+
if (url.protocol !== want) return `the panel's string starts with ${want}// and this one with ${url.protocol}//, which the app's driver may need`;
|
|
92
|
+
if (url.search) return 'it carries options (?…) that the panel\'s string would drop';
|
|
93
|
+
if (REDIS_FAMILY.has(engine) && url.pathname !== '' && url.pathname !== '/' && url.pathname !== '/0') return `it selects Redis database ${url.pathname.slice(1)}, and the panel's string uses 0`;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every value in the file that names a planned database's source host, or the source platform's own host. */
|
|
98
|
+
export function findConnections(plan: ImportPlan, envContent: string): ConnectionHit[] {
|
|
99
|
+
const byHost = new Map<string, PlanDatabase>();
|
|
100
|
+
for (const db of plan.databases) for (const h of sourceHosts(plan.source.kind, db)) byHost.set(h, db);
|
|
101
|
+
let platform: string | null = null;
|
|
102
|
+
try {
|
|
103
|
+
// A Heroku plan's URL is the repository, not a host the app could reach a database on.
|
|
104
|
+
if (plan.source.kind !== 'heroku') platform = new URL(plan.source.url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
105
|
+
} catch {
|
|
106
|
+
platform = null;
|
|
107
|
+
}
|
|
108
|
+
const hits: ConnectionHit[] = [];
|
|
109
|
+
for (const { key, value } of envFileEntries(envContent)) {
|
|
110
|
+
const url = parseUrl(value);
|
|
111
|
+
const host = (url ? url.hostname : value.replace(/:\d+$/, '')).toLowerCase().replace(/^\[|\]$/g, '');
|
|
112
|
+
if (!host) continue;
|
|
113
|
+
const db = byHost.get(host);
|
|
114
|
+
if (db) {
|
|
115
|
+
if (!url) {
|
|
116
|
+
hits.push({ key, database: db, host, action: 'manual', reason: `it holds the source database's host; set it to ${db.name}'s host in NineDeploy (Databases → ${db.name} → Connection)` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const problem = db.engine ? attachProblem(db.engine, url) : 'NineDeploy has no managed equivalent of this database';
|
|
120
|
+
hits.push(problem
|
|
121
|
+
? { key, database: db, host, action: 'manual', reason: problem }
|
|
122
|
+
: { key, database: db, host, action: 'attach', reason: '' });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (platform && host === platform && !url?.protocol.startsWith('http')) {
|
|
126
|
+
hits.push({
|
|
127
|
+
key, database: null, host, action: 'manual',
|
|
128
|
+
reason: 'NineDeploy can still reach that host, so the app would keep using the old database. Point it at the new one',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return hits;
|
|
133
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coolify v4 (field names checked against v4.7.0, eaaaf7a7): REST under
|
|
3
|
+
* /api/v1 with a team-bound Bearer token. A `read` token is enough for a plan:
|
|
4
|
+
* values that need `read:sensitive` (env values, compose YAML, passwords) are
|
|
5
|
+
* never asked for. Databases come from /databases, not from per-environment
|
|
6
|
+
* lists, which leave out KeyDB, Dragonfly, ClickHouse and SQLite.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
cpusToMilli, DATA_NOT_MOVED, domainsFrom, emptyPlan, firstVolumeMount, memToMb, stripUserinfo,
|
|
10
|
+
type ImportPlan, type PlanDatabase, type PlanEngine, type PlanIssue, type PlanService, type SourceGet,
|
|
11
|
+
} from './plan.js';
|
|
12
|
+
|
|
13
|
+
type Obj = Record<string, unknown>;
|
|
14
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
15
|
+
const list = (v: unknown): Obj[] => (Array.isArray(v) ? (v as Obj[]) : []);
|
|
16
|
+
|
|
17
|
+
const ENGINES: Record<string, PlanEngine | null> = {
|
|
18
|
+
'standalone-postgresql': 'postgres',
|
|
19
|
+
'standalone-mysql': 'mysql',
|
|
20
|
+
'standalone-mariadb': 'mariadb',
|
|
21
|
+
'standalone-mongodb': 'mongo',
|
|
22
|
+
'standalone-redis': 'redis',
|
|
23
|
+
'standalone-keydb': 'keydb',
|
|
24
|
+
'standalone-dragonfly': 'dragonfly',
|
|
25
|
+
'standalone-clickhouse': 'clickhouse',
|
|
26
|
+
'standalone-sqlite': null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Coolify git_repository is a URL, or `owner/repo` for GitHub sources. */
|
|
30
|
+
function repoUrlOf(app: Obj, issues: PlanIssue[]): string | undefined {
|
|
31
|
+
const full = str(app['git_full_url']) ?? str(app['git_repository']);
|
|
32
|
+
if (!full) return undefined;
|
|
33
|
+
if (/^(https?|ssh|git):\/\//i.test(full) || /^[^@\s]+@[^:\s]+:/.test(full)) return stripUserinfo(full);
|
|
34
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(full)) {
|
|
35
|
+
issues.push({ level: 'info', option: 'git_repository', detail: `"${full}" read as a GitHub repository.` });
|
|
36
|
+
return `https://github.com/${full}`;
|
|
37
|
+
}
|
|
38
|
+
return stripUserinfo(full);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function envKeys(get: SourceGet, path: string): Promise<Array<{ key: string }>> {
|
|
42
|
+
const rows = list(await get(path));
|
|
43
|
+
const keys = new Set<string>();
|
|
44
|
+
// Preview variables (is_preview) belong to PR previews, not the service itself.
|
|
45
|
+
for (const r of rows) if (r['is_preview'] !== true && str(r['key'])) keys.add(String(r['key']));
|
|
46
|
+
return [...keys].map((key) => ({ key }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function volumesOf(get: SourceGet, path: string): Promise<PlanService['volumes']> {
|
|
50
|
+
const s = (await get(path)) as Obj | null;
|
|
51
|
+
const out: PlanService['volumes'] = [];
|
|
52
|
+
for (const v of list(s?.['persistent_storages'])) {
|
|
53
|
+
const mountPath = str(v['mount_path']);
|
|
54
|
+
if (mountPath) out.push({ mountPath, source: str(v['host_path']) ? 'bind' : 'volume', name: str(v['name']) });
|
|
55
|
+
}
|
|
56
|
+
for (const v of list(s?.['file_storages'])) {
|
|
57
|
+
const mountPath = str(v['mount_path']);
|
|
58
|
+
if (mountPath) out.push({ mountPath, source: 'file' });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function applicationItem(get: SourceGet, app: Obj): Promise<PlanService> {
|
|
64
|
+
const ref = String(app['uuid']);
|
|
65
|
+
const name = str(app['name']) ?? ref;
|
|
66
|
+
const issues: PlanIssue[] = [];
|
|
67
|
+
const pack = str(app['build_pack']) ?? 'nixpacks';
|
|
68
|
+
const base = { name, type: 'docker' as const };
|
|
69
|
+
let kind: PlanService['kind'] = 'git';
|
|
70
|
+
let create: PlanService['create'] = base;
|
|
71
|
+
|
|
72
|
+
if (pack === 'dockerimage') {
|
|
73
|
+
kind = 'image';
|
|
74
|
+
const image = str(app['docker_registry_image_name']);
|
|
75
|
+
const tag = str(app['docker_registry_image_tag']) ?? 'latest';
|
|
76
|
+
create = { ...base, image: image ? `${image}:${tag}` : undefined };
|
|
77
|
+
if (!image) issues.push({ level: 'manual', option: 'docker_registry_image_name', detail: 'No image name in the source; set one before importing.' });
|
|
78
|
+
} else if (pack === 'dockercompose') {
|
|
79
|
+
kind = 'compose';
|
|
80
|
+
create = { name, type: 'compose', repoUrl: repoUrlOf(app, issues), branch: str(app['git_branch']) };
|
|
81
|
+
const loc = str(app['docker_compose_location']);
|
|
82
|
+
issues.push({ level: 'info', option: 'docker_compose_location', detail: `Compose file ${loc ?? '/docker-compose.yaml'} in the repository; NineDeploy reads it from there.` });
|
|
83
|
+
} else {
|
|
84
|
+
const buildPack = pack === 'nixpacks' || pack === 'static' || pack === 'dockerfile' || pack === 'railpack' ? pack : 'auto';
|
|
85
|
+
if (buildPack === 'auto') issues.push({ level: 'unsupported', option: 'build_pack', detail: `Build pack "${pack}" has no NineDeploy equivalent; the plan uses auto-detect.` });
|
|
86
|
+
const dockerfile = str(app['dockerfile_location'])?.replace(/^\/+/, '');
|
|
87
|
+
create = {
|
|
88
|
+
...base,
|
|
89
|
+
repoUrl: repoUrlOf(app, issues),
|
|
90
|
+
branch: str(app['git_branch']),
|
|
91
|
+
build: {
|
|
92
|
+
buildPack,
|
|
93
|
+
baseDir: str(app['base_directory']) ?? '/',
|
|
94
|
+
...(buildPack === 'dockerfile' && dockerfile ? { dockerfilePath: dockerfile } : {}),
|
|
95
|
+
...(buildPack === 'static' && str(app['publish_directory']) ? { outputDir: str(app['publish_directory'])!.replace(/^\/+/, '') } : {}),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
if (!create.repoUrl) issues.push({ level: 'manual', option: 'git_repository', detail: 'No repository in the source.' });
|
|
99
|
+
}
|
|
100
|
+
const sourceType = str(app['source_type']) ?? '';
|
|
101
|
+
// Coolify gives every app made from a public repository its built-in "Public GitHub"
|
|
102
|
+
// source, GithubApp id 0 (checked live on 4.7.5). Only another source needs credentials.
|
|
103
|
+
const publicSource = /GithubApp/i.test(sourceType) && app['source_id'] != null && Number(app['source_id']) === 0;
|
|
104
|
+
const gitSource = /Github|Gitlab|Gitea|Bitbucket/i.test(sourceType) && !publicSource;
|
|
105
|
+
// A read token hides private_key_id, so a deploy-key app shows as an SSH repository URL.
|
|
106
|
+
const sshRepo = /^(ssh:\/\/|[^@\s/]+@[^:\s]+:)/i.test(str(app['git_repository']) ?? '');
|
|
107
|
+
if (gitSource || app['private_key_id'] != null || sshRepo || str(app['git_https_username'])) {
|
|
108
|
+
issues.push({ level: 'manual', option: 'source', detail: 'Private repository access does not transfer: reconnect it in NineDeploy (GitHub App, token or deploy key).' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ports = (str(app['ports_exposes']) ?? '').split(',').map((p) => Number(p.trim())).filter((p) => Number.isInteger(p) && p > 0 && p < 65536);
|
|
112
|
+
if (ports[0]) create.port = ports[0];
|
|
113
|
+
if (ports.length > 1) issues.push({ level: 'info', option: 'ports_exposes', detail: `Exposes ${ports.join(', ')}; NineDeploy routes one port (${ports[0]}).` });
|
|
114
|
+
const mapping = str(app['ports_mappings']);
|
|
115
|
+
if (mapping) issues.push({ level: 'manual', option: 'ports_mappings', detail: `Host port mappings (${mapping}) are not carried; set Public Host Port or port mappings by hand.` });
|
|
116
|
+
if (app['health_check_enabled'] === true && str(app['health_check_path'])) create.healthPath = str(app['health_check_path']);
|
|
117
|
+
const mem = memToMb(str(app['limits_memory']));
|
|
118
|
+
if (mem) create.memLimitMb = mem;
|
|
119
|
+
const cpu = cpusToMilli(str(app['limits_cpus']));
|
|
120
|
+
if (cpu) create.cpuLimitMilli = cpu;
|
|
121
|
+
|
|
122
|
+
const volumes = kind === 'compose' ? [] : await volumesOf(get, `/api/v1/applications/${ref}/storages`);
|
|
123
|
+
const mount = kind === 'compose' ? undefined : firstVolumeMount(volumes, issues);
|
|
124
|
+
if (mount) create.volumeMount = mount;
|
|
125
|
+
return {
|
|
126
|
+
ref, name, kind, create,
|
|
127
|
+
domains: domainsFrom(str(app['fqdn'])),
|
|
128
|
+
env: await envKeys(get, `/api/v1/applications/${ref}/envs`),
|
|
129
|
+
volumes, issues,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function serviceStackItem(get: SourceGet, svc: Obj): Promise<PlanService> {
|
|
134
|
+
const ref = String(svc['uuid']);
|
|
135
|
+
const name = str(svc['name']) ?? ref;
|
|
136
|
+
const issues: PlanIssue[] = [
|
|
137
|
+
{ level: 'manual', option: 'docker_compose', detail: 'The stack\'s compose file needs a read:sensitive token to read; paste it into a new compose service.' },
|
|
138
|
+
];
|
|
139
|
+
const template = str(svc['service_type']);
|
|
140
|
+
if (template) issues.push({ level: 'info', option: 'service_type', detail: `Coolify one-click template "${template}"; the NineDeploy Hub may have an equivalent.` });
|
|
141
|
+
const detail = (await get(`/api/v1/services/${ref}`)) as Obj | null;
|
|
142
|
+
const domains = list(detail?.['applications']).flatMap((a) => domainsFrom(str(a['fqdn'])));
|
|
143
|
+
return { ref, name, kind: 'compose', create: { name, type: 'compose' }, domains, env: await envKeys(get, `/api/v1/services/${ref}/envs`), volumes: [], issues };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function databaseItem(db: Obj): PlanDatabase {
|
|
147
|
+
const type = str(db['database_type']) ?? '';
|
|
148
|
+
const engine = type in ENGINES ? ENGINES[type]! : null;
|
|
149
|
+
const issues: PlanIssue[] = [];
|
|
150
|
+
if (engine === null) issues.push({ level: 'unsupported', option: 'database_type', detail: type === 'standalone-sqlite' ? 'SQLite is a file inside an app, not a managed server; NineDeploy has no managed SQLite.' : `Unknown database type "${type}".` });
|
|
151
|
+
else issues.push(DATA_NOT_MOVED);
|
|
152
|
+
if (db['is_public'] === true) issues.push({ level: 'manual', option: 'is_public', detail: 'Public access was on; turn on public access in NineDeploy if you still need it.' });
|
|
153
|
+
// Coolify names a standalone database's container after its uuid.
|
|
154
|
+
return { ref: String(db['uuid']), name: str(db['name']) ?? String(db['uuid']), engine, image: str(db['image']) ?? null, runtime: { kind: 'container', name: String(db['uuid']) }, issues };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function planFromCoolify(get: SourceGet, url: string): Promise<ImportPlan> {
|
|
158
|
+
const version = await get('/api/v1/version').then((v) => (typeof v === 'string' ? v.trim() : null), () => null);
|
|
159
|
+
const plan = emptyPlan('coolify', url, version, 'the team the API token belongs to');
|
|
160
|
+
for (const app of list(await get('/api/v1/applications'))) plan.services.push(await applicationItem(get, app));
|
|
161
|
+
for (const svc of list(await get('/api/v1/services'))) plan.services.push(await serviceStackItem(get, svc));
|
|
162
|
+
for (const db of list(await get('/api/v1/databases'))) plan.databases.push(databaseItem(db));
|
|
163
|
+
return plan;
|
|
164
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dokploy (field names checked against v0.31.0, 3ac1c8f2): tRPC procedures
|
|
3
|
+
* exposed as REST by @dokploy/trpc-openapi, `GET /api/<procedure>?<input>`,
|
|
4
|
+
* with an `x-api-key` bound to one organization. There is no application.all:
|
|
5
|
+
* the plan walks project.all, then reads each item with its `*.one`.
|
|
6
|
+
*
|
|
7
|
+
* Dokploy returns secrets in plain text (decrypted env, database passwords,
|
|
8
|
+
* the registry password): the adapter reads names only and never copies a
|
|
9
|
+
* value into the plan.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
DATA_NOT_MOVED, emptyPlan, envNames, firstVolumeMount, memToMb, planDomain, replicasFor, stripUserinfo,
|
|
13
|
+
type ImportPlan, type PlanDatabase, type PlanEngine, type PlanIssue, type PlanService, type SourceGet,
|
|
14
|
+
} from './plan.js';
|
|
15
|
+
|
|
16
|
+
type Obj = Record<string, unknown>;
|
|
17
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
18
|
+
const list = (v: unknown): Obj[] => (Array.isArray(v) ? (v as Obj[]) : []);
|
|
19
|
+
const q = (procedure: string, input: Record<string, string>) => `/api/${procedure}?${new URLSearchParams(input).toString()}`;
|
|
20
|
+
|
|
21
|
+
/** The repository a Dokploy application or compose builds from, per source type. */
|
|
22
|
+
function repoOf(row: Obj, issues: PlanIssue[]): { repoUrl?: string; branch?: string } {
|
|
23
|
+
const type = str(row['sourceType']);
|
|
24
|
+
const hosted = (host: string, owner?: string, repo?: string) => (owner && repo ? `https://${host}/${owner}/${repo}` : undefined);
|
|
25
|
+
let out: { repoUrl?: string; branch?: string } = {};
|
|
26
|
+
if (type === 'github') out = { repoUrl: hosted('github.com', str(row['owner']), str(row['repository'])), branch: str(row['branch']) };
|
|
27
|
+
else if (type === 'gitlab') out = { repoUrl: hosted('gitlab.com', str(row['gitlabOwner']), str(row['gitlabRepository'])), branch: str(row['gitlabBranch']) };
|
|
28
|
+
else if (type === 'bitbucket') out = { repoUrl: hosted('bitbucket.org', str(row['bitbucketOwner']), str(row['bitbucketRepository'])), branch: str(row['bitbucketBranch']) };
|
|
29
|
+
else if (type === 'gitea') {
|
|
30
|
+
out = { branch: str(row['giteaBranch']) };
|
|
31
|
+
issues.push({ level: 'manual', option: 'sourceType', detail: `Gitea repository ${str(row['giteaOwner']) ?? '?'}/${str(row['giteaRepository']) ?? '?'}: enter its URL by hand (the Gitea host is not in the record).` });
|
|
32
|
+
} else if (type === 'git') {
|
|
33
|
+
const raw = str(row['customGitUrl']);
|
|
34
|
+
out = { repoUrl: raw ? stripUserinfo(raw) : undefined, branch: str(row['customGitBranch']) };
|
|
35
|
+
}
|
|
36
|
+
if (type === 'github' || type === 'gitlab' || type === 'bitbucket' || type === 'gitea' || (type === 'git' && row['customGitSSHKeyId'] != null)) {
|
|
37
|
+
issues.push({ level: 'manual', option: 'source', detail: 'Repository access does not transfer: reconnect it in NineDeploy (GitHub App, token or deploy key).' });
|
|
38
|
+
}
|
|
39
|
+
if (type === 'gitlab' || type === 'bitbucket') issues.push({ level: 'info', option: 'sourceType', detail: `Assumed the hosted ${type === 'gitlab' ? 'gitlab.com' : 'bitbucket.org'}; change the URL for a self-hosted instance.` });
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function volumesOf(mounts: unknown): PlanService['volumes'] {
|
|
44
|
+
const out: PlanService['volumes'] = [];
|
|
45
|
+
for (const m of list(mounts)) {
|
|
46
|
+
const mountPath = str(m['mountPath']);
|
|
47
|
+
if (!mountPath) continue;
|
|
48
|
+
const type = str(m['type']);
|
|
49
|
+
out.push({ mountPath, source: type === 'bind' ? 'bind' : type === 'file' ? 'file' : 'volume', name: str(m['volumeName']) });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const BUILD_PACKS: Record<string, NonNullable<NonNullable<PlanService['create']['build']>['buildPack']>> = {
|
|
55
|
+
nixpacks: 'nixpacks', dockerfile: 'dockerfile', static: 'static', railpack: 'railpack', heroku_buildpacks: 'heroku', paketo_buildpacks: 'paketo',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function applicationItem(get: SourceGet, id: string): Promise<PlanService> {
|
|
59
|
+
const app = ((await get(q('application.one', { applicationId: id }))) ?? {}) as Obj;
|
|
60
|
+
const name = str(app['name']) ?? str(app['appName']) ?? id;
|
|
61
|
+
const issues: PlanIssue[] = [];
|
|
62
|
+
const base = { name, type: 'docker' as const };
|
|
63
|
+
const sourceType = str(app['sourceType']);
|
|
64
|
+
let kind: PlanService['kind'] = 'git';
|
|
65
|
+
let create: PlanService['create'] = base;
|
|
66
|
+
if (sourceType === 'docker') {
|
|
67
|
+
kind = 'image';
|
|
68
|
+
create = { ...base, image: str(app['dockerImage']) };
|
|
69
|
+
if (str(app['username']) || app['registryUrl']) issues.push({ level: 'manual', option: 'registry', detail: 'A private registry login does not transfer: add the registry credentials in NineDeploy.' });
|
|
70
|
+
} else if (sourceType === 'drop') {
|
|
71
|
+
issues.push({ level: 'unsupported', option: 'sourceType', detail: 'Uploaded ("drop") source: the archive is not readable over the API. Re-upload it as a ZIP source in NineDeploy.' });
|
|
72
|
+
} else {
|
|
73
|
+
const { repoUrl, branch } = repoOf(app, issues);
|
|
74
|
+
const buildType = str(app['buildType']) ?? 'nixpacks';
|
|
75
|
+
const buildPack = BUILD_PACKS[buildType] ?? 'auto';
|
|
76
|
+
if (buildPack === 'auto') issues.push({ level: 'unsupported', option: 'buildType', detail: `Build type "${buildType}" has no NineDeploy equivalent; the plan uses auto-detect.` });
|
|
77
|
+
const dockerfile = str(app['dockerfile']);
|
|
78
|
+
const base_dir = str(app['customGitBuildPath']) ?? str(app['buildPath']);
|
|
79
|
+
create = {
|
|
80
|
+
...base, repoUrl, branch,
|
|
81
|
+
build: {
|
|
82
|
+
buildPack,
|
|
83
|
+
...(base_dir ? { baseDir: base_dir } : {}),
|
|
84
|
+
...(buildPack === 'dockerfile' && dockerfile ? { dockerfilePath: dockerfile.replace(/^\.?\/+/, '') } : {}),
|
|
85
|
+
...(buildPack === 'static' && str(app['publishDirectory']) ? { outputDir: str(app['publishDirectory'])!.replace(/^\.?\/+/, '') } : {}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
if (!repoUrl && sourceType !== 'gitea') issues.push({ level: 'manual', option: 'repository', detail: 'No repository in the source.' });
|
|
89
|
+
}
|
|
90
|
+
const domains = list(app['domains']).map((d) => ({ host: str(d['host']) ?? '', https: d['https'] === true, port: Number(d['port']) })).filter((d) => d.host);
|
|
91
|
+
const port = domains.find((d) => Number.isInteger(d.port) && d.port > 0)?.port;
|
|
92
|
+
if (port) create.port = port;
|
|
93
|
+
const portsList = list(app['ports']);
|
|
94
|
+
if (portsList.length) issues.push({ level: 'manual', option: 'ports', detail: `${portsList.length} published port(s) (${portsList.map((p) => `${p['publishedPort']}:${p['targetPort']}`).join(', ')}); set Public Host Port or port mappings by hand.` });
|
|
95
|
+
const mem = memToMb(str(app['memoryLimit']) ?? (app['memoryLimit'] as number | undefined));
|
|
96
|
+
if (mem) create.memLimitMb = mem;
|
|
97
|
+
// Dokploy passes these straight to Docker: memoryLimit as MemoryBytes and
|
|
98
|
+
// cpuLimit as NanoCPUs (1e9 = one core), both as strings.
|
|
99
|
+
const nano = Number(app['cpuLimit']);
|
|
100
|
+
const cpu = Number.isFinite(nano) && nano > 0 ? Math.round(nano / 1_000_000) : undefined;
|
|
101
|
+
if (cpu) create.cpuLimitMilli = cpu;
|
|
102
|
+
const replicas = replicasFor(app['replicas'], issues);
|
|
103
|
+
if (replicas) create.replicas = replicas;
|
|
104
|
+
if (str(app['command'])) issues.push({ level: 'manual', option: 'command', detail: 'A custom start command is set; add it as the start command under Settings → Build & start.' });
|
|
105
|
+
const volumes = volumesOf(app['mounts']);
|
|
106
|
+
const mount = firstVolumeMount(volumes, issues);
|
|
107
|
+
if (mount) create.volumeMount = mount;
|
|
108
|
+
const appName = str(app['appName']);
|
|
109
|
+
return {
|
|
110
|
+
ref: id, name, kind, create, domains: domains.map(({ host, https }) => planDomain(host, https)), env: envNames(str(app['env'])), volumes, issues,
|
|
111
|
+
// Dokploy runs an application as a Swarm service named after its appName.
|
|
112
|
+
...(appName ? { runtime: { kind: 'swarm-service' as const, name: appName } } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function composeItem(get: SourceGet, id: string): Promise<PlanService> {
|
|
117
|
+
const row = ((await get(q('compose.one', { composeId: id }))) ?? {}) as Obj;
|
|
118
|
+
const name = str(row['name']) ?? str(row['appName']) ?? id;
|
|
119
|
+
const issues: PlanIssue[] = [];
|
|
120
|
+
const create: PlanService['create'] = { name, type: 'compose' };
|
|
121
|
+
if (str(row['sourceType']) === 'raw') {
|
|
122
|
+
issues.push({ level: 'manual', option: 'composeFile', detail: 'An inline compose file: paste it into a new compose service (the plan does not copy file contents).' });
|
|
123
|
+
} else {
|
|
124
|
+
Object.assign(create, repoOf(row, issues));
|
|
125
|
+
issues.push({ level: 'info', option: 'composePath', detail: `Compose file ${str(row['composePath']) ?? './docker-compose.yml'} in the repository.` });
|
|
126
|
+
}
|
|
127
|
+
if (str(row['composeType']) === 'stack') issues.push({ level: 'info', option: 'composeType', detail: 'A Swarm stack; NineDeploy runs it as a Compose stack unless you choose Swarm.' });
|
|
128
|
+
const domains = list(row['domains']).map((d) => planDomain(str(d['host']) ?? '', d['https'] === true)).filter((d) => d.host);
|
|
129
|
+
return { ref: id, name, kind: 'compose', create, domains, env: envNames(str(row['env'])), volumes: volumesOf(row['mounts']), issues };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const DB_KINDS: Array<{ key: string; idField: string; procedure: string; engine: PlanEngine | null }> = [
|
|
133
|
+
{ key: 'postgres', idField: 'postgresId', procedure: 'postgres.one', engine: 'postgres' },
|
|
134
|
+
{ key: 'mysql', idField: 'mysqlId', procedure: 'mysql.one', engine: 'mysql' },
|
|
135
|
+
{ key: 'mariadb', idField: 'mariadbId', procedure: 'mariadb.one', engine: 'mariadb' },
|
|
136
|
+
{ key: 'mongo', idField: 'mongoId', procedure: 'mongo.one', engine: 'mongo' },
|
|
137
|
+
{ key: 'redis', idField: 'redisId', procedure: 'redis.one', engine: 'redis' },
|
|
138
|
+
{ key: 'libsql', idField: 'libsqlId', procedure: 'libsql.one', engine: null },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
export async function planFromDokploy(get: SourceGet, url: string): Promise<ImportPlan> {
|
|
142
|
+
const version = await get('/api/settings.getDokployVersion').then((v) => (typeof v === 'string' ? v.trim() : null), () => null);
|
|
143
|
+
const plan = emptyPlan('dokploy', url, version, "the organization the API key belongs to (other organizations are not read)");
|
|
144
|
+
for (const project of list(await get('/api/project.all'))) {
|
|
145
|
+
for (const env of list(project['environments'])) {
|
|
146
|
+
for (const a of list(env['applications'])) if (str(a['applicationId'])) plan.services.push(await applicationItem(get, String(a['applicationId'])));
|
|
147
|
+
for (const c of list(env['compose'])) if (str(c['composeId'])) plan.services.push(await composeItem(get, String(c['composeId'])));
|
|
148
|
+
for (const kind of DB_KINDS) {
|
|
149
|
+
for (const d of list(env[kind.key])) {
|
|
150
|
+
const id = str(d[kind.idField]);
|
|
151
|
+
if (!id) continue;
|
|
152
|
+
const row = ((await get(q(kind.procedure, { [kind.idField]: id }))) ?? {}) as Obj;
|
|
153
|
+
const issues: PlanIssue[] = kind.engine
|
|
154
|
+
? [DATA_NOT_MOVED]
|
|
155
|
+
: [{ level: 'unsupported', option: 'libsql', detail: 'libSQL has no managed NineDeploy equivalent.' }];
|
|
156
|
+
if (row['externalPort'] != null) issues.push({ level: 'manual', option: 'externalPort', detail: 'Public access was on; turn on public access in NineDeploy if you still need it.' });
|
|
157
|
+
const item: PlanDatabase = { ref: id, name: str(row['name']) ?? str(d['name']) ?? id, engine: kind.engine, image: str(row['dockerImage']) ?? null, issues };
|
|
158
|
+
// Dokploy runs each database as a Swarm service named after its appName.
|
|
159
|
+
const appName = str(row['appName']) ?? str(d['appName']);
|
|
160
|
+
if (appName) item.runtime = { kind: 'swarm-service', name: appName };
|
|
161
|
+
plan.databases.push(item);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return plan;
|
|
167
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A1: an import plan from a Heroku app's own files: `app.json` and the
|
|
3
|
+
* `Procfile` in a checkout of its repository. No Heroku API and no
|
|
4
|
+
* credential: the mapping follows Heroku's documented formats
|
|
5
|
+
* (devcenter: app-json-schema, procfile).
|
|
6
|
+
*
|
|
7
|
+
* - Every Procfile process except `release` becomes a service built from the
|
|
8
|
+
* repository with Nixpacks, whose start command is that process's line.
|
|
9
|
+
* Nixpacks is the build pack that honours a per-service start command, runs
|
|
10
|
+
* it through a shell (so `$PORT` expands) and sets PORT to the service port
|
|
11
|
+
* (checked live on a NineDeploy panel; the heroku/paketo buildpacks and
|
|
12
|
+
* Dockerfile builds ignore the start command).
|
|
13
|
+
* - A process other than `web` gets the `running` health check (it answers
|
|
14
|
+
* no HTTP), no port routing and no domain.
|
|
15
|
+
* - `release` is not mapped to NineDeploy's pre-deploy hook, which runs in
|
|
16
|
+
* the panel's own container before the build, without a shell (checked
|
|
17
|
+
* live). Nixpacks itself runs the Procfile's release line as a build step
|
|
18
|
+
* of every service built from the repository, with the attached
|
|
19
|
+
* DATABASE_URL (checked live), so it is flagged with what that means.
|
|
20
|
+
* - Nixpacks reads the Procfile itself, as YAML: a line with `: ` inside its
|
|
21
|
+
* command fails the build (checked live), so such lines are flagged.
|
|
22
|
+
* - `heroku-postgresql` and `heroku-redis` add-ons become databases attached
|
|
23
|
+
* to every service under DATABASE_URL / REDIS_URL, as Heroku sets them.
|
|
24
|
+
* - Config var names come from app.json `env`; values only ever come from a
|
|
25
|
+
* .env file at apply time (`heroku config --shell`).
|
|
26
|
+
*/
|
|
27
|
+
import { type ImportPlan, type PlanDatabase, type PlanEngine, type PlanIssue, type PlanService, emptyPlan } from './plan.js';
|
|
28
|
+
import { isPlainName } from './shell.js';
|
|
29
|
+
|
|
30
|
+
type Obj = Record<string, unknown>;
|
|
31
|
+
const obj = (v: unknown): Obj => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : {});
|
|
32
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
33
|
+
|
|
34
|
+
export interface HerokuFiles {
|
|
35
|
+
/** app.json content, if the repository has one. */
|
|
36
|
+
appJson?: string;
|
|
37
|
+
/** Procfile content, if the repository has one. */
|
|
38
|
+
procfile?: string;
|
|
39
|
+
/** The repository has a heroku.yml (container builds). */
|
|
40
|
+
herokuYml?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface HerokuOptions {
|
|
43
|
+
repoUrl: string;
|
|
44
|
+
branch: string;
|
|
45
|
+
/** The Heroku app name, for the pg:backups command (optional). */
|
|
46
|
+
app?: string;
|
|
47
|
+
/** Fallback service name (the checkout's directory name). */
|
|
48
|
+
dirName: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Procfile process types in file order: `<type>: <command>` lines. */
|
|
52
|
+
export function parseProcfile(content: string): Array<{ type: string; command: string }> {
|
|
53
|
+
const out: Array<{ type: string; command: string }> = [];
|
|
54
|
+
for (const line of content.split(/\r?\n/)) {
|
|
55
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.+?)\s*$/.exec(line);
|
|
56
|
+
if (m && !out.some((p) => p.type === m[1])) out.push({ type: m[1]!, command: m[2]! });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Add-ons NineDeploy runs itself, and the variable Heroku sets for each. */
|
|
62
|
+
const ADDONS: Record<string, { engine: PlanEngine; env: string }> = {
|
|
63
|
+
'heroku-postgresql': { engine: 'postgres', env: 'DATABASE_URL' },
|
|
64
|
+
'heroku-redis': { engine: 'redis', env: 'REDIS_URL' },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const slugName = (s: string): string => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'app';
|
|
68
|
+
|
|
69
|
+
export function planFromHeroku(files: HerokuFiles, opts: HerokuOptions): ImportPlan {
|
|
70
|
+
if (files.appJson === undefined && files.procfile === undefined) {
|
|
71
|
+
throw new Error('Neither app.json nor a Procfile was found: point --dir at a checkout of the Heroku app\'s repository.');
|
|
72
|
+
}
|
|
73
|
+
let app: Obj = {};
|
|
74
|
+
if (files.appJson !== undefined) {
|
|
75
|
+
try {
|
|
76
|
+
app = obj(JSON.parse(files.appJson));
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error('app.json is not valid JSON.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const base = slugName(str(app['name']) ?? opts.dirName);
|
|
82
|
+
const herokuApp = opts.app && isPlainName(opts.app) ? opts.app : undefined;
|
|
83
|
+
const plan = emptyPlan('heroku', opts.repoUrl, null, `the app.json and Procfile of ${opts.repoUrl}@${opts.branch}`);
|
|
84
|
+
plan.notes.push('Heroku mapping: it follows Heroku\'s documented app.json and Procfile formats; no Heroku API is read.');
|
|
85
|
+
|
|
86
|
+
// Add-ons → databases.
|
|
87
|
+
const shared: PlanIssue[] = [];
|
|
88
|
+
const attachments: Array<{ database: string; envAlias: string }> = [];
|
|
89
|
+
for (const raw of Array.isArray(app['addons']) ? app['addons'] : []) {
|
|
90
|
+
const plan_ = typeof raw === 'string' ? raw : str(obj(raw)['plan']);
|
|
91
|
+
if (!plan_) continue;
|
|
92
|
+
const service = plan_.split(':')[0]!;
|
|
93
|
+
const known = ADDONS[service];
|
|
94
|
+
if (!known) {
|
|
95
|
+
shared.push({ level: 'unsupported', option: 'addon', detail: `${plan_} has no NineDeploy equivalent; run it elsewhere and set its variables by hand.` });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (plan.databases.some((d) => d.engine === known.engine)) continue;
|
|
99
|
+
const ref = `${base}-${known.engine}`;
|
|
100
|
+
const issues: PlanIssue[] = [
|
|
101
|
+
{ level: 'manual', option: 'data', detail: 'Data is not moved by this step: create the database, then restore a dump into it (step 3).' },
|
|
102
|
+
];
|
|
103
|
+
if (known.engine === 'postgres') {
|
|
104
|
+
issues.push({ level: 'manual', option: 'ssl', detail: 'Heroku Postgres requires SSL, so apps often force it in code (sslmode=require, ssl_require=True). The NineDeploy database is reached over the internal Docker network, which has no TLS: drop that requirement for this connection.' });
|
|
105
|
+
}
|
|
106
|
+
const db: PlanDatabase = { ref, name: ref, engine: known.engine, image: null, issues };
|
|
107
|
+
if (herokuApp) db.runtime = { kind: 'heroku-app', name: herokuApp };
|
|
108
|
+
plan.databases.push(db);
|
|
109
|
+
attachments.push({ database: ref, envAlias: known.env });
|
|
110
|
+
}
|
|
111
|
+
if (plan.databases.length && !herokuApp) {
|
|
112
|
+
shared.push({ level: 'info', option: 'app', detail: 'Pass --app <heroku app name> to get a ready pg:backups command in import data.' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Config vars → names.
|
|
116
|
+
const env: PlanService['env'] = [];
|
|
117
|
+
for (const [key, v] of Object.entries(obj(app['env']))) {
|
|
118
|
+
if (attachments.some((a) => a.envAlias === key)) continue;
|
|
119
|
+
env.push({ key });
|
|
120
|
+
if (obj(v)['generator'] === 'secret') shared.push({ level: 'manual', option: 'env', detail: `${key} is generated on Heroku (generator: secret): put its current value in the .env file, or a new random one.` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Build notes.
|
|
124
|
+
const buildpacks = Array.isArray(app['buildpacks']) ? app['buildpacks'].map((b) => str(obj(b)['url']) ?? '').filter(Boolean) : [];
|
|
125
|
+
if (buildpacks.length) {
|
|
126
|
+
shared.push({ level: 'info', option: 'buildpacks', detail: `Built with Nixpacks, which detects the language itself; ${buildpacks.join(', ')} ${buildpacks.length === 1 ? 'is' : 'are'} not used. Add a nixpacks.toml if detection differs.` });
|
|
127
|
+
}
|
|
128
|
+
if (files.herokuYml) shared.push({ level: 'manual', option: 'heroku.yml', detail: 'heroku.yml builds a container: set the build pack to Dockerfile and its path under Service → Settings.' });
|
|
129
|
+
if (str(obj(app['scripts'])['postdeploy'])) {
|
|
130
|
+
shared.push({ level: 'manual', option: 'postdeploy', detail: 'scripts.postdeploy runs once on Heroku after the app is created: run it by hand after the first deploy (it is not a per-deploy hook).' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const formation = obj(app['formation']);
|
|
134
|
+
const procs = files.procfile !== undefined ? parseProcfile(files.procfile) : [];
|
|
135
|
+
const release = procs.find((p) => p.type === 'release');
|
|
136
|
+
const runnable = procs.filter((p) => p.type !== 'release');
|
|
137
|
+
if (runnable.length === 0) runnable.push({ type: 'web', command: '' });
|
|
138
|
+
for (const p of runnable) {
|
|
139
|
+
const isWeb = p.type === 'web';
|
|
140
|
+
const name = isWeb ? base : `${base}-${slugName(p.type)}`;
|
|
141
|
+
const issues: PlanIssue[] = [...shared];
|
|
142
|
+
const create: PlanService['create'] = {
|
|
143
|
+
name, type: 'docker', repoUrl: opts.repoUrl, branch: opts.branch,
|
|
144
|
+
build: { buildPack: 'nixpacks', ...(p.command ? { startCmd: p.command } : {}) },
|
|
145
|
+
};
|
|
146
|
+
if (isWeb) {
|
|
147
|
+
create.port = 5000;
|
|
148
|
+
issues.push({ level: 'info', option: 'PORT', detail: 'NineDeploy sets PORT to the service port (5000) for a Nixpacks build, as Heroku sets $PORT.' });
|
|
149
|
+
if (release) {
|
|
150
|
+
issues.push({ level: 'manual', option: 'release', detail: `Nixpacks runs the Procfile's release line ("${release.command}") as a build step of every service built from this repository, with the attached DATABASE_URL, so it runs before each deploy of each of them, not once. Make sure that is safe (a migration usually is), or take it out of the Procfile and run it by hand. A $VARIABLE in it is built into the image with its value (the deploy log masks secret values).` });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!p.command) issues.push({ level: 'info', option: 'Procfile', detail: 'No Procfile: Nixpacks picks the start command.' });
|
|
154
|
+
if (/: /.test(p.command)) issues.push({ level: 'manual', option: 'Procfile', detail: 'Nixpacks reads the Procfile as YAML, and ": " inside this command breaks it: quote the command in the Procfile, or the build fails.' });
|
|
155
|
+
const q = Number(obj(formation[p.type])['quantity']);
|
|
156
|
+
if (Number.isInteger(q) && q > 1) create.replicas = Math.min(q, 10);
|
|
157
|
+
if (Number.isInteger(q) && q > 10) issues.push({ level: 'manual', option: 'formation', detail: `${q} dynos; NineDeploy runs at most 10 replicas of a service.` });
|
|
158
|
+
if (Number.isInteger(q) && q === 0) issues.push({ level: 'info', option: 'formation', detail: 'Scaled to 0 on Heroku; NineDeploy runs one when it deploys the service.' });
|
|
159
|
+
const size = str(obj(formation[p.type])['size']);
|
|
160
|
+
if (size) issues.push({ level: 'info', option: 'size', detail: `Dyno size ${size}: set a memory limit under Service → Settings if you need one.` });
|
|
161
|
+
const svc: PlanService = { ref: p.type, name, kind: 'git', create, domains: [], env: [...env], volumes: [], issues };
|
|
162
|
+
if (!isWeb) svc.settings = { healthCheck: 'running' };
|
|
163
|
+
if (attachments.length) svc.attachments = attachments.map((a) => ({ ...a }));
|
|
164
|
+
plan.services.push(svc);
|
|
165
|
+
}
|
|
166
|
+
plan.notes.push('Domains are not in app.json: add them with import cutover after editing the plan, or under Service → Domains.');
|
|
167
|
+
return plan;
|
|
168
|
+
}
|