From 118810487861369d4b3fcda27e74c3b9f6b5a06b Mon Sep 17 00:00:00 2001 From: vickeykumar Date: Sat, 14 Nov 2026 18:13:47 +0530 Subject: [PATCH] App Store: libraries and tools installed once on the server and switched on per signed-in user Apps are conda-forge bundles in a shared store (/store/apps), made once by the server (micromamba, outside any terminal's sandbox); each user's list (manifest) says which ones their terminal may read and run, and the Landlock helper allows exactly those folders. Requests and failure reports go to an admin queue; every node (gateway and workers) replays the catalog at start and by polling. Page: Apps dialog, My apps, hints from the terminal's own error; Genie action store_suggest; admin App Store view; apt list for all users. Design: LLD 18 and the canvas 'App Store: all screens and actions'. Not built: object-store tarballs, Node libraries, languages and plugins. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 8 + .github/workflows/ci.yml | 2 +- Dockerfile | 11 + README.md | 1 + docs/lld/18-app-store.md | 106 +++ e2e/tests/app-store.spec.js | 94 ++ src/Makefile | 4 +- src/appstore/appstore_test.go | 393 ++++++++ src/appstore/apt.go | 61 ++ src/appstore/env.go | 113 +++ src/appstore/installer.go | 229 +++++ src/appstore/service.go | 847 ++++++++++++++++++ src/appstore/store.go | 255 ++++++ src/containers/landlock.go | 5 + src/js/src/page/33-app-store.js | 317 +++++++ src/js/src/page/index.js | 1 + src/js/src/page/lib-store-hint.mjs | 24 + src/js/test/lib-store-hint.test.mjs | 28 + src/landlock/policy.go | 7 + src/landlock/policy_test.go | 25 + src/landlockhelper/main.go | 48 +- src/resources/about.html | 1 + .../chat-widget/src/agent-protocol.ts | 9 + src/resources/chat-widget/src/agent.ts | 7 + .../chat-widget/test/agent-protocol.test.ts | 13 + src/resources/css/ui-refresh.css | 39 + src/resources/index.html | 18 + src/resources/js/admin.js | 103 +++ src/resources/js/palette.js | 1 + src/resources/knowledge/app-store.md | 10 + src/server/admin_routes.go | 1 + src/server/agent.go | 3 +- src/server/appstore.go | 575 ++++++++++++ src/server/appstore_test.go | 156 ++++ src/server/knowledge_test.go | 2 + src/server/server.go | 3 + src/server/settings.go | 34 + src/utils/sandbox_env.go | 18 + 38 files changed, 3567 insertions(+), 5 deletions(-) create mode 100644 docs/lld/18-app-store.md create mode 100644 e2e/tests/app-store.spec.js create mode 100644 src/appstore/appstore_test.go create mode 100644 src/appstore/apt.go create mode 100644 src/appstore/env.go create mode 100644 src/appstore/installer.go create mode 100644 src/appstore/service.go create mode 100644 src/appstore/store.go create mode 100644 src/js/src/page/33-app-store.js create mode 100644 src/js/src/page/lib-store-hint.mjs create mode 100644 src/js/test/lib-store-hint.test.mjs create mode 100644 src/resources/knowledge/app-store.md create mode 100644 src/server/appstore.go create mode 100644 src/server/appstore_test.go diff --git a/.env.example b/.env.example index 397a93f5..32398d2e 100644 --- a/.env.example +++ b/.env.example @@ -141,6 +141,14 @@ OPENREPL_HOST=localhost # The filesystem sandbox of the REPLs (Landlock, docs/lld/14): auto (the default), off or require. #OPENREPL_LANDLOCK=auto +# The App Store (LLD 18): the folder where libraries and tools are installed once and shared (default /store; "off" switches the store off). +# Installs run micromamba (conda-forge): OPENREPL_MICROMAMBA is its path (default: found on the PATH), OPENREPL_STORE_PYTHON the Python +# the bundles are solved with, as 3.10 (default: read from python3). OPENREPL_STORE_INSTALLER=fake makes tiny bundles, for tests and development. +#OPENREPL_STORE_DIR=/store +#OPENREPL_MICROMAMBA= +#OPENREPL_STORE_PYTHON= +#OPENREPL_STORE_INSTALLER= + # The private network of a workspace for REPLs that have no kernel network namespace (docs/lld/16): auto (the default), off or require. #OPENREPL_NET_ISOLATION=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 353a96da..1934a363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: run: go build -o "$RUNNER_TEMP/gotty" gotty - name: Tests working-directory: src - run: go test -vet=off server utils user persist cachedb wsync meters reaper landlock netemu containers backend/localcommand objstore backup homesnap fileload livepreview + run: go test -vet=off server utils user persist cachedb wsync meters reaper landlock appstore netemu containers backend/localcommand objstore backup homesnap fileload livepreview - name: Tests with the race detector working-directory: src run: go test -vet=off -race gateway tunnel trusted diff --git a/Dockerfile b/Dockerfile index aa5f0ba2..7a77773b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,17 @@ COPY --from=build-image /opt/openrepl/scripts/run_app.sh /opt/scripts/ RUN chmod 755 /usr/local/bin/gotty /usr/local/bin/landlock-helper && chmod 755 /opt/scripts/run_app.sh +# The App Store (LLD 18) installs libraries and tools from conda-forge with micromamba, a single static program, into /store +# (outside /opt, which the filesystem sandbox lets every REPL read). If the download fails the image still builds: the store then +# says in the admin dashboard that micromamba is missing. +RUN mkdir -p /store && arch="$(dpkg --print-architecture)" && \ + case "$arch" in amd64) m=linux-64;; arm64) m=linux-aarch64;; *) m="";; esac && \ + if [ -n "$m" ]; then \ + (apt-get update && apt-get install -y --no-install-recommends bzip2 && \ + curl -fsSL "https://micro.mamba.pm/api/micromamba/$m/latest" | tar -xj -C /usr/local/bin --strip-components=1 bin/micromamba && \ + rm -rf /var/lib/apt/lists/*) || echo "micromamba was not installed: the App Store cannot install until it is"; \ + fi + WORKDIR /opt/openrepl ENV TERM=xterm diff --git a/README.md b/README.md index f1d0fcbd..f82643c5 100755 --- a/README.md +++ b/README.md @@ -658,6 +658,7 @@ The server-side settings come from environment variables, and gotty can load the | `OPENREPL_HOST` | The origin the chat proxy accepts. Default `localhost`. | `user.host` | | `OPENREPL_HOME_DIR` | The folder that holds the users' workspaces, one folder each. Default `/tmp/home/`. The Docker image sets `/home/openrepl/`, so that `/tmp` can stay open to the REPLs under the filesystem sandbox. It must be the same on a gateway and its workers. | | | `OPENREPL_LANDLOCK` | The filesystem sandbox of the REPLs (LLD 14): `auto` (the default) applies it where the kernel has Landlock and says in the log and on the admin health page where it does not, `off` switches it off, `require` refuses to start a REPL without it. | | +| `OPENREPL_STORE_DIR` | The folder of the App Store (LLD 18), where libraries and tools are installed once and shared by the users that switch them on: `/store` by default, `off` switches the store off. Keep it outside `/opt` (the sandbox lets every REPL read the other folders of `/opt`). `OPENREPL_MICROMAMBA` is the path of micromamba (default: the PATH), `OPENREPL_STORE_PYTHON` the Python the bundles are solved with (default: read from `python3`), `OPENREPL_STORE_INSTALLER=fake` makes tiny test bundles. | | | `OPENREPL_NET_ISOLATION` | The private network of a workspace for REPLs that have no kernel network namespace of their own (LLD 16): `auto` (the default) turns it on where a start-up probe passes and says in the log and on the admin health page where it does not, `off` switches it off, `require` refuses to start a REPL without it. | | | `OPENREPL_TMP_MAX_AGE` | A duration such as `1h`: the server deletes from `/tmp` what nobody has touched for that long. The Docker image sets `1h`. Not set: never. | | | `OPENREPL_FIREBASE_CONFIG` | The Firebase project the page signs in with, as JSON or base64 of JSON. Required for sign-in, shared sessions and the shared editor: there is no built-in project, and a server without it says so in its log and in the console of the page. See below. | | diff --git a/docs/lld/18-app-store.md b/docs/lld/18-app-store.md new file mode 100644 index 00000000..d27de578 --- /dev/null +++ b/docs/lld/18-app-store.md @@ -0,0 +1,106 @@ +# 18. App Store + +Status: built 2026-11-14 on `development` (design approved the same day: canvas "App Store: all screens and actions", 11 boards). Not deployed. +Packages `appstore` (`src/appstore`), `server/appstore.go`, the sandbox in `landlock` and `landlockhelper`, the page `js/src/page/33-app-store.js`, the admin +view `viewStore` in `resources/js/admin.js`, tests `appstore/appstore_test.go`, `server/appstore_test.go`, `landlock/policy_test.go`, `e2e/tests/app-store.spec.js`. + +## 1. What it is + +Libraries and tools (conda-forge packages) are installed **once** on a server into a shared store, never into a workspace. Each signed-in user has a list of the +ones that are on for them; their terminal can read and run exactly those and nothing else. Guests have no store. + +``` +/store/ (OPENREPL_STORE_DIR, "off" switches the store off; outside /opt on purpose: the sandbox lets every REPL read the other folders of /opt) + pkgs/ the download cache, one copy of every package file + apps/-/ a complete conda prefix (the package and everything it needs), hard-linked from the cache, made once; + .openrepl-bundle.json is written last: a bundle without it (an install that was cut) is never used + envs//manifest.json the bundles that are on for the user whose workspace folder is ["flask-3.0.2", ...] + envs//env.sh what a shell runs (. env.sh) to use them without a new terminal +``` + +A bundle is a whole conda prefix because conda programs find their libraries by the RPATH they were built with, relative to themselves: a package without its +dependencies beside it does not work. Python bundles are solved with the server's Python (`python=3.10` here, read from `python3`), so several bundles can be put on one +`PYTHONPATH` and the system's `python3` stays the one that runs. + +## 2. Who can do what + +| | Guest | Signed-in user | Admin | +|---|---|---|---| +| See the store, switch an installed app on or off for themselves | no | yes | yes | +| Install an app on the safe list | no | yes | yes | +| Ask for an app that is not on it (a request; a second asker joins it) | no | yes | | +| Report an install that failed, try it again (once a minute) | no | yes | yes | +| Approve or reject a request, install anything, resolve a report, remove an app, apt, settings | no | no | yes | + +Every `/store/api` call needs a signed-in account (`requestIdentity`) and a workspace that snapshots are kept of (`homesnap.IsKept`: the account's own folder, which is also the +name of its manifest folder). POSTs must be the page's own JSON (`sameOrigin`). The admin routes are `admin/store` (GET the picture, POST one decision; audited). + +## 3. The sandbox (Landlock) + +`containers.wrapWithLandlock` adds `--store-dir ` to the helper when the folder exists. The helper (`landlockhelper.storeFolders`), before it restricts itself and with the +server's rights, reads `/envs//manifest.json`, checks every name (`^[a-z0-9][a-z0-9._-]*-[0-9]...$`, no `/`, no `..`: **a bad entry stops the +terminal with status 126, it is never skipped**), leaves out a bundle this server does not have yet, then +- passes `landlock.Options.ExtraRO`: the folders of the bundles on the list and the user's own `envs/` folder (read and execute, never written, and never a folder that + holds the workspaces), and +- puts the bundles on the program's environment: their `bin` folders **after** the system's `PATH`, their `site-packages` in front of `PYTHONPATH` (and `lib/node_modules` in + `NODE_PATH`). There is no `LD_LIBRARY_PATH`: it would put conda's libraries in front of the system's and break the system's own programs. + +The terminal cannot write `/store` at all, so it cannot add itself to a list or change a bundle (a hard-linked file shares its inode with the cache). Python's bytecode cannot be +written into a bundle either; Python then simply does not cache it. + +Checked by hand 2026-11-14 with the real helper on Landlock (arm64 image): a user with `flask` on can `import flask`, run its tool, and sees `numpy` as "No module named"; listing +`/store/apps`, another bundle or another user's folder gives "Permission denied"; writing a bundle or one's own manifest is refused; `["../../etc"]` in a manifest stops the start. + +## 4. Installing (`appstore.Service`) + +State lives in the site's database (`persist`, file `appstore.db` or Firestore/MongoDB like the other databases): `app:` (name, pinned version, state installing / installed / +failed, size, log tail), `req:`, `rep:`, `user:` (the list), `apt`. The version is pinned by the store: the newest conda-forge build that solves with the server's Python +(`Installer.Resolve`, `micromamba create --dry-run --json`), and every node installs that same version. + +One install at a time (a mutex), as a goroutine: *resolve, download and link* (`micromamba create -p --override-channels -c conda-forge python=3.10 =`), write the marker, +record it, answer the request and the open report. A failure removes the half-made bundle, keeps the last lines of the log (the store's folder shortened) and the step it stopped at. The server runs the +installer outside any terminal's sandbox, so terminals need no internet. The store is refused when it is over its limit (`storeLimitMB`, default 2048). A server that stopped in the middle of +an install marks it failed at its next start (`Recover`). No micromamba on the server (`OPENREPL_MICROMAMBA`, or on the PATH) gives the installer `Missing`: every install says so, and the admin page +shows it. `OPENREPL_STORE_INSTALLER=fake` makes tiny bundles (a Python module of the package's name) for tests and development. + +## 5. Replay on every node + +`Service.Apply(snapshot)` makes a node's store match the catalog: missing bundles are installed (the same pinned versions), every user's `manifest.json` and `env.sh` are written, and, when the +snapshot is complete (`Full`: both listings of the database worked, so an outage is never taken for an empty catalog), bundles that are no longer in it are removed. +- Gateway or standalone: at start, every 10 minutes, and after each admin decision. +- A worker (no database): every minute it POSTs `/store/sync` to the gateway with the shared worker token (`Authorization: Bearer`, constant-time compare) and its state (what it has, what failed, its + size), and applies the catalog it gets back. The gateway keeps the last state of each worker for the Servers matrix. A change a user makes is therefore on a worker within a minute. +- A bundle already in the store is not fetched again, so a deploy that keeps `/store` costs nothing; one that wipes it rebuilds every bundle from conda-forge (the object-store tarballs of the design are **not + built**: the gateway and workers all have internet). +- apt: the admin's list (database) is part of the snapshot; each node installs what is missing with `apt-get install -y --no-install-recommends` (`SystemApt`; it needs root and apt-get, and says so when it + has neither) at every replay, and leaves what `dpkg -s` finds. A package that fails is said in that node's state and on the admin page (a card for `apt:`) and does not stop the others. These are for all users: + there is no switch. A server with the fake installer never runs apt-get. A node that loses its system between starts (a container) installs them again at its next start; for big packages the image is the better place. + +## 6. The page + +`33-app-store.js`: a dialog (`#appstore-dialog`) opened by the Apps button (shown to signed-in users: `body.is-signed-in`), the command menu entry "App Store", `AppStore.open()` and `AppStore.suggest(name)`. Browse (search; a name +nothing matches is looked up on conda-forge, `GET /store/api/info`, and can be asked for), My apps (what is on, asked for, failed). Cards for every state: available (Install), installing (the step; the page polls and +tells you when it is done even if the dialog is closed), installed (a switch, off until the user turns it on), failed (the reason and a log tail, Try again, Report to admin), requested, rejected (with the admin's reason). +After a switch the dialog offers "Use it in my active terminal": it asks first, then types `. ''` into the active terminal (open terminals keep what they had; new ones use the list). + +The terminal's own error: every 3 s, for a signed-in user with the store closed, the page reads the last lines of the active terminal (`lib-store-hint.mjs`, tested in `lib-store-hint.test.mjs`): "No module named 'flask'" +(a few module names are mapped to their package: PIL to pillow, yaml to pyyaml, ...) or "x: command not found". If the store has that app (on the safe list, installed, or asked for) and it is not on for the user, a notice +offers "Open the App Store" (or "Turn it on" for an installed one). One notice for each name in a visit; a name the store does not know is never offered; nothing is installed or typed by the notice. + +Genie: the agent action `store_suggest` (`{"type":"store_suggest","name":"flask"}`) opens the store on an app; **the user decides**. It needs no permission because it installs nothing. The agent prompt +(`server/agent.go`) says when to use it. + +## 7. Admin + +`viewStore` ("App Store" in the dashboard): requests (Approve and install, Reject with a reason), failures reported (Try again, Mark installed, Reject), installed apps with their size, how many users have each on, +and ready / failed / missing on each server (gateway and every worker that has synced), Remove (asks, says how many users lose it), install for everyone, the apt list, and the settings: the safe list, the size +limit and the switch (`SiteSettings.StoreSafeList`, `StoreLimitMB`, `StoreOff`). + +## 8. Not built, and not tested + +- The tarballs of finished bundles in the object store (nodes build from conda-forge). +- A user's own switch reaching a worker takes up to a minute (the poll); a terminal started in that minute does not have it yet. +- Node libraries (npm) and other kinds (languages, editor plugins): `NODE_PATH` is set for a bundle that has `lib/node_modules`, nothing installs them. +- A real micromamba install was **not run** here (the image has micromamba only after the Dockerfile's new step); the fake installer, the Go tests, the browser tests with the page's calls answered by a stand-in, and the + hand check of the sandbox were. +- A browser test cannot sign in: the signed-in screens are tested against stand-in answers. diff --git a/e2e/tests/app-store.spec.js b/e2e/tests/app-store.spec.js new file mode 100644 index 00000000..bc714e5a --- /dev/null +++ b/e2e/tests/app-store.spec.js @@ -0,0 +1,94 @@ +// The App Store (page/33-app-store.js, server/appstore.go, LLD 18). A browser test cannot sign in, so the signed-in side is +// checked against the page's own calls, answered here by a small stand-in for the server; what the real server does is in the +// Go tests (appstore, server) and in the sandbox test of the helper. +const { test, expect } = require("@playwright/test"); +const { waitForTerminalReady } = require("./helpers"); + +async function ready(page) { + await page.goto("/"); + await page.waitForFunction(() => window.AppStore && window.EditorTabs && window.FileBrowser && window.FileBrowser.ready()); + await waitForTerminalReady(page); +} + +test("a guest cannot use the App Store: no button, a sign-in card, and the server refuses", async ({ page }) => { + await ready(page); + await expect(page.locator("#appstore-button")).toBeHidden(); + expect(await page.evaluate(() => window.AppStore.available())).toBe(false); + const status = await page.evaluate(() => fetch("/store/api/list", { credentials: "same-origin" }).then((r) => r.status)); + expect([401, 503]).toContain(status); + const post = await page.evaluate(() => fetch("/store/api/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "flask" }) }).then((r) => r.status)); + expect([401, 503]).toContain(post); + await page.evaluate(() => window.AppStore.open()); + await expect(page.locator("#appstore-dialog")).toBeVisible(); + await expect(page.locator(".appstore__guest")).toContainText("signed-in users"); + await page.locator("#appstore-close").click(); + await expect(page.locator("#appstore-dialog")).toBeHidden(); +}); + +test("a signed-in user installs, switches on and applies an app; a failure can be reported", async ({ page }) => { + await ready(page); + const apps = { + flask: { name: "flask", kind: "lib", state: "available", safe: true }, + numpy: { name: "numpy", version: "1.0.0", id: "numpy-1.0.0", kind: "lib", state: "installed", safe: true, on: false, sizeMB: 96 }, + pillow: { name: "pillow", kind: "lib", state: "failed", safe: true, err: "Resolving the dependencies: not compatible", log: "Found conflicts!" }, + }; + const on = []; + const calls = []; + const views = () => Object.values(apps).map((v) => ({ ...v, on: on.includes(v.id) })); + await page.route("**/store/api/**", async (route) => { + const req = route.request(); + const what = new URL(req.url()).pathname.split("/store/api/")[1]; + const body = req.method() === "POST" ? req.postDataJSON() : {}; + if (req.method() === "POST") calls.push([what, body]); + if (what === "install") Object.assign(apps[body.name], { state: "installed", version: "1.0.0", id: body.name + "-1.0.0", sizeMB: 14, err: undefined }); + if (what === "switch") { const i = on.indexOf(body.id); if (body.on && i < 0) on.push(body.id); if (!body.on && i >= 0) on.splice(i, 1); } + if (what === "report") Object.assign(apps[body.name], { reported: true, mine: true }); + await route.fulfill({ contentType: "application/json", body: JSON.stringify({ ok: true, views: views(), on, envScript: "/store/envs/me/env.sh", installer: "fake" }) }); + }); + await page.evaluate(() => document.body.classList.add("is-signed-in")); + await expect(page.locator("#appstore-button")).toBeVisible(); + await page.locator("#appstore-button").click(); + await expect(page.locator('[data-app="flask"]')).toContainText("Install"); + await expect(page.locator('[data-app="numpy"] .appstore__switch')).toHaveAttribute("aria-checked", "false"); + await expect(page.locator('[data-app="pillow"]')).toContainText("Failed"); + // search narrows the list + await page.locator("#appstore-search").fill("num"); + await expect(page.locator(".appstore__app")).toHaveCount(1); + // a name the store does not know is looked up on conda-forge and can be asked for + await page.route("**/store/api/info?name=*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ name: "ffmpeg", summary: "Convert audio and video.", version: "7.1" }) })); + await page.locator("#appstore-search").fill("ffmpeg"); + await expect(page.locator('[data-app="ffmpeg"]')).toContainText("Convert audio and video"); + await expect(page.locator('[data-app="ffmpeg"] button')).toHaveText("Request ffmpeg"); + await page.locator("#appstore-search").fill(""); + // install an app on the safe list + await page.locator('[data-app="flask"] button', { hasText: "Install" }).click(); + expect(calls.at(-1)).toEqual(["install", { name: "flask", kind: "lib" }]); + await expect(page.locator('[data-app="flask"]')).toContainText("Installed"); + // switch it on, then off + await page.locator('[data-app="flask"] .appstore__switch').click(); + expect(calls.at(-1)).toEqual(["switch", { id: "flask-1.0.0", on: true }]); + await expect(page.locator('[data-app="flask"] .appstore__switch')).toHaveAttribute("aria-checked", "true"); + // my apps lists what is on, and offers to apply it to the open terminal + await page.locator("#appstore-tab-mine").click(); + await expect(page.locator(".appstore__app")).toHaveCount(1); + await expect(page.locator(".appstore__apply")).toContainText("Terminals that are open keep what they had"); + await page.evaluate(() => { window.__sent = []; window.sendToActiveTerminal = (t) => window.__sent.push(t); window.confirm = () => true; }); + await page.locator(".appstore__apply button").click(); + expect(await page.evaluate(() => window.__sent)).toEqual([". '/store/envs/me/env.sh'\r"]); + // a failure is reported to the admin + await page.locator("#appstore-tab-browse").click(); + await page.evaluate(() => { window.prompt = () => "I need it for images"; }); + await page.locator('[data-app="pillow"] button', { hasText: "Report to admin" }).click(); + expect(calls.at(-1)).toEqual(["report", { name: "pillow", note: "I need it for images" }]); + await expect(page.locator('[data-app="pillow"]')).toContainText("You told the admin"); +}); + +test("Genie and the terminal open the store on an app the user needs", async ({ page }) => { + await ready(page); + await page.route("**/store/api/list", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ views: [{ name: "flask", kind: "lib", state: "available", safe: true }], on: [], envScript: "/store/envs/me/env.sh" }) })); + await page.evaluate(() => document.body.classList.add("is-signed-in")); + await page.evaluate(() => window.AppStore.suggest("flask")); + await expect(page.locator("#appstore-dialog")).toBeVisible(); + await expect(page.locator("#appstore-search")).toHaveValue("flask"); + await expect(page.locator('[data-app="flask"]')).toBeVisible(); +}); diff --git a/src/Makefile b/src/Makefile index 56dd5a25..60bf75e8 100755 --- a/src/Makefile +++ b/src/Makefile @@ -12,7 +12,7 @@ ifneq ($(UNAME), Linux) PRE_REQ = tools endif -gotty: go-toolchain asset landlock-helper ${TARGET}/main.go utils/*.go server/*.go webtty/*.go filebrowser/*.go backend/*.go backend/localcommand/*.go containers/*.go netemu/*.go cachedb/*.go meters/*.go reaper/*.go user/*.go cookie/*.go ${TARGET}/Makefile +gotty: go-toolchain asset landlock-helper appstore/*.go ${TARGET}/main.go utils/*.go server/*.go webtty/*.go filebrowser/*.go backend/*.go backend/localcommand/*.go containers/*.go netemu/*.go cachedb/*.go meters/*.go reaper/*.go user/*.go cookie/*.go ${TARGET}/Makefile @echo "building gotty: "${TARGET} cd ${TARGET} && \ make @@ -27,7 +27,7 @@ asset: go-bindata bindata/static/js/gotty-bundle.js bindata/static/js/chat-widge # The helper that puts the filesystem sandbox (Landlock, LLD 14) on a REPL before it runs. Without cgo, # so that it is a static program that loads nothing from the environment of the REPL. .PHONY: landlock-helper -landlock-helper: go-toolchain landlock/*.go landlockhelper/*.go netemu/*.go +landlock-helper: go-toolchain landlock/*.go landlockhelper/*.go netemu/*.go appstore/*.go CGO_ENABLED=0 GO111MODULE=off $(GOROOT)/bin/go build -o $(ROOT)/bin/landlock-helper landlockhelper .PHONY: go-toolchain diff --git a/src/appstore/appstore_test.go b/src/appstore/appstore_test.go new file mode 100644 index 00000000..1f426303 --- /dev/null +++ b/src/appstore/appstore_test.go @@ -0,0 +1,393 @@ +package appstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +type memKV struct { + mu sync.Mutex + m map[string][]byte +} + +func newKV() *memKV { return &memKV{m: map[string][]byte{}} } +func (k *memKV) Store(key, v []byte) error { + k.m[string(key)] = append([]byte(nil), v...) + return nil +} +func (k *memKV) Fetch(key []byte) ([]byte, error) { + v, ok := k.m[string(key)] + if !ok { + return nil, errors.New("not found") + } + return v, nil +} +func (k *memKV) Delete(key []byte) error { delete(k.m, string(key)); return nil } +func (k *memKV) Commit() error { return nil } +func (k *memKV) Each(fn func(k, v []byte) bool) error { + for key, v := range k.m { + if !fn([]byte(key), v) { + break + } + } + return nil +} + +func newService(t *testing.T, inst Installer, safe ...string) (*Service, Store) { + t.Helper() + st := Store{Dir: t.TempDir()} + svc := New(Config{Store: st, KV: newKV(), Installer: inst, Safe: func() []string { return safe }}) + return svc, st +} + +func TestNames(t *testing.T) { + for _, ok := range []string{"flask", "scikit-learn", "numpy", "a.b_c-d", "python3"} { + if !ValidName(ok) { + t.Errorf("%q should be a name", ok) + } + } + for _, bad := range []string{"", "Flask", "../x", "a/b", "-a", "a b", "a..b", strings.Repeat("a", 65)} { + if ValidName(bad) { + t.Errorf("%q should not be a name", bad) + } + } + for _, ok := range []string{"flask-3.0.2", "scikit-learn-1.5.2", "numpy-2.1.0", "x-1"} { + if !ValidBundle(ok) { + t.Errorf("%q should be a bundle", ok) + } + } + for _, bad := range []string{"flask", "../flask-1", "flask-1/../x", "flask-1 ", "flask-x", "-1", "flask-1..2", "a/b-1"} { + if ValidBundle(bad) { + t.Errorf("%q should not be a bundle", bad) + } + } + if !ValidHome("vickohna") || !ValidHome("guest-0e21e4b2e2") || ValidHome("..") || ValidHome("a/b") || ValidHome("") { + t.Error("homes") + } + if !ValidApt("postgresql-client") || ValidApt("a b") || ValidApt("-x") { + t.Error("apt names") + } +} + +func TestVersionFromSolve(t *testing.T) { + raw := []byte(`{"actions":{"LINK":[{"name":"werkzeug","version":"3.0.4"},{"name":"flask","version":"3.0.2"}]}}`) + if v, err := versionFromSolve(raw, "flask"); err != nil || v != "3.0.2" { + t.Errorf("got %q %v", v, err) + } + if _, err := versionFromSolve(raw, "numpy"); err == nil { + t.Error("a package that is not in the answer is an error") + } + if _, err := versionFromSolve([]byte("not json"), "flask"); err == nil { + t.Error("bad json") + } +} + +func TestManifestAndEnv(t *testing.T) { + st := Store{Dir: t.TempDir()} + if err := st.Ready(); err != nil { + t.Fatal(err) + } + // a bundle that is not finished cannot be put on a list + if err := st.WriteManifest("ann", []string{"flask-3.0.2"}); err == nil { + t.Fatal("an unfinished bundle must be refused") + } + f := Fake{} + dest := st.BundlePath("flask-3.0.2") + if err := f.Install(context.Background(), "flask", "3.0.2", dest, os.Stderr); err != nil { + t.Fatal(err) + } + if st.HasBundle("flask-3.0.2") { + t.Fatal("no marker yet") + } + if _, err := st.Finish("flask-3.0.2", "flask", "3.0.2"); err != nil { + t.Fatal(err) + } + if got := st.Bundles(); !reflect.DeepEqual(got, []string{"flask-3.0.2"}) { + t.Fatalf("bundles %v", got) + } + if err := st.WriteManifest("ann", []string{"flask-3.0.2", "flask-3.0.2"}); err != nil { + t.Fatal(err) + } + ids, err := st.Manifest("ann") + if err != nil || !reflect.DeepEqual(ids, []string{"flask-3.0.2"}) { + t.Fatalf("manifest %v %v", ids, err) + } + if none, err := st.Manifest("bob"); err != nil || len(none) != 0 { + t.Fatalf("no manifest is an empty list: %v %v", none, err) + } + script, _ := os.ReadFile(filepath.Join(st.HomeDir("ann"), "env.sh")) + if !strings.Contains(string(script), "PYTHONPATH=") || !strings.Contains(string(script), "flask-3.0.2/lib/python3.10/site-packages") || + !strings.Contains(string(script), `export PATH="$PATH":`) { + t.Fatalf("env.sh:\n%s", script) + } + env := VarsFor(st.Dir, ids).Apply([]string{"PATH=/usr/bin", "HOME=/h"}) + joined := strings.Join(env, "\n") + if !strings.Contains(joined, "PATH=/usr/bin:"+st.Dir+"/apps/flask-3.0.2/bin") || !strings.Contains(joined, "PYTHONPATH="+st.Dir) || strings.Contains(joined, "LD_LIBRARY_PATH") { + t.Fatalf("env:\n%s", joined) + } + // a bad entry in a manifest file is an error, never skipped + bad := filepath.Join(st.HomeDir("eve"), "manifest.json") + os.MkdirAll(filepath.Dir(bad), 0755) + for _, content := range []string{`["../../etc"]`, `["flask-3.0.2","/x-1"]`, `{"a":1}`} { + os.WriteFile(bad, []byte(content), 0644) + if _, err := ReadManifest(bad); err == nil { + t.Errorf("%s must be refused", content) + } + } +} + +func TestDirSizeCountsHardLinksOnce(t *testing.T) { + d := t.TempDir() + os.WriteFile(filepath.Join(d, "a"), make([]byte, 1000), 0644) + os.Link(filepath.Join(d, "a"), filepath.Join(d, "b")) + if got := DirSize(d); got != 1000 { + t.Fatalf("size %d", got) + } +} + +func TestInstallSafeAndRequest(t *testing.T) { + svc, st := newService(t, Fake{}, "flask") + if err := svc.Install("ann", "numpy", "lib", false); !errors.Is(err, ErrNeedsApproval) { + t.Fatalf("not on the safe list: %v", err) + } + if err := svc.Install("ann", "Flask", "lib", false); err != nil { + t.Fatal(err) + } + svc.Wait() + apps := svc.Apps() + if len(apps) != 1 || apps[0].State != StateInstalled || apps[0].ID != "flask-1.0.0" || apps[0].Size <= 0 || !st.HasBundle("flask-1.0.0") { + t.Fatalf("apps %+v", apps) + } + if err := svc.Install("bob", "flask", "lib", false); !errors.Is(err, ErrInstalled) { + t.Fatalf("again: %v", err) + } + // a request is joined by a second asker, then approved: installed, and the request is gone + r, err := svc.Request("ann", "numpy", "lib", "arrays") + if err != nil || r.Status != "waiting" { + t.Fatal(r, err) + } + svc.Request("bob", "numpy", "lib", "") + if reqs := svc.Requests(); len(reqs) != 1 || len(reqs[0].Askers) != 2 || reqs[0].Reason != "arrays" { + t.Fatalf("requests %+v", reqs) + } + if err := svc.Approve("root", "numpy"); err != nil { + t.Fatal(err) + } + svc.Wait() + if len(svc.Requests()) != 0 || len(svc.Apps()) != 2 { + t.Fatalf("after approval: %+v %+v", svc.Requests(), svc.Apps()) + } + // rejected with a reason + svc.Request("ann", "ffmpeg", "tool", "") + if err := svc.Reject("root", "ffmpeg", "too big"); err != nil { + t.Fatal(err) + } + var seen bool + for _, v := range svc.Views("ann", "ann") { + if v.Name == "ffmpeg" && v.State == "rejected" && v.Answer == "too big" && v.Mine { + seen = true + } + } + if !seen { + t.Fatalf("views %+v", svc.Views("ann", "ann")) + } +} + +func TestFailureReportAndAdminFix(t *testing.T) { + inst := Fake{Fail: map[string]bool{"pillow": true}} + svc, st := newService(t, inst, "pillow") + svc.Install("ann", "pillow", "lib", false) + svc.Wait() + a := svc.Apps()[0] + if a.State != StateFailed || !strings.Contains(a.Err, "Resolving the dependencies") || a.ID != "" { + t.Fatalf("%+v", a) + } + if err := svc.Install("ann", "pillow", "lib", false); !errors.Is(err, ErrTooSoon) { + t.Fatalf("a retry within a minute: %v", err) + } + if err := svc.Report("ann", "pillow", "I need it"); err != nil { + t.Fatal(err) + } + svc.Report("bob", "pillow", "") + reps := svc.Reports() + if len(reps) != 1 || len(reps[0].Reporters) != 2 || reps[0].Note != "I need it" || reps[0].Status != "open" { + t.Fatalf("%+v", reps) + } + if err := svc.Report("ann", "nothing", ""); err == nil { + t.Fatal("only a failed app can be reported") + } + // the admin fixes the installer, retries, and everyone sees Installed; the report is closed + svc.cfg.Installer = Fake{} + if err := svc.ResolveReport("root", "pillow", "retry", ""); err != nil { + t.Fatal(err) + } + svc.Wait() + if a := svc.Apps()[0]; a.State != StateInstalled || !st.HasBundle(a.ID) { + t.Fatalf("%+v", a) + } + if reps := svc.Reports(); reps[0].Status != "fixed" { + t.Fatalf("%+v", reps) + } +} + +func TestSwitchAndRemove(t *testing.T) { + svc, st := newService(t, Fake{}, "flask", "numpy") + svc.Install("ann", "flask", "lib", false) + svc.Install("ann", "numpy", "lib", false) + svc.Wait() + if err := svc.Switch("ann", "flask-1.0.0", true); err != nil { + t.Fatal(err) + } + svc.Switch("ann", "numpy-1.0.0", true) + svc.Switch("bob", "flask-1.0.0", true) + svc.Switch("ann", "numpy-1.0.0", false) + if got, _ := st.Manifest("ann"); !reflect.DeepEqual(got, []string{"flask-1.0.0"}) { + t.Fatalf("ann: %v", got) + } + if err := svc.Switch("ann", "nothing-9", true); !errors.Is(err, ErrUnknown) { + t.Fatalf("an app that is not installed: %v", err) + } + if err := svc.Switch("../ann", "flask-1.0.0", true); err == nil { + t.Fatal("a bad home") + } + views := svc.Views("ann", "ann") + on := map[string]bool{} + for _, v := range views { + on[v.Name] = v.On + } + if !on["flask"] || on["numpy"] { + t.Fatalf("views %+v", views) + } + n, err := svc.Remove("root", "flask") + if err != nil || n != 2 { + t.Fatalf("remove: %d %v", n, err) + } + if got, _ := st.Manifest("ann"); len(got) != 0 { + t.Fatalf("ann after: %v", got) + } + if st.HasBundle("flask-1.0.0") || len(svc.Apps()) != 1 { + t.Fatal("the bundle and the record must be gone") + } +} + +func TestStoreFull(t *testing.T) { + svc, _ := newService(t, Fake{}, "flask", "numpy") + svc.cfg.LimitMB = func() int { return 1 } + svc.Install("ann", "flask", "lib", false) + svc.Wait() + os.WriteFile(filepath.Join(svc.cfg.Store.Dir, "big"), make([]byte, 2<<20), 0644) + if err := svc.Install("ann", "numpy", "lib", false); !errors.Is(err, ErrFull) { + t.Fatalf("%v", err) + } +} + +func TestApplyReplaysOnAnotherNode(t *testing.T) { + a, _ := newService(t, Fake{}, "flask", "numpy") + a.Install("ann", "flask", "lib", false) + a.Install("ann", "numpy", "lib", false) + a.Wait() + a.Switch("ann", "flask-1.0.0", true) + a.Switch("bob", "numpy-1.0.0", true) + snap := a.Snapshot() + if len(snap.Apps) != 2 || !snap.Full || len(snap.Users) != 2 { + t.Fatalf("snapshot %+v", snap) + } + // a second node starts empty and a worker joins: it gets the same bundles and the same lists + b, bst := newService(t, Fake{}) + state := b.Apply(context.Background(), snap) + if len(state.Failed) != 0 || !reflect.DeepEqual(state.Have, []string{"flask-1.0.0", "numpy-1.0.0"}) { + t.Fatalf("state %+v", state) + } + if got, _ := bst.Manifest("ann"); !reflect.DeepEqual(got, []string{"flask-1.0.0"}) { + t.Fatalf("ann on b: %v", got) + } + // the app is removed on the first node: the next apply removes it on the second, and empties the list + a.Remove("root", "flask") + b.Apply(context.Background(), a.Snapshot()) + if bst.HasBundle("flask-1.0.0") { + t.Fatal("removed on the first node, still here") + } + if got, _ := bst.Manifest("ann"); len(got) != 0 { + t.Fatalf("ann on b after: %v", got) + } + // a failing install on a node is reported and does not stop the others + c, _ := newService(t, Fake{Fail: map[string]bool{"numpy": true}}) + st2 := c.Apply(context.Background(), a.Snapshot()) + if len(st2.Failed) != 1 || st2.Failed["numpy-1.0.0"] == "" { + t.Fatalf("%+v", st2) + } +} + +func TestRecoverMarksInterruptedInstall(t *testing.T) { + svc, _ := newService(t, Fake{}, "flask") + svc.put("app:flask", App{Name: "flask", State: StateInstalling, At: time.Now().Unix()}) + svc.Recover() + if a := svc.Apps()[0]; a.State != StateFailed || !strings.Contains(a.Err, "stopped") { + t.Fatalf("%+v", a) + } +} + +func TestAptList(t *testing.T) { + svc, _ := newService(t, Fake{}) + if err := svc.SetApt([]string{"postgresql-client", "jq", "jq"}); err != nil { + t.Fatal(err) + } + if got := svc.Apt(); !reflect.DeepEqual(got, []string{"postgresql-client", "jq"}) { + t.Fatalf("%v", got) + } + if err := svc.SetApt([]string{"ok", "bad name"}); err == nil { + t.Fatal("a bad name is refused") + } +} + +func TestCleanLog(t *testing.T) { + got := CleanLog("error in /var/store/apps/x-1/lib\n"+strings.Repeat("line\n", 600), "/var/store") + if strings.Contains(got, "/var/store") || len(got) > 1600 { + t.Fatalf("%d %q", len(got), got[:60]) + } +} + +type fakeApt struct { + have map[string]bool + fail map[string]bool + ran []string +} + +func (f *fakeApt) Installed(p string) bool { return f.have[p] } +func (f *fakeApt) Install(ctx context.Context, p string) error { + f.ran = append(f.ran, p) + if f.fail[p] { + return errors.New("no such package") + } + f.have[p] = true + return nil +} + +func TestApplyInstallsTheApprovedAptPackagesOnce(t *testing.T) { + svc, _ := newService(t, Fake{}) + apt := &fakeApt{have: map[string]bool{"jq": true}, fail: map[string]bool{"nonexistent-pkg": true}} + svc.cfg.Apt = apt + snap := Snapshot{Full: true, Apt: []string{"jq", "postgresql-client", "nonexistent-pkg", "bad name"}} + st := svc.Apply(context.Background(), snap) + if !reflect.DeepEqual(apt.ran, []string{"postgresql-client", "nonexistent-pkg"}) { + t.Fatalf("ran %v: what is there is left alone, and a bad name never reaches apt", apt.ran) + } + if len(st.Failed) != 1 || st.Failed["apt:nonexistent-pkg"] == "" { + t.Fatalf("%+v", st.Failed) + } + apt.ran = nil + svc.Apply(context.Background(), Snapshot{Full: true, Apt: []string{"jq", "postgresql-client"}}) + if len(apt.ran) != 0 { + t.Fatalf("a package that is installed is not installed again: %v", apt.ran) + } + // a server with no runner does nothing + svc.cfg.Apt = nil + svc.Apply(context.Background(), snap) +} diff --git a/src/appstore/apt.go b/src/appstore/apt.go new file mode 100644 index 00000000..531632e6 --- /dev/null +++ b/src/appstore/apt.go @@ -0,0 +1,61 @@ +package appstore + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// AptRunner installs Debian packages on one node. The packages an admin approves are for every user: they go into the system itself +// (as root, from the node's own package sources), not into the store, so there is no switch for them. +type AptRunner interface { + Installed(pkg string) bool + Install(ctx context.Context, pkg string) error +} + +// SystemApt is the real one: dpkg to ask, apt-get to install. It needs root and apt-get; a node that has neither says so. +type SystemApt struct{} + +func (SystemApt) Installed(pkg string) bool { + return exec.Command("dpkg", "-s", pkg).Run() == nil +} + +func (SystemApt) Install(ctx context.Context, pkg string) error { + if !ValidApt(pkg) { + return fmt.Errorf("%q is not a package name", pkg) + } + if os.Geteuid() != 0 { + return fmt.Errorf("this server does not run as root, so it cannot install %s", pkg) + } + if _, err := exec.LookPath("apt-get"); err != nil { + return fmt.Errorf("this server has no apt-get") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + c := exec.CommandContext(ctx, "apt-get", "install", "-y", "--no-install-recommends", pkg) + c.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") + out, err := c.CombinedOutput() + if err != nil { + return fmt.Errorf("apt-get could not install %s: %s", pkg, tail(strings.TrimSpace(string(out)), 300)) + } + return nil +} + +// applyApt makes the node have the approved packages. A package that is there already is left alone, so a node that keeps its system +// between starts does nothing; a failure is said in the node's state and does not stop the others. +func (s *Service) applyApt(ctx context.Context, list []string, st *NodeState) { + if s.cfg.Apt == nil || len(list) == 0 { + return + } + for _, p := range list { + if !ValidApt(p) || s.cfg.Apt.Installed(p) { + continue + } + if err := s.cfg.Apt.Install(ctx, p); err != nil { + st.Failed["apt:"+p] = tail(err.Error(), 300) + } + } +} diff --git a/src/appstore/env.go b/src/appstore/env.go new file mode 100644 index 00000000..09d0d78d --- /dev/null +++ b/src/appstore/env.go @@ -0,0 +1,113 @@ +package appstore + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Vars is what a terminal gets so that it can use the bundles that are on for it. PathAdd goes after the system's PATH (the +// system's python3 stays the one that runs), the others go before what is there. There is no LD_LIBRARY_PATH: a conda program +// finds its libraries by the RPATH it was built with, and a path of conda libraries in front of the system's would break the +// system's own programs. +type Vars struct { + PathAdd []string // bin folders + PythonPath []string // site-packages folders + NodePath []string // lib/node_modules folders +} + +// VarsFor works out the folders of the bundles. Only folders that exist are listed. The names must be valid bundle names: +// the caller has read them from a manifest (ReadManifest checks them). +func VarsFor(dir string, ids []string) Vars { + var v Vars + for _, id := range ids { + if !ValidBundle(id) { + continue + } + root := filepath.Join(dir, "apps", id) + if isDir(filepath.Join(root, "bin")) { + v.PathAdd = append(v.PathAdd, filepath.Join(root, "bin")) + } + sp, _ := filepath.Glob(filepath.Join(root, "lib", "python3*", "site-packages")) + sort.Strings(sp) + for _, p := range sp { + if isDir(p) { + v.PythonPath = append(v.PythonPath, p) + } + } + if isDir(filepath.Join(root, "lib", "node_modules")) { + v.NodePath = append(v.NodePath, filepath.Join(root, "lib", "node_modules")) + } + } + return v +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +// Apply puts the folders in front of (or, for PATH, behind) what an environment already has. env is a list of NAME=value. +func (v Vars) Apply(env []string) []string { + get := func(k string) string { + for _, e := range env { + if strings.HasPrefix(e, k+"=") { + return e[len(k)+1:] + } + } + return "" + } + set := func(k, val string) { + for i, e := range env { + if strings.HasPrefix(e, k+"=") { + env[i] = k + "=" + val + return + } + } + env = append(env, k+"="+val) + } + join := func(first []string, then string) string { + out := strings.Join(first, ":") + if then != "" { + if out != "" { + out += ":" + } + out += then + } + return out + } + if len(v.PathAdd) > 0 { + p := get("PATH") + if p == "" { + p = "/usr/local/bin:/usr/bin:/bin" + } + set("PATH", p+":"+strings.Join(v.PathAdd, ":")) + } + if len(v.PythonPath) > 0 { + set("PYTHONPATH", join(v.PythonPath, get("PYTHONPATH"))) + } + if len(v.NodePath) > 0 { + set("NODE_PATH", join(v.NodePath, get("NODE_PATH"))) + } + return env +} + +// EnvScript is the env.sh of a user: what a shell runs ( . env.sh ) to use the bundles that are on, without a new terminal. +func EnvScript(dir string, ids []string) string { + v := VarsFor(dir, ids) + var b strings.Builder + b.WriteString("# made by OpenREPL's App Store: the bundles that are on for you. Do not edit.\n") + q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + if len(v.PathAdd) > 0 { + fmt.Fprintf(&b, "export PATH=\"$PATH\":%s\n", q(strings.Join(v.PathAdd, ":"))) + } + if len(v.PythonPath) > 0 { + fmt.Fprintf(&b, "export PYTHONPATH=%s${PYTHONPATH:+:$PYTHONPATH}\n", q(strings.Join(v.PythonPath, ":"))) + } + if len(v.NodePath) > 0 { + fmt.Fprintf(&b, "export NODE_PATH=%s${NODE_PATH:+:$NODE_PATH}\n", q(strings.Join(v.NodePath, ":"))) + } + return b.String() +} diff --git a/src/appstore/installer.go b/src/appstore/installer.go new file mode 100644 index 00000000..1e07eb32 --- /dev/null +++ b/src/appstore/installer.go @@ -0,0 +1,229 @@ +package appstore + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Installer makes bundles. The real one runs micromamba against conda-forge; a test, or a dev server without micromamba, uses +// the fake. The server runs it outside any terminal's sandbox, so a terminal needs no network for an install. +type Installer interface { + // Resolve says which version of a package an install would get now (the newest that works with the server's Python). + Resolve(ctx context.Context, name string) (string, error) + // Install makes the finished contents of a bundle in dest (which does not exist yet); what it says goes to log. It does not + // write the marker: the caller does, after its own checks. + Install(ctx context.Context, name, version, dest string, log io.Writer) error + // Name says which installer this is, for the admin page. + Name() string +} + +// ---- micromamba ---------------------------------------------------------------------------------------------------------- + +// Mamba installs from conda-forge with micromamba. Every bundle is a whole conda prefix made of hard links to the shared cache +// in PkgsDir, so a package that two bundles need is on the disk once. +type Mamba struct { + Bin string // the micromamba program + Dir string // the store (its pkgs folder is the cache) + Python string // the Python of the server, as "3.10": every bundle is solved with it + Timeout time.Duration +} + +func (m Mamba) Name() string { return "micromamba (conda-forge)" } + +func (m Mamba) cmd(ctx context.Context, args ...string) *exec.Cmd { + c := exec.CommandContext(ctx, m.Bin, args...) + c.Env = append(os.Environ(), + "MAMBA_ROOT_PREFIX="+filepath.Join(m.Dir, "mamba"), + "CONDA_PKGS_DIRS="+filepath.Join(m.Dir, "pkgs"), + "MAMBA_NO_BANNER=1", "MAMBA_NO_RC=1", "CONDARC=/dev/null") + return c +} + +func (m Mamba) py() string { + if m.Python == "" { + return "3.10" + } + return m.Python +} + +func (m Mamba) common() []string { + return []string{"--override-channels", "-c", "conda-forge", "--no-rc", "-y", "python=" + m.py()} +} + +func (m Mamba) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { + t := m.Timeout + if t == 0 { + t = 15 * time.Minute + } + return context.WithTimeout(ctx, t) +} + +func (m Mamba) Resolve(ctx context.Context, name string) (string, error) { + if !ValidName(name) { + return "", fmt.Errorf("%q is not a package name", name) + } + ctx, cancel := m.withTimeout(ctx) + defer cancel() + args := append([]string{"create", "--dry-run", "--json", "-n", "openrepl-probe"}, m.common()...) + args = append(args, name) + var out, errb bytes.Buffer + c := m.cmd(ctx, args...) + c.Stdout, c.Stderr = &out, &errb + if err := c.Run(); err != nil { + return "", fmt.Errorf("conda-forge could not solve %s: %s", name, tail(errb.String()+out.String(), 600)) + } + return versionFromSolve(out.Bytes(), name) +} + +// versionFromSolve reads the version of a package from the JSON of a dry run. +func versionFromSolve(raw []byte, name string) (string, error) { + var res struct { + Actions struct { + Link []solvedPkg `json:"LINK"` + Fetch []solvedPkg `json:"FETCH"` + } `json:"actions"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return "", errors.New("the answer of micromamba could not be read") + } + for _, list := range [][]solvedPkg{res.Actions.Link, res.Actions.Fetch} { + for _, p := range list { + if p.Name == name && ValidVersion(p.Version) { + return p.Version, nil + } + } + } + return "", fmt.Errorf("conda-forge has no %s for this server's Python", name) +} + +type solvedPkg struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func (m Mamba) Install(ctx context.Context, name, version, dest string, log io.Writer) error { + if !ValidName(name) || !ValidVersion(version) { + return fmt.Errorf("%q %q is not a package", name, version) + } + ctx, cancel := m.withTimeout(ctx) + defer cancel() + args := append([]string{"create", "-p", dest}, m.common()...) + args = append(args, name+"="+version) + c := m.cmd(ctx, args...) + c.Stdout, c.Stderr = log, log + return c.Run() +} + +// ---- the fake ------------------------------------------------------------------------------------------------------------ + +// Fake makes small bundles with a Python module of the package's name, for tests and for a dev server that has no micromamba. +// Failing names fail, as a conda install that cannot be solved does. +type Fake struct { + Version string // what every package resolves to, "1.0.0" when empty + Fail map[string]bool // names that cannot be installed + Delay time.Duration +} + +func (f Fake) Name() string { return "fake (tests and development)" } + +func (f Fake) ver() string { + if f.Version == "" { + return "1.0.0" + } + return f.Version +} + +func (f Fake) Resolve(ctx context.Context, name string) (string, error) { + if !ValidName(name) { + return "", fmt.Errorf("%q is not a package name", name) + } + if f.Fail[name] { + return "", fmt.Errorf("conda-forge could not solve %s: python 3.10.* is not compatible with %s (fake)", name, name) + } + return f.ver(), nil +} + +func (f Fake) Install(ctx context.Context, name, version, dest string, log io.Writer) error { + if f.Delay > 0 { + select { + case <-time.After(f.Delay): + case <-ctx.Done(): + return ctx.Err() + } + } + if f.Fail[name] { + fmt.Fprintf(log, "Solving environment: failed\nFound conflicts! %s is not compatible with python 3.10.*\n", name) + return errors.New("the solver failed") + } + mod := strings.NewReplacer("-", "_", ".", "_").Replace(name) + site := filepath.Join(dest, "lib", "python3.10", "site-packages", mod) + if err := os.MkdirAll(site, 0755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(site, "__init__.py"), []byte(fmt.Sprintf("__version__ = %q\nNAME = %q\n", version, name)), 0644); err != nil { + return err + } + bin := filepath.Join(dest, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + return err + } + script := "#!/bin/sh\necho \"" + name + " " + version + "\"\n" + return os.WriteFile(filepath.Join(bin, name+"-hello"), []byte(script), 0755) +} + +// tail is the last n bytes of s, from a line start. +func tail(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + s = s[len(s)-n:] + if i := strings.IndexByte(s, '\n'); i >= 0 && i < len(s)-1 { + s = s[i+1:] + } + return s +} + +// CleanLog makes a log fit to show and to send to an admin: the last lines, with the store's folders shortened and no secrets. +func CleanLog(s, dir string) string { + s = strings.ReplaceAll(s, dir, "/store") + if h, err := os.UserHomeDir(); err == nil && h != "" { + s = strings.ReplaceAll(s, h, "~") + } + return tail(s, 1500) +} + +// ---- no installer -------------------------------------------------------------------------------------------------------- + +// Missing is the installer of a server that has no micromamba: every install says why it cannot be done. +type Missing struct{ Reason string } + +func (m Missing) Name() string { return "none: " + m.Reason } +func (m Missing) Resolve(ctx context.Context, name string) (string, error) { + return "", errors.New(m.Reason) +} +func (m Missing) Install(ctx context.Context, name, version, dest string, log io.Writer) error { + return errors.New(m.Reason) +} + +// PythonOf asks a python3 program for its "major.minor" (so that every bundle is solved with the Python of the server). +func PythonOf(bin string) string { + out, err := exec.Command(bin, "-c", "import sys;print('%d.%d' % sys.version_info[:2])").Output() + v := strings.TrimSpace(string(out)) + if err != nil || !regexpMinor.MatchString(v) { + return "" + } + return v +} + +var regexpMinor = regexp.MustCompile(`^3\.[0-9]{1,2}$`) diff --git a/src/appstore/service.go b/src/appstore/service.go new file mode 100644 index 00000000..a39dd8b9 --- /dev/null +++ b/src/appstore/service.go @@ -0,0 +1,847 @@ +package appstore + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "sort" + "strings" + "sync" + "time" +) + +// KV is the part of a persist store that the App Store needs (persist.Store satisfies it). +type KV interface { + Store(key, value []byte) error + Fetch(key []byte) ([]byte, error) + Delete(key []byte) error + Commit() error + Each(fn func(key, value []byte) bool) error +} + +// The states of an app. +const ( + StateInstalling = "installing" + StateInstalled = "installed" + StateFailed = "failed" +) + +// The kinds of an app. +const ( + KindLib = "lib" + KindTool = "tool" +) + +// Errors a caller turns into a message. +var ( + ErrNeedsApproval = errors.New("this app is not on the safe list: ask for it, and an admin decides") + ErrBusy = errors.New("this app is being installed already") + ErrInstalled = errors.New("this app is installed already") + ErrFull = errors.New("the store is full: an admin has to make room") + ErrTooSoon = errors.New("try again in a minute") + ErrUnknown = errors.New("no such app") + ErrTooMany = errors.New("too many apps are on: turn one off first") +) + +// MaxOn is how many apps one user may have on. +const MaxOn = 40 + +// App is an app that has been (or is being) installed on this site. One version of each, pinned when it was installed. +type App struct { + Name string `json:"name"` + Version string `json:"version"` + ID string `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + Size int64 `json:"size,omitempty"` + By string `json:"by,omitempty"` + At int64 `json:"at"` + Err string `json:"err,omitempty"` + Log string `json:"log,omitempty"` + Retried int64 `json:"retried,omitempty"` +} + +// Request is a user's ask for an app that is not on the safe list. One for each app: a second asker is added to it. +type Request struct { + Name string `json:"name"` + Kind string `json:"kind"` + Reason string `json:"reason,omitempty"` + Askers []string `json:"askers"` + Status string `json:"status"` // waiting, rejected + Answer string `json:"answer,omitempty"` + At int64 `json:"at"` + Seen []string `json:"seen,omitempty"` // askers that were told the answer + Pending bool `json:"-"` +} + +// Report is a failed install that a user told the admin about. +type Report struct { + Name string `json:"name"` + Reporters []string `json:"reporters"` + Note string `json:"note,omitempty"` + Log string `json:"log,omitempty"` + Status string `json:"status"` // open, fixed, rejected + Answer string `json:"answer,omitempty"` + At int64 `json:"at"` +} + +// Config is what the service is made of. +type Config struct { + Store Store + KV KV + Installer Installer + Apt AptRunner // installs the Debian packages an admin approved, nil for a node that does not + Safe func() []string // the names a user may install without asking + LimitMB func() int // the most the store may use, 0 for no limit + Now func() time.Time +} + +// Service is the App Store of one server (the gateway or a standalone server holds the catalog; a worker only applies it). +type Service struct { + cfg Config + mu sync.Mutex // the records + inst sync.Mutex // one install at a time + jobs map[string]*Job + wg sync.WaitGroup +} + +// Job is an install that is going on. +type Job struct { + Name string + Step string + Start time.Time +} + +// New makes the service. +func New(cfg Config) *Service { + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.Safe == nil { + cfg.Safe = func() []string { return nil } + } + if cfg.LimitMB == nil { + cfg.LimitMB = func() int { return 0 } + } + cfg.KV = &lockedKV{inner: cfg.KV} + return &Service{cfg: cfg, jobs: map[string]*Job{}} +} + +// lockedKV makes every call of the store one at a time (a callback of Each must not call the store again). +type lockedKV struct { + mu sync.Mutex + inner KV +} + +func (l *lockedKV) Store(k, v []byte) error { + l.mu.Lock() + defer l.mu.Unlock() + return l.inner.Store(k, v) +} +func (l *lockedKV) Fetch(k []byte) ([]byte, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.inner.Fetch(k) +} +func (l *lockedKV) Delete(k []byte) error { l.mu.Lock(); defer l.mu.Unlock(); return l.inner.Delete(k) } +func (l *lockedKV) Commit() error { l.mu.Lock(); defer l.mu.Unlock(); return l.inner.Commit() } +func (l *lockedKV) Each(fn func(k, v []byte) bool) error { + l.mu.Lock() + defer l.mu.Unlock() + return l.inner.Each(fn) +} + +func (s *Service) now() int64 { return s.cfg.Now().Unix() } + +// Store is the store of this server. +func (s *Service) Store() Store { return s.cfg.Store } + +// Installer is the installer in use. +func (s *Service) Installer() Installer { return s.cfg.Installer } + +// ---- records ------------------------------------------------------------------------------------------------------------- + +func (s *Service) put(key string, v interface{}) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + if err := s.cfg.KV.Store([]byte(key), raw); err != nil { + return err + } + return s.cfg.KV.Commit() +} + +func (s *Service) get(key string, v interface{}) bool { + raw, err := s.cfg.KV.Fetch([]byte(key)) + if err != nil || len(raw) == 0 { + return false + } + return json.Unmarshal(raw, v) == nil +} + +func (s *Service) del(key string) { + s.cfg.KV.Delete([]byte(key)) + s.cfg.KV.Commit() +} + +func (s *Service) each(prefix string, fn func(key string, raw []byte)) error { + return s.cfg.KV.Each(func(k, v []byte) bool { + if strings.HasPrefix(string(k), prefix) { + fn(string(k), v) + } + return true + }) +} + +// Apps lists the apps of the site. +func (s *Service) Apps() []App { + var out []App + s.each("app:", func(_ string, raw []byte) { + var a App + if json.Unmarshal(raw, &a) == nil { + out = append(out, a) + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func (s *Service) app(name string) (App, bool) { + var a App + ok := s.get("app:"+name, &a) + return a, ok +} + +// Requests lists the requests (status waiting or rejected). +func (s *Service) Requests() []Request { + var out []Request + s.each("req:", func(_ string, raw []byte) { + var r Request + if json.Unmarshal(raw, &r) == nil { + out = append(out, r) + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At }) + return out +} + +// Reports lists the reports of failures. +func (s *Service) Reports() []Report { + var out []Report + s.each("rep:", func(_ string, raw []byte) { + var r Report + if json.Unmarshal(raw, &r) == nil { + out = append(out, r) + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At }) + return out +} + +// MyList is the bundles that are on for a user. +func (s *Service) MyList(home string) []string { + var ids []string + s.get("user:"+home, &ids) + return ids +} + +func (s *Service) setList(home string, ids []string) error { + if ids == nil { + ids = []string{} + } + return s.put("user:"+home, ids) +} + +// ---- who may install ----------------------------------------------------------------------------------------------------- + +// IsSafe says whether a user may install the app without asking. +func (s *Service) IsSafe(name string) bool { + for _, n := range s.cfg.Safe() { + if strings.EqualFold(strings.TrimSpace(n), name) { + return true + } + } + return false +} + +func cleanKind(k string) string { + if k == KindTool { + return KindTool + } + return KindLib +} + +// ---- install ------------------------------------------------------------------------------------------------------------- + +// Install starts the install of an app. A user may install the apps on the safe list; an admin any app. It returns at once: the +// work goes on in the background (Job and Views tell how far it is). Installing an app that failed before is a try again. +func (s *Service) Install(who, name, kind string, admin bool) error { + name = strings.ToLower(strings.TrimSpace(name)) + if !ValidName(name) { + return fmt.Errorf("%q is not a package name", name) + } + if !admin && !s.IsSafe(name) { + return ErrNeedsApproval + } + s.mu.Lock() + defer s.mu.Unlock() + if a, ok := s.app(name); ok { + switch a.State { + case StateInstalled: + return ErrInstalled + case StateInstalling: + if s.jobs[name] != nil { + return ErrBusy + } + case StateFailed: + if !admin && s.cfg.Now().Unix()-a.Retried < 60 { + return ErrTooSoon + } + } + } + if limit := s.cfg.LimitMB(); limit > 0 && s.cfg.Store.Size() > int64(limit)<<20 { + return ErrFull + } + a := App{Name: name, Kind: cleanKind(kind), State: StateInstalling, By: who, At: s.now(), Retried: s.now()} + if old, ok := s.app(name); ok && old.Kind != "" { + a.Kind = old.Kind + } + if err := s.put("app:"+name, a); err != nil { + return err + } + s.jobs[name] = &Job{Name: name, Step: "Waiting its turn", Start: s.cfg.Now()} + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.runInstall(a) + }() + return nil +} + +func (s *Service) step(name, text string) { + s.mu.Lock() + if j := s.jobs[name]; j != nil { + j.Step = text + } + s.mu.Unlock() +} + +func (s *Service) finishJob(name string) { + s.mu.Lock() + delete(s.jobs, name) + s.mu.Unlock() +} + +func (s *Service) fail(a App, step string, err error, log string) { + a.State, a.Err = StateFailed, step+": "+tail(err.Error(), 300) + a.Log = CleanLog(log, s.cfg.Store.Dir) + s.mu.Lock() + s.put("app:"+a.Name, a) + s.mu.Unlock() + s.finishJob(a.Name) +} + +func (s *Service) runInstall(a App) { + s.inst.Lock() // one at a time + defer s.inst.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + var logb bytes.Buffer + if err := s.cfg.Store.Ready(); err != nil { + s.fail(a, "Preparing the store", err, "") + return + } + s.step(a.Name, "Resolving the dependencies") + version, err := s.cfg.Installer.Resolve(ctx, a.Name) + if err != nil { + s.fail(a, "Resolving the dependencies", err, err.Error()) + return + } + a.Version, a.ID = version, BundleID(a.Name, version) + dest := s.cfg.Store.BundlePath(a.ID) + if dest == "" { + s.fail(a, "Resolving the dependencies", fmt.Errorf("%s is not a usable version", version), "") + return + } + os.RemoveAll(dest) // a half-made bundle of an earlier try + s.step(a.Name, "Downloading and linking") + if err := s.cfg.Installer.Install(ctx, a.Name, version, dest, &logb); err != nil { + os.RemoveAll(dest) + s.fail(a, "Downloading and linking", err, logb.String()) + return + } + s.step(a.Name, "Checking that it is complete") + m, err := s.cfg.Store.Finish(a.ID, a.Name, a.Version) + if err != nil { + os.RemoveAll(dest) + s.fail(a, "Checking that it is complete", err, "") + return + } + a.State, a.Size, a.Err, a.Log = StateInstalled, m.Size, "", "" + s.mu.Lock() + s.put("app:"+a.Name, a) + // the request, and the reports of the failure, are answered by it + s.del("req:" + a.Name) + var rep Report + if s.get("rep:"+a.Name, &rep) && rep.Status == "open" { + rep.Status, rep.Answer = "fixed", "It is installed." + s.put("rep:"+a.Name, rep) + } + s.mu.Unlock() + s.finishJob(a.Name) + log.Printf("appstore: %s %s installed (%.1f MB), asked by %s", a.Name, a.Version, float64(m.Size)/(1<<20), a.By) +} + +// Wait waits for the installs that are going on (tests). +func (s *Service) Wait() { s.wg.Wait() } + +// Job tells how far an install is. +func (s *Service) Job(name string) (Job, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if j := s.jobs[name]; j != nil { + return *j, true + } + return Job{}, false +} + +// ---- requests, reports, decisions ---------------------------------------------------------------------------------------- + +func addUnique(list []string, v string) []string { + for _, x := range list { + if x == v { + return list + } + } + return append(list, v) +} + +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + s = s[:n] + } + return s +} + +// Request asks for an app that is not on the safe list. A second ask for the same app joins the first. +func (s *Service) Request(who, name, kind, reason string) (Request, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if !ValidName(name) { + return Request{}, fmt.Errorf("%q is not a package name", name) + } + s.mu.Lock() + defer s.mu.Unlock() + if a, ok := s.app(name); ok && a.State == StateInstalled { + return Request{}, ErrInstalled + } + var r Request + if !s.get("req:"+name, &r) { + r = Request{Name: name, Kind: cleanKind(kind), At: s.now()} + } + r.Status, r.Answer = "waiting", "" + r.Askers = addUnique(r.Askers, who) + if r.Reason == "" { + r.Reason = clip(reason, 200) + } + return r, s.put("req:"+name, r) +} + +// Reject turns a request down, with a reason the askers see. +func (s *Service) Reject(admin, name, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + var r Request + if !s.get("req:"+name, &r) { + return ErrUnknown + } + r.Status, r.Answer = "rejected", clip(reason, 200) + r.Seen = nil + log.Printf("appstore: %s rejected the request for %s", admin, name) + return s.put("req:"+name, r) +} + +// Approve installs a requested app. +func (s *Service) Approve(admin, name string) error { + s.mu.Lock() + var r Request + known := s.get("req:"+name, &r) + s.mu.Unlock() + kind := KindLib + if known { + kind = r.Kind + } + log.Printf("appstore: %s approved %s", admin, name) + return s.Install(admin, name, kind, true) +} + +// Report tells the admin about an install that failed. A second reporter joins the first. +func (s *Service) Report(who, name, note string) error { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.app(name) + if !ok || a.State != StateFailed { + return ErrUnknown + } + var r Report + if !s.get("rep:"+name, &r) || r.Status != "open" { + r = Report{Name: name, Status: "open", At: s.now(), Log: a.Log, Note: clip(note, 300)} + r.Note = clip(note, 300) + } + r.Reporters = addUnique(r.Reporters, who) + if r.Note == "" { + r.Note = clip(note, 300) + } + return s.put("rep:"+name, r) +} + +// ResolveReport ends a report: "retry" installs again, "installed" records that the admin fixed it by hand, "reject" says it will not be. +func (s *Service) ResolveReport(admin, name, action, answer string) error { + s.mu.Lock() + var r Report + known := s.get("rep:"+name, &r) + s.mu.Unlock() + if !known { + return ErrUnknown + } + switch action { + case "retry": + return s.Install(admin, name, KindLib, true) + case "installed": + a, ok := s.app(name) + if !ok || a.ID == "" || !s.cfg.Store.HasBundle(a.ID) { + return fmt.Errorf("%s is not in the store on this server: install it first", name) + } + s.mu.Lock() + defer s.mu.Unlock() + a.State, a.Err, a.Log = StateInstalled, "", "" + a.Size = DirSize(s.cfg.Store.BundlePath(a.ID)) + s.put("app:"+name, a) + r.Status, r.Answer = "fixed", clip(answer, 200) + return s.put("rep:"+name, r) + case "reject": + s.mu.Lock() + defer s.mu.Unlock() + r.Status, r.Answer = "rejected", clip(answer, 200) + return s.put("rep:"+name, r) + } + return fmt.Errorf("%q is not an action", action) +} + +// Remove takes an app out of the store: off every user's list, its bundle deleted. It says how many users had it on. +func (s *Service) Remove(admin, name string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.app(name) + if !ok { + return 0, ErrUnknown + } + if s.jobs[name] != nil { + return 0, ErrBusy + } + users := 0 + lists := map[string][]string{} + s.each("user:", func(key string, raw []byte) { + var ids []string + if json.Unmarshal(raw, &ids) == nil { + lists[strings.TrimPrefix(key, "user:")] = ids + } + }) + for home, ids := range lists { + var keep []string + for _, id := range ids { + if id != a.ID { + keep = append(keep, id) + } + } + if len(keep) != len(ids) { + users++ + s.setList(home, keep) + s.cfg.Store.WriteManifest(home, keep) + } + } + s.del("app:" + name) + if a.ID != "" { + s.cfg.Store.RemoveBundle(a.ID) + } + log.Printf("appstore: %s removed %s (it was on for %d users)", admin, name, users) + return users, nil +} + +// ---- the user's switch --------------------------------------------------------------------------------------------------- + +// Switch turns an installed app on or off for one user (home is the name of the user's workspace folder). It changes the user's +// list and the files a terminal reads at its start; nothing else. +func (s *Service) Switch(home, id string, on bool) error { + if !ValidHome(home) || !ValidBundle(id) { + return ErrUnknown + } + s.mu.Lock() + defer s.mu.Unlock() + known := false + for _, a := range s.Apps() { + if a.ID == id && a.State == StateInstalled { + known = true + } + } + if !known { + return ErrUnknown + } + cur := s.MyList(home) + var next []string + present := false + for _, x := range cur { + if x == id { + present = true + if !on { + continue + } + } + next = append(next, x) + } + if on && !present { + if len(cur) >= MaxOn { + return ErrTooMany + } + next = append(next, id) + } + if err := s.cfg.Store.WriteManifest(home, next); err != nil { + return err + } + return s.setList(home, next) +} + +// ---- what the page shows ------------------------------------------------------------------------------------------------- + +// View is one app as a user sees it. +type View struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` + Kind string `json:"kind"` + State string `json:"state"` // available, installing, installed, failed, requested, rejected + Safe bool `json:"safe"` + On bool `json:"on"` + SizeMB float64 `json:"sizeMB,omitempty"` + Step string `json:"step,omitempty"` + Err string `json:"err,omitempty"` + Log string `json:"log,omitempty"` + Reported bool `json:"reported,omitempty"` + Answer string `json:"answer,omitempty"` + Mine bool `json:"mine,omitempty"` // I asked, or I installed it, or I reported it +} + +// Views lists the apps for one user: what is installed or failing, what was asked for, and the safe list. +func (s *Service) Views(who, home string) []View { + my := map[string]bool{} + for _, id := range s.MyList(home) { + my[id] = true + } + seen := map[string]bool{} + var out []View + for _, a := range s.Apps() { + v := View{Name: a.Name, Version: a.Version, ID: a.ID, Kind: a.Kind, State: a.State, Safe: s.IsSafe(a.Name), + On: my[a.ID], SizeMB: float64(a.Size) / (1 << 20), Mine: a.By == who} + if a.State == StateInstalling { + if j, ok := s.Job(a.Name); ok { + v.Step = j.Step + } else { + v.Step = "Starting" + } + } + if a.State == StateFailed { + v.Err, v.Log = a.Err, a.Log + var r Report + if s.get("rep:"+a.Name, &r) && r.Status == "open" { + v.Reported = true + for _, w := range r.Reporters { + if w == who { + v.Mine = true + } + } + } + } + seen[a.Name] = true + out = append(out, v) + } + for _, r := range s.Requests() { + if seen[r.Name] { + continue + } + v := View{Name: r.Name, Kind: r.Kind, State: "requested", Answer: r.Answer} + if r.Status == "rejected" { + v.State = "rejected" + } + for _, a := range r.Askers { + if a == who { + v.Mine = true + } + } + seen[r.Name] = true + out = append(out, v) + } + for _, n := range s.cfg.Safe() { + n = strings.ToLower(strings.TrimSpace(n)) + if n == "" || seen[n] || !ValidName(n) { + continue + } + seen[n] = true + out = append(out, View{Name: n, Kind: KindLib, State: "available", Safe: true}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// ---- replay: a node makes its store match the catalog -------------------------------------------------------------------- + +// Snapshot is the catalog as a node needs it: the apps that are installed and the users' lists. +type Snapshot struct { + Apps []App `json:"apps"` + Users map[string][]string `json:"users"` + Apt []string `json:"apt,omitempty"` + Full bool `json:"full"` // the list is complete: a bundle that is not in it may be removed +} + +// Snapshot takes the catalog of this server. +func (s *Service) Snapshot() Snapshot { + snap := Snapshot{Users: map[string][]string{}, Full: true} + // a listing that fails (the database is not reachable) must not look like an empty catalog: nothing would be removed + if err := s.each("app:", func(_ string, raw []byte) { + var a App + if json.Unmarshal(raw, &a) == nil && a.State == StateInstalled { + snap.Apps = append(snap.Apps, a) + } + }); err != nil { + snap.Full = false + } + sort.Slice(snap.Apps, func(i, j int) bool { return snap.Apps[i].Name < snap.Apps[j].Name }) + if err := s.each("user:", func(key string, raw []byte) { + var ids []string + if json.Unmarshal(raw, &ids) == nil { + snap.Users[strings.TrimPrefix(key, "user:")] = ids + } + }); err != nil { + snap.Full = false + } + var apt []string + s.get("apt", &apt) + snap.Apt = apt + return snap +} + +// NodeState is what one node has, for the admin page. +type NodeState struct { + Have []string `json:"have"` + Failed map[string]string `json:"failed,omitempty"` + SizeMB float64 `json:"sizeMB"` + At int64 `json:"at"` +} + +// Apply makes this node's store match a snapshot: the bundles that are missing are installed (the same pinned versions), the +// users' files are written, and, where the snapshot is complete, bundles that are no longer in it are removed. It is what a +// node does at its start, when it joins and when the catalog changes (a worker gets the snapshot from the gateway). +func (s *Service) Apply(ctx context.Context, snap Snapshot) NodeState { + st := NodeState{Failed: map[string]string{}, At: s.now()} + if err := s.cfg.Store.Ready(); err != nil { + st.Failed["store"] = err.Error() + return st + } + want := map[string]bool{} + for _, a := range snap.Apps { + if !ValidBundle(a.ID) || !ValidName(a.Name) || !ValidVersion(a.Version) { + continue + } + want[a.ID] = true + if s.cfg.Store.HasBundle(a.ID) { + continue + } + s.inst.Lock() + dest := s.cfg.Store.BundlePath(a.ID) + os.RemoveAll(dest) + var logb bytes.Buffer + err := s.cfg.Installer.Install(ctx, a.Name, a.Version, dest, &logb) + if err == nil { + _, err = s.cfg.Store.Finish(a.ID, a.Name, a.Version) + } + if err != nil { + os.RemoveAll(dest) + st.Failed[a.ID] = tail(err.Error()+" "+logb.String(), 300) + log.Printf("appstore: replay: %s could not be installed here: %v", a.ID, err) + } + s.inst.Unlock() + } + if snap.Full { + for _, id := range s.cfg.Store.Bundles() { + if !want[id] { + s.cfg.Store.RemoveBundle(id) + } + } + } + // the users: the bundles that are here; and an empty list for a user that has none any more + homes := map[string]bool{} + for home, ids := range snap.Users { + homes[home] = true + var have []string + for _, id := range ids { + if s.cfg.Store.HasBundle(id) { + have = append(have, id) + } + } + if ValidHome(home) { + if err := s.cfg.Store.WriteManifest(home, have); err != nil { + st.Failed["user:"+home] = err.Error() + } + } + } + for _, home := range s.cfg.Store.Homes() { + if !homes[home] { + s.cfg.Store.WriteManifest(home, nil) + } + } + s.applyApt(ctx, snap.Apt, &st) + st.Have = s.cfg.Store.Bundles() + st.SizeMB = float64(s.cfg.Store.Size()) / (1 << 20) + return st +} + +// Recover is run at start: an install that was going on when the server stopped did not finish. +func (s *Service) Recover() { + s.mu.Lock() + defer s.mu.Unlock() + for _, a := range s.Apps() { + if a.State == StateInstalling && s.jobs[a.Name] == nil { + a.State, a.Err = StateFailed, "The server stopped while it was being installed. Try again." + s.put("app:"+a.Name, a) + } + } +} + +// Apt is the list of Debian packages an admin approved for every user. +func (s *Service) Apt() []string { + var l []string + s.get("apt", &l) + return l +} + +// SetApt saves the list of Debian packages. +func (s *Service) SetApt(list []string) error { + var clean []string + for _, p := range list { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if !ValidApt(p) { + return fmt.Errorf("%q is not a package name", p) + } + clean = addUnique(clean, p) + } + s.mu.Lock() + defer s.mu.Unlock() + return s.put("apt", clean) +} diff --git a/src/appstore/store.go b/src/appstore/store.go new file mode 100644 index 00000000..bd9b325f --- /dev/null +++ b/src/appstore/store.go @@ -0,0 +1,255 @@ +// Package appstore is the App Store (LLD 18): libraries and tools installed once on a server, in a shared store, and switched on +// for each signed-in user by a list. Nothing is copied into a workspace. The terminal's filesystem sandbox (Landlock) is given +// exactly the folders on the user's list, so a bundle that is off for a user cannot be read or run by it. +// +// /pkgs/ the download cache, one copy of every package file +// /apps/-/ a complete conda prefix (the package and everything it needs), installed once +// /envs// manifest.json (the bundles that are on) and env.sh (what a shell sets to use them) +// +// This file is the part that touches the disk and needs no network, no database and no server. +package appstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" +) + +// DefaultDir is where the store is, unless OPENREPL_STORE_DIR says another place. It is outside /opt on purpose: the sandbox +// gives every terminal read access to the other folders of /opt. +const DefaultDir = "/store" + +// markerName is written last into a bundle, so that a bundle that was being installed when the server stopped is never used. +const markerName = ".openrepl-bundle.json" + +var ( + appNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + versionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9._+!-]{0,31}$`) + bundlePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}-[0-9][A-Za-z0-9._+!-]{0,31}$`) + homePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + aptPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,62}$`) +) + +// ValidName says whether s can be the name of a conda package (lower case letters, digits, . _ -). +func ValidName(s string) bool { return appNamePattern.MatchString(s) && !strings.Contains(s, "..") } + +// ValidVersion says whether s can be a version of one. +func ValidVersion(s string) bool { return versionPattern.MatchString(s) && !strings.Contains(s, "..") } + +// ValidBundle says whether s is the name of a bundle folder: -. +func ValidBundle(s string) bool { return bundlePattern.MatchString(s) && !strings.Contains(s, "..") } + +// ValidHome says whether s can name a user's folder in envs (a workspace folder's name). +func ValidHome(s string) bool { return homePattern.MatchString(s) && !strings.Contains(s, "..") } + +// ValidApt says whether s can be the name of a Debian package. +func ValidApt(s string) bool { return aptPattern.MatchString(s) } + +// BundleID is the folder name of a bundle. +func BundleID(name, version string) string { return name + "-" + version } + +// Marker is what a finished bundle says about itself. +type Marker struct { + Name string `json:"name"` + Version string `json:"version"` + Size int64 `json:"size"` +} + +// Store is the store on one server. +type Store struct{ Dir string } + +func (s Store) AppsDir() string { return filepath.Join(s.Dir, "apps") } +func (s Store) PkgsDir() string { return filepath.Join(s.Dir, "pkgs") } +func (s Store) EnvsDir() string { return filepath.Join(s.Dir, "envs") } + +// BundlePath is the folder of a bundle, or "" for a name that is not one. +func (s Store) BundlePath(id string) string { + if !ValidBundle(id) { + return "" + } + return filepath.Join(s.AppsDir(), id) +} + +// HomeDir is the folder of a user's manifest, or "". +func (s Store) HomeDir(home string) string { + if !ValidHome(home) { + return "" + } + return filepath.Join(s.EnvsDir(), home) +} + +// Ready makes the folders of the store. +func (s Store) Ready() error { + for _, d := range []string{s.AppsDir(), s.PkgsDir(), s.EnvsDir()} { + if err := os.MkdirAll(d, 0755); err != nil { + return err + } + } + return nil +} + +// HasBundle says whether a bundle is finished (its marker is there). +func (s Store) HasBundle(id string) bool { + p := s.BundlePath(id) + if p == "" { + return false + } + _, err := os.Stat(filepath.Join(p, markerName)) + return err == nil +} + +// Bundles lists the finished bundles. +func (s Store) Bundles() []string { + entries, err := os.ReadDir(s.AppsDir()) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() && s.HasBundle(e.Name()) { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +// Finish writes the marker of a bundle that was installed completely. +func (s Store) Finish(id, name, version string) (Marker, error) { + p := s.BundlePath(id) + if p == "" { + return Marker{}, fmt.Errorf("%q is not a bundle name", id) + } + m := Marker{Name: name, Version: version, Size: DirSize(p)} + raw, _ := json.Marshal(m) + return m, os.WriteFile(filepath.Join(p, markerName), raw, 0644) +} + +// RemoveBundle deletes a bundle (finished or not). +func (s Store) RemoveBundle(id string) error { + p := s.BundlePath(id) + if p == "" { + return fmt.Errorf("%q is not a bundle name", id) + } + return os.RemoveAll(p) +} + +// Size is the disk the store uses: every file counted once, however many hard links there are to it. +func (s Store) Size() int64 { return DirSize(s.Dir) } + +// DirSize is the size of the files below dir, each inode counted once (a bundle is hard-linked from the cache). +func DirSize(dir string) int64 { + seen := map[[2]uint64]bool{} + var total int64 + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if st, ok := info.Sys().(*syscall.Stat_t); ok { + key := [2]uint64{uint64(st.Dev), uint64(st.Ino)} + if seen[key] { + return nil + } + seen[key] = true + } + total += info.Size() + return nil + }) + return total +} + +// Manifest is the list of bundles that are on for a user, [] when there is none. A bad entry is an error, never skipped. +func (s Store) Manifest(home string) ([]string, error) { + d := s.HomeDir(home) + if d == "" { + return nil, fmt.Errorf("%q is not a home", home) + } + return ReadManifest(filepath.Join(d, "manifest.json")) +} + +// ReadManifest reads a manifest file: a JSON list of bundle names. A file that is not there is an empty list. +func ReadManifest(path string) ([]string, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var ids []string + if err := json.Unmarshal(raw, &ids); err != nil { + return nil, fmt.Errorf("the manifest %s is not a list of names: %v", path, err) + } + seen := map[string]bool{} + var out []string + for _, id := range ids { + if !ValidBundle(id) { + return nil, fmt.Errorf("the manifest %s has %q, which is not a bundle name", path, id) + } + if !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out, nil +} + +// WriteManifest saves a user's list and the env.sh that goes with it. Only finished bundles may be on it. The files are written +// whole and then renamed, so a terminal that starts at that moment reads the old list or the new one. +func (s Store) WriteManifest(home string, ids []string) error { + d := s.HomeDir(home) + if d == "" { + return fmt.Errorf("%q is not a home", home) + } + seen := map[string]bool{} + var clean []string + for _, id := range ids { + if !s.HasBundle(id) { + return fmt.Errorf("%s is not installed on this server", id) + } + if !seen[id] { + seen[id] = true + clean = append(clean, id) + } + } + if err := os.MkdirAll(d, 0755); err != nil { + return err + } + if clean == nil { + clean = []string{} + } + raw, _ := json.Marshal(clean) + if err := writeAtomic(filepath.Join(d, "manifest.json"), raw); err != nil { + return err + } + return writeAtomic(filepath.Join(d, "env.sh"), []byte(EnvScript(s.Dir, clean))) +} + +func writeAtomic(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// Homes lists the users that have a manifest on this server. +func (s Store) Homes() []string { + entries, err := os.ReadDir(s.EnvsDir()) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() && ValidHome(e.Name()) { + out = append(out, e.Name()) + } + } + return out +} diff --git a/src/containers/landlock.go b/src/containers/landlock.go index 9672f537..b0857509 100644 --- a/src/containers/landlock.go +++ b/src/containers/landlock.go @@ -143,6 +143,11 @@ func wrapWithLandlock(command []string, workspace string, debug, scopeSignals bo if debug { wrapped = append(wrapped, "--debug") } + if dir := utils.StoreDir(); dir != "" { + if fi, err := os.Stat(dir); err == nil && fi.IsDir() { + wrapped = append(wrapped, "--store-dir", dir) // the user's App Store bundles (LLD 18) + } + } } else { wrapped = []string{NetEmuStatus().Helper, "--no-landlock"} } diff --git a/src/js/src/page/33-app-store.js b/src/js/src/page/33-app-store.js new file mode 100644 index 00000000..c6147515 --- /dev/null +++ b/src/js/src/page/33-app-store.js @@ -0,0 +1,317 @@ +// The App Store (docs/lld/18-app-store.md): libraries and tools that a signed-in user switches on for their terminals. They are +// installed once on the server, not in the workspace; this page only asks the server (/store/api/...) and shows what it says. +// Part of the page script: webpack bundles src/js/src/page/*.js into js/scribbler.js. + +import { missingFrom } from "./lib-store-hint.mjs"; + +var tab = "browse"; // browse | mine +var views = []; // what the server says: one for each app +var onList = []; // the bundles that are on for this user +var envScript = ""; // the file a shell can run to use what was switched on +var installer = ""; +var query = ""; +var lookups = {}; // name -> { info } | { error } for a name typed that the store does not list +var changed = false; // the user switched something since the dialog opened: open terminals keep what they had +var watching = {}; // apps that are being installed: a notice says when they are done +var pollTimer = 0; +var loaded = false; + +function el(id) { return document.getElementById(id); } +function signedIn() { return document.body.classList.contains("is-signed-in"); } +function h(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined) n.textContent = text; + return n; +} +function url(path) { return typeof window.preprocessurl === "function" ? window.preprocessurl(path) : path; } + +function api(path, body) { + var opts = { credentials: "same-origin", cache: "no-store" }; + if (body) { opts.method = "POST"; opts.headers = { "Content-Type": "application/json" }; opts.body = JSON.stringify(body); } + return fetch(url("/store/api/" + path), opts).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (j) { + if (!res.ok) { var e = new Error(j.message || j.error || "The App Store did not answer."); e.status = res.status; e.code = j.error; throw e; } + return j; + }); + }); +} + +function say(text, bad) { + var n = el("appstore-note"); + if (!n) return; + n.hidden = !text; + n.textContent = text || ""; + n.classList.toggle("is-bad", !!bad); +} + +function take(j) { + if (j.views) views = j.views; + if (j.on) onList = j.on; + if (j.envScript) envScript = j.envScript; + if (j.installer) installer = j.installer; + loaded = true; + schedulePoll(); +} + +function refresh() { + return api("list").then(function (j) { take(j); render(); }, function (e) { loaded = false; render(e); }); +} + +// ---- watching an install -------------------------------------------------------------------------------------------------- + +function busy() { return views.some(function (v) { return v.state === "installing"; }); } + +function schedulePoll() { + views.forEach(function (v) { if (v.state === "installing") watching[v.name] = true; }); + clearTimeout(pollTimer); + if (!busy()) return; + pollTimer = setTimeout(function () { + api("list").then(function (j) { + var before = views; + take(j); + // an app that was being installed is done (or failed): say so even if the dialog is closed + views.forEach(function (v) { + if (!watching[v.name] || v.state === "installing") return; + delete watching[v.name]; + if (v.state === "installed" && window.notify) { + window.notify(v.name + " is installed.", { type: "success", variant: "program", title: "App Store", timeout: 0, actions: [ + { label: "Turn it on", primary: true, onClick: function () { switchApp(v, true); } }, + { label: "Later", onClick: function () {} } + ] }); + } else if (v.state === "failed" && window.notify) { + window.notify(v.name + " could not be installed. Open the App Store to see why.", { type: "error", title: "App Store" }); + } + }); + if (isOpen()) render(); + }, function () { schedulePoll(); }); + }, 1800); +} + +// ---- actions -------------------------------------------------------------------------------------------------------------- + +function act(path, body, ok) { + say(""); + return api(path, body).then(function (j) { take(j); if (ok) say(ok); render(); }, function (e) { + if (e.code === "signin") { render(e); return; } + say(e.message, true); + }); +} + +function install(v) { return act("install", { name: v.name, kind: v.kind || "lib" }); } +function request(v) { + var reason = window.prompt ? window.prompt("Why do you need " + v.name + "? (optional, one line)", "") : ""; + if (reason === null) return; + return act("request", { name: v.name, kind: v.kind || "lib", reason: reason }, "Asked. An admin will decide; you will see the answer here."); +} +function switchApp(v, on) { + changed = true; + return act("switch", { id: v.id, on: on }, on ? v.name + " is on for your terminals." : v.name + " is off for your terminals."); +} +function report(v) { + var note = window.prompt ? window.prompt("Tell the admin what you were trying to do (optional)", "") : ""; + if (note === null) return; + return act("report", { name: v.name, note: note }, "Sent. The admin will look at it, and you will see the answer here."); +} +function applyHere() { + if (!envScript || typeof window.sendToActiveTerminal !== "function") { say("Open a terminal first, then try again.", true); return; } + if (window.confirm && !window.confirm("This types a line into your active terminal that loads your apps. Do it now? (Do not do it while a program is running in it.)")) return; + window.sendToActiveTerminal(". '" + envScript.replace(/'/g, "'\\''") + "'\r"); + changed = false; + say("Done. Your active terminal uses what is on now."); + render(); +} + +function lookup(name) { + if (lookups[name]) return; + lookups[name] = { loading: true }; + api("info?name=" + encodeURIComponent(name)).then(function (info) { lookups[name] = { info: info }; render(); }, + function (e) { lookups[name] = { error: e.message }; render(); }); +} + +// ---- drawing -------------------------------------------------------------------------------------------------------------- + +var STATE_TEXT = { available: "", installing: "Installing", installed: "Installed", failed: "Failed", requested: "Waiting for an admin", rejected: "Not available" }; + +function card(v) { + var c = h("div", "appstore__app is-" + v.state); + c.setAttribute("data-app", v.name); + var top = h("div", "appstore__top"); + top.appendChild(h("b", "appstore__name", v.name)); + if (v.version) top.appendChild(h("span", "appstore__ver", v.version)); + if (v.kind === "tool") top.appendChild(h("span", "appstore__tag", "tool")); + if (STATE_TEXT[v.state]) top.appendChild(h("span", "appstore__tag appstore__tag--" + v.state, STATE_TEXT[v.state])); + if (v.state === "installed" && v.sizeMB) top.appendChild(h("span", "appstore__size", v.sizeMB.toFixed(v.sizeMB < 10 ? 1 : 0) + " MB")); + c.appendChild(top); + var info = lookups[v.name]; + if (info && info.info && info.info.summary) c.appendChild(h("p", "appstore__sum", info.info.summary)); + if (v.state === "installing") c.appendChild(h("p", "appstore__sum", (v.step || "Starting") + "…")); + if (v.state === "failed") { + c.appendChild(h("p", "appstore__sum", v.err || "The install stopped.")); + if (v.log) { var pre = h("pre", "appstore__log", v.log); c.appendChild(pre); } + } + if (v.state === "rejected" && v.answer) c.appendChild(h("p", "appstore__sum", v.answer)); + var row = h("div", "appstore__row"); + function button(text, fn, primary) { + var b = h("button", "appstore__btn" + (primary ? " is-primary" : ""), text); + b.type = "button"; + b.addEventListener("click", fn); + row.appendChild(b); + return b; + } + if (v.state === "available") button("Install", function () { install(v); }, true); + if (v.state === "installed") { + var sw = h("button", "appstore__switch" + (v.on ? " is-on" : ""), v.on ? "On" : "Off"); + sw.type = "button"; sw.setAttribute("role", "switch"); sw.setAttribute("aria-checked", v.on ? "true" : "false"); + sw.setAttribute("aria-label", "Use " + v.name + " in my terminals"); + sw.addEventListener("click", function () { switchApp(v, !v.on); }); + row.appendChild(sw); + } + if (v.state === "failed") { + button("Try again", function () { install(v); }, true); + if (v.reported) row.appendChild(h("span", "appstore__tag", v.mine ? "You told the admin" : "Reported, the admin is looking at it")); + else button("Report to admin", function () { report(v); }); + } + if (v.state === "rejected") button("Ask again", function () { request(v); }); + c.appendChild(row); + return c; +} + +function lookupCard(name) { + var l = lookups[name]; + var c = h("div", "appstore__app is-lookup"); + c.setAttribute("data-app", name); + var top = h("div", "appstore__top"); + top.appendChild(h("b", "appstore__name", name)); + top.appendChild(h("span", "appstore__tag", "conda-forge")); + c.appendChild(top); + if (!l || l.loading) { c.appendChild(h("p", "appstore__sum", "Looking it up…")); lookup(name); return c; } + if (l.error) { c.appendChild(h("p", "appstore__sum", l.error)); return c; } + c.appendChild(h("p", "appstore__sum", (l.info.summary || "No description.") + (l.info.version ? " Newest: " + l.info.version + "." : ""))); + var row = h("div", "appstore__row"); + var b = h("button", "appstore__btn is-primary", "Request " + name); + b.type = "button"; + b.addEventListener("click", function () { request({ name: name, kind: "lib" }); }); + row.appendChild(b); + c.appendChild(row); + return c; +} + +function render(err) { + var list = el("appstore-list"), foot = el("appstore-foot"); + if (!list) return; + list.textContent = ""; + ["browse", "mine"].forEach(function (t) { + var b = el("appstore-tab-" + t); + if (b) { b.setAttribute("aria-selected", tab === t ? "true" : "false"); b.classList.toggle("is-on", tab === t); } + }); + var tools = el("appstore-tools"); if (tools) tools.hidden = tab !== "browse"; + if (!signedIn() || (err && err.code === "signin")) { + var g = h("div", "appstore__guest"); + g.appendChild(h("b", "", "The App Store is for signed-in users")); + g.appendChild(h("span", "", "Sign in to add libraries and tools to your terminals.")); + var sb = h("button", "appstore__btn is-primary", "Sign in"); + sb.type = "button"; + sb.addEventListener("click", function () { close(); if (typeof window.openSignIn === "function") window.openSignIn(); }); + g.appendChild(sb); + list.appendChild(g); + if (foot) foot.textContent = ""; + return; + } + if (err) { list.appendChild(h("p", "appstore__sum", err.message || "The App Store is not available.")); return; } + if (!loaded) { list.appendChild(h("p", "appstore__sum", "Loading…")); return; } + var q = query.trim().toLowerCase(); + var shown = views.filter(function (v) { + if (tab === "mine") return v.on || v.state === "requested" || v.state === "rejected" || (v.state === "failed" && v.mine) || (v.state === "installing" && v.mine); + return !q || v.name.indexOf(q) >= 0; + }); + shown.forEach(function (v) { list.appendChild(card(v)); }); + // a name that nothing in the store matches can still be looked up on conda-forge and asked for + if (tab === "browse" && q && !shown.length && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(q)) list.appendChild(lookupCard(q)); + if (!shown.length && !(tab === "browse" && q)) list.appendChild(h("p", "appstore__sum", tab === "mine" ? "Nothing is on for you yet. Browse the store and switch an app on." : "No apps yet.")); + if (foot) { + foot.textContent = ""; + if (changed && tab === "mine") { + var note = h("div", "appstore__apply"); + note.appendChild(h("span", "", "You changed what is on. Terminals that are open keep what they had; new ones use it.")); + var ab = h("button", "appstore__btn", "Use it in my active terminal"); + ab.type = "button"; ab.addEventListener("click", applyHere); + note.appendChild(ab); + foot.appendChild(note); + } + foot.appendChild(h("span", "appstore__help", "Apps are installed once on the server and shared. Switching one on only changes your own terminals.")); + } +} + +// ---- opening and closing -------------------------------------------------------------------------------------------------- + +function isOpen() { var d = el("appstore-dialog"); return !!d && d.open; } +function close() { var d = el("appstore-dialog"); if (d && d.open) d.close(); } + +function open(name) { + var d = el("appstore-dialog"); + if (!d) return; + if (!d.open) { if (d.showModal) d.showModal(); else d.setAttribute("open", ""); } + tab = "browse"; + query = name ? String(name).toLowerCase() : ""; + var s = el("appstore-search"); if (s) s.value = query; + say(""); + render(); + if (signedIn()) refresh(); +} + +// ---- the terminal's own error: "No module named flask" ----------------------------------------------------------------------- + +var hinted = {}; // names that were offered already in this visit: one notice for each +var hintBusy = false; + +function terminalTail() { + var rows = document.querySelector(".terminal.active .xterm-rows") || document.querySelector(".xterm-rows"); + return rows ? rows.innerText : ""; +} + +function checkHint() { + if (hintBusy || !signedIn() || !window.notify || isOpen()) return; + var name = missingFrom(terminalTail()); + if (!name || hinted[name]) return; + hintBusy = true; + api("list").then(function (j) { + take(j); + var v = views.filter(function (x) { return x.name === name; })[0]; + // only an app the store has (the safe list, or already installed or asked for); a guess is never offered + if (!v || v.state === "installing" || (v.state === "installed" && v.on)) return; + hinted[name] = true; + var text = v.state === "installed" ? name + " is installed, but not on for your terminals." : name + " is in the App Store."; + window.notify(text, { type: "info", variant: "program", title: "Missing something?", timeout: 0, actions: [ + { label: v.state === "installed" ? "Turn it on" : "Open the App Store", primary: true, onClick: function () { if (v.state === "installed") switchApp(v, true); else open(name); } }, + { label: "Not now", onClick: function () {} } + ] }); + }, function () {}).then(function () { hintBusy = false; }); +} + +function init() { + var d = el("appstore-dialog"); + if (!d || d.dataset.ready) return; + d.dataset.ready = "1"; + el("appstore-close").addEventListener("click", close); + d.addEventListener("click", function (e) { if (e.target === d) close(); }); + el("appstore-tab-browse").addEventListener("click", function () { tab = "browse"; say(""); render(); }); + el("appstore-tab-mine").addEventListener("click", function () { tab = "mine"; say(""); render(); }); + el("appstore-search").addEventListener("input", function (e) { query = e.target.value; render(); }); + var b = el("appstore-button"); + if (b) b.addEventListener("click", function () { open(); }); + setInterval(checkHint, 3000); +} + +window.AppStore = { + open: open, + // Genie and the terminal offer an app: the store opens on it, the user decides + suggest: function (name) { open(name); }, + available: function () { return signedIn(); }, + isOpen: isOpen, + state: function () { return { views: views.slice(), on: onList.slice(), tab: tab, installer: installer }; } +}; + +if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); + +export {}; diff --git a/src/js/src/page/index.js b/src/js/src/page/index.js index 1ed9737c..c9096b31 100644 --- a/src/js/src/page/index.js +++ b/src/js/src/page/index.js @@ -33,3 +33,4 @@ import "./29-web-language"; // HTML and CSS in the language picker import "./30-owner-presence"; // a shared session whose owner is not there import "./31-share-session"; // a share link with a time import "./32-remember-language"; // the language of the last visit +import "./33-app-store"; // libraries and tools for signed-in users diff --git a/src/js/src/page/lib-store-hint.mjs b/src/js/src/page/lib-store-hint.mjs new file mode 100644 index 00000000..5441d292 --- /dev/null +++ b/src/js/src/page/lib-store-hint.mjs @@ -0,0 +1,24 @@ +// What the terminal's own error says is missing, for the App Store's hint (33-app-store.js). Only the last lines are read, and only +// the names a conda-forge package could have. + +// the name of a module is not always the name of its package +export const ALIASES = { PIL: "pillow", yaml: "pyyaml", bs4: "beautifulsoup4", cv2: "opencv", sklearn: "scikit-learn", skimage: "scikit-image", dateutil: "python-dateutil", jwt: "pyjwt", OpenSSL: "pyopenssl", Crypto: "pycryptodome", serial: "pyserial" }; + +const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +// -> a package name, or "" when the text says nothing is missing. The newest error wins. +export function missingFrom(text) { + const lines = String(text || "").split("\n").slice(-25); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + let m = /No module named ['"]([^'"]+)['"]/.exec(line); + if (m) { + const top = m[1].split(".")[0]; + if (!NAME.test(top)) return ""; + return (ALIASES[top] || top).toLowerCase(); + } + m = /(?:^|[\s:])([A-Za-z0-9][A-Za-z0-9._+-]{0,63}): (?:command )?not found\s*$/.exec(line); + if (m && NAME.test(m[1])) return m[1].toLowerCase(); + } + return ""; +} diff --git a/src/js/test/lib-store-hint.test.mjs b/src/js/test/lib-store-hint.test.mjs new file mode 100644 index 00000000..8c54bc90 --- /dev/null +++ b/src/js/test/lib-store-hint.test.mjs @@ -0,0 +1,28 @@ +// What the terminal says is missing (src/page/lib-store-hint.mjs). +import test from "node:test"; +import assert from "node:assert/strict"; +import { missingFrom } from "../src/page/lib-store-hint.mjs"; + +test("a Python import error names the package", () => { + assert.equal(missingFrom("Traceback (most recent call last):\n File \"app.py\", line 1, in \n import flask\nModuleNotFoundError: No module named 'flask'\n$ "), "flask"); + assert.equal(missingFrom("ModuleNotFoundError: No module named 'flask.cli'"), "flask"); + assert.equal(missingFrom("ModuleNotFoundError: No module named 'PIL'"), "pillow"); + assert.equal(missingFrom("ImportError: No module named \"yaml\""), "pyyaml"); +}); + +test("a command that is not found names the tool", () => { + assert.equal(missingFrom("$ ffmpeg -i a.mp4\nbash: ffmpeg: command not found"), "ffmpeg"); + assert.equal(missingFrom("sh: 1: jq: not found"), "jq"); +}); + +test("the newest error wins, and old lines are not read", () => { + assert.equal(missingFrom("No module named 'numpy'\nsome output\nNo module named 'flask'\n"), "flask"); + assert.equal(missingFrom("No module named 'numpy'\n" + "ok\n".repeat(40)), ""); +}); + +test("nothing is missing, or the name is not one a package could have", () => { + assert.equal(missingFrom(""), ""); + assert.equal(missingFrom("hello world\n$ "), ""); + assert.equal(missingFrom("No module named '../../etc'"), ""); + assert.equal(missingFrom("No module named ''"), ""); +}); diff --git a/src/landlock/policy.go b/src/landlock/policy.go index fafeac5b..deb5febb 100644 --- a/src/landlock/policy.go +++ b/src/landlock/policy.go @@ -83,6 +83,8 @@ type Options struct { Debug bool // ScopeSignals asks for the signal scope. It is applied only where the kernel has it (ABI 6 or newer). ScopeSignals bool + // ExtraRO are more folders that may be read and run: the bundles of the App Store that are on for this user (LLD 18). + ExtraRO []string } // Policy is what Build returns. @@ -237,6 +239,11 @@ func Build(o Options, fs FS) (*Policy, error) { } } } + for _, d := range o.ExtraRO { + if filepath.IsAbs(d) { + add(d, ro) + } + } for _, f := range deviceFiles { add(f, devRW) } diff --git a/src/landlock/policy_test.go b/src/landlock/policy_test.go index 5e38944b..f05d46ac 100644 --- a/src/landlock/policy_test.go +++ b/src/landlock/policy_test.go @@ -287,3 +287,28 @@ func TestTheDevicesAreAnAllowlist(t *testing.T) { t.Error("/dev itself has a rule") } } + +// The App Store (LLD 18): only the bundles on the user's list are readable and runnable, never writable, and a folder that +// holds the workspaces never gets a rule. +func TestStoreBundlesGetReadAndExecuteOnly(t *testing.T) { + fs := newFS("/home/openrepl", "/home/openrepl/ann") + fs.dirs["/store/apps/flask-3.0.2"] = true + fs.dirs["/store/apps/numpy-2.1.0"] = true + fs.dirs["/store/envs/ann"] = true + p, err := Build(Options{Workspace: "/home/openrepl/ann", HomeRoot: "/home/openrepl", ABI: 4, + ExtraRO: []string{"/store/apps/flask-3.0.2", "/store/envs/ann", "relative/path", "/home/openrepl"}}, fs) + if err != nil { + t.Fatal(err) + } + m := paths(p) + for _, d := range []string{"/store/apps/flask-3.0.2", "/store/envs/ann"} { + if m[d]&Execute == 0 || m[d]&ReadFile == 0 || m[d]&WriteFile != 0 || m[d]&MakeReg != 0 { + t.Errorf("%s has %b: it must be read and execute only", d, m[d]) + } + } + for _, d := range []string{"/store/apps/numpy-2.1.0", "/store", "relative/path", "/home/openrepl"} { + if _, ok := m[d]; ok { + t.Errorf("%s must have no rule", d) + } + } +} diff --git a/src/landlockhelper/main.go b/src/landlockhelper/main.go index 6275da35..cfab79de 100644 --- a/src/landlockhelper/main.go +++ b/src/landlockhelper/main.go @@ -21,6 +21,7 @@ import ( "path/filepath" "syscall" + "appstore" "landlock" "netemu" ) @@ -36,6 +37,7 @@ func main() { maxFileMB := flag.Int("max-file-mb", 0, "the size one file the REPL writes may have, in MB (0: no limit)") noLandlock := flag.Bool("no-landlock", false, "no filesystem policy: only the network emulation (--net-emulation)") netEmulation := flag.Bool("net-emulation", false, "the REPL gets the private network of its workspace (LLD 16); the server gave this process a socket as descriptor 3") + storeDir := flag.String("store-dir", "", "the App Store's folder (LLD 18): the bundles on the user's list are readable and on the PATH") netProbe := flag.Bool("net-probe", false, "the self test of the network emulation, run by the server at start") netSelftest := flag.Bool("net-selftest", false, "internal: the second half of --net-probe") flag.Parse() @@ -81,8 +83,9 @@ func main() { fmt.Fprintln(os.Stderr, "Landlock sandbox not applied:", err) os.Exit(failed) } + extra := storeFolders(*storeDir, *workspace) policy, err := landlock.Build(landlock.Options{Workspace: *workspace, HomeRoot: *homeRoot, ABI: abi, Self: landlock.Self(), Debug: *debug, - ScopeSignals: *scopeSignals && landlock.ScopeSupported()}, nil) + ScopeSignals: *scopeSignals && landlock.ScopeSupported(), ExtraRO: extra}, nil) if err != nil { fmt.Fprintln(os.Stderr, "Landlock sandbox not applied:", err) os.Exit(failed) @@ -150,3 +153,46 @@ func runWithoutPolicy(netEmulation bool, args []string) { os.Exit(127) } } + +// storeFolders reads the list of the user's App Store bundles (the manifest of the user whose workspace this is) and puts them on +// the environment of the program: PATH (after the system's), PYTHONPATH, LD_LIBRARY_PATH. It returns the folders the sandbox +// must let through: the bundles and the user's own folder of the store (where env.sh is). It runs before the restriction, with +// the server's rights, and a terminal cannot write the store: it cannot add itself to a list. A bad entry stops the start. +func storeFolders(dir, workspace string) []string { + if dir == "" { + return nil + } + store := appstore.Store{Dir: dir} + home := filepath.Base(filepath.Clean(workspace)) + own := store.HomeDir(home) + if own == "" { + return nil + } + ids, err := store.Manifest(home) + if err != nil { + fmt.Fprintln(os.Stderr, "landlock-helper: the App Store list of this user:", err) + os.Exit(failed) + } + var folders []string + var have []string + for _, id := range ids { + if store.HasBundle(id) { // a bundle this server does not have yet (it is being replayed) is left out + folders = append(folders, store.BundlePath(id)) + have = append(have, id) + } + } + if len(have) == 0 { + return nil + } + folders = append(folders, own) + env := appstore.VarsFor(dir, have).Apply(os.Environ()) + for _, e := range env { + for i := 0; i < len(e); i++ { + if e[i] == '=' { + os.Setenv(e[:i], e[i+1:]) + break + } + } + } + return folders +} diff --git a/src/resources/about.html b/src/resources/about.html index fd2c23f6..da9920c9 100644 --- a/src/resources/about.html +++ b/src/resources/about.html @@ -71,6 +71,7 @@

What you can do

  • Share a live link so others can watch and type along, or make a code link to a copy of your code that works until a day you choose.
  • Fork a terminal to run a server and a client side by side.
  • Keep files in your workspace, and sign in to keep them between visits.
  • +
  • Signed in, add libraries and tools (such as Flask or NumPy) from the App Store: installed once on the server, and switched on only for your own terminals.
  • Ask Genie, the AI helper, about your code and your terminal output, review its suggestions as a diff, let it carry out a task in Agent mode when you are signed in (it can also work in your terminal, open terminal tabs and manage your files), or right-click selected code to explain, fix, comment or test it. Add files, folders and selected code to the chat from the Files panel and the editor, so Genie reads exactly what you point at. Genie keeps working when you close its panel, and the Ask Genie button tells you when it is done or needs you. Pin it to dock it beside the editor.
  • Practice DSA with 300+ ready-made interview problems (and your own generated ones), with a coach for hints, a solution review and complexity.
  • diff --git a/src/resources/chat-widget/src/agent-protocol.ts b/src/resources/chat-widget/src/agent-protocol.ts index 7446fbb2..fb8cc791 100644 --- a/src/resources/chat-widget/src/agent-protocol.ts +++ b/src/resources/chat-widget/src/agent-protocol.ts @@ -37,6 +37,7 @@ export const ACTIONS = [ "preview_close", "preview_reload", "preview_state", + "store_suggest", "finish", ] as const; export type ActionType = (typeof ACTIONS)[number]; @@ -584,6 +585,12 @@ export function parseStep(content: string): { ok: true; step: Step } | { ok: fal case "preview_state": actions.push({ type }); break; + case "store_suggest": { + const n = typeof a.name === "string" ? a.name.trim().toLowerCase() : ""; + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(n)) dropped.push('store_suggest left out: "name" has to be a conda-forge package name, for example flask'); + else actions.push({ type, name: n }); + break; + } case "preview_open": { const port = a.port === undefined ? undefined : typeof a.port === "number" && Number.isInteger(a.port) && a.port >= 1024 && a.port <= 65535 ? a.port : null; const p = a.path === undefined ? undefined : cleanPath(a.path, false); @@ -680,6 +687,8 @@ export function describe(a: Action): string { return "Moving " + a.path + " into " + where(a.to); case "files_delete": return "Deleting " + a.path; + case "store_suggest": + return "Offering to install " + a.name + " from the App Store"; case "preview_open": return a.port ? "Opening the Preview on port " + a.port : a.path ? "Opening " + a.path + " in the Preview" : "Opening the Preview"; case "preview_close": diff --git a/src/resources/chat-widget/src/agent.ts b/src/resources/chat-widget/src/agent.ts index eacd75bb..81315aad 100644 --- a/src/resources/chat-widget/src/agent.ts +++ b/src/resources/chat-widget/src/agent.ts @@ -720,6 +720,13 @@ export class AgentRunner { detail: "tab closed; now on tab " + now.active + " of " + now.count + "; Run uses the language in the picker (" + this.pickerLanguage() + ")", }; } + case "store_suggest": { + // the App Store opens on the app; the user decides whether to install it and to switch it on (nothing is installed here) + const S = (window as any).AppStore; + if (!S || typeof S.available !== "function" || !S.available()) return { type: a.type, status: "failed", detail: "the App Store is only for signed-in users, and this one is not signed in or it is switched off" }; + S.suggest(String(a.name || "")); + return { type: a.type, status: "done", detail: "the App Store is open on " + a.name + "; the user chooses whether to install it and turn it on, then tell them to run their program again" }; + } case "preview_open": case "preview_close": case "preview_reload": diff --git a/src/resources/chat-widget/test/agent-protocol.test.ts b/src/resources/chat-widget/test/agent-protocol.test.ts index 283c2f7b..2cf29411 100644 --- a/src/resources/chat-widget/test/agent-protocol.test.ts +++ b/src/resources/chat-widget/test/agent-protocol.test.ts @@ -130,6 +130,7 @@ test("the list of actions is the one in the server's prompt", () => { "preview_close", "preview_reload", "preview_state", + "store_suggest", "finish", ]); }); @@ -539,3 +540,15 @@ test("a shell command typed into a language's REPL is recognised, the language's } assert.ok(SHELL_REPLS.includes("bash") && !SHELL_REPLS.includes("python")); }); + +test("store_suggest names a conda-forge package and needs no permission: it only opens the App Store", () => { + const types = (j: string) => ok(j).actions; + assert.deepEqual(types('{"actions":[{"type":"store_suggest","name":"Flask"}]}'), [{ type: "store_suggest", name: "flask" }]); + for (const bad of ['{"type":"store_suggest"}', '{"type":"store_suggest","name":"../x"}', '{"type":"store_suggest","name":"a b"}', '{"type":"store_suggest","name":5}']) { + const s = ok('{"actions":[' + bad + "]}"); + assert.equal(s.actions.length, 0, bad); + assert.equal(s.dropped.length, 1, bad); + } + assert.equal(scopeOf({ type: "store_suggest", name: "flask" }), null); + assert.match(describe({ type: "store_suggest", name: "flask" }), /flask/); +}); diff --git a/src/resources/css/ui-refresh.css b/src/resources/css/ui-refresh.css index 3adb2f8a..3bbde395 100644 --- a/src/resources/css/ui-refresh.css +++ b/src/resources/css/ui-refresh.css @@ -4383,3 +4383,42 @@ body .ctx-item:focus-visible { outline: 2px solid var(--accent-on-dark) !importa .preview-drop { position: fixed; z-index: 10000; pointer-events: none; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px dashed var(--accent-color, #b6e03a); border-radius: 10px; background: rgba(182, 224, 58, 0.12); color: var(--paper); font-weight: 600; font-size: 14px; } .preview-drop[hidden] { display: none; } .preview-drop.is-out { transform: translateX(-50%); padding: 8px 14px; background: var(--ink-800, #1d1e1a); } + +/* The App Store (page/33-app-store.js) */ +#appstore-button { display: none; } +body.is-signed-in #appstore-button { display: inline-flex; } +.appstore { border: 0; padding: 0; background: transparent; color: var(--paper); max-width: min(720px, 94vw); width: 720px; } +.appstore::backdrop { background: rgba(0, 0, 0, 0.55); } +.appstore__card { background: var(--ink-850, #17181a); border: 1px solid var(--ink-600); border-radius: 14px; padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; max-height: 84vh; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); } +.appstore__head { display: flex; align-items: center; justify-content: space-between; } +.appstore__head h2 { margin: 0; font-size: 18px; font-weight: 600; } +.appstore__close { background: none; border: 0; color: var(--muted-1); cursor: pointer; padding: 4px; border-radius: 6px; } +.appstore__close:hover, .appstore__close:focus-visible { color: var(--paper); background: var(--ink-800); } +.appstore__tabs { display: flex; gap: 6px; } +.appstore__tabs button { background: none; border: 1px solid var(--ink-600); color: var(--muted-1); border-radius: 9px; padding: 5px 14px; font: inherit; cursor: pointer; } +.appstore__tabs button.is-on { border-color: var(--accent-color, #b6e03a); color: var(--paper); } +.appstore__tools input { width: 100%; box-sizing: border-box; background: var(--ink-900); border: 1px solid var(--ink-600); border-radius: 9px; padding: 8px 12px; color: var(--paper); font: inherit; } +.appstore__note { background: var(--ink-800); border: 1px solid var(--ink-600); border-radius: 9px; padding: 8px 12px; color: var(--muted-1); line-height: 1.4; } +.appstore__note.is-bad { border-color: #7a3b33; color: #ffb4a6; } +.appstore__list { display: flex; flex-direction: column; gap: 8px; overflow: auto; min-height: 120px; } +.appstore__app { background: var(--ink-900); border: 1px solid var(--ink-700); border-radius: 11px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; } +.appstore__top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.appstore__name { font-size: 14px; } +.appstore__ver, .appstore__size { font-family: var(--font-code, monospace); font-size: 11.5px; color: var(--muted-2); } +.appstore__tag { font-size: 11px; border-radius: 9px; padding: 1px 8px; background: var(--ink-800); color: var(--muted-1); } +.appstore__tag--installed { background: #1f2b12; color: #b6e03a; } +.appstore__tag--installing { background: #16262e; color: #8fd0f0; } +.appstore__tag--failed, .appstore__tag--rejected { background: #38201c; color: #ffb4a6; } +.appstore__tag--requested { background: #33290f; color: #f2d58a; } +.appstore__sum { margin: 0; color: var(--muted-1); line-height: 1.45; font-size: 13px; } +.appstore__log { margin: 0; background: #0e0f0c; border: 1px solid var(--ink-700); border-radius: 8px; padding: 8px 10px; font: 11.5px/1.5 var(--font-code, monospace); color: #ffb4a6; white-space: pre-wrap; max-height: 120px; overflow: auto; } +.appstore__row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.appstore__btn { background: var(--ink-800); color: var(--paper); border: 1px solid var(--ink-600); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; } +.appstore__btn.is-primary { background: var(--accent-color, #b6e03a); color: #15190a; border-color: transparent; font-weight: 600; } +.appstore__switch { min-width: 52px; border-radius: 12px; border: 1px solid var(--ink-600); background: var(--ink-800); color: var(--muted-1); padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; } +.appstore__switch.is-on { background: var(--accent-color, #b6e03a); color: #15190a; border-color: transparent; font-weight: 600; } +.appstore__guest { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; padding: 28px 10px; color: var(--muted-1); } +.appstore__guest b { color: var(--paper); font-size: 15px; } +.appstore__foot { display: flex; flex-direction: column; gap: 6px; } +.appstore__apply { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: #16262e; border: 1px solid #27414d; border-radius: 9px; padding: 8px 12px; color: #a8d4e8; } +.appstore__help { font-size: 12px; color: var(--muted-2); } diff --git a/src/resources/index.html b/src/resources/index.html index c961bcb8..ca5bcdd6 100644 --- a/src/resources/index.html +++ b/src/resources/index.html @@ -203,6 +203,23 @@ + + +
    +
    +

    App Store

    + +
    +
    + + +
    +
    + +
    +
    +
    +