aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
blob: 901c3d568e941f6cac1a28d8550923d80b874105 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""Starting, stopping and reloading the node: systemd here, the Startup
launcher or the service task on Windows."""

import sys

from meshbay_node.cli.api import _daemon_api, _systemctl_user
from meshbay_node.config import DEFAULT_CONFIG_PATH, load_config


def reload(args) -> None:
    if sys.platform == "win32":
        # No systemd, no SIGHUP: the daemon exposes a hot reload on its
        # own loopback API (the same one ops.reload_config drives).
        cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
        _daemon_api(cfg, "/api/reload", method="POST")
        print("sent reload to the running node")
        return
    _systemctl_user(
        "reload", "meshbay-node",
        not_running_hint="Node is not running as a systemd unit — start it "
                          "with: systemctl --user start meshbay-node",
        success="sent reload to meshbay-node",
        watch="watch the result:  journalctl --user -u meshbay-node -f")
    return


def _await_daemon(cfg, since: float, timeout: float = 30.0) -> dict | None:
    """The status of a daemon started at or after `since`, or None.

    The token file is rewritten at every start, so one older than `since`
    belongs to the instance that was just stopped — answering from it would
    report the old daemon as the new one.
    """
    import json
    import time
    import urllib.request

    token_file = cfg.data_dir / "ui-token"
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            if token_file.stat().st_mtime >= since:
                token = token_file.read_text(encoding="utf-8").strip()
                url = f"http://127.0.0.1:{cfg.node.ui_port}/api/status?t={token}"
                with urllib.request.urlopen(url, timeout=2) as r:
                    return json.loads(r.read())
        except (OSError, ValueError):
            pass
        time.sleep(0.5)
    return None


def _running_status(cfg) -> dict | None:
    """The status of whatever node answers right now, or None."""
    return _await_daemon(cfg, since=0, timeout=0.1)


def _stop_node(cfg) -> str:
    """Stop the node, whichever session it runs in: through its own control API
    first, so it shuts down properly, then by force. Returns how it went."""
    import time

    from meshbay_node.platform import (
        autostart_end,
        request_graceful_stop,
        service_end,
        service_state,
        service_status,
    )
    if request_graceful_stop(cfg.data_dir, cfg.node.ui_port):
        how = "stopped"
    elif _running_status(cfg) is None:
        how = "not running"
    else:
        how = "stopped (forced)"
    if service_status()["installed"]:
        # Also leaves the task "Ready": a /run while it still reads "Running"
        # is dropped (MultipleInstances IgnoreNew), leaving no node at all.
        service_end()
        deadline = time.monotonic() + 15
        while service_state().lower() == "running" and time.monotonic() < deadline:
            time.sleep(0.25)
    autostart_end()   # a node in this session that would not stop
    return how


def _start_and_confirm(args, action: str) -> None:
    """Start the node the way this machine is set up to, and report what
    actually answered -- never "started" about a node nobody checked."""
    import time

    from meshbay_node import __version__
    from meshbay_node.platform import autostart_run, log_file, service_run, service_status

    cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
    if action == "restart":
        _stop_node(cfg)
    else:
        already = _running_status(cfg)
        if already is not None:
            print(f"already running — node {already.get('version', '?')}, "
                  f"{already.get('status', '?')}")
            return
    since = time.time() - 1
    service = service_status()["installed"]
    try:
        service_run() if service else autostart_run()
    except RuntimeError as e:
        print(f"Could not start the node: {e}")
        sys.exit(1)
    status = _await_daemon(cfg, since)
    if status is None:
        print("no node answered within 30s of being started"
              + (" by the service task." if service else "."))
        print(f"See the node's log: {log_file()}")
        sys.exit(1)
    running = status.get("version", "?")
    print(f"{'restarted' if action == 'restart' else 'started'} — node {running}, "
          f"{status.get('status', '?')}")
    if running != __version__:
        print(f"warning: that is node {running} but this is {__version__} — "
              "its files were not replaced, or another copy was started.")


def restart_daemon(args) -> None:
    if sys.platform == "win32":
        _start_and_confirm(args, "restart")
        return
    _systemctl_user(
        "restart", "meshbay-node",
        not_running_hint="meshbay-node is not installed as a systemd unit — "
                          "see packaging/systemd/",
        success="meshbay-node restarted via systemd",
        watch="check status:  systemctl --user status meshbay-node\n"
              "watch logs:    journalctl --user -u meshbay-node -f")
    return


def autostart(args) -> None:
    from meshbay_node import platform as _plat
    if sys.platform != "win32":
        print("autostart is Windows-only — elsewhere use "
              "'systemctl --user enable --now meshbay-node'.")
        sys.exit(1)
    sub = args.subcommand or "status"
    service_mode = _plat.service_status()["installed"]
    if sub == "install":
        if service_mode:
            # Both would start the node: at boot, then again at sign-in.
            print("The node already runs as a background service. Remove it "
                  "first (meshbay-node service remove, elevated) to start it at "
                  "sign-in instead.")
            sys.exit(1)
        _plat.autostart_install()
        print("Installed the Startup launcher — meshbay-node starts at "
              "each sign-in (no window, no admin).")
        print("Start it now with: meshbay-node autostart start")
    elif sub == "remove":
        _plat.autostart_remove()
        print("Removed the Startup launcher.")
    elif sub == "start":
        _start_and_confirm(args, "start")
    elif sub == "stop":
        print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)))
    elif sub == "status":
        st = _plat.autostart_status()
        if st["installed"]:
            print("autostart    installed — runs meshbay-node at sign-in")
        elif service_mode:
            print("autostart    not used — the node runs as a background service")
        else:
            print("autostart    not installed — meshbay-node autostart install")
    else:
        print("autostart: install | remove | start | stop | status")
        sys.exit(1)
    return


def service(args) -> None:
    from meshbay_node import platform as _plat
    if sys.platform != "win32":
        print("service mode is Windows-only — elsewhere use "
              "'systemctl --user enable --now meshbay-node'.")
        sys.exit(1)
    sub = args.subcommand or "status"
    if sub == "install":
        # A node already running in this session holds the control API's port:
        # the service's own would exit at once, leaving the old one in charge.
        _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))
        try:
            _plat.service_install()
        except RuntimeError as e:
            print(f"Could not install: {e}")
            if "denied" in str(e).lower():
                print("Run this from an elevated (Administrator) prompt.")
            sys.exit(1)
        print(f"Registered the {_plat.TASK_NAME!r} scheduled task — it starts "
              "meshbay-node at boot, as this user, whether or not you have "
              "signed in yet (no password stored).")
        print("Start it now with: meshbay-node service start")
    elif sub == "remove":
        # Deleting a task does not end its running instance: stop the node
        # first, or it runs on in session 0 with nothing left to stop it.
        _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))
        _plat.service_remove()
        print(f"Removed the {_plat.TASK_NAME!r} scheduled task.")
    elif sub == "start":
        if not _plat.service_status()["installed"]:
            print("service       not installed — meshbay-node service install "
                  "(needs an elevated prompt)")
            sys.exit(1)
        _start_and_confirm(args, "start")
    elif sub == "stop":
        print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)))
    elif sub == "status":
        st = _plat.service_status()
        if st["installed"]:
            print(f"service       installed — {st['state'] or 'unknown state'}")
        else:
            print("service       not installed — meshbay-node service install "
                  "(needs an elevated prompt)")
    else:
        print("service: install | remove | start | stop | status")
        sys.exit(1)
    return