@penpair/docker 0.1.3 → 0.3.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/cli.js +3 -1
- package/dist/index.js +7 -2
- package/dist/init.js +38 -11
- package/dist/lifecycle.js +302 -62
- package/dist/secrets.js +15 -5
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
import { runCli } from "@penpair/core";
|
|
3
3
|
import { dockerProfile } from "./index.js";
|
|
4
4
|
// The same `penpair` command as @penpair/core, with the commands that run Penpot in Docker added.
|
|
5
|
-
process.exitCode = await runCli(process.argv.slice(2), {
|
|
5
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
6
|
+
local: dockerProfile,
|
|
7
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -10,9 +10,14 @@ export { renderCompose } from "./template.js";
|
|
|
10
10
|
/** The Docker way of running Penpot: what the `penpair` command is started with. */
|
|
11
11
|
export const dockerProfile = {
|
|
12
12
|
async preflight(runner) {
|
|
13
|
-
const version = await runner.run("docker", [
|
|
14
|
-
|
|
13
|
+
const version = await runner.run("docker", [
|
|
14
|
+
"version",
|
|
15
|
+
"--format",
|
|
16
|
+
"{{.Server.Version}}",
|
|
17
|
+
]);
|
|
18
|
+
if (version.code !== 0) {
|
|
15
19
|
throw new ConfigError("Docker is not running. Start Docker Desktop, wait until it says it is running, then run the command again.");
|
|
20
|
+
}
|
|
16
21
|
},
|
|
17
22
|
init: initDocker,
|
|
18
23
|
up,
|
package/dist/init.js
CHANGED
|
@@ -13,12 +13,17 @@ export async function isPortFree(port, host = "127.0.0.1") {
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
export async function firstFreePort(from, avoid = [], free = isPortFree) {
|
|
16
|
-
for (let port = from; port < from + 200; port++)
|
|
17
|
-
if (!avoid.includes(port) && (await free(port)))
|
|
16
|
+
for (let port = from; port < from + 200; port++) {
|
|
17
|
+
if (!avoid.includes(port) && (await free(port))) {
|
|
18
18
|
return port;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
19
21
|
throw new Error(`no free port from ${from}`);
|
|
20
22
|
}
|
|
21
|
-
const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, {
|
|
23
|
+
const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, {
|
|
24
|
+
directory: true,
|
|
25
|
+
comment: "penpair: generated compose file, secrets and backups",
|
|
26
|
+
});
|
|
22
27
|
/**
|
|
23
28
|
* Sets an instance up in a project: config (committed), then the generated compose file and secrets (in a git-ignored
|
|
24
29
|
* directory). Running it again rewrites the compose file from the config; existing secrets are never replaced.
|
|
@@ -30,27 +35,43 @@ export async function initDocker(root, options = {}) {
|
|
|
30
35
|
config = await loadConfig(root);
|
|
31
36
|
}
|
|
32
37
|
catch (error) {
|
|
33
|
-
if (!(error instanceof ConfigError) ||
|
|
38
|
+
if (!(error instanceof ConfigError) || error.code !== "missing") {
|
|
34
39
|
throw error;
|
|
40
|
+
}
|
|
35
41
|
const free = options.isFree ?? isPortFree;
|
|
36
42
|
const frontend = options.port ?? (await firstFreePort(9001, [], free));
|
|
37
|
-
const mail = options.mailPort ??
|
|
43
|
+
const mail = options.mailPort ??
|
|
44
|
+
(await firstFreePort(1080, [
|
|
45
|
+
frontend,
|
|
46
|
+
], free));
|
|
38
47
|
config = validateConfig({
|
|
39
48
|
instance: options.instance ?? defaultInstanceName(root),
|
|
40
49
|
penpotVersion: options.penpotVersion ?? testedPenpotVersion,
|
|
41
|
-
ports: {
|
|
50
|
+
ports: {
|
|
51
|
+
frontend,
|
|
52
|
+
mail,
|
|
53
|
+
},
|
|
42
54
|
directory: options.directory ?? ".penpair",
|
|
43
|
-
...(options.composeProject
|
|
55
|
+
...(options.composeProject
|
|
56
|
+
? {
|
|
57
|
+
composeProject: options.composeProject,
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
44
60
|
});
|
|
45
61
|
await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
|
|
46
62
|
createdConfig = true;
|
|
47
63
|
}
|
|
48
|
-
if (isExternal(config))
|
|
64
|
+
if (isExternal(config)) {
|
|
49
65
|
throw new ConfigError("This project uses a Penpot that someone else runs; there is nothing for Docker to set up.");
|
|
66
|
+
}
|
|
50
67
|
const paths = instancePaths(root, config);
|
|
51
|
-
await mkdir(paths.directory, {
|
|
68
|
+
await mkdir(paths.directory, {
|
|
69
|
+
recursive: true,
|
|
70
|
+
});
|
|
52
71
|
await writeFile(paths.compose, renderCompose(config));
|
|
53
|
-
const files = [
|
|
72
|
+
const files = [
|
|
73
|
+
paths.compose,
|
|
74
|
+
];
|
|
54
75
|
const createdSecrets = await writeSecretFile(paths.env, renderEnv(config, generateSecrets()));
|
|
55
76
|
files.push(paths.env);
|
|
56
77
|
let gitignoreAdded = false;
|
|
@@ -58,5 +79,11 @@ export async function initDocker(root, options = {}) {
|
|
|
58
79
|
const directoryAdded = await ensureGitignore(root, config.directory);
|
|
59
80
|
gitignoreAdded = (await ensureKeyFileIgnored(root)) || directoryAdded;
|
|
60
81
|
}
|
|
61
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
config,
|
|
84
|
+
createdConfig,
|
|
85
|
+
createdSecrets,
|
|
86
|
+
files,
|
|
87
|
+
gitignoreAdded,
|
|
88
|
+
};
|
|
62
89
|
}
|
package/dist/lifecycle.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { composeProjectOf, endpointIsInstance, frontendUrl, instancePaths, LifecycleError, } from "@penpair/core";
|
|
5
|
+
import { composeProjectOf, endpointIsInstance, frontendUrl, doctorEditor, instancePaths, LifecycleError, } from "@penpair/core";
|
|
6
6
|
import { readEnvValues } from "./secrets.js";
|
|
7
7
|
const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
8
8
|
const sleepOf = (c) => c.sleep ?? defaultSleep;
|
|
@@ -10,12 +10,22 @@ const say = (c, message) => c.log?.(message);
|
|
|
10
10
|
/** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
|
|
11
11
|
export function composeArgs(c, ...rest) {
|
|
12
12
|
const paths = instancePaths(c.root, c.config);
|
|
13
|
-
return [
|
|
13
|
+
return [
|
|
14
|
+
"compose",
|
|
15
|
+
"-p",
|
|
16
|
+
composeProjectOf(c.config),
|
|
17
|
+
"-f",
|
|
18
|
+
paths.compose,
|
|
19
|
+
"--env-file",
|
|
20
|
+
paths.env,
|
|
21
|
+
...rest,
|
|
22
|
+
];
|
|
14
23
|
}
|
|
15
24
|
async function docker(c, args, what) {
|
|
16
25
|
const result = await c.runner.run("docker", args);
|
|
17
|
-
if (result.code !== 0)
|
|
26
|
+
if (result.code !== 0) {
|
|
18
27
|
throw new LifecycleError(`${what} failed: ${result.stderr.trim().split("\n").slice(-3).join(" ") || `exit ${result.code}`}`);
|
|
28
|
+
}
|
|
19
29
|
return result.stdout;
|
|
20
30
|
}
|
|
21
31
|
/** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
|
|
@@ -24,9 +34,12 @@ export async function waitForHttp(c, url, timeoutMs = 240_000, stepMs = 3_000) {
|
|
|
24
34
|
const doFetch = c.fetch ?? fetch;
|
|
25
35
|
for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
|
|
26
36
|
try {
|
|
27
|
-
const response = await doFetch(url, {
|
|
28
|
-
|
|
37
|
+
const response = await doFetch(url, {
|
|
38
|
+
redirect: "manual",
|
|
39
|
+
});
|
|
40
|
+
if (response.status < 500) {
|
|
29
41
|
return true;
|
|
42
|
+
}
|
|
30
43
|
}
|
|
31
44
|
catch {
|
|
32
45
|
// not up yet
|
|
@@ -39,7 +52,10 @@ export async function up(c) {
|
|
|
39
52
|
say(c, "starting the containers");
|
|
40
53
|
await docker(c, composeArgs(c, "up", "-d"), "docker compose up");
|
|
41
54
|
say(c, "waiting for Penpot to answer (the first start can take a few minutes)");
|
|
42
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
url: frontendUrl(c.config),
|
|
57
|
+
ready: await waitForHttp(c, readyUrl(c.config)),
|
|
58
|
+
};
|
|
43
59
|
}
|
|
44
60
|
/** Stops and removes the containers. Volumes are never removed: the design data stays. */
|
|
45
61
|
export async function down(c) {
|
|
@@ -48,14 +64,21 @@ export async function down(c) {
|
|
|
48
64
|
export function parseComposePs(output) {
|
|
49
65
|
const rows = [];
|
|
50
66
|
const text = output.trim();
|
|
51
|
-
if (text.startsWith("["))
|
|
67
|
+
if (text.startsWith("[")) {
|
|
52
68
|
rows.push(...JSON.parse(text));
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
55
72
|
rows.push(JSON.parse(line));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
return rows.map((row) => {
|
|
57
76
|
const r = row;
|
|
58
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
service: r.Service ?? r.Name ?? "?",
|
|
79
|
+
state: r.State ?? "?",
|
|
80
|
+
health: r.Health ?? "",
|
|
81
|
+
};
|
|
59
82
|
});
|
|
60
83
|
}
|
|
61
84
|
export async function status(c) {
|
|
@@ -64,29 +87,50 @@ export async function status(c) {
|
|
|
64
87
|
const url = frontendUrl(c.config);
|
|
65
88
|
const answers = async (target, ok) => {
|
|
66
89
|
try {
|
|
67
|
-
return ok((await (c.fetch ?? fetch)(target, {
|
|
90
|
+
return ok((await (c.fetch ?? fetch)(target, {
|
|
91
|
+
redirect: "manual",
|
|
92
|
+
})).status);
|
|
68
93
|
}
|
|
69
94
|
catch {
|
|
70
95
|
return false;
|
|
71
96
|
}
|
|
72
97
|
};
|
|
73
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
services,
|
|
100
|
+
frontendReachable: await answers(url, (code) => code < 500),
|
|
101
|
+
backendReady: await answers(readyUrl(c.config), (code) => code === 200),
|
|
102
|
+
url,
|
|
103
|
+
};
|
|
74
104
|
}
|
|
75
105
|
async function containerOf(c, service) {
|
|
76
|
-
const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`))
|
|
77
|
-
|
|
106
|
+
const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`))
|
|
107
|
+
.trim()
|
|
108
|
+
.split("\n")[0];
|
|
109
|
+
if (!id) {
|
|
78
110
|
throw new LifecycleError(`The ${service} container is not running. Run \`penpair up\` first.`);
|
|
111
|
+
}
|
|
79
112
|
return id;
|
|
80
113
|
}
|
|
81
114
|
const countsSql = `select json_build_object('files',(select count(*) from file where deleted_at is null),'profiles',(select count(*) from profile),'teams',(select count(*) from team),'storageObjects',(select count(*) from storage_object),'fileRevisions',(select coalesce(json_agg(json_build_object('id',id,'name',name,'revn',revn) order by name,id),'[]'::json) from file where deleted_at is null))`;
|
|
82
115
|
async function readCounts(c, container) {
|
|
83
|
-
const out = await docker(c, [
|
|
116
|
+
const out = await docker(c, [
|
|
117
|
+
"exec",
|
|
118
|
+
container,
|
|
119
|
+
"psql",
|
|
120
|
+
"-U",
|
|
121
|
+
"penpot",
|
|
122
|
+
"-d",
|
|
123
|
+
"penpot",
|
|
124
|
+
"-tAc",
|
|
125
|
+
countsSql,
|
|
126
|
+
], "reading the database counts");
|
|
84
127
|
return JSON.parse(out.trim());
|
|
85
128
|
}
|
|
86
129
|
async function sha256File(file) {
|
|
87
130
|
const hash = createHash("sha256");
|
|
88
|
-
for await (const chunk of createReadStream(file))
|
|
131
|
+
for await (const chunk of createReadStream(file)) {
|
|
89
132
|
hash.update(chunk);
|
|
133
|
+
}
|
|
90
134
|
return hash.digest("hex");
|
|
91
135
|
}
|
|
92
136
|
/**
|
|
@@ -97,29 +141,69 @@ export async function backup(c, label) {
|
|
|
97
141
|
const paths = instancePaths(c.root, c.config);
|
|
98
142
|
const stamp = (c.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
|
|
99
143
|
const directory = path.join(paths.backups, label ? `${stamp}-${label}` : stamp);
|
|
100
|
-
await mkdir(directory, {
|
|
144
|
+
await mkdir(directory, {
|
|
145
|
+
recursive: true,
|
|
146
|
+
});
|
|
101
147
|
const postgres = await containerOf(c, "penpot-postgres");
|
|
102
148
|
const backend = await containerOf(c, "penpot-backend");
|
|
103
149
|
say(c, "dumping the database");
|
|
104
|
-
const dump = await c.runner.run("docker", [
|
|
105
|
-
|
|
150
|
+
const dump = await c.runner.run("docker", [
|
|
151
|
+
"exec",
|
|
152
|
+
postgres,
|
|
153
|
+
"pg_dump",
|
|
154
|
+
"-U",
|
|
155
|
+
"penpot",
|
|
156
|
+
"-d",
|
|
157
|
+
"penpot",
|
|
158
|
+
"-Fc",
|
|
159
|
+
], {
|
|
160
|
+
outFile: path.join(directory, "db.dump"),
|
|
161
|
+
});
|
|
162
|
+
if (dump.code !== 0) {
|
|
106
163
|
throw new LifecycleError(`pg_dump failed: ${dump.stderr.trim().slice(-200)}`);
|
|
164
|
+
}
|
|
107
165
|
say(c, "archiving the assets");
|
|
108
|
-
const assets = await c.runner.run("docker", [
|
|
109
|
-
|
|
166
|
+
const assets = await c.runner.run("docker", [
|
|
167
|
+
"exec",
|
|
168
|
+
backend,
|
|
169
|
+
"tar",
|
|
170
|
+
"czf",
|
|
171
|
+
"-",
|
|
172
|
+
"-C",
|
|
173
|
+
"/opt/data/assets",
|
|
174
|
+
".",
|
|
175
|
+
], {
|
|
176
|
+
outFile: path.join(directory, "assets.tgz"),
|
|
177
|
+
});
|
|
178
|
+
if (assets.code !== 0) {
|
|
110
179
|
throw new LifecycleError(`archiving the assets failed: ${assets.stderr.trim().slice(-200)}`);
|
|
111
|
-
|
|
180
|
+
}
|
|
181
|
+
const entry = async (name) => ({
|
|
182
|
+
name,
|
|
183
|
+
sha256: await sha256File(path.join(directory, name)),
|
|
184
|
+
bytes: (await stat(path.join(directory, name))).size,
|
|
185
|
+
});
|
|
112
186
|
const manifest = {
|
|
113
187
|
instance: c.config.instance,
|
|
114
188
|
penpotVersion: c.config.penpotVersion,
|
|
115
189
|
createdAt: (c.now?.() ?? new Date()).toISOString(),
|
|
116
|
-
...(label
|
|
117
|
-
|
|
190
|
+
...(label
|
|
191
|
+
? {
|
|
192
|
+
label,
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
195
|
+
files: {
|
|
196
|
+
db: await entry("db.dump"),
|
|
197
|
+
assets: await entry("assets.tgz"),
|
|
198
|
+
},
|
|
118
199
|
counts: await readCounts(c, postgres),
|
|
119
200
|
note: "Contains account data (password hashes). Never commit it. Keep the instance's .env with it.",
|
|
120
201
|
};
|
|
121
202
|
await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
122
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
directory,
|
|
205
|
+
manifest,
|
|
206
|
+
};
|
|
123
207
|
}
|
|
124
208
|
async function loadManifest(directory) {
|
|
125
209
|
try {
|
|
@@ -131,7 +215,10 @@ async function loadManifest(directory) {
|
|
|
131
215
|
}
|
|
132
216
|
async function checksumChecks(directory, manifest) {
|
|
133
217
|
const checks = [];
|
|
134
|
-
for (const key of [
|
|
218
|
+
for (const key of [
|
|
219
|
+
"db",
|
|
220
|
+
"assets",
|
|
221
|
+
]) {
|
|
135
222
|
const wanted = manifest.files[key];
|
|
136
223
|
let actual = "missing";
|
|
137
224
|
try {
|
|
@@ -140,13 +227,32 @@ async function checksumChecks(directory, manifest) {
|
|
|
140
227
|
catch {
|
|
141
228
|
// reported below
|
|
142
229
|
}
|
|
143
|
-
checks.push({
|
|
230
|
+
checks.push({
|
|
231
|
+
check: `${wanted.name} checksum`,
|
|
232
|
+
ok: actual === wanted.sha256,
|
|
233
|
+
detail: actual === wanted.sha256
|
|
234
|
+
? "matches the manifest"
|
|
235
|
+
: `expected ${wanted.sha256.slice(0, 12)}, found ${actual.slice(0, 12)}`,
|
|
236
|
+
});
|
|
144
237
|
}
|
|
145
238
|
return checks;
|
|
146
239
|
}
|
|
147
240
|
const compareCounts = (wanted, found) => [
|
|
148
|
-
...[
|
|
149
|
-
|
|
241
|
+
...[
|
|
242
|
+
"files",
|
|
243
|
+
"profiles",
|
|
244
|
+
"teams",
|
|
245
|
+
"storageObjects",
|
|
246
|
+
].map((key) => ({
|
|
247
|
+
check: `restored ${key}`,
|
|
248
|
+
ok: wanted[key] === found[key],
|
|
249
|
+
detail: `${found[key]} (backup says ${wanted[key]})`,
|
|
250
|
+
})),
|
|
251
|
+
{
|
|
252
|
+
check: "file revisions",
|
|
253
|
+
ok: JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions),
|
|
254
|
+
detail: `${found.fileRevisions.length} files, ids, names and revisions ${JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions) ? "match" : "differ"}`,
|
|
255
|
+
},
|
|
150
256
|
];
|
|
151
257
|
/** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
|
|
152
258
|
export async function verifyBackup(c, directory) {
|
|
@@ -154,39 +260,93 @@ export async function verifyBackup(c, directory) {
|
|
|
154
260
|
const checks = await checksumChecks(directory, manifest);
|
|
155
261
|
const name = `penpair-verify-${Math.random().toString(36).slice(2, 8)}`;
|
|
156
262
|
say(c, "starting a throwaway Postgres");
|
|
157
|
-
await docker(c, [
|
|
263
|
+
await docker(c, [
|
|
264
|
+
"run",
|
|
265
|
+
"-d",
|
|
266
|
+
"--name",
|
|
267
|
+
name,
|
|
268
|
+
"-e",
|
|
269
|
+
"POSTGRES_USER=penpot",
|
|
270
|
+
"-e",
|
|
271
|
+
"POSTGRES_PASSWORD=verify",
|
|
272
|
+
"-e",
|
|
273
|
+
"POSTGRES_DB=penpot",
|
|
274
|
+
"-v",
|
|
275
|
+
`${path.resolve(directory)}:/backup:ro`,
|
|
276
|
+
"postgres:15",
|
|
277
|
+
], "starting the throwaway Postgres");
|
|
158
278
|
try {
|
|
159
279
|
let ready = false;
|
|
160
280
|
for (let i = 0; i < 40 && !ready; i++) {
|
|
161
|
-
ready =
|
|
162
|
-
|
|
281
|
+
ready =
|
|
282
|
+
(await c.runner.run("docker", [
|
|
283
|
+
"exec",
|
|
284
|
+
name,
|
|
285
|
+
"pg_isready",
|
|
286
|
+
"-U",
|
|
287
|
+
"penpot",
|
|
288
|
+
"-d",
|
|
289
|
+
"penpot",
|
|
290
|
+
])).code === 0;
|
|
291
|
+
if (!ready) {
|
|
163
292
|
await sleepOf(c)(1_000);
|
|
293
|
+
}
|
|
164
294
|
}
|
|
165
|
-
if (!ready)
|
|
295
|
+
if (!ready) {
|
|
166
296
|
throw new LifecycleError("the throwaway Postgres did not become ready");
|
|
297
|
+
}
|
|
167
298
|
say(c, "restoring the dump");
|
|
168
|
-
await c.runner.run("docker", [
|
|
299
|
+
await c.runner.run("docker", [
|
|
300
|
+
"exec",
|
|
301
|
+
name,
|
|
302
|
+
"pg_restore",
|
|
303
|
+
"-U",
|
|
304
|
+
"penpot",
|
|
305
|
+
"-d",
|
|
306
|
+
"penpot",
|
|
307
|
+
"--no-owner",
|
|
308
|
+
"/backup/db.dump",
|
|
309
|
+
]);
|
|
169
310
|
checks.push(...compareCounts(manifest.counts, await readCounts(c, name)));
|
|
170
|
-
const listing = await c.runner.run("tar", [
|
|
311
|
+
const listing = await c.runner.run("tar", [
|
|
312
|
+
"tzf",
|
|
313
|
+
path.join(directory, manifest.files.assets.name),
|
|
314
|
+
]);
|
|
171
315
|
const entries = listing.stdout.split("\n").filter((line) => line && !line.endsWith("/")).length;
|
|
172
|
-
checks.push({
|
|
316
|
+
checks.push({
|
|
317
|
+
check: "asset files",
|
|
318
|
+
ok: entries === manifest.counts.storageObjects,
|
|
319
|
+
detail: `${entries} files in the tarball, ${manifest.counts.storageObjects} storage objects`,
|
|
320
|
+
});
|
|
173
321
|
}
|
|
174
322
|
finally {
|
|
175
|
-
await c.runner.run("docker", [
|
|
323
|
+
await c.runner.run("docker", [
|
|
324
|
+
"rm",
|
|
325
|
+
"-f",
|
|
326
|
+
name,
|
|
327
|
+
]);
|
|
176
328
|
}
|
|
177
|
-
return {
|
|
329
|
+
return {
|
|
330
|
+
ok: checks.every((check) => check.ok),
|
|
331
|
+
checks,
|
|
332
|
+
};
|
|
178
333
|
}
|
|
179
334
|
/**
|
|
180
335
|
* Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
|
|
181
336
|
* not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
|
|
182
337
|
*/
|
|
183
338
|
export async function restore(c, directory, options) {
|
|
184
|
-
if (!options.yes)
|
|
339
|
+
if (!options.yes) {
|
|
185
340
|
throw new LifecycleError("Restoring replaces this instance's data. Run again with --yes to confirm.");
|
|
341
|
+
}
|
|
186
342
|
const manifest = await loadManifest(directory);
|
|
187
343
|
const checks = await checksumChecks(directory, manifest);
|
|
188
|
-
if (!checks.every((check) => check.ok))
|
|
189
|
-
return {
|
|
344
|
+
if (!checks.every((check) => check.ok)) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
checks,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
190
350
|
let safetyBackup;
|
|
191
351
|
if (!options.skipSafetyBackup) {
|
|
192
352
|
try {
|
|
@@ -194,8 +354,9 @@ export async function restore(c, directory, options) {
|
|
|
194
354
|
safetyBackup = (await backup(c, "pre-restore")).directory;
|
|
195
355
|
}
|
|
196
356
|
catch (error) {
|
|
197
|
-
if (!(error instanceof LifecycleError) || !/not running/.test(error.message))
|
|
357
|
+
if (!(error instanceof LifecycleError) || !/not running/.test(error.message)) {
|
|
198
358
|
throw error;
|
|
359
|
+
}
|
|
199
360
|
say(c, "the instance is not running, so there is no current data to back up");
|
|
200
361
|
}
|
|
201
362
|
}
|
|
@@ -204,39 +365,119 @@ export async function restore(c, directory, options) {
|
|
|
204
365
|
await docker(c, composeArgs(c, "up", "-d", "penpot-postgres", "penpot-valkey"), "starting the database");
|
|
205
366
|
const postgres = await containerOf(c, "penpot-postgres");
|
|
206
367
|
for (let i = 0; i < 60; i++) {
|
|
207
|
-
if ((await c.runner.run("docker", [
|
|
368
|
+
if ((await c.runner.run("docker", [
|
|
369
|
+
"exec",
|
|
370
|
+
postgres,
|
|
371
|
+
"pg_isready",
|
|
372
|
+
"-U",
|
|
373
|
+
"penpot",
|
|
374
|
+
"-d",
|
|
375
|
+
"penpot",
|
|
376
|
+
])).code === 0) {
|
|
208
377
|
break;
|
|
378
|
+
}
|
|
209
379
|
await sleepOf(c)(1_000);
|
|
210
380
|
}
|
|
211
381
|
say(c, "replacing the database");
|
|
212
|
-
await docker(c, [
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
382
|
+
await docker(c, [
|
|
383
|
+
"exec",
|
|
384
|
+
postgres,
|
|
385
|
+
"psql",
|
|
386
|
+
"-U",
|
|
387
|
+
"penpot",
|
|
388
|
+
"-d",
|
|
389
|
+
"postgres",
|
|
390
|
+
"-c",
|
|
391
|
+
"drop database penpot with (force)",
|
|
392
|
+
], "dropping the database");
|
|
393
|
+
await docker(c, [
|
|
394
|
+
"exec",
|
|
395
|
+
postgres,
|
|
396
|
+
"psql",
|
|
397
|
+
"-U",
|
|
398
|
+
"penpot",
|
|
399
|
+
"-d",
|
|
400
|
+
"postgres",
|
|
401
|
+
"-c",
|
|
402
|
+
"create database penpot",
|
|
403
|
+
], "creating the database");
|
|
404
|
+
const loaded = await c.runner.run("docker", [
|
|
405
|
+
"exec",
|
|
406
|
+
"-i",
|
|
407
|
+
postgres,
|
|
408
|
+
"pg_restore",
|
|
409
|
+
"-U",
|
|
410
|
+
"penpot",
|
|
411
|
+
"-d",
|
|
412
|
+
"penpot",
|
|
413
|
+
"--no-owner",
|
|
414
|
+
], {
|
|
415
|
+
inFile: path.join(directory, manifest.files.db.name),
|
|
416
|
+
});
|
|
417
|
+
if (loaded.code !== 0) {
|
|
216
418
|
throw new LifecycleError(`pg_restore failed: ${loaded.stderr.trim().slice(-200)}`);
|
|
419
|
+
}
|
|
217
420
|
say(c, "replacing the assets");
|
|
218
421
|
const volume = `${composeProjectOf(c.config)}_penpot_assets`;
|
|
219
|
-
const assets = await c.runner.run("docker", [
|
|
220
|
-
|
|
422
|
+
const assets = await c.runner.run("docker", [
|
|
423
|
+
"run",
|
|
424
|
+
"--rm",
|
|
425
|
+
"-i",
|
|
426
|
+
"-v",
|
|
427
|
+
`${volume}:/data`,
|
|
428
|
+
"--entrypoint",
|
|
429
|
+
"sh",
|
|
430
|
+
`penpotapp/backend:${c.config.penpotVersion}`,
|
|
431
|
+
"-c",
|
|
432
|
+
"find /data -mindepth 1 -delete && tar xzf - -C /data",
|
|
433
|
+
], {
|
|
434
|
+
inFile: path.join(directory, manifest.files.assets.name),
|
|
435
|
+
});
|
|
436
|
+
if (assets.code !== 0) {
|
|
221
437
|
throw new LifecycleError(`restoring the assets failed: ${assets.stderr.trim().slice(-200)}`);
|
|
438
|
+
}
|
|
222
439
|
say(c, "starting everything again");
|
|
223
440
|
await up(c);
|
|
224
441
|
checks.push(...compareCounts(manifest.counts, await readCounts(c, await containerOf(c, "penpot-postgres"))));
|
|
225
|
-
return {
|
|
442
|
+
return {
|
|
443
|
+
ok: checks.every((check) => check.ok),
|
|
444
|
+
checks,
|
|
445
|
+
...(safetyBackup
|
|
446
|
+
? {
|
|
447
|
+
safetyBackup,
|
|
448
|
+
}
|
|
449
|
+
: {}),
|
|
450
|
+
};
|
|
226
451
|
}
|
|
227
452
|
/** What is up, what is not, and what to do about it. */
|
|
228
453
|
export async function doctor(c, options = {}) {
|
|
229
454
|
const out = [];
|
|
230
|
-
const add = (check, ok, detail, remedy) => void out.push({
|
|
231
|
-
|
|
455
|
+
const add = (check, ok, detail, remedy) => void out.push({
|
|
456
|
+
check,
|
|
457
|
+
ok,
|
|
458
|
+
detail,
|
|
459
|
+
...(remedy && !ok
|
|
460
|
+
? {
|
|
461
|
+
remedy,
|
|
462
|
+
}
|
|
463
|
+
: {}),
|
|
464
|
+
});
|
|
465
|
+
const version = await c.runner.run("docker", [
|
|
466
|
+
"version",
|
|
467
|
+
"--format",
|
|
468
|
+
"{{.Server.Version}}",
|
|
469
|
+
]);
|
|
232
470
|
add("docker", version.code === 0, version.code === 0 ? `engine ${version.stdout.trim()}` : "not answering", "Start Docker Desktop (or the Docker daemon).");
|
|
233
|
-
if (version.code !== 0)
|
|
471
|
+
if (version.code !== 0) {
|
|
234
472
|
return out;
|
|
473
|
+
}
|
|
235
474
|
const paths = instancePaths(c.root, c.config);
|
|
236
475
|
try {
|
|
237
476
|
const mode = (await stat(paths.env)).mode & 0o777;
|
|
238
477
|
const values = await readEnvValues(paths.env);
|
|
239
|
-
add("secrets file", mode === 0o600 &&
|
|
478
|
+
add("secrets file", mode === 0o600 &&
|
|
479
|
+
Boolean(values.PENPOT_SECRET_KEY) &&
|
|
480
|
+
Boolean(values.PENPOT_DATABASE_PASSWORD), `permissions ${mode.toString(8)}, keys ${values.PENPOT_SECRET_KEY && values.PENPOT_DATABASE_PASSWORD ? "present" : "missing"}`, "Run `penpair init` to create it, and `chmod 600` the file.");
|
|
240
481
|
}
|
|
241
482
|
catch {
|
|
242
483
|
add("secrets file", false, "missing", "Run `penpair init`.");
|
|
@@ -250,7 +491,12 @@ export async function doctor(c, options = {}) {
|
|
|
250
491
|
}
|
|
251
492
|
if (state) {
|
|
252
493
|
const running = state.services.filter((s) => s.state === "running").map((s) => s.service);
|
|
253
|
-
const needed = [
|
|
494
|
+
const needed = [
|
|
495
|
+
"penpot-frontend",
|
|
496
|
+
"penpot-backend",
|
|
497
|
+
"penpot-postgres",
|
|
498
|
+
"penpot-mcp",
|
|
499
|
+
];
|
|
254
500
|
const missing = needed.filter((service) => !running.includes(service));
|
|
255
501
|
add("containers", missing.length === 0, missing.length === 0 ? `${running.length} running` : `not running: ${missing.join(", ")}`, "Run `penpair up`.");
|
|
256
502
|
add("frontend", state.frontendReachable, state.frontendReachable ? state.url : `${state.url} does not answer`, "Run `penpair up` and wait for Penpot to start.");
|
|
@@ -260,13 +506,7 @@ export async function doctor(c, options = {}) {
|
|
|
260
506
|
add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not this instance (${frontendUrl(c.config)})`, "Put this instance's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL.");
|
|
261
507
|
}
|
|
262
508
|
else if (options.mcp) {
|
|
263
|
-
|
|
264
|
-
const { file } = await options.mcp.connect();
|
|
265
|
-
add("editor and plugin", true, `connected to file "${file}"`);
|
|
266
|
-
}
|
|
267
|
-
catch (error) {
|
|
268
|
-
add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible.");
|
|
269
|
-
}
|
|
509
|
+
out.push(...(await doctorEditor(c.config, options)));
|
|
270
510
|
}
|
|
271
511
|
return out;
|
|
272
512
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -3,7 +3,10 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
/** A 512-bit master key for Penpot and a database password. Generated once; changing the password later breaks an existing database volume. */
|
|
5
5
|
export function generateSecrets() {
|
|
6
|
-
return {
|
|
6
|
+
return {
|
|
7
|
+
secretKey: randomBytes(64).toString("base64url"),
|
|
8
|
+
databasePassword: randomBytes(24).toString("base64url"),
|
|
9
|
+
};
|
|
7
10
|
}
|
|
8
11
|
export function renderEnv(config, secrets) {
|
|
9
12
|
return `# Generated by penpair. Secrets: never commit, never print. Keep this file with the backups; the database password
|
|
@@ -15,13 +18,19 @@ PENPOT_DATABASE_PASSWORD=${secrets.databasePassword}
|
|
|
15
18
|
}
|
|
16
19
|
/** Writes a file readable by its owner only. Refuses to overwrite unless told to. */
|
|
17
20
|
export async function writeSecretFile(file, content, overwrite = false) {
|
|
18
|
-
await mkdir(path.dirname(file), {
|
|
21
|
+
await mkdir(path.dirname(file), {
|
|
22
|
+
recursive: true,
|
|
23
|
+
});
|
|
19
24
|
try {
|
|
20
|
-
await writeFile(file, content, {
|
|
25
|
+
await writeFile(file, content, {
|
|
26
|
+
mode: 0o600,
|
|
27
|
+
flag: overwrite ? "w" : "wx",
|
|
28
|
+
});
|
|
21
29
|
}
|
|
22
30
|
catch (error) {
|
|
23
|
-
if (error.code === "EEXIST")
|
|
31
|
+
if (error.code === "EEXIST") {
|
|
24
32
|
return false;
|
|
33
|
+
}
|
|
25
34
|
throw error;
|
|
26
35
|
}
|
|
27
36
|
await chmod(file, 0o600);
|
|
@@ -31,8 +40,9 @@ export async function readEnvValues(file) {
|
|
|
31
40
|
const out = {};
|
|
32
41
|
for (const line of (await readFile(file, "utf8")).split(/\r?\n/)) {
|
|
33
42
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
|
|
34
|
-
if (match)
|
|
43
|
+
if (match) {
|
|
35
44
|
out[match[1]] = match[2];
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
47
|
return out;
|
|
38
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/docker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Everything in @penpair/core, plus running Penpot in Docker on your computer: one instance per project, generated secrets, up and down, backup, verify and restore.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@penpair/core": "0.
|
|
45
|
+
"@penpair/core": "0.3.0"
|
|
46
46
|
},
|
|
47
47
|
"bin": {
|
|
48
48
|
"penpair": "dist/cli.js"
|