dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
function render(overrides = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
DSH_DEFAULT_WORKSPACE: '/workspace',
|
||||
DSH_IMAGE_TAG: 'latest',
|
||||
DSH_LISTEN_ADDRESS: '0.0.0.0',
|
||||
DSH_PORT: '3080',
|
||||
DSH_PROXY_POLYFILL: 'true',
|
||||
DSH_TELEMETRY_DISABLED: 'true',
|
||||
DSH_TRUSTED_HOSTS: '',
|
||||
DSH_WORKSPACE: './workspace',
|
||||
...overrides,
|
||||
}
|
||||
if (!Object.hasOwn(overrides, 'DSH_SUDO_ENABLED')) delete env.DSH_SUDO_ENABLED
|
||||
|
||||
const result = spawnSync(
|
||||
'docker',
|
||||
['compose', '--env-file', '/dev/null', '-f', 'docker-compose.yaml', 'config', '--format', 'json'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
},
|
||||
)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
return JSON.parse(result.stdout).services['deepseek-harness']
|
||||
}
|
||||
|
||||
const defaults = render()
|
||||
assert.equal(defaults.image, 'szcq/deepseek-harness:latest')
|
||||
assert.equal(defaults.container_name, 'deepseek-harness')
|
||||
assert.equal(defaults.ports[0].host_ip, '0.0.0.0')
|
||||
assert.equal(defaults.ports[0].published, '3080')
|
||||
assert.equal(Object.hasOwn(defaults.environment, 'DSH_PROXY_USERNAME'), false)
|
||||
assert.equal(Object.hasOwn(defaults.environment, 'DSH_PROXY_PASSWORD'), false)
|
||||
assert.equal(Object.hasOwn(defaults.environment, 'DSH_PLATFORM_PASSWORD'), false)
|
||||
assert.equal(defaults.environment.DSH_PROXY_POLYFILL, 'true')
|
||||
assert.equal(defaults.environment.DSH_TRUSTED_HOSTS, '')
|
||||
assert.equal(Object.hasOwn(defaults.environment, 'DSH_UPDATE_METADATA_URL'), false)
|
||||
assert.deepEqual(defaults.group_add, ['dsh-sudo-false'])
|
||||
assert.equal(defaults.environment.DSH_EXPERIMENTAL_PROBATION_SECONDS, '120')
|
||||
assert.equal(defaults.environment.DSH_LOG_MAX_BYTES, '104857600')
|
||||
assert.equal(defaults.environment.DSH_PLATFORM_DATA, '/data/platform')
|
||||
assert.equal(defaults.environment.DSH_HOME, '/data/dsh')
|
||||
assert.deepEqual(defaults.volumes.map(volume => volume.target).sort(), ['/data/dsh', '/data/platform', '/workspace'])
|
||||
assert.equal(defaults.volumes.find(volume => volume.target === '/data/dsh').type, 'volume')
|
||||
assert.equal(defaults.volumes.find(volume => volume.target === '/data/platform').type, 'volume')
|
||||
assert.equal(defaults.volumes.find(volume => volume.target === '/workspace').source, resolve('./workspace'))
|
||||
|
||||
const configured = render({
|
||||
DSH_IMAGE_TAG: '1.0.0',
|
||||
DSH_LISTEN_ADDRESS: '0.0.0.0',
|
||||
DSH_PORT: '4080',
|
||||
DSH_PROXY_POLYFILL: 'false',
|
||||
DSH_SUDO_ENABLED: 'true',
|
||||
DSH_TELEMETRY_DISABLED: 'false',
|
||||
DSH_TRUSTED_HOSTS: '192.168.1.10,dsh.example:8443',
|
||||
})
|
||||
assert.equal(configured.image, 'szcq/deepseek-harness:1.0.0')
|
||||
assert.equal(configured.ports[0].host_ip, '0.0.0.0')
|
||||
assert.equal(configured.ports[0].published, '4080')
|
||||
assert.equal(Object.hasOwn(configured.environment, 'DSH_PROXY_USERNAME'), false)
|
||||
assert.equal(Object.hasOwn(configured.environment, 'DSH_PROXY_PASSWORD'), false)
|
||||
assert.equal(Object.hasOwn(configured.environment, 'DSH_PLATFORM_PASSWORD'), false)
|
||||
assert.equal(configured.environment.DSH_PROXY_POLYFILL, 'false')
|
||||
assert.equal(configured.environment.DSH_TELEMETRY_DISABLED, 'false')
|
||||
assert.equal(configured.environment.DSH_TRUSTED_HOSTS, '192.168.1.10,dsh.example:8443')
|
||||
assert.deepEqual(configured.group_add, ['dsh-sudo-true'])
|
||||
|
||||
console.log('Compose configuration checks passed')
|
||||
Executable
+936
@@ -0,0 +1,936 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -gt 1 ]; then
|
||||
echo "usage: $0 [image]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
image="${1:-dsh-docker:smoke}"
|
||||
container="dsh-gateway-smoke-$$"
|
||||
platform_volume="dsh-platform-smoke-$$"
|
||||
home_volume="dsh-home-smoke-$$"
|
||||
readiness_timeout_ms=30000
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
docker volume rm "$platform_volume" "$home_volume" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
wait_platform_ready() {
|
||||
started="$(date +%s%3N)"
|
||||
while ! docker exec "$container" sh -c '
|
||||
curl --fail --silent http://127.0.0.1:3080/_dsh_gateway/health >/dev/null \
|
||||
&& test "$(curl --silent --output /dev/null --write-out "%{http_code}" --noproxy "*" http://127.0.0.1:3079/)" -ge 200 \
|
||||
&& test "$(curl --silent --output /dev/null --write-out "%{http_code}" --noproxy "*" http://127.0.0.1:3079/)" -lt 500 \
|
||||
&& dsh-platform status | jq -e '\''.dshLifecycle.state == "running"'\'' >/dev/null
|
||||
' >/dev/null 2>&1; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "platform readiness exceeded 30 seconds" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
now="$(date +%s%3N)"
|
||||
echo "$((now - started))"
|
||||
}
|
||||
|
||||
wait_control_ready() {
|
||||
started="$(date +%s%3N)"
|
||||
while ! docker exec "$container" curl --fail --silent \
|
||||
--header 'Host: smoke.example' --header 'Accept: text/html' \
|
||||
http://127.0.0.1:3080/_dsh_platform/auth/ >/dev/null 2>&1; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "control-plane readiness exceeded 30 seconds" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
establish_sessions() {
|
||||
action="$1"
|
||||
expected=200
|
||||
[ "$action" = login ] || expected=201
|
||||
marker='autocomplete="current-password"'
|
||||
[ "$action" = login ] || marker='autocomplete="new-password"'
|
||||
session_cookie=/tmp/dsh-smoke.cookies
|
||||
auth_page=/tmp/dsh-smoke-auth.html
|
||||
started="$(date +%s%3N)"
|
||||
while ! docker exec "$container" sh -c '
|
||||
curl --fail --silent --header "Host: smoke.example" --header "Accept: text/html" \
|
||||
http://127.0.0.1:3080/_dsh_platform/auth/ > "$1" \
|
||||
&& grep -F "$2" "$1" >/dev/null
|
||||
' _ "$auth_page" "$marker"; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker exec "$container" cat "$auth_page" >&2 || true
|
||||
docker logs "$container" >&2
|
||||
echo "authentication state did not become $action-ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" rm -f "$session_cookie"
|
||||
docker exec "$container" curl --fail --silent \
|
||||
--cookie-jar "$session_cookie" --header 'Host: smoke.example' --header 'Accept: text/html' \
|
||||
http://127.0.0.1:3080/_dsh_platform/auth/ >/dev/null
|
||||
auth_csrf="$(docker exec "$container" awk '$6 == "dsh_auth_csrf" { print $7 }' "$session_cookie")"
|
||||
[ -n "$auth_csrf" ]
|
||||
authentication_context="$(docker exec "$container" sed -n 's/.*authenticationContextId=\("[^"]*"\).*/\1/p' "$auth_page" | sed 's/^"//; s/"$//')"
|
||||
[ -n "$authentication_context" ]
|
||||
authentication_body="$(jq -nc --arg username smoke-admin --arg password smoke-password --arg authenticationContext "$authentication_context" '{username:$username,password:$password,authenticationContext:$authenticationContext}')"
|
||||
auth_response=/tmp/dsh-smoke-auth-response.json
|
||||
status="$(docker exec "$container" curl --silent --output "$auth_response" --write-out '%{http_code}' \
|
||||
--cookie "$session_cookie" --cookie-jar "$session_cookie" \
|
||||
--header 'Host: smoke.example' --header 'Origin: http://smoke.example' \
|
||||
--header 'Content-Type: application/json' --header "X-DSH-CSRF: $auth_csrf" \
|
||||
--data "$authentication_body" \
|
||||
http://127.0.0.1:3080/_dsh_platform/auth/session)"
|
||||
if [ "$status" != "$expected" ]; then
|
||||
docker exec "$container" cat "$auth_response" >&2 || true
|
||||
echo "DSH authentication returned HTTP $status, expected $expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
status="$(docker exec "$container" curl --fail --silent --location \
|
||||
--output /dev/null --write-out '%{http_code}' \
|
||||
--cookie "$session_cookie" --cookie-jar "$session_cookie" \
|
||||
--header 'Host: smoke.example' --header 'Accept: text/html' \
|
||||
http://127.0.0.1:3080/_dsh_platform/auth/management/start)"
|
||||
[ "$status" = 200 ]
|
||||
management_csrf="$(docker exec "$container" awk '$6 == "dsh_management_csrf" { print $7 }' "$session_cookie")"
|
||||
[ -n "$management_csrf" ]
|
||||
}
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
docker build --tag "$image" .
|
||||
fi
|
||||
|
||||
docker run --detach --name "$container" \
|
||||
--group-add dsh-sudo-false \
|
||||
--env DSH_TRUSTED_HOSTS=smoke.example \
|
||||
--volume "$platform_volume:/data/platform" \
|
||||
--volume "$home_volume:/data/dsh" \
|
||||
"$image" >/dev/null
|
||||
|
||||
wait_control_ready
|
||||
establish_sessions initialize
|
||||
startup_one="$(wait_platform_ready)"
|
||||
|
||||
if docker exec --user node "$container" sudo -n true >/dev/null 2>&1; then
|
||||
echo "DSH node identity unexpectedly retained sudo while DSH_SUDO_ENABLED=false" >&2
|
||||
exit 1
|
||||
fi
|
||||
status="$(docker exec --user node "$container" curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--unix-socket /run/dsh-platform/maintenance.sock \
|
||||
http://localhost/_dsh_platform/api/v1/files/config)"
|
||||
[ "$status" = 401 ]
|
||||
attempt=0
|
||||
until docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"bootstrap".*"stream":"platform".*"message":"platform.ready"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 50 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"stage0".*"stream":"platform".*"message":"stage0.ready"' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' 'http://127.0.0.1:3080/_dsh_platform/api/v1/logs?limit=5000' \
|
||||
| jq -e 'any(.entries[]; .source == "stage0" and .message == "stage0.ready")
|
||||
and any(.entries[]; .source == "bootstrap" and .message == "platform.ready")
|
||||
and any(.entries[]; .source == "gateway" and .message == "gateway.ready")' >/dev/null
|
||||
|
||||
docker exec "$container" sh -c '
|
||||
set -eu
|
||||
command -v g++ >/dev/null
|
||||
command -v make >/dev/null
|
||||
command -v python3 >/dev/null
|
||||
venv="$(mktemp -d)/venv"
|
||||
python3 -m venv "$venv"
|
||||
"$venv/bin/python" -c "import sys; assert sys.prefix != sys.base_prefix"
|
||||
"$venv/bin/pip" --version >/dev/null
|
||||
'
|
||||
|
||||
sh "$(dirname "$0")/profile-package-compatibility-smoke.sh" "$container"
|
||||
|
||||
status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--header 'Host: evil.example' http://127.0.0.1:3080/)"
|
||||
[ "$status" = 403 ]
|
||||
|
||||
status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--header 'Accept: text/html' --header 'Host: smoke.example' http://127.0.0.1:3080/)"
|
||||
[ "$status" = 303 ]
|
||||
|
||||
status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/)"
|
||||
[ "$status" = 401 ]
|
||||
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/ >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/console/ \
|
||||
| grep -F 'DSH Management Console' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e '.updateChannel == "stable"
|
||||
and .automaticCheck == {"enabled":true,"intervalSeconds":21600,"notificationsEnabled":true}
|
||||
and .latestAutomatic == {"stable":null,"upstream":null}' >/dev/null
|
||||
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins \
|
||||
| jq -e '.plugins == [{
|
||||
"id":"platform-management",
|
||||
"artifactId":"system-plugin-platform-management",
|
||||
"sha256":.plugins[0].sha256,
|
||||
"description":{"zh":"管理 DSH Docker 更新、运行维护、系统资源与登录会话。","en":"Manage DSH Docker updates, runtime maintenance, system resources, and login sessions."},
|
||||
"installed":true,
|
||||
"enabled":true,
|
||||
"activeInstalled":true,
|
||||
"activeEnabled":true,
|
||||
"pendingRestart":false,
|
||||
"protected":true,
|
||||
"reason":null
|
||||
},{
|
||||
"id":"settings-navigation",
|
||||
"artifactId":"system-plugin-settings-navigation",
|
||||
"sha256":.plugins[1].sha256,
|
||||
"description":{"zh":"优化 DSH 设置目录滚动和窄屏分级导航。","en":"Improve DSH Settings directory scrolling and compact navigation."},
|
||||
"installed":true,
|
||||
"enabled":true,
|
||||
"activeInstalled":true,
|
||||
"activeEnabled":true,
|
||||
"pendingRestart":false,
|
||||
"protected":false,
|
||||
"reason":null
|
||||
},{
|
||||
"id":"settings-document-editor",
|
||||
"artifactId":"system-plugin-settings-document-editor",
|
||||
"sha256":.plugins[2].sha256,
|
||||
"description":{"zh":"在浏览器中查看和编辑 DSH 配置文件。","en":"View and edit the DSH settings document in the browser."},
|
||||
"installed":true,
|
||||
"enabled":true,
|
||||
"activeInstalled":true,
|
||||
"activeEnabled":true,
|
||||
"pendingRestart":false,
|
||||
"protected":false,
|
||||
"reason":null
|
||||
}]' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/system-skills \
|
||||
| jq -e '.skills == [{
|
||||
"id":"dsh-docker-operations",
|
||||
"sha256":.skills[0].sha256,
|
||||
"description":{"zh":"在 DSH Docker 环境中使用受支持的命令完成开发、插件、维护、更新、恢复与诊断。","en":"Use supported commands for development, plugins, maintenance, updates, recovery, and diagnostics in the DSH Docker Environment."},
|
||||
"installed":true,
|
||||
"enabled":true
|
||||
}]' >/dev/null
|
||||
skill_view="/run/dsh-platform/views/skills/dsh-docker-operations"
|
||||
docker exec "$container" sh -c "test -L '$skill_view' && test -f '$skill_view/SKILL.md'"
|
||||
docker exec -i --user node \
|
||||
--env DSH_HOME=/data/dsh \
|
||||
--env DSH_DEFAULT_WORKSPACE=/workspace \
|
||||
--env DSH_BUNDLED_SKILL_DIR=/run/dsh-platform/views/skills \
|
||||
"$container" /usr/local/bin/node --input-type=module \
|
||||
< "$(dirname "$0")/system-skill-discovery-smoke.mjs" \
|
||||
| jq -e '.name == "dsh-docker-operations" and .source == "bundled" and .provider == "filesystem" and .contentBytes > 500' >/dev/null
|
||||
skill_dsh_pid="$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )"
|
||||
skill_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"skillId":"dsh-docker-operations","action":"disable"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/system-skills/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$skill_task" '.systemSkillOperation.taskId == $task and .systemSkillOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
if docker exec "$container" test -e "$skill_view"; then
|
||||
echo "disabled System Skill remained in the active view" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$skill_dsh_pid" = "$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )" ]
|
||||
skill_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"skillId":"dsh-docker-operations","action":"enable"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/system-skills/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$skill_task" '.systemSkillOperation.taskId == $task and .systemSkillOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
docker exec "$container" sh -c "test -L '$skill_view' && test -f '$skill_view/SKILL.md'"
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"system-skill.disable.completed"' >/dev/null
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"system-skill.enable.completed"' >/dev/null
|
||||
docker exec -i --user node \
|
||||
--env DSH_HOME=/data/dsh \
|
||||
--env DSH_DEFAULT_WORKSPACE=/workspace \
|
||||
--env DSH_BUNDLED_SKILL_DIR=/run/dsh-platform/views/skills \
|
||||
"$container" /usr/local/bin/node --input-type=module \
|
||||
< "$(dirname "$0")/user-skill-watcher-smoke.mjs" \
|
||||
| jq -e '. == {"discovered":true,"disabled":true,"enabled":true,"deleted":true}' >/dev/null
|
||||
docker exec --user node "$container" sh -c '
|
||||
set -eu
|
||||
skill=/data/dsh/skills/smoke-managed-user-skill
|
||||
mkdir -p "$skill"
|
||||
cat > "$skill/SKILL.md" <<"EOF"
|
||||
---
|
||||
name: smoke-managed-user-skill
|
||||
description: Smoke-test Management user skill actions.
|
||||
---
|
||||
|
||||
# Smoke Managed User Skill
|
||||
EOF
|
||||
'
|
||||
user_skill_inventory="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills)"
|
||||
user_skill_id="$(echo "$user_skill_inventory" | jq -er '.skills[] | select(.name == "smoke-managed-user-skill") | .entryId')"
|
||||
user_skill_revision="$(echo "$user_skill_inventory" | jq -er .revision)"
|
||||
user_skill_dsh_pid="$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )"
|
||||
user_skill_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg entryId "$user_skill_id" --arg revision "$user_skill_revision" \
|
||||
'{entryId:$entryId,revision:$revision,action:"disable"}')" \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$user_skill_task" '.userSkillOperation.taskId == $task and .userSkillOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
docker exec "$container" test -f /data/dsh/skills/.disabled/smoke-managed-user-skill/SKILL.md
|
||||
[ "$user_skill_dsh_pid" = "$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )" ]
|
||||
user_skill_inventory="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills)"
|
||||
user_skill_id="$(echo "$user_skill_inventory" | jq -er '.skills[] | select(.name == "smoke-managed-user-skill") | .entryId')"
|
||||
user_skill_revision="$(echo "$user_skill_inventory" | jq -er .revision)"
|
||||
user_skill_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg entryId "$user_skill_id" --arg revision "$user_skill_revision" \
|
||||
'{entryId:$entryId,revision:$revision,action:"enable"}')" \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$user_skill_task" '.userSkillOperation.taskId == $task and .userSkillOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
docker exec "$container" test -f /data/dsh/skills/smoke-managed-user-skill/SKILL.md
|
||||
[ "$user_skill_dsh_pid" = "$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )" ]
|
||||
user_skill_inventory="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills)"
|
||||
user_skill_id="$(echo "$user_skill_inventory" | jq -er '.skills[] | select(.name == "smoke-managed-user-skill") | .entryId')"
|
||||
user_skill_revision="$(echo "$user_skill_inventory" | jq -er .revision)"
|
||||
user_skill_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg entryId "$user_skill_id" --arg revision "$user_skill_revision" \
|
||||
'{entryId:$entryId,revision:$revision,action:"delete"}')" \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/user-skills/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$user_skill_task" '.userSkillOperation.taskId == $task and .userSkillOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
if docker exec "$container" test -e /data/dsh/skills/smoke-managed-user-skill; then
|
||||
echo "deleted User Skill remained in the active user root" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$user_skill_dsh_pid" = "$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ' )" ]
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"user-skill.disable.completed"' >/dev/null
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"user-skill.enable.completed"' >/dev/null
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"user-skill.delete.completed"' >/dev/null
|
||||
settings_document="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/settings-document)"
|
||||
[ "$(echo "$settings_document" | jq -r .exists)" = false ]
|
||||
settings_revision="$(echo "$settings_document" | jq -r .revision)"
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' --request PUT \
|
||||
--data "$(jq -nc --arg revision "$settings_revision" '{content:"{}\n",revision:$revision}')" \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/settings-document \
|
||||
| jq -e '.content == "{}\n" and .exists == true' >/dev/null
|
||||
docker exec "$container" sh -c '
|
||||
[ "$(cat /data/dsh/settings.yaml)" = "{}" ]
|
||||
[ "$(stat -c %U /data/dsh/settings.yaml)" = node ]
|
||||
grep -F "@dsh-docker/settings-navigation" /run/dsh-platform/views/system-plugins/cordis.patch.yml >/dev/null
|
||||
test -s /run/dsh-platform/views/system-plugins/packages/settings-navigation/lib/client.bundle.js
|
||||
grep -F "data-dsh-settings-navigation-role" \
|
||||
/run/dsh-platform/views/system-plugins/packages/settings-navigation/lib/client.bundle.js >/dev/null
|
||||
grep -F "@dsh-docker/settings-document-editor" /run/dsh-platform/views/system-plugins/cordis.patch.yml >/dev/null
|
||||
'
|
||||
dsh_pid_before_plugin_changes="$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')"
|
||||
plugin_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"id":"platform-management","action":"uninstall"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$plugin_task" \
|
||||
'.systemPluginOperation.taskId == $task
|
||||
and .systemPluginOperation.status == "failed"
|
||||
and (.systemPluginOperation.error | contains("managed by the platform"))' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 50 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins \
|
||||
| jq -e '.plugins[0] | .installed and .enabled and .protected' >/dev/null
|
||||
|
||||
recovery_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"id":"platform-management","action":"uninstall"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/recovery-action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$recovery_task" \
|
||||
'.systemPluginOperation.taskId == $task and .systemPluginOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/console/ \
|
||||
| grep -F 'Standalone console' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins \
|
||||
| jq -e '.plugins[0] | (.installed | not) and (.enabled | not) and .protected' >/dev/null
|
||||
|
||||
recovery_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"id":"platform-management","action":"install"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/recovery-action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$recovery_task" \
|
||||
'.systemPluginOperation.taskId == $task and .systemPluginOperation.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins \
|
||||
| jq -e '.plugins[0] | .installed and .enabled and .protected' >/dev/null
|
||||
plugin_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"id":"settings-document-editor","action":"disable"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$plugin_task" \
|
||||
'.systemPluginOperation.taskId == $task and .systemPluginOperation.status == "success"
|
||||
and .systemPluginOperation.restartRequired == true' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins \
|
||||
| jq -e '.plugins[] | select(.id == "settings-document-editor") | .installed and (.enabled | not)' >/dev/null
|
||||
docker exec "$container" grep -F '@dsh-docker/settings-document-editor' \
|
||||
/run/dsh-platform/views/system-plugins/cordis.patch.yml >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e '.systemPluginOperation.restartRequired == true' >/dev/null
|
||||
dsh_pid_before="$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')"
|
||||
[ "$dsh_pid_before_plugin_changes" = "$dsh_pid_before" ]
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --request POST \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/discard \
|
||||
| jq -e '.plugins[] | select(.id == "settings-document-editor") | .installed and .enabled and (.pendingRestart | not)' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e '.systemPluginOperation.restartRequired == false' >/dev/null
|
||||
[ "$dsh_pid_before" = "$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')" ]
|
||||
plugin_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--data '{"id":"settings-document-editor","action":"disable"}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/bundled-plugins/action | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$plugin_task" \
|
||||
'.systemPluginOperation.taskId == $task and .systemPluginOperation.status == "success"
|
||||
and .systemPluginOperation.restartRequired == true' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
restart_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --request POST \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/restart-dsh | jq -r .taskId)"
|
||||
started="$(date +%s%3N)"
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$restart_task" \
|
||||
'.dshLifecycle.taskId == $task and .dshLifecycle.state == "running"
|
||||
and .systemPluginOperation.restartRequired == false' >/dev/null; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "DSH plugin-change restart did not complete within the readiness timeout" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
dsh_pid_after="$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')"
|
||||
[ "$dsh_pid_before" != "$dsh_pid_after" ]
|
||||
if docker exec "$container" grep -F '@dsh-docker/settings-document-editor' \
|
||||
/run/dsh-platform/views/system-plugins/cordis.patch.yml >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/ >/dev/null
|
||||
docker exec "$container" sh -c "
|
||||
grep -F '\"message\":\"component.stopping\"' /data/platform/logs/dsh-runtime.jsonl >/dev/null
|
||||
grep -F '\"message\":\"component.ready\"' /data/platform/logs/dsh-runtime.jsonl >/dev/null
|
||||
grep -F '\"message\":\"system-plugin.changes.discarded\"' /data/platform/logs/audit.jsonl >/dev/null
|
||||
"
|
||||
|
||||
bootstrap_pid="$(docker exec "$container" pgrep -f '/platform/bootstrap/index.mjs')"
|
||||
dsh_pid="$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')"
|
||||
docker exec "$container" kill -9 "$dsh_pid"
|
||||
started="$(date +%s%3N)"
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e '.dshLifecycle.state == "running" and .recoveryMode == null' >/dev/null \
|
||||
&& [ "$(docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web')" != "$dsh_pid" ]; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "DSH automatic recovery did not complete within the readiness timeout" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$(docker exec "$container" pgrep -f '/platform/bootstrap/index.mjs')" = "$bootstrap_pid" ]
|
||||
[ "$(docker inspect --format '{{.RestartCount}}' "$container")" = 0 ]
|
||||
|
||||
stop_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --request POST \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/stop-dsh | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$stop_task" '.dshLifecycle.taskId == $task and .dshLifecycle.state == "stopped"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 50 ] || exit 1
|
||||
sleep 0.1
|
||||
done
|
||||
sleep 1
|
||||
if docker exec "$container" pgrep -f '^node /run/dsh-platform/views/runtime/bin/dsh web' >/dev/null; then
|
||||
echo "DSH recovered after an explicit stop" >&2
|
||||
exit 1
|
||||
fi
|
||||
wait_status="$(docker exec "$container" curl --silent --show-error --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' \
|
||||
--output /tmp/dsh-smoke-wait.html --write-out '%{http_code}' \
|
||||
'http://127.0.0.1:3080/_dsh_gateway/wait?return=%2Fsessions%2Fcurrent')"
|
||||
[ "$wait_status" = 200 ]
|
||||
if ! docker exec "$container" grep -E 'DeepSeek Harness is stopped|DeepSeek Harness 已停止' \
|
||||
/tmp/dsh-smoke-wait.html >/dev/null; then
|
||||
docker exec "$container" cat /tmp/dsh-smoke-wait.html >&2
|
||||
exit 1
|
||||
fi
|
||||
docker exec -i --user node --env "SMOKE_MANAGEMENT_CSRF=$management_csrf" \
|
||||
"$container" /usr/local/bin/node --input-type=module \
|
||||
< container/test/standalone-file-management-smoke.mjs \
|
||||
| jq -e '.range == "3456" and .searched >= 2 and .attributes == 488 and .dshUnavailable == true' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"file-manager".*"message":"file-task.copy.completed"' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"audit".*"message":"files.content.saved"' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"error":"size target changed".*"source":"audit".*"message":"files.size.failed"' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"audit".*"message":"files.attributes.completed"' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Accept: text/html' --header 'Host: smoke.example' http://127.0.0.1:3080/ >/dev/null
|
||||
if docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' 'http://127.0.0.1:3080/_dsh_platform/api/v1/logs?source=gateway&limit=5000' \
|
||||
| jq -e 'any(.entries[]; .message == "gateway.upstream.failed" and .upstream == "dsh")' >/dev/null; then
|
||||
echo "explicit DSH stop was reported as an upstream failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
managed_start_output="$(docker exec --user node "$container" dsh web --no-open)"
|
||||
printf '%s\n' "$managed_start_output" | grep -F 'requested managed DSH start' >/dev/null
|
||||
start_task="$(printf '%s\n' "$managed_start_output" | sed -n 's/.*(task \([^)]*\)).*/\1/p')"
|
||||
[ -n "$start_task" ]
|
||||
started="$(date +%s%3N)"
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$start_task" \
|
||||
'.dshLifecycle.taskId == $task and .dshLifecycle.state == "running" and .recoveryMode == null' >/dev/null; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "managed DSH start did not complete within the readiness timeout" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
internal_status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
http://127.0.0.1:3079/)"
|
||||
[ "$internal_status" -ge 200 ] && [ "$internal_status" -lt 500 ]
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/ >/dev/null
|
||||
if docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' 'http://127.0.0.1:3080/_dsh_platform/api/v1/logs?source=gateway&limit=5000' \
|
||||
| jq -e 'any(.entries[]; .message == "gateway.upstream.recovered" and .upstream == "dsh")' >/dev/null; then
|
||||
echo "explicit DSH start was reported as outage recovery" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec -i --user node --env "SMOKE_MANAGEMENT_CSRF=$management_csrf" \
|
||||
"$container" /usr/local/bin/node --input-type=module \
|
||||
< container/test/standalone-recovery-smoke.mjs \
|
||||
| jq -e '.faultNames == ["smoke-fault-one","smoke-fault-two"]' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"terminal".*"message":"terminal.session.created"' >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -E '"source":"audit".*"message":"user-plugin.apply.completed"' >/dev/null
|
||||
internal_status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' --noproxy '*' \
|
||||
http://127.0.0.1:3079/)"
|
||||
[ "$internal_status" -ge 200 ] && [ "$internal_status" -lt 500 ]
|
||||
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--request PUT --data '{"enabled":false,"intervalSeconds":3600,"notificationsEnabled":false}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/automatic-check \
|
||||
| jq -e '. == {"enabled":false,"intervalSeconds":3600,"notificationsEnabled":false}' >/dev/null
|
||||
|
||||
loopback_patch_count="$(docker exec "$container" sh -c \
|
||||
"grep -F -o 'isLoopback: true,' /run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-client-connection/lib/client.js | wc -l")"
|
||||
[ "$loopback_patch_count" = 1 ]
|
||||
|
||||
docker exec "$container" sh -c '
|
||||
set -eu
|
||||
pgrep -f "^/usr/local/bin/node /opt/dsh-platform/runtime/platform/stage0/index.mjs$" >/dev/null
|
||||
pgrep -f "/opt/dsh-platform/seed/bootstrap/.*/platform/bootstrap/index.mjs" >/dev/null
|
||||
pgrep -f "^node /run/dsh-platform/views/runtime/bin/dsh web --patch /run/dsh-platform/views/system-plugins/cordis.patch.yml --host 127.0.0.1 --port 3079$" >/dev/null
|
||||
pgrep -f "^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs$" >/dev/null
|
||||
pgrep -f "^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs$" >/dev/null
|
||||
dsh-platform trust status | jq -e ".keyringGeneration == 1" >/dev/null
|
||||
dsh-platform status | jq -e ".trust.keyringGeneration == 1 and .platformLayout == 1 and .current.source == \"image\"" >/dev/null
|
||||
[ "$(stat -c %a /run/dsh-platform/recovery.sock)" = 600 ]
|
||||
[ "$(stat -c %U /run/dsh-platform/recovery.sock)" = root ]
|
||||
[ "$(readlink /usr/local/bin/dsh 2>/dev/null || true)" = "" ]
|
||||
grep -F "exec /run/dsh-platform/views/runtime/bin/dsh" /usr/local/bin/dsh >/dev/null
|
||||
'
|
||||
|
||||
docker exec "$container" sh -c 'printf runtime-reset-smoke > /data/dsh/runtime-reset-sentinel'
|
||||
docker exec "$container" cp /data/platform/state/deployments/slots.json /tmp/slots-before-runtime-reset.json
|
||||
runtime_hash="$(docker exec "$container" sha256sum /run/dsh-platform/views/runtime/package/package.json | cut -d ' ' -f 1)"
|
||||
docker exec "$container" sh -c 'printf "\ncorrupt-runtime-smoke" >> /run/dsh-platform/views/runtime/package/package.json'
|
||||
runtime_reset_task="$(docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --request POST \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/runtime/reset | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$runtime_reset_task" '.runtimeReset.taskId == $task and .runtimeReset.status == "success"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 600 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "Runtime reset did not complete" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$(docker exec "$container" sha256sum /run/dsh-platform/views/runtime/package/package.json | cut -d ' ' -f 1)" = "$runtime_hash" ]
|
||||
[ "$(docker exec "$container" cat /data/dsh/runtime-reset-sentinel)" = runtime-reset-smoke ]
|
||||
docker exec "$container" jq -e --slurpfile before /tmp/slots-before-runtime-reset.json \
|
||||
'.generation == ($before[0].generation + 1) and .previous == $before[0].previous' \
|
||||
/data/platform/state/deployments/slots.json >/dev/null
|
||||
docker exec "$container" dsh-platform status \
|
||||
| jq -e '.current.source == "managed" and .recoveryMode == null' >/dev/null
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"runtime.reset.started"' >/dev/null
|
||||
docker logs "$container" 2>&1 | grep -E '"source":"audit".*"message":"runtime.reset.completed"' >/dev/null
|
||||
|
||||
stage0_pid="$(docker exec "$container" pgrep -f '^/usr/local/bin/node /opt/dsh-platform/runtime/platform/stage0/index.mjs$')"
|
||||
bootstrap_pid="$(docker exec "$container" pgrep -f '/opt/dsh-platform/seed/bootstrap/.*/platform/bootstrap/index.mjs')"
|
||||
management_pid="$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs$')"
|
||||
gateway_pid="$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs$')"
|
||||
dsh_pid="$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ')"
|
||||
docker exec --user node "$container" node -e '
|
||||
const fs = require("node:fs")
|
||||
const path = "/data/dsh/profiles/web/package.json"
|
||||
const manifest = JSON.parse(fs.readFileSync(path, "utf8"))
|
||||
manifest.dsh.profile.bundles.push("smoke-removed-plugin")
|
||||
fs.writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n")
|
||||
'
|
||||
restart_task="$(docker exec "$container" dsh-platform restart | jq -r .taskId)"
|
||||
started="$(date +%s%3N)"
|
||||
until docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e --arg task "$restart_task" '.dshLifecycle.taskId == $task and .dshLifecycle.state == "running"' >/dev/null; do
|
||||
now="$(date +%s%3N)"
|
||||
if [ $((now - started)) -ge "$readiness_timeout_ms" ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "DSH orphaned-bundle restart did not complete within the readiness timeout" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /opt/dsh-platform/runtime/platform/stage0/index.mjs$')" = "$stage0_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '/opt/dsh-platform/seed/bootstrap/.*/platform/bootstrap/index.mjs')" = "$bootstrap_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs$')" = "$management_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs$')" = "$gateway_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ')" != "$dsh_pid" ]
|
||||
docker exec "$container" jq -e '.dsh.profile.bundles | index("smoke-removed-plugin") == null' \
|
||||
/data/dsh/profiles/web/package.json >/dev/null
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -F 'dsh: removed orphaned profile bundle(s): smoke-removed-plugin' >/dev/null
|
||||
|
||||
dsh_pid="$(docker exec "$container" pgrep -o -f '^node /run/dsh-platform/views/runtime/bin/dsh web ')"
|
||||
managed_restart_result="$(docker exec -i --user node --env CURRENT_DSH_PID="$dsh_pid" \
|
||||
"$container" /usr/local/bin/node --input-type=module \
|
||||
< "$(dirname "$0")/managed-lifecycle-restart-smoke.mjs")"
|
||||
printf '%s\n' "$managed_restart_result" | jq -e \
|
||||
'.helperExitCode == 0 and .sawRestarting == true and .dshCount == 1
|
||||
and (.taskId | type == "string" and length > 0) and .oldPid != .newPid' >/dev/null
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /opt/dsh-platform/runtime/platform/stage0/index.mjs$')" = "$stage0_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '/opt/dsh-platform/seed/bootstrap/.*/platform/bootstrap/index.mjs')" = "$bootstrap_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs$')" = "$management_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -f '^/usr/local/bin/node /run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs$')" = "$gateway_pid" ]
|
||||
[ "$(docker exec "$container" pgrep -c -f '^node /run/dsh-platform/views/runtime/bin/dsh web ')" = 1 ]
|
||||
|
||||
if docker exec --user node "$container" curl --silent --unix-socket /run/dsh-platform/recovery.sock \
|
||||
http://localhost/v1/status >/dev/null 2>&1; then
|
||||
echo "node user unexpectedly accessed the Stage-0 recovery socket" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
internal_status="$(docker exec "$container" curl --silent --output /dev/null --write-out '%{http_code}' --noproxy '*' \
|
||||
http://127.0.0.1:3079/)"
|
||||
[ "$internal_status" -ge 200 ] && [ "$internal_status" -lt 500 ]
|
||||
|
||||
container_ip="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container")"
|
||||
if docker exec "$container" curl --fail --silent --max-time 2 --noproxy '*' \
|
||||
"http://${container_ip}:3079/" >/dev/null 2>&1; then
|
||||
echo "DSH unexpectedly accepts non-loopback connections on port 3079" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker rm -f "$container" >/dev/null
|
||||
docker run --detach --name "$container" \
|
||||
--group-add dsh-sudo-true \
|
||||
--env DSH_TRUSTED_HOSTS=smoke.example \
|
||||
--volume "$platform_volume:/data/platform" \
|
||||
--volume "$home_volume:/data/dsh" \
|
||||
"$image" >/dev/null
|
||||
wait_control_ready
|
||||
startup_two="$(wait_platform_ready)"
|
||||
establish_sessions login
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' --header 'Content-Type: application/json' \
|
||||
--request PUT --data '{"enabled":false,"intervalSeconds":3600,"notificationsEnabled":false}' \
|
||||
http://127.0.0.1:3080/_dsh_platform/api/v1/automatic-check >/dev/null
|
||||
docker exec --user node "$container" sh -c 'printf platform > /data/platform/state/updater/smoke && printf home > /data/dsh/smoke'
|
||||
stop_task="$(docker exec "$container" dsh-platform stop | jq -r .taskId)"
|
||||
attempt=0
|
||||
until docker exec "$container" dsh-platform status \
|
||||
| jq -e --arg task "$stop_task" '.dshLifecycle.taskId == $task and .dshLifecycle.state == "stopped"' >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" curl --fail --silent --noproxy '*' \
|
||||
http://127.0.0.1:3080/_dsh_gateway/health >/dev/null
|
||||
if health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container")" \
|
||||
&& [ -n "$health_status" ] && [ "$health_status" != healthy ] && [ "$health_status" != starting ]; then
|
||||
echo "container health became $health_status after an intentional DSH stop" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$container" chown root:root /data/platform/cache/npm
|
||||
docker restart "$container" >/dev/null
|
||||
wait_control_ready
|
||||
startup_three="$(wait_platform_ready)"
|
||||
establish_sessions login
|
||||
docker exec "$container" sh -c '[ "$(stat -c %u:%g /data/platform/cache/npm)" = 1000:1000 ]'
|
||||
docker exec "$container" sh -c '[ "$(cat /data/platform/state/updater/smoke)" = platform ] && [ "$(cat /data/dsh/smoke)" = home ]'
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status \
|
||||
| jq -e '.automaticCheck == {"enabled":false,"intervalSeconds":3600,"notificationsEnabled":false}
|
||||
and .dshLifecycle.state == "running"' >/dev/null
|
||||
echo "Cold readiness (ms): $startup_one, $startup_two, $startup_three"
|
||||
|
||||
docker exec "$container" dsh-platform channel experimental >/dev/null
|
||||
[ "$(docker exec "$container" dsh-platform channel)" = experimental ]
|
||||
attempt=0
|
||||
until docker exec --user node "$container" curl --fail --silent --unix-socket /run/dsh-platform/bootstrap.sock \
|
||||
http://localhost/v1/status >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 60 ] || exit 1
|
||||
sleep 1
|
||||
done
|
||||
docker exec --user node "$container" curl --fail --silent --unix-socket /run/dsh-platform/bootstrap.sock \
|
||||
--request POST http://localhost/v1/components/dsh-runtime/suspend >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/console/ \
|
||||
| grep -F 'DSH Management Console' >/dev/null
|
||||
docker exec "$container" curl --fail --silent --cookie "$session_cookie" \
|
||||
--header 'Origin: http://smoke.example' --header "X-DSH-CSRF: $management_csrf" \
|
||||
--header 'Host: smoke.example' http://127.0.0.1:3080/_dsh_platform/api/v1/status >/dev/null
|
||||
docker exec -i --user node "$container" /usr/local/bin/node --input-type=module <<'NODE'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { LocalApiClient } from '/run/dsh-platform/views/bootstrap/control-plane/modules/updater/lib/client.mjs'
|
||||
import { UpdateJournal } from '/run/dsh-platform/views/bootstrap/control-plane/modules/updater/lib/journal.mjs'
|
||||
import { PersistentStateSnapshots } from '/run/dsh-platform/views/bootstrap/control-plane/modules/updater/lib/snapshots.mjs'
|
||||
|
||||
const bootstrap = new LocalApiClient('/run/dsh-platform/bootstrap.sock')
|
||||
const { record } = await bootstrap.request('GET', '/v1/deployments/current')
|
||||
const metadata = JSON.parse(await readFile('/run/dsh-platform/views/runtime/package/package.json', 'utf8'))
|
||||
const snapshots = new PersistentStateSnapshots({ root: '/data/platform/store/snapshots', sourceRoot: '/data/dsh' })
|
||||
await snapshots.create({
|
||||
id: 'smoke-recovery', runtimeId: record.id,
|
||||
environmentVersion: record.environmentVersion, dshVersion: metadata.version,
|
||||
})
|
||||
const journal = new UpdateJournal('/data/platform/state/updater/transaction.json')
|
||||
await journal.begin({
|
||||
transactionId: 'smoke-recovery', mode: 'experimental',
|
||||
from: {
|
||||
dsh: metadata.version, environment: record.environmentVersion, runtime: record.id,
|
||||
dataSnapshot: null, receiptTokens: record.receiptTokens,
|
||||
},
|
||||
to: { dsh: metadata.version, environment: record.environmentVersion, runtime: record.id },
|
||||
})
|
||||
await journal.transition('candidate-ready', { receiptTokens: record.receiptTokens })
|
||||
await journal.transition('suspended')
|
||||
await journal.transition('snapshot-created', { snapshotId: 'smoke-recovery' })
|
||||
await journal.transition('switched')
|
||||
await journal.transition('probation', { probationUntil: '2099-01-01T00:00:00.000Z' })
|
||||
NODE
|
||||
docker exec --user node "$container" sh -c 'printf changed-after-snapshot > /data/dsh/smoke'
|
||||
docker restart "$container" >/dev/null
|
||||
attempt=0
|
||||
until docker exec "$container" sh -c '
|
||||
test "$(cat /data/dsh/smoke 2>/dev/null)" = home \
|
||||
&& test "$(jq -r .phase /data/platform/state/updater/transaction.json 2>/dev/null)" = rolled-back \
|
||||
&& test "$(dsh-platform channel 2>/dev/null)" = experimental
|
||||
' >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "Experimental restart recovery did not complete" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
docker exec "$container" sh -c '
|
||||
set -eu
|
||||
[ "$(cat /data/dsh/smoke)" = home ]
|
||||
[ "$(jq -r .phase /data/platform/state/updater/transaction.json)" = rolled-back ]
|
||||
[ "$(dsh-platform channel)" = experimental ]
|
||||
'
|
||||
docker logs "$container" 2>&1 | node -e '
|
||||
const input = require("node:fs").readFileSync(0, "utf8").split("\n")
|
||||
const entries = input.flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } })
|
||||
const recovery = entries.filter(entry => entry.message === "startup-recovery.completed").at(-1)
|
||||
const dshStart = entries.filter(entry => entry.message === "component.starting" && entry.componentId === "dsh-runtime").at(-1)
|
||||
if (recovery?.updatePhase !== "rolled-back" || dshStart === undefined
|
||||
|| Date.parse(recovery.timestamp) > Date.parse(dshStart.timestamp)) {
|
||||
throw new Error("DSH started before interrupted update recovery completed")
|
||||
}
|
||||
'
|
||||
cleanup
|
||||
trap - EXIT INT TERM
|
||||
|
||||
platform_volume="dsh-platform-legacy-$$"
|
||||
home_volume="dsh-home-legacy-$$"
|
||||
trap cleanup EXIT INT TERM
|
||||
docker run --rm --entrypoint sh \
|
||||
--volume "$platform_volume:/data/platform" \
|
||||
--volume "$home_volume:/data/dsh" \
|
||||
"$image" -c 'mkdir -p /data/platform/runtime && printf preserved > /data/dsh/sentinel'
|
||||
set +e
|
||||
legacy_output="$(timeout 15s docker run --rm \
|
||||
--volume "$platform_volume:/data/platform" \
|
||||
--volume "$home_volume:/data/dsh" \
|
||||
"$image" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
[ "$status" -ne 0 ]
|
||||
echo "$legacy_output" | grep -F 'clear only /data/platform' >/dev/null
|
||||
echo "$legacy_output" | grep -F 'Do not delete /data/dsh' >/dev/null
|
||||
docker run --rm --entrypoint sh --volume "$home_volume:/data/dsh" "$image" \
|
||||
-c '[ "$(cat /data/dsh/sentinel)" = preserved ]'
|
||||
cleanup
|
||||
trap - EXIT INT TERM
|
||||
|
||||
echo "Container smoke checks passed"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 image" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
image="$1"
|
||||
|
||||
docker run --rm --entrypoint sh "$image" -c '
|
||||
set -eu
|
||||
for package in \
|
||||
bash-completion build-essential dnsutils file htop iproute2 iputils-ping less lsof \
|
||||
nano netcat-openbsd openssl pkg-config python3 python3-venv rsync tmux tree unzip \
|
||||
vim wget xz-utils zip; do
|
||||
dpkg-query --show "$package" >/dev/null
|
||||
done
|
||||
|
||||
for command in \
|
||||
bash curl dig file g++ git htop ip jq less lsof make nano nc openssl ping \
|
||||
pkg-config ps python3 rg rsync ssh tmux tree unzip uv uvx vim wget xz zip; do
|
||||
command -v "$command" >/dev/null
|
||||
done
|
||||
|
||||
[ "$(command -v python3)" = /usr/bin/python3 ]
|
||||
! command -v pip >/dev/null
|
||||
! command -v pip3 >/dev/null
|
||||
[ ! -e /opt/dsh-python ]
|
||||
uv --version | grep "^uv 0\.11\.32 (x86_64-unknown-linux-gnu)$" >/dev/null
|
||||
uvx --version | grep "^uvx 0\.11\.32 (x86_64-unknown-linux-gnu)$" >/dev/null
|
||||
grep '\''^python-downloads = "manual"$'\'' /etc/uv/uv.toml >/dev/null
|
||||
uv run --isolated --no-python-downloads -- python -c \
|
||||
"import sys; assert sys.prefix != sys.base_prefix"
|
||||
|
||||
venv="$(mktemp -d)/venv"
|
||||
python3 -m venv "$venv"
|
||||
"$venv/bin/python" --version >/dev/null
|
||||
'
|
||||
|
||||
echo "Devtools smoke checks passed"
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { request } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const MANAGEMENT_SOCKET = '/run/dsh-platform/management-cli.sock'
|
||||
const DSH_EXECUTABLE = '/run/dsh-platform/views/runtime/bin/dsh'
|
||||
const oldPid = Number.parseInt(process.env.CURRENT_DSH_PID ?? '', 10)
|
||||
|
||||
assert.ok(Number.isInteger(oldPid) && oldPid > 1, 'CURRENT_DSH_PID must identify the running DSH')
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function status() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({
|
||||
socketPath: MANAGEMENT_SOCKET,
|
||||
method: 'GET',
|
||||
path: '/_dsh_platform/api/v1/status',
|
||||
}, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Management status returned HTTP ${String(response.statusCode)}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(2_000, () => req.destroy(new Error('Management status timed out')))
|
||||
req.once('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function dshPids() {
|
||||
const entries = await readdir('/proc', { withFileTypes: true })
|
||||
const matches = []
|
||||
await Promise.all(entries.filter(entry => entry.isDirectory() && /^\d+$/.test(entry.name)).map(async entry => {
|
||||
try {
|
||||
const command = await readFile(`/proc/${entry.name}/cmdline`)
|
||||
const args = command.toString('utf8').split('\0').filter(Boolean)
|
||||
if (args[1] === DSH_EXECUTABLE && args[2] === 'web') matches.push(Number.parseInt(entry.name, 10))
|
||||
} catch {}
|
||||
}))
|
||||
return matches.sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
function runUnauthorizedHelper() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(DSH_EXECUTABLE, ['web', '--no-open'], {
|
||||
detached: true,
|
||||
env: { ...process.env, DSH_PLATFORM_LAUNCH_TOKEN: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const output = []
|
||||
child.stdout.on('data', chunk => output.push(chunk))
|
||||
child.stderr.on('data', chunk => output.push(chunk))
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('unauthorized detached DSH helper did not exit'))
|
||||
}, 10_000)
|
||||
child.once('error', error => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', code => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ code, output: Buffer.concat(output).toString('utf8') })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const before = await status()
|
||||
assert.equal(before.dshLifecycle?.state, 'running')
|
||||
assert.deepEqual(await dshPids(), [oldPid])
|
||||
|
||||
const helper = await runUnauthorizedHelper()
|
||||
assert.equal(helper.code, 0)
|
||||
assert.match(helper.output, /managed DSH is running; no second Web instance was started/)
|
||||
assert.deepEqual(await dshPids(), [oldPid])
|
||||
|
||||
process.kill(oldPid, 'SIGTERM')
|
||||
|
||||
let taskId = null
|
||||
let sawRestarting = false
|
||||
let finalStatus = null
|
||||
let newPids = []
|
||||
const deadline = Date.now() + 25_000
|
||||
while (Date.now() < deadline) {
|
||||
const current = await status()
|
||||
if (current.dshLifecycle?.taskId && current.dshLifecycle.taskId !== before.dshLifecycle?.taskId) {
|
||||
taskId = current.dshLifecycle.taskId
|
||||
}
|
||||
if (current.dshLifecycle?.state === 'restarting') sawRestarting = true
|
||||
newPids = await dshPids()
|
||||
if (taskId !== null && current.dshLifecycle?.state === 'running'
|
||||
&& newPids.length === 1 && newPids[0] !== oldPid) {
|
||||
finalStatus = current
|
||||
break
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
assert.ok(taskId, 'unregistered SIGTERM did not create a managed restart task')
|
||||
assert.equal(sawRestarting, true, 'managed restart state was not observed')
|
||||
assert.equal(finalStatus?.dshLifecycle?.state, 'running')
|
||||
assert.equal(finalStatus?.recoveryMode, null)
|
||||
assert.equal(newPids.length, 1)
|
||||
assert.notEqual(newPids[0], oldPid)
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
helperExitCode: helper.code,
|
||||
oldPid,
|
||||
newPid: newPids[0],
|
||||
taskId,
|
||||
sawRestarting,
|
||||
dshCount: newPids.length,
|
||||
})}\n`)
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 <container>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
container="$1"
|
||||
profile=/data/dsh/profiles/web
|
||||
legacy_store=/workspace/.pnpm-store
|
||||
stable_store=/data/dsh/.pnpm-store
|
||||
fixture=/workspace/profile-package-smoke
|
||||
|
||||
docker exec "$container" dsh-platform stop --wait >/dev/null
|
||||
docker exec --user node "$container" sh -c "
|
||||
set -eu
|
||||
test -f '$profile/package.json'
|
||||
mkdir -p '$legacy_store'
|
||||
if [ -d '$stable_store' ]; then cp -a '$stable_store/.' '$legacy_store/'; fi
|
||||
"
|
||||
docker exec --user node --env PROFILE="$profile" --env LEGACY_STORE="$legacy_store" \
|
||||
"$container" node --input-type=module -e '
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
const path = process.env.PROFILE + "/pnpm-workspace.yaml"
|
||||
const source = readFileSync(path, "utf8")
|
||||
const line = `storeDir: ${JSON.stringify(process.env.LEGACY_STORE)}\n`
|
||||
const pattern = /^storeDir:[^\r\n]*(?:\r?\n|$)/m
|
||||
writeFileSync(path, pattern.test(source)
|
||||
? source.replace(pattern, line)
|
||||
: source.replace(/(?:\r?\n)?$/, `\n\n${line}`))
|
||||
'
|
||||
docker exec --user node "$container" sh -c "
|
||||
set -eu
|
||||
rm -rf '$profile/node_modules'
|
||||
cd '$profile'
|
||||
pnpm install --offline --frozen-lockfile --reporter=append-only >/dev/null
|
||||
"
|
||||
docker exec --user node --env PROFILE="$profile" --env LEGACY_STORE="$legacy_store" \
|
||||
"$container" node -e '
|
||||
const value = JSON.parse(require("fs").readFileSync(process.env.PROFILE + "/node_modules/.modules.yaml", "utf8"))
|
||||
if (!value.storeDir.startsWith(process.env.LEGACY_STORE + "/")) throw new Error(value.storeDir)
|
||||
'
|
||||
|
||||
docker exec "$container" dsh-platform start --wait >/dev/null
|
||||
docker exec --user node --env PROFILE="$profile" --env STABLE_STORE="$stable_store" \
|
||||
"$container" node -e '
|
||||
const value = JSON.parse(require("fs").readFileSync(process.env.PROFILE + "/node_modules/.modules.yaml", "utf8"))
|
||||
if (!value.storeDir.startsWith(process.env.STABLE_STORE + "/")) throw new Error(value.storeDir)
|
||||
'
|
||||
|
||||
docker exec --user node "$container" sh -c "
|
||||
set -eu
|
||||
mkdir -p '$fixture'
|
||||
printf '%s\n' '{\"name\":\"profile-package-smoke\",\"version\":\"1.0.0\",\"type\":\"module\",\"main\":\"index.mjs\",\"dsh\":{\"bundle\":{\"patch\":\"./cordis.patch.yml\"}}}' > '$fixture/package.json'
|
||||
printf '%s\n' 'export default function () {}' > '$fixture/index.mjs'
|
||||
printf '%s\n' '- insert:' ' - id: profile-package-smoke' ' name: file://$fixture/index.mjs' > '$fixture/cordis.patch.yml'
|
||||
"
|
||||
docker exec \
|
||||
--env npm_config_userconfig=/root/.config/npm/npmrc \
|
||||
--env npm_config_cache=/root/.npm \
|
||||
"$container" dsh plugin --profile web add "$fixture" >/dev/null
|
||||
docker exec "$container" sh -c "
|
||||
set -eu
|
||||
test \"\$(stat -c %U '$profile/package.json')\" = node
|
||||
test \"\$(stat -c %U '$profile/pnpm-lock.yaml')\" = node
|
||||
test \"\$(stat -c %U '$profile/node_modules/profile-package-smoke')\" = node
|
||||
"
|
||||
docker exec "$container" dsh plugin --profile web remove profile-package-smoke >/dev/null
|
||||
docker exec --user node "$container" rm -rf "$fixture"
|
||||
docker logs "$container" 2>&1 \
|
||||
| grep -F "dsh: migrated Profile package storage from $legacy_store/" >/dev/null
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
|
||||
const API = 'http://127.0.0.1:3080/_dsh_platform/api/v1'
|
||||
const cookie = (await readFile('/tmp/dsh-smoke.cookies', 'utf8'))
|
||||
.split('\n')
|
||||
.map(line => line.startsWith('#HttpOnly_') ? line.slice('#HttpOnly_'.length) : line)
|
||||
.filter(line => line !== '' && !line.startsWith('#'))
|
||||
.map(line => line.split('\t'))
|
||||
.filter(fields => fields.length === 7)
|
||||
.map(fields => `${fields[5]}=${fields[6]}`)
|
||||
.join('; ')
|
||||
const headers = {
|
||||
cookie,
|
||||
host: 'smoke.example',
|
||||
origin: 'http://smoke.example',
|
||||
'x-dsh-csrf': process.env.SMOKE_MANAGEMENT_CSRF,
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, raw = false, extraHeaders = {} } = {}) {
|
||||
const encoded = body === undefined ? undefined : raw ? body : Buffer.from(JSON.stringify(body))
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const outgoing = httpRequest(`${API}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...headers,
|
||||
...extraHeaders,
|
||||
...(body !== undefined && !raw ? { 'content-type': 'application/json' } : {}),
|
||||
...(encoded === undefined ? {} : { 'content-length': Buffer.byteLength(encoded) }),
|
||||
},
|
||||
}, incoming => {
|
||||
const chunks = []
|
||||
incoming.on('data', chunk => chunks.push(chunk))
|
||||
incoming.once('end', () => resolve(new Response(Buffer.concat(chunks), {
|
||||
status: incoming.statusCode,
|
||||
headers: incoming.headers,
|
||||
})))
|
||||
})
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(encoded)
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} ${path} returned ${String(response.status)}: ${await response.text()}`)
|
||||
return raw ? response : response.json()
|
||||
}
|
||||
|
||||
async function task(body) {
|
||||
const started = await request('files/tasks', { method: 'POST', body })
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const state = await request(`files/tasks/${started.taskId}`)
|
||||
if (state.status === 'success') return state
|
||||
if (!['queued', 'running'].includes(state.status)) throw new Error(`file task ${state.taskId} ${state.status}: ${state.error}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`file task ${started.taskId} timed out`)
|
||||
}
|
||||
|
||||
async function item(path) { return request(`files/stat?path=${encodeURIComponent(path)}`) }
|
||||
|
||||
const config = await request('files/config')
|
||||
assert.equal(config.privileged, true)
|
||||
const rootFile = `/root/dsh-maintenance-smoke-${String(process.pid)}.txt`
|
||||
await request('files/content', {
|
||||
method: 'PUT', body: { path: rootFile, revision: null, content: 'root maintenance\n', create: true },
|
||||
})
|
||||
const rootItem = await item(rootFile)
|
||||
assert.equal(rootItem.user, 'root')
|
||||
assert.equal(rootItem.group, 'root')
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(rootFile)}`)).content, 'root maintenance\n')
|
||||
await task({ operation: 'delete', sources: [{ path: rootItem.path, revision: rootItem.revision }] })
|
||||
await assert.rejects(item(rootFile), /returned 404/)
|
||||
|
||||
const workspaceRoot = `/workspace/file-management-smoke-${String(process.pid)}`
|
||||
const dshRoot = `/data/dsh/file-management-smoke-${String(process.pid)}`
|
||||
await mkdir(workspaceRoot)
|
||||
await task({ operation: 'mkdir', destination: dshRoot })
|
||||
|
||||
const uploadedPath = `${workspaceRoot}/uploaded-中文.txt`
|
||||
const upload = await request(`files/upload?path=${encodeURIComponent(uploadedPath)}&conflict=reject`, {
|
||||
method: 'POST', body: Buffer.from('0123456789'), raw: true, extraHeaders: { 'content-length': '10' },
|
||||
}).then(response => response.json())
|
||||
assert.equal(upload.size, 10)
|
||||
const range = await request(`files/download?path=${encodeURIComponent(uploadedPath)}&revision=${encodeURIComponent(upload.revision)}`, {
|
||||
raw: true, extraHeaders: { range: 'bytes=3-6' },
|
||||
})
|
||||
assert.equal(range.status, 206)
|
||||
assert.equal(await range.text(), '3456')
|
||||
|
||||
const before = await request(`files/content?path=${encodeURIComponent(uploadedPath)}`)
|
||||
const saved = await request('files/content', {
|
||||
method: 'PUT', body: { path: uploadedPath, revision: before.revision, content: 'edited in recovery mode\n' },
|
||||
})
|
||||
assert.notEqual(saved.revision, before.revision)
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(uploadedPath)}`)).content, 'edited in recovery mode\n')
|
||||
|
||||
const copyRoot = `${workspaceRoot}/copies`
|
||||
await task({ operation: 'mkdir', destination: copyRoot })
|
||||
const source = await item(uploadedPath)
|
||||
await task({ operation: 'copy', sources: [{ path: source.path, revision: source.revision }], destination: copyRoot, conflict: 'reject' })
|
||||
const copiedPath = `${copyRoot}/uploaded-中文.txt`
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(copiedPath)}`)).content, 'edited in recovery mode\n')
|
||||
|
||||
const linkPath = `${workspaceRoot}/relative-link`
|
||||
await symlink('uploaded-中文.txt', linkPath)
|
||||
const link = await item(linkPath)
|
||||
assert.equal(link.type, 'symlink')
|
||||
await task({ operation: 'copy', sources: [{ path: link.path, revision: link.revision }], destination: copyRoot, conflict: 'reject' })
|
||||
assert.equal((await item(`${copyRoot}/relative-link`)).type, 'symlink')
|
||||
|
||||
const movedRoot = `${workspaceRoot}/moved`
|
||||
await task({ operation: 'mkdir', destination: movedRoot })
|
||||
const copied = await item(copiedPath)
|
||||
await task({ operation: 'move', sources: [{ path: copied.path, revision: copied.revision }], destination: movedRoot, conflict: 'reject' })
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(`${movedRoot}/uploaded-中文.txt`)}`)).content, 'edited in recovery mode\n')
|
||||
|
||||
const dshFile = `${dshRoot}/recovery.json`
|
||||
await request('files/content', { method: 'PUT', body: { path: dshFile, revision: null, content: '{"recovered":true}\n', create: true } })
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(dshFile)}`)).content, '{"recovered":true}\n')
|
||||
|
||||
const archiveSource = `${workspaceRoot}/archive-source`
|
||||
await task({ operation: 'mkdir', destination: archiveSource })
|
||||
await request('files/content', {
|
||||
method: 'PUT', body: { path: `${archiveSource}/entry.txt`, revision: null, content: 'archive fixture\n', create: true },
|
||||
})
|
||||
for (const [format, extension] of [['zip', 'zip'], ['7z', '7z'], ['tar.gz', 'tar.gz']]) {
|
||||
const source = await item(archiveSource)
|
||||
const archivePath = `${workspaceRoot}/fixture-${format}.${extension}`
|
||||
await task({
|
||||
operation: 'archive', archiveFormat: format,
|
||||
sources: [{ path: source.path, revision: source.revision }], destination: archivePath,
|
||||
})
|
||||
const extraction = `${workspaceRoot}/extracted-${format}`
|
||||
await task({ operation: 'mkdir', destination: extraction })
|
||||
const archive = await item(archivePath)
|
||||
await task({
|
||||
operation: 'extract', archiveFormat: format,
|
||||
sources: [{ path: archive.path, revision: archive.revision }], destination: extraction,
|
||||
})
|
||||
assert.equal((await request(`files/content?path=${encodeURIComponent(`${extraction}/archive-source/entry.txt`)}`)).content, 'archive fixture\n')
|
||||
}
|
||||
const directoryDownload = await request(`files/download?path=${encodeURIComponent(archiveSource)}&revision=${encodeURIComponent((await item(archiveSource)).revision)}`, { raw: true })
|
||||
assert.equal(directoryDownload.headers.get('content-disposition').includes('archive-source.zip'), true)
|
||||
assert.deepEqual([...new Uint8Array(await directoryDownload.arrayBuffer()).slice(0, 2)], [0x50, 0x4b])
|
||||
|
||||
const pagingRoot = `${workspaceRoot}/paging`
|
||||
await mkdir(pagingRoot)
|
||||
await Promise.all(Array.from({ length: 125 }, (_, index) => writeFile(`${pagingRoot}/${String(index).padStart(3, '0')}.txt`, 'x')))
|
||||
const firstPage = await request(`files/list?path=${encodeURIComponent(pagingRoot)}&limit=50`)
|
||||
assert.equal(firstPage.entries.length, 50)
|
||||
assert.equal(firstPage.total, 125)
|
||||
assert.equal(typeof firstPage.nextCursor, 'string')
|
||||
const secondPage = await request(`files/list?path=${encodeURIComponent(pagingRoot)}&limit=50&cursor=${encodeURIComponent(firstPage.nextCursor)}`)
|
||||
assert.equal(secondPage.entries.length, 50)
|
||||
assert.equal(secondPage.entries[0].name, '050.txt')
|
||||
|
||||
const searchRoot = await request(`files/list?path=${encodeURIComponent(workspaceRoot)}`)
|
||||
assert.ok(searchRoot.entries.every(entry => typeof entry.user === 'string' && typeof entry.group === 'string'))
|
||||
const workspaceBeforeAttributes = await item(workspaceRoot)
|
||||
await task({
|
||||
operation: 'attributes',
|
||||
sources: [{ path: workspaceRoot, revision: workspaceBeforeAttributes.revision }],
|
||||
attributes: { user: 'root', group: 'root', mode: '0750', recursive: true },
|
||||
})
|
||||
const workspaceAttributes = await item(workspaceRoot)
|
||||
assert.equal(workspaceAttributes.user, 'root')
|
||||
assert.equal(workspaceAttributes.group, 'root')
|
||||
assert.equal(workspaceAttributes.mode, 0o750)
|
||||
assert.equal((await item(uploadedPath)).mode, 0o750)
|
||||
const sizeTask = await request('files/tasks', { method: 'POST', body: { operation: 'size', path: workspaceRoot, revision: workspaceAttributes.revision } })
|
||||
let sizeResult
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
sizeResult = await request(`files/tasks/${sizeTask.taskId}`)
|
||||
if (sizeResult.status !== 'running') break
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.equal(sizeResult.status, 'success')
|
||||
assert.ok(sizeResult.bytes >= 10)
|
||||
const staleSize = await request('files/tasks', { method: 'POST', body: { operation: 'size', path: workspaceRoot, revision: searchRoot.revision } })
|
||||
let staleSizeResult
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
staleSizeResult = await request(`files/tasks/${staleSize.taskId}`)
|
||||
if (staleSizeResult.status !== 'running') break
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.equal(staleSizeResult.status, 'failed')
|
||||
assert.match(staleSizeResult.error, /changed/)
|
||||
const searchRootAfterAttributes = await request(`files/list?path=${encodeURIComponent(workspaceRoot)}`)
|
||||
const search = await request('files/tasks', { method: 'POST', body: { operation: 'search', path: workspaceRoot, revision: searchRootAfterAttributes.revision, query: 'uploaded' } })
|
||||
let searchResult
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
searchResult = await request(`files/tasks/${search.taskId}?limit=1000`)
|
||||
if (searchResult.status !== 'running') break
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
assert.equal(searchResult.status, 'success')
|
||||
assert.ok(searchResult.results.length >= 2)
|
||||
|
||||
const workspace = await item(workspaceRoot)
|
||||
const dsh = await item(dshRoot)
|
||||
await task({ operation: 'delete', sources: [{ path: workspace.path, revision: workspace.revision }, { path: dsh.path, revision: dsh.revision }] })
|
||||
await assert.rejects(item(workspaceRoot), /returned 404/)
|
||||
await assert.rejects(item(dshRoot), /returned 404/)
|
||||
|
||||
console.log(JSON.stringify({ range: '3456', searched: searchResult.results.length, directoryBytes: sizeResult.bytes, attributes: workspaceAttributes.mode, archives: 3, paged: firstPage.total, dshUnavailable: true }))
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { request as httpRequest } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import WebSocket from '/run/dsh-platform/views/bootstrap/control-plane/services/management/node_modules/ws/wrapper.mjs'
|
||||
|
||||
const API = '/_dsh_platform/api/v1/'
|
||||
const cookie = (await readFile('/tmp/dsh-smoke.cookies', 'utf8'))
|
||||
.split('\n')
|
||||
.map(line => line.startsWith('#HttpOnly_') ? line.slice('#HttpOnly_'.length) : line)
|
||||
.filter(line => line !== '' && !line.startsWith('#'))
|
||||
.map(line => line.split('\t'))
|
||||
.filter(fields => fields.length === 7)
|
||||
.map(fields => `${fields[5]}=${fields[6]}`)
|
||||
.join('; ')
|
||||
const headers = Object.freeze({
|
||||
cookie,
|
||||
host: 'smoke.example',
|
||||
origin: 'http://smoke.example',
|
||||
'x-dsh-csrf': process.env.SMOKE_MANAGEMENT_CSRF,
|
||||
})
|
||||
|
||||
function request(method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const encoded = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
||||
const outgoing = httpRequest({
|
||||
host: '127.0.0.1', port: 3080, method, path,
|
||||
headers: {
|
||||
...headers,
|
||||
...(encoded === undefined ? {} : { 'content-type': 'application/json', 'content-length': encoded.byteLength }),
|
||||
},
|
||||
}, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.once('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const value = text === '' ? undefined : JSON.parse(text)
|
||||
if ((response.statusCode ?? 500) >= 400) {
|
||||
const error = new Error(value?.error ?? `HTTP ${String(response.statusCode)}`)
|
||||
error.statusCode = response.statusCode
|
||||
reject(error)
|
||||
} else resolve(value)
|
||||
})
|
||||
})
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(encoded)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitFor(check, label, attempts = 300) {
|
||||
let last
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
last = await check()
|
||||
if (last) return last
|
||||
} catch (error) {
|
||||
last = error
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
throw new Error(`${label} timed out: ${last instanceof Error ? last.message : JSON.stringify(last)}`)
|
||||
}
|
||||
|
||||
function connectTerminal(sessionId) {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:3080${API}terminal/sessions/${sessionId}/stream`, {
|
||||
headers,
|
||||
})
|
||||
let output = ''
|
||||
const waiters = new Set()
|
||||
socket.on('message', data => {
|
||||
const message = JSON.parse(data.toString())
|
||||
if (message.type === 'output') output += message.data
|
||||
for (const waiter of waiters) waiter()
|
||||
})
|
||||
const opened = new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve)
|
||||
socket.once('error', reject)
|
||||
})
|
||||
const waitOutput = async (marker, occurrences = 1) => {
|
||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||
if (output.split(marker).length - 1 >= occurrences) return output
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => { waiters.delete(done); resolve() }, 100)
|
||||
const done = () => { clearTimeout(timer); waiters.delete(done); resolve() }
|
||||
waiters.add(done)
|
||||
})
|
||||
}
|
||||
throw new Error(`terminal output did not contain ${marker}: ${output}`)
|
||||
}
|
||||
return { socket, opened, waitOutput }
|
||||
}
|
||||
|
||||
async function waitTask(kind, taskId, expected) {
|
||||
let lastOperation
|
||||
try {
|
||||
return await waitFor(async () => {
|
||||
const status = await request('GET', `${API}status`)
|
||||
const operation = status[kind]
|
||||
lastOperation = operation
|
||||
const operationState = operation?.status ?? operation?.state
|
||||
return operation?.taskId === taskId && expected.includes(operationState) ? operation : false
|
||||
}, `${kind} ${taskId}`, 600)
|
||||
} catch (error) {
|
||||
throw new Error(`${error.message}; last operation: ${JSON.stringify(lastOperation)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const terminal = await request('POST', `${API}terminal/sessions`, { cols: 100, rows: 32 })
|
||||
assert.equal(terminal.privileged, true)
|
||||
const first = connectTerminal(terminal.sessionId)
|
||||
await first.opened
|
||||
first.socket.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: "printf 'terminal-before-restart\\n'; printf 'uid=%s\\n' \"$(id -u)\"; printf 'gids=%s\\n' \"$(id -G)\"; printf 'cwd=%s\\n' \"$PWD\"; printf 'home=%s\\n' \"$HOME\"; printf 'dsh=%s\\n' \"$DSH_HOME\"; sudo -n true && printf 'sudo=ok\\n'\n",
|
||||
}))
|
||||
const identity = await first.waitOutput('sudo=ok', 3)
|
||||
assert.match(identity, /terminal-before-restart/)
|
||||
assert.match(identity, /uid=0/)
|
||||
assert.match(identity, /gids=.+/)
|
||||
assert.match(identity, /cwd=\/root/)
|
||||
assert.match(identity, /home=\/root/)
|
||||
assert.match(identity, /dsh=\/data\/dsh/)
|
||||
|
||||
const restart = await request('POST', `${API}restart-dsh`)
|
||||
await waitTask('dshLifecycle', restart.taskId, ['running'])
|
||||
first.socket.send(JSON.stringify({ type: 'input', data: "printf 'terminal-after-restart-中文\\n'\n" }))
|
||||
await first.waitOutput('terminal-after-restart-中文', 2)
|
||||
first.socket.close()
|
||||
await new Promise(resolve => first.socket.once('close', resolve))
|
||||
|
||||
const second = connectTerminal(terminal.sessionId)
|
||||
await second.opened
|
||||
await second.waitOutput('terminal-after-restart-中文')
|
||||
await request('DELETE', `${API}terminal/sessions/${terminal.sessionId}`)
|
||||
await new Promise(resolve => second.socket.once('close', resolve))
|
||||
await assert.rejects(request('GET', `${API}terminal/sessions/${terminal.sessionId}`), error => error.statusCode === 404)
|
||||
|
||||
const profileRoot = '/data/dsh/profiles/web'
|
||||
const manifestPath = join(profileRoot, 'package.json')
|
||||
const faultNames = ['smoke-fault-one', 'smoke-fault-two']
|
||||
const pluginRoots = new Map()
|
||||
for (const name of faultNames) {
|
||||
const source = join('/workspace/smoke-user-plugin-sources', name)
|
||||
const installed = join(profileRoot, 'node_modules', name)
|
||||
pluginRoots.set(name, source)
|
||||
await mkdir(source, { recursive: true })
|
||||
await writeFile(join(source, 'package.json'), `${JSON.stringify({
|
||||
name,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
main: './index.mjs',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, null, 2)}\n`)
|
||||
await writeFile(join(source, 'index.mjs'), `throw new Error('${name} startup failure')\n`)
|
||||
await writeFile(join(source, 'cordis.patch.yml'), `- insert:\n - id: ${name}\n name: ${JSON.stringify(`file://${join(source, 'index.mjs')}`)}\n`)
|
||||
await cp(source, installed, { recursive: true })
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
manifest.dependencies ??= {}
|
||||
manifest.dsh ??= {}
|
||||
manifest.dsh.profile ??= {}
|
||||
manifest.dsh.profile.bundles ??= []
|
||||
for (const name of faultNames) {
|
||||
manifest.dependencies[name] = `file:${pluginRoots.get(name)}`
|
||||
if (!manifest.dsh.profile.bundles.includes(name)) manifest.dsh.profile.bundles.push(name)
|
||||
}
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
|
||||
const brokenRestart = await request('POST', `${API}restart-dsh`)
|
||||
await waitTask('dshLifecycle', brokenRestart.taskId, ['failed'])
|
||||
await waitFor(async () => (await request('GET', `${API}status`)).recoveryMode ?? false, 'DSH recovery mode')
|
||||
|
||||
let inventory = await request('GET', `${API}user-plugins`)
|
||||
for (const name of faultNames) {
|
||||
const plugin = inventory.plugins.find(value => value.name === name)
|
||||
assert.equal(plugin?.enabled, true)
|
||||
assert.equal(plugin?.damaged, false)
|
||||
}
|
||||
|
||||
const firstRecovery = await request('POST', `${API}user-plugins/apply`, {
|
||||
profile: 'web', revision: inventory.revision,
|
||||
actions: [{ name: faultNames[0], action: 'disable' }],
|
||||
})
|
||||
const firstOutcome = await waitTask('userPluginOperation', firstRecovery.taskId, ['success'])
|
||||
assert.equal(firstOutcome.strategy, 'next-start')
|
||||
assert.equal(firstOutcome.activationError, null)
|
||||
inventory = await request('GET', `${API}user-plugins`)
|
||||
assert.equal(inventory.plugins.find(value => value.name === faultNames[0])?.enabled, false)
|
||||
assert.equal(inventory.plugins.find(value => value.name === faultNames[1])?.enabled, true)
|
||||
|
||||
const secondRecovery = await request('POST', `${API}user-plugins/apply`, {
|
||||
profile: 'web', revision: inventory.revision,
|
||||
actions: [{ name: faultNames[1], action: 'disable' }],
|
||||
})
|
||||
const secondOutcome = await waitTask('userPluginOperation', secondRecovery.taskId, ['success'])
|
||||
assert.equal(secondOutcome.strategy, 'next-start')
|
||||
assert.equal(secondOutcome.activationError, null)
|
||||
const recoveredStart = await request('POST', `${API}start-dsh`)
|
||||
await waitTask('dshLifecycle', recoveredStart.taskId, ['running'])
|
||||
await waitFor(async () => {
|
||||
const status = await request('GET', `${API}status`)
|
||||
return status.recoveryMode === null ? status : false
|
||||
}, 'DSH recovery completion')
|
||||
inventory = await request('GET', `${API}user-plugins`)
|
||||
for (const name of faultNames) assert.equal(inventory.plugins.find(value => value.name === name)?.enabled, false)
|
||||
|
||||
const uninstall = await request('POST', `${API}user-plugins/apply`, {
|
||||
profile: 'web', revision: inventory.revision,
|
||||
actions: [{ name: faultNames[0], action: 'uninstall' }],
|
||||
})
|
||||
const uninstallOutcome = await waitTask('userPluginOperation', uninstall.taskId, ['success', 'failed'])
|
||||
if (uninstallOutcome.status !== 'success') {
|
||||
const diagnostics = await request('GET', `${API}logs?limit=50`)
|
||||
throw new Error(JSON.stringify({
|
||||
error: uninstallOutcome.error,
|
||||
manifest: JSON.parse(await readFile(manifestPath, 'utf8')),
|
||||
logs: diagnostics.entries.slice(-10),
|
||||
}))
|
||||
}
|
||||
inventory = await request('GET', `${API}user-plugins`)
|
||||
assert.equal(inventory.plugins.some(value => value.name === faultNames[0]), false)
|
||||
|
||||
const logEntries = (await request('GET', `${API}logs?limit=5000`)).entries
|
||||
for (const message of [
|
||||
'terminal.session.created', 'terminal.session.connected', 'terminal.session.disconnected',
|
||||
'terminal.session.reconnected', 'terminal.session.closed', 'user-plugin.apply.started',
|
||||
'user-plugin.apply.completed',
|
||||
]) assert.ok(logEntries.some(entry => entry.message === message), `missing platform log ${message}`)
|
||||
assert.doesNotMatch(JSON.stringify(logEntries.filter(entry => entry.source === 'terminal')), /terminal-before-restart|terminal-after-restart/)
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ terminalSession: terminal.sessionId, faultNames })}\n`)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Context } from "/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/cordis/lib/index.js";
|
||||
import SkillRegistry from "/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-skill/lib/index.js";
|
||||
import * as SkillFileSystem from "/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-skill-filesystem/lib/index.js";
|
||||
import LocalFileSystem from "/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-fs-local/lib/index.js";
|
||||
|
||||
const context = new Context();
|
||||
context.plugin(SkillRegistry);
|
||||
context.plugin(LocalFileSystem, { cwd: process.env.DSH_DEFAULT_WORKSPACE ?? "/workspace" });
|
||||
context.plugin(SkillFileSystem, {
|
||||
dshHome: process.env.DSH_HOME ?? "/data/dsh",
|
||||
agentsHome: process.env.DSH_AGENTS_HOME ?? "/home/node/.agents",
|
||||
bundledSkillDir: process.env.DSH_BUNDLED_SKILL_DIR ?? "/run/dsh-platform/views/skills",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const summaries = await context.skills.list({
|
||||
cwd: process.env.DSH_DEFAULT_WORKSPACE ?? "/workspace",
|
||||
});
|
||||
const summary = summaries.find((entry) => entry.name === "dsh-docker-operations");
|
||||
if (summary?.source !== "bundled" || summary.provider !== "filesystem") {
|
||||
throw new Error(`bundled operations skill was not discovered: ${JSON.stringify(summary)}`);
|
||||
}
|
||||
|
||||
const definition = await context.skills.get("dsh-docker-operations", {
|
||||
cwd: process.env.DSH_DEFAULT_WORKSPACE ?? "/workspace",
|
||||
});
|
||||
if (!definition || definition.name !== "dsh-docker-operations" || definition.content.length < 500) {
|
||||
throw new Error("bundled operations skill did not load a complete definition");
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
name: definition.name,
|
||||
source: summary.source,
|
||||
provider: summary.provider,
|
||||
contentBytes: Buffer.byteLength(definition.content),
|
||||
}));
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/cordis/lib/index.js'
|
||||
import SkillRegistry from '/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-skill/lib/index.js'
|
||||
import * as SkillFileSystem from '/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-skill-filesystem/lib/index.js'
|
||||
import LocalFileSystem from '/run/dsh-platform/views/runtime/package/node_modules/@deepseek-ai/dsh-fs-local/lib/index.js'
|
||||
|
||||
const dshHome = process.env.DSH_HOME ?? '/data/dsh'
|
||||
const workspace = process.env.DSH_DEFAULT_WORKSPACE ?? '/workspace'
|
||||
const skillRoot = join(dshHome, 'skills')
|
||||
const activePath = join(skillRoot, 'smoke-user-skill')
|
||||
const disabledPath = join(skillRoot, '.disabled', 'smoke-user-skill')
|
||||
|
||||
async function waitForSkill(context, expected) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const values = await context.skills?.list({ cwd: workspace })
|
||||
if (values !== undefined && values.some(value => value.name === 'smoke-user-skill') === expected) return
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error(`user skill watcher did not report expected=${expected}`)
|
||||
}
|
||||
|
||||
await rm(activePath, { recursive: true, force: true })
|
||||
await rm(disabledPath, { recursive: true, force: true })
|
||||
await mkdir(activePath, { recursive: true })
|
||||
await writeFile(join(activePath, 'SKILL.md'), `---
|
||||
name: smoke-user-skill
|
||||
description: Smoke-test user skill watcher behavior.
|
||||
---
|
||||
|
||||
# Smoke User Skill
|
||||
`)
|
||||
|
||||
const context = new Context()
|
||||
context.plugin(SkillRegistry)
|
||||
context.plugin(LocalFileSystem, { cwd: workspace })
|
||||
context.plugin(SkillFileSystem, {
|
||||
dshHome,
|
||||
agentsHome: process.env.DSH_AGENTS_HOME ?? '/home/node/.agents',
|
||||
bundledSkillDir: process.env.DSH_BUNDLED_SKILL_DIR ?? '/run/dsh-platform/views/skills',
|
||||
})
|
||||
|
||||
await waitForSkill(context, true)
|
||||
await mkdir(join(skillRoot, '.disabled'), { recursive: true })
|
||||
await rename(activePath, disabledPath)
|
||||
await waitForSkill(context, false)
|
||||
await rename(disabledPath, activePath)
|
||||
await waitForSkill(context, true)
|
||||
await rm(activePath, { recursive: true })
|
||||
await waitForSkill(context, false)
|
||||
|
||||
console.log(JSON.stringify({ discovered: true, disabled: true, enabled: true, deleted: true }))
|
||||
process.exit(0)
|
||||
Reference in new issue
Block a user