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-0.2.0.dist-info/METADATA +27 -0
- dsh_cloud-0.2.0.dist-info/RECORD +23 -0
- dsh_cloud-0.2.0.dist-info/WHEEL +4 -0
- dsh_cloud-0.2.0.dist-info/entry_points.txt +2 -0
- dsh_cloud-0.2.0.dist-info/licenses/LICENSE +264 -0
- dsh_cloud_cli/__init__.py +1 -0
- dsh_cloud_cli/__main__.py +5 -0
- dsh_cloud_cli/cli.py +367 -0
- dsh_cloud_cli/release-manifest.json +22 -0
- dsh_cloud_cli/templates/Caddyfile +67 -0
- dsh_cloud_cli/templates/compose.build.yml +10 -0
- dsh_cloud_cli/templates/compose.postgres.yml +36 -0
- dsh_cloud_cli/templates/config/i18n/en.json +583 -0
- dsh_cloud_cli/templates/config/i18n/zh.json +583 -0
- dsh_cloud_cli/templates/config/models.curated.txt +37 -0
- dsh_cloud_cli/templates/config/models.json +227 -0
- dsh_cloud_cli/templates/config/pricing.cny.json +96 -0
- dsh_cloud_cli/templates/config/pricing.eur.json +96 -0
- dsh_cloud_cli/templates/config/pricing.gbp.json +96 -0
- dsh_cloud_cli/templates/config/pricing.hkd.json +96 -0
- dsh_cloud_cli/templates/config/pricing.jpy.json +96 -0
- dsh_cloud_cli/templates/config/pricing.usd.json +96 -0
- dsh_cloud_cli/templates/docker-compose.yml +159 -0
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
|