Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d6f9b3d8 | ||
|
|
05af559adb | ||
|
|
524f0dde36 |
@@ -37,11 +37,20 @@ jobs:
|
||||
username: ${{ secrets.GITEA_USERNAME }}
|
||||
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
|
||||
id: tags
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
SOURCE_VERSION: ${{ steps.source.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="sha-${GITHUB_SHA::12}"
|
||||
@@ -59,10 +68,12 @@ jobs:
|
||||
"git.tben.me/moduletrace/cobble:${minor}" \
|
||||
"git.tben.me/moduletrace/cobble:${sha}")"
|
||||
else
|
||||
version="$SOURCE_VERSION"
|
||||
ghcr="ghcr.io/moduletrace/cobble:${sha}"
|
||||
gitea="git.tben.me/moduletrace/cobble:${sha}"
|
||||
fi
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "ghcr<<EOF"
|
||||
echo "$ghcr"
|
||||
echo "EOF"
|
||||
@@ -79,6 +90,8 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
COBBLE_VERSION=${{ steps.tags.outputs.version }}
|
||||
tags: ${{ steps.tags.outputs.ghcr }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
@@ -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`).
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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)
|
||||
- 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` (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`).
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ ENV DATA_DIR=/data
|
||||
ENV DB_DIR=/data/db
|
||||
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
|
||||
|
||||
RUN echo 'alias ll="ls -laF"' >/root/.bashrc
|
||||
|
||||
+3
-1
@@ -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
|
||||
- update to a new tag: backup taken, data intact
|
||||
- `--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:
|
||||
|
||||
git tag v0.1.0
|
||||
|
||||
+94
-17
@@ -345,23 +345,35 @@ if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
|
||||
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
|
||||
fi
|
||||
|
||||
SELINUX_SUFFIX=""
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
# SELinux hosts need the `z` mount option so docker relabels the bind
|
||||
# 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
|
||||
SELINUX_SUFFIX=",z"
|
||||
elif [ -e /sys/fs/selinux/enforce ]; then
|
||||
SELINUX_SUFFIX=",z"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
[ -e /sys/fs/selinux/enforce ]
|
||||
}
|
||||
|
||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
|
||||
DATA_MOUNT="$DATA_DIR:/data"
|
||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives"
|
||||
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"
|
||||
MOUNT_OPTS=""
|
||||
if [ "$OS" = "Linux" ] && selinux_enforcing; then
|
||||
MOUNT_OPTS="z"
|
||||
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
|
||||
|
||||
@@ -527,10 +539,7 @@ backup_database() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
say "Backing up the database"
|
||||
|
||||
local backup_mount="$data_source:/data"
|
||||
if [ -n "$SELINUX_SUFFIX" ]; then
|
||||
backup_mount="$backup_mount,z"
|
||||
fi
|
||||
local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||
|
||||
local image_id=""
|
||||
if container_running; then
|
||||
@@ -575,6 +584,71 @@ backup_database() {
|
||||
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() {
|
||||
local dir="$1"
|
||||
case "$dir" in
|
||||
@@ -789,6 +863,9 @@ if [ "$FORCE" = 0 ] && container_matches; then
|
||||
fi
|
||||
|
||||
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
|
||||
say "Replacing the Cobble container"
|
||||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||||
|
||||
+220
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.1.2",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -21,6 +21,22 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
||||
const is_signup_page = url.pathname.startsWith("/auth/signup");
|
||||
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) {
|
||||
return redirect({ destination: "/auth/signup" });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
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 runningVersion from "@/src/utils/running-version";
|
||||
|
||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||
|
||||
@@ -44,6 +45,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
||||
has_password,
|
||||
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
|
||||
running_version: runningVersion({ env: process.env }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 PromptSection from "./(sections)/prompt-section";
|
||||
import StorageSection from "./(sections)/storage-section";
|
||||
import VersionSection from "./(sections)/version-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
@@ -13,6 +14,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
<AppearanceSection />
|
||||
<PromptSection />
|
||||
<StorageSection />
|
||||
<VersionSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from "@/db/types/db";
|
||||
import type { UserTypes } from "../dict/user-types-dict";
|
||||
import type { StorageVolume } from "../utils/storage-volumes";
|
||||
import type { RunningVersion } from "../utils/running-version";
|
||||
import type { WebsocketRooms } from "../dict/websocket-rooms";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
||||
@@ -1038,6 +1039,10 @@ export type PagePropsType = {
|
||||
setting_key: string;
|
||||
text: string;
|
||||
} | null;
|
||||
/**
|
||||
* The release the server is running, for the General settings page.
|
||||
*/
|
||||
running_version?: RunningVersion | null;
|
||||
push_overview?: PushOverview | null;
|
||||
mcps_overview?: McpServersOverviewPayload | null;
|
||||
tools_overview?: CustomToolsOverviewPayload | null;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user