dsh-cloud 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dsh_cloud_cli/cli.py ADDED
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ HELP = """Usage: dsh-cloud COMMAND [DIRECTORY] [OPTIONS]
14
+
15
+ Commands:
16
+ start safely initialize when needed, validate, and start the stack
17
+ init write a managed deployment without starting Docker
18
+ doctor validate Docker Compose and the managed configuration
19
+ up start an initialized deployment
20
+ down stop it without deleting data
21
+ status show Compose service status
22
+ logs show service logs
23
+
24
+ Global options:
25
+ --help show this help
26
+ --version show the product release version
27
+ --mode trial|selfhost (default: trial)
28
+ --dir PATH deployment directory (default: ./dsh-cloud)
29
+ --dry-run print the exact plan without writing or running Docker
30
+ --json stable machine-readable output
31
+ """
32
+ COMMANDS = {"init", "start", "doctor", "up", "down", "status", "logs"}
33
+ VALUE_OPTIONS = {"--mode": "mode", "--dir": "dir", "--domain": "domain", "--admin-email": "adminEmail", "--project-name": "projectName"}
34
+ BOOLEAN_OPTIONS = {"--yes": "yes", "--json": "json", "--dry-run": "dryRun", "--wait": "wait", "--follow": "follow"}
35
+ TRIAL_PUBLIC_BASE = "http://localhost:8787"
36
+ TRIAL_CADDY_SITE = "http://localhost"
37
+ PROJECT_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
38
+
39
+
40
+ class CliError(RuntimeError):
41
+ def __init__(self, message: str, exit_code: int = 2):
42
+ super().__init__(message)
43
+ self.exit_code = exit_code
44
+
45
+
46
+ def parse_args(argv: list[str]) -> dict:
47
+ if not argv or "--help" in argv or argv[0] == "-h":
48
+ return {"special": "help"}
49
+ if "--version" in argv:
50
+ return {"special": "version"}
51
+ command = argv[0]
52
+ if command not in COMMANDS:
53
+ raise CliError(f"unknown command: {command}")
54
+ options: dict[str, str | bool] = {}
55
+ positionals: list[str] = []
56
+ index = 1
57
+ while index < len(argv):
58
+ token = argv[index]
59
+ if token == "--upstream-key" or token.startswith("--upstream-key="):
60
+ raise CliError("secret values are not accepted as command arguments")
61
+ if token in BOOLEAN_OPTIONS:
62
+ options[BOOLEAN_OPTIONS[token]] = True
63
+ elif token in VALUE_OPTIONS:
64
+ index += 1
65
+ if index >= len(argv) or argv[index].startswith("--"):
66
+ raise CliError(f"{token} requires a value")
67
+ options[VALUE_OPTIONS[token]] = argv[index]
68
+ elif token.startswith("-"):
69
+ raise CliError(f"unknown option: {token}")
70
+ else:
71
+ positionals.append(token)
72
+ index += 1
73
+ return {"command": command, "options": options, "positionals": positionals}
74
+
75
+
76
+ def repository_root() -> Path:
77
+ return Path(__file__).resolve().parents[4]
78
+
79
+
80
+ def load_manifest() -> dict:
81
+ packaged = Path(__file__).with_name("release-manifest.json")
82
+ if packaged.is_file():
83
+ return json.loads(packaged.read_text(encoding="utf-8"))
84
+ source = json.loads((repository_root() / "release/release.json").read_text(encoding="utf-8"))
85
+ return {
86
+ "schemaVersion": 1,
87
+ "version": source["version"],
88
+ "license": source["license"],
89
+ "stackSchema": source["stackSchema"],
90
+ "databaseSchema": source["databaseSchema"],
91
+ "minCliVersion": source["minCliVersion"],
92
+ "minUpgradeFrom": source["minUpgradeFrom"],
93
+ "harnessRuntime": source["harnessRuntime"],
94
+ "images": source["productImages"],
95
+ "baseImages": source["baseImages"],
96
+ }
97
+
98
+
99
+ def target_of(parsed: dict) -> Path:
100
+ options, positionals = parsed["options"], parsed["positionals"]
101
+ return Path(str(options.get("dir") or (positionals[0] if positionals else "dsh-cloud"))).resolve()
102
+
103
+
104
+ def docker_argv(target: Path, project: str, action: list[str] | None = None) -> list[str]:
105
+ return ["docker", "compose", "--project-directory", str(target), "--project-name", project,
106
+ "--env-file", str(target / ".env"), "-f", str(target / "docker-compose.yml"),
107
+ *(action or ["up", "-d", "--wait"])]
108
+
109
+
110
+ def valid_domain(value: object) -> bool:
111
+ if not isinstance(value, str) or len(value) > 253 or "." not in value:
112
+ return False
113
+ return all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in value.split("."))
114
+
115
+
116
+ def validate_options(options: dict, mode: str, project: str) -> None:
117
+ if not PROJECT_NAME_PATTERN.fullmatch(project):
118
+ raise CliError("invalid project name")
119
+ if mode != "selfhost":
120
+ return
121
+ if not valid_domain(options.get("domain")):
122
+ raise CliError("--domain must be a hostname without a scheme or port")
123
+ email = options.get("adminEmail")
124
+ if not isinstance(email, str) or len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
125
+ raise CliError("--admin-email must be a valid email address")
126
+
127
+
128
+ def plan(parsed: dict, manifest: dict) -> dict:
129
+ target = target_of(parsed)
130
+ options = parsed["options"]
131
+ mode = str(options.get("mode", "trial"))
132
+ if mode not in {"trial", "selfhost"}:
133
+ raise CliError(f"invalid mode: {mode}")
134
+ project = str(options.get("projectName", "dsh-selfhost"))
135
+ validate_options(options, mode, project)
136
+ trial = mode == "trial"
137
+ return {
138
+ "ok": True,
139
+ "dryRun": bool(options.get("dryRun")),
140
+ "command": parsed["command"],
141
+ "mode": mode,
142
+ "directory": str(target),
143
+ "projectName": project,
144
+ "bindAddress": "127.0.0.1" if trial else "0.0.0.0",
145
+ "url": TRIAL_PUBLIC_BASE if trial else f"https://{options.get('domain', '')}",
146
+ "publicBaseUrl": TRIAL_PUBLIC_BASE if trial else f"https://{options.get('domain', '')}",
147
+ "version": manifest["version"],
148
+ "dockerArgv": docker_argv(target, project, action_for(parsed["command"], options)),
149
+ }
150
+
151
+
152
+ def atomic_write(path: Path, value: str, mode: int = 0o644) -> None:
153
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
154
+ temporary = path.with_name(f"{path.name}.tmp-{os.getpid()}-{secrets.token_hex(4)}")
155
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
156
+ try:
157
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
158
+ handle.write(value)
159
+ handle.flush()
160
+ os.fsync(handle.fileno())
161
+ os.chmod(temporary, mode)
162
+ os.replace(temporary, path)
163
+ finally:
164
+ if temporary.exists():
165
+ temporary.unlink()
166
+
167
+
168
+ def template_roots() -> tuple[Path, Path]:
169
+ packaged = Path(__file__).with_name("templates")
170
+ if (packaged / "docker-compose.yml").is_file():
171
+ return packaged, packaged / "config"
172
+ root = repository_root()
173
+ return root / "deploy/selfhost", root / "server/config"
174
+
175
+
176
+ def generated_env(parsed: dict, manifest: dict) -> str:
177
+ options = parsed["options"]
178
+ mode = str(options.get("mode", "trial"))
179
+ trial = mode == "trial"
180
+ if not trial and (not options.get("domain") or not options.get("adminEmail")):
181
+ raise CliError("--domain and --admin-email are required in selfhost mode")
182
+ domain = "localhost" if trial else str(options["domain"])
183
+ public_base = TRIAL_PUBLIC_BASE if trial else f"https://{domain}"
184
+ values = [
185
+ ("COMPOSE_PROJECT_NAME", str(options.get("projectName", "dsh-selfhost"))),
186
+ ("DOMAIN", domain), ("SITE_SCHEME", "http" if trial else "https"),
187
+ ("PUBLIC_BASE", public_base), ("BIND_ADDRESS", "127.0.0.1" if trial else "0.0.0.0"),
188
+ ("DSH_SITE", TRIAL_CADDY_SITE if trial else f"https://{domain}"),
189
+ ("HTTP_PORT", "8787" if trial else "80"), ("HTTPS_PORT", "8443" if trial else "443"),
190
+ ("DHC_DEV", "1" if trial else "0"), ("DHC_CONFIG_DIR", "./config"),
191
+ ("PRICING_FILE", "pricing.cny.json"), ("AUTH_SECRET_FILE", "/run/secrets/auth_secret"),
192
+ ("UPSTREAM_BASE_URL", "https://api.deepseek.com/v1"), ("UPSTREAM_API_KEY", ""),
193
+ ("ADMIN_EMAILS", str(options.get("adminEmail", ""))),
194
+ ("MAIL_SMTP_HOST", ""), ("MAIL_SMTP_USER", ""), ("MAIL_SMTP_PASS", ""), ("MAIL_FROM", ""),
195
+ ("GOOGLE_LOGIN_CLIENT_ID", ""), ("GOOGLE_LOGIN_CLIENT_SECRET", ""),
196
+ ("GITHUB_LOGIN_CLIENT_ID", ""), ("GITHUB_LOGIN_CLIENT_SECRET", ""),
197
+ ("DHC_SERVER_IMAGE", manifest["images"]["server"]),
198
+ ("CADDY_IMAGE", manifest["baseImages"]["caddy"]),
199
+ ("POSTGRES_IMAGE", manifest["baseImages"]["postgres"]),
200
+ ("SOCKET_PROXY_IMAGE", manifest["baseImages"]["socketProxy"]),
201
+ ("WORK_IMAGE", manifest["images"]["workspace"]),
202
+ ]
203
+ return "".join(f"{key}={value}\n" for key, value in values)
204
+
205
+
206
+ def initialize(parsed: dict, manifest: dict, deployment_plan: dict | None = None) -> Path:
207
+ deployment_plan = deployment_plan or plan(parsed, manifest)
208
+ target = target_of(parsed)
209
+ if target.exists():
210
+ entries = list(target.iterdir())
211
+ if (target / ".dsh-cloud").exists():
212
+ raise CliError(f"deployment is already initialized: {target}")
213
+ if entries:
214
+ raise CliError(f"refusing to overwrite non-empty directory: {target}")
215
+ target.mkdir(parents=True, exist_ok=True, mode=0o700)
216
+ templates, config = template_roots()
217
+ for name in ("docker-compose.yml", "Caddyfile", "compose.build.yml", "compose.postgres.yml"):
218
+ if (templates / name).is_file():
219
+ shutil.copy2(templates / name, target / name)
220
+ compose_path = target / "docker-compose.yml"
221
+ compose = compose_path.read_text(encoding="utf-8").replace(
222
+ " - dhc-data:/app/data\n",
223
+ " - dhc-data:/app/data\n - ./secrets/auth_secret:/run/secrets/auth_secret:ro\n",
224
+ )
225
+ atomic_write(compose_path, compose)
226
+ shutil.copytree(config, target / "config")
227
+ secret = os.environ.get("DSH_CLOUD_TEST_RANDOM_HEX", secrets.token_hex(32))
228
+ atomic_write(target / "secrets/auth_secret", f"{secret}\n", 0o600)
229
+ atomic_write(target / ".env", generated_env(parsed, manifest), 0o600)
230
+ atomic_write(target / ".gitignore", ".env\nsecrets/\n.dsh-cloud/lock\n")
231
+ state = {
232
+ "schemaVersion": 1, "version": manifest["version"], "stackSchema": manifest["stackSchema"],
233
+ "mode": parsed["options"].get("mode", "trial"),
234
+ "projectName": parsed["options"].get("projectName", "dsh-selfhost"),
235
+ "publicBaseUrl": deployment_plan["publicBaseUrl"],
236
+ "composeFiles": ["docker-compose.yml"],
237
+ }
238
+ atomic_write(target / ".dsh-cloud/state.json", json.dumps(state, indent=2) + "\n", 0o600)
239
+ return target
240
+
241
+
242
+ def action_for(command: str, options: dict) -> list[str]:
243
+ if command in {"up", "start"}:
244
+ return ["up", "-d", "--wait"]
245
+ if command == "down":
246
+ return ["down"]
247
+ if command == "status":
248
+ return ["ps", "--format", "json"]
249
+ if command == "logs":
250
+ return ["logs", *(["--follow"] if options.get("follow") else [])]
251
+ return ["config", "--quiet"]
252
+
253
+
254
+ def restore_deployment(value: dict, state_path: Path, parsed: dict) -> None:
255
+ try:
256
+ persisted = json.loads(state_path.read_text(encoding="utf-8"))
257
+ except (OSError, json.JSONDecodeError) as error:
258
+ raise CliError(f"invalid deployment state: {state_path}") from error
259
+ project_name = persisted.get("projectName")
260
+ if not isinstance(project_name, str) or not project_name:
261
+ raise CliError(f"invalid deployment project name: {state_path}")
262
+ environment: dict[str, str] = {}
263
+ try:
264
+ for line in (state_path.parent.parent / ".env").read_text(encoding="utf-8").splitlines():
265
+ if line and not line.startswith("#") and "=" in line:
266
+ key, setting = line.split("=", 1)
267
+ environment[key] = setting
268
+ except OSError:
269
+ pass
270
+ value["projectName"] = project_name
271
+ value["mode"] = persisted.get("mode", value["mode"])
272
+ value["publicBaseUrl"] = environment.get("PUBLIC_BASE") or persisted.get("publicBaseUrl") or value["publicBaseUrl"]
273
+ value["url"] = value["publicBaseUrl"]
274
+ value["bindAddress"] = environment.get("BIND_ADDRESS") or ("127.0.0.1" if value["mode"] == "trial" else "0.0.0.0")
275
+ value["dockerArgv"] = docker_argv(
276
+ Path(value["directory"]), value["projectName"], action_for(parsed["command"], parsed["options"])
277
+ )
278
+
279
+
280
+ def parse_compose_output(stdout: str) -> object | None:
281
+ output = stdout.strip()
282
+ if not output:
283
+ return None
284
+ try:
285
+ return json.loads(output)
286
+ except json.JSONDecodeError:
287
+ return output
288
+
289
+
290
+ def public_identity_configured(target: Path) -> bool:
291
+ environment: dict[str, str] = {}
292
+ for line in (target / ".env").read_text(encoding="utf-8").splitlines():
293
+ if line and not line.startswith("#") and "=" in line:
294
+ key, value = line.split("=", 1)
295
+ environment[key] = value.strip()
296
+ return bool(
297
+ (environment.get("MAIL_SMTP_HOST") and (environment.get("MAIL_FROM") or environment.get("MAIL_SMTP_USER")))
298
+ or (environment.get("GOOGLE_LOGIN_CLIENT_ID") and environment.get("GOOGLE_LOGIN_CLIENT_SECRET"))
299
+ or (environment.get("GITHUB_LOGIN_CLIENT_ID") and environment.get("GITHUB_LOGIN_CLIENT_SECRET"))
300
+ )
301
+
302
+
303
+ def execute(parsed: dict) -> dict:
304
+ manifest = load_manifest()
305
+ if parsed.get("special") == "help":
306
+ return {"text": HELP}
307
+ if parsed.get("special") == "version":
308
+ return {"text": manifest["version"] + "\n"}
309
+ value = plan(parsed, manifest)
310
+ if parsed["command"] == "init":
311
+ if parsed["options"].get("dryRun"):
312
+ return {"json": value}
313
+ initialize(parsed, manifest, value)
314
+ return {"json": {**value, "initialized": True}}
315
+ state = Path(value["directory"]) / ".dsh-cloud/state.json"
316
+ if parsed["command"] == "start" and not state.is_file() and not parsed["options"].get("dryRun"):
317
+ initialize(parsed, manifest, value)
318
+ if state.is_file():
319
+ restore_deployment(value, state, parsed)
320
+ if parsed["options"].get("dryRun"):
321
+ return {"json": value}
322
+ if not state.is_file():
323
+ raise CliError(f"not an initialized deployment: {value['directory']}")
324
+ if (
325
+ value["mode"] == "selfhost"
326
+ and parsed["command"] in {"start", "up", "doctor"}
327
+ and not public_identity_configured(Path(value["directory"]))
328
+ ):
329
+ raise CliError(
330
+ f"public self-host requires SMTP or OAuth for the first verified account; "
331
+ f"edit {Path(value['directory']) / '.env'} and run dsh-cloud up"
332
+ )
333
+ argv = docker_argv(Path(value["directory"]), value["projectName"], action_for(parsed["command"], parsed["options"]))
334
+ prefix = json.loads(os.environ["DSH_CLOUD_TEST_COMMAND_JSON"]) if os.environ.get("DSH_CLOUD_TEST_COMMAND_JSON") else []
335
+ capture = bool(parsed["options"].get("json"))
336
+ result = subprocess.run(
337
+ [*prefix, *argv] if prefix else argv,
338
+ cwd=value["directory"], check=False, capture_output=capture, text=capture,
339
+ )
340
+ if result.returncode:
341
+ raise CliError(f"Docker Compose exited with status {result.returncode}", result.returncode)
342
+ if capture:
343
+ response = {**value, "dockerArgv": argv}
344
+ output = parse_compose_output(result.stdout)
345
+ if output is not None:
346
+ response["composeOutput"] = output
347
+ if result.stderr.strip():
348
+ response["composeError"] = result.stderr.strip()
349
+ return {"json": response}
350
+ return {"text": value["url"] + "\n" if parsed["command"] in {"start", "up"} else ""}
351
+
352
+
353
+ def main(argv: list[str] | None = None) -> int:
354
+ values = list(sys.argv[1:] if argv is None else argv)
355
+ try:
356
+ result = execute(parse_args(values))
357
+ if result.get("text"):
358
+ print(result["text"], end="")
359
+ if result.get("json") is not None:
360
+ print(json.dumps(result["json"], separators=(",", ":")))
361
+ return 0
362
+ except CliError as error:
363
+ if "--json" in values:
364
+ print(json.dumps({"ok": False, "error": str(error)}, separators=(",", ":")))
365
+ else:
366
+ print(f"error: {error}", file=sys.stderr)
367
+ return error.exit_code
@@ -0,0 +1,22 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "version": "0.2.0",
4
+ "license": "LicenseRef-DSH-Cloud-Community-1.0",
5
+ "stackSchema": 1,
6
+ "databaseSchema": 1,
7
+ "minCliVersion": "0.2.0",
8
+ "minUpgradeFrom": "0.1.0",
9
+ "harnessRuntime": "0.1.0-rc.8",
10
+ "images": {
11
+ "server": "ghcr.io/agentsdanceai/dsh-cloud-server:0.2.0",
12
+ "workspace": "ghcr.io/agentsdanceai/dsh-cloud-workspace:0.2.0"
13
+ },
14
+ "baseImages": {
15
+ "python": "python:3.11-slim-bookworm@sha256:2e32f7d302adc1c37428355c1e646897c0c53f4fd60b6a551245fb90ee129f91",
16
+ "uv": "ghcr.io/astral-sh/uv:0.8.11@sha256:8101ad825250a114e7bef89eefaa73c31e34e10ffbe5aff01562740bac97553c",
17
+ "node": "node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03",
18
+ "caddy": "caddy:2@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d",
19
+ "postgres": "postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2",
20
+ "socketProxy": "tecnativa/docker-socket-proxy@sha256:1f5038b54f06c3e18422902cf00ba21803d1c97805aae032e5e6673d532d3459"
21
+ }
22
+ }
@@ -0,0 +1,67 @@
1
+ # =============================================================================
2
+ # DSH Cloud self-host — Caddy configuration
3
+ #
4
+ # Both site addresses come from docker-compose.yml (which derives them from
5
+ # .env), so nothing in this file needs editing for a normal deployment:
6
+ #
7
+ # DSH_SITE https://dsh.example.com -> automatic Let's Encrypt cert
8
+ # http://localhost -> local mode, no TLS
9
+ # DSH_WORK_SITE https://work.example.com -> cloud workspaces
10
+ # https://work.localhost -> workspaces off (internal CA,
11
+ # no public certificate request)
12
+ #
13
+ # Optional: to receive certificate-expiry notices from the CA, add a global
14
+ # options block as the FIRST thing in this file:
15
+ # {
16
+ # email you@example.com
17
+ # }
18
+ # =============================================================================
19
+
20
+ # --- main site: console, LLM gateway, payments, /releases, /preview ----------
21
+ {$DSH_SITE:http://localhost} {
22
+ # flush_interval -1 disables response buffering: the gateway streams SSE
23
+ # (every dsh request is stream:true) and buffering would stall replies.
24
+ reverse_proxy dhc-server:8100 {
25
+ flush_interval -1
26
+ }
27
+ }
28
+
29
+ # --- cloud workspaces: per-user dsh containers + PWA shell -------------------
30
+ # The following request-header transformations are required by the workspace
31
+ # protocol:
32
+ # header_up -Upgrade on the AUTH subrequest only — dsh's chat uses
33
+ # WebSocket upgrades (/api/events.mux, /api/events.host). With Upgrade
34
+ # present, uvicorn treats the HTTP-only authorization subrequest as a
35
+ # WebSocket handshake and rejects it.
36
+ # header_up Host 127.0.0.1:3080
37
+ # header_up Origin http://127.0.0.1:3080
38
+ # dsh only trusts requests that look like they came from its own loopback
39
+ # (reachability fence + CSRF origin check). Drop either line and the
40
+ # workspace answers 403 to everything.
41
+ {$DSH_WORK_SITE:https://work.localhost} {
42
+ # "/" and the PWA assets are served by the app: it fetches the container's
43
+ # index document and injects the manifest/service-worker/mobile CSS layers.
44
+ @pwa path / /index.html /manifest.webmanifest /sw.js /pwa/*
45
+ handle @pwa {
46
+ @rootdoc path / /index.html
47
+ rewrite @rootdoc /api/work/shell
48
+ reverse_proxy dhc-server:8100
49
+ }
50
+
51
+ # Everything else: the session cookie decides which container answers.
52
+ # /api/work/route ensures the user's container is running and replies
53
+ # 200 + "X-Work-Upstream: dshwork-<uid>:3081" (or a redirect to login /
54
+ # pricing / the starting page).
55
+ handle {
56
+ forward_auth dhc-server:8100 {
57
+ uri /api/work/route
58
+ copy_headers X-Work-Upstream
59
+ header_up -Upgrade
60
+ }
61
+ reverse_proxy {http.request.header.X-Work-Upstream} {
62
+ header_up Host 127.0.0.1:3080
63
+ header_up Origin http://127.0.0.1:3080
64
+ flush_interval -1
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,10 @@
1
+ services:
2
+ dhc-server:
3
+ pull_policy: build
4
+ build:
5
+ context: ../..
6
+ dockerfile: server/Dockerfile
7
+ args:
8
+ VERSION: "0.2.0"
9
+ PYTHON_IMAGE: python:3.11-slim-bookworm@sha256:2e32f7d302adc1c37428355c1e646897c0c53f4fd60b6a551245fb90ee129f91
10
+ UV_IMAGE: ghcr.io/astral-sh/uv:0.8.11@sha256:8101ad825250a114e7bef89eefaa73c31e34e10ffbe5aff01562740bac97553c
@@ -0,0 +1,36 @@
1
+ services:
2
+ dhc-server:
3
+ depends_on:
4
+ postgres:
5
+ condition: service_healthy
6
+ environment:
7
+ DB_BACKEND: postgres
8
+ POSTGRES_DSN: ${POSTGRES_DSN:-postgresql://dhc:${POSTGRES_PASSWORD:-dsh-cloud-local}@postgres:5432/dhc}
9
+
10
+ postgres:
11
+ image: ${POSTGRES_IMAGE:-postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2}
12
+ restart: unless-stopped
13
+ environment:
14
+ POSTGRES_DB: dhc
15
+ POSTGRES_USER: dhc
16
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dsh-cloud-local}
17
+ volumes:
18
+ # postgres:18 stores versioned data below /var/lib/postgresql/18/docker.
19
+ # Mount the parent or Docker creates an anonymous volume for the real DB.
20
+ - dhc-pgdata:/var/lib/postgresql
21
+ networks: [dhc-net]
22
+ healthcheck:
23
+ test: ["CMD-SHELL", "pg_isready -U dhc -d dhc"]
24
+ interval: 10s
25
+ timeout: 5s
26
+ retries: 10
27
+ security_opt: ["no-new-privileges:true"]
28
+ logging:
29
+ driver: json-file
30
+ options:
31
+ max-size: "10m"
32
+ max-file: "3"
33
+
34
+ volumes:
35
+ dhc-pgdata:
36
+ name: ${COMPOSE_PROJECT_NAME:-dsh-selfhost}_dhc-pgdata