Merge pull request #511 from miaowmint/feat/auto-install-docker

feat(mobile): auto-install Docker for Android container backend

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jubaoliang
2026-09-02 09:24:47 +00:00
co-authored by Cursor
11 changed files with 1584 additions and 13 deletions
+2
View File
@@ -9,6 +9,8 @@
### 新增
- 飞书频道改为官方 `lark-oapi` 扫码创建应用,凭据自动回填(不再走浏览器 CDP)
- 远程手机:主机缺少 Docker 时自动安装——纯测速选择安装源(官方 download.docker.com 与境内镜像竞速,官方最快则不设 DOWNLOAD_URL),通过**内置的官方 get.docker.com 脚本**(`scripts/linux/v1.0/install-docker.sh`,含 TencentOS/OpenCloudOS releasever 映射等增强)安装;安装后按腾讯云镜像加速可达性决定是否写入 `registry-mirrors`(不检测地区,连不上则跳过)。自动安装失败时回退为提示用户手动安装。
- Docker 自动安装报错兼容:脚本输出尾部按已知失败模式(不支持的发行版、无 root 权限、网络/curl 失败、包管理器锁死、GPG 密钥、磁盘满、已有 Docker)追加本地化修复提示;脚本启动失败、长时间无输出(卡死看门狗)、脚本缺失均有独立提示;重启失败提示手动启动命令。
### 修复
+3 -3
View File
@@ -4837,7 +4837,7 @@
"pickDevice": "Select a device first",
"devicePlaceholder": "Select device",
"needsInstall": "Container install required",
"needsInstallDesc": "This host uses a container Android backend. One-click install pulls and starts the container (Docker required on the host).",
"needsInstallDesc": "This host uses a container Android backend. One-click install pulls and starts the container; Docker is installed automatically when missing (manual install required if that fails).",
"install": "Install container",
"installing": "Starting install…",
"installProgress": "Installing Android container…",
@@ -4845,8 +4845,8 @@
"installFailed": "Container install failed. Check the log and retry.",
"installRetry": "Retry install",
"installCancelHint": "Install request cancelled. The server may still be installing — refresh status later.",
"installStep1": "Make sure Docker is installed and usable on this host",
"installStep2": "Click Install container to pull and start the Android container",
"installStep1": "Click Install container; Docker is installed automatically when missing",
"installStep2": "Wait for the Android container to be pulled and started",
"installStep3": "When install finishes, refresh status, then click Connect",
"needsDevice": "No device connected",
"needsDeviceDesc": "Start an Android emulator or connect a phone via USB, then refresh.",
+3 -3
View File
@@ -4976,7 +4976,7 @@
"pickDevice": "请先选择设备",
"devicePlaceholder": "选择设备",
"needsInstall": "需要安装容器",
"needsInstallDesc": "此主机使用容器 Android 后端。可一键拉取并启动容器(需本机已安装 Docker)。",
"needsInstallDesc": "此主机使用容器 Android 后端。可一键拉取并启动容器;若未安装 Docker 将自动尝试安装(失败时需手动安装)。",
"install": "安装容器",
"installing": "正在启动安装…",
"installProgress": "正在安装 Android 容器…",
@@ -4984,8 +4984,8 @@
"installFailed": "容器安装失败,请查看日志后重试",
"installRetry": "重新安装",
"installCancelHint": "已取消安装请求,服务端可能仍在继续安装,请稍后刷新状态。",
"installStep1": "确认主机已安装并可使用 Docker",
"installStep2": "点击「安装容器」,拉取并启动 Android 容器",
"installStep1": "点击「安装容器」;若主机未安装 Docker 将自动安装",
"installStep2": "等待拉取并启动 Android 容器",
"installStep3": "安装完成后刷新状态,再点击「连接」",
"needsDevice": "未连接设备",
"needsDeviceDesc": "请启动 Android 模拟器或通过 USB 连接手机,然后刷新。",
@@ -100,7 +100,7 @@ export default function RemotePhoneIdleGuide({
: needsInstall
? t(
"remoteAndroid.needsInstallDesc",
"此主机使用容器 Android 后端。可一键拉取并启动容器(需本机已安装 Docker)。",
"此主机使用容器 Android 后端。可一键拉取并启动容器;若未安装 Docker 将自动尝试安装(失败时需手动安装)。",
)
: needsDevice
? t(
@@ -135,13 +135,13 @@ export default function RemotePhoneIdleGuide({
{
label: t(
"remoteAndroid.installStep1",
"确认主机已安装并可使用 Docker",
"点击「安装容器」;若主机未安装 Docker 将自动安装",
),
},
{
label: t(
"remoteAndroid.installStep2",
"点击「安装容器」,拉取并启动 Android 容器",
"等待拉取并启动 Android 容器",
),
},
{
@@ -1106,7 +1106,7 @@ export default function RemoteAndroidPage({
)}
description={t(
"remoteAndroid.needsInstallDesc",
"此主机使用容器 Android 后端。可一键拉取并启动容器(需本机已安装 Docker)。",
"此主机使用容器 Android 后端。可一键拉取并启动容器;若未安装 Docker 将自动尝试安装(失败时需手动安装)。",
)}
action={
<Button
+25
View File
@@ -614,6 +614,31 @@
"error_script_missing": "Mobile install script is missing from the Octop package.",
"error_docker_missing": "Docker is required to install the Android container backend.",
"error_command_failed": "Command failed (exit {exit_code}).",
"docker_missing_auto_install": "Docker is not installed. Trying to install it automatically (official get.docker.com script, source picked by latency race)…",
"docker_auto_install_ok": "Docker installed successfully.",
"docker_auto_install_failed": "Automatic Docker install failed.",
"docker_source_official": "Latency race: official download.docker.com is the fastest; using the official install source.",
"docker_source_selected": "Latency race: fastest source is {source} ({delay}s); it will be passed to the official install script via DOWNLOAD_URL.",
"docker_source_fallback": "Could not measure source latency; running the official install script with its default source.",
"docker_install_log_start": "Running the bundled official Docker install script…",
"docker_install_script_failed": "Docker install script failed (exit {exit_code}).",
"docker_install_script_missing": "The bundled Docker install script is missing from the Octop package.",
"docker_install_spawn_failed": "Could not start the Docker install script: {error}",
"docker_install_stalled": "The Docker install script produced no output for too long and was stopped. Check the network or package manager state, then retry.",
"docker_hint_unsupported_distro": "This Linux distribution is not supported by the Docker install script. Install Docker manually, see https://docs.docker.com/engine/install/",
"docker_hint_no_root": "The install script needs root or passwordless sudo. Grant it, or install Docker manually.",
"docker_hint_already_installed": "An existing Docker was detected by the script. If it is broken, uninstall it first, then retry.",
"docker_hint_network": "The install script could not download packages (network / curl / DNS). Check connectivity or configure a proxy, then retry.",
"docker_hint_pkg_manager": "The system package manager failed (lock held, broken index, or interrupted step). Resolve it (e.g. sudo apt clean && sudo apt update), then retry.",
"docker_hint_gpg_key": "The Docker repository GPG key could not be fetched or verified. Check the network / mirror, then retry.",
"docker_hint_disk_full": "Not enough disk space to install Docker. Free up space and retry.",
"docker_mirror_reachable": "Tencent Cloud registry mirror is reachable; configuring it as a registry mirror.",
"docker_mirror_unreachable": "Tencent Cloud registry mirror is unreachable; keeping the default registry.",
"docker_mirror_skipped": "Registry mirror is already configured (or daemon.json is unreadable); leaving it unchanged.",
"docker_mirror_configured": "Registry mirror written to /etc/docker/daemon.json (previous file backed up as daemon.json.backup).",
"docker_restart_ok": "Docker daemon restarted.",
"docker_restart_ready": "Docker daemon is up and responding.",
"docker_restart_failed": "Failed to restart Docker (no systemd/service manager, or the service did not come back). The mirror config takes effect after the next Docker restart — start it manually if needed (systemctl restart docker or service docker restart).",
"handoff_message": "Please complete this step on the device: {reason}",
"handoff_default": "manual action required on the device"
}
+25
View File
@@ -614,6 +614,31 @@
"error_script_missing": "Octop 包中缺少移动设备安装脚本。",
"error_docker_missing": "安装 Android 容器后端需要 Docker。",
"error_command_failed": "命令失败(退出码 {exit_code})。",
"docker_missing_auto_install": "未检测到 Docker,正在尝试自动安装(官方 get.docker.com 脚本,按延迟测速选择安装源)…",
"docker_auto_install_ok": "Docker 安装成功。",
"docker_auto_install_failed": "Docker 自动安装失败。",
"docker_source_official": "测速完成:官方源 download.docker.com 延迟最低,使用官方安装源。",
"docker_source_selected": "测速完成:延迟最低的源是 {source}({delay} 秒),将通过 DOWNLOAD_URL 传给官方安装脚本。",
"docker_source_fallback": "无法测速各安装源,将直接使用官方安装脚本的默认源。",
"docker_install_log_start": "正在运行内置的官方 Docker 安装脚本…",
"docker_install_script_failed": "Docker 安装脚本执行失败(退出码 {exit_code})。",
"docker_install_script_missing": "Octop 包中缺少内置的 Docker 安装脚本。",
"docker_install_spawn_failed": "无法启动 Docker 安装脚本:{error}",
"docker_install_stalled": "Docker 安装脚本长时间无输出,已停止。请检查网络或包管理器状态后重试。",
"docker_hint_unsupported_distro": "当前 Linux 发行版不受 Docker 安装脚本支持,请参考 https://docs.docker.com/engine/install/ 手动安装。",
"docker_hint_no_root": "安装脚本需要 root 或免密 sudo 权限。请授权后重试,或手动安装 Docker。",
"docker_hint_already_installed": "脚本检测到主机已有 Docker。若其已损坏,请先卸载后重试。",
"docker_hint_network": "安装脚本无法下载软件包(网络 / curl / DNS 问题)。请检查网络或配置代理后重试。",
"docker_hint_pkg_manager": "系统包管理器执行失败(锁被占用、索引损坏或步骤中断)。请处理后重试(如 sudo apt clean && sudo apt update)。",
"docker_hint_gpg_key": "无法获取或校验 Docker 仓库的 GPG 密钥。请检查网络 / 镜像源后重试。",
"docker_hint_disk_full": "磁盘空间不足,无法安装 Docker。请清理空间后重试。",
"docker_mirror_reachable": "腾讯云镜像加速可达,将配置为 registry mirror。",
"docker_mirror_unreachable": "腾讯云镜像加速不可达,保持默认 registry 配置。",
"docker_mirror_skipped": "registry-mirrors 已配置(或 daemon.json 无法读取),保持不变。",
"docker_mirror_configured": "镜像加速已写入 /etc/docker/daemon.json(原文件已备份为 daemon.json.backup)。",
"docker_restart_ok": "Docker 守护进程已重启。",
"docker_restart_ready": "Docker 守护进程已就绪并正常响应。",
"docker_restart_failed": "Docker 重启失败(可能无 systemd/service 管理器,或服务未能恢复)。镜像加速配置将在下次 Docker 重启后生效——必要时请手动启动(systemctl restart docker 或 service docker restart)。",
"handoff_message": "请在设备上完成此步骤:{reason}",
"handoff_default": "需要在设备上手动操作"
}
+351
View File
@@ -0,0 +1,351 @@
"""Automatic Docker install for the Android container backend.
Design notes:
- No geo detection: the install source is picked by a pure latency race that
includes the official https://download.docker.com (fastest wins; when the
official source wins, no ``DOWNLOAD_URL`` override is passed).
- Installs via the bundled official get.docker.com script (vendored copy at
scripts/linux/v1.0/install-docker.sh) so hosts the online script does not
support (TencentOS / OpenCloudOS releasever mapping, offline curl failures,
etc.) still install, and we never pipe the network straight into ``sh``.
- Registry-mirror config probes Tencent Cloud's mirror reachability (again no
geo check) and runs only right after a fresh install, so a daemon that may
already be running user containers is never restarted.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import re
import shutil
import subprocess
from collections.abc import AsyncIterator
from pathlib import Path
from octop.i18n import tr
from octop.infra.utils.posix_compat import geteuid
# Official source first; latency race decides (no region detection).
_DOCKER_CE_SOURCES = (
"https://download.docker.com",
"https://mirrors.aliyun.com/docker-ce",
"https://mirrors.tencent.com/docker-ce",
"https://mirrors.163.com/docker-ce",
"https://mirrors.cernet.edu.cn/docker-ce",
)
_OFFICIAL_SOURCE = "https://download.docker.com"
_TENCENT_MIRROR_HOST = "mirror.ccs.tencentyun.com"
_TENCENT_MIRROR_URL = f"https://{_TENCENT_MIRROR_HOST}/"
_DAEMON_JSON = Path("/etc/docker/daemon.json")
_SPEED_TEST_ITERATIONS = 3
_SPEED_TEST_TIMEOUT = 5.0
_PROBE_TIMEOUT = 3.0
_DAEMON_READY_TIMEOUT = 10.0
_DAEMON_WAIT_AFTER_RESTART = 30.0
_DAEMON_WAIT_INTERVAL = 2.0
# The vendored script sleeps 20s when docker already exists, and package
# manager steps can take minutes on slow links; keep the read patient.
_SCRIPT_READLINE_TIMEOUT = 600.0
_SCRIPT_TAIL_LINES = 40
# Known fatal script outputs → friendly localized hints. Matched
# case-insensitively against the tail of the script output.
_SCRIPT_ERROR_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(r"unsupported (?:operating system|distribution)", re.I),
"docker_hint_unsupported_distro",
),
(re.compile(r"needs the ability to run commands as root", re.I), "docker_hint_no_root"),
(re.compile(r"unable to find either .sudo. or .su.", re.I), "docker_hint_no_root"),
(re.compile(r"command appears to already exist", re.I), "docker_hint_already_installed"),
(re.compile(r"curl", re.I), "docker_hint_network"),
(
re.compile(r"(?:apt|apt-get|dpkg|dnf|yum).*?(?:error|failed|lock)", re.I),
"docker_hint_pkg_manager",
),
(
re.compile(r"^E: .*(?:lock|unable to locate|not available)", re.I | re.M),
"docker_hint_pkg_manager",
),
(re.compile(r"key.*(?:expired|not found|rejected)", re.I), "docker_hint_gpg_key"),
(re.compile(r"no space left on device", re.I), "docker_hint_disk_full"),
)
def _log(locale: str, key: str, **kwargs: object) -> str:
text = tr(f"mobile.{key}", locale)
return text.format(**kwargs) if kwargs else text
def bundled_scripts_dir() -> Path:
return Path(__file__).resolve().parent / "scripts" / "linux" / "v1.0"
def bundled_install_script() -> Path:
"""Path of the vendored official Docker install script."""
return bundled_scripts_dir() / "install-docker.sh"
def _classify_script_error(lines: list[str]) -> str | None:
"""Map the tail of script output onto a friendly hint key, if any."""
tail = "\n".join(lines[-_SCRIPT_TAIL_LINES:])
for pattern, key in _SCRIPT_ERROR_HINTS:
if pattern.search(tail):
return key
return None
async def _measure_source_delay(url: str) -> float | None:
"""Average curl time_total over a few probes; None when unreachable."""
if shutil.which("curl") is None:
return None
times: list[float] = []
for _ in range(_SPEED_TEST_ITERATIONS):
try:
proc = await asyncio.create_subprocess_exec(
"curl",
"-o",
"/dev/null",
"-s",
"-w",
"%{time_total}",
"--connect-timeout",
"3",
"-m",
str(_SPEED_TEST_TIMEOUT),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return None
out, _ = await proc.communicate()
if proc.returncode != 0:
return None
try:
times.append(float(out.decode("utf-8", errors="replace").strip()))
except ValueError:
return None
return sum(times) / len(times)
async def select_download_source() -> tuple[str | None, float | None]:
"""Latency-race all sources.
Returns ``(mirror_url, delay)`` for the fastest mirror, ``(None, delay)``
when the official source wins (caller must not set ``DOWNLOAD_URL``), or
``(None, None)`` when nothing is reachable.
"""
best_source: str | None = None
best_delay: float | None = None
for source in _DOCKER_CE_SOURCES:
delay = await _measure_source_delay(source)
if delay is None:
continue
if best_delay is None or delay < best_delay:
best_delay = delay
best_source = None if source == _OFFICIAL_SOURCE else source
return best_source, best_delay
def can_install_without_password() -> bool:
"""Whether this process may install packages (root or passwordless sudo)."""
if geteuid() == 0:
return True
if shutil.which("sudo") is None:
return False
try:
proc = subprocess.run(
["sudo", "-n", "true"],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return False
return proc.returncode == 0
async def docker_daemon_ready(timeout: float = _DAEMON_READY_TIMEOUT) -> bool:
"""Docker CLI present and the daemon answers."""
if shutil.which("docker") is None:
return False
try:
proc = await asyncio.create_subprocess_exec(
"docker",
"version",
"--format",
"{{.Server.Version}}",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.communicate(), timeout=timeout)
except (TimeoutError, OSError):
return False
return proc.returncode == 0
async def _probe_tencent_mirror() -> bool:
"""Plain TCP reachability check for the Tencent Cloud registry mirror."""
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(_TENCENT_MIRROR_HOST, 443), timeout=_PROBE_TIMEOUT
)
except (TimeoutError, OSError):
return False
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
return True
def _merge_registry_mirror(daemon_json: Path, mirror: str) -> bool:
"""Add ``registry-mirrors`` to daemon.json unless already configured.
Backs up the previous file as ``daemon.json.backup``. Returns True when the
file was written; False when left untouched (already configured, or the
existing file is unreadable/corrupt).
"""
config: dict[str, object] = {}
if daemon_json.exists():
try:
loaded = json.loads(daemon_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
if not isinstance(loaded, dict):
return False
config = loaded
if "registry-mirrors" in config:
return False
if not daemon_json.parent.exists():
return False
if daemon_json.exists():
try:
shutil.copy2(daemon_json, daemon_json.parent / f"{daemon_json.name}.backup")
except OSError:
return False
config["registry-mirrors"] = [mirror]
try:
daemon_json.write_text(
json.dumps(config, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
except OSError:
return False
return True
async def _restart_docker_daemon() -> bool:
for cmd in (
("systemctl", "restart", "docker"),
("service", "docker", "restart"),
):
if shutil.which(cmd[0]) is None:
continue
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.communicate(), timeout=_DAEMON_READY_TIMEOUT)
except (TimeoutError, OSError):
continue
if proc.returncode == 0:
return True
return False
async def _wait_for_daemon(seconds: float) -> bool:
deadline = asyncio.get_running_loop().time() + seconds
while True:
if await docker_daemon_ready():
return True
if asyncio.get_running_loop().time() >= deadline:
return False
await asyncio.sleep(_DAEMON_WAIT_INTERVAL)
async def auto_install_docker_stream(*, locale: str = "en") -> AsyncIterator[str]:
"""Yield human-readable log lines for the automatic Docker install.
Success is judged by the caller via :func:`docker_daemon_ready`, so this
generator only reports what happened.
"""
# 0) The vendored official script must ship with this Octop install.
script = bundled_install_script()
if not script.is_file():
yield _log(locale, "docker_install_script_missing")
return
# 1) Latency-race install sources (official included, no geo detection).
source, delay = await select_download_source()
if delay is not None and source is None:
yield _log(locale, "docker_source_official")
elif source is not None and delay is not None:
yield _log(locale, "docker_source_selected", source=source, delay=round(delay, 3))
else:
yield _log(locale, "docker_source_fallback")
# 2) Run the bundled official install script; a winning mirror rides on
# DOWNLOAD_URL (the script honours a preset DOWNLOAD_URL env var).
env = {k: v for k, v in os.environ.items() if k != "DOWNLOAD_URL"}
if source is not None:
env["DOWNLOAD_URL"] = source
yield _log(locale, "docker_install_log_start")
output_lines: list[str] = []
try:
proc = await asyncio.create_subprocess_exec(
"sh",
str(script),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=env,
)
except OSError as exc:
yield _log(locale, "docker_install_spawn_failed", error=str(exc))
return
assert proc.stdout is not None
while True:
try:
line = await asyncio.wait_for(proc.stdout.readline(), timeout=_SCRIPT_READLINE_TIMEOUT)
except TimeoutError:
proc.kill()
await proc.wait()
yield _log(locale, "docker_install_stalled")
return
if not line:
break
text = line.decode("utf-8", errors="replace").strip()
if text:
output_lines.append(text)
yield text
code = await proc.wait()
if code != 0:
yield _log(locale, "docker_install_script_failed", exit_code=code)
hint_key = _classify_script_error(output_lines)
if hint_key is not None:
yield _log(locale, hint_key)
return
# 3) Registry mirror: only probe reachability, only for this fresh install.
if await _probe_tencent_mirror():
yield _log(locale, "docker_mirror_reachable")
if _merge_registry_mirror(_DAEMON_JSON, _TENCENT_MIRROR_URL):
yield _log(locale, "docker_mirror_configured")
if await _restart_docker_daemon():
yield _log(locale, "docker_restart_ok")
if await _wait_for_daemon(_DAEMON_WAIT_AFTER_RESTART):
yield _log(locale, "docker_restart_ready")
else:
yield _log(locale, "docker_restart_failed")
else:
yield _log(locale, "docker_restart_failed")
else:
yield _log(locale, "docker_mirror_skipped")
else:
yield _log(locale, "docker_mirror_unreachable")
+857
View File
@@ -0,0 +1,857 @@
#!/bin/sh
set -e
# Docker Engine for Linux installation script.
#
# This script is intended as a convenient way to configure docker's package
# repositories and to install Docker Engine, This script is not recommended
# for production environments. Before running this script, make yourself familiar
# with potential risks and limitations, and refer to the installation manual
# at https://docs.docker.com/engine/install/ for alternative installation methods.
#
# The script:
#
# - Requires `root` or `sudo` privileges to run.
# - Attempts to detect your Linux distribution and version and configure your
# package management system for you.
# - Doesn't allow you to customize most installation parameters.
# - Installs dependencies and recommendations without asking for confirmation.
# - Installs the latest stable release (by default) of Docker CLI, Docker Engine,
# Docker Buildx, Docker Compose, containerd, and runc. When using this script
# to provision a machine, this may result in unexpected major version upgrades
# of these packages. Always test upgrades in a test environment before
# deploying to your production systems.
# - Isn't designed to upgrade an existing Docker installation. When using the
# script to update an existing installation, dependencies may not be updated
# to the expected version, resulting in outdated versions.
#
# Source code is available at https://github.com/docker/docker-install/
#
# Usage
# ==============================================================================
#
# To install the latest stable versions of Docker CLI, Docker Engine, and their
# dependencies:
#
# 1. download the script
#
# $ curl -fsSL https://get.docker.com -o install-docker.sh
#
# 2. verify the script's content
#
# $ cat install-docker.sh
#
# 3. run the script with --dry-run to verify the steps it executes
#
# $ sh install-docker.sh --dry-run
#
# 4. run the script either as root, or using sudo to perform the installation.
#
# $ sudo sh install-docker.sh
#
# Command-line options
# ==============================================================================
#
# --version <VERSION>
# Use the --version option to install a specific version, for example:
#
# $ sudo sh install-docker.sh --version 23.0
#
# --channel <stable|test>
#
# Use the --channel option to install from an alternative installation channel.
# The following example installs the latest versions from the "test" channel,
# which includes pre-releases (alpha, beta, rc):
#
# $ sudo sh install-docker.sh --channel test
#
# Alternatively, use the script at https://test.docker.com, which uses the test
# channel as default.
#
# --mirror <Aliyun|AzureChinaCloud>
#
# Use the --mirror option to install from a mirror supported by this script.
# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and
# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example:
#
# $ sudo sh install-docker.sh --mirror AzureChinaCloud
#
# --setup-repo
#
# Use the --setup-repo option to configure Docker's package repositories without
# installing Docker packages. This is useful when you want to add the repository
# but install packages separately:
#
# $ sudo sh install-docker.sh --setup-repo
#
# Automatic Service Start
#
# By default, this script automatically starts the Docker daemon and enables the docker
# service after installation if systemd is used as init.
#
# If you prefer to start the service manually, use the --no-autostart option:
#
# $ sudo sh install-docker.sh --no-autostart
#
# Note: Starting the service requires appropriate privileges to manage system services.
#
# Installing docker-sbx
#
# Set the SBX environment variable to "1" to also install the docker-sbx
# package alongside the regular Docker Engine packages:
#
# $ curl -fsSL https://get.docker.com | sudo SBX=1 sh
#
# ==============================================================================
# Git commit from https://github.com/docker/docker-install when
# the script was uploaded (Should only be modified by upload job):
SCRIPT_COMMIT_SHA="42dcae692436f34526524ed46d3b32885c9355f5"
# strip "v" prefix if present
VERSION="${VERSION#v}"
# The channel to install from:
# * stable
# * test
DEFAULT_CHANNEL_VALUE="stable"
if [ -z "$CHANNEL" ]; then
CHANNEL=$DEFAULT_CHANNEL_VALUE
fi
DEFAULT_DOWNLOAD_URL="https://download.docker.com"
if [ -z "$DOWNLOAD_URL" ]; then
DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL
fi
DEFAULT_REPO_FILE="docker-ce.repo"
if [ -z "$REPO_FILE" ]; then
REPO_FILE="$DEFAULT_REPO_FILE"
# Automatically default to a staging repo fora
# a staging download url (download-stage.docker.com)
case "$DOWNLOAD_URL" in
*-stage*) REPO_FILE="docker-ce-staging.repo";;
esac
fi
mirror=''
DRY_RUN=${DRY_RUN:-}
REPO_ONLY=${REPO_ONLY:-0}
NO_AUTOSTART=${NO_AUTOSTART:-0}
SBX=${SBX:-0}
# Provide a helpful usage statement when --help or any invalid argument is passed
# to the script. Exit code deliberately not included here as error depends on
# argument provided.
usage() {
echo
echo "USAGE: "
echo " ${0} [--channel <stable|test>] [--mirror <Aliyun|AzureChinaCloud>] [--version <VERSION>] [--setup-repo] [--no-autostart] [--dry-run] [--help]"
echo
}
while [ $# -gt 0 ]; do
case "$1" in
--channel)
CHANNEL="$2"
shift
;;
--dry-run)
DRY_RUN=1
;;
--mirror)
mirror="$2"
shift
;;
--version)
VERSION="${2#v}"
shift
;;
--setup-repo)
REPO_ONLY=1
shift
;;
--no-autostart)
NO_AUTOSTART=1
;;
--help)
usage
exit 0
;;
--*)
echo "Illegal option $1"
usage
exit 1
;;
esac
shift $(( $# > 0 ? 1 : 0 ))
done
case "$mirror" in
Aliyun)
DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce"
;;
AzureChinaCloud)
DOWNLOAD_URL="https://mirror.azure.cn/docker-ce"
;;
"")
;;
*)
>&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'."
exit 1
;;
esac
case "$CHANNEL" in
stable|test)
;;
*)
>&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test."
exit 1
;;
esac
command_exists() {
command -v "$@" > /dev/null 2>&1
}
# version_gte checks if the version specified in $VERSION is at least the given
# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success)
# if $VERSION is either unset (=latest) or newer or equal than the specified
# version, or returns 1 (fail) otherwise.
#
# examples:
#
# VERSION=23.0
# version_gte 23.0 // 0 (success)
# version_gte 20.10 // 0 (success)
# version_gte 19.03 // 0 (success)
# version_gte 26.1 // 1 (fail)
version_gte() {
if [ -z "$VERSION" ]; then
return 0
fi
version_compare "$VERSION" "$1"
}
# version_compare compares two version strings (either SemVer (Major.Minor.Path),
# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer
# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release
# (-alpha/-beta) are not taken into account
#
# examples:
#
# version_compare 23.0.0 20.10 // 0 (success)
# version_compare 23.0 20.10 // 0 (success)
# version_compare 20.10 19.03 // 0 (success)
# version_compare 20.10 20.10 // 0 (success)
# version_compare 19.03 20.10 // 1 (fail)
version_compare() (
set +x
yy_a="$(echo "$1" | cut -d'.' -f1)"
yy_b="$(echo "$2" | cut -d'.' -f1)"
if [ "$yy_a" -lt "$yy_b" ]; then
return 1
fi
if [ "$yy_a" -gt "$yy_b" ]; then
return 0
fi
mm_a="$(echo "$1" | cut -d'.' -f2)"
mm_b="$(echo "$2" | cut -d'.' -f2)"
# trim leading zeros to accommodate CalVer
mm_a="${mm_a#0}"
mm_b="${mm_b#0}"
if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then
return 1
fi
return 0
)
is_dry_run() {
if [ -z "$DRY_RUN" ]; then
return 1
else
return 0
fi
}
is_wsl() {
case "$(uname -r)" in
*microsoft* ) true ;; # WSL 2
*Microsoft* ) true ;; # WSL 1
* ) false;;
esac
}
is_darwin() {
case "$(uname -s)" in
*darwin* ) true ;;
*Darwin* ) true ;;
* ) false;;
esac
}
deprecation_notice() {
distro=$1
distro_version=$2
echo
printf "\033[91;1mDEPRECATION WARNING\033[0m\n"
printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version"
echo " No updates or security fixes will be released for this distribution, and users are recommended"
echo " to upgrade to a currently maintained version of $distro."
echo
printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue."
echo
sleep 10
}
get_distribution() {
lsb_dist=""
# Every system that we officially support has /etc/os-release
if [ -r /etc/os-release ]; then
lsb_dist="$(. /etc/os-release && echo "$ID")"
fi
# Normalize Fedora Asahi Remix to fedora
if [ "$lsb_dist" = "fedora-asahi-remix" ]; then
lsb_dist="fedora"
fi
# Returning an empty string here should be alright since the
# case statements don't act unless you provide an actual value
echo "$lsb_dist"
}
start_docker_daemon() {
# Use systemctl if available (for systemd-based systems)
if command_exists systemctl; then
is_dry_run || >&2 echo "Using systemd to manage Docker service"
if (
is_dry_run || set -x
$sh_c "systemctl enable --now docker.service 2>/dev/null"
); then
is_dry_run || echo "INFO: Docker daemon enabled and started" >&2
else
is_dry_run || echo "WARNING: unable to enable the docker service" >&2
fi
else
# No service management available (container environment)
if ! is_dry_run; then
>&2 echo "Note: Running in a container environment without service management"
>&2 echo "Docker daemon cannot be started automatically in this environment"
>&2 echo "The Docker packages have been installed successfully"
fi
fi
>&2 echo
}
echo_docker_as_nonroot() {
if is_dry_run; then
return
fi
if command_exists docker && [ -e /var/run/docker.sock ]; then
(
set -x
$sh_c 'docker version'
) || true
fi
# intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output
echo
echo "================================================================================"
echo
if version_gte "20.10"; then
echo "To run Docker as a non-privileged user, consider setting up the"
echo "Docker daemon in rootless mode for your user:"
echo
echo " dockerd-rootless-setuptool.sh install"
echo
echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode."
echo
fi
echo
echo "To run the Docker daemon as a fully privileged service, but granting non-root"
echo "users access, refer to https://docs.docker.com/go/daemon-access/"
echo
echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent"
echo " to root access on the host. Refer to the 'Docker daemon attack surface'"
echo " documentation for details: https://docs.docker.com/go/attack-surface/"
echo
echo "================================================================================"
echo
}
# Check if this is a forked Linux distro
check_forked() {
# Check for lsb_release command existence, it usually exists in forked distros
if command_exists lsb_release; then
# Check if the `-u` option is supported
set +e
lsb_release -a -u > /dev/null 2>&1
lsb_release_exit_code=$?
set -e
# Check if the command has exited successfully, it means we're in a forked distro
if [ "$lsb_release_exit_code" = "0" ]; then
# Print info about current distro
cat <<-EOF
You're using '$lsb_dist' version '$dist_version'.
EOF
# Get the upstream release info
lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]')
dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]')
# Print info about upstream distro
cat <<-EOF
Upstream release is '$lsb_dist' version '$dist_version'.
EOF
else
if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then
if [ "$lsb_dist" = "osmc" ]; then
# OSMC runs Raspbian
lsb_dist=raspbian
else
# We're Debian and don't even know it!
lsb_dist=debian
fi
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13|14|forky)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
fi
fi
fi
}
do_install() {
echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA"
if [ "$REPO_ONLY" != "1" ] && command_exists docker; then
cat >&2 <<-'EOF'
Warning: the "docker" command appears to already exist on this system.
If you already have Docker installed, this script can cause trouble, which is
why we're displaying this warning and provide the opportunity to cancel the
installation.
If you installed the current Docker package using this script and are using it
again to update Docker, you can ignore this message, but be aware that the
script resets any custom changes in the deb and rpm repo configuration
files to match the parameters passed to the script.
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
user="$(id -un 2>/dev/null || true)"
sh_c='sh -c'
if [ "$user" != 'root' ]; then
if command_exists sudo; then
sh_c='sudo -E sh -c'
elif command_exists su; then
sh_c='su -c'
else
cat >&2 <<-'EOF'
Error: this installer needs the ability to run commands as root.
We are unable to find either "sudo" or "su" available to make this happen.
EOF
exit 1
fi
fi
if is_dry_run; then
sh_c="echo"
fi
# perform some very rudimentary platform detection
lsb_dist=$( get_distribution )
lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')"
if is_wsl; then
echo
echo "WSL DETECTED: We recommend using Docker Desktop for Windows."
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/"
echo
cat >&2 <<-'EOF'
You may press Ctrl+C now to abort this script.
EOF
( set -x; sleep 20 )
fi
case "$lsb_dist" in
ubuntu)
if command_exists lsb_release; then
dist_version="$(lsb_release --codename | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then
dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")"
fi
;;
debian|raspbian)
dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')"
case "$dist_version" in
13)
dist_version="trixie"
;;
12)
dist_version="bookworm"
;;
11)
dist_version="bullseye"
;;
10)
dist_version="buster"
;;
9)
dist_version="stretch"
;;
8)
dist_version="jessie"
;;
esac
;;
centos|rhel|rocky|tencentos|opencloudos)
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
*)
if command_exists lsb_release; then
dist_version="$(lsb_release --release | cut -f2)"
fi
if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then
dist_version="$(. /etc/os-release && echo "$VERSION_ID")"
fi
;;
esac
# Check if this is a forked Linux distro
check_forked
# Print deprecation warnings for distro versions that recently reached EOL,
# but may still be commonly used (especially LTS versions).
case "$lsb_dist.$dist_version" in
centos.8|centos.7|rhel.7)
deprecation_notice "$lsb_dist" "$dist_version"
;;
debian.buster|debian.stretch|debian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
raspbian.buster|raspbian.stretch|raspbian.jessie)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty)
deprecation_notice "$lsb_dist" "$dist_version"
;;
ubuntu.questing|ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic)
deprecation_notice "$lsb_dist" "$dist_version"
;;
fedora.*)
if [ "$dist_version" -lt 43 ]; then
deprecation_notice "$lsb_dist" "$dist_version"
fi
;;
esac
# Run setup for each distro accordingly
case "$lsb_dist" in
ubuntu|debian|raspbian)
pre_reqs="ca-certificates curl"
apt_repo_lsb_dist="$lsb_dist"
# Docker does not publish a Raspbian Trixie repo; use Debian Trixie instead.
if [ "$lsb_dist" = "raspbian" ] && [ "$dist_version" = "trixie" ]; then
apt_repo_lsb_dist="debian"
fi
apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$apt_repo_lsb_dist $dist_version $CHANNEL"
(
if ! is_dry_run; then
set -x
fi
$sh_c 'apt-get -qq update >/dev/null'
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null"
$sh_c 'install -m 0755 -d /etc/apt/keyrings'
$sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$apt_repo_lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc"
$sh_c "chmod a+r /etc/apt/keyrings/docker.asc"
$sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list"
$sh_c 'apt-get -qq update >/dev/null'
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
cli_pkg_version=""
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
# Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')"
search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
pkg_version="$($sh_c "$search_command")"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst apt-cache madison results"
echo
exit 1
fi
pkg_version="=$pkg_version"
if version_gte "18.09"; then
search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3"
echo "INFO: $search_command"
cli_pkg_version="$($sh_c "$search_command")"
if [ -n "$cli_pkg_version" ]; then
cli_pkg_version="=$cli_pkg_version"
fi
fi
fi
fi
(
pkgs="docker-ce${pkg_version}"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
pkgs="$pkgs docker-ce-cli${cli_pkg_version} containerd.io"
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin"
fi
if version_gte "28.2"; then
pkgs="$pkgs docker-model-plugin"
fi
if [ "$SBX" = "1" ]; then
pkgs="$pkgs docker-sbx"
fi
if ! is_dry_run; then
set -x
fi
apt_flags="-y -qq"
if [ -n "$pkg_version" ]; then
apt_flags="$apt_flags --allow-downgrades"
fi
$sh_c "DEBIAN_FRONTEND=noninteractive apt-get $apt_flags install $pkgs >/dev/null"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
centos|fedora|rhel|rocky|tencentos|opencloudos)
if [ "$lsb_dist" = "tencentos" ] || [ "$lsb_dist" = "opencloudos" ]; then
repo_file_url="$DOWNLOAD_URL/linux/centos/$REPO_FILE"
else
repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE"
fi
(
if ! is_dry_run; then
set -x
fi
if command_exists dnf5; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'"
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\""
$sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\""
fi
$sh_c "dnf makecache"
elif command_exists dnf; then
$sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "dnf config-manager --add-repo $repo_file_url"
if [ "$lsb_dist" = "tencentos" ] || [ "$lsb_dist" = "opencloudos" ]; then
# TencentOS / OpenCloudOS 的 $releasever 在 Docker 官方 centos 仓库中没有对应目录(如 tencentos 4 -> 404),
# 需映射到 ABI 兼容的 el 版本:TencentOS 3 -> el8 / 4 (glibc 2.38) -> el9;OpenCloudOS 8 -> el8 / 9 -> el9
el_releasever=9
case "$lsb_dist" in
tencentos)
case "$dist_version" in 3*) el_releasever=8 ;; esac
;;
opencloudos)
case "$dist_version" in 8*) el_releasever=8 ;; esac
;;
esac
$sh_c "sed -i 's|\$releasever|$el_releasever|g' /etc/yum.repos.d/docker-ce.repo"
# 部分镜像站(如腾讯)提供的 repo 文件内部仍写死官方域名,
# 当下载源非官方时,同步替换 repo 内的域名,否则 makecache 仍会去访问官方源
if [ "$DOWNLOAD_URL" != "$DEFAULT_DOWNLOAD_URL" ]; then
$sh_c "sed -i 's|https://download.docker.com|$DOWNLOAD_URL|g' /etc/yum.repos.d/docker-ce.repo"
fi
fi
if [ "$CHANNEL" != "stable" ]; then
$sh_c "dnf config-manager --set-disabled \"docker-ce-*\""
$sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\""
fi
$sh_c "dnf makecache"
else
$sh_c "yum -y -q install yum-utils"
$sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo"
$sh_c "yum-config-manager --add-repo $repo_file_url"
if [ "$lsb_dist" = "tencentos" ] || [ "$lsb_dist" = "opencloudos" ]; then
# TencentOS / OpenCloudOS 的 $releasever 在 Docker 官方 centos 仓库中没有对应目录(如 tencentos 4 -> 404),
# 需映射到 ABI 兼容的 el 版本:TencentOS 3 -> el8 / 4 (glibc 2.38) -> el9;OpenCloudOS 8 -> el8 / 9 -> el9
el_releasever=9
case "$lsb_dist" in
tencentos)
case "$dist_version" in 3*) el_releasever=8 ;; esac
;;
opencloudos)
case "$dist_version" in 8*) el_releasever=8 ;; esac
;;
esac
$sh_c "sed -i 's|\$releasever|$el_releasever|g' /etc/yum.repos.d/docker-ce.repo"
# 部分镜像站(如腾讯)提供的 repo 文件内部仍写死官方域名,
# 当下载源非官方时,同步替换 repo 内的域名,否则 makecache 仍会去访问官方源
if [ "$DOWNLOAD_URL" != "$DEFAULT_DOWNLOAD_URL" ]; then
$sh_c "sed -i 's|https://download.docker.com|$DOWNLOAD_URL|g' /etc/yum.repos.d/docker-ce.repo"
fi
fi
if [ "$CHANNEL" != "stable" ]; then
$sh_c "yum-config-manager --disable \"docker-ce-*\""
$sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\""
fi
$sh_c "yum makecache"
fi
)
if [ "$REPO_ONLY" = "1" ]; then
exit 0
fi
pkg_version=""
cli_pkg_version=""
if command_exists dnf; then
pkg_manager="dnf"
pkg_manager_flags="-y -q --best"
else
pkg_manager="yum"
pkg_manager_flags="-y -q"
fi
if [ -n "$VERSION" ]; then
if is_dry_run; then
echo "# WARNING: VERSION pinning is not supported in DRY_RUN"
else
if [ "$lsb_dist" = "fedora" ]; then
pkg_suffix="fc$dist_version"
else
pkg_suffix="el"
fi
pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix"
search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
echo "INFO: Searching repository for VERSION '$VERSION'"
echo "INFO: $search_command"
pkg_version="$($sh_c "$search_command")"
if [ -z "$pkg_version" ]; then
echo
echo "ERROR: '$VERSION' not found amongst $pkg_manager list results"
echo
exit 1
fi
# Cut out the epoch and prefix with a '-'
pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)"
if version_gte "18.09"; then
# older versions don't support a cli package
search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'"
cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)"
fi
fi
fi
(
pkgs="docker-ce$pkg_version"
if version_gte "18.09"; then
# older versions didn't ship the cli and containerd as separate packages
if [ -n "$cli_pkg_version" ]; then
pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io"
else
pkgs="$pkgs docker-ce-cli containerd.io"
fi
fi
if version_gte "20.10"; then
pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version"
fi
if version_gte "23.0"; then
pkgs="$pkgs docker-buildx-plugin docker-model-plugin"
fi
if [ "$SBX" = "1" ]; then
pkgs="$pkgs docker-sbx"
fi
if ! is_dry_run; then
set -x
fi
$sh_c "$pkg_manager $pkg_manager_flags install $pkgs"
)
if [ "$NO_AUTOSTART" != "1" ]; then
start_docker_daemon
fi
echo_docker_as_nonroot
exit 0
;;
sles)
echo "Effective v27.5, please consult SLES distro statement for s390x support."
exit 1
;;
*)
if [ -z "$lsb_dist" ]; then
if is_darwin; then
echo
echo "ERROR: Unsupported operating system 'macOS'"
echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop"
echo
exit 1
fi
fi
echo
echo "ERROR: Unsupported distribution '$lsb_dist'"
echo
exit 1
;;
esac
exit 1
}
# wrapped up in a function so that we have some protection against only getting
# half the file during "curl | sh"
do_install
+21 -3
View File
@@ -15,6 +15,11 @@ from typing import Literal
from octop.config import OctopConfig
from octop.i18n import tr
from octop.infra.mobile.adb import adb_connect, find_adb, list_devices
from octop.infra.mobile.docker_install import (
auto_install_docker_stream,
can_install_without_password,
docker_daemon_ready,
)
SetupState = Literal["needs_device", "needs_install", "ready", "unsupported"]
MobileBackend = Literal["physical", "redroid", "emulator", "none"]
@@ -213,9 +218,22 @@ async def install_mobile_stream(*, locale: str = "en") -> AsyncIterator[str]:
yield _sse({"done": False, "error": "script_missing"})
return
if not _docker_available():
yield _sse({"log": _mobile_log(locale, "error_docker_missing")})
yield _sse({"done": False, "error": "docker_missing"})
return
# Try to install Docker automatically; fall back to manual guidance.
installed = False
if platform.system().lower() == "linux" and can_install_without_password():
yield _sse({"log": _mobile_log(locale, "docker_missing_auto_install")})
async for msg in auto_install_docker_stream(locale=locale):
yield _sse({"log": msg})
# Authoritative check: the daemon answering beats script exit codes
# (e.g. Docker was installed just now by another process).
installed = await docker_daemon_ready()
else:
yield _sse({"log": _mobile_log(locale, "error_docker_missing")})
if not installed:
yield _sse({"log": _mobile_log(locale, "docker_auto_install_failed")})
yield _sse({"done": False, "error": "docker_missing"})
return
yield _sse({"log": _mobile_log(locale, "docker_auto_install_ok")})
yield _sse({"log": _mobile_log(locale, "install_log_start")})
proc = await asyncio.create_subprocess_exec(
"bash",
+293
View File
@@ -0,0 +1,293 @@
"""tests/unit/mobile/test_docker_install.py"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from octop.infra.mobile import docker_install
from octop.infra.mobile.docker_install import (
_merge_registry_mirror,
_probe_tencent_mirror,
select_download_source,
)
def _with_measure(delays: dict[str, float | None]):
def deco(fn):
async def wrapper():
async def fake(url: str) -> float | None:
return delays[url]
with patch.object(docker_install, "_measure_source_delay", fake):
return await fn()
wrapper.__name__ = fn.__name__
return wrapper
return deco
@pytest.mark.asyncio
@_with_measure(
{
"https://download.docker.com": 0.1,
"https://mirrors.aliyun.com/docker-ce": 0.4,
"https://mirrors.tencent.com/docker-ce": 0.5,
"https://mirrors.163.com/docker-ce": 0.6,
"https://mirrors.cernet.edu.cn/docker-ce": 0.7,
}
)
async def test_select_download_source_official_wins_no_override() -> None:
source, delay = await select_download_source()
assert source is None # official wins → no DOWNLOAD_URL override
assert delay == pytest.approx(0.1)
@pytest.mark.asyncio
@_with_measure(
{
"https://download.docker.com": 0.9,
"https://mirrors.aliyun.com/docker-ce": 0.2,
"https://mirrors.tencent.com/docker-ce": 0.5,
"https://mirrors.163.com/docker-ce": 0.6,
"https://mirrors.cernet.edu.cn/docker-ce": 0.7,
}
)
async def test_select_download_source_mirror_wins() -> None:
source, delay = await select_download_source()
assert source == "https://mirrors.aliyun.com/docker-ce"
assert delay == pytest.approx(0.2)
@pytest.mark.asyncio
@_with_measure(
{
"https://download.docker.com": None,
"https://mirrors.aliyun.com/docker-ce": None,
"https://mirrors.tencent.com/docker-ce": None,
"https://mirrors.163.com/docker-ce": None,
"https://mirrors.cernet.edu.cn/docker-ce": None,
}
)
async def test_select_download_source_all_unreachable_falls_back() -> None:
source, delay = await select_download_source()
assert source is None
assert delay is None
def test_merge_registry_mirror_fresh_file(tmp_path: Path) -> None:
daemon = tmp_path / "daemon.json"
assert _merge_registry_mirror(daemon, "https://mirror.example/") is True
data = json.loads(daemon.read_text(encoding="utf-8"))
assert data["registry-mirrors"] == ["https://mirror.example/"]
def test_merge_registry_mirror_backs_up_existing(tmp_path: Path) -> None:
daemon = tmp_path / "daemon.json"
daemon.write_text('{"log-level": "warn"}', encoding="utf-8")
assert _merge_registry_mirror(daemon, "https://mirror.example/") is True
backup = tmp_path / "daemon.json.backup"
assert json.loads(backup.read_text(encoding="utf-8")) == {"log-level": "warn"}
data = json.loads(daemon.read_text(encoding="utf-8"))
assert data["registry-mirrors"] == ["https://mirror.example/"]
assert data["log-level"] == "warn" # other keys preserved
def test_merge_registry_mirror_skips_when_configured(tmp_path: Path) -> None:
daemon = tmp_path / "daemon.json"
daemon.write_text('{"registry-mirrors": ["https://old/"]}', encoding="utf-8")
assert _merge_registry_mirror(daemon, "https://mirror.example/") is False
assert json.loads(daemon.read_text(encoding="utf-8")) == {"registry-mirrors": ["https://old/"]}
def test_merge_registry_mirror_skips_corrupt_file(tmp_path: Path) -> None:
daemon = tmp_path / "daemon.json"
daemon.write_text("{not json", encoding="utf-8")
assert _merge_registry_mirror(daemon, "https://mirror.example/") is False
assert daemon.read_text(encoding="utf-8") == "{not json" # left untouched
@pytest.mark.asyncio
async def test_probe_tencent_mirror_unreachable() -> None:
# 127.0.0.1:1 → connection refused → probe returns False quickly.
with patch.object(docker_install, "_TENCENT_MIRROR_HOST", "127.0.0.1"):
assert await _probe_tencent_mirror() is False
@pytest.mark.asyncio
async def test_install_mobile_stream_auto_installs_docker_then_proceeds() -> None:
from octop.infra.mobile import setup as mobile_setup
async def fake_auto_install(*, locale: str = "en"):
yield "docker install log line"
class FakeProc:
def __init__(self) -> None:
self.stdout = self
self._lines = [b"container install log line"]
async def readline(self) -> bytes:
return self._lines.pop(0) if self._lines else b""
async def wait(self) -> int:
return 0
async def fake_exec(*args: object, **kwargs: object) -> FakeProc:
return FakeProc()
async def collect():
events = []
async for evt in mobile_setup.install_mobile_stream(locale="en"):
events.append(evt)
return events
script = mobile_setup.bundled_scripts_dir() / "install.sh"
with (
patch.object(mobile_setup, "_docker_available", return_value=False),
patch.object(mobile_setup, "platform") as fake_platform,
patch.object(mobile_setup, "can_install_without_password", return_value=True),
patch.object(mobile_setup, "auto_install_docker_stream", side_effect=fake_auto_install),
patch.object(mobile_setup, "docker_daemon_ready", return_value=True),
patch.object(mobile_setup, "bundled_scripts_dir", return_value=script.parent),
patch.object(mobile_setup.asyncio, "create_subprocess_exec", new=fake_exec),
):
fake_platform.system.return_value = "Linux"
events = await collect()
logs = [
json.loads(e.removeprefix("data: ").strip())["log"]
for e in events
if "log" in json.loads(e.removeprefix("data: ").strip())
]
assert any("docker install log line" in line for line in logs)
done = [json.loads(e.removeprefix("data: ").strip()) for e in events][-1]
assert done.get("done") is True
@pytest.mark.asyncio
async def test_install_mobile_stream_auto_install_fails_reports_docker_missing() -> None:
from octop.infra.mobile import setup as mobile_setup
async def fake_auto_install(*, locale: str = "en"):
yield "failed log"
async def collect():
events = []
async for evt in mobile_setup.install_mobile_stream(locale="en"):
events.append(evt)
return events
with (
patch.object(mobile_setup, "_docker_available", return_value=False),
patch.object(mobile_setup, "platform") as fake_platform,
patch.object(mobile_setup, "can_install_without_password", return_value=True),
patch.object(mobile_setup, "auto_install_docker_stream", side_effect=fake_auto_install),
patch.object(mobile_setup, "docker_daemon_ready", return_value=False),
):
fake_platform.system.return_value = "Linux"
events = await collect()
last = [json.loads(e.removeprefix("data: ").strip()) for e in events][-1]
assert last.get("done") is False
assert last.get("error") == "docker_missing"
@pytest.mark.asyncio
async def test_install_mobile_stream_no_sudo_reports_docker_missing() -> None:
from octop.infra.mobile import setup as mobile_setup
async def collect():
events = []
async for evt in mobile_setup.install_mobile_stream(locale="en"):
events.append(evt)
return events
with (
patch.object(mobile_setup, "_docker_available", return_value=False),
patch.object(mobile_setup, "platform") as fake_platform,
patch.object(mobile_setup, "can_install_without_password", return_value=False),
):
fake_platform.system.return_value = "Linux"
events = await collect()
last = [json.loads(e.removeprefix("data: ").strip()) for e in events][-1]
assert last.get("done") is False
assert last.get("error") == "docker_missing"
def test_classify_script_error_maps_known_failures() -> None:
assert (
docker_install._classify_script_error(["ERROR: Unsupported distribution 'sles'"])
== "docker_hint_unsupported_distro"
)
assert (
docker_install._classify_script_error(
["Error: this installer needs the ability to run commands as root."]
)
== "docker_hint_no_root"
)
assert (
docker_install._classify_script_error(
["+ curl -fsSL https://example/gpg", "curl: (7) Failed to connect"]
)
== "docker_hint_network"
)
assert (
docker_install._classify_script_error(["E: Could not get lock /var/lib/dpkg/lock-frontend"])
== "docker_hint_pkg_manager"
)
assert (
docker_install._classify_script_error(["No space left on device"])
== "docker_hint_disk_full"
)
# Unrelated output → no hint.
assert docker_install._classify_script_error(["All good"]) is None
def test_bundled_install_script_ships_with_package() -> None:
script = docker_install.bundled_install_script()
assert script.is_file()
assert script.name == "install-docker.sh"
@pytest.mark.asyncio
async def test_auto_install_missing_script_reports_error() -> None:
async def collect():
return [line async for line in docker_install.auto_install_docker_stream(locale="en")]
with patch.object(docker_install, "bundled_install_script", return_value=Path("/nonexistent")):
lines = await collect()
assert any("missing from the Octop package" in line for line in lines)
@pytest.mark.asyncio
async def test_auto_install_script_failure_emits_friendly_hint() -> None:
class FakeProc:
def __init__(self) -> None:
self.stdout = self
self._lines = [b"Installing...", b"ERROR: Unsupported distribution 'xyz'"]
async def readline(self) -> bytes:
return self._lines.pop(0) if self._lines else b""
async def wait(self) -> int:
return 1
async def fake_exec(*args: object, **kwargs: object) -> FakeProc:
return FakeProc()
async def collect():
return [line async for line in docker_install.auto_install_docker_stream(locale="en")]
with (
patch.object(docker_install, "select_download_source", return_value=(None, 0.1)),
patch.object(docker_install.asyncio, "create_subprocess_exec", new=fake_exec),
):
lines = await collect()
assert any("exit 1" in line for line in lines)
assert any("not supported by the Docker install script" in line for line in lines)