5 Commits
Author SHA1 Message Date
tben 70d6f9b3d8 Bump the version to 0.1.2
CI / check (push) Canceled after 0s
2026-10-22 16:43:19 +01:00
tben 05af559adb Keep SELinux mount options out of the mount destination
Appending `,z` to a mount destination (`$DATA_DIR:/data,z`) makes docker
mount a directory literally named `/data,z`. The app then writes its
database into its own container layer instead of the host volume,
`container_matches` never matches so every update recreates the container
without its data, `backup_database` silently skips, and `resolveHostPath`
refuses to provision computers (`/data/computers/1 isn't on a volume`).

Build each spec as `source:destination[:options]` instead, with the `z`
and `ro` options comma separated in the third field and the destination a
plain absolute path. `backup_database` uses the same helper.

`rescue_container_data` runs before a container whose /data is not the
host's directory is replaced: it stops the app, copies `CONTAINER:/data`
out with `docker cp`, moves a non-empty host directory aside and writes
the copy in, so repairing an install from the broken form does not lose
the database, the media or the computers' home directories.

`install.test.ts` lifts the marked `selinux-detection` and `mount-specs`
blocks out of install.sh, runs them in bash and parses the specs the way
docker does, so the two forms can't drift back.
2026-10-22 16:43:10 +01:00
tben 524f0dde36 Show the running version on Settings > General
The General page ends with a Version block naming the release this
server runs, so an update can be confirmed from the app.

