diff --git a/README.MD b/README.MD index 0440a3c..b335043 100644 --- a/README.MD +++ b/README.MD @@ -254,6 +254,10 @@ docker-compose logs -f 2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。 3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。 +Zeabur 首次空卷启动时可以没有 `/data/config.json`;DS2API 会先使用空的文件模式配置启动,并在管理台首次保存时创建该文件。 + +不依赖模板手动部署时,在 Zeabur 中选择 GitHub 仓库服务,Root Directory 保持 `/`,使用仓库根目录 `Dockerfile` 构建;添加持久卷 `/data`,设置 `PORT=5001`、`DS2API_ADMIN_KEY=你的强密钥`、`DS2API_CONFIG_PATH=/data/config.json`,然后暴露 HTTP 端口 `5001`。更完整步骤见 [docs/DEPLOY.md](docs/DEPLOY.md#不使用模板手动部署)。 + 说明:Zeabur 使用仓库内 `Dockerfile` 直接构建时,不需要额外传入 `BUILD_VERSION`;镜像会优先读取该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。 ### 方式三:Vercel 部署 diff --git a/README.en.md b/README.en.md index 609acd8..1376767 100644 --- a/README.en.md +++ b/README.en.md @@ -243,6 +243,10 @@ Rebuild after updates: `docker-compose up -d --build` 2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions. 3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`). +Fresh Zeabur volumes can start without `/data/config.json`; DS2API will boot with an empty file-backed config and create the file on the first Admin UI save. + +For manual deployment without the template, create a Zeabur GitHub service, keep Root Directory as `/`, build with the repo-root `Dockerfile`, mount a persistent volume at `/data`, set `PORT=5001`, `DS2API_ADMIN_KEY=your-strong-secret`, and `DS2API_CONFIG_PATH=/data/config.json`, then expose HTTP port `5001`. See [docs/DEPLOY.en.md](docs/DEPLOY.en.md#manual-deployment-without-the-template) for the full guide. + Note: when Zeabur builds directly from the repo `Dockerfile`, you do not need to pass `BUILD_VERSION`. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent. ### Option 3: Vercel diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 4c1df13..d082326 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -196,7 +196,7 @@ This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur: Notes: - **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`. -- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path. +- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. On a fresh volume, DS2API starts with an empty file-backed config; after importing config in Admin UI, it will be written and persisted to this path. - **`open /app/config.json: permission denied`**: this means the instance is trying to persist runtime tokens to a read-only path (commonly `/app` inside the image). Recommended handling: 1. Set a writable path explicitly: `DS2API_CONFIG_PATH=/data/config.json` (and mount a persistent volume at `/data`); @@ -205,6 +205,37 @@ Notes: - **Build version**: Zeabur / regular `docker build` does not require `BUILD_VERSION` by default. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent. - **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login). +#### Manual Deployment Without The Template + +If you do not want to use the `zeabur.yaml` one-click template, deploy directly from the repo root with Zeabur's GitHub integration: + +1. Fork this repo, or push the code to your own GitHub repository. +2. In Zeabur Dashboard, create a Project, add a Service, then choose a GitHub/Git repository source. +3. Select the repository and branch. Keep Root Directory as `/`. +4. Use the Dockerfile build path. Zeabur auto-detects the repo-root `Dockerfile`; do not set `ZBPACK_IGNORE_DOCKERFILE=true`. If the UI asks for a Dockerfile name, enter `Dockerfile`. +5. Add a persistent volume in the Service settings and mount it at `/data`. +6. Configure environment variables: + +| Variable | Recommended value | Description | +| --- | --- | --- | +| `PORT` | `5001` | Service listen port; keep it aligned with the exposed Zeabur HTTP port. | +| `DS2API_ADMIN_KEY` | Strong random string | Required admin login key. | +| `DS2API_CONFIG_PATH` | `/data/config.json` | Recommended persistent config path. | +| `LOG_LEVEL` | `INFO` | Optional log level. | +| `DS2API_CONFIG_JSON` | Raw JSON or Base64 JSON | Optional config bootstrap from env. | +| `DS2API_ENV_WRITEBACK` | `1` | Optional; enable only when using `DS2API_CONFIG_JSON` and you want the initial config written to `/data/config.json`. | + +7. Expose HTTP port `5001`. The health check path can be `/healthz`. +8. After deployment, open `/admin`, login with `DS2API_ADMIN_KEY`, then import or edit config in Admin UI. A fresh volume does not need `/data/config.json` up front; the service boots first and creates the file on the first save. + +Troubleshooting: + +- **Startup log says `open /data/config.json: no such file or directory`**: make sure you deployed a version that includes the fresh-volume bootstrap fix, then redeploy the latest code. +- **`open /app/config.json: permission denied`**: the config path still points at the read-only image directory; mount `/data` and set `DS2API_CONFIG_PATH=/data/config.json`. +- **Config disappears after restart**: check that the `/data` persistent volume is mounted on this service. If you use `DS2API_CONFIG_JSON` but want Admin UI saves persisted, enable `DS2API_ENV_WRITEBACK=1`. + +References: Zeabur's official [GitHub/Git integration](https://zeabur.com/docs/en-US/deploy/github), [Dockerfile deployment](https://zeabur.com/docs/en-US/deploy/dockerfile), and [Volumes](https://zeabur.com/docs/data-management/volumes) docs. + --- ## 3. Vercel Deployment diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 47dfd4b..3faaf95 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -196,7 +196,7 @@ healthcheck: 部署要点: - **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。 -- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。 +- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;首次空卷启动时会先使用空的文件模式配置,在管理台导入配置后,会写入并持久化到该路径。 - **`open /app/config.json: permission denied`**:说明当前实例在尝试把运行时 token 持久化到只读路径(常见于镜像内 `/app`)。 处理建议: 1. 显式设置可写路径:`DS2API_CONFIG_PATH=/data/config.json`(并挂载持久卷到 `/data`); @@ -205,6 +205,37 @@ healthcheck: - **构建版本号**:Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。 - **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。 +#### 不使用模板手动部署 + +如果你不想使用 `zeabur.yaml` 一键模板,可以直接用 Zeabur 的 GitHub 集成从仓库根目录构建: + +1. Fork 本仓库,或把代码推送到你自己的 GitHub 仓库。 +2. 在 Zeabur Dashboard 中创建 Project,然后添加 Service,选择 GitHub/Git 仓库来源。 +3. 选择仓库与分支,Root Directory 保持 `/`。 +4. 构建方式使用 Dockerfile。Zeabur 会自动检测仓库根目录的 `Dockerfile`;不要设置 `ZBPACK_IGNORE_DOCKERFILE=true`。如果界面要求填写 Dockerfile 名称,填写 `Dockerfile`。 +5. 在 Service 配置中添加持久卷,挂载目录填写 `/data`。 +6. 配置环境变量: + +| 变量 | 推荐值 | 说明 | +| --- | --- | --- | +| `PORT` | `5001` | 服务监听端口,需要和 Zeabur 暴露的 HTTP 端口一致。 | +| `DS2API_ADMIN_KEY` | 强随机字符串 | 管理台登录密钥,必填。 | +| `DS2API_CONFIG_PATH` | `/data/config.json` | 配置持久化路径,建议必填。 | +| `LOG_LEVEL` | `INFO` | 可选,日志级别。 | +| `DS2API_CONFIG_JSON` | 原始 JSON 或 Base64 JSON | 可选,用于用环境变量初始化配置。 | +| `DS2API_ENV_WRITEBACK` | `1` | 可选;当设置了 `DS2API_CONFIG_JSON` 且希望首次启动后写入 `/data/config.json` 时再启用。 | + +7. 暴露 HTTP 端口 `5001`,健康检查路径可填 `/healthz`。 +8. 部署完成后访问 `/admin`,用 `DS2API_ADMIN_KEY` 登录,然后在管理台导入或编辑配置。首次空卷可以没有 `/data/config.json`,服务会先启动,第一次保存时自动创建该文件。 + +常见问题: + +- **启动日志出现 `open /data/config.json: no such file or directory`**:请确认已经部署包含“首次空卷启动”修复的版本,并重新部署最新代码。 +- **出现 `open /app/config.json: permission denied`**:说明配置路径仍指向镜像内只读目录;设置持久卷 `/data`,并确认 `DS2API_CONFIG_PATH=/data/config.json`。 +- **管理台保存后重启配置丢失**:检查 `/data` 持久卷是否已挂载到当前服务;如果使用了 `DS2API_CONFIG_JSON`,但想让管理台保存落盘,请启用 `DS2API_ENV_WRITEBACK=1`。 + +参考:Zeabur 官方文档的 [GitHub/Git 集成](https://zeabur.com/docs/en-US/deploy/github)、[Dockerfile 部署](https://zeabur.com/docs/zh-CN/deploy/dockerfile) 与 [Volumes](https://zeabur.com/docs/data-management/volumes)。 + --- ## 三、Vercel 部署 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1c7700f..d695403 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -144,6 +144,44 @@ func TestLoadStoreIgnoresLegacyConfigJSONEnv(t *testing.T) { } } +func TestExplicitMissingConfigPathBootstrapsEmptyFileBackedStore(t *testing.T) { + path := t.TempDir() + "/config.json" + + t.Setenv("DS2API_CONFIG_JSON", "") + t.Setenv("DS2API_CONFIG_PATH", path) + + store, err := LoadStoreWithError() + if err != nil { + t.Fatalf("expected missing explicit config path to bootstrap, got: %v", err) + } + if store.IsEnvBacked() { + t.Fatal("expected bootstrap store to be file-backed") + } + if store.ConfigPath() != path { + t.Fatalf("ConfigPath() = %q, want %q", store.ConfigPath(), path) + } + if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { + t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(store.Keys()), len(store.Accounts())) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected bootstrap not to create config until first save, stat err=%v", statErr) + } + + if err := store.Update(func(c *Config) error { + c.Keys = []string{"first-key"} + return nil + }); err != nil { + t.Fatalf("update should persist bootstrap config: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected first update to write config: %v", err) + } + if !strings.Contains(string(content), "first-key") { + t.Fatalf("expected saved config to contain first key, got: %s", content) + } +} + func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") if err != nil { diff --git a/internal/config/store.go b/internal/config/store.go index 0af367f..603ff9a 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -52,11 +52,12 @@ func loadStore() (*Store, error) { func loadConfig() (Config, bool, error) { rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) + path := ConfigPath() if rawCfg != "" { cfg, err := parseConfigString(rawCfg) if err != nil { if !IsVercel() && envWritebackEnabled() { - if fileCfg, fileErr := loadConfigFromFile(ConfigPath()); fileErr == nil { + if fileCfg, fileErr := loadConfigFromFile(path); fileErr == nil { return fileCfg, false, nil } } @@ -67,7 +68,7 @@ func loadConfig() (Config, bool, error) { if IsVercel() || !envWritebackEnabled() { return cfg, true, err } - content, fileErr := os.ReadFile(ConfigPath()) + content, fileErr := os.ReadFile(path) if fileErr == nil { var fileCfg Config if unmarshalErr := json.Unmarshal(content, &fileCfg); unmarshalErr == nil { @@ -79,7 +80,7 @@ func loadConfig() (Config, bool, error) { if validateErr := ValidateConfig(cfg); validateErr != nil { return cfg, true, validateErr } - if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil { + if writeErr := writeConfigFile(path, cfg.Clone()); writeErr == nil { return cfg, false, err } else { Logger.Warn("[config] env writeback bootstrap failed", "error", writeErr) @@ -87,7 +88,7 @@ func loadConfig() (Config, bool, error) { } return cfg, true, err } - cfg, err := loadConfigFromFile(ConfigPath()) + cfg, err := loadConfigFromFile(path) if err != nil { if shouldTryLegacyContainerConfigPath() { legacyPath := legacyContainerConfigPath() @@ -100,6 +101,10 @@ func loadConfig() (Config, bool, error) { // Vercel may start without writable/present config; keep in-memory bootstrap config. return Config{}, true, nil } + if shouldBootstrapMissingConfigFile(err) { + Logger.Warn("[config] config file missing; starting with empty file-backed config", "path", path) + return Config{}, false, nil + } return Config{}, false, err } if IsVercel() { @@ -109,6 +114,10 @@ func loadConfig() (Config, bool, error) { return cfg, false, nil } +func shouldBootstrapMissingConfigFile(err error) bool { + return errors.Is(err, os.ErrNotExist) && strings.TrimSpace(os.Getenv("DS2API_CONFIG_PATH")) != "" +} + func loadConfigFromFile(path string) (Config, error) { content, err := os.ReadFile(path) if err != nil { diff --git a/zeabur.yaml b/zeabur.yaml index 8ed1340..502bdfb 100644 --- a/zeabur.yaml +++ b/zeabur.yaml @@ -25,6 +25,7 @@ spec: 1. Open your service URL, then visit `/admin` 2. Login with `DS2API_ADMIN_KEY` (shown in Zeabur env/instructions) 3. Import / edit config in Admin UI (saved to `/data/config.json`) + 4. On a fresh volume, DS2API starts with an empty config and creates `/data/config.json` on the first save services: - name: ds2api