Limit the app container to 1 CPU and 2 GB, and stop stale caches
CI / check (push) Canceled after 0s

The app container is now created with --cpus 1 --memory 2048m: the heavy
work happens in the computers' own containers, so the orchestrator does
not need more. Both are installer flags (--cpus, --memory), kept in
install.conf, and part of the idempotency check, so an existing install
picks them up on its next update.

The env and app-setting caches lived in module variables, and bunext
bundles pages and API routes separately. A save cleared only its own
copy, so a saved provider key never reached the page that lists models
until the process restarted. They now share one slot per name on
globalThis, behind a symbol from the global registry, with the pattern
extracted into a tested helper.
This commit is contained in:
2026-10-22 15:16:51 +01:00
parent 06210f00a5
commit e1faa2141e
7 changed files with 172 additions and 10 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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 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. 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.
+6
View File
@@ -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 the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
thing that should reach it. 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 ## HTTPS
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy 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` | | `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
| `--port PORT` | Host port, default `7227` | | `--port PORT` | Host port, default `7227` |
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` | | `--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` | | `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
| `--version TAG` | Image tag, default `latest` | | `--version TAG` | Image tag, default `latest` |
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | | `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
+34
View File
@@ -17,6 +17,10 @@ CONTAINER="cobble"
NETWORK="cobble" NETWORK="cobble"
DEFAULT_PORT="7227" DEFAULT_PORT="7227"
DEFAULT_BIND="0.0.0.0" 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_IMAGE="ghcr.io/moduletrace/cobble"
DEFAULT_VERSION="latest" DEFAULT_VERSION="latest"
INSTALL_URL="https://url.moduletrace.com/cobble" INSTALL_URL="https://url.moduletrace.com/cobble"
@@ -69,6 +73,8 @@ Options:
--data-dir DIR Data directory, mounted at /data (default HOME/data) --data-dir DIR Data directory, mounted at /data (default HOME/data)
--port PORT Host port (default 7227) --port PORT Host port (default 7227)
--bind ADDRESS Host address to publish on (default 0.0.0.0) --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) --image IMAGE Container image (default $DEFAULT_IMAGE)
--version TAG Image tag (default latest) --version TAG Image tag (default latest)
--no-pull Use the image already on this machine --no-pull Use the image already on this machine
@@ -144,6 +150,8 @@ fi
DATA_DIR="${COBBLE_DATA_DIR:-}" DATA_DIR="${COBBLE_DATA_DIR:-}"
PORT="${COBBLE_PORT:-}" PORT="${COBBLE_PORT:-}"
BIND="${COBBLE_BIND:-}" BIND="${COBBLE_BIND:-}"
CPUS="${COBBLE_CPUS:-}"
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
IMAGE="${COBBLE_IMAGE:-}" IMAGE="${COBBLE_IMAGE:-}"
VERSION="${COBBLE_VERSION:-}" VERSION="${COBBLE_VERSION:-}"
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}" EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
@@ -170,6 +178,8 @@ fi
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}" DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
PORT="${PORT:-$DEFAULT_PORT}" PORT="${PORT:-$DEFAULT_PORT}"
BIND="${BIND:-$DEFAULT_BIND}" BIND="${BIND:-$DEFAULT_BIND}"
CPUS="${CPUS:-$DEFAULT_CPUS}"
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
IMAGE="${IMAGE:-$DEFAULT_IMAGE}" IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
VERSION="${VERSION:-$DEFAULT_VERSION}" VERSION="${VERSION:-$DEFAULT_VERSION}"
@@ -191,6 +201,14 @@ while [ $# -gt 0 ]; do
BIND="${2:-}" BIND="${2:-}"
shift 2 shift 2
;; ;;
--cpus)
CPUS="${2:-}"
shift 2
;;
--memory)
MEMORY_MB="${2:-}"
shift 2
;;
--image) --image)
IMAGE="${2:-}" IMAGE="${2:-}"
shift 2 shift 2
@@ -256,6 +274,8 @@ done
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value" [ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
[ -n "$PORT" ] || fail "--port needs a value" [ -n "$PORT" ] || fail "--port needs a value"
[ -n "$BIND" ] || fail "--bind 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 "$IMAGE" ] || fail "--image needs a value"
[ -n "$VERSION" ] || fail "--version needs a value" [ -n "$VERSION" ] || fail "--version needs a value"
@@ -276,6 +296,14 @@ esac
case "$BIND" in case "$BIND" in
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;; '' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
esac 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 case "$IMAGE" in
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;; '' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
esac 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 '{{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.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || 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 "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 "PORT=$PORT" || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || 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 \ --init \
--network "$NETWORK" \ --network "$NETWORK" \
-p "$BIND:$PORT:$PORT" \ -p "$BIND:$PORT:$PORT" \
--cpus "$CPUS" \
--memory "${MEMORY_MB}m" \
"${mounts[@]}" \ "${mounts[@]}" \
"${envs[@]}" \ "${envs[@]}" \
--log-driver json-file \ --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_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
printf 'COBBLE_PORT=%q\n' "$PORT" printf 'COBBLE_PORT=%q\n' "$PORT"
printf 'COBBLE_BIND=%q\n' "$BIND" 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_IMAGE=%q\n' "$IMAGE"
printf 'COBBLE_VERSION=%q\n' "$VERSION" printf 'COBBLE_VERSION=%q\n' "$VERSION"
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK" printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
@@ -1,14 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
import globalCache from "@/src/utils/global-cache";
let cache: Map<string, string> | 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<Map<string, string>>({
name: "cobble.grabAppSettings.cache",
});
/** /**
* Every install-wide setting, cached per process (like `grabAllEnvs`). * Every install-wide setting, cached per process (like `grabAllEnvs`).
* `saveAppSetting` and `deleteAppSetting` clear it. * `saveAppSetting` and `deleteAppSetting` clear it.
*/ */
export default async function grabAppSettings() { export default async function grabAppSettings() {
if (cache) return cache; const cached = cache.get();
if (cached) return cached;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({ const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
sql: `SELECT key, value FROM app_settings`, 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 || ""); if (row.key) map.set(row.key, row.value || "");
} }
cache = map; cache.set(map);
return map; return map;
} }
@@ -30,5 +41,5 @@ export default async function grabAppSettings() {
* Drops the cache, so the next read sees the database. * Drops the cache, so the next read sees the database.
*/ */
export function clearAppSettingsCache() { export function clearAppSettingsCache() {
cache = null; cache.clear();
} }
+21 -5
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
import type { EnvParadigm } from "@/src/dict/env-dict"; import type { EnvParadigm } from "@/src/dict/env-dict";
import { decrypt } from "@/src/utils/crypt"; import { decrypt } from "@/src/utils/crypt";
import globalCache from "@/src/utils/global-cache";
export type DecryptedEnv = { export type DecryptedEnv = {
id: number; id: number;
@@ -16,7 +17,16 @@ export type DecryptedEnv = {
expose_to_computers: boolean; expose_to_computers: boolean;
}; };
let cache: Promise<DecryptedEnv[]> | 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<Promise<DecryptedEnv[]>>({
name: "cobble.grabAllEnvs.cache",
});
/** /**
* Every env, decrypted, cached in this process until `clearEnvCache` (every * Every env, decrypted, cached in this process until `clearEnvCache` (every
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
* encryption key changed) reads as empty. * encryption key changed) reads as empty.
*/ */
export default function grabAllEnvs(): Promise<DecryptedEnv[]> { export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
cache ||= load().catch((error) => { const cached = cache.get();
cache = undefined;
if (cached) return cached;
const loading = load().catch((error: unknown) => {
cache.clear();
throw error; throw error;
}); });
return cache; cache.set(loading);
return loading;
} }
export function clearEnvCache() { export function clearEnvCache() {
cache = undefined; cache.clear();
} }
async function load() { async function load() {
+54
View File
@@ -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<number>({ 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<string>({
name: "cobble.grabAppSettings.cache",
});
const api_bundle = globalCache<string>({
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<string>({ name: "cobble.grabAllEnvs.cache" });
const settings = globalCache<string>({
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();
});
});
+41
View File
@@ -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<Value> = {
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<Value>({
name,
}: {
name: GlobalCacheName;
}): Slot<Value> {
const key = Symbol.for(name);
const holder = globalThis as Record<symbol, Value | undefined>;
return {
get: () => holder[key],
set: (value: Value) => {
holder[key] = value;
},
clear: () => {
delete holder[key];
},
};
}