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
4 changes: 3 additions & 1 deletion api/agent/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ export function projectArgs(client) {
return { project: client.projectName, home: client.options.home, deploy: client.options.deploy };
}

export const storeArgs = (client) => (client.options.storePath ? { storePath: client.options.storePath } : {});
export const storeArgs = (client) => (client.options.storePath
? { storePath: client.options.storePath, ...(client.options.readOnly ? { readOnly: true } : {}) }
: {});

/** Opens the client's Store in this process (one more client of it). */
export async function storeOpen(client) {
Expand Down
25 changes: 20 additions & 5 deletions api/agent/src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// @layerfs/agent (#428): what an agent's script calls. A client is opened by
// `LayerFS.open(name)` (an existing project) or `LayerFS.init(name, folder,
// timeout?)` (a new one) and released by `close()`; there is no public
// constructor and no options argument: settings come from the environment.
// `LayerFS.open(name)` (an existing project), `LayerFS.openReadOnly(name)`
// (the same, with the Store opened for reads only) or `LayerFS.init(name,
// folder, timeout?)` (a new one) and released by `close()`; there is no
// public constructor and no options argument: settings come from the
// environment.
// Calls name their Branch. `sandbox.create()` starts a Sandbox (the only
// call that does) and `sandbox.close` stops it; `workspace.mount(sandbox,
// branch)` returns a Workspace, `{ sandbox, path }`, in the Sandbox it names
Expand Down Expand Up @@ -78,6 +80,18 @@ export class LayerFS {
return client;
}

/** A ready client of the existing project `projectName`, with its Store
* opened for reads only: file-changing and history-mutating Store calls
* are refused `ReadOnly`. Workspace and Sandbox calls still go to the
* daemon. The constructor lists its settings. */
static async openReadOnly(projectName, ...rest) {
noOptions("LayerFS.openReadOnly(name)", rest.some((value) => value !== undefined));
const client = new LayerFS(projectName, FACTORY);
client.options.readOnly = true;
await opened(client);
return client;
}

/** Creates the project `projectName` from `folder` (one Init), then opens
* it. `created` on the client is what Init returned. An uncertain Init
* keeps everything it made. `timeout` (milliseconds, a positive whole
Expand All @@ -92,7 +106,8 @@ export class LayerFS {
return client;
}

/** Not public: `LayerFS.open` and `LayerFS.init` make clients. Every
/** Not public: `LayerFS.open`, `LayerFS.openReadOnly` and `LayerFS.init`
* make clients. Every
* setting is read from the environment once, here, into `options`, by its
* LAYERFS_ name. LAYERFS_TRACE names a
* per-call trace file; LAYERFS_TRACE_STEP, read at each call, labels its
Expand All @@ -113,7 +128,7 @@ export class LayerFS {
* `LayerFS.init` records instead of the default. */
constructor(projectName, token = undefined) {
if (token !== FACTORY) {
throw new LayerFSError("InvalidInput", "use `await LayerFS.open(name)` or `await LayerFS.init(name, folder)`; there is no public constructor", null);
throw new LayerFSError("InvalidInput", "use `await LayerFS.open(name)`, `await LayerFS.openReadOnly(name)` or `await LayerFS.init(name, folder)`; there is no public constructor", null);
}
if (typeof projectName !== "string" || !/^[a-z0-9](?:[a-z0-9_.-]{0,61}[a-z0-9])?$/.test(projectName)) {
throw new LayerFSError("InvalidInput", "a project name is required", null);
Expand Down
17 changes: 10 additions & 7 deletions api/agent/src/transport/worker_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,9 @@ function initRun(init, request) {

/** The binding that sends every call to the worker named by the endpoint file `file`. */
export function workerClient(file) {
/** Stores this process has opened, by path, with their clients. */
/** Stores this process has opened, by path and open mode, with their clients. */
const opened = new Map();
const storeKey = ({ storePath, readOnly }) => `${storePath}\0${readOnly ? "1" : "0"}`;
async function send(fn, op, args) {
let info;
try { info = endpointOf(file); } catch (error) { return refused("Failed", `the LayerFS worker is not running for this home: ${error.message}`); }
Expand All @@ -144,21 +145,23 @@ export function workerClient(file) {
...(info.docker ? { docker: info.docker } : {}),
};
const sent = info.daemonHost || info.docker ? { ...args, deploy } : args;
const request = { fn, op, args: sent, open: opened.has(args.storePath) };
const request = { fn, op, args: sent, open: opened.has(storeKey(args)) };
if (fn === "call" && op === "project.init" && info.init) return initRun(info.init, request);
return connection(info).call(request);
}
return {
call: (op, args) => send("call", op, args),
backendCall: (op, args) => send("backendCall", op, args),
/** The Store opens in the worker; here only its clients are counted. */
async storeOpen({ storePath }) {
opened.set(storePath, (opened.get(storePath) ?? 0) + 1);
async storeOpen(args) {
const key = storeKey(args);
opened.set(key, (opened.get(key) ?? 0) + 1);
return { value: null };
},
async storeClose({ storePath }) {
const left = (opened.get(storePath) ?? 1) - 1;
if (left > 0) opened.set(storePath, left); else opened.delete(storePath);
async storeClose(args) {
const key = storeKey(args);
const left = (opened.get(key) ?? 1) - 1;
if (left > 0) opened.set(key, left); else opened.delete(key);
return { value: null };
},
/** The worker returns a spooled exec's output whole, so there is no spool to read here. */
Expand Down
5 changes: 4 additions & 1 deletion api/agent/test/fixture/fake-native.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ function answer(op, args) {
if (refuse.includes(op)) return refusal(process.env.CLI_FAKE_CODE || "Conflict", `${op} refused by the fake`, { op });
if (args.storePath === "/missing") return refusal("NoSuchProject", "no such project");
if (args.path === "/missing") return refusal("NoSuchPath", "/missing does not exist");
if (args.readOnly && /^(fs\.(write|edit|apply|mkdir|rm|cp|mv)|branch\.(fork|merge|publish))$/.test(op)) {
return refusal("ReadOnly", "this Store is open read-only");
}
switch (op) {
case "project.init":
if (exports.initRefusal) return { refused: exports.initRefusal };
Expand Down Expand Up @@ -99,7 +102,7 @@ const traced = (op, args) => {
};
exports.stores = [];
// The in-process Store (#352 F-020): opened once per client, never traced.
exports.storeOpen = async (args) => { log("store.open", args); exports.stores.push(["open", args.storePath]); return { value: { clients: 1 } }; };
exports.storeOpen = async (args) => { log("store.open", args); exports.stores.push(args.readOnly ? ["open", args.storePath, true] : ["open", args.storePath]); return { value: { clients: 1 } }; };
exports.storeClose = async (args) => { log("store.close", args); exports.stores.push(["close", args.storePath]); return { value: { clients: 0 } }; };
exports.call = async (op, args) => traced(op, args);
exports.backendCall = async (op, args) => traced(op, args);
53 changes: 53 additions & 0 deletions api/agent/test/open_read_only.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// LayerFS.openReadOnly over the stand-in binding: the Store is opened with
// readOnly, reads send it, and file-changing calls are refused ReadOnly.
// node api/agent/test/open_read_only.mjs
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { ENV } from "./fixture/cases.mjs";

const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url));
Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE });
delete process.env.CLI_FAKE_LOG;
delete process.env.LAYERFS_WORKER_ENDPOINT;
const native = createRequire(import.meta.url)(FAKE);
const { LayerFS } = await import("../src/index.js");

await assert.rejects(LayerFS.openReadOnly("website", {}), {
code: "InvalidInput",
message: /takes no options: settings come from the environment/,
});

const reader = await LayerFS.openReadOnly("website");
assert.equal(reader.options.readOnly, true);
assert.deepEqual(native.stores.at(-1), ["open", ENV.LAYERFS_STORE_PATH, true]);
await reader.fs.read("main", "/README.md");
assert.equal(native.args.at(-1).readOnly, true);
await assert.rejects(reader.fs.write("feat", "/README.md", "nope\n"), {
code: "ReadOnly",
message: /this Store is open read-only/,
});
await assert.rejects(reader.branch.fork("other"), { code: "ReadOnly" });
await reader.close();

const { fakeWorker } = await import("./fixture/fake-worker.mjs");
const { useWorker } = await import("../src/internal.js");
const worker = await fakeWorker();
const dir = mkdtempSync(join(tmpdir(), "layerfs-open-read-only-"));
try {
const file = join(dir, "endpoint.json");
worker.endpoint(file, ENV.LAYERFS_HOME);
useWorker(file);
const through = await LayerFS.openReadOnly("website");
await through.fs.read("main", "/README.md");
assert.equal(worker.calls.at(-1).args.readOnly, true);
await through.close();
} finally {
worker.close();
rmSync(dir, { recursive: true, force: true });
}

console.log("PASS open_read_only");
16 changes: 15 additions & 1 deletion api/agent/test/packaging.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,19 @@ if (selected === "surface") {
assert.deepEqual(native.stores.slice(before), [["open", "/missing"], ["close", "/missing"]]);
// the options argument is gone: an object where it went is refused, never ignored
const asked = native.calls.length;
for (const call of [() => LayerFS.open("website", {}), () => LayerFS.open("website", { storePath: "/missing" }), () => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.init("fresh", "/src/fresh", 5000, {})]) {
for (const call of [() => LayerFS.open("website", {}), () => LayerFS.open("website", { storePath: "/missing" }), () => LayerFS.openReadOnly("website", {}), () => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.init("fresh", "/src/fresh", 5000, {})]) {
await assert.rejects(call(), { code: "InvalidInput", message: /takes no options: settings come from the environment/ });
}
assert.equal(native.calls.length, asked, "a refused options argument sends nothing");
// openReadOnly opens the Store with readOnly and refuses mutation at the binding
const reader = await LayerFS.openReadOnly("website");
assert.equal(reader.options.readOnly, true);
assert.deepEqual(native.stores.at(-1), ["open", "/fake/store.sqlite", true]);
await reader.fs.read("main", "/README.md");
assert.equal(native.args.at(-1).readOnly, true);
await assert.rejects(reader.fs.write("feat", "/README.md", "nope\n"), { code: "ReadOnly", message: /this Store is open read-only/ });
await assert.rejects(reader.branch.fork("other"), { code: "ReadOnly" });
await reader.close();
// LayerFS.init runs project.init, then the same open
const made = await LayerFS.init("fresh", "/src/fresh");
assert.deepEqual(native.calls.slice(-2), ["project.init", "project.list_branches"]);
Expand Down Expand Up @@ -194,6 +203,11 @@ if (selected === "surface") {
assert.equal(native.calls.length, local + 1, "the later client never reaches the addon");
assert.deepEqual(worker.calls.at(-1).op, "fs.read");
assert.equal(worker.calls.at(-1).open, true, "the worker opens the later client's Store");
assert.equal("readOnly" in worker.calls.at(-1).args, false);
const reader = await LayerFS.openReadOnly("website");
await reader.fs.read("main", "/README.md");
assert.equal(worker.calls.at(-1).args.readOnly, true);
await reader.close();
// Init's timeout reaches the worker unchanged; an omitted one is the 300 s default
await (await LayerFS.init("fresh", "/src/fresh", 2500)).close();
await (await LayerFS.init("fresh", "/src/fresh")).close();
Expand Down
5 changes: 3 additions & 2 deletions api/cli/src/argv.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ export class UsageError extends Error {

const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());

/** Splits the global options out of argv: --project <name>, --text, -h/--help, --version. */
/** Splits the global options out of argv: --project <name>, --text, --read-only, -h/--help, --version. */
export function globals(argv) {
const g = { rest: [], project: undefined, text: false, help: false, version: false };
const g = { rest: [], project: undefined, text: false, readOnly: false, help: false, version: false };
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t === "--") { g.rest.push(...argv.slice(i)); break; }
if (t === "--text") g.text = true;
else if (t === "--read-only") g.readOnly = true;
else if (t === "-h" || t === "--help") g.help = true;
else if (t === "--version") g.version = true;
else if (t === "--project" || t.startsWith("--project=")) {
Expand Down
1 change: 1 addition & 0 deletions api/cli/src/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ usage: layerfs <family> <call> [args] [options]
GLOBAL OPTIONS
--project <name> the project (default $LAYERFS_PROJECT)
--text readable output instead of JSON
--read-only open the Store for reads only; writes are refused ReadOnly
-h, --help help for the command

SETTINGS come from the environment, as for the SDK: LAYERFS_HOME, LAYERFS_STORE_PATH,
Expand Down
7 changes: 4 additions & 3 deletions api/cli/src/main.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// layerfs (#428): one @layerfs/agent call per command. It finds the command
// in the call table, parses its arguments, reads any input, opens the
// project with LayerFS.open, makes the call once, prints the result and
// closes. Nothing is retried. Exit 0 on success, 2 for a wrong command line
// project with LayerFS.open (or LayerFS.openReadOnly when --read-only),
// makes the call once, prints the result and closes. Nothing is retried. Exit 0 on success, 2 for a wrong command line
// (nothing called), 125 when LayerFS refused or failed the call; exec
// --stream exits with the command's own code.
import { readFileSync } from "node:fs";
Expand Down Expand Up @@ -55,11 +55,12 @@ export async function main(argv, io) {
const { LayerFS } = await import("@layerfs/agent");
let result;
if (entry.init) {
if (g.readOnly) throw new UsageError("--read-only cannot be used with init");
result = await entry.run(LayerFS, a, o, exits);
} else {
const project = g.project ?? process.env.LAYERFS_PROJECT;
if (!project) throw new UsageError("no LayerFS project: pass --project <name> or set LAYERFS_PROJECT");
const layerfs = await LayerFS.open(project);
const layerfs = await (g.readOnly ? LayerFS.openReadOnly : LayerFS.open)(project);
try {
result = await entry.run(layerfs, a, o, exits);
} finally {
Expand Down
21 changes: 21 additions & 0 deletions api/cli/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,27 @@ test("a missing project exits 2 before anything opens", () => {
assert.deepEqual(r.calls, []);
});

test("--read-only opens the Store for reads only", () => {
const r = cli(["--read-only", "fs", "stat", "main", "/a"]);
assert.equal(r.code, 0, r.err);
const open = r.calls.find(([op]) => op === "store.open");
assert.equal(open[1].readOnly, true);
assert.equal(r.made[0][1].readOnly, true);
});

test("--read-only refuses a file write", () => {
const r = cli(["--read-only", "fs", "write", "cart", "/a"], { input: "x\n" });
assert.equal(r.code, 125, r.err);
assert.equal(JSON.parse(r.err).code, "ReadOnly");
});

test("--read-only cannot be used with init", () => {
const r = cli(["--read-only", "init", "site", source]);
assert.equal(r.code, 2);
assert.match(r.err, /--read-only cannot be used with init/);
assert.deepEqual(r.calls, []);
});

test("--project wins over LAYERFS_PROJECT; an integration's prefixed name is not read", () => {
assert.equal(cli(["fs", "stat", "main", "/a", "--project", "other"]).made[0][1].project, "other");
assert.equal(cli(["--project=other", "fs", "stat", "main", "/a"]).made[0][1].project, "other");
Expand Down
Loading