@gentbajko/slopify 1.5.1 → 2.0.1
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/README.md +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/projection.js +9 -1
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
package/README.md
CHANGED
|
@@ -30,17 +30,40 @@ slopify
|
|
|
30
30
|
|
|
31
31
|
Both commands launch the same local app.
|
|
32
32
|
|
|
33
|
-
For Docker on Linux with
|
|
33
|
+
For Docker on Linux with Node 26+ and util-linux (`flock`), use `slopify --docker` or
|
|
34
34
|
`npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
|
|
35
35
|
Claude Code and Gemini CLIs. It asks once before installing a private host helper
|
|
36
36
|
and enabling automatic startup, including user lingering. The CLIs run on your
|
|
37
37
|
host with their existing logins; credentials never move into Docker.
|
|
38
38
|
|
|
39
|
-
Slopify runs in the background
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
Slopify runs in the background at `http://127.0.0.1:6969` and restarts with Docker.
|
|
40
|
+
The managed Linux launcher saves generated project files in `~/Slopify/Projects`.
|
|
41
|
+
Custom container names use `~/Slopify/<container>/Projects`. The database,
|
|
42
|
+
credentials, logs and staging stay private in the named `slopify-data` volume.
|
|
43
|
+
Run as your normal user with Node 26+, Docker access and util-linux (`flock`).
|
|
44
|
+
Systemd is needed only for the optional host CLI helper. Native installs continue
|
|
45
|
+
using their configured data directory and need no helper.
|
|
46
|
+
|
|
47
|
+
Use `--projects-dir "/path/to/Projects"` or `SLOPIFY_DOCKER_PROJECTS_DIR` to choose
|
|
48
|
+
a dedicated folder. Later launches remember that absolute path when the override
|
|
49
|
+
is omitted. `--port 7070` changes the localhost port. Custom installations sharing
|
|
50
|
+
a machine need distinct `SLOPIFY_DOCKER_NAME` and `SLOPIFY_DOCKER_VOLUME` values.
|
|
51
|
+
Do not store unrelated documents in the managed Projects tree: storage
|
|
52
|
+
reconciliation owns it. Moving to another folder performs a verified copy;
|
|
53
|
+
existing unrelated contents are never merged or overwritten.
|
|
54
|
+
|
|
55
|
+
Existing installations are stopped, copied and verified before the new folder
|
|
56
|
+
is activated. Current and historical output actions show the real host folder
|
|
57
|
+
on the machine running Slopify; downloads remain available. This also works with
|
|
58
|
+
`--host-cli=off`, without installing a host helper. Docker does not open a desktop
|
|
59
|
+
window on your browser's machine.
|
|
60
|
+
|
|
61
|
+
Rerun the launcher after changing host CLI installations or search paths.
|
|
62
|
+
Plain `docker run` is API-only unless connected to an already configured helper.
|
|
63
|
+
It does not set up a host project folder or install host services; its files stay
|
|
64
|
+
in the private named volume. Use the managed launcher for automatic migration
|
|
65
|
+
and host-folder access. Use `--host-cli=off` for API-only launcher setup, or
|
|
66
|
+
`--accept-host-cli` to approve non-interactive setup.
|
|
44
67
|
See the [Docker setup guide](https://github.com/GentBajko/slopify#docker) for
|
|
45
68
|
updates and the dedicated helper's status/disable commands.
|
|
46
69
|
|
|
@@ -85,6 +108,7 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
|
|
|
85
108
|
| `--port` | `SLOPIFY_PORT` | `6969` |
|
|
86
109
|
| `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
|
|
87
110
|
| `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
|
|
111
|
+
| `--projects-dir` (Docker only) | `SLOPIFY_DOCKER_PROJECTS_DIR` | remembered path; otherwise `~/Slopify/Projects` (custom names: `~/Slopify/<container>/Projects`) |
|
|
88
112
|
| `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
|
|
89
113
|
| `--docker` | — | run locally; with the flag, launch Docker with host CLI detection |
|
|
90
114
|
| — | `SLOPIFY_FFMPEG` | the bundled binary |
|
|
@@ -92,8 +116,10 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
|
|
|
92
116
|
There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
|
|
93
117
|
it to whoever reaches the port, and the CLI says so on startup.
|
|
94
118
|
|
|
95
|
-
|
|
96
|
-
|
|
119
|
+
Native installations keep `slopify.db`, `projects/`, `staging/` and `logs/` under
|
|
120
|
+
their configured data directory. Managed Docker places only projects in its
|
|
121
|
+
remembered host folder; keep both that folder and the named private volume when
|
|
122
|
+
backing up an installation. The portable settings archive is not a project/history backup.
|
|
97
123
|
|
|
98
124
|
## ffmpeg and the GPL
|
|
99
125
|
|
|
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Readable } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
8
|
const revision = "a19f851b3d42865797e410752b4c570c871e4825";
|
|
8
9
|
export const alignmentModel = {
|
|
9
10
|
filename: "wav2vec2-base-960h-cd5040c1.onnx",
|
|
@@ -17,22 +18,70 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
|
|
|
17
18
|
const target = join(input.cacheDir, deps.model.filename);
|
|
18
19
|
if (await verified(target, deps.model, input.signal))
|
|
19
20
|
return target;
|
|
21
|
+
let lastFailure;
|
|
22
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
23
|
+
try {
|
|
24
|
+
return await downloadModel(input, deps, target);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
input.signal.throwIfAborted();
|
|
28
|
+
if (!(error instanceof Error) || error.name !== "SubtitleModelDownloadInterrupted")
|
|
29
|
+
throw error;
|
|
30
|
+
lastFailure = error;
|
|
31
|
+
if (attempt < 3) {
|
|
32
|
+
if (deps.wait)
|
|
33
|
+
await deps.wait(attempt * 1000, input.signal);
|
|
34
|
+
else
|
|
35
|
+
await delay(attempt * 1000, undefined, { signal: input.signal });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("The free subtitle model download was interrupted after 3 attempts. Check your connection and retry.", { cause: lastFailure });
|
|
40
|
+
}
|
|
41
|
+
async function downloadModel(input, deps, target) {
|
|
42
|
+
input.signal.throwIfAborted();
|
|
20
43
|
const staging = await mkdtemp(join(input.cacheDir, ".alignment-download-"));
|
|
21
44
|
const part = join(staging, "model.part");
|
|
22
45
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await deps.fetch(deps.model.url, {
|
|
49
|
+
signal: AbortSignal.any([input.signal, AbortSignal.timeout(300_000)]),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw interrupted(error);
|
|
54
|
+
}
|
|
55
|
+
if (!response.ok || response.body === null) {
|
|
56
|
+
const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}). Check your connection and try again.`);
|
|
57
|
+
let failure = error;
|
|
58
|
+
try {
|
|
59
|
+
if (response.body !== null)
|
|
60
|
+
await response.body.cancel();
|
|
61
|
+
}
|
|
62
|
+
catch (cleanup) {
|
|
63
|
+
failure = new AggregateError([error, cleanup], error.message, { cause: error });
|
|
64
|
+
}
|
|
65
|
+
if (response.status === 408 || response.status === 429 || response.status >= 500)
|
|
66
|
+
throw interrupted(failure);
|
|
67
|
+
throw failure;
|
|
68
|
+
}
|
|
28
69
|
let current = 0;
|
|
29
70
|
const hash = createHash("sha256");
|
|
30
71
|
const reader = response.body.getReader();
|
|
31
72
|
async function* chunks() {
|
|
73
|
+
let readFailed = false;
|
|
32
74
|
try {
|
|
33
75
|
for (;;) {
|
|
34
76
|
input.signal.throwIfAborted();
|
|
35
|
-
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = await reader.read();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
readFailed = true;
|
|
83
|
+
throw interrupted(error);
|
|
84
|
+
}
|
|
36
85
|
if (result.done)
|
|
37
86
|
break;
|
|
38
87
|
current += result.value.length;
|
|
@@ -44,7 +93,13 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
|
|
|
44
93
|
}
|
|
45
94
|
}
|
|
46
95
|
finally {
|
|
47
|
-
|
|
96
|
+
try {
|
|
97
|
+
if (!readFailed)
|
|
98
|
+
await reader.cancel();
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
reader.releaseLock();
|
|
102
|
+
}
|
|
48
103
|
}
|
|
49
104
|
}
|
|
50
105
|
await pipeline(Readable.from(chunks()), createWriteStream(part, { mode: 0o600 }), {
|
|
@@ -72,6 +127,11 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
|
|
|
72
127
|
await rm(staging, { recursive: true, force: true });
|
|
73
128
|
}
|
|
74
129
|
}
|
|
130
|
+
function interrupted(cause) {
|
|
131
|
+
const error = new Error("The subtitle model download was interrupted.", { cause });
|
|
132
|
+
error.name = "SubtitleModelDownloadInterrupted";
|
|
133
|
+
return error;
|
|
134
|
+
}
|
|
75
135
|
async function verified(path, model, signal) {
|
|
76
136
|
try {
|
|
77
137
|
if ((await stat(path)).size !== model.bytes)
|
package/dist/edge/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const { values } = parseArgs({
|
|
|
16
16
|
port: { type: "string" },
|
|
17
17
|
host: { type: "string" },
|
|
18
18
|
"data-dir": { type: "string" },
|
|
19
|
+
"projects-dir": { type: "string" },
|
|
19
20
|
"no-open": { type: "boolean" },
|
|
20
21
|
docker: { type: "boolean" },
|
|
21
22
|
"host-cli": { type: "string" },
|
|
@@ -23,6 +24,8 @@ const { values } = parseArgs({
|
|
|
23
24
|
},
|
|
24
25
|
});
|
|
25
26
|
try {
|
|
27
|
+
if (values["projects-dir"] !== undefined && !values.docker)
|
|
28
|
+
throw new Error("--projects-dir requires --docker; native installs use --data-dir.");
|
|
26
29
|
if ((values["host-cli"] !== undefined || values["accept-host-cli"] !== undefined) &&
|
|
27
30
|
!values.docker)
|
|
28
31
|
throw new Error("Host CLI options require --docker.");
|
|
@@ -30,8 +33,10 @@ try {
|
|
|
30
33
|
throw new Error("The --host-cli override supports only off (API-only Docker).");
|
|
31
34
|
if (values.docker) {
|
|
32
35
|
if (values.host !== undefined || values["data-dir"] !== undefined)
|
|
33
|
-
throw new Error("Docker
|
|
34
|
-
const
|
|
36
|
+
throw new Error("Docker keeps private data in its named volume and binds to localhost. Use --projects-dir for project files and --port for the port.");
|
|
37
|
+
const docker = await import("./docker.js");
|
|
38
|
+
docker.assertManagedDockerHost(process.platform, process.getuid?.(), process.getgid?.());
|
|
39
|
+
const { prepareDockerHostCli } = docker;
|
|
35
40
|
const { nodeHostSetupRunner } = await import("../host-cli/install.js");
|
|
36
41
|
const bridge = await prepareDockerHostCli({
|
|
37
42
|
root: join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local/share"), "slopify/host-cli"),
|
|
@@ -62,6 +67,9 @@ try {
|
|
|
62
67
|
...process.env,
|
|
63
68
|
SLOPIFY_HOST_CLI_DIR: bridge.directory ?? "",
|
|
64
69
|
...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
|
|
70
|
+
...(values["projects-dir"] === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { SLOPIFY_DOCKER_PROJECTS_DIR: values["projects-dir"] }),
|
|
65
73
|
},
|
|
66
74
|
});
|
|
67
75
|
if (result.error)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { open } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { nodeHostSetupRunner } from "../host-cli/install.js";
|
|
9
|
+
import { assertManagedDockerHost } from "./docker.js";
|
|
10
|
+
import { dockerEngine } from "./docker-projects/engine.js";
|
|
11
|
+
import { installProjects } from "./docker-projects/install.js";
|
|
12
|
+
import { dockerConfig, privateDirectory } from "./docker-projects/state.js";
|
|
13
|
+
async function main() {
|
|
14
|
+
const uid = process.getuid?.();
|
|
15
|
+
const gid = process.getgid?.();
|
|
16
|
+
assertManagedDockerHost(process.platform, uid, gid);
|
|
17
|
+
if (gid === undefined)
|
|
18
|
+
throw new Error("Missing host group.");
|
|
19
|
+
const c = dockerConfig(process.env, homedir(), process.cwd(), uid, gid);
|
|
20
|
+
await privateDirectory(c.root, uid);
|
|
21
|
+
if (process.argv[2] !== "--locked") {
|
|
22
|
+
const path = join(c.root, "setup.lock");
|
|
23
|
+
const fd = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
24
|
+
try {
|
|
25
|
+
const s = await fd.stat();
|
|
26
|
+
if (!s.isFile() || s.nlink !== 1 || s.uid !== uid || (s.mode & 0o077) !== 0)
|
|
27
|
+
throw new Error("Unsafe Docker setup lock.");
|
|
28
|
+
const child = spawn("flock", [
|
|
29
|
+
"--nonblock",
|
|
30
|
+
"--no-fork",
|
|
31
|
+
"--conflict-exit-code",
|
|
32
|
+
"73",
|
|
33
|
+
path,
|
|
34
|
+
process.execPath,
|
|
35
|
+
fileURLToPath(import.meta.url),
|
|
36
|
+
"--locked",
|
|
37
|
+
], { stdio: "inherit" });
|
|
38
|
+
const code = await new Promise((resolve, reject) => {
|
|
39
|
+
child.once("error", () => reject(new Error("Install util-linux (flock) before using the managed Docker launcher.")));
|
|
40
|
+
child.once("exit", (status, signal) => signal
|
|
41
|
+
? reject(new Error("Docker setup was interrupted; rerun to recover."))
|
|
42
|
+
: resolve(status ?? 1));
|
|
43
|
+
});
|
|
44
|
+
if (code === 73)
|
|
45
|
+
throw new Error("Another managed Docker installation is changing storage. Wait for it to finish and rerun.");
|
|
46
|
+
process.exitCode = code;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await fd.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const abort = () => controller.abort(new Error("Docker setup interrupted."));
|
|
55
|
+
process.once("SIGINT", abort);
|
|
56
|
+
process.once("SIGTERM", abort);
|
|
57
|
+
try {
|
|
58
|
+
const e = dockerEngine(nodeHostSetupRunner, controller.signal, process.env);
|
|
59
|
+
const recovery = () => dockerEngine(nodeHostSetupRunner, AbortSignal.timeout(5 * 60_000), process.env);
|
|
60
|
+
const result = await installProjects(c, e, recovery);
|
|
61
|
+
console.log(`Slopify is running at ${result.url}`);
|
|
62
|
+
console.log(`Project files on this machine: ${result.projects}`);
|
|
63
|
+
if (result.recovery)
|
|
64
|
+
console.log(`Recovery volume retained: ${result.recovery}. Previous containers remain stopped.`);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
process.off("SIGINT", abort);
|
|
68
|
+
process.off("SIGTERM", abort);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await main();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error(error instanceof Error ? error.message : "Docker setup failed.");
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readState } from "./state.js";
|
|
6
|
+
const activationSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
token: z.string().regex(/^[a-f0-9]{64}$/),
|
|
10
|
+
committed: z.boolean(),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
export async function dockerActivationCommitted(path, token) {
|
|
14
|
+
if (!/^[a-f0-9]{64}$/.test(token))
|
|
15
|
+
return false;
|
|
16
|
+
const marker = await readState(path, activationSchema, process.getuid?.() ?? -1);
|
|
17
|
+
return (marker?.committed === true && timingSafeEqual(Buffer.from(marker.token), Buffer.from(token)));
|
|
18
|
+
}
|
|
19
|
+
export async function dockerFolderConfiguration(env, projectsRoot) {
|
|
20
|
+
if (env.SLOPIFY_CONTAINER !== "1")
|
|
21
|
+
return { container: false, hostProjects: null };
|
|
22
|
+
const host = env.SLOPIFY_DOCKER_PROJECTS_DIR;
|
|
23
|
+
if (!host || env.SLOPIFY_DOCKER_INSTALL_STATE !== "/opt/slopify-install/activation.json")
|
|
24
|
+
return { container: true, hostProjects: null };
|
|
25
|
+
if (!isAbsolute(host) || resolve(host) !== host || /[\p{Cc},]/u.test(host))
|
|
26
|
+
throw new Error("Invalid Docker host project path.");
|
|
27
|
+
const root = await lstat(projectsRoot);
|
|
28
|
+
const mounts = await readFile("/proc/self/mountinfo", "utf8");
|
|
29
|
+
if (projectsRoot !== "/data/projects" ||
|
|
30
|
+
!root.isDirectory() ||
|
|
31
|
+
root.isSymbolicLink() ||
|
|
32
|
+
!mounts.split("\n").some((line) => line.split(" ")[4] === "/data/projects"))
|
|
33
|
+
throw new Error("Managed Docker project bind is missing.");
|
|
34
|
+
return { container: true, hostProjects: host };
|
|
35
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { journalSchema, readState } from "./state.js";
|
|
4
|
+
export async function assertVolumeClaims(c, e, daemon, volumeIdentity, current) {
|
|
5
|
+
const permitted = new Set(current ? [current.id] : []);
|
|
6
|
+
for (const entry of await readdir(c.directory)) {
|
|
7
|
+
if (!/^[a-f0-9-]{36}$/.test(entry))
|
|
8
|
+
continue;
|
|
9
|
+
const directory = join(c.directory, entry);
|
|
10
|
+
const s = await lstat(directory);
|
|
11
|
+
if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== c.uid || (s.mode & 0o077) !== 0)
|
|
12
|
+
throw new Error("Unsafe recovery transaction directory.");
|
|
13
|
+
const j = await readState(join(directory, "journal.json"), journalSchema, c.uid);
|
|
14
|
+
if (!j ||
|
|
15
|
+
j.id !== entry ||
|
|
16
|
+
j.name !== c.name ||
|
|
17
|
+
j.daemon !== daemon ||
|
|
18
|
+
j.volume !== c.volume ||
|
|
19
|
+
j.volumeIdentity !== volumeIdentity)
|
|
20
|
+
continue;
|
|
21
|
+
const readerName = `slopify-reader-${j.id}`;
|
|
22
|
+
const reader = await e.inspect(readerName);
|
|
23
|
+
if (reader) {
|
|
24
|
+
const transaction = await e.command([
|
|
25
|
+
"inspect",
|
|
26
|
+
"--format",
|
|
27
|
+
'{{index .Config.Labels "io.slopify.reader"}}',
|
|
28
|
+
reader.id,
|
|
29
|
+
]);
|
|
30
|
+
const source = reader.mounts.find((m) => m.destination === "/source");
|
|
31
|
+
const bind = reader.mounts.find((m) => m.destination === "/source/projects");
|
|
32
|
+
if (reader.name !== readerName ||
|
|
33
|
+
transaction !== j.id ||
|
|
34
|
+
reader.image !== j.image ||
|
|
35
|
+
reader.running ||
|
|
36
|
+
source?.type !== "volume" ||
|
|
37
|
+
source.name !== j.volume ||
|
|
38
|
+
source.rw ||
|
|
39
|
+
(j.sourceBind !== null &&
|
|
40
|
+
(bind?.type !== "bind" || bind.source !== j.sourceBind || bind.rw)) ||
|
|
41
|
+
reader.mounts.some((m) => m !== source &&
|
|
42
|
+
!(j.sourceBind !== null && m === bind) &&
|
|
43
|
+
!(m.type === "tmpfs" && m.destination === "/data")))
|
|
44
|
+
throw new Error("Copy reader identity or mounts conflict; recovery material is retained.");
|
|
45
|
+
await e.command(["rm", reader.id]);
|
|
46
|
+
}
|
|
47
|
+
if (j.previous) {
|
|
48
|
+
const prior = await e.inspect(j.previous.id);
|
|
49
|
+
if (prior &&
|
|
50
|
+
[j.previous.name, `${c.name}-previous-${j.id}`].includes(prior.name) &&
|
|
51
|
+
prior.image === j.previous.image &&
|
|
52
|
+
prior.user === j.previous.user &&
|
|
53
|
+
sameMounts(prior.mounts, j.previous.mounts))
|
|
54
|
+
permitted.add(prior.id);
|
|
55
|
+
}
|
|
56
|
+
if (j.candidate) {
|
|
57
|
+
const candidate = await e.inspect(j.candidate);
|
|
58
|
+
if (candidate &&
|
|
59
|
+
[c.name, `${c.name}-failed-${j.id}`].includes(candidate.name) &&
|
|
60
|
+
candidate.installation === j.installation &&
|
|
61
|
+
candidate.signature === j.signature)
|
|
62
|
+
permitted.add(candidate.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await e.claims(c.volume, [...permitted], (container) => legacyRollback(container, c.name));
|
|
66
|
+
}
|
|
67
|
+
// Docker reports a container's mounts in no fixed order, so the recorded shape is compared as
|
|
68
|
+
// a set; comparing the JSON as listed refused this installation's own retained container on
|
|
69
|
+
// some runs.
|
|
70
|
+
function sameMounts(a, b) {
|
|
71
|
+
const shape = (mounts) => JSON.stringify([...mounts].sort((x, y) => `${x.destination}\0${x.type}\0${x.name}\0${x.source}`.localeCompare(`${y.destination}\0${y.type}\0${y.name}\0${y.source}`)));
|
|
72
|
+
return shape(a) === shape(b);
|
|
73
|
+
}
|
|
74
|
+
// Launchers before project folders kept each replaced container stopped as a rollback, named
|
|
75
|
+
// `<name>-previous-<YYYYmmddHHMMSS>` with the launcher's label and restart off. Such a container
|
|
76
|
+
// never writes unless someone starts it by hand, which is equally true of the rollbacks this
|
|
77
|
+
// launcher keeps itself, so it does not block an upgrade. Anything running, restartable,
|
|
78
|
+
// unlabelled or otherwise named still does.
|
|
79
|
+
function legacyRollback(container, name) {
|
|
80
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
return (!container.running &&
|
|
82
|
+
container.restart.Name === "no" &&
|
|
83
|
+
container.signature !== null &&
|
|
84
|
+
new RegExp(`^${escaped}-previous-\\d{14}$`).test(container.name));
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { journalSchema, readState, } from "./state.js";
|
|
3
|
+
export async function readCommittedJournal(c, receipt, current) {
|
|
4
|
+
const j = await readState(join(c.directory, receipt.transaction, "journal.json"), journalSchema, c.uid);
|
|
5
|
+
if (!j ||
|
|
6
|
+
j.id !== receipt.transaction ||
|
|
7
|
+
j.name !== receipt.name ||
|
|
8
|
+
j.installation !== receipt.installation ||
|
|
9
|
+
j.daemon !== receipt.daemon ||
|
|
10
|
+
j.volume !== receipt.volume ||
|
|
11
|
+
j.volumeIdentity !== receipt.volumeIdentity ||
|
|
12
|
+
j.destination !== receipt.projects ||
|
|
13
|
+
j.signature !== receipt.signature ||
|
|
14
|
+
j.image !== receipt.image ||
|
|
15
|
+
j.user !== receipt.user ||
|
|
16
|
+
j.publishedIdentity?.dev !== receipt.directoryIdentity.dev ||
|
|
17
|
+
j.publishedIdentity.ino !== receipt.directoryIdentity.ino)
|
|
18
|
+
throw new Error("Committed activation journal is missing or inconsistent.");
|
|
19
|
+
if (current) {
|
|
20
|
+
const data = current.mounts.find((m) => m.destination === "/data");
|
|
21
|
+
const projects = current.mounts.find((m) => m.destination === "/data/projects");
|
|
22
|
+
const activation = current.mounts.find((m) => m.destination === "/opt/slopify-install");
|
|
23
|
+
if (current.id !== j.candidate ||
|
|
24
|
+
current.image !== receipt.image ||
|
|
25
|
+
current.user !== receipt.user ||
|
|
26
|
+
current.signature !== receipt.signature ||
|
|
27
|
+
current.installation !== receipt.installation ||
|
|
28
|
+
data?.type !== "volume" ||
|
|
29
|
+
data.name !== receipt.volume ||
|
|
30
|
+
!data.rw ||
|
|
31
|
+
projects?.type !== "bind" ||
|
|
32
|
+
projects.source !== receipt.projects ||
|
|
33
|
+
!projects.rw ||
|
|
34
|
+
activation?.type !== "bind" ||
|
|
35
|
+
activation.source !== join(c.directory, receipt.transaction) ||
|
|
36
|
+
activation.rw)
|
|
37
|
+
throw new Error("Committed container identity or mounts differ from its receipt.");
|
|
38
|
+
}
|
|
39
|
+
return j;
|
|
40
|
+
}
|