Skip to content
Open
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
6 changes: 6 additions & 0 deletions app/client/models/AdminChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ all depend on this being correct."),
extra headers in order to work. Sometimes a reverse proxy can \
interfere with these requirements."),
},

"persist-data": {
info: t("If running in a container without external storage or with default database, \
/persist should be a mounted volume. Otherwise Grist documents and metadata will be lost \
when the container stops."),
},
};

/**
Expand Down
7 changes: 6 additions & 1 deletion app/client/ui/AdminPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to "true" to
"session-secret",
"service-status",
"backups",
"persist-data",
].includes(probe.id);
const show = isRedundant ? options.showRedundant : options.showNovel;
if (!show) { return null; }
Expand Down Expand Up @@ -1128,7 +1129,7 @@ Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to "true" to
t("Results"),
{ style: "margin-top: 0px; padding-top: 0px;" },
),
result.verdict ? dom("pre", result.verdict) : null,
result.verdict ? cssVerdict(result.verdict) : null,
(result.status === "none") ? null :
dom("p",
(result.status === "success") ? t("Check succeeded.") : t("Check failed.")),
Expand Down Expand Up @@ -1391,6 +1392,10 @@ const cssAdminAccountItemPart = styled("span", `
}
`);

const cssVerdict = styled("pre", `
white-space: normal;
`);

async function reloadSafe() {
// Reload the page.
const currentUrl = new URL(window.location.href);
Expand Down
23 changes: 22 additions & 1 deletion app/client/ui/BackupsSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { AdminChecks } from "app/client/models/AdminChecks";
import { cssDangerText, cssHappyText } from "app/client/ui/AdminPanelCss";
import { quickSetupStepHeader } from "app/client/ui/QuickSetupStepHeader";
import { cssCardSurface, cssValueLabel } from "app/client/ui/SettingsLayout";
import { colors } from "app/client/ui2018/cssVars";
import { buildHeroCard } from "app/client/ui/SetupCard";
import { colors, testId } from "app/client/ui2018/cssVars";
import { cssLink } from "app/client/ui2018/links";
import { loadingSpinner } from "app/client/ui2018/loaders";
import { BackupsBootProbeDetails } from "app/common/BootProbe";
Expand Down Expand Up @@ -86,6 +87,10 @@ export class BackupsSection extends Disposable {
private readonly _activeBackend = Computed.create(this, use => use(this._backupsProbeDetails)?.backend);
private readonly _availableBackends = Computed.create(this, use => use(this._backupsProbeDetails)?.availableBackends);
private readonly _selectedBackend = Observable.create<BackendName | undefined>(this, undefined);
private readonly _persistResult = Computed.create(this, (use) => {
const req = this._props.checks.requestCheckById(use, "persist-data");
return req ? use(req.result) : undefined;
});

constructor(private _props: BackupsSectionProps) {
super();
Expand All @@ -97,6 +102,7 @@ export class BackupsSection extends Disposable {

public buildDom() {
return cssSection(
this._buildPersistWarning(),
this._props.inAdminPanel ? cssDescription(
t("Store document backups on an external service like S3 or Azure. \
This protects against data loss if the server's disk fails."),
Expand All @@ -121,6 +127,21 @@ export class BackupsSection extends Disposable {
});
}

private _buildPersistWarning() {
return dom.maybe(this._persistResult, result =>
// Require a verdict — a failed check request faults without one, which isn't data loss.
result?.status === "fault" && result.verdict ?
buildHeroCard({
indicator: "warning",
header: t("Your data may not survive a container restart."),
text: t("To preserve it, mount a volume at /persist."),
badges: [{ label: t("Action needed"), variant: "warning" }],
args: [dom.attr("title", result.verdict), testId("backups-persist-warning")],
}) :
null,
);
}

private _buildBackendCards() {
return dom.domComputed((use) => {
const availableBackends = use(this._availableBackends);
Expand Down
3 changes: 2 additions & 1 deletion app/common/BootProbe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type BootProbeIds =
"session-secret" |
"service-status" |
"backups" |
"sandbox-providers"
"sandbox-providers" |
"persist-data"
;

export interface BootProbeResult {
Expand Down
41 changes: 41 additions & 0 deletions app/server/lib/BootProbes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GristServer } from "app/server/lib/GristServer";
import { getBootKey, getInService, getSandboxFlavor, getSandboxFlavorSource } from "app/server/lib/gristSettings";
import { DEFAULT_SESSION_SECRET } from "app/server/lib/ICreate";
import { getAvailableSandboxes, testSandboxFlavor } from "app/server/lib/NSandbox";
import { classifyStorage } from "app/server/lib/storageDurability";

import * as express from "express";
import fetch from "node-fetch";
Expand Down Expand Up @@ -74,6 +75,7 @@ export class BootProbes {
this._probes.push(_serviceStatusProbe);
this._probes.push(_backupsProbe);
this._probes.push(_sandboxProvidersProbe);
this._probes.push(_dataPersistsProbe);
this._probeById = new Map(this._probes.map(p => [p.id, p]));
}
}
Expand Down Expand Up @@ -439,3 +441,42 @@ const _sandboxProvidersProbe: Probe = {
};
},
};

// Heuristic: the official Docker image sets GRIST_DATA_DIR to this and expects a
// volume at /persist. We use it to guess we're in that image, where `/` is a
// throwaway layer — the filesystem alone can't tell us that.
const IMAGE_DATA_DIR = "/persist/docs";

/**
* Warns when Grist's data would be lost on restart — i.e. it sits on the
* container's own filesystem rather than a mounted volume or external storage.
*/
const _dataPersistsProbe: Probe = {
id: "persist-data",
name: "Does data persist across restarts",
apply: async () => {
const dataDir = process.env.GRIST_DATA_DIR;
const homeDb = process.env.TYPEORM_DATABASE;
// We can't truly detect ephemeral storage, so treat the official image's DATA_DIR as the signal.
const rootMayBeEphemeral = (dataDir === IMAGE_DATA_DIR);

const isExternalStorageActive = appSettings.section("externalStorage").flag("active").getAsBool();
const usesPostgres = (process.env.TYPEORM_TYPE === "postgres");

const docs = isExternalStorageActive ? "durable" : await classifyStorage(dataDir, rootMayBeEphemeral);
const home = usesPostgres ? "durable" : await classifyStorage(homeDb, rootMayBeEphemeral);

const details = { dataDir, homeDb, docs, home };
if (docs === "ephemeral" || home === "ephemeral") {
return {
status: "fault",
verdict: "Your data will be lost when Grist restarts. To keep it, mount a volume at /persist.",
details,
};
}
// Claim success only when both stores are positively durable; otherwise we
// couldn't fully verify and stay neutral.
const verified = docs === "durable" && home === "durable";
return { status: verified ? "success" : "none", details };
},
};
67 changes: 67 additions & 0 deletions app/server/lib/storageDurability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Determines whether a filesystem path lives on storage that survives a restart,
* by inspecting the OS mount table. Used by the "persist-data" boot probe.
*/
import { promises as fse } from "node:fs";
import * as path from "node:path";

