codex-snapshots 0.1.0 → 0.1.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 +101 -6
- package/bin/codex-snapshot.mjs +1 -6326
- package/deploy/aliyun/README.md +311 -0
- package/deploy/aliyun/backup-share-data.sh +109 -0
- package/deploy/aliyun/check-ecs-status.sh +149 -0
- package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
- package/deploy/aliyun/codex-snapshot-share.service +26 -0
- package/deploy/aliyun/configure-github-pages-api.sh +141 -0
- package/deploy/aliyun/configure-local-publisher.sh +197 -0
- package/deploy/aliyun/deploy-to-ecs.sh +669 -0
- package/deploy/aliyun/deploy.env.example +52 -0
- package/deploy/aliyun/doctor.mjs +398 -0
- package/deploy/aliyun/install-share-api.sh +252 -0
- package/deploy/aliyun/install-system-deps.sh +84 -0
- package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
- package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
- package/deploy/aliyun/preflight.mjs +321 -0
- package/deploy/aliyun/restore-share-data.sh +141 -0
- package/deploy/aliyun/verify-public-share.mjs +404 -0
- package/dist/cli/codex-snapshot.mjs +2654 -0
- package/dist/core/privacy.js +81 -0
- package/dist/core/snapshot.js +1 -0
- package/dist/renderers/markdown.mjs +81 -0
- package/dist/renderers/transcript.js +195 -0
- package/dist/server/http.js +10 -0
- package/dist/server/local-security.js +66 -0
- package/dist/server/local-viewer-app.mjs +1670 -0
- package/dist/server/local-viewer.mjs +210 -0
- package/dist/server/share-api.mjs +1149 -0
- package/dist/server/share-store.js +136 -0
- package/dist/shared/sanitize.js +126 -0
- package/dist/shared/transcript.js +1 -0
- package/dist/sources/index.mjs +2 -0
- package/dist/sources/local-history.mjs +2221 -0
- package/package.json +42 -14
- package/scripts/build-site.mjs +71 -0
- package/scripts/launch-agent.mjs +19 -227
- package/scripts/serve-site.mjs +2 -2
- package/scripts/test-aliyun-deploy-config.sh +230 -0
- package/scripts/test-share-api.mjs +967 -0
- package/scripts/test-site-config.mjs +100 -0
- package/scripts/test-static-site.mjs +403 -0
- package/scripts/write-site-config.mjs +161 -0
- package/server/share-api.mjs +1 -771
- package/site/assets/config.js +3 -0
- package/site/assets/share.js +43 -106
- package/site/assets/site.css +3 -605
- package/site/assets/site.js +15 -92
- package/site/favicon.svg +7 -0
- package/site/index.html +3 -83
- package/site/share/index.html +3 -8
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import dns from "node:dns/promises";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
|
|
7
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
8
|
+
|
|
9
|
+
if (parsed.help) {
|
|
10
|
+
printHelp();
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const options = parsed.options;
|
|
15
|
+
|
|
16
|
+
main().catch((error) => {
|
|
17
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
if (!options.domain) {
|
|
23
|
+
throw new Error("Missing --domain.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const results = [];
|
|
27
|
+
|
|
28
|
+
results.push(await runCheck("Resolve API domain", () => checkDomain(options.domain)));
|
|
29
|
+
|
|
30
|
+
if (options.sshTarget) {
|
|
31
|
+
results.push(await runCheck("Compare DNS with SSH host", () => compareDnsWithSshHost(options.domain, options.sshTarget), false));
|
|
32
|
+
results.push(await runCheck("Open SSH session", () => checkSsh(options)));
|
|
33
|
+
results.push(await runCheck("Check remote dependencies", () => checkRemoteDependencies(options)));
|
|
34
|
+
} else {
|
|
35
|
+
results.push({ ok: true, skipped: true, label: "SSH checks skipped; pass --ssh to verify the ECS host" });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.checkPublicPorts) {
|
|
39
|
+
results.push(await runCheck("Reach public HTTP port 80", () => checkTcp(options.domain, 80)));
|
|
40
|
+
results.push(await runCheck("Reach public HTTPS port 443", () => checkTcp(options.domain, 443), false));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const failed = results.filter((result) => !result.ok && result.required);
|
|
44
|
+
const warnings = results.filter((result) => !result.ok && !result.required);
|
|
45
|
+
|
|
46
|
+
if (failed.length) {
|
|
47
|
+
throw new Error(`${failed.length} required preflight check(s) failed`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (warnings.length) {
|
|
51
|
+
console.log(`! ${warnings.length} warning(s); deployment may still work after the missing external setup is completed.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log("✓ Aliyun deployment preflight passed");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runCheck(label, check, required = true) {
|
|
58
|
+
try {
|
|
59
|
+
const message = await check();
|
|
60
|
+
console.log(`✓ ${label}${message ? `: ${message}` : ""}`);
|
|
61
|
+
return { ok: true, label, required };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
console.log(`${required ? "✗" : "!"} ${label}: ${message}`);
|
|
65
|
+
return { ok: false, label, required };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function checkDomain(domain) {
|
|
70
|
+
const addresses = await resolveHost(domain);
|
|
71
|
+
if (!addresses.length) {
|
|
72
|
+
throw new Error("no A or AAAA records found");
|
|
73
|
+
}
|
|
74
|
+
return addresses.join(", ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function compareDnsWithSshHost(domain, sshTarget) {
|
|
78
|
+
const domainAddresses = await resolveHost(domain);
|
|
79
|
+
const sshHost = extractSshHost(sshTarget);
|
|
80
|
+
|
|
81
|
+
if (!sshHost) {
|
|
82
|
+
throw new Error("could not parse SSH host");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sshAddresses = net.isIP(sshHost) ? [sshHost] : await resolveHost(sshHost);
|
|
86
|
+
const overlap = domainAddresses.filter((address) => sshAddresses.includes(address));
|
|
87
|
+
|
|
88
|
+
if (!overlap.length) {
|
|
89
|
+
throw new Error(`domain resolves to ${domainAddresses.join(", ")}, SSH host resolves to ${sshAddresses.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return overlap.join(", ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function checkSsh({ sshTarget, sshPort, identityFile, timeoutMs }) {
|
|
96
|
+
const { stdout } = await runCommand(
|
|
97
|
+
"ssh",
|
|
98
|
+
buildSshArgs({ sshPort, identityFile, timeoutMs }).concat(sshTarget, "printf ready"),
|
|
99
|
+
timeoutMs
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (stdout.trim() !== "ready") {
|
|
103
|
+
throw new Error(`unexpected SSH output: ${stdout.trim() || "(empty)"}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return sshTarget;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function checkRemoteDependencies({ sshTarget, sshPort, identityFile, proxyMode, requireCertbotNginx, timeoutMs }) {
|
|
110
|
+
const requiredCommands =
|
|
111
|
+
proxyMode === "caddy"
|
|
112
|
+
? ["node", "caddy", "rsync", "openssl", "systemctl"]
|
|
113
|
+
: ["node", "nginx", "certbot", "rsync", "openssl", "systemctl"];
|
|
114
|
+
const certbotNginxCheck = requireCertbotNginx
|
|
115
|
+
? `
|
|
116
|
+
if ! certbot plugins 2>/dev/null | grep -qi nginx; then
|
|
117
|
+
echo "missing: certbot nginx plugin"
|
|
118
|
+
exit 14
|
|
119
|
+
fi
|
|
120
|
+
`
|
|
121
|
+
: "";
|
|
122
|
+
const remoteScript = `
|
|
123
|
+
set -eu
|
|
124
|
+
PATH="$PATH:/usr/local/sbin:/usr/sbin:/sbin"
|
|
125
|
+
missing=""
|
|
126
|
+
for cmd in ${requiredCommands.join(" ")}; do
|
|
127
|
+
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
128
|
+
missing="$missing $cmd"
|
|
129
|
+
fi
|
|
130
|
+
done
|
|
131
|
+
if [ -n "$missing" ]; then
|
|
132
|
+
echo "missing:$missing"
|
|
133
|
+
exit 11
|
|
134
|
+
fi
|
|
135
|
+
node -e 'const major=Number(process.versions.node.split(".")[0]); if (!Number.isFinite(major) || major < 18) process.exit(12);'
|
|
136
|
+
${certbotNginxCheck}
|
|
137
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
138
|
+
echo "privilege:root"
|
|
139
|
+
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
|
140
|
+
echo "privilege:sudo"
|
|
141
|
+
else
|
|
142
|
+
echo "missing: passwordless sudo"
|
|
143
|
+
exit 13
|
|
144
|
+
fi
|
|
145
|
+
`;
|
|
146
|
+
const args = buildSshArgs({ sshPort, identityFile, timeoutMs }).concat(sshTarget, `sh -lc ${shellQuote(remoteScript)}`);
|
|
147
|
+
const { stdout } = await runCommand("ssh", args, timeoutMs);
|
|
148
|
+
return stdout.trim().replace(/\s+/g, " ");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildSshArgs({ sshPort, identityFile, timeoutMs }) {
|
|
152
|
+
const args = [
|
|
153
|
+
"-o",
|
|
154
|
+
"BatchMode=yes",
|
|
155
|
+
"-o",
|
|
156
|
+
`ConnectTimeout=${Math.max(1, Math.ceil(Number(timeoutMs || 8000) / 1000))}`,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
if (identityFile) {
|
|
160
|
+
args.push("-i", identityFile);
|
|
161
|
+
}
|
|
162
|
+
if (sshPort) {
|
|
163
|
+
args.push("-p", String(sshPort));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return args;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function checkTcp(host, port) {
|
|
170
|
+
await new Promise((resolve, reject) => {
|
|
171
|
+
const socket = net.createConnection({ host, port });
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
socket.destroy();
|
|
174
|
+
reject(new Error("connection timed out"));
|
|
175
|
+
}, Number(options.timeoutMs || 8000));
|
|
176
|
+
|
|
177
|
+
socket.once("connect", () => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
socket.end();
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
socket.once("error", (error) => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
reject(error);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return `${host}:${port}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function resolveHost(host) {
|
|
192
|
+
const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]);
|
|
193
|
+
return [
|
|
194
|
+
...(v4.status === "fulfilled" ? v4.value : []),
|
|
195
|
+
...(v6.status === "fulfilled" ? v6.value : []),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractSshHost(target) {
|
|
200
|
+
const withoutUser = String(target || "").replace(/^ssh:\/\//, "").split("@").pop() || "";
|
|
201
|
+
if (withoutUser.startsWith("[")) {
|
|
202
|
+
return withoutUser.slice(1, withoutUser.indexOf("]"));
|
|
203
|
+
}
|
|
204
|
+
return withoutUser.split(":")[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runCommand(command, args, timeoutMs) {
|
|
208
|
+
return await new Promise((resolve, reject) => {
|
|
209
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
210
|
+
let stdout = "";
|
|
211
|
+
let stderr = "";
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
child.kill("SIGTERM");
|
|
214
|
+
reject(new Error(`${command} timed out`));
|
|
215
|
+
}, Number(timeoutMs || 8000));
|
|
216
|
+
|
|
217
|
+
child.stdout.setEncoding("utf8");
|
|
218
|
+
child.stderr.setEncoding("utf8");
|
|
219
|
+
child.stdout.on("data", (chunk) => {
|
|
220
|
+
stdout += chunk;
|
|
221
|
+
});
|
|
222
|
+
child.stderr.on("data", (chunk) => {
|
|
223
|
+
stderr += chunk;
|
|
224
|
+
});
|
|
225
|
+
child.once("error", (error) => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
reject(error);
|
|
228
|
+
});
|
|
229
|
+
child.once("exit", (code) => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
if (code === 0) {
|
|
232
|
+
resolve({ stdout, stderr });
|
|
233
|
+
} else {
|
|
234
|
+
reject(new Error((stderr || stdout || `${command} exited with ${code}`).trim()));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function shellQuote(value) {
|
|
241
|
+
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseArgs(args) {
|
|
245
|
+
const options = {
|
|
246
|
+
checkPublicPorts: false,
|
|
247
|
+
domain: "",
|
|
248
|
+
identityFile: "",
|
|
249
|
+
requireCertbotNginx: false,
|
|
250
|
+
proxyMode: "nginx",
|
|
251
|
+
sshPort: "",
|
|
252
|
+
sshTarget: "",
|
|
253
|
+
timeoutMs: 8000,
|
|
254
|
+
};
|
|
255
|
+
let help = false;
|
|
256
|
+
|
|
257
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
258
|
+
const arg = args[index];
|
|
259
|
+
if (arg === "-h" || arg === "--help") {
|
|
260
|
+
help = true;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (arg === "--domain") {
|
|
264
|
+
options.domain = String(args[++index] || "");
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (arg === "--ssh") {
|
|
268
|
+
options.sshTarget = String(args[++index] || "");
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (arg === "--identity-file") {
|
|
272
|
+
options.identityFile = String(args[++index] || "");
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (arg === "--port") {
|
|
276
|
+
options.sshPort = String(args[++index] || "");
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (arg === "--timeout-ms") {
|
|
280
|
+
options.timeoutMs = Number(args[++index] || 8000);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (arg === "--check-public-ports") {
|
|
284
|
+
options.checkPublicPorts = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (arg === "--require-certbot-nginx") {
|
|
288
|
+
options.requireCertbotNginx = true;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (arg === "--proxy-mode") {
|
|
292
|
+
options.proxyMode = String(args[++index] || "nginx");
|
|
293
|
+
if (!["nginx", "caddy"].includes(options.proxyMode)) {
|
|
294
|
+
throw new Error("--proxy-mode must be nginx or caddy");
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`unknown option: ${arg}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { help, options };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function printHelp() {
|
|
305
|
+
console.log(`aliyun preflight
|
|
306
|
+
|
|
307
|
+
Usage:
|
|
308
|
+
node deploy/aliyun/preflight.mjs --domain snapshots.example.com --ssh root@1.2.3.4
|
|
309
|
+
|
|
310
|
+
Options:
|
|
311
|
+
--domain DOMAIN Public API domain that should point to ECS.
|
|
312
|
+
--ssh TARGET SSH target, for example root@1.2.3.4.
|
|
313
|
+
--identity-file FILE SSH private key for the ECS host.
|
|
314
|
+
--port PORT SSH port.
|
|
315
|
+
--timeout-ms MS Per-check timeout. Defaults to 8000.
|
|
316
|
+
--check-public-ports Also test public TCP 80 and 443.
|
|
317
|
+
--proxy-mode MODE Remote reverse proxy: nginx or caddy. Defaults to nginx.
|
|
318
|
+
--require-certbot-nginx Check that certbot has the Nginx plugin.
|
|
319
|
+
-h, --help Show help.
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SSH_TARGET=""
|
|
5
|
+
SSH_OPTS=()
|
|
6
|
+
INPUT_FILE=""
|
|
7
|
+
REMOTE_FILE="/var/lib/codex-snapshots/shares.json"
|
|
8
|
+
RESTART_SERVICE=1
|
|
9
|
+
|
|
10
|
+
usage() {
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
Usage:
|
|
13
|
+
deploy/aliyun/restore-share-data.sh --ssh root@1.2.3.4 --file backups/codex-snapshots/shares-20260101T000000Z.json
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--ssh TARGET SSH target, for example root@1.2.3.4.
|
|
17
|
+
--file FILE Local share data JSON backup to restore.
|
|
18
|
+
--identity-file FILE SSH private key for the ECS host.
|
|
19
|
+
--port PORT SSH port.
|
|
20
|
+
--remote-file FILE Remote share data file. Defaults to /var/lib/codex-snapshots/shares.json.
|
|
21
|
+
--no-restart Restore the file without restarting codex-snapshot-share.
|
|
22
|
+
-h, --help Show help.
|
|
23
|
+
EOF
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
while [[ $# -gt 0 ]]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--ssh)
|
|
29
|
+
SSH_TARGET="${2:-}"
|
|
30
|
+
shift 2
|
|
31
|
+
;;
|
|
32
|
+
--file)
|
|
33
|
+
INPUT_FILE="${2:-}"
|
|
34
|
+
shift 2
|
|
35
|
+
;;
|
|
36
|
+
--identity-file)
|
|
37
|
+
SSH_OPTS+=("-i" "${2:-}")
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
--port)
|
|
41
|
+
SSH_OPTS+=("-p" "${2:-}")
|
|
42
|
+
shift 2
|
|
43
|
+
;;
|
|
44
|
+
--remote-file)
|
|
45
|
+
REMOTE_FILE="${2:-}"
|
|
46
|
+
shift 2
|
|
47
|
+
;;
|
|
48
|
+
--no-restart)
|
|
49
|
+
RESTART_SERVICE=0
|
|
50
|
+
shift
|
|
51
|
+
;;
|
|
52
|
+
-h|--help)
|
|
53
|
+
usage
|
|
54
|
+
exit 0
|
|
55
|
+
;;
|
|
56
|
+
*)
|
|
57
|
+
echo "Unknown option: $1" >&2
|
|
58
|
+
usage >&2
|
|
59
|
+
exit 1
|
|
60
|
+
;;
|
|
61
|
+
esac
|
|
62
|
+
done
|
|
63
|
+
|
|
64
|
+
if [[ -z "${SSH_TARGET}" ]]; then
|
|
65
|
+
echo "Missing --ssh target." >&2
|
|
66
|
+
usage >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [[ -z "${INPUT_FILE}" || ! -f "${INPUT_FILE}" ]]; then
|
|
71
|
+
echo "Missing --file JSON backup." >&2
|
|
72
|
+
usage >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
node -e '
|
|
77
|
+
const { readFileSync } = require("node:fs");
|
|
78
|
+
const file = process.argv[1];
|
|
79
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
80
|
+
if (!Array.isArray(parsed) && !(parsed && typeof parsed === "object" && Array.isArray(parsed.entries))) {
|
|
81
|
+
throw new Error("share data must be an array or an object with an entries array");
|
|
82
|
+
}
|
|
83
|
+
' "${INPUT_FILE}"
|
|
84
|
+
|
|
85
|
+
shell_quote() {
|
|
86
|
+
printf "'"
|
|
87
|
+
printf "%s" "$1" | sed "s/'/'\"'\"'/g"
|
|
88
|
+
printf "'"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
remote_file_q="$(shell_quote "${REMOTE_FILE}")"
|
|
92
|
+
|
|
93
|
+
remote_cmd=$(
|
|
94
|
+
cat <<EOF
|
|
95
|
+
set -e
|
|
96
|
+
tmp_file="\$(mktemp /tmp/codex-snapshots-restore.XXXXXX.json)"
|
|
97
|
+
root_script="\$(mktemp /tmp/codex-snapshots-restore-root.XXXXXX.sh)"
|
|
98
|
+
cleanup() {
|
|
99
|
+
rm -f "\${tmp_file}" "\${root_script}"
|
|
100
|
+
}
|
|
101
|
+
trap cleanup EXIT
|
|
102
|
+
|
|
103
|
+
cat > "\${tmp_file}"
|
|
104
|
+
cat > "\${root_script}" <<'ROOT_SCRIPT'
|
|
105
|
+
#!/usr/bin/env sh
|
|
106
|
+
set -e
|
|
107
|
+
remote_file="\$1"
|
|
108
|
+
restart_service="\$2"
|
|
109
|
+
tmp_file="\$3"
|
|
110
|
+
backup_file="\${remote_file}.restore-backup.\$(date -u +%Y%m%dT%H%M%SZ)"
|
|
111
|
+
|
|
112
|
+
mkdir -p "\$(dirname "\${remote_file}")"
|
|
113
|
+
if [ -f "\${remote_file}" ]; then
|
|
114
|
+
cp -a "\${remote_file}" "\${backup_file}"
|
|
115
|
+
echo "Saved previous remote data to \${backup_file}"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
install -o codexsnap -g codexsnap -m 0640 "\${tmp_file}" "\${remote_file}"
|
|
119
|
+
|
|
120
|
+
if [ "\${restart_service}" = "1" ]; then
|
|
121
|
+
systemctl restart codex-snapshot-share.service
|
|
122
|
+
fi
|
|
123
|
+
ROOT_SCRIPT
|
|
124
|
+
chmod +x "\${root_script}"
|
|
125
|
+
|
|
126
|
+
if [ "\$(id -u)" -eq 0 ]; then
|
|
127
|
+
"\${root_script}" ${remote_file_q} ${RESTART_SERVICE} "\${tmp_file}"
|
|
128
|
+
else
|
|
129
|
+
sudo -n "\${root_script}" ${remote_file_q} ${RESTART_SERVICE} "\${tmp_file}"
|
|
130
|
+
fi
|
|
131
|
+
EOF
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
SSH_CMD=(ssh)
|
|
135
|
+
if ((${#SSH_OPTS[@]})); then
|
|
136
|
+
SSH_CMD+=("${SSH_OPTS[@]}")
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
"${SSH_CMD[@]}" "${SSH_TARGET}" "${remote_cmd}" < "${INPUT_FILE}"
|
|
140
|
+
|
|
141
|
+
echo "Restored ${INPUT_FILE} to ${SSH_TARGET}:${REMOTE_FILE}"
|