Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
508 changes: 504 additions & 4 deletions apps/daemon/src/docker/client.spec.ts

Large diffs are not rendered by default.

453 changes: 439 additions & 14 deletions apps/daemon/src/docker/client.ts

Large diffs are not rendered by default.

86 changes: 85 additions & 1 deletion apps/daemon/src/server/disk-usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { directorySize } from './disk-usage.js';
import { directorySize, formatBytes, freeSpaceBytes, usableSpace } from './disk-usage.js';

/**
* Synchronous probe at module level: `it.runIf` is evaluated when the tests are
Expand Down Expand Up @@ -80,3 +80,87 @@ describe('directorySize', () => {
},
);
});

/**
* What the install preflight reads before it lets a download begin.
*
* The measurement has to be of the filesystem the volume is really on, which is
* why it takes a path rather than assuming the daemon's root: `dataDirectory`
* can sit on a different disk, and an operator who gave a server its own mount
* deserves that mount checked.
*/
describe('freeSpaceBytes', () => {
let sandbox: string;

beforeEach(async () => {
sandbox = await mkdtemp(join(tmpdir(), 'hopper-free-'));
});

afterEach(async () => {
await rm(sandbox, { recursive: true, force: true });
});

it('reads the free space of the filesystem a path is on', async () => {
const free = await freeSpaceBytes(sandbox);

expect(free).not.toBeNull();
// Any machine that can check out this repository has a megabyte spare; the
// figure itself is the host's business, not this test's.
expect(free).toBeGreaterThan(1024 * 1024);
});

// Not knowing must not be a refusal: an exotic filesystem `statfs` cannot
// describe would otherwise make every installation on that node impossible.
it('answers null rather than throwing when the question cannot be answered', async () => {
expect(await freeSpaceBytes(join(sandbox, 'never-created', 'deeper'))).toBeNull();
});
});

/**
* Which of the two free-block figures `statfs` offers is the one that gets
* spent.
*
* Asked of the answer rather than of a path, because no real filesystem can be
* made to demonstrate the difference on demand: on a machine that can check out
* this repository `bavail` and `bfree` are both simply large, so a test against
* a real directory passes whichever field the code reads.
*/
describe('usableSpace', () => {
/**
* `bfree` is every free block; `bavail` is every free block an unprivileged
* process may have. The difference is what the filesystem holds back for root
* — five percent of an ext4 by default, which on a 2 TB volume is a hundred
* gigabytes — and hopperd runs as root, so `bfree` really is space it can
* write into. Those blocks are the margin that keeps a full machine
* repairable, and spending them on a game server's install is how a full disk
* becomes an unrecoverable one.
*/
it('leaves the blocks a filesystem reserves for root out of the figure', () => {
expect(usableSpace({ bsize: 4096, bavail: 1_000, bfree: 1_250 })).toBe(4_096_000);
});

/**
* An answer arithmetic cannot use reads as not knowing rather than as a
* quantity. Handed to the preflight as free space, either of these would let
* an installation start on a node with nothing left.
*/
it.each([
['a product too large to be a number', { bsize: Number.MAX_VALUE, bavail: Number.MAX_VALUE }],
['a negative count', { bsize: 4096, bavail: -1 }],
])('answers null for %s', (_name, answer) => {
expect(usableSpace({ ...answer, bfree: answer.bavail })).toBeNull();
});
});

describe('formatBytes', () => {
it('scales to the unit an operator would use', () => {
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(1024 ** 2)).toBe('1 MiB');
expect(formatBytes(8 * 1024 ** 3)).toBe('8 GiB');
expect(formatBytes(1024 ** 5)).toBe('1 PiB');
});

it('keeps one decimal for a figure that is not round', () => {
expect(formatBytes(1536 * 1024 * 1024)).toBe('1.5 GiB');
});
});
76 changes: 75 additions & 1 deletion apps/daemon/src/server/disk-usage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lstat, readdir } from 'node:fs/promises';
import { lstat, readdir, statfs } from 'node:fs/promises';
import { join } from 'node:path';

/**
Expand Down Expand Up @@ -55,3 +55,77 @@ export async function directorySize(root: string): Promise<number> {

return total;
}

/**
* The usable part of what `statfs` answered, or `null` if it answered nothing
* usable.
*
* **`bavail` and not `bfree`, and that choice is the whole content of this
* function.** The two differ by the blocks a filesystem reserves for root — five
* percent of an ext4 by default, which on a 2 TB volume is a hundred gigabytes —
* and hopperd runs as root, so `bfree` really is space it can write into. That
* is exactly why it must not: those blocks are the margin that keeps a full
* machine repairable, and an operator logging in to delete something needs the
* shell, the log and the package manager to still work. Spending them on a game
* server's install is how a full disk becomes an unrecoverable one.
*
* Separated from the `statfs` call below for the one reason that matters: no
* real filesystem can be made to demonstrate the difference on demand, so a test
* against a real path passes whichever field is read. Given the answer instead,
* a test can fail on the one-character change that gives a node's reserve away.
*
* `null` for an answer arithmetic cannot use. Some filesystems report block
* counts whose product overflows into `Infinity`, and a few report nonsense
* outright; handed to the preflight as free space, either would let an
* installation start on a node with nothing left, which is the one thing that
* check exists to prevent.
*/
export function usableSpace(stats: {
bavail: number;
bfree: number;
bsize: number;
}): number | null {
const free = stats.bavail * stats.bsize;

return Number.isFinite(free) && free >= 0 ? free : null;
}

/**
* Space left on the filesystem a path lives on.
*
* `null` rather than a throw when the question cannot be answered — an exotic
* filesystem, a path that has just gone. The caller decides what to do about not
* knowing, and refusing every installation on a node whose `statfs` returns
* something unexpected is not it. See {@link usableSpace} for which figure is
* read out of the answer, and why it is the smaller of the two on offer.
*/
export async function freeSpaceBytes(path: string): Promise<number | null> {
try {
return usableSpace(await statfs(path));
} catch {
return null;
}
}

/**
* Bytes as an operator reads them.
*
* The panel has its own copy of this over `bigint`, and the two are deliberately
* not shared: this one exists to put figures in a console line the daemon writes
* at the moment it refuses something, and a shared helper would drag the panel's
* dependency graph into hopperd for eight lines of arithmetic.
*/
export function formatBytes(bytes: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let value = Math.max(0, bytes);
let unit = 0;

while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}

// One decimal, and none at all on a whole number: "1 GiB" reads as a limit
// somebody chose, "1.0 GiB" as a measurement that happened to land there.
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unit]}`;
}
Loading
Loading