directops-operator 0.1.9__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.
- agent/__init__.py +6 -0
- agent/cli.py +146 -0
- agent/client.py +423 -0
- agent/discovery.py +229 -0
- agent/dops.py +408 -0
- agent/executor.py +388 -0
- agent/updater.py +182 -0
- agent/ws_transport.py +52 -0
- directops_operator-0.1.9.dist-info/METADATA +66 -0
- directops_operator-0.1.9.dist-info/RECORD +22 -0
- directops_operator-0.1.9.dist-info/WHEEL +5 -0
- directops_operator-0.1.9.dist-info/entry_points.txt +4 -0
- directops_operator-0.1.9.dist-info/top_level.txt +2 -0
- protocol/__init__.py +33 -0
- protocol/constants.py +36 -0
- protocol/crypto.py +46 -0
- protocol/frames.py +159 -0
- protocol/messages.py +271 -0
- protocol/state.py +99 -0
- protocol/tcp_transport.py +37 -0
- protocol/transport.py +29 -0
- protocol/ws_transport.py +46 -0
agent/discovery.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Machine capability discovery — runs at install time and on demand.
|
|
2
|
+
|
|
3
|
+
Probes the local system for installed tools, configured accounts, and
|
|
4
|
+
available credentials. Results are written to ~/.config/directops/capabilities.json
|
|
5
|
+
and sent to the Director in RegisterMessage.system_context on every connect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
CAPABILITIES_PATH = Path.home() / ".config" / "directops" / "capabilities.json"
|
|
21
|
+
|
|
22
|
+
# Curated categories for semantic context. The Director uses these to understand
|
|
23
|
+
# what *role* a tool plays (e.g. msmtp = email). The full available_tools list
|
|
24
|
+
# comes from a live PATH scan and includes everything on the machine.
|
|
25
|
+
_TOOLS: dict[str, list[str]] = {
|
|
26
|
+
"email": ["msmtp", "sendmail", "mutt", "mailx", "s-nail", "swaks"],
|
|
27
|
+
"vcs": ["git", "gh", "svn", "hg"],
|
|
28
|
+
"containers": ["docker", "podman", "kubectl", "helm", "compose", "docker-compose"],
|
|
29
|
+
"languages": ["python3", "python", "node", "go", "ruby", "java", "rustc", "php",
|
|
30
|
+
"perl", "lua", "elixir", "erlang", "scala", "kotlinc"],
|
|
31
|
+
"package_managers": ["apt", "apt-get", "brew", "dnf", "yum", "pacman", "zypper",
|
|
32
|
+
"pip3", "pip", "npm", "yarn", "pnpm", "cargo", "gem", "composer"],
|
|
33
|
+
"shell_tools": ["curl", "wget", "jq", "make", "cmake", "ssh", "rsync",
|
|
34
|
+
"tmux", "screen", "socat", "envsubst", "parallel"],
|
|
35
|
+
"editors": ["vim", "nvim", "nano", "emacs", "code", "helix", "micro"],
|
|
36
|
+
"network": ["nmap", "masscan", "arp-scan", "nc", "ncat", "netcat",
|
|
37
|
+
"ss", "ip", "ifconfig", "netstat", "tcpdump", "tshark",
|
|
38
|
+
"dig", "nslookup", "host", "ping", "ping6", "traceroute",
|
|
39
|
+
"tracepath", "mtr", "whois", "ngrep", "iftop", "nethogs",
|
|
40
|
+
"bmon", "iperf", "iperf3", "speedtest-cli", "bandwhich"],
|
|
41
|
+
"security": ["openssl", "gpg", "gpg2", "age", "sshpass", "ssh-keygen",
|
|
42
|
+
"fail2ban-client", "iptables", "ip6tables", "ufw", "nft",
|
|
43
|
+
"firewall-cmd", "apparmor_status", "semanage"],
|
|
44
|
+
"browsers": ["firefox", "firefox-esr", "chromium", "chromium-browser",
|
|
45
|
+
"google-chrome", "google-chrome-stable", "brave-browser",
|
|
46
|
+
"brave", "opera", "vivaldi", "lynx", "w3m", "links",
|
|
47
|
+
"elinks", "epiphany", "midori"],
|
|
48
|
+
"system": ["systemctl", "service", "journalctl", "crontab", "at",
|
|
49
|
+
"ps", "top", "htop", "btop", "lsof", "strace", "ltrace",
|
|
50
|
+
"perf", "dmesg", "lshw", "lscpu", "lsblk", "lspci", "lsusb",
|
|
51
|
+
"df", "du", "free", "vmstat", "iostat", "sar", "uptime",
|
|
52
|
+
"dmidecode", "hwinfo", "inxi"],
|
|
53
|
+
"database": ["mysql", "mysqldump", "psql", "pg_dump", "sqlite3",
|
|
54
|
+
"redis-cli", "mongosh", "mongo", "mongodump", "mongorestore",
|
|
55
|
+
"influx", "clickhouse-client"],
|
|
56
|
+
"automation": ["ansible", "ansible-playbook", "terraform", "puppet",
|
|
57
|
+
"chef", "salt", "fabric", "capistrano"],
|
|
58
|
+
"media": ["ffmpeg", "ffprobe", "convert", "magick", "identify",
|
|
59
|
+
"vlc", "mpv", "yt-dlp", "youtube-dl", "sox", "mediainfo"],
|
|
60
|
+
"archive": ["tar", "zip", "unzip", "7z", "gzip", "bzip2", "xz",
|
|
61
|
+
"zstd", "rar", "unrar", "lz4", "lzma"],
|
|
62
|
+
"text": ["grep", "sed", "awk", "sort", "uniq", "cut", "tr",
|
|
63
|
+
"xargs", "find", "fd", "rg", "ripgrep", "bat", "fzf",
|
|
64
|
+
"less", "head", "tail", "wc", "column", "jq", "yq",
|
|
65
|
+
"xmllint", "pandoc", "miller", "csvkit"],
|
|
66
|
+
"monitoring": ["prometheus", "grafana-server", "datadog-agent",
|
|
67
|
+
"telegraf", "collectd", "munin-run", "zabbix_agentd"],
|
|
68
|
+
"cloud": ["aws", "az", "gcloud", "doctl", "linode-cli", "vultr-cli",
|
|
69
|
+
"terraform", "vault", "consul", "nomad", "packer"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _probe_all_executables() -> list[str]:
|
|
74
|
+
"""Walk every directory in $PATH and return all executable file names.
|
|
75
|
+
|
|
76
|
+
This is the primary source of available_tools — it finds everything on
|
|
77
|
+
the machine regardless of whether it's in the curated _TOOLS list above.
|
|
78
|
+
nmap, browsers, custom scripts — anything in PATH shows up here.
|
|
79
|
+
"""
|
|
80
|
+
found: set[str] = set()
|
|
81
|
+
for dir_str in os.environ.get("PATH", "").split(os.pathsep):
|
|
82
|
+
d = Path(dir_str)
|
|
83
|
+
if not d.is_dir():
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
for entry in d.iterdir():
|
|
87
|
+
if (
|
|
88
|
+
entry.is_file()
|
|
89
|
+
and os.access(entry, os.X_OK)
|
|
90
|
+
and len(entry.name) >= 2
|
|
91
|
+
and not entry.name.startswith(".")
|
|
92
|
+
):
|
|
93
|
+
found.add(entry.name)
|
|
94
|
+
except PermissionError:
|
|
95
|
+
pass
|
|
96
|
+
return sorted(found)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _probe_tools() -> dict[str, dict[str, Any]]:
|
|
100
|
+
"""Check which curated tools are installed. Returns {tool: {available, path}}."""
|
|
101
|
+
found: dict[str, dict[str, Any]] = {}
|
|
102
|
+
for _category, tools in _TOOLS.items():
|
|
103
|
+
for tool in tools:
|
|
104
|
+
path = shutil.which(tool)
|
|
105
|
+
found[tool] = {"available": path is not None, "path": path or ""}
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _probe_email_accounts() -> dict[str, str]:
|
|
110
|
+
"""Parse ~/.msmtprc and return {account_name: from_address} mappings.
|
|
111
|
+
|
|
112
|
+
msmtp accounts are referenced by their NAME (e.g. 'gmail'), NOT by
|
|
113
|
+
email address. Always pass the account name to --account / -a.
|
|
114
|
+
"""
|
|
115
|
+
accounts: dict[str, str] = {}
|
|
116
|
+
msmtprc = Path.home() / ".msmtprc"
|
|
117
|
+
if not msmtprc.exists():
|
|
118
|
+
return accounts
|
|
119
|
+
try:
|
|
120
|
+
current: str | None = None
|
|
121
|
+
for raw in msmtprc.read_text().splitlines():
|
|
122
|
+
line = raw.strip()
|
|
123
|
+
if not line or line.startswith("#"):
|
|
124
|
+
continue
|
|
125
|
+
parts = line.split(None, 1)
|
|
126
|
+
key = parts[0].lower()
|
|
127
|
+
val = parts[1].strip() if len(parts) > 1 else ""
|
|
128
|
+
if key == "account" and ":" not in val:
|
|
129
|
+
current = val
|
|
130
|
+
elif key == "from" and "@" in val and current:
|
|
131
|
+
accounts[current] = val
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return accounts
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _probe_git_identity() -> str:
|
|
138
|
+
"""Return git user.email if configured."""
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["git", "config", "--global", "user.email"],
|
|
142
|
+
capture_output=True, text=True, timeout=5,
|
|
143
|
+
)
|
|
144
|
+
return result.stdout.strip()
|
|
145
|
+
except Exception:
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _probe_python_packages() -> list[str]:
|
|
150
|
+
"""Return list of notable installed Python packages."""
|
|
151
|
+
notable = ["requests", "httpx", "boto3", "paramiko", "fabric", "ansible"]
|
|
152
|
+
try:
|
|
153
|
+
out = subprocess.run(
|
|
154
|
+
["pip3", "show"] + notable,
|
|
155
|
+
capture_output=True, text=True, timeout=10,
|
|
156
|
+
).stdout
|
|
157
|
+
except Exception:
|
|
158
|
+
return []
|
|
159
|
+
found = []
|
|
160
|
+
for line in out.splitlines():
|
|
161
|
+
if line.startswith("Name:"):
|
|
162
|
+
found.append(line.split(":", 1)[1].strip())
|
|
163
|
+
return found
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run_discovery() -> dict[str, Any]:
|
|
167
|
+
"""Probe the machine and return a capabilities dict."""
|
|
168
|
+
import platform
|
|
169
|
+
import socket
|
|
170
|
+
|
|
171
|
+
logger.info("Running capability discovery…")
|
|
172
|
+
|
|
173
|
+
# Full PATH scan — the authoritative list of what's installed
|
|
174
|
+
all_executables = _probe_all_executables()
|
|
175
|
+
|
|
176
|
+
# Curated probe — adds semantic category context for the Director
|
|
177
|
+
categorized = _probe_tools()
|
|
178
|
+
|
|
179
|
+
msmtp_accounts = _probe_email_accounts()
|
|
180
|
+
git_email = _probe_git_identity()
|
|
181
|
+
|
|
182
|
+
context: dict[str, Any] = {
|
|
183
|
+
"hostname": socket.gethostname(),
|
|
184
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
185
|
+
"user": os.getenv("USER", os.getenv("LOGNAME", "unknown")),
|
|
186
|
+
"tools": categorized,
|
|
187
|
+
"accounts": {
|
|
188
|
+
"msmtp": msmtp_accounts, # {name: email} — use name with -a flag
|
|
189
|
+
"email": list(msmtp_accounts.values()),
|
|
190
|
+
"git": git_email,
|
|
191
|
+
},
|
|
192
|
+
"python_packages": _probe_python_packages(),
|
|
193
|
+
# Full PATH scan: every executable on the machine. This is what Devstral
|
|
194
|
+
# and Claude check when planning — nothing gets missed.
|
|
195
|
+
"available_tools": all_executables,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info(
|
|
199
|
+
"Discovery complete: %d executables in PATH, email accounts: %s",
|
|
200
|
+
len(all_executables),
|
|
201
|
+
list(msmtp_accounts.keys()) or "none",
|
|
202
|
+
)
|
|
203
|
+
return context
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_or_run() -> dict[str, Any]:
|
|
207
|
+
"""Load cached capabilities, or run discovery if cache is missing."""
|
|
208
|
+
if CAPABILITIES_PATH.exists():
|
|
209
|
+
try:
|
|
210
|
+
data = json.loads(CAPABILITIES_PATH.read_text())
|
|
211
|
+
logger.debug("Loaded capabilities from %s", CAPABILITIES_PATH)
|
|
212
|
+
return data
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return run_discovery()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def save(context: dict[str, Any]) -> None:
|
|
219
|
+
"""Write capabilities to ~/.config/directops/capabilities.json."""
|
|
220
|
+
CAPABILITIES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
CAPABILITIES_PATH.write_text(json.dumps(context, indent=2))
|
|
222
|
+
logger.info("Capabilities saved to %s", CAPABILITIES_PATH)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_and_save() -> dict[str, Any]:
|
|
226
|
+
"""Run discovery, save result, and return it."""
|
|
227
|
+
context = run_discovery()
|
|
228
|
+
save(context)
|
|
229
|
+
return context
|
agent/dops.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""dops — DirectOps Operator control CLI.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
dops start Start the operator service
|
|
5
|
+
dops stop Stop the operator service
|
|
6
|
+
dops restart Restart the operator service
|
|
7
|
+
dops status Show current status
|
|
8
|
+
dops enable Install + enable as systemd service (start on boot)
|
|
9
|
+
dops disable Disable + remove systemd service
|
|
10
|
+
dops update Check for and install updates
|
|
11
|
+
dops update --check Check only, do not install
|
|
12
|
+
dops config auto-update on|off Toggle automatic updates
|
|
13
|
+
dops config show Show current configuration
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SERVICE_NAME = "directops-operator"
|
|
26
|
+
CONFIG_DIR = Path.home() / ".config" / "directops"
|
|
27
|
+
ENV_FILE = CONFIG_DIR / "operator.env"
|
|
28
|
+
PID_FILE = CONFIG_DIR / "operator.pid"
|
|
29
|
+
SERVICE_FILE = Path(f"/etc/systemd/system/{SERVICE_NAME}.service")
|
|
30
|
+
|
|
31
|
+
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def _has_systemd() -> bool:
|
|
34
|
+
return shutil.which("systemctl") is not None and SERVICE_FILE.exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _systemctl(*args: str) -> subprocess.CompletedProcess:
|
|
38
|
+
cmd = ["sudo", "systemctl", *args, SERVICE_NAME]
|
|
39
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_pid() -> int | None:
|
|
43
|
+
try:
|
|
44
|
+
return int(PID_FILE.read_text().strip())
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _proc_running(pid: int) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
os.kill(pid, 0)
|
|
52
|
+
return True
|
|
53
|
+
except (ProcessLookupError, PermissionError):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── commands ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def cmd_start(args) -> None:
|
|
60
|
+
if _has_systemd():
|
|
61
|
+
r = _systemctl("start")
|
|
62
|
+
if r.returncode == 0:
|
|
63
|
+
print("Operator started (systemd).")
|
|
64
|
+
else:
|
|
65
|
+
print(f"Failed to start: {r.stderr.strip()}", file=sys.stderr)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
else:
|
|
68
|
+
pid = _read_pid()
|
|
69
|
+
if pid and _proc_running(pid):
|
|
70
|
+
print(f"Operator already running (PID {pid}).")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
operator_bin = shutil.which("directops-operator")
|
|
74
|
+
if not operator_bin:
|
|
75
|
+
print("directops-operator not found in PATH.", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
env_vars = _load_env()
|
|
79
|
+
cmd = [operator_bin]
|
|
80
|
+
if env_vars.get("DIRECTOR_URL"):
|
|
81
|
+
cmd += ["--url", env_vars["DIRECTOR_URL"]]
|
|
82
|
+
if env_vars.get("OPERATOR_TOKEN"):
|
|
83
|
+
cmd += ["--token", env_vars["OPERATOR_TOKEN"]]
|
|
84
|
+
if env_vars.get("MACHINE_NAME"):
|
|
85
|
+
cmd += ["--name", env_vars["MACHINE_NAME"]]
|
|
86
|
+
|
|
87
|
+
proc = subprocess.Popen(cmd, start_new_session=True)
|
|
88
|
+
PID_FILE.write_text(str(proc.pid))
|
|
89
|
+
print(f"Operator started (PID {proc.pid}).")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_stop(args) -> None:
|
|
93
|
+
if _has_systemd():
|
|
94
|
+
r = _systemctl("stop")
|
|
95
|
+
if r.returncode == 0:
|
|
96
|
+
print("Operator stopped.")
|
|
97
|
+
else:
|
|
98
|
+
print(f"Failed to stop: {r.stderr.strip()}", file=sys.stderr)
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
else:
|
|
101
|
+
pid = _read_pid()
|
|
102
|
+
if not pid or not _proc_running(pid):
|
|
103
|
+
print("Operator is not running.")
|
|
104
|
+
return
|
|
105
|
+
import signal
|
|
106
|
+
os.kill(pid, signal.SIGTERM)
|
|
107
|
+
PID_FILE.unlink(missing_ok=True)
|
|
108
|
+
print(f"Operator stopped (PID {pid}).")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_restart(args) -> None:
|
|
112
|
+
if _has_systemd():
|
|
113
|
+
r = _systemctl("restart")
|
|
114
|
+
if r.returncode != 0:
|
|
115
|
+
print(f"Failed to restart: {r.stderr.strip()}", file=sys.stderr)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
# Give it a moment, then confirm it didn't immediately crash-loop.
|
|
118
|
+
time.sleep(2)
|
|
119
|
+
active = _systemctl("is-active").stdout.strip()
|
|
120
|
+
if active == "active":
|
|
121
|
+
print("Operator restarted.")
|
|
122
|
+
else:
|
|
123
|
+
print(f"Operator restarted but is now '{active}' — check logs (dops logs).", file=sys.stderr)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
else:
|
|
126
|
+
cmd_stop(args)
|
|
127
|
+
cmd_start(args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_status(args) -> None:
|
|
131
|
+
from .updater import get_installed_version
|
|
132
|
+
version = get_installed_version()
|
|
133
|
+
|
|
134
|
+
if _has_systemd():
|
|
135
|
+
r = _systemctl("is-active")
|
|
136
|
+
active = r.stdout.strip()
|
|
137
|
+
r2 = subprocess.run(
|
|
138
|
+
["sudo", "systemctl", "status", SERVICE_NAME, "--no-pager", "-l"],
|
|
139
|
+
capture_output=True, text=True,
|
|
140
|
+
)
|
|
141
|
+
state = "running" if active == "active" else active
|
|
142
|
+
print(f"Operator v{version} — {state} (systemd)")
|
|
143
|
+
print(r2.stdout)
|
|
144
|
+
else:
|
|
145
|
+
pid = _read_pid()
|
|
146
|
+
if pid and _proc_running(pid):
|
|
147
|
+
print(f"Operator v{version} — running (PID {pid})")
|
|
148
|
+
else:
|
|
149
|
+
print(f"Operator v{version} — stopped")
|
|
150
|
+
|
|
151
|
+
env_vars = _load_env()
|
|
152
|
+
auto = env_vars.get("AUTO_UPDATE", "false")
|
|
153
|
+
url = env_vars.get("DIRECTOR_URL", "(not set)")
|
|
154
|
+
print(f" Director URL : {url}")
|
|
155
|
+
print(f" Auto-update : {auto}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_enable(args) -> None:
|
|
159
|
+
"""Install and enable systemd service."""
|
|
160
|
+
operator_bin = shutil.which("directops-operator")
|
|
161
|
+
if not operator_bin:
|
|
162
|
+
print("directops-operator not found in PATH.", file=sys.stderr)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
env_vars = _load_env()
|
|
166
|
+
user = os.environ.get("USER", os.environ.get("LOGNAME", ""))
|
|
167
|
+
service_content = f"""[Unit]
|
|
168
|
+
Description=DirectOps Operator
|
|
169
|
+
After=network-online.target
|
|
170
|
+
Wants=network-online.target
|
|
171
|
+
|
|
172
|
+
[Service]
|
|
173
|
+
Type=simple
|
|
174
|
+
User={user}
|
|
175
|
+
EnvironmentFile={ENV_FILE}
|
|
176
|
+
ExecStart={operator_bin} --url ${{DIRECTOR_URL}} --token ${{OPERATOR_TOKEN}} --name "${{MACHINE_NAME}}"
|
|
177
|
+
Restart=on-failure
|
|
178
|
+
RestartSec=10
|
|
179
|
+
|
|
180
|
+
[Install]
|
|
181
|
+
WantedBy=multi-user.target
|
|
182
|
+
"""
|
|
183
|
+
tmp = Path("/tmp/directops-operator.service")
|
|
184
|
+
tmp.write_text(service_content)
|
|
185
|
+
|
|
186
|
+
print("Admin password required to install the DirectOps systemd service.")
|
|
187
|
+
subprocess.check_call(["sudo", "-v"])
|
|
188
|
+
subprocess.check_call(["sudo", "cp", str(tmp), str(SERVICE_FILE)])
|
|
189
|
+
tmp.unlink()
|
|
190
|
+
subprocess.check_call(["sudo", "systemctl", "daemon-reload"])
|
|
191
|
+
subprocess.check_call(["sudo", "systemctl", "enable", "--now", SERVICE_NAME])
|
|
192
|
+
print(f"Service installed and enabled. Operator will start on boot.")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_disable(args) -> None:
|
|
196
|
+
"""Disable and remove systemd service."""
|
|
197
|
+
print("Admin password required to remove the DirectOps systemd service.")
|
|
198
|
+
subprocess.check_call(["sudo", "-v"])
|
|
199
|
+
subprocess.check_call(["sudo", "systemctl", "disable", "--now", SERVICE_NAME])
|
|
200
|
+
subprocess.check_call(["sudo", "rm", "-f", str(SERVICE_FILE)])
|
|
201
|
+
subprocess.check_call(["sudo", "systemctl", "daemon-reload"])
|
|
202
|
+
print("Service disabled and removed.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_update(args) -> None:
|
|
206
|
+
from .updater import (
|
|
207
|
+
fetch_latest, get_installed_version, is_newer, install_update
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
check_only = getattr(args, "check", False)
|
|
211
|
+
installed = get_installed_version()
|
|
212
|
+
print(f"Installed: v{installed}")
|
|
213
|
+
print("Checking for updates…")
|
|
214
|
+
|
|
215
|
+
manifest = fetch_latest()
|
|
216
|
+
if not manifest:
|
|
217
|
+
print("Could not reach update server.", file=sys.stderr)
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
latest = manifest.get("version", "")
|
|
221
|
+
if not is_newer(latest, installed):
|
|
222
|
+
print(f"Already up to date (v{installed}).")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
changelog = manifest.get("changelog", "")
|
|
226
|
+
print(f"Update available: v{latest}")
|
|
227
|
+
if changelog:
|
|
228
|
+
print(f" {changelog}")
|
|
229
|
+
|
|
230
|
+
if check_only:
|
|
231
|
+
print("Run `dops update` to install.")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
wheel_url = manifest.get("wheel_url", "")
|
|
235
|
+
sha256 = manifest.get("sha256", "")
|
|
236
|
+
if not wheel_url:
|
|
237
|
+
print("No download URL in manifest.", file=sys.stderr)
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
print(f"Installing v{latest}…")
|
|
241
|
+
if install_update(wheel_url, sha256):
|
|
242
|
+
print(f"Updated to v{latest}. Restart the operator to apply: dops restart")
|
|
243
|
+
else:
|
|
244
|
+
print("Update failed.", file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_logs(args) -> None:
|
|
249
|
+
"""Stream operator logs. Falls back to log file if systemd is unavailable."""
|
|
250
|
+
n = getattr(args, "lines", 50)
|
|
251
|
+
follow = getattr(args, "follow", False)
|
|
252
|
+
|
|
253
|
+
if _has_systemd():
|
|
254
|
+
cmd = ["journalctl", "-u", SERVICE_NAME, "--no-pager", "-n", str(n)]
|
|
255
|
+
if follow:
|
|
256
|
+
cmd.append("-f")
|
|
257
|
+
try:
|
|
258
|
+
subprocess.run(cmd)
|
|
259
|
+
except KeyboardInterrupt:
|
|
260
|
+
pass
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Fallback: look for a log file written by the operator
|
|
264
|
+
log_candidates = [
|
|
265
|
+
CONFIG_DIR / "operator.log",
|
|
266
|
+
Path("/tmp/directops-operator.log"),
|
|
267
|
+
]
|
|
268
|
+
for log_file in log_candidates:
|
|
269
|
+
if log_file.exists():
|
|
270
|
+
lines = log_file.read_text().splitlines()
|
|
271
|
+
print("\n".join(lines[-n:]))
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
print("No systemd service and no log file found. Run the operator via systemd for logs.", file=sys.stderr)
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def cmd_set_token(args) -> None:
|
|
279
|
+
"""Write a new token to operator.env and restart the service."""
|
|
280
|
+
token = args.token.strip()
|
|
281
|
+
if not token:
|
|
282
|
+
print("Error: token must not be empty.", file=sys.stderr)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
env_vars = _load_env()
|
|
287
|
+
env_vars["OPERATOR_TOKEN"] = token
|
|
288
|
+
|
|
289
|
+
lines = "\n".join(f"{k}={v}" for k, v in env_vars.items()) + "\n"
|
|
290
|
+
ENV_FILE.write_text(lines)
|
|
291
|
+
ENV_FILE.chmod(0o600)
|
|
292
|
+
print("Token updated.")
|
|
293
|
+
|
|
294
|
+
if _has_systemd():
|
|
295
|
+
r = _systemctl("restart")
|
|
296
|
+
if r.returncode == 0:
|
|
297
|
+
print("Operator restarted.")
|
|
298
|
+
else:
|
|
299
|
+
print(f"Restart failed: {r.stderr.strip()}", file=sys.stderr)
|
|
300
|
+
sys.exit(1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_config(args) -> None:
|
|
304
|
+
from .updater import set_auto_update_setting
|
|
305
|
+
|
|
306
|
+
sub = getattr(args, "setting", None)
|
|
307
|
+
|
|
308
|
+
if sub == "show" or sub is None:
|
|
309
|
+
env_vars = _load_env()
|
|
310
|
+
print("Current configuration:")
|
|
311
|
+
for k, v in env_vars.items():
|
|
312
|
+
display = v if k != "OPERATOR_TOKEN" else v[:8] + "…" if len(v) > 8 else v
|
|
313
|
+
print(f" {k}={display}")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
if sub == "auto-update":
|
|
317
|
+
value = getattr(args, "value", "").lower()
|
|
318
|
+
if value in ("on", "true", "1", "yes"):
|
|
319
|
+
set_auto_update_setting(True)
|
|
320
|
+
print("Auto-update enabled. Operator will update automatically on startup.")
|
|
321
|
+
elif value in ("off", "false", "0", "no"):
|
|
322
|
+
set_auto_update_setting(False)
|
|
323
|
+
print("Auto-update disabled. Run `dops update` to update manually.")
|
|
324
|
+
else:
|
|
325
|
+
print("Usage: dops config auto-update on|off", file=sys.stderr)
|
|
326
|
+
sys.exit(1)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── env file ───────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def _load_env() -> dict[str, str]:
|
|
332
|
+
result: dict[str, str] = {}
|
|
333
|
+
if not ENV_FILE.exists():
|
|
334
|
+
return result
|
|
335
|
+
for line in ENV_FILE.read_text().splitlines():
|
|
336
|
+
line = line.strip()
|
|
337
|
+
if line and not line.startswith("#") and "=" in line:
|
|
338
|
+
k, _, v = line.partition("=")
|
|
339
|
+
result[k.strip()] = v.strip()
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── CLI wiring ─────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
def main() -> None:
|
|
346
|
+
import argparse
|
|
347
|
+
|
|
348
|
+
parser = argparse.ArgumentParser(
|
|
349
|
+
prog="dops",
|
|
350
|
+
description="DirectOps Operator control CLI",
|
|
351
|
+
)
|
|
352
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
353
|
+
|
|
354
|
+
sub.add_parser("start", help="Start the operator service")
|
|
355
|
+
sub.add_parser("stop", help="Stop the operator service")
|
|
356
|
+
sub.add_parser("restart", help="Restart the operator service")
|
|
357
|
+
sub.add_parser("status", help="Show operator status")
|
|
358
|
+
sub.add_parser("enable", help="Install + enable systemd service (start on boot)")
|
|
359
|
+
sub.add_parser("disable", help="Disable + remove systemd service")
|
|
360
|
+
|
|
361
|
+
p_update = sub.add_parser("update", help="Check for and install updates")
|
|
362
|
+
p_update.add_argument(
|
|
363
|
+
"--check", action="store_true",
|
|
364
|
+
help="Check only — do not install",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
p_logs = sub.add_parser("logs", help="Show operator logs (live with -f)")
|
|
368
|
+
p_logs.add_argument("-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)")
|
|
369
|
+
p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log output in real time")
|
|
370
|
+
|
|
371
|
+
p_set_token = sub.add_parser("set-token", help="Update the auth token and restart the operator")
|
|
372
|
+
p_set_token.add_argument("token", help="New token from the DirectOps dashboard")
|
|
373
|
+
|
|
374
|
+
p_config = sub.add_parser("config", help="View or change configuration")
|
|
375
|
+
p_config.add_argument(
|
|
376
|
+
"setting", nargs="?", default="show",
|
|
377
|
+
choices=["show", "auto-update"],
|
|
378
|
+
help="Setting to view or change",
|
|
379
|
+
)
|
|
380
|
+
p_config.add_argument(
|
|
381
|
+
"value", nargs="?", default="",
|
|
382
|
+
help="Value for the setting (e.g. on|off for auto-update)",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
args = parser.parse_args()
|
|
386
|
+
|
|
387
|
+
dispatch = {
|
|
388
|
+
"start": cmd_start,
|
|
389
|
+
"stop": cmd_stop,
|
|
390
|
+
"restart": cmd_restart,
|
|
391
|
+
"status": cmd_status,
|
|
392
|
+
"logs": cmd_logs,
|
|
393
|
+
"enable": cmd_enable,
|
|
394
|
+
"disable": cmd_disable,
|
|
395
|
+
"update": cmd_update,
|
|
396
|
+
"set-token": cmd_set_token,
|
|
397
|
+
"config": cmd_config,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if not args.command:
|
|
401
|
+
parser.print_help()
|
|
402
|
+
sys.exit(0)
|
|
403
|
+
|
|
404
|
+
dispatch[args.command](args)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
if __name__ == "__main__":
|
|
408
|
+
main()
|