feat: initialize XAS test console with native execution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,10 @@
|
||||
*
|
||||
!console/
|
||||
!console/**
|
||||
!catalog/
|
||||
!catalog/*.json
|
||||
!deploy/
|
||||
!deploy/console.Dockerfile
|
||||
!deploy/console.Dockerfile.dockerignore
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
rundeck {
|
||||
org.rundeck.jaas.jetty.ReloadablePropertyFileLoginModule required
|
||||
debug="false"
|
||||
file="/home/rundeck/secure/realm.properties";
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"address": "10.88.118.188",
|
||||
"allowed_cidrs": ["10.88.0.0/17", "10.20.0.0/21"],
|
||||
"port": 14440
|
||||
}
|
||||
@@ -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;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
@@ -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()
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Password login is disabled. The private backend accepts the proxy's fixed operator identity.
|
||||
+111
@@ -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()
|
||||
Reference in New Issue
Block a user