diff --git a/docs/configuration.md b/docs/configuration.md index a8d30d318..1306c3e8f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,7 +24,7 @@ Installer flags in [installation options](./getting-started/install-options.md) 1. It runs the `init` service, which checks that the data volume belongs to the installed release and finishes initializing it if the first start stopped early. 2. It runs `oac-core check-config` with the `.env` you edited and changes no service if a value is invalid. -3. It runs `docker compose up -d --wait` with the images already on the host. Compose recreates only the services whose configuration changed and removes containers the release no longer defines. +3. It runs `docker compose up -d --wait` with the images already on the host. Compose recreates only the services whose configuration changed and removes containers the release no longer defines. If a service does not become healthy, the message names it and the command that prints its log. Use `docker compose ps` to check the services. See [stop and restart](./getting-started/operations.md#stop-and-restart) for what a restart interrupts. diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 9fa35a4d3..3219ca165 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -45,7 +45,7 @@ Stop with `docker compose stop` using the same files and environment. Back up th ## Process settings -These flags are written to `.env` once. After installation, edit that file and run `oac apply`. Rerunning the installer on an existing installation ignores these flags and starts it with the saved settings. +These flags are written to `.env` once. After installation, edit that file and run `oac apply`. Rerunning the installer on an existing installation ignores these flags, names those that differ from the saved settings, and starts it with the saved settings. | Flag | `.env` variable | | --- | --- | diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index c390872f4..0804e9736 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -55,15 +55,15 @@ The script downloads that release's Compose files, checks their SHA-256, and: 2. prepares the [installation directory](../configuration.md#installation-directory), `~/.oac/core`, with `.env` and the native `oac` command (`oac.exe` on Windows); 3. starts the services with Docker Compose. Web serves the console on port 8080 and forwards `/v1`, `/api/v1` and `/docs` to Core. Core and PostgreSQL are not published. -The installer prints the console address and Core key. After signing in, configure execution resources and create Projects in Web. +The installer prints the console address and, on a fresh install only, the Core key; a rerun or upgrade prints `oac core-key --show` instead. When the installer chose the console address itself, it adds that browsers must open exactly that address and how to change it with `OAC_PUBLIC_URL`. After signing in, configure execution resources and create Projects in Web. -Downloads and configuration checks happen before the installation directory is published. After that, failures preserve the configuration and data and report the failed step; inspect it with `docker compose logs --tail 100` in the installation directory. Fix the reported cause and rerun the same command, or specify the installation directory: +Downloads and configuration checks happen before the installation directory is published. After that, failures preserve the configuration and data. A rejected setting or data volume is reported by itself: fix it as the message says. A service that does not become healthy within three minutes is named together with the `docker compose logs --tail 50` command for it; fix the cause and rerun the same command. Ctrl-C prints `Interrupted; rerun the installer to continue.` and leaves the installation ready for that rerun. Rerun the same command, or specify the installation directory: ```bash curl -fsSL https://openagentcore.dev/install.sh | bash -s -- --install-dir "$HOME/.oac/core" ``` -Use your chosen directory if it differs. A rerun uses the saved Compose file and settings; installation flags only apply to new directories. Existing images and containers are reused; missing images are downloaded. To change settings, edit `.env` and run `oac apply`. To move to a newer release, follow [Upgrade](./operations.md#upgrade). +Use your chosen directory if it differs. A rerun uses the saved Compose file and settings; installation flags only apply to new directories, and the installer names the ones it ignored. Existing images and containers are reused; missing images are downloaded. To change settings, edit `.env` and run `oac apply`. To move to a newer release, follow [Upgrade](./operations.md#upgrade). For insufficient space or quota, free space on the filesystem named by the error. Image-loading failures can also require space in Docker's storage, which may be on a different filesystem. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index fe3ba1d73..e8bc7b441 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -17,9 +17,9 @@ docker compose -f ~/.oac/core/compose.yaml ps | `docker compose ps` | Shows the services. Run it in the installation directory | | `docker compose start` | Starts the services | | `docker compose stop` | Stops the services. Data, nodes and sandboxes are kept | -| `oac apply` | Checks the data volume and `.env`, then starts the services and recreates those whose configuration changed; see [how oac apply works](../configuration.md#how-oac-apply-works). A failed check changes no service | +| `oac apply` | Checks the data volume and `.env`, then starts the services and recreates those whose configuration changed; see [how oac apply works](../configuration.md#how-oac-apply-works). A failed check changes no service. On success it prints `Services are running with the current settings.` | | `oac core-key [--show]` | Identifies the key location in the data volume, or prints the key with `--show` | -| `oac rotate-core-key` | Replaces the Core key and restarts Core and Web | +| `oac rotate-core-key` | Replaces the Core key and restarts Core and Web, then says how to read the new key | | `docker compose down` | Removes the containers. Data is kept | | `oac uninstall` | Deletes the installation and its data after confirmation; see [Uninstall](#uninstall) | @@ -216,7 +216,7 @@ Without `--upgrade`, another release's installer changes nothing and stops with The installer downloads the release's `compose.yaml` and pulls missing images. Before it replaces any file, it runs the new release's configuration check and initialization with the installation's `.env` and Compose overrides. Initialization upgrades the data volume, or refuses a downgrade. The installer then puts the new `compose.yaml` and `oac` in place and starts the services, recreating those that changed; Core migrates the database as it starts. [Deployment](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md#installation) describes each step. The restart interrupts work as any [Core restart](#stop-and-restart) does; nodes and sandboxes keep running and reconnect. -On success the installer prints the Core key and the line `Upgraded from to . Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.` +On success the installer prints the console address, `oac core-key --show` in place of the key, and the line `Upgraded from to . Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.` If a check fails, the installer prints `Upgrade stopped; the running services are unchanged` with Docker's output, and the previous release keeps serving. Fix the cause and rerun the command. Once initialization has upgraded the data volume, the previous release can no longer start on it: if a later step fails, for example with `startup failed; data retained, rerun the installer`, rerun the same command, which repeats every step safely, or [roll back](#roll-back). @@ -277,7 +277,7 @@ The project is the `oac-…` name from the list. Then delete `~/.oac/core.lock` | `This account cannot access …` | An earlier run with `sudo` left files owned by root. Run the `sudo chown` command the message prints, then rerun as your own account | | `Docker Compose 2.26 or newer is required` | Install or update the Docker Compose plugin | | `Docker could not pull …` | The Docker daemon cannot reach the registry. Configure its proxy or a registry mirror, or use the [offline archive](./install-options.md#offline-hosts) | -| `startup failed; data retained, rerun the installer` | Read the service logs above it, fix the cause, then [resume installation](./install.md#install); keep the data | +| `startup failed; data retained, rerun the installer` | The message lists the services that are not healthy and the `docker compose logs --tail 50` command for them. Fix the cause, then [resume installation](./install.md#install); keep the data. After `oac apply`, the same message says to run `oac apply` again | | Web answers 403 `Forbidden` | The browser host is not `OAC_PUBLIC_URL`. Open that origin; a reverse proxy must pass the original Host | | Web shows that Core is unavailable (502) | Core is stopped or failing: `docker compose ps`, then Core's log | | Session creation returns 400 `model_provider_required` | No model provider: set a [default model](../configuration.md#default-models) for the Harness, or pass one; self-hosted Sessions always pass their own | diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 9ee49aafb..6be99025d 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 51d98943e71402c79cf134e757192607c3628944369e869a69a9582e34fa8666 +source_hash: 600946b2952a270983450ee93e75fbcdb950c2249ff845c7e3fc81a6154a6910 --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -26,7 +26,7 @@ Core 安装的每项设置都恰好只有一个归属位置,分属以下三类 1. 它运行 `init` 服务,确认数据卷属于已安装的版本;如果首次启动中途停止,就完成初始化。 2. 它用你改过的 `.env` 运行 `oac-core check-config`。值无效时不改动任何服务。 -3. 它用主机上已有的镜像运行 `docker compose up -d --wait`。Compose 只重新创建配置有变化的服务,并移除该版本不再定义的容器。 +3. 它用主机上已有的镜像运行 `docker compose up -d --wait`。Compose 只重新创建配置有变化的服务,并移除该版本不再定义的容器。若有服务未变为健康,消息会指出该服务和打印其日志的命令。 用 `docker compose ps` 检查服务。重启会中断哪些操作,见[停止和重启](getting-started/operations.md#stop-and-restart)。 diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index b1687cc7c..515fa4855 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- title: "安装选项" source: docs/getting-started/install-options.md -source_hash: dc50c81c07f235146acb81aa965c6d9814dc3e57e43fd9769f98713eb008da4f +source_hash: a27a6f3c7c819690fa81d5ede14c80b93446ffea33781764272dc9bec58ea942 --- [默认安装](install.md)无需任何选项。本页介绍安装选项、Compose 部署和反向代理配置。 @@ -47,7 +47,7 @@ docker compose exec web oac-web core-key ## 进程设置 {#process-settings} -这些标志只会一次性写入 `.env`。安装完成后,编辑该文件并运行 `oac apply`。对已有安装重新运行安装程序时,这些标志会被忽略,安装以已保存的设置启动。 +这些标志只会一次性写入 `.env`。安装完成后,编辑该文件并运行 `oac apply`。对已有安装重新运行安装程序时,这些标志会被忽略,安装程序会指出与已保存设置不同的标志,并以已保存的设置启动。 以下表格是由脚本自动生成的参考信息,因此保留为英文原文: | Flag | `.env` variable | diff --git a/docs/zh/getting-started/install.md b/docs/zh/getting-started/install.md index ec9786376..88216b03f 100644 --- a/docs/zh/getting-started/install.md +++ b/docs/zh/getting-started/install.md @@ -1,7 +1,7 @@ --- title: "安装 Core 和 Web" source: docs/getting-started/install.md -source_hash: de77f596cad24c26ddb2ddfa71ef104ace08dfe62d57a785b26f692df8f5a3ed +source_hash: 5589a96a188bc9d778f5bebf8cab78db7f9fbebffb1656a0b91001ac05f31d95 --- 一条命令即可在 Linux、macOS 或 Windows 上安装 Core、Web 控制台和 PostgreSQL。用 Core 密钥登录 Web,设置默认模型并签发项目 API 密钥。应用使用这些密钥调用 Core。Session 在你添加的节点上的沙箱中运行,也可以在 E2B 上运行。 @@ -57,15 +57,15 @@ curl -fsSL https://openagentcore.dev/install.sh | bash -s -- --public-url https: 2. 准备[安装目录](../configuration.md#installation-directory) `~/.oac/core`,写入 `.env` 和原生 `oac` 命令(Windows 为 `oac.exe`); 3. 用 Docker Compose 启动服务。Web 在 8080 端口提供控制台,并把 `/v1`、`/api/v1` 和 `/docs` 转发到 Core。Core 和 PostgreSQL 不发布端口。 -安装器会打印控制台地址和 Core 密钥。登录后,在 Web 中配置执行资源并创建项目。 +安装器会打印控制台地址;仅在全新安装时打印 Core 密钥,重新运行或升级则打印 `oac core-key --show`。控制台地址由安装器自行选定时,它还会提示浏览器必须打开的确切地址,以及如何用 `OAC_PUBLIC_URL` 修改。登录后,在 Web 中配置执行资源并创建项目。 -安装目录就位前,先完成下载和配置检查。之后的失败会保留配置与数据,并报告失败步骤;在安装目录运行 `docker compose logs --tail 100` 查看日志。修复报错后,重新执行同一命令,或指定安装目录即可继续: +安装目录就位前,先完成下载和配置检查。之后的失败会保留配置与数据。被拒绝的设置或数据卷会单独报告,按提示修复即可。服务三分钟内未变为健康时,会列出这些服务和查看其日志的 `docker compose logs --tail 50` 命令;修复原因后重新执行同一命令。按 Ctrl-C 会打印 `Interrupted; rerun the installer to continue.`,安装保持可继续的状态。重新执行同一命令,或指定安装目录即可继续: ```bash curl -fsSL https://openagentcore.dev/install.sh | bash -s -- --install-dir "$HOME/.oac/core" ``` -如果使用了其他目录,请替换路径。重新执行时沿用已保存的 Compose 文件和设置;安装参数只对新目录生效。已有镜像和容器直接复用,缺失镜像会重新下载。修改设置请编辑 `.env` 并运行 `oac apply`。要换到更新的发布版本,请按[升级](operations.md#upgrade)操作。 +如果使用了其他目录,请替换路径。重新执行时沿用已保存的 Compose 文件和设置;安装参数只对新目录生效,安装器会指出被忽略的参数。已有镜像和容器直接复用,缺失镜像会重新下载。修改设置请编辑 `.env` 并运行 `oac apply`。要换到更新的发布版本,请按[升级](operations.md#upgrade)操作。 空间或配额不足时,请释放错误信息所指文件系统的空间。加载镜像失败还可能需要释放 Docker 存储空间,该存储可能位于另一个文件系统。 diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 5700c6c4b..b4a265576 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- title: "运维" source: docs/getting-started/operations.md -source_hash: c4e6dc4ae4e0167591ecad9e5a8534271c523e655a95d605c5cd7f3902db4876 +source_hash: daba721bddebf8d6544ddef597390307a7b4a28f041978133acc200004812d69 --- 安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。 @@ -19,9 +19,9 @@ docker compose -f ~/.oac/core/compose.yaml ps | `docker compose ps` | 在安装目录中展示服务 | | `docker compose start` | 启动服务 | | `docker compose stop` | 停止服务。保留数据、节点和沙箱 | -| `oac apply` | 检查数据卷和 `.env`,然后启动服务,并重新创建配置有变化的服务;参见 [oac apply 的工作方式](../configuration.md#how-oac-apply-works)。校验失败时不改动任何服务 | +| `oac apply` | 检查数据卷和 `.env`,然后启动服务,并重新创建配置有变化的服务;参见 [oac apply 的工作方式](../configuration.md#how-oac-apply-works)。校验失败时不改动任何服务。成功时打印 `Services are running with the current settings.` | | `oac core-key [--show]` | 指出 Core 密钥在数据卷中的位置;加上 `--show` 时打印密钥本身 | -| `oac rotate-core-key` | 替换 Core 密钥并重启 Core 和 Web | +| `oac rotate-core-key` | 替换 Core 密钥并重启 Core 和 Web,然后说明如何读取新密钥 | | `docker compose down` | 移除容器。数据保留 | | `oac uninstall` | 确认后删除安装及其数据;参见[卸载](#uninstall) | @@ -218,7 +218,7 @@ v0.0.10 及更高版本的安装可以原地升级到更新的发布版本,并 安装程序下载该版本的 `compose.yaml` 并拉取缺失的镜像。替换任何文件之前,它使用安装的 `.env` 和 Compose 覆盖文件运行新版本的配置检查和初始化。初始化会升级数据卷,或拒绝降级。随后安装程序将新的 `compose.yaml` 和 `oac` 放到位并启动服务,重新创建有变化的服务;Core 启动时迁移数据库。[部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md#installation)说明了每个步骤。这次重启对工作的影响与任何一次 [Core 重启](#stop-and-restart)相同;节点和沙箱继续运行并重新连接。 -成功时,安装程序打印 Core 密钥以及一行 `Upgraded from to . Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.` +成功时,安装程序打印控制台地址、用 `oac core-key --show` 代替密钥,以及一行 `Upgraded from to . Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.` 如果某项检查失败,安装程序会打印 `Upgrade stopped; the running services are unchanged` 及 Docker 的输出,先前版本继续提供服务。修复原因后重新运行命令。初始化升级数据卷后,先前版本便无法再在该数据卷上启动:如果后续步骤失败(例如出现 `startup failed; data retained, rerun the installer`),重新运行同一命令,它会安全地重复每个步骤;也可以[回滚](#roll-back)。 @@ -279,7 +279,7 @@ docker compose -p down --volumes --remove-orphans | `This account cannot access …` | 之前用 `sudo` 运行留下了属于 root 的文件。运行消息给出的 `sudo chown` 命令,再以自己的账号重新运行 | | `Docker Compose 2.26 or newer is required` | 安装或更新 Docker Compose 插件 | | `Docker could not pull …` | Docker 守护进程无法访问容器注册表。配置其代理或镜像加速器,或使用[离线归档](install-options.md#offline-hosts) | -| `startup failed; data retained, rerun the installer` | 查看上方服务日志,修复原因,再[继续安装](install.md#install);不要删除数据 | +| `startup failed; data retained, rerun the installer` | 消息会列出不健康的服务和查看其日志的 `docker compose logs --tail 50` 命令。修复原因,再[继续安装](install.md#install);不要删除数据。运行 `oac apply` 时,同一消息会提示再次运行 `oac apply` | | Web 返回 403 `Forbidden` | 浏览器主机名不是 `OAC_PUBLIC_URL`。打开该源地址;反向代理必须传递原始 Host | | Web 显示 Core 不可用(502) | Core 停止或失败:先 `docker compose ps`,再查看 Core 日志 | | 创建 Session 返回 400 `model_provider_required` | 缺少模型提供商:为 Harness 设置[默认模型](../configuration.md#default-models),或显式提供;自托管 Session 始终自带提供商 | diff --git a/services/core/cmd/oac/compose.go b/services/core/cmd/oac/compose.go index 3e84b16be..81c5acced 100644 --- a/services/core/cmd/oac/compose.go +++ b/services/core/cmd/oac/compose.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "strings" ) // Runner executes docker compose in the installation directory. Tests substitute it. @@ -42,6 +43,19 @@ func (r execRunner) command(ctx context.Context, args []string) *exec.Cmd { } cmd := exec.CommandContext(ctx, "docker", append(prefix, args...)...) cmd.Dir = r.dir - cmd.Env = os.Environ() + cmd.Env = dockerEnv() return cmd } + +// dockerEnv is the environment of every Docker child. The installation's .env is the one home of OAC_* and COMPOSE_* settings, so the caller's shell cannot override them. +func dockerEnv() []string { + var env []string + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + name = strings.ToUpper(name) + if !strings.HasPrefix(name, "OAC_") && !strings.HasPrefix(name, "COMPOSE_") { + env = append(env, entry) + } + } + return env +} diff --git a/services/core/cmd/oac/install.go b/services/core/cmd/oac/install.go index 2582056ce..8cc2d91eb 100644 --- a/services/core/cmd/oac/install.go +++ b/services/core/cmd/oac/install.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "flag" "fmt" @@ -28,6 +29,8 @@ type installOptions struct { dir, version, publicURL, host string port int upgrade bool + // given names the settings flags the caller passed, which an existing installation ignores. + given []string } type installer struct { docker func(context.Context, string, ...string) ([]byte, error) @@ -48,9 +51,20 @@ func installCommand(ctx context.Context, args []string) error { flags.StringVar(&o.host, "host", "0.0.0.0", "Web bind address") flags.IntVar(&o.port, "web-port", 8080, "Web port") flags.BoolVar(&o.upgrade, "upgrade", false, "upgrade an existing installation to this release") + // The flag package's own report would repeat the error main prints. + flags.SetOutput(io.Discard) if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(os.Stderr) + flags.PrintDefaults() + } return err } + flags.Visit(func(f *flag.Flag) { + if f.Name == "host" || f.Name == "web-port" || f.Name == "public-url" { + o.given = append(o.given, f.Name) + } + }) if flags.NArg() != 0 { return errors.New("unexpected installation arguments") } @@ -66,6 +80,7 @@ func installCommand(ctx context.Context, args []string) error { func dockerOutput(ctx context.Context, dir string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = dir + cmd.Env = dockerEnv() var stderr bytes.Buffer cmd.Stderr = &stderr output, err := cmd.Output() @@ -150,7 +165,8 @@ func (i installer) install(ctx context.Context, o installOptions) error { if net.ParseIP(o.host) == nil { return errors.New("--host must be an IP address") } - if o.publicURL == "" { + detected := o.publicURL == "" + if detected { o.publicURL = "http://localhost:" + strconv.Itoa(o.port) if o.host == "0.0.0.0" { // UDP connect selects a route without transmitting a packet. @@ -186,10 +202,11 @@ func (i installer) install(ctx context.Context, o installOptions) error { } else if err != nil && !errors.Is(err, os.ErrNotExist) { return accessError(o.dir, err) } - return withLock(o.dir, func() error { return i.installLocked(ctx, o) }) + return withLock(o.dir, func() error { return i.installLocked(ctx, o, detected) }) } -func (i installer) installLocked(ctx context.Context, o installOptions) error { +// installLocked installs or resumes under the lock; detected says the console origin was not given. +func (i installer) installLocked(ctx context.Context, o installOptions, detected bool) error { info, err := i.docker(ctx, "", "info", "--format", "{{.OSType}}/{{.Architecture}}") switch { case errors.Is(err, exec.ErrNotFound): @@ -299,11 +316,12 @@ func (i installer) installLocked(ctx context.Context, o installOptions) error { if err != nil { return err } - fmt.Println("Checking and downloading images...") + fmt.Println("Checking images...") for _, image := range strings.Fields(string(images)) { if _, err := i.docker(ctx, "", "image", "inspect", image); err == nil { continue } + fmt.Printf("Pulling %s…\n", image) if _, err := i.docker(ctx, "", "pull", image); err != nil { return fmt.Errorf("Docker could not pull %s: %w\nImage pulls go through the Docker daemon, which does not use this shell's proxy settings: if the registry is unreachable, configure the daemon's proxy or a registry mirror, or install from the offline archive (Offline hosts in the installation options).", image, err) } @@ -338,34 +356,36 @@ func (i installer) installLocked(ctx context.Context, o installOptions) error { _ = os.Remove(filepath.Join(o.dir, stageMarker(o.dir))) } compose = installCompose{docker: i.docker, dir: o.dir} - fmt.Println("Starting services...") - if fresh || upgrade { - err = compose.Run(ctx, startServices...) - } else { - err = apply(ctx, compose) - } - if err != nil { - return fmt.Errorf("startup failed; data retained, rerun the installer: %w", err) + if !fresh { + if differing := i.differingSettings(o); len(differing) > 0 { + fmt.Printf("Ignored %s: they differ from the saved settings. To change them, edit .env and run %s apply.\n", strings.Join(differing, ", "), filepath.Join(o.dir, cliName())) + } } - key, err := compose.Output(ctx, "exec", "-T", "web", "/usr/local/bin/oac-web", "core-key") - if err != nil { + fmt.Println("Starting services (up to 3 minutes)…") + if err := apply(ctx, compose, o.dir, "rerun the installer"); err != nil { return err } - environment, err := compose.Output(ctx, "config", "--environment") + origin, err := consoleOrigin(ctx, compose) if err != nil { return err } - for _, line := range strings.Split(string(environment), "\n") { - if strings.HasPrefix(line, "OAC_PUBLIC_URL=") { - o.publicURL = strings.TrimPrefix(line, "OAC_PUBLIC_URL=") + command := filepath.Join(o.dir, cliName()) + fmt.Printf("\nOpenAgentCore is running.\n\nConsole: %s\n", origin) + if fresh { + key, err := compose.Output(ctx, "exec", "-T", "web", "/usr/local/bin/oac-web", "core-key") + if err != nil { + return err } + fmt.Printf("Core key: %s\n", strings.TrimSpace(string(key))) + } else { + fmt.Printf("Core key: %s core-key --show\n", command) } - fmt.Printf("\nOpenAgentCore is running.\n\nConsole: %s\nCore key: %s\nCommand: %s\n", o.publicURL, strings.TrimSpace(string(key)), filepath.Join(o.dir, cliName())) + fmt.Printf("Command: %s\n", command) if upgrade { fmt.Printf("Upgraded from %s to %s. Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.\n", short(installed), short(buildRevision)) } - if u, _ := url.Parse(o.publicURL); u != nil && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1") { - fmt.Println("For remote access, set OAC_PUBLIC_URL in .env to a reachable origin and run oac apply.") + if fresh && detected { + fmt.Printf("Browsers must open exactly %s. If they reach this host at another address, set OAC_PUBLIC_URL in .env and run %s apply.\n", origin, command) } return nil } @@ -384,6 +404,43 @@ func (i installer) portHolderHint(ctx context.Context, port int) string { return "" } +// consoleOrigin is the origin Web accepts, with the installation's defaults applied. +func consoleOrigin(ctx context.Context, compose Runner) (string, error) { + raw, err := compose.Output(ctx, "config", "--format", "json") + if err != nil { + return "", err + } + var config struct { + Services struct { + Web struct{ Environment map[string]string } + } + } + if err := json.Unmarshal(raw, &config); err != nil { + return "", err + } + return config.Services.Web.Environment["OAC_PUBLIC_URL"], nil +} + +// differingSettings names the settings flags the caller passed that disagree with the saved .env. +func (i installer) differingSettings(o installOptions) []string { + saved := map[string]string{} + raw, _ := os.ReadFile(filepath.Join(o.dir, ".env")) + for _, line := range strings.Split(string(raw), "\n") { + if name, value, ok := strings.Cut(line, "="); ok { + saved[name] = strings.Trim(value, "'") + } + } + want := map[string]string{"host": saved["OAC_HOST"], "web-port": saved["OAC_WEB_PORT"], "public-url": saved["OAC_PUBLIC_URL"]} + given := map[string]string{"host": o.host, "web-port": strconv.Itoa(o.port), "public-url": o.publicURL} + var names []string + for _, name := range o.given { + if given[name] != want[name] { + names = append(names, "--"+name) + } + } + return names +} + // releaseCompose downloads the requested release's compose.yaml, verifies its published checksum and requires it to start this installer's release. func (i installer) releaseCompose(ctx context.Context, version string) ([]byte, error) { repository := os.Getenv("OAC_REPOSITORY") @@ -456,8 +513,15 @@ type installCompose struct { files []string } +// Output reports a failure as the last line of Compose's output, which holds the cause; the progress lines before it only repeat what happened. func (c installCompose) Output(ctx context.Context, args ...string) ([]byte, error) { - return c.docker(ctx, c.dir, append(append([]string{"compose"}, c.files...), args...)...) + output, err := c.docker(ctx, c.dir, append(append([]string{"compose"}, c.files...), args...)...) + if err != nil { + if lines := strings.Split(strings.TrimSpace(err.Error()), "\n"); len(lines) > 1 { + err = errors.New(strings.TrimSpace(lines[len(lines)-1])) + } + } + return output, err } func (c installCompose) Run(ctx context.Context, args ...string) error { diff --git a/services/core/cmd/oac/install_test.go b/services/core/cmd/oac/install_test.go index 2c5f8b8e3..519137900 100644 --- a/services/core/cmd/oac/install_test.go +++ b/services/core/cmd/oac/install_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" + "io" "maps" "net" "net/http" @@ -25,6 +26,10 @@ type installFixture struct { // failureErr is what the failing Docker call reports; os.ErrPermission when nil. failureErr error cached bool + // origin is the console origin Compose resolves. + origin string + // psOutput is what compose ps reports after a failed start. + psOutput string } // installedRevision reads the release the installation directory starts. @@ -51,6 +56,7 @@ func newInstallFixture(t *testing.T) *installFixture { } f := &installFixture{options: installOptions{dir: filepath.Join(root, "installation with spaces"), version: "latest", host: "127.0.0.1", port: port}} f.executable = exe + f.origin = "http://localhost:8080" // The release served for download is always the running installer's. f.download = func(_ context.Context, address string) ([]byte, error) { if f.failure == "download" { @@ -99,10 +105,15 @@ func newInstallFixture(t *testing.T) *installFixture { return []byte("v2.26.0"), nil case "version --format {{.Server.APIVersion}}": return []byte("1.45"), nil + case "compose exec -T web /usr/local/bin/oac-web core-key": + return []byte("oac_admin_test\n"), nil case "compose config --images": return []byte("example/core:latest\nexample/web:latest"), nil - case "compose config --environment": - return []byte("OAC_PUBLIC_URL=http://localhost:8080"), nil + case "compose config --format json": + return []byte(`{"services":{"web":{"environment":{"OAC_PUBLIC_URL":"` + f.origin + `"}}}}`), nil + } + if command == "compose ps --all --format json" { + return []byte(f.psOutput), nil } if strings.HasPrefix(command, "image inspect") && !f.cached { return nil, os.ErrNotExist @@ -412,3 +423,118 @@ func TestInstallerInterruptedProcess(t *testing.T) { t.Fatalf("killed installer left unrecoverable state: %v", err) } } + +// captureOutput returns what fn prints to standard output. +func captureOutput(t *testing.T, fn func()) string { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + previous := os.Stdout + os.Stdout = writer + fn() + os.Stdout = previous + writer.Close() + raw, _ := io.ReadAll(reader) + return string(raw) +} + +func TestFreshInstallStartsLikeApplyAndPrintsTheKeyOnce(t *testing.T) { + f := newInstallFixture(t) + f.options.upgrade = false + var err error + out := captureOutput(t, func() { err = f.run() }) + if err != nil { + t.Fatal(err) + } + // The shared start: init, then check-config, then up. + assertCallsInOrder(t, f.calls, "compose "+strings.Join(initVolume, " "), "compose "+strings.Join(checkConfig, " "), "compose "+strings.Join(startServices, " ")) + command := filepath.Join(f.options.dir, cliName()) + for _, want := range []string{"Pulling example/core:latest…", "Starting services (up to 3 minutes)…", "Console: http://localhost:8080\n", "Core key: oac_admin_test\n", + "Browsers must open exactly http://localhost:8080. If they reach this host at another address, set OAC_PUBLIC_URL in .env and run " + command + " apply."} { + if !strings.Contains(out, want) { + t.Errorf("fresh output lacks %q:\n%s", want, out) + } + } + f.options.publicURL, f.options.port, f.options.given = "", f.options.port+1, []string{"web-port"} + out = captureOutput(t, func() { err = f.run() }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Core key: "+command+" core-key --show\n") || strings.Contains(out, "oac_admin_") || strings.Contains(out, "Browsers must open") { + t.Errorf("rerun output:\n%s", out) + } + if !strings.Contains(out, "Ignored --web-port: they differ from the saved settings. To change them, edit .env and run "+command+" apply.") { + t.Errorf("rerun does not name the ignored flag:\n%s", out) + } +} + +func TestInstallReportsStartFailuresByCause(t *testing.T) { + progress := "Container x-database-1 Healthy\nContainer x-core-1 Error\ndependency failed to start: container x-core-1 is unhealthy" + for _, c := range []struct { + call, stderr, want, unwanted string + }{ + // A refusal is not fixed by rerunning, and only its own line is shown. + {"compose run --rm --no-deps init", "{\"msg\":\"Initialization started\"}\nthis data belongs to a newer release", "data volume check failed; no service was changed: this data belongs to a newer release", "startup failed"}, + {"compose run --rm --no-deps --entrypoint", "OAC_EXECUTION_CONCURRENCY must be positive", "configuration check failed; no service was changed: OAC_EXECUTION_CONCURRENCY must be positive", "startup failed"}, + {"compose up", progress, "startup failed; data retained, rerun the installer: dependency failed to start: container x-core-1 is unhealthy\nNot healthy: core (unhealthy), web (starting)\nSee why: docker compose -f '", "Healthy"}, + } { + t.Run(c.call, func(t *testing.T) { + f := newInstallFixture(t) + f.failure, f.failureErr = c.call, errors.New(c.stderr) + f.psOutput = `{"Service":"init","State":"exited","ExitCode":0} +{"Service":"database","State":"running","Health":"healthy"} +{"Service":"core","State":"running","Health":"unhealthy"} +{"Service":"web","State":"running","Health":"starting"}` + err := f.run() + if err == nil || !strings.HasPrefix(err.Error(), c.want) || strings.Contains(err.Error(), c.unwanted) { + t.Fatalf("got %v", err) + } + if c.call == "compose up" && !strings.HasSuffix(err.Error(), "compose.yaml' logs --tail 50 core web") { + t.Fatalf("no log command: %v", err) + } + }) + } +} + +func TestInterruptedInstallLeavesAStateTheRerunAccepts(t *testing.T) { + f := newInstallFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + docker := f.docker + f.docker = func(c context.Context, dir string, args ...string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), " up ") { + cancel() + return nil, errors.New("signal: killed") + } + return docker(c, dir, args...) + } + err := f.install(ctx, f.options) + if err == nil || failureMessage(ctx, "install", err) != "Interrupted; rerun the installer to continue." { + t.Fatalf("got %v", err) + } + if _, err := os.Stat(f.options.dir + ".staging"); !os.IsNotExist(err) { + t.Fatal("interrupt left the stage") + } + f.docker = docker + if err := f.run(); err != nil { + t.Fatalf("rerun after interrupt: %v", err) + } +} + +func TestDockerChildrenIgnoreTheCallersInstallationSettings(t *testing.T) { + t.Setenv("COMPOSE_PROJECT_NAME", "elsewhere") + t.Setenv("OAC_WEB_PORT", "1") + t.Setenv("DOCKER_HOST", "unix:///kept.sock") + env := strings.Join(dockerEnv(), "\n") + if strings.Contains(env, "COMPOSE_PROJECT_NAME") || strings.Contains(env, "OAC_WEB_PORT") || !strings.Contains(env, "DOCKER_HOST=unix:///kept.sock") { + t.Fatal("unexpected child environment") + } +} + +func TestUnknownCommandPrintsUsageOutsideAnInstallation(t *testing.T) { + err := run(context.Background(), "frobnicate", nil) + if err == nil || strings.Count(err.Error(), "Usage: oac") != 1 || !strings.HasPrefix(err.Error(), `Unknown command "frobnicate".`) { + t.Fatalf("got %v", err) + } +} diff --git a/services/core/cmd/oac/main.go b/services/core/cmd/oac/main.go index 3a9cfaf57..caa970331 100644 --- a/services/core/cmd/oac/main.go +++ b/services/core/cmd/oac/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "crypto/rand" "crypto/sha256" @@ -36,20 +37,31 @@ func main() { if errors.Is(err, flag.ErrHelp) { return } - if os.Args[1] != "init" { - fmt.Fprintln(os.Stderr, err.Error()) - } + fmt.Fprintln(os.Stderr, failureMessage(ctx, os.Args[1], err)) os.Exit(1) } } +// failureMessage reports an interrupt as one. Whatever the command had started stays valid, so repeating it continues. +func failureMessage(ctx context.Context, command string, err error) string { + if ctx.Err() == nil { + return err.Error() + } + if command == "install" { + return "Interrupted; rerun the installer to continue." + } + return "Interrupted; rerun oac " + command + " to continue." +} + // buildRevision is set by the release build. var buildRevision = "development" -func usage() { - fmt.Fprintf(os.Stderr, "oac (%s)\nUsage: oac install|apply|core-key|rotate-core-key|uninstall\n", buildRevision) +func usageText() string { + return fmt.Sprintf("oac (%s)\nUsage: oac install|apply|core-key|rotate-core-key|uninstall", buildRevision) } +func usage() { fmt.Fprintln(os.Stderr, usageText()) } + func run(ctx context.Context, command string, args []string) error { switch command { case "install": @@ -60,6 +72,9 @@ func run(ctx context.Context, command string, args []string) error { return uninstallCommand(ctx, args) case "rotate-volume-key": return withLock("/data/secrets/init", func() error { return rotateVolumeKey("/data") }) + case "apply", "core-key", "rotate-core-key": + default: + return fmt.Errorf("Unknown command %q.\n%s", command, usageText()) } root, err := installDir() if err != nil { @@ -68,7 +83,13 @@ func run(ctx context.Context, command string, args []string) error { runner := execRunner{dir: root} switch command { case "apply": - return withLock(root, func() error { return apply(ctx, runner) }) + return withLock(root, func() error { + if err := apply(ctx, runner, root, "fix the cause and run oac apply again"); err != nil { + return err + } + fmt.Println("Services are running with the current settings.") + return nil + }) case "core-key": return coreKeyCommand(ctx, runner, args) case "rotate-core-key": @@ -76,12 +97,14 @@ func run(ctx context.Context, command string, args []string) error { if err := runner.Run(ctx, "run", "--rm", "--no-deps", "init", "/usr/local/bin/oac", "rotate-volume-key"); err != nil { return err } - return runner.Run(ctx, "restart", "core", "web") + if err := runner.Run(ctx, "restart", "core", "web"); err != nil { + return err + } + fmt.Printf("Core key rotated. The old key no longer works and console sessions ended. Read the new key with: %s core-key --show\n", quote(filepath.Join(root, cliName()))) + return nil }) - default: - usage() - return errors.New("unknown command") } + return nil } func installDir() (string, error) { @@ -136,7 +159,6 @@ func accessError(root string, err error) error { } path = filepath.Dir(path) } - quote := func(name string) string { return "'" + strings.ReplaceAll(name, "'", `'\''`) + "'" } owned := []string{root, root + ".lock", root + ".staging"} var targets []string home, _ := os.UserHomeDir() @@ -166,19 +188,58 @@ var ( startServices = []string{"up", "-d", "--wait", "--wait-timeout", "180", "--pull", "never", "--remove-orphans"} ) -// apply is the start shared by oac apply and a resumed installation. Init runs +// apply is the start shared by oac apply and every installation. Init runs // first: it refuses a data volume committed to another release and finishes one // whose first start was interrupted, before check-config reads its secrets. -func apply(ctx context.Context, runner Runner) error { +// Only a failed start can be fixed by repeating the command, so only it carries +// the advice in rerun. +func apply(ctx context.Context, runner Runner, dir, rerun string) error { if err := runner.Run(ctx, initVolume...); err != nil { return fmt.Errorf("data volume check failed; no service was changed: %w", err) } if err := runner.Run(ctx, checkConfig...); err != nil { return fmt.Errorf("configuration check failed; no service was changed: %w", err) } - return runner.Run(ctx, startServices...) + err := runner.Run(ctx, startServices...) + if err == nil || ctx.Err() != nil { + return err + } + message := fmt.Sprintf("startup failed; data retained, %s: %v", rerun, err) + if names, states := unhealthyServices(ctx, runner); len(names) > 0 { + message += fmt.Sprintf("\nNot healthy: %s\nSee why: docker compose -f %s logs --tail 50 %s", strings.Join(states, ", "), quote(filepath.Join(dir, "compose.yaml")), strings.Join(names, " ")) + } + return errors.New(message) +} + +// unhealthyServices lists the services that are not running and healthy, by name and as "name (state)". A finished init is healthy. +func unhealthyServices(ctx context.Context, runner Runner) (names, states []string) { + raw, err := runner.Output(ctx, "ps", "--all", "--format", "json") + if err != nil { + return nil, nil + } + for decoder := json.NewDecoder(bytes.NewReader(raw)); ; { + var s struct { + Service, State, Health string + ExitCode int + } + if decoder.Decode(&s) != nil { + return names, states + } + state := s.State + if s.Health != "" { + state = s.Health + } + if state == "healthy" || state == "running" || (s.State == "exited" && s.ExitCode == 0) { + continue + } + names = append(names, s.Service) + states = append(states, s.Service+" ("+state+")") + } } +// quote makes a path one shell word for a command the user copies. +func quote(name string) string { return "'" + strings.ReplaceAll(name, "'", `'\''`) + "'" } + func coreKeyCommand(ctx context.Context, runner Runner, args []string) error { path := "Docker volume: secrets/web/core.key (use oac core-key --show)" if len(args) == 0 { diff --git a/services/core/cmd/oac/oac_test.go b/services/core/cmd/oac/oac_test.go index a6e169393..a8fd3bf75 100644 --- a/services/core/cmd/oac/oac_test.go +++ b/services/core/cmd/oac/oac_test.go @@ -18,7 +18,7 @@ func TestApplyDoesNotStartWhenTheConfigurationCheckFails(t *testing.T) { } return nil }} - if err := apply(context.Background(), runner); err == nil { + if err := apply(context.Background(), runner, t.TempDir(), "rerun"); err == nil { t.Fatal("apply continued") } if len(calls) != 2 || strings.Join(calls[0], " ") != strings.Join(initVolume, " ") {