The version is COBBLE_VERSION, the release tag the image was built with
(Dockerfile ARG, passed by the release workflow, which falls back to
package.json's version on a workflow-dispatch build), else package.json's
version, which a development server reports too, with (development)
after it. runningVersion (src/utils/running-version.ts) drops an
unusable version string rather than showing it.

Also includes the working tree's pending signup guard in
src/pages/__root.server.ts: a user who still holds a session while the
install has no users is sent to /auth/logout (and may load it).
2026-10-22 16:03:56 +01:00
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
22 changed files with 758 additions and 42 deletions
+13
View File
@@ -37,11 +37,20 @@ jobs:
username: ${{ secrets.GITEA_USERNAME }} username: ${{ secrets.GITEA_USERNAME }}
password: ${{ secrets.GITEA_TOKEN }} password: ${{ secrets.GITEA_TOKEN }}
# The version the app reports on Settings > General when the build
# isn't a published release.
- name: Read the source version
id: source
run: |
set -euo pipefail
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT"
- name: Work out image tags - name: Work out image tags
id: tags id: tags
env: env:
EVENT: ${{ github.event_name }} EVENT: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }} RELEASE_TAG: ${{ github.event.release.tag_name }}
SOURCE_VERSION: ${{ steps.source.outputs.version }}
run: | run: |
set -euo pipefail set -euo pipefail
sha="sha-${GITHUB_SHA::12}" sha="sha-${GITHUB_SHA::12}"
@@ -59,10 +68,12 @@ jobs:
"git.tben.me/moduletrace/cobble:${minor}" \ "git.tben.me/moduletrace/cobble:${minor}" \
"git.tben.me/moduletrace/cobble:${sha}")" "git.tben.me/moduletrace/cobble:${sha}")"
else else
version="$SOURCE_VERSION"
ghcr="ghcr.io/moduletrace/cobble:${sha}" ghcr="ghcr.io/moduletrace/cobble:${sha}"
gitea="git.tben.me/moduletrace/cobble:${sha}" gitea="git.tben.me/moduletrace/cobble:${sha}"
fi fi
{ {
echo "version=$version"
echo "ghcr<<EOF" echo "ghcr<<EOF"
echo "$ghcr" echo "$ghcr"
echo "EOF" echo "EOF"
@@ -79,6 +90,8 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
build-args: |
COBBLE_VERSION=${{ steps.tags.outputs.version }}
tags: ${{ steps.tags.outputs.ghcr }} tags: ${{ steps.tags.outputs.ghcr }}
provenance: false provenance: false
sbom: false sbom: false
+5 -3
View File
@@ -126,11 +126,13 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`). `install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`).
The bind mounts are built in the marked `selinux-detection` and `mount-specs` blocks, and `install.test.ts` runs those blocks in bash and parses the specs the way docker does. A mount spec is `source:destination[:options]`: docker takes the SELinux `z` and the read-only `ro` as comma separated options in that third field, so a destination is always a plain absolute path. Appending an option to the destination (`$DATA_DIR:/data,z`) makes docker mount a directory literally named `/data,z`: the app then writes its database to its own container layer, `container_matches` never matches, every update recreates the container without its data, `backup_database` silently skips, and `resolveHostPath` refuses to provision computers (`/data/computers/1 isn't on a volume`). `rescue_container_data` runs before a container is replaced and copies a container's unmounted `/data` onto the host, so repairing an install from the broken form does not lose its database.
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. The version they are built with is passed as the `COBBLE_VERSION` build arg (the published tag, else `package.json`'s version read from `<version>`), which the image turns into `ENV COBBLE_VERSION` and `runningVersion` (`src/utils/running-version.ts`) reports on Settings > General; bump `package.json`'s `version` with a release, since a local build and a workflow-dispatch build fall back to it. `.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.
### Envs ### Envs
@@ -212,7 +214,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
- Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings - Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` - `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
+6
View File
@@ -17,6 +17,12 @@ ENV DATA_DIR=/data
ENV DB_DIR=/data/db ENV DB_DIR=/data/db
ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1 ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1
# The release's version, which Settings > General shows. The release
# workflow passes the published tag; a local build leaves it empty and
# the app falls back to `package.json`'s version.
ARG COBBLE_VERSION=
ENV COBBLE_VERSION=$COBBLE_VERSION
WORKDIR /app WORKDIR /app
RUN echo 'alias ll="ls -laF"' >/root/.bashrc RUN echo 'alias ll="ls -laF"' >/root/.bashrc
+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 |
+3 -1
View File
@@ -25,7 +25,9 @@ The repository starts private and is made public at the launch gate below.
- rerun with no changes: reports up to date, no restart - rerun with no changes: reports up to date, no restart
- update to a new tag: backup taken, data intact - update to a new tag: backup taken, data intact
- `--uninstall` keeps data, `--uninstall --purge` removes it - `--uninstall` keeps data, `--uninstall --purge` removes it
5. [ ] Update the version if the release changes it. 5. [ ] Update the version if the release changes it, including
`package.json`'s `version` (the image reports it on Settings > General
when it isn't built from a release tag).
6. [ ] Tag and publish a GitHub release: 6. [ ] Tag and publish a GitHub release:
git tag v0.1.0 git tag v0.1.0
+128 -17
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
@@ -317,23 +345,35 @@ if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash" fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
fi fi
SELINUX_SUFFIX="" # SELinux hosts need the `z` mount option so docker relabels the bind
if [ "$OS" = "Linux" ]; then # mount. It is a third colon-separated field (`src:/data:z`), never a
# suffix on the destination: docker reads `src:/data,z` as the directory
# "/data,z", which silently leaves the app writing to its own container
# layer, breaks every update (the container is recreated without its data)
# and makes computer provisioning fail. Never append to the destination.
# MARK:selinux-detection
selinux_enforcing() {
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
SELINUX_SUFFIX=",z" return 0
elif [ -e /sys/fs/selinux/enforce ]; then
SELINUX_SUFFIX=",z"
fi
fi fi
[ -e /sys/fs/selinux/enforce ]
}
ENV_MOUNT="$ENV_FILE:/app/.env:ro" MOUNT_OPTS=""
DATA_MOUNT="$DATA_DIR:/data" if [ "$OS" = "Linux" ] && selinux_enforcing; then
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives" MOUNT_OPTS="z"
if [ -n "$SELINUX_SUFFIX" ]; then
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
DATA_MOUNT="$DATA_DIR:/data,z"
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives,z"
fi fi
# END:selinux-detection
# MARK:mount-specs
# A spec is `source:destination[:options]`. Options are comma separated and
# only count in that third field, so each spec below either opens the field
# with a colon or adds to one that is already there. A destination is
# always a plain absolute path.
ENV_MOUNT="$ENV_FILE:/app/.env:ro${MOUNT_OPTS:+,$MOUNT_OPTS}"
DATA_MOUNT="$DATA_DIR:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives${MOUNT_OPTS:+:$MOUNT_OPTS}"
# END:mount-specs
# ---------------------------------------------------------------- helpers # ---------------------------------------------------------------- helpers
@@ -428,6 +468,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
@@ -497,10 +539,7 @@ backup_database() {
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
say "Backing up the database" say "Backing up the database"
local backup_mount="$data_source:/data" local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
if [ -n "$SELINUX_SUFFIX" ]; then
backup_mount="$backup_mount,z"
fi
local image_id="" local image_id=""
if container_running; then if container_running; then
@@ -545,6 +584,71 @@ backup_database() {
fi fi
} }
# Whether the container's /data is really the host's data directory.
# Older installers wrote the SELinux option onto the destination
# (`$DATA_DIR:/data,z`), so docker mounted a directory named "/data,z"
# and the app kept its database in the container's own writable layer.
# Such a container must have that data lifted out before it is replaced.
data_mount_broken() {
container_exists || return 1
local mounted
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
[ "$mounted" != "$DATA_DIR" ]
}
# Copies a container's unmounted /data back onto the host before the
# container is recreated, so a broken mount is repaired without losing the
# database, the media or the computers' home directories. Runs only when
# the container's /data is not the host's directory, and refuses to
# overwrite a host directory that already holds a database.
rescue_container_data() {
data_mount_broken || return 0
# Stop the app first: the copy must see a database nothing is writing
# to, and the container is about to be replaced anyway.
if container_running; then
say "Stopping Cobble to copy its data out"
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
fi
local staged
staged="$(mktemp -d)"
if ! docker cp "$CONTAINER:/data/." "$staged/" >/dev/null 2>&1; then
rm -rf "$staged"
fail "Could not read the container's data. Its database is still in $CONTAINER; copy it out before updating."
fi
# A container that never wrote anything has nothing to rescue: the app
# is about to start fresh on the host directory.
if [ -z "$(ls -A "$staged" 2>/dev/null)" ]; then
rm -rf "$staged"
return 0
fi
say "This install's data was inside its container (a broken mount); moving it to $DATA_DIR"
if [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
local aside
aside="$DATA_DIR.before-rescue-$(date +%s)"
warn "$DATA_DIR was not empty; the copy is kept at $aside"
mv "$DATA_DIR" "$aside" || fail "Could not move $DATA_DIR aside"
fi
mkdir -p "$DATA_DIR"
cp -a "$staged/." "$DATA_DIR/" || {
rm -rf "$staged"
fail "Could not copy the rescued data into $DATA_DIR"
}
rm -rf "$staged"
if [ -f "$DATA_DIR/db/cobble" ]; then
ok "Rescued the database and data into $DATA_DIR"
else
warn "Rescued data has no database file; the install will start empty"
fi
}
safe_remove_dir() { safe_remove_dir() {
local dir="$1" local dir="$1"
case "$dir" in case "$dir" in
@@ -584,6 +688,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 +825,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"
@@ -755,6 +863,9 @@ if [ "$FORCE" = 0 ] && container_matches; then
fi fi
if container_exists; then if container_exists; then
# A container whose /data was never mounted keeps the database in its
# own layer, so lift it out before anything replaces the container.
rescue_container_data
backup_database backup_database
say "Replacing the Cobble container" say "Replacing the Cobble container"
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, test } from "bun:test";
import path from "path";
const ROOT = import.meta.dir;
const INSTALL_SCRIPT = path.join(ROOT, "install.sh");
/**
* Lifts one marked block out of `install.sh`, so the tests run the real
* installer code instead of a copy of it that could drift.
*/
async function readMarkedBlock({ name }: { name: string }): Promise<string> {
const source = await Bun.file(INSTALL_SCRIPT).text();
const start = `# MARK:${name}\n`;
const end = `# END:${name}`;
const from = source.indexOf(start);
const to = source.indexOf(end);
expect(from).toBeGreaterThan(-1);
expect(to).toBeGreaterThan(from);
return source.slice(from + start.length, to);
}
/** Runs a bash snippet and returns its stdout, failing on any stderr. */
async function runBash({ script }: { script: string }): Promise<string> {
const proc = Bun.spawn({
cmd: ["bash", "-c", script],
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stderr).toBe("");
expect(code).toBe(0);
return stdout.trim();
}
type MountSpecs = {
env: string;
data: string;
backup: string;
};
/** The three mount specs the installer builds for one SELinux state. */
async function runMountSpecs({
selinux,
}: {
selinux: boolean;
}): Promise<MountSpecs> {
const block = await readMarkedBlock({ name: "mount-specs" });
const script = [
`ENV_FILE="/opt/cobble/.env"`,
`DATA_DIR="/opt/cobble/data"`,
`BACKUP_ARCHIVE_DIR_HOST="/opt/cobble/backups-archives"`,
`MOUNT_OPTS="${selinux ? "z" : ""}"`,
block,
`printf '%s\\n' "$ENV_MOUNT" "$DATA_MOUNT" "$BACKUP_ARCHIVE_MOUNT"`,
].join("\n");
const [env = "", data = "", backup = ""] = (
await runBash({ script })
).split("\n");
return { env, data, backup };
}
/**
* Splits a `source:destination[:options]` spec the way docker does, so a
* destination that accidentally swallowed an option is caught.
*/
function parseMountSpec({ spec }: { spec: string }) {
const [source = "", destination = "", ...rest] = spec.split(":");
return { source, destination, options: rest.join(":") };
}
describe("install.sh SELinux detection", () => {
test("asks for the mount option when SELinux is enforcing", async () => {
const block = await readMarkedBlock({
name: "selinux-detection",
});
// An enforcing `getenforce` answers before the /sys fallback, so
// this result does not depend on the machine running the test.
const script = [
`OS="Linux"`,
`getenforce() { printf '%s' "Enforcing"; }`,
block,
`printf '%s' "$MOUNT_OPTS"`,
].join("\n");
expect(await runBash({ script })).toBe("z");
});
test("asks for no mount option when SELinux is disabled", async () => {
const block = await readMarkedBlock({
name: "selinux-detection",
});
// Answering `Disabled` makes the block fall through to the
// /sys/fs/selinux/enforce probe. Shadowing the path check keeps the
// result the same on an SELinux host and on a plain one.
const script = [
`OS="Linux"`,
`getenforce() { printf '%s' "Disabled"; }`,
`SELINUX_ENFORCE_FILE="/nonexistent/cobble-test"`,
block,
`printf '%s' "$MOUNT_OPTS"`,
].join("\n");
expect(await runBash({ script })).toBe("");
});
});
describe("install.sh mount specs", () => {
for (const selinux of [false, true]) {
const label = selinux ? "with SELinux" : "without SELinux";
test(`mounts the data directory at /data ${label}`, async () => {
const { data } = await runMountSpecs({ selinux });
const { source, destination, options } = parseMountSpec({
spec: data,
});
// The regression: the SELinux option must be its own field.
// Gluing it onto the destination makes docker mount a
// directory named "/data,z", so the app keeps its database in
// its own container layer, every update wipes the install and
// computer provisioning fails.
expect(source).toBe("/opt/cobble/data");
expect(destination).toBe("/data");
expect(options).toBe(selinux ? "z" : "");
});
test(`mounts the backup archive directory ${label}`, async () => {
const { backup } = await runMountSpecs({ selinux });
const { source, destination, options } = parseMountSpec({
spec: backup,
});
expect(source).toBe("/opt/cobble/backups-archives");
expect(destination).toBe("/backups-archives");
expect(options).toBe(selinux ? "z" : "");
});
test(`mounts .env read-only ${label}`, async () => {
const { env } = await runMountSpecs({ selinux });
const { source, destination, options } = parseMountSpec({
spec: env,
});
expect(source).toBe("/opt/cobble/.env");
expect(destination).toBe("/app/.env");
// Docker splits options on commas, never on colons: `ro:z` is
// an unknown option, and the container refuses to start.
expect(options).toBe(selinux ? "ro,z" : "ro");
});
test(`keeps every option in its own field ${label}`, async () => {
const specs = await runMountSpecs({ selinux });
for (const spec of Object.values(specs)) {
const { destination, options } = parseMountSpec({ spec });
expect(destination).not.toContain(",");
expect(destination).not.toContain(":");
expect(destination.startsWith("/")).toBe(true);
// Only the options docker understands, comma separated.
for (const option of options.split(",")) {
expect(["", "ro", "rw", "z", "Z"]).toContain(option);
}
}
});
}
});
describe("install.sh data rescue", () => {
test("rescues the container's data before replacing it", async () => {
const source = await Bun.file(INSTALL_SCRIPT).text();
const rescue = source.indexOf("\n rescue_container_data\n");
const replace = source.indexOf(
`say "Replacing the Cobble container"`,
);
// A container whose /data was never mounted keeps its database in
// its own layer, so the copy has to happen before the container is
// removed.
expect(rescue).toBeGreaterThan(-1);
expect(replace).toBeGreaterThan(rescue);
});
test("the database backup uses the same mount options", async () => {
const source = await Bun.file(INSTALL_SCRIPT).text();
expect(source).toContain(`backup_mount="$data_source:/data`);
expect(source).toContain("${MOUNT_OPTS:+:$MOUNT_OPTS}");
});
test("no code line glues an option onto a destination", async () => {
const source = await Bun.file(INSTALL_SCRIPT).text();
// Comments may quote the old broken form; only real code counts.
const code = source
.split("\n")
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n");
expect(code).not.toContain("/data,z");
expect(code).not.toContain("/app/.env:ro,z");
expect(code).not.toContain("/backups-archives,z");
});
});
+1
View File
@@ -1,5 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.1.2",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -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() {
+16
View File
@@ -21,6 +21,22 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
const is_signup_page = url.pathname.startsWith("/auth/signup"); const is_signup_page = url.pathname.startsWith("/auth/signup");
const has_users = await grabHasUsers(); const has_users = await grabHasUsers();
if (
!has_users &&
user?.logged_in_status &&
!url.pathname.includes(`auth/logout`)
) {
return redirect({ destination: "/auth/logout" });
}
if (
!has_users &&
user?.logged_in_status &&
url.pathname.includes(`auth/logout`)
) {
return {};
}
if (!has_users && !is_signup_page) { if (!has_users && !is_signup_page) {
return redirect({ destination: "/auth/signup" }); return redirect({ destination: "/auth/signup" });
} }
@@ -7,6 +7,7 @@ import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview"; import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append"; import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
import runningVersion from "@/src/utils/running-version";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0]; type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
@@ -44,6 +45,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
has_password, has_password,
storage_quota: await grabStorageQuotaOverview().catch(() => null), storage_quota: await grabStorageQuotaOverview().catch(() => null),
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null), system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
running_version: runningVersion({ env: process.env }),
}, },
}; };
} }
@@ -2,10 +2,16 @@ import { useState } from "react";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; 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 postEnvApi from "../../(functions)/post-env-api";
import EnvForm from "./env-form"; import EnvForm from "./env-form";
import EnvRowActions from "./env-row-actions"; import EnvRowActions from "./env-row-actions";
import { twMerge } from "tailwind-merge";
type Props = { type Props = {
scope: EnvScope; scope: EnvScope;
@@ -27,14 +33,26 @@ type Props = {
* One env: its key, description and value (or "secret"), with Edit and * One env: its key, description and value (or "secret"), with Edit and
* Remove. Edit opens the form in place. * 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 [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const key = env?.key || known?.key || ""; const key = env?.key || known?.key || "";
const remove = async () => { const remove = async () => {
if (!env || busy) return; 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); setBusy(true);
const res = await postEnvApi({ const res = await postEnvApi({
@@ -43,7 +61,8 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
}); });
setBusy(false); 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."); 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" ? "From the server's environment"
: "Not set"; : "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 ( 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"> <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 ? ( {env?.description || known?.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words"> <Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
{known?.description || env?.description} {known?.description || env?.description}
@@ -34,20 +34,34 @@ type Props = {
}; };
const Descriptions: Record<EnvScope["paradigm"], string> = { const Descriptions: Record<EnvScope["paradigm"], string> = {
general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.", general:
workspace: "For every bot working here. Beats general, loses to the bot's own.", "Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
computer: "For this computer only. Beats a workspace, loses to the bot's own.", 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.", 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. * 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); const [adding, setAdding] = useState(false);
return ( 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."> <SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
{envs.map((env) => ( {envs.map((env) => (
<EnvRow <EnvRow
@@ -0,0 +1,27 @@
import Span from "@moduletrace/twui/layout/Span";
import type { RunningVersion } from "@/src/utils/running-version";
type Props = {
running?: RunningVersion | null;
};
/**
* The release this server runs, in words: settings are where someone
* checks whether an update landed. A development server says so beside
* its version, so the two are never mistaken.
*/
export default function VersionLine({ running }: Props) {
if (!running) return null;
const version = running.version
? `Cobble ${running.version}`
: "Cobble";
return (
<Span className="text-sm leading-5 text-ink">
{running.mode == "development"
? `${version} (development)`
: version}
</Span>
);
}
@@ -0,0 +1,22 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import SettingsGroup from "../../(partials)/settings-group";
import VersionLine from "../(partials)/version-line";
/**
* What this server is running, so an update can be confirmed from the app
* instead of the installer's output.
*/
export default function VersionSection() {
const { pageProps } = useContext(AppContext);
return (
<SettingsGroup
title="Version"
description="The release this server runs, so an update can be confirmed from the app."
id="version"
>
<VersionLine running={pageProps?.running_version} />
</SettingsGroup>
);
}
@@ -1,6 +1,7 @@
import AppearanceSection from "./(sections)/appearance-section"; import AppearanceSection from "./(sections)/appearance-section";
import PromptSection from "./(sections)/prompt-section"; import PromptSection from "./(sections)/prompt-section";
import StorageSection from "./(sections)/storage-section"; import StorageSection from "./(sections)/storage-section";
import VersionSection from "./(sections)/version-section";
import SettingsShell from "../(partials)/settings-shell"; import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta"; import settingsPageMeta from "../(functions)/settings-page-meta";
@@ -13,6 +14,7 @@ export default function AdminSettingsGeneralPage() {
<AppearanceSection /> <AppearanceSection />
<PromptSection /> <PromptSection />
<StorageSection /> <StorageSection />
<VersionSection />
</SettingsShell> </SettingsShell>
); );
} }
+5
View File
@@ -19,6 +19,7 @@ import type {
} from "@/db/types/db"; } from "@/db/types/db";
import type { UserTypes } from "../dict/user-types-dict"; import type { UserTypes } from "../dict/user-types-dict";
import type { StorageVolume } from "../utils/storage-volumes"; import type { StorageVolume } from "../utils/storage-volumes";
import type { RunningVersion } from "../utils/running-version";
import type { WebsocketRooms } from "../dict/websocket-rooms"; import type { WebsocketRooms } from "../dict/websocket-rooms";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ServerQueryParam } from "@moduletrace/bun-sqlite"; import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
@@ -1038,6 +1039,10 @@ export type PagePropsType = {
setting_key: string; setting_key: string;
text: string; text: string;
} | null; } | null;
/**
* The release the server is running, for the General settings page.
*/
running_version?: RunningVersion | null;
push_overview?: PushOverview | null; push_overview?: PushOverview | null;
mcps_overview?: McpServersOverviewPayload | null; mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null; tools_overview?: CustomToolsOverviewPayload | null;
+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];
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import runningVersion from "./running-version";
describe("runningVersion", () => {
test("a release reports the build's version", () => {
expect(
runningVersion({
env: { NODE_ENV: "production", COBBLE_VERSION: "v0.1.1" },
}),
).toEqual({ version: "v0.1.1", mode: "production" });
});
test("a production build without a version falls back to the source's", () => {
expect(
runningVersion({
env: { NODE_ENV: "production" },
package_version: "0.1.1",
}),
).toEqual({ version: "0.1.1", mode: "production" });
});
test("a development server reports the source's version too", () => {
expect(
runningVersion({
env: { NODE_ENV: "development" },
package_version: "0.1.1",
}),
).toEqual({ version: "0.1.1", mode: "development" });
});
test("a development server with a build version prefers it", () => {
expect(
runningVersion({
env: { NODE_ENV: "development", COBBLE_VERSION: "v0.2.0" },
package_version: "0.1.1",
}),
).toEqual({ version: "v0.2.0", mode: "development" });
});
test("no NODE_ENV at all is a development server", () => {
expect(runningVersion({ env: {}, package_version: "0.1.1" })).toEqual({
version: "0.1.1",
mode: "development",
});
});
test("an unusable version is dropped rather than shown", () => {
expect(
runningVersion({
env: { NODE_ENV: "production", COBBLE_VERSION: "bad version!" },
package_version: "",
}),
).toEqual({ version: null, mode: "production" });
});
test("an empty build version falls back to the source's", () => {
expect(
runningVersion({
env: { NODE_ENV: "production", COBBLE_VERSION: " " },
package_version: "0.1.1",
}),
).toEqual({ version: "0.1.1", mode: "production" });
});
});
+52
View File
@@ -0,0 +1,52 @@
import package_json from "@/package.json";
/**
* What the General settings page shows as the running Cobble.
*/
export type RunningVersion = {
/**
* The release's version: the image tag the installer pulled
* (`COBBLE_VERSION`, set when the image was built), else
* `package.json`'s version, which a development server reports too.
*/
version: string | null;
/**
* Where the live server runs (`NODE_ENV`): `production`,
* `development` or the environment's own name when it set another.
*/
mode: string;
};
type Params = {
/**
* The process environment (`process.env`).
*/
env: Record<string, string | undefined>;
/**
* `package.json`'s version, for a build that wasn't given one.
*/
package_version?: string;
};
const VersionPattern = /^[A-Za-z0-9._-]{1,64}$/;
/**
* The version a running Cobble reports, development included: the image's
* build argument when it has one (the release tag), else the source's own
* version, so a dev server names the code it is working on.
*/
export default function runningVersion({
env,
package_version = package_json.version,
}: Params): RunningVersion {
const build_version = env.COBBLE_VERSION?.trim();
const version =
build_version && VersionPattern.test(build_version)
? build_version
: package_version && VersionPattern.test(package_version)
? package_version
: null;
return { version, mode: env.NODE_ENV || "development" };
}