diff --git a/AGENTS.md b/AGENTS.md index 1f76bd3..7823a24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`. -The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume. +The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, `--cpus 1` and `--memory 2048m` (a light orchestration process: the heavy work happens in each computer's own container; overridable with `--cpus`/`--memory`), a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume. Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative. diff --git a/README.md b/README.md index 82d528e..629b966 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ The installer binds to `0.0.0.0` so the app is reachable from your phone on the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only thing that should reach it. +The app container itself is limited to 1 CPU and 2 GB of memory, since the +heavy work happens in the computers' own containers. Change that with +`--cpus` and `--memory`; each computer keeps its own limits. + ## HTTPS Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy @@ -147,6 +151,8 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu | `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` | | `--port PORT` | Host port, default `7227` | | `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` | +| `--cpus N` | CPU cores for the app container, default `1` | +| `--memory MB` | Memory for the app container in MB, default `2048` | | `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` | | `--version TAG` | Image tag, default `latest` | | `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | diff --git a/install.sh b/install.sh index 82c80db..9a4ad03 100644 --- a/install.sh +++ b/install.sh @@ -17,6 +17,10 @@ CONTAINER="cobble" NETWORK="cobble" DEFAULT_PORT="7227" DEFAULT_BIND="0.0.0.0" +# The app container's own limits (the computers' sandboxes are limited +# separately, per computer). +DEFAULT_CPUS="1" +DEFAULT_MEMORY_MB="2048" DEFAULT_IMAGE="ghcr.io/moduletrace/cobble" DEFAULT_VERSION="latest" INSTALL_URL="https://url.moduletrace.com/cobble" @@ -69,6 +73,8 @@ Options: --data-dir DIR Data directory, mounted at /data (default HOME/data) --port PORT Host port (default 7227) --bind ADDRESS Host address to publish on (default 0.0.0.0) + --cpus N CPU cores the app container may use (default 1) + --memory MB Memory the app container may use (default 2048) --image IMAGE Container image (default $DEFAULT_IMAGE) --version TAG Image tag (default latest) --no-pull Use the image already on this machine @@ -144,6 +150,8 @@ fi DATA_DIR="${COBBLE_DATA_DIR:-}" PORT="${COBBLE_PORT:-}" BIND="${COBBLE_BIND:-}" +CPUS="${COBBLE_CPUS:-}" +MEMORY_MB="${COBBLE_MEMORY_MB:-}" IMAGE="${COBBLE_IMAGE:-}" VERSION="${COBBLE_VERSION:-}" EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}" @@ -170,6 +178,8 @@ fi DATA_DIR="${DATA_DIR:-$HOME_DIR/data}" PORT="${PORT:-$DEFAULT_PORT}" BIND="${BIND:-$DEFAULT_BIND}" +CPUS="${CPUS:-$DEFAULT_CPUS}" +MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}" IMAGE="${IMAGE:-$DEFAULT_IMAGE}" VERSION="${VERSION:-$DEFAULT_VERSION}" @@ -191,6 +201,14 @@ while [ $# -gt 0 ]; do BIND="${2:-}" shift 2 ;; + --cpus) + CPUS="${2:-}" + shift 2 + ;; + --memory) + MEMORY_MB="${2:-}" + shift 2 + ;; --image) IMAGE="${2:-}" shift 2 @@ -256,6 +274,8 @@ done [ -n "$DATA_DIR" ] || fail "--data-dir needs a value" [ -n "$PORT" ] || fail "--port needs a value" [ -n "$BIND" ] || fail "--bind needs a value" +[ -n "$CPUS" ] || fail "--cpus needs a value" +[ -n "$MEMORY_MB" ] || fail "--memory needs a value" [ -n "$IMAGE" ] || fail "--image needs a value" [ -n "$VERSION" ] || fail "--version needs a value" @@ -276,6 +296,14 @@ esac case "$BIND" in '' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;; esac +case "$CPUS" in + '' | *[!0-9.]*) fail "--cpus must be a number" ;; +esac +[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0" +case "$MEMORY_MB" in + '' | *[!0-9]*) fail "--memory must be a number of megabytes" ;; +esac +[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512" case "$IMAGE" in '' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;; esac @@ -428,6 +456,8 @@ container_matches() { [ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1 [ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1 [ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1 + [ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1 + [ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1 docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1 docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1 docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1 @@ -584,6 +614,8 @@ run_container() { --init \ --network "$NETWORK" \ -p "$BIND:$PORT:$PORT" \ + --cpus "$CPUS" \ + --memory "${MEMORY_MB}m" \ "${mounts[@]}" \ "${envs[@]}" \ --log-driver json-file \ @@ -719,6 +751,8 @@ unset_env HTTP_REDIRECT_PORT printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST" printf 'COBBLE_PORT=%q\n' "$PORT" printf 'COBBLE_BIND=%q\n' "$BIND" + printf 'COBBLE_CPUS=%q\n' "$CPUS" + printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB" printf 'COBBLE_IMAGE=%q\n' "$IMAGE" printf 'COBBLE_VERSION=%q\n' "$VERSION" printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK" diff --git a/src/functions/backend/db/app-settings/grab-app-settings.ts b/src/functions/backend/db/app-settings/grab-app-settings.ts index 8a66463..ffe8afc 100644 --- a/src/functions/backend/db/app-settings/grab-app-settings.ts +++ b/src/functions/backend/db/app-settings/grab-app-settings.ts @@ -1,14 +1,25 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db"; +import globalCache from "@/src/utils/global-cache"; -let cache: Map | null = null; +/** + * The cache lives on `globalThis`, behind a registered symbol, rather than + * in a module variable: the page and API bundles each carry their own copy + * of this module, so a module-scoped cache would let a save clear its own + * copy while another page kept serving a stale one until the next restart. + */ +const cache = globalCache>({ + name: "cobble.grabAppSettings.cache", +}); /** * Every install-wide setting, cached per process (like `grabAllEnvs`). * `saveAppSetting` and `deleteAppSetting` clear it. */ export default async function grabAppSettings() { - if (cache) return cache; + const cached = cache.get(); + + if (cached) return cached; const res = await BunSQLite.sql({ sql: `SELECT key, value FROM app_settings`, @@ -21,7 +32,7 @@ export default async function grabAppSettings() { if (row.key) map.set(row.key, row.value || ""); } - cache = map; + cache.set(map); return map; } @@ -30,5 +41,5 @@ export default async function grabAppSettings() { * Drops the cache, so the next read sees the database. */ export function clearAppSettingsCache() { - cache = null; + cache.clear(); } diff --git a/src/functions/backend/db/envs/grab-all-envs.ts b/src/functions/backend/db/envs/grab-all-envs.ts index 753a602..8a25b73 100644 --- a/src/functions/backend/db/envs/grab-all-envs.ts +++ b/src/functions/backend/db/envs/grab-all-envs.ts @@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db"; import type { EnvParadigm } from "@/src/dict/env-dict"; import { decrypt } from "@/src/utils/crypt"; +import globalCache from "@/src/utils/global-cache"; export type DecryptedEnv = { id: number; @@ -16,7 +17,16 @@ export type DecryptedEnv = { expose_to_computers: boolean; }; -let cache: Promise | undefined; +/** + * The cache lives on `globalThis`, behind a registered symbol, rather than + * in a module variable: the page and API bundles each carry their own copy + * of this module, so a module-scoped cache would let `save-env` clear its + * own copy while the page kept serving a stale one until the next restart + * (a saved API key never reached the models shown in the app). + */ +const cache = globalCache>({ + name: "cobble.grabAllEnvs.cache", +}); /** * Every env, decrypted, cached in this process until `clearEnvCache` (every @@ -24,16 +34,22 @@ let cache: Promise | undefined; * encryption key changed) reads as empty. */ export default function grabAllEnvs(): Promise { - cache ||= load().catch((error) => { - cache = undefined; + const cached = cache.get(); + + if (cached) return cached; + + const loading = load().catch((error: unknown) => { + cache.clear(); throw error; }); - return cache; + cache.set(loading); + + return loading; } export function clearEnvCache() { - cache = undefined; + cache.clear(); } async function load() { diff --git a/src/utils/global-cache.test.ts b/src/utils/global-cache.test.ts new file mode 100644 index 0000000..855c489 --- /dev/null +++ b/src/utils/global-cache.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import globalCache from "./global-cache"; + +describe("globalCache", () => { + test("keeps a value and clears it", () => { + const cache = globalCache({ name: "cobble.grabAllEnvs.cache" }); + + cache.clear(); + expect(cache.get()).toBeUndefined(); + + cache.set(7); + expect(cache.get()).toBe(7); + + cache.clear(); + expect(cache.get()).toBeUndefined(); + }); + + test("two slots with one name share a value, as two bundles would", () => { + const page_bundle = globalCache({ + name: "cobble.grabAppSettings.cache", + }); + const api_bundle = globalCache({ + name: "cobble.grabAppSettings.cache", + }); + + page_bundle.clear(); + page_bundle.set("stale"); + + // The API sees what the page wrote, so a save there clears it too. + expect(api_bundle.get()).toBe("stale"); + + api_bundle.clear(); + expect(page_bundle.get()).toBeUndefined(); + }); + + test("different names never share a slot", () => { + const envs = globalCache({ name: "cobble.grabAllEnvs.cache" }); + const settings = globalCache({ + name: "cobble.grabAppSettings.cache", + }); + + envs.clear(); + settings.clear(); + + envs.set("a"); + settings.set("b"); + + expect(envs.get()).toBe("a"); + expect(settings.get()).toBe("b"); + + envs.clear(); + settings.clear(); + }); +}); diff --git a/src/utils/global-cache.ts b/src/utils/global-cache.ts new file mode 100644 index 0000000..77d26e3 --- /dev/null +++ b/src/utils/global-cache.ts @@ -0,0 +1,41 @@ +/** + * A per-process cache slot that survives module duplication. + * + * bunext bundles pages and API routes separately, so a module-scoped `let` + * cache exists once per bundle: a save that clears its own copy leaves the + * other one serving stale data until the process restarts (a saved API key + * never reached the model list). The key is taken from the global symbol + * registry, which resolves to the same symbol in every bundle, and the value + * is stored on `globalThis`, so every copy reads and clears one slot. + */ +type Slot = { + get(): Value | undefined; + set(value: Value): void; + clear(): void; +}; + +/** + * Named caches, so two modules never share a slot by accident. + */ +export type GlobalCacheName = + | "cobble.grabAllEnvs.cache" + | "cobble.grabAppSettings.cache"; + +export default function globalCache({ + name, +}: { + name: GlobalCacheName; +}): Slot { + const key = Symbol.for(name); + const holder = globalThis as Record; + + return { + get: () => holder[key], + set: (value: Value) => { + holder[key] = value; + }, + clear: () => { + delete holder[key]; + }, + }; +}