NumericalOS

tests/test_numctl.py

back to source

"""Floor numctl: seeded unit execs must not name a vapor binary.

identity-init and join are the only implemented commands. Everything else
halts with a named reason. Control-socket query/command remains residual.
"""

import os
import shutil
import subprocess
import tempfile
import time
import unittest

from numos.state import render

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NUMCTL = os.path.join(ROOT, "boot", "numctl").replace("\\", "/")
NUMINIT = os.path.join(ROOT, "boot", "numinit.sh").replace("\\", "/")
BOOT = os.path.join(ROOT, "boot").replace("\\", "/")


def _bash():
    found = shutil.which("bash")
    if found:
        return found
    for candidate in (
            r"C:\Program Files\Git\bin\bash.exe",
            r"C:\Program Files\Git\usr\bin\bash.exe",
            "/usr/bin/bash",
            "/bin/bash"):
        if os.path.isfile(candidate):
            return candidate
    raise unittest.SkipTest("bash not found")


class NumctlCase(unittest.TestCase):
    def setUp(self):
        self.work = tempfile.mkdtemp()
        self.rundir = os.path.join(self.work, "run").replace("\\", "/")

    def tearDown(self):
        shutil.rmtree(self.work, ignore_errors=True)

    def run_numctl(self, *args, timeout=30):
        env = dict(os.environ)
        env["NUMOS_RUNDIR"] = self.rundir
        # boot/ on PATH so bare `numctl` resolves the way seed units expect.
        env["PATH"] = BOOT + os.pathsep + env.get("PATH", "")
        return subprocess.run(
            [_bash(), NUMCTL] + list(args),
            capture_output=True, text=True, env=env, timeout=timeout)

    def test_identity_init_writes_an_identity_file(self):
        proc = self.run_numctl("identity-init")
        self.assertEqual(proc.returncode, 0, proc.stderr)
        path = os.path.join(self.work, "run", "identity")
        self.assertTrue(os.path.isfile(path), "identity file was not written")
        with open(path) as handle:
            first = handle.read().strip()
        self.assertTrue(first, "identity file is empty")
        # Idempotent: second call must not rotate the identity.
        proc2 = self.run_numctl("identity-init")
        self.assertEqual(proc2.returncode, 0, proc2.stderr)
        with open(path) as handle:
            second = handle.read().strip()
        self.assertEqual(first, second)

    def test_unknown_command_halts_with_named_reason(self):
        proc = self.run_numctl("explode")
        self.assertNotEqual(proc.returncode, 0)
        self.assertIn("numctl: HALT:", proc.stderr)
        self.assertIn("unknown command", proc.stderr)

    def test_status_halts_because_control_socket_is_absent(self):
        proc = self.run_numctl("status")
        self.assertNotEqual(proc.returncode, 0)
        self.assertIn("numctl: HALT:", proc.stderr)
        self.assertIn("control socket not implemented", proc.stderr)

    def test_run_op_without_state_halts_named(self):
        # Bare PATH invoke has no NUMOS_STATE: refuse before inventing work.
        proc = self.run_numctl("run-op", "SwarmExecutor_v1")
        self.assertNotEqual(proc.returncode, 0)
        self.assertIn("numctl: HALT:", proc.stderr)
        self.assertIn("SwarmExecutor_v1", proc.stderr)
        self.assertIn("NUMOS_STATE", proc.stderr)

    def test_join_stays_alive_as_a_longrun_floor(self):
        env = dict(os.environ)
        env["NUMOS_RUNDIR"] = self.rundir
        env["PATH"] = BOOT + os.pathsep + env.get("PATH", "")
        proc = subprocess.Popen(
            [_bash(), NUMCTL, "join"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            env=env)
        try:
            time.sleep(0.3)
            self.assertIsNone(proc.poll(), "join floor exited immediately")
        finally:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)

    def test_seeded_identity_unit_runs_against_the_floor(self):
        """The seed's real exec string must work when numctl is on PATH."""
        text = render({
            "version": 1,
            "arch_targets": [],
            "units": [{
                "name": "identity", "kind": "oneshot", "restart": "never",
                "backoff_ms": 0, "backoff_max_ms": 0, "arch_mask": [],
                "health_probe": None, "requires": [], "after": [],
                "exec": "numctl identity-init",
            }],
            "boot_phases": [{
                "ordinal": 10, "name": "identity", "on_failure": "halt",
                "required_units": ["identity"],
            }],
            "health": [],
        })
        state = os.path.join(self.work, "numos.state").replace("\\", "/")
        with open(state, "w", newline="\n") as handle:
            handle.write(text)
        env = dict(os.environ)
        env["NUMOS_STATE"] = state
        env["NUMOS_RUNDIR"] = self.rundir
        env["NUMOS_MAX_TICKS"] = "1"
        env["NUMOS_NO_SLEEP"] = "1"
        env["PATH"] = BOOT + os.pathsep + env.get("PATH", "")
        env.pop("NUMOS_DRY_RUN", None)
        env.pop("NUMOS_SOURCE_ONLY", None)
        proc = subprocess.run(
            [_bash(), NUMINIT], capture_output=True, text=True,
            env=env, timeout=60)
        self.assertEqual(proc.returncode, 0, proc.stderr)
        self.assertIn("boot complete", proc.stdout)
        self.assertTrue(
            os.path.isfile(os.path.join(self.work, "run", "identity")),
            "seeded identity unit did not create the identity file")


if __name__ == "__main__":
    unittest.main()