Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af110766d | ||
|
|
8363be933d | ||
|
|
128ca9d8b3 | ||
|
|
c398febf0f | ||
|
|
3849709cc2 |
No files matched your search
@@ -13,8 +13,16 @@ name: Sync upstream and build images
|
|||||||
# 4. Publish one Gitea release per built version on the mirrored upstream
|
# 4. Publish one Gitea release per built version on the mirrored upstream
|
||||||
# tag, titled like upstream, carrying upstream's own notes plus a short
|
# tag, titled like upstream, carrying upstream's own notes plus a short
|
||||||
# image footer. Updated in place when the image is rebuilt.
|
# image footer. Updated in place when the image is rebuilt.
|
||||||
# 5. A version that fails to build or push fails the run: a missed upstream
|
# 5. A version that fails to build or push fails the run, but only when the
|
||||||
# release must be visible, not a warning in a green run.
|
# registry has never had it: a missed upstream release must be visible,
|
||||||
|
# not a warning in a green run. A *refresh* rebuild — the registry
|
||||||
|
# already has an image for that version and is only being moved onto a
|
||||||
|
# newer pnpm/base/recipe — is best-effort: it warns, leaves the existing
|
||||||
|
# image alone, and cannot turn the run red. Old versions cannot always
|
||||||
|
# be rebuilt any more, because their floating dependency ranges now
|
||||||
|
# resolve to packages that have moved past them, and a doomed 0.1.x
|
||||||
|
# refresh must not hide the 0.2.x release that did ship. Every failure
|
||||||
|
# stays listed in the run either way.
|
||||||
#
|
#
|
||||||
# Images are pushed with regctl, in 50 MB chunks. The registry sits behind
|
# Images are pushed with regctl, in 50 MB chunks. The registry sits behind
|
||||||
# Cloudflare, which rejects any request body over 100 MB; `docker push` sends a
|
# Cloudflare, which rejects any request body over 100 MB; `docker push` sends a
|
||||||
@@ -143,23 +151,35 @@ jobs:
|
|||||||
done < <(sort -V /tmp/upstream-versions)
|
done < <(sort -V /tmp/upstream-versions)
|
||||||
echo "candidates:$CANDIDATES"
|
echo "candidates:$CANDIDATES"
|
||||||
|
|
||||||
BUILDS=""
|
# Two kinds of build, and they do not matter equally. NEW is a
|
||||||
|
# version the registry has never had — an upstream release that has
|
||||||
|
# not shipped here yet, which is the whole point of this job.
|
||||||
|
# REFRESH is an already-published version being moved onto newer
|
||||||
|
# toolchain. Plan NEW first, so a new release lands in the first
|
||||||
|
# minutes of the run instead of queueing behind refreshes that may
|
||||||
|
# all be unbuildable.
|
||||||
|
NEW=""
|
||||||
|
REFRESH=""
|
||||||
for V in $CANDIDATES; do
|
for V in $CANDIDATES; do
|
||||||
if ! tag_exists "$V"; then
|
if ! tag_exists "$V"; then
|
||||||
echo "$V: missing -> build"; BUILDS="$BUILDS $V"
|
echo "$V: missing -> build"; NEW="$NEW $V"
|
||||||
elif forced "$V"; then
|
elif forced "$V"; then
|
||||||
echo "$V: rebuild requested"; BUILDS="$BUILDS $V"
|
echo "$V: rebuild requested"; REFRESH="$REFRESH $V"
|
||||||
else
|
else
|
||||||
HAVE=$(tag_toolchain "$V")
|
HAVE=$(tag_toolchain "$V")
|
||||||
if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; BUILDS="$BUILDS $V"; fi
|
if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; REFRESH="$REFRESH $V"; fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
BUILDS="$NEW $REFRESH"
|
||||||
# Newest release candidate = what :latest points at.
|
# Newest release candidate = what :latest points at.
|
||||||
CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1)
|
CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1)
|
||||||
|
echo "new (must ship):$NEW"
|
||||||
|
echo "refresh (best-effort):$REFRESH"
|
||||||
echo "planned builds:$BUILDS"
|
echo "planned builds:$BUILDS"
|
||||||
echo "current (:latest): $CURRENT"
|
echo "current (:latest): $CURRENT"
|
||||||
{
|
{
|
||||||
echo "builds=${BUILDS}"
|
echo "builds=${BUILDS}"
|
||||||
|
echo "new=${NEW}"
|
||||||
echo "current=${CURRENT}"
|
echo "current=${CURRENT}"
|
||||||
echo "pnpm=${PNPM_LATEST}"
|
echo "pnpm=${PNPM_LATEST}"
|
||||||
echo "base_digest=${BASE_DIGEST}"
|
echo "base_digest=${BASE_DIGEST}"
|
||||||
@@ -174,7 +194,6 @@ jobs:
|
|||||||
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
|
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
|
||||||
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
|
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
|
||||||
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
|
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
|
||||||
CURRENT: ${{ steps.plan.outputs.current }}
|
|
||||||
CI_RUN: ${{ github.run_number }}
|
CI_RUN: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
@@ -215,6 +234,21 @@ jobs:
|
|||||||
DOCTOR_RC=0
|
DOCTOR_RC=0
|
||||||
docker exec "$SMOKE" dsh-workspace-doctor.mjs --check > /dev/null || DOCTOR_RC=$?
|
docker exec "$SMOKE" dsh-workspace-doctor.mjs --check > /dev/null || DOCTOR_RC=$?
|
||||||
[ "$DOCTOR_RC" -le 1 ]
|
[ "$DOCTOR_RC" -le 1 ]
|
||||||
|
# ...and the entrypoint must actually invoke it. The doctor prints a
|
||||||
|
# "dsh-workspace-doctor:" line when it runs; the entrypoint logs a
|
||||||
|
# "workspace doctor:" line only when it could not. A direct
|
||||||
|
# `docker exec` above sails past a wrong path or a lost exec bit, so
|
||||||
|
# the boot log, not the binary, is what proves the wiring.
|
||||||
|
if ! docker logs "$SMOKE" 2>&1 | grep -q "^dsh-workspace-doctor:"; then
|
||||||
|
echo "the workspace doctor did not run at boot"
|
||||||
|
docker logs "$SMOKE" | tail -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if docker logs "$SMOKE" 2>&1 | grep -q "workspace doctor:"; then
|
||||||
|
echo "the entrypoint could not run the workspace doctor"
|
||||||
|
docker logs "$SMOKE" | tail -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
OK=""
|
OK=""
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if docker exec "$SMOKE" node -e '
|
if docker exec "$SMOKE" node -e '
|
||||||
@@ -247,6 +281,7 @@ jobs:
|
|||||||
)
|
)
|
||||||
|
|
||||||
FAILED=""
|
FAILED=""
|
||||||
|
MISSING=""
|
||||||
for V in ${{ steps.plan.outputs.builds }}; do
|
for V in ${{ steps.plan.outputs.builds }}; do
|
||||||
if build_one "$V"; then
|
if build_one "$V"; then
|
||||||
echo "::notice::built ${IMAGE}:${V}"
|
echo "::notice::built ${IMAGE}:${V}"
|
||||||
@@ -254,17 +289,24 @@ jobs:
|
|||||||
docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true
|
docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true
|
||||||
echo "::warning::${V} did not build or push; any existing image for it is left untouched"
|
echo "::warning::${V} did not build or push; any existing image for it is left untouched"
|
||||||
FAILED="$FAILED $V"
|
FAILED="$FAILED $V"
|
||||||
|
# Missing from the registry AND failed to build = an upstream
|
||||||
|
# release we failed to ship. Record it for the final gate.
|
||||||
|
case " ${{ steps.plan.outputs.new }} " in *" $V "*) MISSING="$MISSING $V";; esac
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "failed=${FAILED}" >> "$GITHUB_OUTPUT"
|
echo "failed=${FAILED}" >> "$GITHUB_OUTPUT"
|
||||||
if [ -n "$FAILED" ]; then
|
echo "missing=${MISSING}" >> "$GITHUB_OUTPUT"
|
||||||
echo "failed versions:$FAILED"
|
# Deliberately no exit here. Steps 5-7 still have to run so the
|
||||||
case " $FAILED " in *" $CURRENT "*) echo "::error::the current version (${CURRENT}) failed to build"; exit 1;; esac
|
# releases for every version that did ship are in place even when
|
||||||
fi
|
# this run ends red; the single red/green decision belongs to the
|
||||||
|
# last step. Aborting here is what used to leave a new release
|
||||||
|
# unpublished.
|
||||||
|
[ -z "$FAILED" ] || echo "failed versions:$FAILED"
|
||||||
|
|
||||||
# `:latest` = the newest release-candidate version. A manifest copy
|
# `:latest` = the newest release-candidate version. A manifest copy
|
||||||
# through the registry API: identical digest, nothing rebuilt.
|
# through the registry API: identical digest, nothing rebuilt.
|
||||||
- name: Point :latest at the current version
|
- name: Point :latest at the current version
|
||||||
|
id: latest
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
OWNER: ${{ github.repository_owner }}
|
OWNER: ${{ github.repository_owner }}
|
||||||
@@ -275,7 +317,9 @@ jobs:
|
|||||||
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
|
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
|
||||||
digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; }
|
digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; }
|
||||||
WANT=$(digest_of "$CURRENT")
|
WANT=$(digest_of "$CURRENT")
|
||||||
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry"; exit 1; }
|
# Do not exit non-zero here either: that would skip both the prune
|
||||||
|
# and the release publish. Record the miss, let the gate decide.
|
||||||
|
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry, so :latest cannot be moved"; echo "latest_failed=1" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||||
HAVE=$(digest_of latest || true)
|
HAVE=$(digest_of latest || true)
|
||||||
echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}"
|
echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}"
|
||||||
[ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; }
|
[ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; }
|
||||||
@@ -334,9 +378,14 @@ jobs:
|
|||||||
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js
|
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js
|
||||||
|
|
||||||
# Last, so :latest and the releases for everything that did ship are
|
# Last, so :latest and the releases for everything that did ship are
|
||||||
# already in place when the run turns red.
|
# already in place before the run turns red — and the only thing that
|
||||||
- name: Fail the run if a version did not ship
|
# turns it red is a version this registry has never published. Refreshes
|
||||||
if: steps.build.outputs.failed != ''
|
# of versions we already ship stay warnings.
|
||||||
|
- name: Fail the run if a new version did not ship
|
||||||
|
if: steps.build.outputs.missing != '' || steps.latest.outputs.latest_failed != ''
|
||||||
run: |
|
run: |
|
||||||
echo "::error::these versions did not build or push:${{ steps.build.outputs.failed }}"
|
[ -z "${{ steps.build.outputs.missing }}" ] || \
|
||||||
|
echo "::error::never-published versions did not build or push:${{ steps.build.outputs.missing }}"
|
||||||
|
[ "${{ steps.latest.outputs.latest_failed }}" != "1" ] || \
|
||||||
|
echo "::error::${{ steps.plan.outputs.current }} has no image in the registry, so :latest was not moved"
|
||||||
exit 1
|
exit 1
|
||||||
+11
-3
@@ -6,9 +6,13 @@
|
|||||||
# image installs that exact pinned release from registry.npmjs.org, so its
|
# image installs that exact pinned release from registry.npmjs.org, so its
|
||||||
# provenance chain is: upstream tag -> upstream npm release -> this Dockerfile.
|
# provenance chain is: upstream tag -> upstream npm release -> this Dockerfile.
|
||||||
#
|
#
|
||||||
# No upstream code is patched. The all-interfaces bind goes through upstream's
|
# No upstream code is patched. The bind address comes from the `--patch`
|
||||||
# supported `--patch` launcher layer (/opt/deepseek-harness/web.cordis.patch.yml);
|
# launcher layer (/opt/deepseek-harness/web.cordis.patch.yml), which the
|
||||||
# the UI is only reachable through ports you explicitly publish on the host.
|
# entrypoint renders at startup: dsh 0.2.1-alpha.2+ rejects the unspecified
|
||||||
|
# address in the webserver schema and gets the container's own IPv4 instead,
|
||||||
|
# while releases whose schema still only accepts 127.0.0.1 or 0.0.0.0 keep
|
||||||
|
# 0.0.0.0. The UI is reachable only through ports you explicitly publish on the
|
||||||
|
# host.
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
@@ -62,11 +66,15 @@ RUN set -eux; \
|
|||||||
# installs) lives inside the mounted state volume, never the rootfs.
|
# installs) lives inside the mounted state volume, never the rootfs.
|
||||||
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
|
# PUID/PGID: the ids dsh runs as; the entrypoint aligns the state volume to
|
||||||
# them at startup. Unraid's share default is 99:100.
|
# them at startup. Unraid's share default is 99:100.
|
||||||
|
# DSH_WORKSPACE_DOCTOR: the entrypoint's workspace-grouping step. `repair` is
|
||||||
|
# the default so an update heals the projects whose directories the update
|
||||||
|
# erased; `warn` reports without repairing, `off` is silent.
|
||||||
ENV DSH_HOME=/home/node/.dsh \
|
ENV DSH_HOME=/home/node/.dsh \
|
||||||
PNPM_HOME=/home/node/.dsh/.pnpm \
|
PNPM_HOME=/home/node/.dsh/.pnpm \
|
||||||
XDG_CACHE_HOME=/home/node/.dsh/.cache \
|
XDG_CACHE_HOME=/home/node/.dsh/.cache \
|
||||||
XDG_STATE_HOME=/home/node/.dsh/.state \
|
XDG_STATE_HOME=/home/node/.dsh/.state \
|
||||||
XDG_CONFIG_HOME=/home/node/.dsh/.config \
|
XDG_CONFIG_HOME=/home/node/.dsh/.config \
|
||||||
|
DSH_WORKSPACE_DOCTOR=repair \
|
||||||
PUID=1000 \
|
PUID=1000 \
|
||||||
PGID=1000
|
PGID=1000
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,11 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
|
|||||||
- **Workspace grouping is checked before every start.** A project directory outside the
|
- **Workspace grouping is checked before every start.** A project directory outside the
|
||||||
mounted `/workspace` and state volume does not survive a container update, and dsh joins
|
mounted `/workspace` and state volume does not survive a container update, and dsh joins
|
||||||
a session to its project only at creation — so those chats collect under "Ungrouped" and
|
a session to its project only at creation — so those chats collect under "Ungrouped" and
|
||||||
stay there. The entrypoint reports every such project and every recoverable chat, and
|
stay there. Before every start the entrypoint recreates the missing project directories
|
||||||
with `DSH_WORKSPACE_DOCTOR=repair` recreates the missing directories and re-accounts the
|
and re-accounts the orphaned chats, so an update heals itself with no configuration.
|
||||||
orphaned sessions at startup. Session logs are never touched; the workspace registry is
|
Session logs are never touched; the workspace registry is backed up first.
|
||||||
backed up first. See [Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
|
`DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent. See
|
||||||
|
[Chats land in Ungrouped after an update](#chats-land-in-ungrouped-after-an-update).
|
||||||
|
|
||||||
#### Operations
|
#### Operations
|
||||||
- **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly,
|
- **Upstream mirror.** The `master` branch and the `dsh-v*` tags track GitHub exactly,
|
||||||
@@ -229,10 +230,16 @@ Your DeepSeek API key can be set below (stored in the container env) or entered
|
|||||||
<Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
|
<Config Name="PUID" Target="PUID" Default="1000" Description="User id dsh runs as, and the owner applied to the appdata volume at startup. Set to 99 to keep Unraid's usual nobody ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
|
||||||
<Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
|
<Config Name="PGID" Target="PGID" Default="1000" Description="Group id dsh runs as. Set to 100 (users) alongside PUID=99 for Unraid's usual share ownership." Type="Variable" Display="advanced" Required="false" Mask="false">1000</Config>
|
||||||
<Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
|
<Config Name="Take ownership of Workspace" Target="DSH_CHOWN_WORKSPACE" Default="0" Description="Set to 1 to recursively chown the Workspace contents to PUID:PGID at startup, so the agent can modify files that arrived owned by someone else. Off by default - it rewrites ownership across your data." Type="Variable" Display="advanced" Required="false" Mask="false">0</Config>
|
||||||
<Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. warn (default in the image) only reports; repair recreates project directories that the container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config>
|
<Config Name="Workspace Doctor" Target="DSH_WORKSPACE_DOCTOR" Default="repair" Description="Workspace grouping at startup. repair (the image default, so leaving this alone is fine) recreates project directories a container update erased and re-accounts the chats orphaned with them, so they return to their project instead of 'Ungrouped' (a backup of the workspace registry is written first, session logs are never touched); warn only reports; off is silent. Project directories outside /workspace and appdata are erased by every update - keep project data in Workspace." Type="Variable" Display="advanced" Required="false" Mask="false">repair</Config>
|
||||||
</Container>
|
</Container>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An **existing** container keeps the fields it was created with: Unraid does not add a
|
||||||
|
variable a template gained later. That is fine here — the image default is already
|
||||||
|
`repair`, so the Workspace Doctor row above only matters if you want to differ from it
|
||||||
|
(`warn` reports without repairing, `off` is silent), set by hand under Docker → Edit →
|
||||||
|
*Add another Path, Port, Variable, Label or Device* → Variable.
|
||||||
|
|
||||||
Behind a Cloudflare Tunnel, add the public hostname as a `--trusted-host` and have the
|
Behind a Cloudflare Tunnel, add the public hostname as a `--trusted-host` and have the
|
||||||
tunnel rewrite `Host` to that name (and strip `Origin`) so the fence sees a name it
|
tunnel rewrite `Host` to that name (and strip `Origin`) so the fence sees a name it
|
||||||
trusts.
|
trusts.
|
||||||
@@ -250,7 +257,7 @@ trusts.
|
|||||||
| `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. |
|
| `/home/node/.dsh` | `DSH_HOME` — settings, credentials, sessions, plugins, pnpm store/cache/config. **Mount this.** Taken over at startup. |
|
||||||
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless `DSH_CHOWN_WORKSPACE=1`. |
|
| `/workspace` | The agent's working directory (`WORKDIR`). Mount only what the agent may read and modify. Mount point made writable; contents untouched unless `DSH_CHOWN_WORKSPACE=1`. |
|
||||||
| `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` |
|
| `/opt/deepseek-harness/web.cordis.patch.yml` | The baked bind patch: `webserver.host: 0.0.0.0`, `port: 3080` |
|
||||||
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`warn` default, `repair` fixes lost grouping, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume |
|
| Env | `PUID`, `PGID`, `DSH_CHOWN_WORKSPACE`, `DSH_WORKSPACE_DOCTOR` (`repair` default — reports and repairs lost grouping at every start; `warn` reports only, `off` is silent), plus `DSH_HOME` / `PNPM_HOME` / `XDG_*` pinned into the state volume |
|
||||||
| Labels | `dev.milesward.dsh-version`, `.pnpm-version`, `.base-digest`, `.recipe-hash` — what CI's freshness check reads |
|
| Labels | `dev.milesward.dsh-version`, `.pnpm-version`, `.base-digest`, `.recipe-hash` — what CI's freshness check reads |
|
||||||
|
|
||||||
Four rules that are easy to get wrong:
|
Four rules that are easy to get wrong:
|
||||||
@@ -322,15 +329,25 @@ runs once per state volume, on the first project, and never again.) Two conseque
|
|||||||
chat into it. The old chats stay under "Ungrouped".
|
chat into it. The old chats stay under "Ungrouped".
|
||||||
|
|
||||||
Before every start the entrypoint reports this — each project with its storage, each
|
Before every start the entrypoint reports this — each project with its storage, each
|
||||||
missing directory, every chat it could recover — and with `DSH_WORKSPACE_DOCTOR=repair`
|
missing directory, every chat it could recover — and repairs it, with no configuration to
|
||||||
it fixes it at startup:
|
set. `DSH_WORKSPACE_DOCTOR=warn` reports without repairing, `=off` is silent:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists |
|
| Repairs | recreates missing workspace directories, and re-accounts chats whose recorded directory matches a project but which no project lists |
|
||||||
| Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in |
|
| Never | touches a session log, deletes a session, invents a project, or moves a chat into a project it did not run in |
|
||||||
| Before writing | copies the workspace registry to `workspace.json.bak-<timestamp>`; the write itself is atomic and in dsh's own format |
|
| Before writing | copies the workspace registry to `workspace.json.bak-<timestamp>`; the write itself is atomic and in dsh's own format |
|
||||||
| By hand | `docker exec -u 1000:1000 <container> dsh-workspace-doctor.mjs --check` (add `--all --dry-run` to preview a repair) |
|
| By hand | report with `docker exec <container> dsh-workspace-doctor.mjs --check`; repair with the one-off container below |
|
||||||
|
|
||||||
|
To repair without waiting for a container start, stop the container first so nothing races
|
||||||
|
the registry write, then run the doctor against the state volume directly (`--entrypoint`
|
||||||
|
bypasses dsh, and the image's `PUID`/`PGID` are applied to what it writes):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --entrypoint /usr/local/bin/dsh-workspace-doctor.mjs \
|
||||||
|
-v /mnt/user/appdata/deepseek-harness:/home/node/.dsh \
|
||||||
|
gitea.milesward.dev/mward4/deepseek-harness:latest --all
|
||||||
|
```
|
||||||
|
|
||||||
The recreated directory is empty: this restores the *grouping*, not the files — those lived
|
The recreated directory is empty: this restores the *grouping*, not the files — those lived
|
||||||
in the container and nothing here can bring them back. Keep project data under `/workspace`
|
in the container and nothing here can bring them back. Keep project data under `/workspace`
|
||||||
|
|||||||
Regular → Executable
+99
-14
@@ -38,6 +38,71 @@ else
|
|||||||
-*) set -- web --patch "$PATCH" --no-open "$@" ;;
|
-*) set -- web --patch "$PATCH" --no-open "$@" ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
# 0.2.1-alpha.2 replaced the webserver's old `host: "127.0.0.1" | "0.0.0.0"`
|
||||||
|
# union with a config transform that rejects the unspecified address outright —
|
||||||
|
# "host … is an unspecified (wildcard) address, which is not supported … bind
|
||||||
|
# one concrete IPv4 or IPv6 address of a local interface instead" — and a
|
||||||
|
# schema-level check cannot be opted out of from the launcher. The shipped patch
|
||||||
|
# is therefore a template: render it onto this container's own address and hand
|
||||||
|
# dsh the rendered copy, so a port published on the host still reaches the UI
|
||||||
|
# while the socket binds one concrete interface instead of every interface.
|
||||||
|
# Releases whose schema still accepts the wildcard keep 0.0.0.0, so old images
|
||||||
|
# rebuild exactly as before. DSH_WEB_HOST overrides the address.
|
||||||
|
if [ -r "$PATCH" ]; then
|
||||||
|
BIND_HOST="${DSH_WEB_HOST:-}"
|
||||||
|
if [ -z "$BIND_HOST" ]; then
|
||||||
|
# Detect the installed webserver schema rather than guessing from a
|
||||||
|
# version string.
|
||||||
|
dsh_root="${DSH_INSTALL_ROOT:-/usr/local/lib/node_modules/@deepseek-ai/dsh}"
|
||||||
|
ws="$dsh_root/node_modules/@deepseek-ai/dsh-host-webserver/lib/index.js"
|
||||||
|
if [ -f "$ws" ] && grep -qs 'unspecified (wildcard) address' "$ws"; then
|
||||||
|
wildcard_rejected=1
|
||||||
|
elif [ ! -f "$ws" ] && grep -rqs 'unspecified (wildcard) address' "$dsh_root" 2>/dev/null; then
|
||||||
|
wildcard_rejected=1
|
||||||
|
else
|
||||||
|
wildcard_rejected=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$wildcard_rejected" = 1 ]; then
|
||||||
|
# Same discovery dsh's own boot smoke test uses: first non-internal IPv4.
|
||||||
|
BIND_HOST="$(node -e 'const os=require("os");const n=Object.values(os.networkInterfaces()).flat().find(i=>i&&i.family==="IPv4"&&!i.internal);process.stdout.write(n?n.address:"")' 2>/dev/null || true)"
|
||||||
|
[ -n "$BIND_HOST" ] || BIND_HOST=127.0.0.1
|
||||||
|
else
|
||||||
|
BIND_HOST=0.0.0.0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
render_patch() {
|
||||||
|
sed -e "s|__DSH_BIND_HOST__|${BIND_HOST}|g" \
|
||||||
|
-e "s|^\([[:space:]]*host:[[:space:]]*\)0\.0\.0\.0[[:space:]]*$|\1${BIND_HOST}|" "$PATCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$STATE" 2>/dev/null || true
|
||||||
|
RUNTIME_PATCH="$STATE/web.cordis.patch.yml"
|
||||||
|
if ! render_patch > "$RUNTIME_PATCH" 2>/dev/null; then
|
||||||
|
RUNTIME_PATCH=/tmp/web.cordis.patch.yml
|
||||||
|
render_patch > "$RUNTIME_PATCH"
|
||||||
|
fi
|
||||||
|
chmod 0644 "$RUNTIME_PATCH" 2>/dev/null || true
|
||||||
|
log "webserver binds ${BIND_HOST}; runtime patch: ${RUNTIME_PATCH}"
|
||||||
|
|
||||||
|
# Point every --patch argument that names the shipped template at the
|
||||||
|
# rendered copy. Rebuilt with a rotating `set --`, so quoting survives.
|
||||||
|
expect=0
|
||||||
|
left=$#
|
||||||
|
while [ "$left" -gt 0 ]; do
|
||||||
|
arg="$1"
|
||||||
|
shift
|
||||||
|
if [ "$expect" = 1 ]; then
|
||||||
|
if [ "$arg" = "$PATCH" ]; then arg="$RUNTIME_PATCH"; fi
|
||||||
|
expect=0
|
||||||
|
elif [ "$arg" = "--patch" ]; then
|
||||||
|
expect=1
|
||||||
|
fi
|
||||||
|
set -- "$@" "$arg"
|
||||||
|
left=$((left - 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops
|
# True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops
|
||||||
# at the first hit, so the common "already correct" case costs one stat.
|
# at the first hit, so the common "already correct" case costs one stat.
|
||||||
@@ -133,21 +198,38 @@ if [ "$(id -u)" = 0 ]; then
|
|||||||
# session is created, against the project directory's canonical path — and a
|
# session is created, against the project directory's canonical path — and a
|
||||||
# directory outside the mounted /workspace and state volume does not survive
|
# directory outside the mounted /workspace and state volume does not survive
|
||||||
# a container update. Those chats then collect under "Ungrouped" for good,
|
# a container update. Those chats then collect under "Ungrouped" for good,
|
||||||
# because the one-time history bootstrap never runs again. Report it always;
|
# because the one-time history bootstrap never runs again. The default is to
|
||||||
# recreate the directories and re-account the orphaned sessions when asked
|
# repair: recreate the directories and re-account the orphaned sessions
|
||||||
# (backup first, session logs never touched). See README:
|
# (backup first, session logs never touched), so a container update heals
|
||||||
# "Chats land in Ungrouped after an update".
|
# itself with no configuration. DSH_WORKSPACE_DOCTOR=warn only reports,
|
||||||
case "${DSH_WORKSPACE_DOCTOR:-warn}" in
|
# =off is silent. See README: "Chats land in Ungrouped after an update".
|
||||||
|
#
|
||||||
|
# Resolve it by name: the installed file is dsh-workspace-doctor.mjs, and a
|
||||||
|
# path spelled out here is exactly how a "not found" reaches production. The
|
||||||
|
# CI smoke test asserts this step's output appears in the boot log, so a
|
||||||
|
# rename or a wrong spelling fails the build instead of the container start.
|
||||||
|
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
|
||||||
|
case "${DSH_WORKSPACE_DOCTOR:-repair}" in
|
||||||
0|off|no) ;;
|
0|off|no) ;;
|
||||||
repair|all|1|yes)
|
warn|check|report)
|
||||||
/usr/local/bin/dsh-workspace-doctor --all \
|
if [ -n "$DOCTOR" ]; then
|
||||||
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
|
"$DOCTOR" --check \
|
||||||
# The doctor runs as root here; hand what it wrote back to the runtime user.
|
|| log "workspace doctor: see the report above; DSH_WORKSPACE_DOCTOR defaults to repair, so this start was told not to fix it"
|
||||||
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
|
else
|
||||||
|
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
|
||||||
|
log " workspace grouping was NOT checked. Update the image."
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
/usr/local/bin/dsh-workspace-doctor --check \
|
if [ -n "$DOCTOR" ]; then
|
||||||
|| log "workspace doctor: see the report above - set DSH_WORKSPACE_DOCTOR=repair to fix it at startup"
|
"$DOCTOR" --all \
|
||||||
|
|| log "workspace doctor: repair did not finish; starting dsh with the registry unchanged"
|
||||||
|
# The doctor runs as root here; hand what it wrote back to the runtime user.
|
||||||
|
chown "$PUID:$PGID" "$STATE/storages/workspace.json" "$STATE/storages/"workspace.json.bak-* 2>/dev/null || true
|
||||||
|
else
|
||||||
|
log "workspace doctor: dsh-workspace-doctor.mjs is not installed in this image;"
|
||||||
|
log " workspace grouping was NOT repaired. Update the image."
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -163,6 +245,9 @@ if [ ! -w "$STATE" ]; then
|
|||||||
log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>"
|
log " chown -R $(id -u):$(id -g) <host path mounted at $STATE>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
/usr/local/bin/dsh-workspace-doctor --check \
|
DOCTOR="$(command -v dsh-workspace-doctor.mjs 2>/dev/null || true)"
|
||||||
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
|
if [ -n "$DOCTOR" ]; then
|
||||||
|
"$DOCTOR" --check \
|
||||||
|
|| log "workspace doctor: see the report above (running unprivileged, so it only reports)"
|
||||||
|
fi
|
||||||
exec dsh "$@"
|
exec dsh "$@"
|
||||||
+14
-6
@@ -1,10 +1,18 @@
|
|||||||
# Launcher patch layer for containerized `dsh web` (passed via --patch).
|
# Launcher patch layer for containerized `dsh web` (passed via --patch).
|
||||||
# Upstream's CLI refuses `--host 0.0.0.0` (deliberate friction: the web UI can
|
#
|
||||||
# execute code), but the webserver plugin's schema supports exactly
|
# The host is a placeholder, not a value. From 0.2.1-alpha.2 the webserver
|
||||||
# '127.0.0.1' | '0.0.0.0'; this file is the supported opt-in. Inside a
|
# schema rejects the unspecified address outright ("host … is an unspecified
|
||||||
# container an all-interfaces bind is only reachable through ports you
|
# (wildcard) address, which is not supported … bind one concrete IPv4 or IPv6
|
||||||
# explicitly publish on the Docker host.
|
# address of a local interface instead") and that check is a config transform,
|
||||||
|
# so no launcher flag can opt out of it; a static 0.0.0.0 — which the earlier
|
||||||
|
# `"127.0.0.1" | "0.0.0.0"` schema accepted — now fails the boot smoke test.
|
||||||
|
#
|
||||||
|
# The image entrypoint renders this file at startup: onto the container's own
|
||||||
|
# IPv4 address when the installed schema rejects the wildcard, otherwise back
|
||||||
|
# to 0.0.0.0 so older images build unchanged. A port published on the Docker
|
||||||
|
# host still reaches the UI either way, because the published port is forwarded
|
||||||
|
# to exactly that address. Set DSH_WEB_HOST to pin a different address.
|
||||||
- id: webserver
|
- id: webserver
|
||||||
config:
|
config:
|
||||||
host: 0.0.0.0
|
host: __DSH_BIND_HOST__
|
||||||
port: 3080
|
port: 3080
|
||||||
Reference in new issue
Block a user