pithos/hosted/supervisor.js

207 lines
8.2 KiB
JavaScript
Raw Normal View History

// One s3d per hosted account, each wrapped in the same createPithos() the
// desktop runs, so the server speaks exactly the /api the Pithos UI already
// knows. Accounts live under <root>/users/<id>/:
//
// account.json who (public key), how they got in (gate), port slot, state
// s3d.yml loopback-only addresses from the slot, random admin password
// data/ the s3d data folder moved here from the user's PC
// rules.json public folders, links and shared keys
// usage.json bytes served this month
//
// An s3d starts on the account's first request and is stopped after
// `idleMinutes` without one, flushing leftovers to Sia first. At most
// `maxRunning` run at once; the least recently used idle one makes room.
import fs from 'node:fs';
import path from 'node:path';
import { createPithos } from '../core/server.js';
import { makeCli } from '../core/cli.js';
import { readConfig, writeConfig, randomPassword } from '../core/config.js';
import { writePrefs } from '../core/prefs.js';
import { S3Client } from '../core/s3.js';
import * as admin from '../core/admin.js';
import { readJson, writeJson } from './store.js';
export function createSupervisor(cfg, log = console.log) {
const usersDir = path.join(cfg.root, 'users');
const slotsFile = path.join(cfg.root, 'slots.json');
const keysFile = path.join(cfg.root, 'keys.json');
const running = new Map(); // id -> instance
const loading = new Map(); // id -> Promise<instance>
const keyIndex = readJson(keysFile, {}); // accessKeyId -> account id (owners' s3d keys)
const dir = (id) => path.join(usersDir, id);
const accountFile = (id) => path.join(dir(id), 'account.json');
const configFile = (id) => path.join(dir(id), 's3d.yml');
const dataDir = (id) => path.join(dir(id), 'data');
function account(id) {
if (!/^[0-9a-f]{32}$/.test(id)) return null;
return readJson(accountFile(id), null);
}
function saveAccount(a) { writeJson(accountFile(a.id), a); }
function createAccount({ id, pubkey, gate }) {
const slots = readJson(slotsFile, { next: 0 });
const slot = slots.next++;
writeJson(slotsFile, slots);
const a = { id, pubkey, gate, slot, state: 'empty', createdAt: Date.now() };
fs.mkdirSync(dataDir(id), { recursive: true });
const port = cfg.portBase + slot * 2;
writeConfig(configFile(id), {
apiAddress: `127.0.0.1:${port}`,
adminAddress: `127.0.0.1:${port + 1}`,
adminPassword: randomPassword(),
directory: dataDir(id),
'sia.diskUsageLimit': cfg.bufferLimitBytes,
});
// Leftovers always go to Sia in hosted mode: an idle s3d gets stopped.
writePrefs(configFile(id), { autostart: false, autoFlush: true, autoFlushMinutes: 10 });
saveAccount(a);
return a;
}
function setState(id, state) {
const a = account(id);
a.state = state;
saveAccount(a);
writePrefs(configFile(id), { autostart: state === 'active' });
return a;
}
async function load(id) {
if (running.has(id)) return running.get(id);
if (loading.has(id)) return loading.get(id);
const p = (async () => {
await makeRoom();
const pithos = await createPithos({
host: '127.0.0.1', port: 0, hostName: 'hosted',
configFile: configFile(id), binary: cfg.s3dBinary,
});
const inst = { id, pithos, cli: makeCli(pithos.daemon), cookie: pithos.internalSession(), lastUsed: Date.now(), busy: 0, buckets: new Map() };
running.set(id, inst);
pithos.daemon.on('log', (l) => { if (l.stream !== 'out') log(`[${id.slice(0, 8)}] ${l.line}`); });
return inst;
})().finally(() => loading.delete(id));
loading.set(id, p);
return p;
}
// The instance with s3d up, for anything that talks S3.
async function ensure(id) {
const a = account(id);
if (!a) throw httpError(404, 'no such account');
if (a.state === 'empty') throw httpError(409, 'nothing is hosted yet: move your drives here from Pithos first');
if (a.state === 'moving-out') throw httpError(409, 'this account is being moved to its owner\'s computer');
const inst = await load(id);
inst.lastUsed = Date.now();
const d = inst.pithos.daemon;
if (d.state !== 'running') {
if (d.state !== 'starting') d.start();
const until = Date.now() + 30_000;
while (d.state === 'starting' && Date.now() < until) await sleep(200);
if (d.state !== 'running') throw httpError(503, 'your s3d did not start; try again in a minute');
await indexKeys(inst);
}
return inst;
}
async function indexKeys(inst) {
const keys = await inst.cli.listKeys().catch(() => []);
let changed = false;
for (const [k, v] of Object.entries(keyIndex)) if (v === inst.id && !keys.some((x) => x.accessKeyId === k)) { delete keyIndex[k]; changed = true; }
for (const k of keys) if (keyIndex[k.accessKeyId] !== inst.id) { keyIndex[k.accessKeyId] = inst.id; changed = true; }
if (changed) writeJson(keysFile, keyIndex);
inst.keys = keys;
inst.buckets.clear();
return keys;
}
// Every drive on the account, mapped to a key of the s3d user owning it.
async function mapBuckets(inst) {
inst.buckets.clear();
for (const k of inst.keys || await indexKeys(inst)) {
const list = await clientFor(inst, k).listBuckets().catch(() => []);
for (const b of list) if (!inst.buckets.has(b.name)) inst.buckets.set(b.name, k);
}
return [...inst.buckets.keys()];
}
// An owner key that can act on `bucket`.
async function bucketClient(inst, bucket) {
if (!inst.buckets.has(bucket)) await mapBuckets(inst);
const k = inst.buckets.get(bucket);
return k ? clientFor(inst, k) : null;
}
function clientFor(inst, k) {
return new S3Client({ endpoint: `http://${readConfig(configFile(inst.id)).apiAddress}`, accessKeyId: k.accessKeyId, secretKey: k.secretKey });
}
async function close(id, reason) {
const inst = running.get(id);
if (!inst) return;
running.delete(id);
log(`[${id.slice(0, 8)}] stopping s3d (${reason})`);
await inst.pithos.close().catch((e) => log(`[${id.slice(0, 8)}] close failed: ${e.message}`));
}
async function flushQuietly(inst, timeoutMs = 120_000) {
const c = readConfig(configFile(inst.id));
try {
const st = await admin.uploadStats(c);
if (!st.pendingObjects) return true;
await Promise.race([admin.flush(c), sleep(timeoutMs).then(() => { throw new Error('timed out'); })]);
return true;
} catch (e) {
log(`[${inst.id.slice(0, 8)}] flush before stop failed: ${e.message}`);
return false;
}
}
async function makeRoom() {
if (running.size < cfg.maxRunning) return;
const idle = [...running.values()].filter((i) => !i.busy).sort((a, b) => a.lastUsed - b.lastUsed);
if (!idle.length) throw httpError(503, 'the server is busy; try again in a minute');
await flushQuietly(idle[0]);
await close(idle[0].id, 'making room');
}
async function sweep() {
const cutoff = Date.now() - cfg.idleMinutes * 60_000;
for (const inst of [...running.values()]) {
if (inst.busy || inst.lastUsed > cutoff) continue;
await flushQuietly(inst);
if (!inst.busy && inst.lastUsed <= cutoff) await close(inst.id, 'idle');
}
}
const sweeper = setInterval(() => { sweep().catch((e) => log('sweep failed:', e.message)); }, 60_000);
sweeper.unref?.();
// Holds an instance "in use" for the duration of a streamed request, so the
// sweeper does not stop s3d under a long download.
function hold(inst) {
inst.busy++;
let done = false;
return () => { if (!done) { done = true; inst.busy--; inst.lastUsed = Date.now(); } };
}
return {
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold,
ownerOfKey: (akid) => keyIndex[akid] || null,
async closeAll() {
clearInterval(sweeper);
for (const id of [...running.keys()]) {
await flushQuietly(running.get(id), 30_000);
await close(id, 'shutdown');
}
},
runningCount: () => running.size,
};
}
export function httpError(status, message) { return Object.assign(new Error(message), { status }); }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));