2 Commits
Author SHA1 Message Date
tben 89e57cc467 Pad env rows and reflow the envs group
CI / check (push) Canceled after 0s
Give each env row its own horizontal padding so the key lines up with the
group's other content, and reflow the props and description literals in
env-row and envs-group.
2026-10-22 15:24:58 +01:00
tben e1faa2141e 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.
2026-10-22 15:16:51 +01:00
9 changed files with 227 additions and 22 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 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.
+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
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 |
+34
View File
@@ -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"
@@ -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<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`).
* `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<BUN_SQLITE_COBBLE_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 || "");
}
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();
}
+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 { 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<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
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
* encryption key changed) reads as empty.
*/
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
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() {
@@ -2,10 +2,16 @@ import { useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
import type {
EnvScope,
EnvsMutationResult,
KnownEnvStatus,
PublicEnv,
} from "@/src/types";
import postEnvApi from "../../(functions)/post-env-api";
import EnvForm from "./env-form";
import EnvRowActions from "./env-row-actions";
import { twMerge } from "tailwind-merge";
type Props = {
scope: EnvScope;
@@ -27,14 +33,26 @@ type Props = {
* One env: its key, description and value (or "secret"), with Edit and
* Remove. Edit opens the form in place.
*/
export default function EnvRow({ scope, env, known, scoped, onChanged, onError }: Props) {
export default function EnvRow({
scope,
env,
known,
scoped,
onChanged,
onError,
}: Props) {
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const key = env?.key || known?.key || "";
const remove = async () => {
if (!env || busy) return;
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
if (
!window.confirm(
`Remove ${key}? Computers drop it on their next run.`,
)
)
return;
setBusy(true);
const res = await postEnvApi({
@@ -43,7 +61,8 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
});
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
if (res.success && res.singleRes)
onChanged(res.singleRes, res.msg || "Removed.");
else onError(res.msg || "Couldn't remove the env.");
};
@@ -72,12 +91,22 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
? "From the server's environment"
: "Not set";
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
const flags =
env && !env.known && !env.expose_to_computers
? " · not on computers"
: "";
return (
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
<Row
className={twMerge(
"w-full gap-3! flex-nowrap items-start justify-between py-3 px-4",
"border-0 border-b border-solid border-hairline last:border-b-0",
)}
>
<Stack className="gap-0.5! min-w-0">
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
<Span className="text-sm font-bold leading-5 text-ink truncate">
{key}
</Span>
{env?.description || known?.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
{known?.description || env?.description}
@@ -34,20 +34,34 @@ type Props = {
};
const Descriptions: Record<EnvScope["paradigm"], string> = {
general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
workspace: "For every bot working here. Beats general, loses to the bot's own.",
computer: "For this computer only. Beats a workspace, loses to the bot's own.",
general:
"Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
workspace:
"For every bot working here. Beats general, loses to the bot's own.",
computer:
"For this computer only. Beats a workspace, loses to the bot's own.",
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
};
/**
* The envs bots and computers use, in one scope, with an add form.
*/
export default function EnvsGroup({ scope, envs, scoped, title, status, onChanged, onError }: Props) {
export default function EnvsGroup({
scope,
envs,
scoped,
title,
status,
onChanged,
onError,
}: Props) {
const [adding, setAdding] = useState(false);
return (
<SettingsGroup title={title || "Envs"} description={Descriptions[scope.paradigm]}>
<SettingsGroup
title={title || "Envs"}
description={Descriptions[scope.paradigm]}
>
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
{envs.map((env) => (
<EnvRow
+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];
},
};
}