feat: initialize XAS test console with native execution

This commit is contained in:
AdamWen95
2026-10-14 09:38:58 +08:00
commit 438844b01a
83 changed files with 9334 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM rundeck/rundeck:6.2.1@sha256:63d12aefbbd129a121d2af7d97abfaa64c3cbd7756063f74dbf3ab3377d136bd
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3=3.10.6-1~22.04.1 \
&& dpkg-query -W python3 > /opt/xas-runtime-packages.txt \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=1000:0 scripts /opt/xas-test-library/scripts
USER 1000
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=XAS script console private execution worker
After=docker.service
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/adamwen-work-space/xas-test-library
Environment=XAS_CONSOLE_ROOT=/adamwen-work-space/xas-test-library/runtime
Environment=XAS_WORKER_CONFIG=/adamwen-work-space/xas-test-library/deploy/console-worker.json
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 -m console.worker
User=root
Group=root
UMask=0022
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
+10
View File
@@ -0,0 +1,10 @@
ARG PYTHON_IMAGE=python@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
FROM ${PYTHON_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY console/requirements.txt /app/console/requirements.txt
RUN pip install --no-cache-dir -r /app/console/requirements.txt
COPY console /app/console
COPY catalog /app/catalog
ENTRYPOINT ["python3", "-m", "uvicorn", "console.app:create_app", "--factory", "--no-proxy-headers", "--no-access-log", "--host", "10.88.118.188"]
CMD ["--port", "14442"]
+10
View File
@@ -0,0 +1,10 @@
*
!console/
!console/**
!catalog/
!catalog/*.json
!deploy/
!deploy/console.Dockerfile
!deploy/console.Dockerfile.dockerignore
**/__pycache__
**/*.pyc
+90
View File
@@ -0,0 +1,90 @@
"""Cut over the verified dedicated console; preserve both previous deployments."""
from datetime import datetime, timezone
import json
from pathlib import Path
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from deploy.dedicated import ROOT, NAME, IMAGE, UNIT, command, prepare, web_arguments
from console.settings import Settings
from console.store import Store
PREVIEW = NAME + "-preview-20261013"
def inspect(name):
return json.loads(command("docker", "inspect", name))[0]
def get(port, path):
req = urllib.request.Request(f"http://10.88.118.188:{port}{path}", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=8) as response:
return json.load(response)
def main():
prepare()
preview = inspect(NAME)
if preview["Config"]["Labels"].get("xas.application") != "script-console":
raise RuntimeError("Unexpected preview owner")
if command("docker", "ps", "-aq", "--filter", "name=^/" + PREVIEW + "$"):
raise RuntimeError("Cutover has already been attempted; inspect saved state")
for name in ("adamwen-test-console", "adamwen-test-console-proxy"):
if inspect(name)["Config"]["Labels"].get("cn.aisuan.owner") != "adamwen-test-console":
raise RuntimeError("Unexpected old console owner")
old_runs = get(14440, "/api/41/project/xas-test-library/executions/running")
if not isinstance(old_runs.get("executions"), list):
raise RuntimeError("Cannot verify old console running tasks")
if get(14442, "/api/summary")["active"] or old_runs["executions"]:
raise RuntimeError("Console has running work; wait before cutover")
store = Store(Settings.from_env())
if not any(r["state"] == "succeeded" and r["config"]["profile"] == "legacy_smoke" for r in store.runs()):
raise RuntimeError("Successful single smoke acceptance missing")
backup = ROOT / "runtime/backups/before-dedicated-20261013"
backup.mkdir(mode=0o700, exist_ok=False)
command("systemctl", "stop", UNIT)
command("docker", "stop", NAME)
with sqlite3.connect(store.settings.db) as source, sqlite3.connect(backup / "console.sqlite3") as destination:
source.backup(destination)
if destination.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("Snapshot integrity failure")
for folder in ("library", "runs"):
shutil.copytree(ROOT / "runtime" / folder, backup / folder)
(backup / "metadata.json").write_text(json.dumps({"observed_at": datetime.now(timezone.utc).isoformat(),
"preview_image": preview["Image"], "new_image": IMAGE, "old_console": "adamwen-test-console",
"old_proxy": "adamwen-test-console-proxy"}, indent=2))
command("docker", "rename", NAME, PREVIEW)
command("docker", "stop", "adamwen-test-console")
command("docker", "stop", "adamwen-test-console-proxy")
try:
print(command(*web_arguments(14440)), flush=True)
command("systemctl", "start", UNIT)
for _ in range(20):
try:
if get(14440, "/api/summary")["worker"]["online"]:
print("CUTOVER_OK", flush=True)
break
except (OSError, ValueError):
pass
time.sleep(.5)
else:
raise RuntimeError("New entry point failed readiness check")
except Exception:
# Only revert app availability, never restore old data over newer records.
result = subprocess.run(["docker", "inspect", NAME], capture_output=True, text=True)
if result.returncode == 0 and json.loads(result.stdout)[0]["Config"]["Labels"].get("xas.application") == "script-console":
command("docker", "rm", "-f", NAME)
command("docker", "start", "adamwen-test-console-proxy")
command("docker", "start", "adamwen-test-console")
command("docker", "start", PREVIEW)
command("systemctl", "start", UNIT)
raise
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
"""First deployment / explicit port cutover. Only manages this application's resources."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
ROOT = Path("/adamwen-work-space/xas-test-library")
NAME = "adamwen-script-console"
IMAGE = "adamwen/script-console:20261013-5"
UNIT = "adamwen-script-worker.service"
def validate_runtime(root):
for path in (root / "runtime", *(root / "runtime" / name for name in ("console-data", "library", "runs", "native-ipc"))):
if not path.resolve().is_relative_to(root.resolve()):
raise ValueError("Runtime must remain inside this project")
if any(part.is_symlink() for part in (path, *path.parents)):
raise ValueError("Runtime symlink rejected")
def command(*args, timeout=30):
return subprocess.check_output(args, text=True, timeout=timeout).strip()
def web_arguments(port):
validate_runtime(ROOT)
runtime = ROOT / "runtime"
args = ["docker", "run", "-d", "--name", NAME, "--label", "xas.application=script-console",
"--runtime", "runc", "--network", "host", "--user", "0:0", "--cap-drop", "ALL",
"--security-opt", "no-new-privileges", "--read-only", "--memory", "512m", "--cpus", "1",
"--pids-limit", "96", "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=67108864",
"--log-driver", "local", "--log-opt", "max-size=10m", "--log-opt", "max-file=2",
"--env", "XAS_CONSOLE_ROOT=" + str(runtime)]
for name in ("console-data", "library", "runs", "native-ipc"):
path = runtime / name
args += ["--mount", f"type=bind,src={path},dst={path}" + (",readonly" if name in {"runs", "native-ipc"} else "")]
sources = json.loads((ROOT / "catalog/external-sources.json").read_text())["sources"]
for source in sources:
path = Path(source["path"])
if path.is_dir():
args += ["--mount", f"type=bind,src={path},dst={path},readonly"]
if path.name == "scripts":
for folder in ("logs", "results"):
logs = path.parent / folder
if logs.is_dir():
args += ["--mount", f"type=bind,src={logs},dst={logs},readonly"]
return args + [IMAGE, "--port", str(port)]
def prepare():
if os.geteuid() != 0 or ROOT.resolve() != ROOT or not (ROOT / "console/worker.py").is_file():
raise RuntimeError("Run through the documented sudo flow in the confirmed project")
validate_runtime(ROOT)
for name in ("console-data", "library", "runs", "native-ipc"):
path = ROOT / "runtime" / name
if path.is_symlink():
raise RuntimeError("Runtime symlink rejected")
path.mkdir(mode=0o750, exist_ok=True)
runner = command("docker", "image", "inspect", "--format", "{{.Id}}", "adamwen/test-console:20261013-2")
config = {"project": str(ROOT), "runner_image": runner,
"smoke_upstream": "http://100.64.0.55:18100",
"smoke_credential": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env"}
path = ROOT / "deploy/console-worker.json"
if path.exists() and json.loads(path.read_text()) != config:
raise RuntimeError("Existing worker configuration differs; review before replacing")
path.write_text(json.dumps(config, indent=2))
path.chmod(0o600)
# Existing roots are 0700/root. Container root has no capabilities and read-only mounts.
# Runtime code used by non-root jobs needs read access, without changing original scripts.
for p in (ROOT / "console").rglob("*"):
p.chmod(0o755 if p.is_dir() else 0o644)
(ROOT / "console").chmod(0o755)
def start(port):
existing = command("docker", "ps", "-aq", "--filter", "name=^/" + NAME + "$")
if existing:
raise RuntimeError("Console container already exists; inspect before replacement")
with socket.socket() as probe:
probe.bind(("10.88.118.188", port))
print(command(*web_arguments(port)))
service = Path("/etc/systemd/system") / UNIT
content = (ROOT / "deploy/console-worker.service").read_bytes()
if service.exists() and service.read_bytes() != content:
raise RuntimeError("Existing systemd service differs")
service.write_bytes(content)
command("systemctl", "daemon-reload")
# Start only; no automatic boot scheduling is enabled by this deployment.
command("systemctl", "start", UNIT)
print(command("systemctl", "is-active", UNIT))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, choices=[14440, 14442], default=14442)
options = parser.parse_args()
prepare()
start(options.port)
+5
View File
@@ -0,0 +1,5 @@
rundeck {
org.rundeck.jaas.jetty.ReloadablePropertyFileLoginModule required
debug="false"
file="/home/rundeck/secure/realm.properties";
};
+31
View File
@@ -0,0 +1,31 @@
- uuid: ef76123d-f532-42c7-b7f7-a78f9231c18d
name: 快速流程验收(模拟数据)
group: 00-platform-acceptance
description: |
验证网页、执行、日志与结果流程。不使用 GPU,不调用模型。
success:约一秒后成功;failure:主动失败;invalid-report:报告异常;wait:等待,用于测试超时或点击停止。
结果中的 synthetic=true 表示模拟验收数据,不能作为模型性能结果。
executionEnabled: true
scheduleEnabled: false
multipleExecutions: false
loglevel: INFO
timeout: 45s
nodeFilterEditable: false
options:
- name: mode
label: 验收场景
required: true
enforced: true
value: success
values: [success, failure, invalid-report, wait]
- name: timeout
label: 最长运行秒数
required: true
enforced: true
value: '8'
values: ['2', '5', '8', '15', '30']
sequence:
keepgoing: false
strategy: node-first
commands:
- exec: python3 -u /opt/xas-test-library/scripts/run_smoke.py
+5
View File
@@ -0,0 +1,5 @@
{
"address": "10.88.118.188",
"allowed_cidrs": ["10.88.0.0/17", "10.20.0.0/21"],
"port": 14440
}
+89
View File
@@ -0,0 +1,89 @@
"""Validated private-interface settings and a fixed-identity reverse proxy."""
import ipaddress
import json
PROXY_IMAGE = 'nginx:stable-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94'
NETWORK = 'adamwen-test-console-net'
PROXY = 'adamwen-test-console-proxy'
def validate_settings(value):
if not isinstance(value, dict) or set(value) != {'address', 'allowed_cidrs', 'port'}:
raise ValueError('Exactly address, allowed_cidrs and port must be configured')
address = ipaddress.ip_address(value['address'])
if not isinstance(value['allowed_cidrs'],list) or not 1 <= len(value['allowed_cidrs']) <= 8:
raise ValueError('Configure one to eight explicit private source networks')
networks = [ipaddress.ip_network(n, strict=True) for n in value['allowed_cidrs']]
private = [ipaddress.ip_network(n) for n in ('10.0.0.0/8','172.16.0.0/12','192.168.0.0/16')]
if address.version != 4 or any(n.version != 4 or not any(n.subnet_of(p) for p in private) for n in networks):
raise ValueError('An explicit RFC1918 IPv4 interface and network are required')
if not any(address in n and address not in (n.network_address,n.broadcast_address) for n in networks):
raise ValueError('The interface must belong to the allowed LAN subnet')
if type(value['port']) is not int or not 1024 <= value['port'] <= 65535:
raise ValueError('The port must be an integer between 1024 and 65535')
if len({str(n) for n in networks}) != len(networks):
raise ValueError('Duplicate source networks are not allowed')
return {'address': str(address), 'allowed_cidrs': [str(n) for n in networks], 'port': value['port']}
def load_settings(path):
return validate_settings(json.loads(path.read_text(encoding='utf-8')))
def proxy_config(settings):
value = validate_settings(settings)
address, port = value['address'], value['port']
allows = '\n '.join(f'allow {cidr};' for cidr in value['allowed_cidrs'])
origin = f'http://{address}:{port}'
return f'''worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {{ worker_connections 256; }}
http {{
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
log_format limited '$remote_addr [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent';
access_log /dev/stdout limited;
map $http_origin $origin_allowed {{ default 0; "" 1; "{origin}" 1; }}
map $http_sec_fetch_site $cross_site {{ default 0; cross-site 1; }}
server {{
listen 8080;
server_name {address};
{allows}
deny all;
if ($host != {address}) {{ return 403; }}
if ($origin_allowed = 0) {{ return 403; }}
if ($cross_site = 1) {{ return 403; }}
if ($arg_authtoken != "") {{ return 403; }}
client_max_body_size 1m;
keepalive_timeout 30s;
send_timeout 30s;
location / {{
proxy_pass http://127.0.0.1:4440;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host {address}:{port};
proxy_set_header X-Forwarded-Host {address}:{port};
proxy_set_header X-Forwarded-Port {port};
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Forwarded "";
proxy_set_header X-Forwarded-Uuid xas-intranet;
proxy_set_header X-Forwarded-Roles xas-operator;
proxy_set_header X-Forwarded-User-FirstName "";
proxy_set_header X-Forwarded-User-LastName "";
proxy_set_header X-Forwarded-User-Email "";
proxy_set_header Authorization "";
proxy_set_header X-Rundeck-Auth-Token "";
proxy_buffering off;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 30s;
}}
}}
}}
'''
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""One-time, scoped cutover from the loopback preview; preserve a recovery snapshot."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import tarfile
from start import CONTAINER, IMAGE, validate_runtime
OLD = 'adamwen-test-console-before-lan-20261013'
def run(args):
return subprocess.check_output(args, text=True, timeout=90).strip()
def inspect(name):
result = subprocess.run(['docker','inspect',name], capture_output=True, text=True)
return json.loads(result.stdout)[0] if result.returncode == 0 else None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project-root',type=Path,required=True)
args = parser.parse_args()
project = args.project_root.resolve(strict=True)
runtime = validate_runtime(project)
before = inspect(CONTAINER)
if before is None or before['Config'].get('Labels',{}).get('cn.aisuan.owner') != CONTAINER:
parser.error('Expected the existing project-owned preview container')
if before['HostConfig'].get('PortBindings',{}).get('4440/tcp') != [{'HostIp':'127.0.0.1','HostPort':'14440'}]:
parser.error('Expected the original loopback preview; do not repeat this migration')
if inspect(OLD) or inspect('adamwen-test-console-proxy'):
parser.error('Cutover containers already exist; inspect the saved outcome before retrying')
if inspect(IMAGE) is None:
parser.error('Build the new project image before cutover')
backup = runtime / 'backups/before-lan-20261013'
if not (backup/'source/deploy/start.py').is_file() or backup.is_symlink():
parser.error('The source snapshot must already exist inside this project')
archive = backup/'runtime-before-lan.tar.gz'
if archive.exists():
parser.error('A data snapshot already exists; refusing to overwrite it')
active = run(['docker','exec',CONTAINER,'python3','-c',
"from pathlib import Path;import os;print(len([p for p in Path('/proc').glob('[0-9]*/cmdline') if p.parent.name != str(os.getpid()) and any(x in p.read_bytes().split(b'\\x00') for x in (b'/opt/xas-test-library/scripts/run_smoke.py',b'/opt/xas-test-library/scripts/smoke_task.py'))]))"])
if active != '0':
parser.error('An acceptance task is still running; wait for it to finish')
for name in ('configure-login.sh','set_login.py'):
current, saved = project/'deploy'/name, backup/'source/deploy'/name
if current.exists() and hashlib.sha256(current.read_bytes()).digest() != hashlib.sha256(saved.read_bytes()).digest():
parser.error('A legacy source file has changed since backup; preserve the user change')
print(run(['docker','stop','--timeout','30',CONTAINER]),flush=True)
try:
with tarfile.open(archive,'x:gz') as tar:
for name in ('data','logs','projects','results','auth'):
source = runtime/name
if source.exists():
tar.add(source,arcname=name,recursive=True)
archive.chmod(0o600)
metadata = {'container_id':before['Id'],'image_id':before['Image'],
'started_at':before['State']['StartedAt'],'backup_container':OLD,
'database_snapshot':archive.name}
(backup/'metadata.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8')
print(run(['docker','rename',CONTAINER,OLD]),flush=True)
except Exception:
run(['docker','start',CONTAINER])
raise
# The two removed files are this project's reviewed password provisioning implementation.
for name in ('configure-login.sh','set_login.py'):
path=project/'deploy'/name
if path.exists():
path.unlink()
result = subprocess.run(['python3',str(project/'deploy/start.py'),'--project-root',str(project)])
if result.returncode:
raise SystemExit('New stack was not fully started. Old container and data snapshot are preserved; inspect before rollback.')
print(json.dumps({'cutover':'started','backup':str(backup),'previous_container':OLD}))
if __name__ == '__main__':
main()
+83
View File
@@ -0,0 +1,83 @@
"""Auditable, additive configuration migration; no source/history/schema changes."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console.native_plans import profile_for, validate_config
from console.settings import Settings
from console.store import Store, now
def migrate(store, backup, apply=False):
changes = []
for item in store.scripts():
profile = profile_for(store.settings, item)
if not profile:
continue
if hashlib.sha256(Path(item["source_path"]).read_bytes()).hexdigest() != item["sha256"]:
raise ValueError("Source changed: " + profile["id"])
updated = {**item}
updated["config"] = validate_config(store.settings, item,
{"timeout_seconds": profile.get("timeout_seconds", 3600), "native_options": item["config"].get("native_options", {})})
available = profile.get("available", True)
updated.update(enabled=available, blocked=not available,
status="ready" if available else "needs_configuration",
reason="" if available else profile.get("unavailable_reason", "原环境不可用"))
if all(updated[k] == item.get(k) for k in ("config", "enabled", "blocked", "status", "reason")):
continue
updated["updated_at"] = now()
changes.append((item, updated))
if len({profile_for(store.settings, item)["id"] for item in store.scripts() if profile_for(store.settings, item)}) != len(store.settings.native_catalog["profiles"]):
raise ValueError("Registered sources do not match trusted profiles")
if not apply:
return {"pending": len(changes), "applied": False}
if not changes:
return {"pending": 0, "applied": True}
with store.connect(write=True) as db:
if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone():
raise ValueError("Drain active runs before migrating")
saved = []
for old, updated in changes:
raw = db.execute("SELECT data FROM scripts WHERE id=?", (old["id"],)).fetchone()[0]
if json.loads(raw) != old:
raise ValueError("Concurrent configuration change; migration aborted")
saved.append({"id": old["id"], "before": raw, "after": json.dumps(updated)})
backup.parent.mkdir(parents=True, exist_ok=True)
with backup.open("x", encoding="utf-8") as handle:
json.dump({"created_at": now(), "scripts": saved}, handle, ensure_ascii=False, indent=2)
backup.chmod(0o600)
for item in saved:
db.execute("UPDATE scripts SET data=? WHERE id=?", (item["after"], item["id"]))
return {"pending": len(changes), "applied": True, "backup": str(backup)}
def restore(store, backup):
saved = json.loads(backup.read_text(encoding="utf-8"))["scripts"]
with store.connect(write=True) as db:
if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone():
raise ValueError("Drain active runs before rollback")
for item in saved:
current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone()[0]
if current not in (item["before"], item["after"]):
raise ValueError("Configuration changed after migration; review before rollback")
for item in saved:
db.execute("UPDATE scripts SET data=? WHERE id=?", (item["before"], item["id"]))
return {"restored": len(saved), "history_preserved": True}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--restore", action="store_true")
parser.add_argument("--backup", type=Path, required=True)
args = parser.parse_args()
if os.geteuid() != 0:
raise SystemExit("Run using the documented sudo flow")
if args.apply and args.restore:
parser.error("Choose apply or restore")
store = Store(Settings.from_env())
print(json.dumps(restore(store, args.backup) if args.restore else migrate(store, args.backup, args.apply)))
+30
View File
@@ -0,0 +1,30 @@
description: Operators may view and execute registered acceptance jobs only
context:
project: xas-test-library
for:
resource:
- equals:
kind: job
allow: [read]
- equals:
kind: event
allow: [read]
job:
- equals:
group: 00-platform-acceptance
allow: [read, run, kill]
node:
- allow: [read, run]
by:
group: xas-operator
---
description: Operators may see this project, but not create projects or commands
context:
application: rundeck
for:
project:
- equals:
name: xas-test-library
allow: [read]
by:
group: xas-operator
+1
View File
@@ -0,0 +1 @@
# Password login is disabled. The private backend accepts the proxy's fixed operator identity.
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Start only this project's CPU-only Rundeck container (run via normal sudo)."""
import argparse
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
CONTAINER = "adamwen-test-console"
IMAGE = "adamwen/test-console:20261013-2"
def validate_runtime(project):
project = project.resolve(strict=True)
runtime = project / "runtime"
candidates = [runtime] + [runtime / name for name in ("auth", "data", "logs", "projects", "results", "proxy")]
for path in candidates:
if path.is_symlink() or not path.resolve().is_relative_to(project):
raise ValueError("Runtime directories must remain inside this project without symlinks")
if path.exists() and not path.is_dir():
raise ValueError("Runtime paths must be directories")
return runtime
def main():
from lan_config import load_settings, proxy_config, PROXY_IMAGE, NETWORK, PROXY
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", type=Path, required=True)
args = parser.parse_args()
project = args.project_root.resolve(strict=True)
try:
runtime = validate_runtime(project)
except ValueError as exc:
parser.error(str(exc))
settings = load_settings(project / "deploy/lan-settings.json")
for name in (CONTAINER, PROXY):
existing = subprocess.run(["docker", "container", "inspect", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if existing.returncode == 0:
parser.error(f"{name} already exists; inspect it instead of replacing it")
with socket.socket() as probe:
probe.bind((settings['address'], settings['port']))
network = subprocess.run(["docker", "network", "inspect", NETWORK], capture_output=True, text=True)
if network.returncode == 0:
if json.loads(network.stdout)[0].get('Labels', {}).get('cn.aisuan.owner') != CONTAINER:
parser.error('Existing network does not belong to this project')
else:
subprocess.run(['docker','network','create','--label',f'cn.aisuan.owner={CONTAINER}',NETWORK],check=True)
for name in ("data", "logs", "projects", "results", "proxy"):
path = runtime / name
if path.is_symlink():
parser.error("Symlink runtime directories are forbidden")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chown(path, 1000, 0)
os.chmod(path, 0o700)
config = runtime / 'proxy/nginx.conf'
if config.is_symlink():
parser.error('Proxy config must not be a symlink')
config.write_text(proxy_config(settings), encoding='utf-8')
os.chmod(config, 0o644)
url = f"http://{settings['address']}:{settings['port']}"
command = ["docker", "run", "-d", "--name", CONTAINER,
"--label", "cn.aisuan.owner=adamwen-test-console",
"--runtime", "runc", "--user", "1000:0", "--init",
"--cpus", "2", "--memory", "2g", "--memory-swap", "2g",
"--pids-limit", "256", "--cap-drop", "ALL",
"--security-opt", "no-new-privileges:true",
"--restart", "no", "--network", f"container:{PROXY}",
"--log-opt", "max-size=10m", "--log-opt", "max-file=3",
"-e", f"RUNDECK_GRAILS_URL={url}",
"-e", "RUNDECK_SERVER_ADDRESS=127.0.0.1",
"-e", "RUNDECK_SERVER_FORWARDED=true",
"-e", "RUNDECK_PREAUTH_ENABLED=true",
"-e", "RUNDECK_PREAUTH_ATTRIBUTE_NAME=REMOTE_USER_GROUPS",
"-e", "RUNDECK_PREAUTH_USERNAME_HEADER=X-Forwarded-Uuid",
"-e", "RUNDECK_PREAUTH_ROLES_HEADER=X-Forwarded-Roles",
"-e", "RUNDECK_SERVER_SESSION_TIMEOUT=1800",
"-e", "RUNDECK_QUARTZ_THREADPOOL_THREADCOUNT=2",
"-e", "JVM_MAX_RAM_PERCENTAGE=60",
"-e", "NVIDIA_VISIBLE_DEVICES=void",
"-e", "CUDA_VISIBLE_DEVICES="]
mounts = [(project / 'deploy/realm.disabled.properties', "/home/rundeck/secure/realm.properties", "ro"),
(runtime / "data", "/home/rundeck/server/data", "rw"),
(runtime / "logs", "/home/rundeck/var/logs", "rw"),
(runtime / "projects", "/home/rundeck/projects", "rw"),
(runtime / "results", "/home/rundeck/test-results", "rw"),
(project / "deploy" / "operator.aclpolicy", "/home/rundeck/etc/xas-operator.aclpolicy", "ro"),
(project / "deploy" / "jaas-loginmodule.conf.template", "/etc/remco/templates/jaas-loginmodule.conf", "ro")]
for source, destination, access in mounts:
command += ["--mount", f"type=bind,src={source},dst={destination}" + (",readonly" if access == "ro" else "")]
command.append(IMAGE)
proxy_base = ['docker','run','--runtime','runc','--network',NETWORK,'--user','101:101',
'--cpus','0.25','--memory','64m','--pids-limit','32','--cap-drop','ALL',
'--security-opt','no-new-privileges:true','--read-only',
'--tmpfs','/tmp:rw,noexec,nosuid,size=16m',
'--mount',f'type=bind,src={config},dst=/etc/nginx/nginx.conf,readonly',
'--entrypoint','nginx']
subprocess.run(proxy_base + ['--rm',PROXY_IMAGE,'-t'],check=True)
subprocess.run(proxy_base + ['-d','--name',PROXY,'--label',f'cn.aisuan.owner={CONTAINER}',
'--publish',f"{settings['address']}:{settings['port']}:8080",
'--restart','no','--log-opt','max-size=5m','--log-opt','max-file=2',
PROXY_IMAGE,'-g','daemon off;'],check=True)
# Namespace loopback cannot be reached via the container IP from the shared host.
subprocess.run(command, check=True)
print(json.dumps({"container":CONTAINER,"proxy":PROXY,"url":url,"allowed_cidrs":settings['allowed_cidrs'],"gpu":False}))
if __name__ == "__main__":
main()