export type Durability = "durable" | "ephemeral" | "unknown";

// Filesystems that live in RAM and so never survive a restart.
const RAM_FILESYSTEMS = new Set(["tmpfs", "ramfs"]);

/**
* Whether the storage backing `target` survives a restart. RAM filesystems never
* do; sharing the root mount means nothing is mounted there (ephemeral only if the
* root itself may be a throwaway container layer); assume any other mount is durable.
* Returns "unknown" when it can't be determined, e.g. not on Linux.
*/
export async function classifyStorage(
target: string | undefined, rootMayBeEphemeral: boolean,
): Promise<Durability> {
if (!target) { return "unknown"; }
const mounts = await readMounts();
if (!mounts) { return "unknown"; }
const root = mountFor("/", mounts);
const mount = mountFor(target, mounts);
if (!root || !mount) { return "unknown"; }
if (RAM_FILESYSTEMS.has(mount.fsType)) { return "ephemeral"; }
if (mount.mountPoint === root.mountPoint) { return rootMayBeEphemeral ? "ephemeral" : "unknown"; }
return "durable";
}

interface MountInfo {
mountPoint: string; // e.g. "/", "/persist"
fsType: string; // e.g. "ext4", "overlay", "tmpfs"
}

// Read /proc/self/mountinfo (Linux only); undefined if unreadable.
// Line: ID PID MAJ:MIN ROOT MOUNTPOINT OPTS [TAGS...] - FSTYPE SOURCE SUPEROPTS
async function readMounts(): Promise<MountInfo[] | undefined> {
let content: string;
try {
content = await fse.readFile("/proc/self/mountinfo", "utf8");
} catch {
return undefined;
}
return content.split("\n").flatMap((line) => {
const [before, after] = line.split(" - ");
if (!after) { return []; }
const mountPoint = before.split(" ")[4];
const fsType = after.split(" ")[0];
return mountPoint && fsType ? [{ mountPoint, fsType }] : [];
});
}

// The mount whose mount point is the longest prefix of `target`. Pure string
// logic, so it works even if `target` doesn't exist yet.
function mountFor(target: string, mounts: MountInfo[]): MountInfo | undefined {
const p = path.resolve(target);
let best: MountInfo | undefined;
for (const mount of mounts) {
const { mountPoint } = mount;
const within = mountPoint === "/" || p === mountPoint || p.startsWith(mountPoint + "/");
if (within && (!best || mountPoint.length > best.mountPoint.length)) { best = mount; }
}
return best;
}
Loading