fix(compose): 恢复默认数据具名卷 (#484)
This commit is contained in:
1 parent
a710acab1e
commit
17dbcb592c
6 files changed
+24
-96
No files matched your search
@@ -13,7 +13,6 @@ IDLE_TIMEOUT=120
|
||||
# Storage
|
||||
# Empty DATABASE_DSN uses ${DATA_DIR}/gpt-load.db. Otherwise set a SQLite,
|
||||
# MySQL, or PostgreSQL URL; container file paths must use mounted paths.
|
||||
# Compose uses this host path as the bind-mount source for /app/data.
|
||||
DATA_DIR=./data
|
||||
DATABASE_DSN=
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ Anthropic clients use `/v1/messages` and Gemini clients use `/v1beta/models/...`
|
||||
|
||||
## Deployment and data
|
||||
|
||||
Docker Compose uses application-managed SQLite by default. Data lives in the host `DATA_DIR` (default `./data`), mounted at `/app/data`, and includes the database, `auth.key`, and `encryption.key`. The service creates and validates this directory during startup.
|
||||
Docker Compose uses application-managed SQLite by default. Data lives in the `gpt-load-data` named volume and includes the database, `auth.key`, and `encryption.key`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `encryption.key` decrypts channel credentials. When backing up or migrating, the database and the key **must be kept together**. Once the key is lost or replaced, existing encrypted credentials cannot be recovered, and this version does not support master key rotation.
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ Anthropic 客户端使用 `/v1/messages`,Gemini 客户端使用 `/v1beta/model
|
||||
|
||||
## 部署与数据
|
||||
|
||||
Docker Compose 默认使用应用管理的 SQLite,数据存放在宿主机的 `DATA_DIR`(默认 `./data`)中,并挂载到容器 `/app/data`,包含数据库、`auth.key` 和 `encryption.key`。服务会在启动时创建并校验该目录。
|
||||
Docker Compose 默认使用应用管理的 SQLite,数据存放在 `gpt-load-data` 具名卷中,包含数据库、`auth.key` 和 `encryption.key`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `encryption.key` 用于解密渠道凭据。备份或迁移时,数据库和密钥**必须一起保存**;密钥丢失或被替换后,已有加密凭据无法恢复,且当前版本不支持主密钥轮换。
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ Anthropic クライアントは `/v1/messages`、Gemini クライアントは `/
|
||||
|
||||
## デプロイとデータ
|
||||
|
||||
Docker Compose は既定でアプリケーション管理の SQLite を使用します。データはホストの `DATA_DIR`(既定は `./data`)に保存され、コンテナの `/app/data` にマウントされます。データにはデータベース、`auth.key`、`encryption.key` が含まれます。サービスは起動時にこのディレクトリを作成し、安全性を検証します。
|
||||
Docker Compose は既定でアプリケーション管理の SQLite を使用します。データは `gpt-load-data` という名前付きボリュームに保存され、データベース、`auth.key`、`encryption.key` を含みます。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `encryption.key` はチャネル認証情報の復号に使われます。バックアップや移行の際は、データベースと鍵を**必ず一緒に保管**してください。鍵を失ったり置き換えたりすると、既存の暗号化済み認証情報は復元できません。また現行バージョンはマスターキーのローテーションに対応していません。
|
||||
|
||||
+4
-25
@@ -1,25 +1,4 @@
|
||||
services:
|
||||
gpt-load-data-init:
|
||||
image: ghcr.io/tbphp/gpt-load:v2beta
|
||||
user: "0:0"
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
data_owner="$$(stat -c '%u' /app/data)"
|
||||
if [ "$$data_owner" = "10001" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$$(find /app/data -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "DATA_DIR is non-empty and not owned by UID 10001; refusing to change permissions" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown 10001:10001 /app/data
|
||||
chmod 0700 /app/data
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ${DATA_DIR:-./data}:/app/data
|
||||
|
||||
gpt-load:
|
||||
image: ghcr.io/tbphp/gpt-load:v2beta
|
||||
# build:
|
||||
@@ -36,12 +15,9 @@ services:
|
||||
HOST: 0.0.0.0
|
||||
PORT: ${PORT:-3001}
|
||||
DATA_DIR: /app/data
|
||||
depends_on:
|
||||
gpt-load-data-init:
|
||||
condition: service_completed_successfully
|
||||
restart: always
|
||||
volumes:
|
||||
- ${DATA_DIR:-./data}:/app/data
|
||||
- gpt-load-data:/app/data
|
||||
stop_grace_period: ${CONTAINER_STOP_GRACE_PERIOD:-15s}
|
||||
healthcheck:
|
||||
test: wget -q --spider -T 10 -O /dev/null http://localhost:${PORT:-3001}/health
|
||||
@@ -49,3 +25,6 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
gpt-load-data:
|
||||
@@ -403,19 +403,13 @@ func TestComposeBindsLoopbackAndConfiguresContainerAllInterfaces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectsKeepIndependentNamesAndUseProjectDataDirectories(t *testing.T) {
|
||||
func TestComposeProjectsHaveIndependentNamesApplicationPortsAndVolumes(t *testing.T) {
|
||||
t.Setenv("HOST", "")
|
||||
t.Setenv("BIND_ADDRESS", "")
|
||||
t.Setenv("OAUTH_CALLBACK_BIND_ADDRESS", "")
|
||||
t.Setenv("PORT", "")
|
||||
t.Setenv("DATA_DIR", "")
|
||||
|
||||
projectDir := t.TempDir()
|
||||
resolvedProjectDir, err := filepath.EvalSymlinks(projectDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve temporary Compose project directory: %v", err)
|
||||
}
|
||||
projectDataDir := filepath.Join(resolvedProjectDir, "data")
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(projectDir, "docker-compose.yml"),
|
||||
[]byte(readRepositoryFile(t, "docker-compose.yml")),
|
||||
@@ -436,12 +430,10 @@ func TestComposeProjectsKeepIndependentNamesAndUseProjectDataDirectories(t *test
|
||||
Published string `json:"published"`
|
||||
HostIP string `json:"host_ip"`
|
||||
} `json:"ports"`
|
||||
Volumes []struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
} `json:"volumes"`
|
||||
} `json:"services"`
|
||||
Volumes map[string]struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"volumes"`
|
||||
}
|
||||
|
||||
render := func(projectName, publishedPort string) composeConfig {
|
||||
@@ -497,19 +489,18 @@ func TestComposeProjectsKeepIndependentNamesAndUseProjectDataDirectories(t *test
|
||||
service.Ports[3].HostIP != "127.0.0.1" {
|
||||
t.Fatalf("resolved project %s ports = %#v", item.projectName, service.Ports)
|
||||
}
|
||||
if len(service.Volumes) != 1 {
|
||||
t.Fatalf("resolved project %s volume count = %d, want 1", item.projectName, len(service.Volumes))
|
||||
}
|
||||
volume := service.Volumes[0]
|
||||
if volume.Type != "bind" || volume.Source != projectDataDir || volume.Target != "/app/data" {
|
||||
t.Fatalf("resolved project %s volume = %#v, want project data bind mount", item.projectName, volume)
|
||||
wantVolume := item.projectName + "_gpt-load-data"
|
||||
if got := item.config.Volumes["gpt-load-data"].Name; got != wantVolume {
|
||||
t.Fatalf("resolved project %s volume = %q, want %q", item.projectName, got, wantVolume)
|
||||
}
|
||||
}
|
||||
if first.Volumes["gpt-load-data"].Name == second.Volumes["gpt-load-data"].Name {
|
||||
t.Fatal("different Compose projects resolve the same named volume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeResolvesConfiguredDataDirBindMountAndBetaChannelImage(t *testing.T) {
|
||||
hostDataDir := filepath.Join(t.TempDir(), "data")
|
||||
t.Setenv("DATA_DIR", hostDataDir)
|
||||
func TestComposeResolvesNamedVolumeContainerPathsAndBetaChannelImage(t *testing.T) {
|
||||
t.Setenv("DATA_DIR", "/host/path/must-not-reach-container")
|
||||
t.Setenv("DATABASE_DSN", "/host/database/must-not-reach-container.db")
|
||||
|
||||
projectDir := t.TempDir()
|
||||
@@ -536,17 +527,11 @@ func TestComposeResolvesConfiguredDataDirBindMountAndBetaChannelImage(t *testing
|
||||
var resolved struct {
|
||||
Services map[string]struct {
|
||||
Image string `json:"image"`
|
||||
User string `json:"user"`
|
||||
Restart string `json:"restart"`
|
||||
Entrypoint []string `json:"entrypoint"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Privileged bool `json:"privileged"`
|
||||
StopGracePeriod string `json:"stop_grace_period"`
|
||||
Healthcheck map[string]any `json:"healthcheck"`
|
||||
DependsOn map[string]struct {
|
||||
Condition string `json:"condition"`
|
||||
} `json:"depends_on"`
|
||||
Volumes []struct {
|
||||
Volumes []struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
@@ -565,10 +550,6 @@ func TestComposeResolvesConfiguredDataDirBindMountAndBetaChannelImage(t *testing
|
||||
if service.Image != "ghcr.io/tbphp/gpt-load:v2beta" {
|
||||
t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:v2beta", service.Image)
|
||||
}
|
||||
dependency, ok := service.DependsOn["gpt-load-data-init"]
|
||||
if !ok || dependency.Condition != "service_completed_successfully" {
|
||||
t.Fatalf("resolved gpt-load depends_on = %#v, want successful data initializer", service.DependsOn)
|
||||
}
|
||||
if service.Environment["DATA_DIR"] != "/app/data" {
|
||||
t.Fatalf("resolved DATA_DIR = %q, want /app/data", service.Environment["DATA_DIR"])
|
||||
}
|
||||
@@ -588,42 +569,11 @@ func TestComposeResolvesConfiguredDataDirBindMountAndBetaChannelImage(t *testing
|
||||
t.Fatalf("resolved volume count = %d, want 1", len(service.Volumes))
|
||||
}
|
||||
volume := service.Volumes[0]
|
||||
if volume.Type != "bind" || volume.Source != hostDataDir || volume.Target != "/app/data" {
|
||||
t.Fatalf("resolved volume = %#v, want configured DATA_DIR bind-mounted at /app/data", volume)
|
||||
if volume.Type != "volume" || volume.Source != "gpt-load-data" || volume.Target != "/app/data" {
|
||||
t.Fatalf("resolved volume = %#v, want named gpt-load-data mounted at /app/data", volume)
|
||||
}
|
||||
if len(resolved.Volumes) != 0 {
|
||||
t.Fatalf("resolved Compose top-level volumes = %#v, want none", resolved.Volumes)
|
||||
}
|
||||
initializer, ok := resolved.Services["gpt-load-data-init"]
|
||||
if !ok {
|
||||
t.Fatal("resolved Compose lacks gpt-load-data-init")
|
||||
}
|
||||
if initializer.Image != service.Image || initializer.User != "0:0" || initializer.Restart != "no" || initializer.Privileged {
|
||||
t.Fatalf("resolved data initializer = %#v, want root one-shot service using application image", initializer)
|
||||
}
|
||||
if len(initializer.Environment) != 0 {
|
||||
t.Fatalf("resolved data initializer environment = %#v, want no process secrets", initializer.Environment)
|
||||
}
|
||||
wantInitializerScript := `data_owner="$$(stat -c '%u' /app/data)"
|
||||
if [ "$$data_owner" = "10001" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$$(find /app/data -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "DATA_DIR is non-empty and not owned by UID 10001; refusing to change permissions" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown 10001:10001 /app/data
|
||||
chmod 0700 /app/data`
|
||||
if len(initializer.Entrypoint) != 3 || initializer.Entrypoint[0] != "/bin/sh" ||
|
||||
initializer.Entrypoint[1] != "-ec" ||
|
||||
strings.TrimSpace(initializer.Entrypoint[2]) != wantInitializerScript {
|
||||
t.Fatalf("resolved data initializer entrypoint = %#v", initializer.Entrypoint)
|
||||
}
|
||||
if len(initializer.Volumes) != 1 ||
|
||||
initializer.Volumes[0].Type != "bind" ||
|
||||
initializer.Volumes[0].Source != hostDataDir ||
|
||||
initializer.Volumes[0].Target != "/app/data" {
|
||||
t.Fatalf("resolved data initializer volumes = %#v, want configured DATA_DIR bind mount", initializer.Volumes)
|
||||
if _, ok := resolved.Volumes["gpt-load-data"]; !ok {
|
||||
t.Fatal("resolved Compose lacks top-level gpt-load-data volume")
|
||||
}
|
||||
for _, volume := range service.Volumes {
|
||||
if strings.Contains(volume.Source, "docker.sock") ||
|
||||
|
||||
Reference in new issue
Block a user