mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-30 18:56:16 +00:00
601 lines
22 KiB
Python
601 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
# PTOS: LAYER=INFRA / DOMAIN=ORCHESTRATOR / CONDITION=ALPHA
|
|
"""
|
|
unified_orchestrator.py — Unified Observe → Decide → Act → Emit loop.
|
|
|
|
Merges:
|
|
- infra_controller.py (node health probes via SSH)
|
|
- storage_agent.py (Garage/restic backup stack probes)
|
|
|
|
Both run in the same cycle. Decisions and actions are dispatched separately
|
|
but emitted as a single unified receipt. Hoxels recorded for every action.
|
|
|
|
Usage:
|
|
python3 4-Infrastructure/auto/orchestrator.py [options]
|
|
|
|
Options:
|
|
--once Run one cycle and exit (default)
|
|
--probe-only Observe only, emit receipt, no actions
|
|
--dry-run Show what would happen, don't execute
|
|
--loop Run in polling loop
|
|
--interval N Seconds between ticks (default: 300)
|
|
--dump Print receipt to stdout
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import os
|
|
import time
|
|
import json
|
|
import hashlib
|
|
import argparse
|
|
import shlex
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
AUTO_DIR = Path(__file__).resolve().parent
|
|
INFRA_DIR = AUTO_DIR.parent
|
|
sys.path.insert(0, str(INFRA_DIR))
|
|
|
|
from auto.lib.config import load_config
|
|
from auto.lib.receipt import read_chain, write_receipt, sha256
|
|
from auto.lib.probe import run_probe
|
|
from auto.lib.alerting import Alerter
|
|
from auto.lib.q16 import Q16_ONE
|
|
|
|
SCHEMA = "unified_orchestrator_receipt_v1"
|
|
VERSION = "1.0.0"
|
|
LOG_PATH = Path.home() / ".cache" / "orchestrator.jsonl"
|
|
|
|
BACKUP_SH = INFRA_DIR / "storage" / "restic" / "backup.sh"
|
|
CONSOLIDATE_SH = INFRA_DIR / "storage" / "garage" / "db-consolidate.sh"
|
|
|
|
|
|
# ── helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
def _ts() -> str:
|
|
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
|
|
|
|
def _log(msg: str) -> None:
|
|
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def _run(
|
|
args: list[str],
|
|
env_extra: dict[str, str] | None = None,
|
|
timeout: int = 120,
|
|
) -> tuple[int, str, str]:
|
|
import subprocess
|
|
env = os.environ.copy()
|
|
if env_extra:
|
|
env.update(env_extra)
|
|
try:
|
|
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=env)
|
|
return r.returncode, r.stdout, r.stderr
|
|
except subprocess.TimeoutExpired:
|
|
return -1, "", "timeout"
|
|
except Exception as e:
|
|
return -1, "", str(e)
|
|
|
|
|
|
def _send_alert(subject: str, body: str) -> None:
|
|
"""Send an alert email via the microvm-racknerd msmtp relay."""
|
|
import subprocess
|
|
target = os.environ.get("RS_ALERT_RELAY_SSH_TARGET", "root@100.101.247.127")
|
|
remote = (
|
|
"send-alert "
|
|
f"{shlex.quote(subject)} "
|
|
f"{shlex.quote(body)}"
|
|
)
|
|
cmd = [
|
|
"ssh", "-o", "StrictHostKeyChecking=no",
|
|
target,
|
|
remote,
|
|
]
|
|
try:
|
|
subprocess.run(cmd, capture_output=True, timeout=30)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _record_hoxel(action: str, outcome: str, domain: str = "infra") -> None:
|
|
import urllib.request
|
|
endpoint = os.environ.get(
|
|
"RS_HOXEL_ENDPOINT",
|
|
"http://100.101.247.127:8444/v1/hoxels/record",
|
|
)
|
|
payload = json.dumps({
|
|
"obj_key": f"orchestrator/{action}",
|
|
"bucket": "research-stack",
|
|
"to_tier": domain,
|
|
"spectral_mode": "orchestration",
|
|
"thermal_score": 0.0,
|
|
"residual": 0.0,
|
|
"payload_bytes": 0,
|
|
"density": 1.0,
|
|
"confidence": 1.0,
|
|
"semantic_load": 0.0,
|
|
}).encode()
|
|
try:
|
|
req = urllib.request.Request(
|
|
endpoint, data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Garage/restic credential loading (from storage_agent) ───────────────────────
|
|
|
|
def _load_garage_env() -> dict[str, str]:
|
|
env: dict[str, str] = {}
|
|
garage_env = Path("/etc/garage/garage.env")
|
|
if garage_env.exists():
|
|
try:
|
|
raw_lines = garage_env.read_text().splitlines()
|
|
except PermissionError:
|
|
raw_lines = []
|
|
for line in raw_lines:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
key, _, val = line.partition("=")
|
|
env[key.strip()] = val.strip()
|
|
return {
|
|
"AWS_ACCESS_KEY_ID": env.get("GARAGE_ACCESS_KEY_ID", os.environ.get("AWS_ACCESS_KEY_ID", "")),
|
|
"AWS_SECRET_ACCESS_KEY": env.get("GARAGE_SECRET_ACCESS_KEY", os.environ.get("AWS_SECRET_ACCESS_KEY", "")),
|
|
"AWS_DEFAULT_REGION": env.get("AWS_DEFAULT_REGION", os.environ.get("AWS_DEFAULT_REGION", "garage")),
|
|
"AWS_ENDPOINT_URL": env.get("AWS_ENDPOINT_URL", os.environ.get("AWS_ENDPOINT_URL", "http://localhost:3900")),
|
|
"RESTIC_REPOSITORY": os.environ.get("RESTIC_REPOSITORY", "s3:http://localhost:3900/research-stack"),
|
|
"RESTIC_PASSWORD_FILE": os.environ.get("RESTIC_PASSWORD_FILE", "/etc/garage/restic-password"),
|
|
}
|
|
|
|
|
|
# ── 1. OBSERVE ──────────────────────────────────────────────────────────────────
|
|
|
|
class StorageObservation:
|
|
def __init__(self) -> None:
|
|
self.garage_up: bool = False
|
|
self.garage_nodes_total: int = 0
|
|
self.garage_nodes_ok: int = 0
|
|
self.garage_buckets: list[str] = []
|
|
self.restic_snapshot_count: int = 0
|
|
self.restic_latest_ts: str | None = None
|
|
self.restic_latest_size_bytes: int = 0
|
|
self.restic_stored_bytes: int = 0
|
|
self.dedup_ratio_q16: int = 0
|
|
self.backup_log_last_ok: bool = False
|
|
self.cold_copy_needed: bool = False
|
|
self.errors: list[str] = []
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"garage_up": self.garage_up,
|
|
"garage_nodes_total": self.garage_nodes_total,
|
|
"garage_nodes_ok": self.garage_nodes_ok,
|
|
"garage_buckets": self.garage_buckets,
|
|
"restic": {
|
|
"snapshot_count": self.restic_snapshot_count,
|
|
"latest_ts": self.restic_latest_ts,
|
|
"latest_size_bytes": self.restic_latest_size_bytes,
|
|
"stored_bytes": self.restic_stored_bytes,
|
|
"dedup_ratio_q16": self.dedup_ratio_q16,
|
|
},
|
|
"backup_log_last_ok": self.backup_log_last_ok,
|
|
"cold_copy_needed": self.cold_copy_needed,
|
|
"errors": self.errors,
|
|
}
|
|
|
|
|
|
class InfraObservation:
|
|
def __init__(self) -> None:
|
|
self.probes: dict[str, Any] = {}
|
|
self.critical_down: list[str] = []
|
|
self.optional_unavailable: list[str] = []
|
|
self.backup_status: dict[str, Any] = {}
|
|
self.garage_status: dict[str, Any] = {}
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"probes": {
|
|
n: [r.to_dict() if hasattr(r, "to_dict") else r.__dict__ for r in rs]
|
|
for n, rs in self.probes.items()
|
|
},
|
|
"critical_down": self.critical_down,
|
|
"optional_unavailable": self.optional_unavailable,
|
|
"backup_status": self.backup_status,
|
|
"garage_status": self.garage_status,
|
|
}
|
|
|
|
|
|
def _probe_garage(obs: StorageObservation, creds: dict[str, str]) -> None:
|
|
import urllib.request
|
|
import urllib.error
|
|
try:
|
|
urllib.request.urlopen(creds["AWS_ENDPOINT_URL"], timeout=5)
|
|
obs.garage_up = True
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (403, 400):
|
|
obs.garage_up = True
|
|
else:
|
|
obs.errors.append(f"garage: HTTP {e.code}")
|
|
except Exception as exc:
|
|
obs.errors.append(f"garage: {exc}")
|
|
return
|
|
|
|
rc, out, err = _run(
|
|
["aws", "s3", "ls", "--endpoint-url", creds["AWS_ENDPOINT_URL"]],
|
|
env_extra=creds, timeout=30,
|
|
)
|
|
if rc == 0:
|
|
obs.garage_buckets = [line.split()[-1] for line in out.splitlines() if line.strip()]
|
|
|
|
|
|
def _probe_restic(obs: StorageObservation, creds: dict[str, str]) -> None:
|
|
if not obs.garage_up:
|
|
obs.errors.append("restic: skipped (Garage unreachable)")
|
|
return
|
|
rc, out, err = _run(["restic", "snapshots", "--json"], env_extra=creds, timeout=60)
|
|
if rc != 0:
|
|
obs.errors.append(f"restic snapshots: {err.strip()[:300]}")
|
|
return
|
|
try:
|
|
snapshots = json.loads(out)
|
|
except json.JSONDecodeError as e:
|
|
obs.errors.append(f"restic parse: {e}")
|
|
return
|
|
obs.restic_snapshot_count = len(snapshots)
|
|
if snapshots:
|
|
latest = sorted(snapshots, key=lambda s: s.get("time", ""), reverse=True)[0]
|
|
obs.restic_latest_ts = latest.get("time")
|
|
|
|
rc2, out2, err2 = _run(["restic", "stats", "--json"], env_extra=creds, timeout=120)
|
|
if rc2 == 0:
|
|
try:
|
|
stats = json.loads(out2)
|
|
pre = stats.get("total_file_size", 0)
|
|
post = stats.get("total_size", 0)
|
|
obs.restic_latest_size_bytes = pre
|
|
obs.restic_stored_bytes = post
|
|
if pre > 0:
|
|
ratio = max(0.0, 1.0 - (post / pre))
|
|
obs.dedup_ratio_q16 = int(ratio * Q16_ONE)
|
|
except (json.JSONDecodeError, KeyError, ZeroDivisionError) as e:
|
|
obs.errors.append(f"restic stats: {e}")
|
|
|
|
|
|
def _probe_backup_log(obs: StorageObservation) -> None:
|
|
log_path = Path.home() / ".cache" / "restic-backup.log"
|
|
if not log_path.exists():
|
|
return
|
|
try:
|
|
text = log_path.read_text(errors="replace")
|
|
obs.backup_log_last_ok = any("snapshot" in l and "saved" in l for l in text.splitlines())
|
|
except OSError as e:
|
|
obs.errors.append(f"backup log: {e}")
|
|
|
|
|
|
def _probe_cold_copy(obs: StorageObservation) -> None:
|
|
if obs.restic_snapshot_count == 0 or obs.restic_latest_ts is None:
|
|
return
|
|
try:
|
|
latest = datetime.fromisoformat(obs.restic_latest_ts.rstrip("Z").split("+")[0])
|
|
age = (datetime.now(timezone.utc).replace(tzinfo=None) - latest).total_seconds() / 3600
|
|
obs.cold_copy_needed = age > 26
|
|
except Exception as e:
|
|
obs.errors.append(f"cold copy: {e}")
|
|
|
|
|
|
def observe_storage(creds: dict[str, str]) -> StorageObservation:
|
|
obs = StorageObservation()
|
|
_probe_garage(obs, creds)
|
|
_probe_restic(obs, creds)
|
|
_probe_backup_log(obs)
|
|
_probe_cold_copy(obs)
|
|
return obs
|
|
|
|
|
|
def observe_infra(config: dict) -> tuple[InfraObservation, Alerter]:
|
|
obs = InfraObservation()
|
|
alerter = Alerter(config)
|
|
nodes = config.get("nodes", {})
|
|
tier_1 = set(config.get("tier_1_required", []))
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
futures = {}
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
for name, cfg in nodes.items():
|
|
ip = cfg["ip"]
|
|
target = cfg.get("ssh_target", f"root@{ip}")
|
|
optional = cfg.get("optional", False)
|
|
for pname in cfg.get("probes", []):
|
|
f = pool.submit(run_probe, name, ip, target, pname, optional)
|
|
futures[f] = (name, pname, optional)
|
|
|
|
for f in as_completed(futures):
|
|
name, pname, optional = futures[f]
|
|
try:
|
|
result = f.result()
|
|
except Exception as e:
|
|
result = type("PR", (), {})()
|
|
result.__dict__ = {"node": name, "ip": "", "optional": optional, "reachable": False, "error": str(e), "data": {}, "elapsed_ms": 0}
|
|
obs.probes.setdefault(name, []).append(result)
|
|
if not result.reachable and not optional:
|
|
obs.critical_down.append(name)
|
|
alerter.alert(f"node DOWN: {name} unreachable")
|
|
elif not result.reachable and optional:
|
|
obs.optional_unavailable.append(name)
|
|
alerter.note(f"optional node unavailable: {name}")
|
|
|
|
obs.critical_down = sorted(set(obs.critical_down))
|
|
obs.optional_unavailable = sorted(set(obs.optional_unavailable))
|
|
return obs, alerter
|
|
|
|
|
|
# ── 2. DECIDE ───────────────────────────────────────────────────────────────────
|
|
|
|
class UnifiedDecision:
|
|
def __init__(self) -> None:
|
|
self.storage_actions: list[str] = []
|
|
self.infra_actions: list[str] = []
|
|
self.alerts: list[str] = []
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"storage_actions": self.storage_actions,
|
|
"infra_actions": self.infra_actions,
|
|
"alerts": self.alerts,
|
|
}
|
|
|
|
|
|
Q16_DEDUP_LOW = 19661
|
|
|
|
|
|
def decide(
|
|
storage: StorageObservation,
|
|
infra: InfraObservation,
|
|
alerter: Alerter,
|
|
) -> UnifiedDecision:
|
|
d = UnifiedDecision()
|
|
|
|
# ── Storage decisions ───────────────────────────────────────────────────
|
|
if not storage.garage_up:
|
|
d.storage_actions.append("restart_garage")
|
|
d.alerts.append("Garage S3 unreachable")
|
|
if storage.restic_snapshot_count == 0 and storage.garage_up:
|
|
d.storage_actions.append("snap")
|
|
if not storage.backup_log_last_ok and storage.garage_up:
|
|
d.storage_actions.append("snap")
|
|
if (
|
|
storage.dedup_ratio_q16 > 0
|
|
and storage.dedup_ratio_q16 < Q16_DEDUP_LOW
|
|
and storage.restic_snapshot_count > 5
|
|
):
|
|
d.storage_actions.append("verify")
|
|
if storage.restic_snapshot_count > 30:
|
|
d.storage_actions.append("forget")
|
|
if storage.cold_copy_needed:
|
|
d.storage_actions.append("cold_copy")
|
|
if storage.garage_up:
|
|
d.storage_actions.append("offload")
|
|
|
|
# ── Infra decisions ────────────────────────────────────────────────────
|
|
has_infra_issues = bool(
|
|
infra.critical_down
|
|
or infra.backup_status.get("snapshot_count", 0) == 0
|
|
or infra.garage_status.get("healthy_nodes", 0) < 3
|
|
)
|
|
if has_infra_issues:
|
|
d.infra_actions.append("dispatch_alerts")
|
|
|
|
d.alerts.extend(alerter.alerts)
|
|
return d
|
|
|
|
|
|
# ── 3. ACT ──────────────────────────────────────────────────────────────────────
|
|
|
|
class UnifiedActionResult:
|
|
def __init__(self) -> None:
|
|
self.storage_succeeded: list[str] = []
|
|
self.storage_failed: list[str] = []
|
|
self.infra_dispatched: list[str] = []
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"storage_succeeded": self.storage_succeeded,
|
|
"storage_failed": self.storage_failed,
|
|
"infra_dispatched": self.infra_dispatched,
|
|
}
|
|
|
|
|
|
def _act_one(
|
|
label: str,
|
|
args: list[str],
|
|
succeeded: list[str],
|
|
failed: list[str],
|
|
env_extra: dict[str, str] | None = None,
|
|
timeout: int = 600,
|
|
) -> None:
|
|
rc, out, err = _run(args, env_extra=env_extra, timeout=timeout)
|
|
if rc == 0:
|
|
succeeded.append(label)
|
|
_record_hoxel(label, "success", "storage")
|
|
else:
|
|
failed.append(label)
|
|
|
|
|
|
def act_storage(d: UnifiedDecision, creds: dict[str, str], probe_only: bool, dry_run: bool) -> tuple[list[str], list[str]]:
|
|
succeeded: list[str] = []
|
|
failed: list[str] = []
|
|
|
|
if probe_only or dry_run:
|
|
return succeeded, failed
|
|
|
|
for action in d.storage_actions:
|
|
if action == "restart_garage":
|
|
_act_one("restart_garage", ["systemctl", "--user", "restart", "garage.service"], succeeded, failed, timeout=30)
|
|
if "restart_garage" in failed:
|
|
_act_one("restart_garage_system", ["sudo", "systemctl", "restart", "garage.service"], succeeded, failed, timeout=30)
|
|
elif action == "snap":
|
|
_act_one("snap", ["bash", str(BACKUP_SH), "snap", "orchestrator-triggered"], succeeded, failed, env_extra=creds, timeout=3600)
|
|
elif action == "cold_copy":
|
|
_act_one("cold_copy", ["bash", str(BACKUP_SH), "cold-copy"], succeeded, failed, env_extra=creds, timeout=3600)
|
|
elif action == "verify":
|
|
_act_one("verify", ["bash", str(BACKUP_SH), "verify"], succeeded, failed, env_extra=creds, timeout=600)
|
|
elif action == "forget":
|
|
_act_one("forget", ["bash", str(BACKUP_SH), "forget"], succeeded, failed, env_extra=creds, timeout=600)
|
|
elif action == "offload":
|
|
_act_one("offload", ["bash", str(CONSOLIDATE_SH), "offload"], succeeded, failed, env_extra=creds, timeout=300)
|
|
|
|
return succeeded, failed
|
|
|
|
|
|
def act_infra(d: UnifiedDecision, alerter: Alerter, dry_run: bool) -> list[str]:
|
|
if dry_run:
|
|
return []
|
|
if "dispatch_alerts" in d.infra_actions:
|
|
result = alerter.send()
|
|
return list(result.keys())
|
|
return []
|
|
|
|
|
|
# ── 4. EMIT RECEIPT ─────────────────────────────────────────────────────────────
|
|
|
|
def build_receipt(
|
|
tick: int,
|
|
parent_hash: str,
|
|
storage_obs: StorageObservation,
|
|
infra_obs: InfraObservation,
|
|
decision: UnifiedDecision,
|
|
result: UnifiedActionResult,
|
|
) -> dict:
|
|
receipt = {
|
|
"schema": SCHEMA,
|
|
"version": VERSION,
|
|
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
"tick": tick,
|
|
"parent_hash": parent_hash,
|
|
"observation": {
|
|
"storage": storage_obs.to_dict(),
|
|
"infra": infra_obs.to_dict(),
|
|
},
|
|
"decision": decision.to_dict(),
|
|
"action_result": result.to_dict(),
|
|
"claim_boundary": "unified-orchestrator-observe-decide-act",
|
|
}
|
|
preimage = {k: v for k, v in receipt.items() if k not in ("generated_at_utc", "receipt_hash")}
|
|
receipt["receipt_hash"] = sha256(json.dumps(preimage, sort_keys=True))
|
|
return receipt
|
|
|
|
|
|
def emit_local(receipt: dict) -> None:
|
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(LOG_PATH, "a") as f:
|
|
f.write(json.dumps(receipt) + "\n")
|
|
write_receipt(receipt)
|
|
|
|
|
|
# ── 5. MAIN CYCLE ───────────────────────────────────────────────────────────────
|
|
|
|
def run_cycle(
|
|
config: dict,
|
|
creds: dict[str, str],
|
|
tick: int,
|
|
parent_hash: str,
|
|
probe_only: bool = False,
|
|
dry_run: bool = False,
|
|
) -> str:
|
|
_log(f"tick={tick} starting unified cycle...")
|
|
|
|
storage_obs = observe_storage(creds)
|
|
infra_obs, alerter = observe_infra(config)
|
|
|
|
dec = decide(storage_obs, infra_obs, alerter)
|
|
|
|
storage_ok, storage_fail = act_storage(dec, creds, probe_only=probe_only, dry_run=dry_run)
|
|
infra_dispatch = act_infra(dec, alerter, dry_run=dry_run)
|
|
|
|
result = UnifiedActionResult()
|
|
result.storage_succeeded = storage_ok
|
|
result.storage_failed = storage_fail
|
|
result.infra_dispatched = infra_dispatch
|
|
|
|
receipt = build_receipt(tick, parent_hash, storage_obs, infra_obs, dec, result)
|
|
emit_local(receipt)
|
|
|
|
summary = (
|
|
f"tick={tick} "
|
|
f"garage={'UP' if storage_obs.garage_up else 'DOWN'} "
|
|
f"snapshots={storage_obs.restic_snapshot_count} "
|
|
f"nodes_down={len(infra_obs.critical_down)} "
|
|
f"storage_ok={storage_ok} "
|
|
f"storage_fail={storage_fail} "
|
|
f"hash={receipt['receipt_hash'][:16]}..."
|
|
)
|
|
_log(summary)
|
|
|
|
for alert in dec.alerts:
|
|
_log(f" ALERT: {alert}")
|
|
if not dry_run:
|
|
_send_alert("Orchestrator Alert", alert)
|
|
|
|
if infra_obs.critical_down and not dry_run:
|
|
_send_alert("Node DOWN", f"Critical nodes unreachable: {', '.join(infra_obs.critical_down)}")
|
|
|
|
for h in storage_ok:
|
|
_record_hoxel(h, "success", "storage")
|
|
for h in storage_fail:
|
|
_record_hoxel(h, "failure", "storage")
|
|
|
|
return receipt["receipt_hash"]
|
|
|
|
|
|
# ── CLI ────────────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description="unified_orchestrator.py — unified infrastructure daemon")
|
|
p.add_argument("--once", action="store_true", default=True)
|
|
p.add_argument("--probe-only", action="store_true")
|
|
p.add_argument("--dry-run", action="store_true")
|
|
p.add_argument("--loop", action="store_true")
|
|
p.add_argument("--interval", type=int, default=300)
|
|
p.add_argument("--dump", action="store_true")
|
|
args = p.parse_args()
|
|
|
|
config = load_config()
|
|
creds = _load_garage_env()
|
|
|
|
tick, parent_hash = read_chain()
|
|
tick += 1
|
|
|
|
if args.loop:
|
|
_log(f"loop mode, interval={args.interval}s, resuming tick={tick}")
|
|
while True:
|
|
try:
|
|
parent_hash = run_cycle(config, creds, tick, parent_hash,
|
|
probe_only=args.probe_only,
|
|
dry_run=args.dry_run)
|
|
tick += 1
|
|
except Exception as e:
|
|
_log(f"cycle error: {e}")
|
|
import traceback
|
|
traceback.print_exc(file=sys.stderr)
|
|
time.sleep(args.interval)
|
|
else:
|
|
run_cycle(config, creds, tick, parent_hash,
|
|
probe_only=args.probe_only,
|
|
dry_run=args.dry_run)
|
|
if args.dump:
|
|
if LOG_PATH.exists():
|
|
with open(LOG_PATH) as f:
|
|
lines = f.readlines()
|
|
if lines:
|
|
print(lines[-1].strip())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|