From e950eb18b8d128595506f6144e191d4af57f5eef Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 18 Jun 2026 18:08:16 +0800 Subject: [PATCH] fix: support Debian 13 offline bootstrap --- scripts/setup-ai-workspace-all-in-one.sh | 91 ++++++++++++- tests/setup-ai-workspace-all-in-one-test.sh | 143 ++++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/scripts/setup-ai-workspace-all-in-one.sh b/scripts/setup-ai-workspace-all-in-one.sh index f24f268..57f2c85 100755 --- a/scripts/setup-ai-workspace-all-in-one.sh +++ b/scripts/setup-ai-workspace-all-in-one.sh @@ -169,7 +169,7 @@ dynamic_parallel_job_limit() { fi load_average="$(one_minute_load_average)" - load_ceiling="$(awk -v load="$load_average" 'BEGIN { value=int(load); if (load > value) value++; print value }')" + load_ceiling="$(awk -v average="$load_average" 'BEGIN { value=int(average); if (average > value) value++; print value }')" dynamic_limit=$((hard_limit - load_ceiling)) if [ "$dynamic_limit" -lt 1 ]; then dynamic_limit=1 @@ -711,6 +711,40 @@ validate_offline_package_target() { fi } +validate_offline_package_requirements() { + local root=$1 + local target=$2 + + case "$target" in + "ubuntu 26.04 "*) + if ! compgen -G "$root/packages/apt/npm_*.deb" >/dev/null; then + warn "Ubuntu 26.04 offline package is missing the required standalone npm package." + return 1 + fi + ;; + esac +} + +refresh_offline_package_repositories() { + local root=$1 + local repo_dir branch + + offline_mode_is_force && return + command -v git >/dev/null 2>&1 || return + curl -m 3 -sI https://github.com >/dev/null 2>&1 || return + + for repo_dir in "$root/repos/xworkspace-console" "$root/repos/playbooks"; do + [ -d "$repo_dir/.git" ] || continue + branch="$(git -C "$repo_dir" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ -n "$branch" ] || continue + info "Refreshing packaged $(basename "$repo_dir") checkout from origin/$branch..." + if ! git -C "$repo_dir" fetch origin "$branch" >/dev/null 2>&1 || + ! git -C "$repo_dir" reset --hard "origin/$branch" >/dev/null 2>&1; then + warn "Unable to refresh packaged $(basename "$repo_dir") checkout; using bundled revision." + fi + done +} + run_offline_installer() { local root=$1 local target=$2 @@ -718,11 +752,17 @@ run_offline_installer() { local env_args=( "AI_WORKSPACE_OFFLINE_ACTIVE=true" "AI_WORKSPACE_DEPLOYMENT_LOCK_HELD=true" + # The bundled repositories can retain the release builder's uid. Keep + # this exception scoped to the offline installer process and children. + "GIT_CONFIG_COUNT=1" + "GIT_CONFIG_KEY_0=safe.directory" + "GIT_CONFIG_VALUE_0=*" ) local env_name [ -f "$installer" ] || return 1 validate_offline_package_target "$root" "$target" + refresh_offline_package_repositories "$root" chmod +x "$installer" for env_name in \ @@ -746,6 +786,8 @@ run_offline_installer() { ANSIBLE_VAULT_PASSWORD \ AI_WORKSPACE_AUTH_TOKEN_FILE \ AI_WORKSPACE_VAULT_PASSWORD_FILE \ + XWORKSPACE_CONSOLE_USER \ + XWORKSPACE_CONSOLE_HOME \ XWORKSPACE_CONSOLE_SOURCE_REPO \ XWORKSPACE_CONSOLE_SOURCE_VERSION \ AI_WORKSPACE_APT_LOCK_TIMEOUT; do @@ -792,6 +834,10 @@ try_bootstrap_from_offline_package() { offline_fail_or_fallback "Unable to prepare offline package from $source." return 1 } + if ! validate_offline_package_requirements "$root" "$target"; then + offline_fail_or_fallback "Offline package requirements are incomplete for this host." + return 1 + fi if run_offline_installer "$root" "$target"; then return 0 fi @@ -799,6 +845,41 @@ try_bootstrap_from_offline_package() { error "Offline package installer failed after making deployment changes; online fallback was not started." } +linux_default_console_user() { + if [ -n "${XWORKSPACE_CONSOLE_USER:-}" ]; then + printf '%s\n' "$XWORKSPACE_CONSOLE_USER" + elif [ "$(id -u)" -eq 0 ]; then + printf 'ubuntu\n' + else + id -un + fi +} + +linux_default_console_home() { + local user=$1 + if [ -n "${XWORKSPACE_CONSOLE_HOME:-}" ]; then + printf '%s\n' "$XWORKSPACE_CONSOLE_HOME" + elif command -v getent >/dev/null 2>&1 && getent passwd "$user" >/dev/null 2>&1; then + getent passwd "$user" | cut -d: -f6 + elif [ "$user" = "root" ]; then + printf '/root\n' + else + printf '/home/%s\n' "$user" + fi +} + +append_linux_console_identity_vars() { + local console_user=$1 + local console_home=$2 + + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_user=$console_user") + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_home=$console_home") + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_root=$console_home/.local/state/ai-workspace") + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_config_dir=$console_home/.config/xworkspace") + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_scripts_dir=$console_home/.local/state/ai-workspace/scripts") + ANSIBLE_EXTRA_VARS+=("-e" "xworkspace_console_repo_dir=$console_home/xworkspace-console") +} + resolve_unified_auth_token() { local token="${AI_WORKSPACE_AUTH_TOKEN:-}" if [ -z "$token" ]; then token="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-}"; fi @@ -1630,6 +1711,9 @@ if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null install_prerequisites "$OS_NAME" fi +# Git may have been installed after the early best-effort configuration above. +git config --global --add safe.directory '*' || true + export XWORKSPACE_CONSOLE_PUBLIC_ACCESS="${XWORKSPACE_CONSOLE_PUBLIC_ACCESS:-false}" export XWORKMATE_BRIDGE_PUBLIC_ACCESS="${XWORKMATE_BRIDGE_PUBLIC_ACCESS:-true}" export GATEWAY_OPENCLAW_PUBLIC_ACCESS="${GATEWAY_OPENCLAW_PUBLIC_ACCESS:-false}" @@ -1897,6 +1981,11 @@ if [ "$(detect_os)" = "darwin" ]; then ANSIBLE_EXTRA_VARS+=("-e" "gateway_openclaw_home=$HOME") ANSIBLE_EXTRA_VARS+=("-e" "gateway_openclaw_compile_cache_dir=$HOME/.cache/openclaw-compile-cache") ANSIBLE_EXTRA_VARS+=("-e" "gateway_openclaw_service_path=$DARWIN_SERVICE_PATH") +else + LINUX_CONSOLE_USER="$(linux_default_console_user)" + LINUX_CONSOLE_HOME="$(linux_default_console_home "$LINUX_CONSOLE_USER")" + info "Deploying AI Workspace runtime as $LINUX_CONSOLE_USER under $LINUX_CONSOLE_HOME..." + append_linux_console_identity_vars "$LINUX_CONSOLE_USER" "$LINUX_CONSOLE_HOME" fi # Export environment fallbacks for roles/scripts that read environment directly. diff --git a/tests/setup-ai-workspace-all-in-one-test.sh b/tests/setup-ai-workspace-all-in-one-test.sh index c276104..cf2c27e 100755 --- a/tests/setup-ai-workspace-all-in-one-test.sh +++ b/tests/setup-ai-workspace-all-in-one-test.sh @@ -70,6 +70,133 @@ test_non_root_without_sudo_fails_cleanly() ( run_as_root apt-get update -y ) +test_forced_offline_mode_does_not_refresh_repositories() ( + # shellcheck disable=SC2329 + git() { + fail "forced offline mode invoked git" + } + AI_WORKSPACE_OFFLINE_MODE=force refresh_offline_package_repositories /nonexistent +) + +test_auto_offline_mode_refreshes_packaged_repositories() ( + package_root="$(mktemp -d)" + mkdir -p "$package_root/repos/xworkspace-console/.git" "$package_root/repos/playbooks/.git" + git_log="$(mktemp)" + # shellcheck disable=SC2329 + curl() { + return 0 + } + # shellcheck disable=SC2329 + git() { + printf '%s\n' "$*" >> "$git_log" + if [ "${3:-}" = "symbolic-ref" ]; then + printf 'main\n' + fi + } + + AI_WORKSPACE_OFFLINE_MODE=auto refresh_offline_package_repositories "$package_root" + [ "$(grep -c 'fetch origin main' "$git_log")" -eq 2 ] || fail "packaged repositories were not fetched" + [ "$(grep -c 'reset --hard origin/main' "$git_log")" -eq 2 ] || fail "packaged repositories were not updated" + rm -rf "$package_root" "$git_log" +) + +test_ubuntu_2604_offline_package_requires_npm() ( + package_root="$(mktemp -d)" + mkdir -p "$package_root/packages/apt" + if validate_offline_package_requirements "$package_root" "ubuntu 26.04 amd64" 2>/dev/null; then + fail "Ubuntu 26.04 package without npm was accepted" + fi + touch "$package_root/packages/apt/npm_9.2.0_all.deb" + validate_offline_package_requirements "$package_root" "ubuntu 26.04 amd64" + validate_offline_package_requirements "$package_root" "debian 12 amd64" + rm -rf "$package_root" +) + +test_dynamic_parallel_limit_avoids_awk_reserved_names() ( + # shellcheck disable=SC2329 + online_cpu_count() { printf '4\n'; } + # shellcheck disable=SC2329 + one_minute_load_average() { printf '1.2\n'; } + [ "$(AI_WORKSPACE_MAX_PARALLEL_JOBS=auto dynamic_parallel_job_limit)" = "6" ] || fail "dynamic parallel limit was calculated incorrectly" +) + +test_offline_installer_gets_scoped_git_config() ( + installer_root="$(mktemp -d)" + mkdir -p "$installer_root/scripts" + cat > "$installer_root/scripts/ai-workspace-offline-install.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "${GIT_CONFIG_COUNT:-}" = "1" ] +[ "${GIT_CONFIG_KEY_0:-}" = "safe.directory" ] +[ "${GIT_CONFIG_VALUE_0:-}" = "*" ] +EOF + chmod +x "$installer_root/scripts/ai-workspace-offline-install.sh" + # shellcheck disable=SC2329 + validate_offline_package_target() { :; } + # shellcheck disable=SC2329 + id() { + [ "${1:-}" = "-u" ] && printf '0\n' + } + run_offline_installer "$installer_root" "debian 13 amd64" + rm -rf "$installer_root" +) + +test_linux_root_defaults_to_ubuntu_home() ( + # shellcheck disable=SC2329 + id() { + if [ "${1:-}" = "-u" ]; then + printf '0\n' + elif [ "${1:-}" = "-un" ]; then + printf 'root\n' + fi + } + # shellcheck disable=SC2329 + getent() { + return 1 + } + + user="$(linux_default_console_user)" + home="$(linux_default_console_home "$user")" + [ "$user" = "ubuntu" ] || fail "root Linux default user was not ubuntu" + [ "$home" = "/home/ubuntu" ] || fail "root Linux default home was not /home/ubuntu" +) + +test_linux_non_root_uses_current_user_home() ( + # shellcheck disable=SC2329 + id() { + if [ "${1:-}" = "-u" ]; then + printf '501\n' + elif [ "${1:-}" = "-un" ]; then + printf 'shenlan\n' + fi + } + # shellcheck disable=SC2329 + getent() { + [ "${1:-}" = "passwd" ] && [ "${2:-}" = "shenlan" ] || return 1 + printf 'shenlan:x:501:20::/Users/shenlan:/bin/zsh\n' + } + + user="$(linux_default_console_user)" + home="$(linux_default_console_home "$user")" + [ "$user" = "shenlan" ] || fail "non-root Linux default user was not current user" + [ "$home" = "/Users/shenlan" ] || fail "non-root Linux default home did not come from passwd" +) + +test_linux_identity_vars_can_be_overridden() ( + export XWORKSPACE_CONSOLE_USER=deploy + export XWORKSPACE_CONSOLE_HOME=/srv/deploy + user="$(linux_default_console_user)" + home="$(linux_default_console_home "$user")" + [ "$user" = "deploy" ] || fail "explicit console user was ignored" + [ "$home" = "/srv/deploy" ] || fail "explicit console home was ignored" + + ANSIBLE_EXTRA_VARS=() + append_linux_console_identity_vars "$user" "$home" + printf '%s\n' "${ANSIBLE_EXTRA_VARS[@]}" | grep -q '^xworkspace_console_user=deploy$' || fail "console user extra var missing" + printf '%s\n' "${ANSIBLE_EXTRA_VARS[@]}" | grep -q '^xworkspace_console_home=/srv/deploy$' || fail "console home extra var missing" + printf '%s\n' "${ANSIBLE_EXTRA_VARS[@]}" | grep -q '^xworkspace_console_repo_dir=/srv/deploy/xworkspace-console$' || fail "console repo extra var missing" +) + test_root_does_not_require_sudo printf 'ok - root execution does not require sudo\n' test_non_root_uses_sudo @@ -81,3 +208,19 @@ set -e [ "$privilege_status" -ne 0 ] || fail "non-root execution without sudo unexpectedly succeeded" printf '%s' "$privilege_error" | grep -q "Root privileges are required" || fail "missing sudo error was not actionable" printf 'ok - missing sudo reports a privilege error\n' +test_forced_offline_mode_does_not_refresh_repositories +printf 'ok - forced offline mode does not refresh packaged repositories\n' +test_auto_offline_mode_refreshes_packaged_repositories +printf 'ok - auto offline mode refreshes packaged repositories\n' +test_ubuntu_2604_offline_package_requires_npm +printf 'ok - Ubuntu 26.04 offline package requires npm\n' +test_dynamic_parallel_limit_avoids_awk_reserved_names +printf 'ok - dynamic parallel limit is compatible with modern gawk\n' +test_offline_installer_gets_scoped_git_config +printf 'ok - offline installer receives scoped Git ownership compatibility\n' +test_linux_root_defaults_to_ubuntu_home +printf 'ok - Linux root deployment defaults to ubuntu home\n' +test_linux_non_root_uses_current_user_home +printf 'ok - Linux non-root deployment uses passwd home\n' +test_linux_identity_vars_can_be_overridden +printf 'ok - Linux deployment identity can be overridden\n'