openvibers/openvibe.host Commits
Stage B abuse limits from the threat review: deploys stop before the shared disk fills, one person owns at most 10 projects, a takedown clears what the site left in visitors' browsers
Changes
@@@ -47,6 +47,11 @@ HOST_FORM_SECRET=# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.#HOST_MAX_CONCURRENT_UPLOADS=2++# Deploys are refused (507) while the object store's filesystem has less than this free: the disk++# is shared with every other service on the host. Serving is never affected. Default 5 GiB.++#HOST_MIN_FREE_BYTES=5368709120++# Projects one person may own; each gets the per-project quotas below. Default 10.++#HOST_MAX_PROJECTS_PER_OWNER=10# Per-project quota defaults (a staff override per project wins). Production projects:#HOST_QUOTA_STORAGE_BYTES=1073741824@@@ -76,7 +76,7 @@ function createApp(opts = {}) {const takedowns = createTakedowns({ store });const projects = createProjects({ store, config, access, blobs, takedowns, log });const sites = createSites({ store, config, access, projects, takedowns });-- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });++ const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });const auth = opts.auth || createAuthClient(config);const viewers = createViewerResolver({ auth, config });@@@ -129,7 +129,7 @@ function createApp(opts = {}) {// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));app.get('/release.json', release.handler);-- const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });++ const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes });app.get('/api/ready', readiness.handler);app.get('/metrics', sharedMetrics.metricsHandler(registry));// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites@@@ -76,6 +76,14 @@ function load(env = process.env) {maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),// Uploads read and validated at the same time, service-wide (each is held in memory).maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),++ // Uploads are refused while the object store's filesystem has less than this free. The++ // disk is shared with every other service on the host (Live, Media, databases), so a++ // tenant must never be able to fill it.++ minFreeBytes: int(env.HOST_MIN_FREE_BYTES, 5 * 1024 * MiB),++ },++ // Projects one person may own (each gets the per-project quotas below).++ projects: {++ maxPerOwner: int(env.HOST_MAX_PROJECTS_PER_OWNER, 10),},// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).@@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');const DAY = 24 * 3600 * 1000;const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);--function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {++function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console }) {const { db } = store;const q = {byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),@@@ -75,6 +75,15 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, takedownfunction precheck(viewer, siteId) {const { site, project } = sites.load(viewer, siteId, 'deploy');takedowns.assertOpen(site);++ const minFree = config ? config.uploads.minFreeBytes : 0;++ if (minFree > 0) {++ let free = Infinity;++ try { free = blobs.freeBytes(); } catch (err) { log.warn('[Host] could not read free disk space:', err.message); }++ if (free < minFree) {++ log.warn(`[Host] uploads refused: ${free} bytes free, HOST_MIN_FREE_BYTES is ${minFree}`);++ throw new ApiError(507, 'storage.host_full', 'Host is short of disk space and is not accepting deploys right now; try again later');++ }++ }const limits = limitsFor(project);const recent = q.deploysSince.get(project.id, store.now() - DAY).n;if (recent >= limits.quota.deploysPerDay) {@@@ -81,7 +81,8 @@ function createProjects({ store, config, access, blobs, takedowns, log = consolenetworkProjectId = String(input.network_project_id);if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');}-- if (q.ownedCount.get(owner).n >= 50) throw new ApiError(429, 'quota.projects', 'you already own 50 projects');++ const maxOwned = (config.projects && config.projects.maxPerOwner) || 10;++ if (q.ownedCount.get(owner).n >= maxOwned) throw new ApiError(429, 'quota.projects', `you already own ${maxOwned} projects`);const id = newId('project', store.now());const now = store.now();try {@@@ -181,7 +181,9 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons}// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.if (takedowns && takedowns.ofSite(site)) {-- return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');++ // Clear-Site-Data asks the browser to drop what the site left behind for this visitor++ // (caches, storage and service workers a phishing page may have installed).++ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });}// Absolute-form request targets must agree with the Host header (or they would pick the site).let target = req.url || '/';@@@ -12,7 +12,7 @@const { createReadiness } = require('openvibe-shared/ready');const { CHARTER_TABLES } = require('./db');--function createHostReadiness({ store, blobs, auth, outbox, release = null }) {++function createHostReadiness({ store, blobs, auth, outbox, release = null, minFreeBytes = () => 0 }) {const { db } = store;return createReadiness({service: 'host',@@@ -27,6 +27,15 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null }) {},},{ name: 'storage', required: true, check: () => blobs.writable() },++ {++ // Serving goes on; only new deploys are refused below the floor.++ name: 'disk_headroom', required: false,++ check: () => {++ const free = blobs.freeBytes();++ const floor = minFreeBytes();++ return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below HOST_MIN_FREE_BYTES (${floor}): new deploys are refused`;++ },++ },{name: 'network_jwks', required: false,check: () => {@@@ -95,13 +95,19 @@ function createBlobStore(root) {return n;}++ /** Free bytes on the filesystem that holds the objects (shared with every other service on the host). */++ function freeBytes() {++ const s = fs.statfsSync(base);++ return Number(s.bavail) * Number(s.bsize);++ }++function writable() {fs.accessSync(tmpDir, fs.constants.W_OK);fs.accessSync(projectsDir, fs.constants.W_OK);return true;}-- return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable };++ return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable, freeBytes };}module.exports = { createBlobStore, SHA_RE };@@@ -13,7 +13,7 @@ const { boot, check, done } = require('./stageb/boot');const { site: siteTar } = require('./stageb/tar');(async () => {-- const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });++ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1', HOST_MAX_PROJECTS_PER_OWNER: '4' } });const alice = t.user('alice');const staff = t.user('root', { role: 'admin' });const pa = await t.project(alice, 'A');@@@ -61,6 +61,7 @@ const { site: siteTar } = require('./stageb/tar');assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');assert.strictEqual(g.headers['cache-control'], 'no-store');assert.strictEqual(g.headers.etag, undefined);++ assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');}}assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');@@@ -165,6 +166,30 @@ const { site: siteTar } = require('./stageb/tar');assert.strictEqual(t.ctx.uploadGate.inFlight, 0);});++ await check('the shared disk: below HOST_MIN_FREE_BYTES deploys are refused (507) before the body is read; serving goes on; readiness says so', async () => {++ const pd = await t.project(alice, 'D');++ const delta = await t.site(alice, pd.id, 'delta');++ await t.deploy(alice, delta.id, { 'index.html': 'DELTA' });++ const saved = t.ctx.config.uploads.minFreeBytes;++ t.ctx.config.uploads.minFreeBytes = Number.MAX_SAFE_INTEGER;++ try {++ const r = await t.upload(alice, delta.id, { 'index.html': 'MORE' });++ assert.strictEqual(r.status, 507, r.text);++ assert.strictEqual(r.json().code, 'storage.host_full');++ assert.strictEqual((await t.get('delta.openvibe.host', '/')).text, 'DELTA');++ const ready = (await t.api('GET', '/api/ready')).json();++ const check = Array.isArray(ready.checks) ? ready.checks.find((c) => c.name === 'disk_headroom') : ready.checks.disk_headroom;++ assert.ok(check && !check.ok && /new deploys are refused/.test(JSON.stringify(check)), JSON.stringify(ready));++ } finally { t.ctx.config.uploads.minFreeBytes = saved; }++ assert.strictEqual((await t.upload(alice, delta.id, { 'index.html': 'MORE' })).status, 201);++ });++++ await check('one person owns at most HOST_MAX_PROJECTS_PER_OWNER projects (each gets its own quotas)', async () => {++ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'fifth' } });++ assert.strictEqual(r.status, 429, r.text);++ assert.match(r.json().detail, /you already own 4 projects/);++ });++await t.close();done();})();@@@ -44,7 +44,7 @@ async function boot(opts = {}) {HOST_SITES_DOMAIN: 'openvibe.host',OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',-- HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',++ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off', HOST_MIN_FREE_BYTES: '0',...(opts.env || {}),};const configLib = require('../../server/config');
@@ -47,6 +47,11 @@ HOST_FORM_SECRET=
@@ -47,6 +47,11 @@ HOST_FORM_SECRET=
# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
#HOST_MAX_CONCURRENT_UPLOADS=2
#HOST_MAX_CONCURRENT_UPLOADS=2
+# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
+# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
+#HOST_MIN_FREE_BYTES=5368709120
+# Projects one person may own; each gets the per-project quotas below. Default 10.
+#HOST_MAX_PROJECTS_PER_OWNER=10
# Per-project quota defaults (a staff override per project wins). Production projects:
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
#HOST_QUOTA_STORAGE_BYTES=1073741824
@@ -76,7 +76,7 @@ function createApp(opts = {}) {
@@ -76,7 +76,7 @@ function createApp(opts = {}) {
const takedowns = createTakedowns({ store });
const takedowns = createTakedowns({ store });
const projects = createProjects({ store, config, access, blobs, takedowns, log });
const projects = createProjects({ store, config, access, blobs, takedowns, log });
const sites = createSites({ store, config, access, projects, takedowns });
const sites = createSites({ store, config, access, projects, takedowns });
- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });
+ const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const auth = opts.auth || createAuthClient(config);
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config });
const viewers = createViewerResolver({ auth, config });
@@ -129,7 +129,7 @@ function createApp(opts = {}) {
@@ -129,7 +129,7 @@ function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-host', version: VERSION }));
app.get('/release.json', release.handler);
app.get('/release.json', release.handler);
- const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });
+ const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
@@ -76,6 +76,14 @@ function load(env = process.env) {
@@ -76,6 +76,14 @@ function load(env = process.env) {
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
// Uploads read and validated at the same time, service-wide (each is held in memory).
// Uploads read and validated at the same time, service-wide (each is held in memory).
maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
+ // Uploads are refused while the object store's filesystem has less than this free. The
+ // disk is shared with every other service on the host (Live, Media, databases), so a
+ // tenant must never be able to fill it.
+ minFreeBytes: int(env.HOST_MIN_FREE_BYTES, 5 * 1024 * MiB),
+ },
+ // Projects one person may own (each gets the per-project quotas below).
+ projects: {
+ maxPerOwner: int(env.HOST_MAX_PROJECTS_PER_OWNER, 10),
},
},
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
const DAY = 24 * 3600 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
-function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {
+function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
@@ -75,6 +75,15 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, takedown
@@ -75,6 +75,15 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, takedown
function precheck(viewer, siteId) {
function precheck(viewer, siteId) {
const { site, project } = sites.load(viewer, siteId, 'deploy');
const { site, project } = sites.load(viewer, siteId, 'deploy');
takedowns.assertOpen(site);
takedowns.assertOpen(site);
+ const minFree = config ? config.uploads.minFreeBytes : 0;
+ if (minFree > 0) {
+ let free = Infinity;
+ try { free = blobs.freeBytes(); } catch (err) { log.warn('[Host] could not read free disk space:', err.message); }
+ if (free < minFree) {
+ log.warn(`[Host] uploads refused: ${free} bytes free, HOST_MIN_FREE_BYTES is ${minFree}`);
+ throw new ApiError(507, 'storage.host_full', 'Host is short of disk space and is not accepting deploys right now; try again later');
+ }
+ }
const limits = limitsFor(project);
const limits = limitsFor(project);
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
if (recent >= limits.quota.deploysPerDay) {
if (recent >= limits.quota.deploysPerDay) {
@@ -81,7 +81,8 @@ function createProjects({ store, config, access, blobs, takedowns, log = console
@@ -81,7 +81,8 @@ function createProjects({ store, config, access, blobs, takedowns, log = console
networkProjectId = String(input.network_project_id);
networkProjectId = String(input.network_project_id);
if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');
if (!NETWORK_PROJECT_RE.test(networkProjectId)) throw new ApiError(422, 'project.invalid_network_project_id', 'network_project_id is not a project id');
}
}
- if (q.ownedCount.get(owner).n >= 50) throw new ApiError(429, 'quota.projects', 'you already own 50 projects');
+ const maxOwned = (config.projects && config.projects.maxPerOwner) || 10;
+ if (q.ownedCount.get(owner).n >= maxOwned) throw new ApiError(429, 'quota.projects', `you already own ${maxOwned} projects`);
const id = newId('project', store.now());
const id = newId('project', store.now());
const now = store.now();
const now = store.now();
try {
try {
@@ -181,7 +181,9 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
@@ -181,7 +181,9 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
}
}
// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
// Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
if (takedowns && takedowns.ofSite(site)) {
if (takedowns && takedowns.ofSite(site)) {
- return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');
+ // Clear-Site-Data asks the browser to drop what the site left behind for this visitor
+ // (caches, storage and service workers a phishing page may have installed).
+ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
}
}
// Absolute-form request targets must agree with the Host header (or they would pick the site).
// Absolute-form request targets must agree with the Host header (or they would pick the site).
let target = req.url || '/';
let target = req.url || '/';
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
+function createHostReadiness({ store, blobs, auth, outbox, release = null, minFreeBytes = () => 0 }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'host',
service: 'host',
@@ -27,6 +27,15 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
@@ -27,6 +27,15 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null }) {
},
},
},
},
{ name: 'storage', required: true, check: () => blobs.writable() },
{ name: 'storage', required: true, check: () => blobs.writable() },
+ {
+ // Serving goes on; only new deploys are refused below the floor.
+ name: 'disk_headroom', required: false,
+ check: () => {
+ const free = blobs.freeBytes();
+ const floor = minFreeBytes();
+ return free >= floor ? { ok: true, detail: { free_bytes: free } } : `${free} bytes free, below HOST_MIN_FREE_BYTES (${floor}): new deploys are refused`;
+ },
+ },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -95,13 +95,19 @@ function createBlobStore(root) {
@@ -95,13 +95,19 @@ function createBlobStore(root) {
return n;
return n;
}
}
+ /** Free bytes on the filesystem that holds the objects (shared with every other service on the host). */
+ function freeBytes() {
+ const s = fs.statfsSync(base);
+ return Number(s.bavail) * Number(s.bsize);
+ }
+
function writable() {
function writable() {
fs.accessSync(tmpDir, fs.constants.W_OK);
fs.accessSync(tmpDir, fs.constants.W_OK);
fs.accessSync(projectsDir, fs.constants.W_OK);
fs.accessSync(projectsDir, fs.constants.W_OK);
return true;
return true;
}
}
- return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable };
+ return { root: base, pathFor, put, exists, remove, removeProject, list, sweepTmp, writable, freeBytes };
}
}
module.exports = { createBlobStore, SHA_RE };
module.exports = { createBlobStore, SHA_RE };
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./stageb/boot');
const { site: siteTar } = require('./stageb/tar');
const { site: siteTar } = require('./stageb/tar');
(async () => {
(async () => {
- const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });
+ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1', HOST_MAX_PROJECTS_PER_OWNER: '4' } });
const alice = t.user('alice');
const alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
const staff = t.user('root', { role: 'admin' });
const pa = await t.project(alice, 'A');
const pa = await t.project(alice, 'A');
@@ -61,6 +61,7 @@ const { site: siteTar } = require('./stageb/tar');
@@ -61,6 +61,7 @@ const { site: siteTar } = require('./stageb/tar');
assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
assert.strictEqual(g.headers['cache-control'], 'no-store');
assert.strictEqual(g.headers['cache-control'], 'no-store');
assert.strictEqual(g.headers.etag, undefined);
assert.strictEqual(g.headers.etag, undefined);
+ assert.strictEqual(g.headers['clear-site-data'], '"cache", "storage"', 'the visitor\'s browser drops what the site left behind');
}
}
}
}
assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
@@ -165,6 +166,30 @@ const { site: siteTar } = require('./stageb/tar');
@@ -165,6 +166,30 @@ const { site: siteTar } = require('./stageb/tar');
assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
});
});
+ await check('the shared disk: below HOST_MIN_FREE_BYTES deploys are refused (507) before the body is read; serving goes on; readiness says so', async () => {
+ const pd = await t.project(alice, 'D');
+ const delta = await t.site(alice, pd.id, 'delta');
+ await t.deploy(alice, delta.id, { 'index.html': 'DELTA' });
+ const saved = t.ctx.config.uploads.minFreeBytes;
+ t.ctx.config.uploads.minFreeBytes = Number.MAX_SAFE_INTEGER;
+ try {
+ const r = await t.upload(alice, delta.id, { 'index.html': 'MORE' });
+ assert.strictEqual(r.status, 507, r.text);
+ assert.strictEqual(r.json().code, 'storage.host_full');
+ assert.strictEqual((await t.get('delta.openvibe.host', '/')).text, 'DELTA');
+ const ready = (await t.api('GET', '/api/ready')).json();
+ const check = Array.isArray(ready.checks) ? ready.checks.find((c) => c.name === 'disk_headroom') : ready.checks.disk_headroom;
+ assert.ok(check && !check.ok && /new deploys are refused/.test(JSON.stringify(check)), JSON.stringify(ready));
+ } finally { t.ctx.config.uploads.minFreeBytes = saved; }
+ assert.strictEqual((await t.upload(alice, delta.id, { 'index.html': 'MORE' })).status, 201);
+ });
+
+ await check('one person owns at most HOST_MAX_PROJECTS_PER_OWNER projects (each gets its own quotas)', async () => {
+ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'fifth' } });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.match(r.json().detail, /you already own 4 projects/);
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
HOST_SITES_DOMAIN: 'openvibe.host',
HOST_SITES_DOMAIN: 'openvibe.host',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
- HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off',
+ HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off', HOST_MIN_FREE_BYTES: '0',
...(opts.env || {}),
...(opts.env || {}),
};
};
const configLib = require('../../server/config');
const configLib = require('../../server/config');