Skip to content

Commit ccf0a2e

Browse files
asullivan-blzebzlo
andauthored
feat: add --workspace-status-json to boardwalkd to generate JSON status for all workspaces (#229)
* feat: add --workspace-status-json for unauthenticated global ws status Adds a boardwalkd flag `--workspace-status-json` to enable the route /api/workspaces/status to provide a JSON object containing the status for all known workspaces. This can, for example, feed into a secondary monitoring system, allowing the status of Boardwalk to be centrally monitored by a NOC team, for example. Co-authored-by: lo <lorelei@backblaze.com>
1 parent f5816cc commit ccf0a2e

File tree

7 files changed

+101
-41
lines changed

7 files changed

+101
-41
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
__pycache__/
22
.pytest_cache/
33
.ruff_cache/
4+
.ansible/
45
.boardwalk/
56
.boardwalkd/
67
.DS_store

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ develop-server: develop
3737
ifdef BOARDWALKD_SLACK_WEBHOOK_URL
3838
poetry run boardwalkd serve \
3939
--develop \
40+
--workspace-status-json \
4041
--host-header-pattern="(localhost|127\.0\.0\.1)" \
4142
--port=8888 \
4243
--url='http://localhost:8888'
4344
else
4445
poetry run boardwalkd serve \
4546
--develop \
47+
--workspace-status-json \
4648
--host-header-pattern="(localhost|127\.0\.0\.1)" \
4749
--port=8888 \
4850
--url='http://localhost:8888'

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "poetry.core.masonry.api"
66
name = "boardwalk"
77
description = "Boardwalk is a linear Ansible workflow engine"
88
readme = "README.md"
9-
version = "0.8.27"
9+
version = "0.8.28"
1010
requires-python = ">=3.11,<4"
1111
authors = [
1212
{name="Mat Hornbeek", email="84995001+m4wh6k@users.noreply.github.com"},

src/boardwalkd/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,13 @@ def cli():
170170
type=str,
171171
required=True,
172172
)
173+
@click.option(
174+
"--workspace-status-json/--no-workspace-status-json",
175+
help=("Exposed an unauthenticated JSON object of the status of all workspaces at /api/workspaces/status"),
176+
type=bool,
177+
default=False,
178+
show_envvar=True,
179+
)
173180
def serve(
174181
auth_expire_days: float,
175182
auth_method: str,
@@ -186,6 +193,7 @@ def serve(
186193
tls_key: str | None,
187194
tls_port: int | None,
188195
url: str,
196+
workspace_status_json: bool,
189197
):
190198
"""Runs the server"""
191199
# Validate host_header_pattern
@@ -242,6 +250,7 @@ def serve(
242250
tls_key_path=tls_key,
243251
tls_port_number=tls_port,
244252
url=url,
253+
workspace_status_json=workspace_status_json,
245254
)
246255
)
247256

src/boardwalkd/server.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from boardwalkd.broadcast import handle_slack_broadcast
3737
from boardwalkd.protocol import ApiLoginMessage, WorkspaceDetails, WorkspaceEvent
3838
from boardwalkd.state import User, WorkspaceState, load_state, valid_user_roles
39+
from boardwalkd.utils import is_workspace_active
3940

4041
module_dir = Path(__file__).resolve().parent
4142
state = load_state()
@@ -518,10 +519,10 @@ def check_xsrf_cookie(self):
518519
pass
519520

520521
def get_current_user(self) -> bytes | None:
521-
"""Decodes the API token to return the current logged in user"""
522+
"""Decodes the API token to return the current logged in user."""
522523
return self.get_secure_cookie(
523524
"boardwalk_api_token",
524-
value=self.request.headers["boardwalk-api-token"],
525+
value=self.request.headers.get("boardwalk-api-token"),
525526
max_age_days=self.settings["auth_expire_days"],
526527
min_version=2,
527528
)
@@ -613,6 +614,32 @@ def get(self):
613614
return self.send_error(403)
614615

615616

617+
class WorkspacesStatusApiHandler(APIBaseHandler):
618+
"""Returns an unauthenticated, read-only summary of all workspaces for monitoring integrations"""
619+
620+
# nosemgrep: test.boardwalk.python.security.handler-method-missing-authentication
621+
def get(self):
622+
result = []
623+
for name, ws in state.workspaces.items():
624+
entry: dict[str, Any] = {
625+
"name": name,
626+
"semaphores": ws.semaphores.model_dump(),
627+
}
628+
if ws.details:
629+
entry["details"] = {
630+
"workflow": ws.details.workflow,
631+
"worker": f"{ws.details.worker_username}@{ws.details.worker_hostname}",
632+
"worker_connected": is_workspace_active(workspace_name=name),
633+
"host_pattern": ws.details.host_pattern,
634+
"limit_pattern": "<unknown>" if ws.details.worker_limit == "" else ws.details.worker_limit,
635+
"command": ws.details.worker_command,
636+
}
637+
if ws.last_seen:
638+
entry["last_seen"] = ws.last_seen.isoformat()
639+
result.append(entry)
640+
self.write({"workspaces": result})
641+
642+
616643
class WorkspaceCatchApiHandler(APIBaseHandler):
617644
"""Handles setting a catch on a workspace"""
618645

@@ -817,6 +844,7 @@ def make_app(
817844
slack_error_webhook_url: str,
818845
slack_webhook_url: str,
819846
url: str,
847+
workspace_status_json: bool,
820848
) -> tornado.web.Application:
821849
"""Builds the tornado application object"""
822850
handlers: list[tornado.web.OutputTransform] = []
@@ -836,6 +864,7 @@ def make_app(
836864
"server_version": ui_method_server_version,
837865
"sort_events_by_date": ui_method_sort_events_by_date,
838866
},
867+
"workspace_status_json": workspace_status_json,
839868
"url": urlparse(url),
840869
"websocket_ping_interval": 10,
841870
"xsrf_cookies": True,
@@ -901,6 +930,10 @@ def make_app(
901930
r"/api/auth/login/socket",
902931
AuthLoginApiWebsocketHandler,
903932
),
933+
(
934+
r"/api/workspaces/status",
935+
WorkspacesStatusApiHandler,
936+
),
904937
(
905938
r"/api/workspace/(\w+)/details",
906939
WorkspaceDetailsApiHandler,
@@ -955,6 +988,7 @@ async def run(
955988
slack_webhook_url: str,
956989
slack_slash_command_prefix: str,
957990
url: str,
991+
workspace_status_json: bool,
958992
):
959993
"""Starts the tornado server and IO loop"""
960994
global state
@@ -969,6 +1003,7 @@ async def run(
9691003
slack_error_webhook_url=slack_error_webhook_url,
9701004
slack_webhook_url=slack_webhook_url,
9711005
url=url,
1006+
workspace_status_json=workspace_status_json,
9721007
)
9731008

9741009
if port_number is not None:

src/boardwalkd/slack.py

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -39,44 +39,11 @@
3939
from boardwalkd.protocol import WorkspaceEvent
4040
from boardwalkd.server import SERVER_URL, SLACK_SLASH_COMMAND_PREFIX, SLACK_TOKENS, internal_workspace_event
4141
from boardwalkd.server import state as STATE
42+
from boardwalkd.utils import count_of_workspaces_caught, list_active_workspaces, list_inactive_workspaces
4243

4344
app = AsyncApp(token=SLACK_TOKENS.get("bot"))
4445

4546

46-
# Possibly move this elsewhere if these functions are useful in other locations?
47-
def _count_of_workspaces_caught() -> int:
48-
"""
49-
Returns the number of workspaces which are caught
50-
"""
51-
return len([k for k, v in STATE.workspaces.items() if v.semaphores.caught])
52-
53-
54-
def _list_active_workspaces(last_seen_seconds: int = 10, _sorted: bool = True) -> list[str]:
55-
"""
56-
Returns a sorted list[str] of currently active workspaces. Takes
57-
`last_seen_seconds`, which corresponds to how long ago the worker connected
58-
to the workspace was last seen. Defaults to 10.
59-
"""
60-
workspaces: list[str] = []
61-
for name, workspace in STATE.workspaces.items():
62-
if (datetime.now(UTC) - workspace.last_seen.replace(tzinfo=UTC)).total_seconds() < last_seen_seconds: # type: ignore
63-
workspaces.append(name)
64-
if _sorted:
65-
return sorted(workspaces)
66-
else:
67-
return workspaces
68-
69-
70-
def _list_inactive_workspaces(last_seen_seconds: int = 10) -> list[str]:
71-
"""
72-
Returns a sorted list[str] of inactive workspaces. Takes
73-
`last_seen_seconds`, which corresponds to the time at which the last
74-
connected worker was seen before the workspace is considered inactive.
75-
Defaults to 10.
76-
"""
77-
return sorted([name for name in STATE.workspaces.keys() if name not in _list_active_workspaces(last_seen_seconds)])
78-
79-
8047
@app.command(f"/{SLACK_SLASH_COMMAND_PREFIX}-version")
8148
async def hello_command(ack: AsyncAck, body: dict[str, Any], client: AsyncWebClient) -> None:
8249
await ack()
@@ -273,7 +240,7 @@ async def command_list_active_workspaces(ack: AsyncAck, body: dict[str, Any], cl
273240
message_blocks = [
274241
SectionBlock(text=MarkdownTextObject(text="The following workspaces have an active worker connected:"))
275242
]
276-
active_workspaces = _list_active_workspaces()
243+
active_workspaces = list_active_workspaces()
277244

278245
if len(active_workspaces) > 0:
279246
message_blocks.append(SectionBlock(text=MarkdownTextObject(text=", ".join(active_workspaces))))
@@ -341,8 +308,8 @@ async def app_home_opened(ack: AsyncAck, client: AsyncWebClient, logger: Logger,
341308
SectionBlock(
342309
text=" ".join( # semgrep avoidance https://sg.run/Kl07 -- string-concat-in-list
343310
[
344-
f"There are {len(STATE.workspaces)} workspaces in total, of which {_count_of_workspaces_caught()} are",
345-
f"caught, with {len(_list_active_workspaces())} active CLI workers.",
311+
f"There are {len(STATE.workspaces)} workspaces in total, of which {count_of_workspaces_caught()} are",
312+
f"caught, with {len(list_active_workspaces())} active CLI workers.",
346313
]
347314
)
348315
),
@@ -351,7 +318,7 @@ async def app_home_opened(ack: AsyncAck, client: AsyncWebClient, logger: Logger,
351318

352319
_active: list[str] = []
353320
_inactive: list[str] = []
354-
for _list, _workspaces in [(_active, _list_active_workspaces()), (_inactive, _list_inactive_workspaces())]:
321+
for _list, _workspaces in [(_active, list_active_workspaces()), (_inactive, list_inactive_workspaces())]:
355322
for _workspace in _workspaces:
356323
_list.append(
357324
f"{'🕵️‍♀️' if STATE.workspaces[_workspace].details.worker_command == 'check' else '👟'}"

src/boardwalkd/utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from datetime import UTC, datetime
2+
3+
from boardwalkd import server
4+
5+
6+
def list_active_workspaces(last_seen_seconds: int = 10, _sorted: bool = True) -> list[str]:
7+
"""
8+
Returns a sorted list[str] of currently active workspaces. Takes
9+
`last_seen_seconds`, which corresponds to how long ago the worker connected
10+
to the workspace was last seen. Defaults to 10.
11+
"""
12+
workspaces: list[str] = []
13+
for name, workspace in server.state.workspaces.items():
14+
if (datetime.now(UTC) - workspace.last_seen.replace(tzinfo=UTC)).total_seconds() < last_seen_seconds: # type: ignore
15+
workspaces.append(name)
16+
if _sorted:
17+
return sorted(workspaces)
18+
else:
19+
return workspaces
20+
21+
22+
def is_workspace_active(workspace_name: str, last_seen_seconds: int = 10) -> bool:
23+
"""Returns Boolean True if the provided workspace name is active, based on the configured `last_seen_seconds` value."""
24+
if ws := server.state.workspaces.get(workspace_name):
25+
if (datetime.now(UTC) - ws.last_seen.replace(tzinfo=UTC)).total_seconds() < last_seen_seconds: # type: ignore
26+
return True
27+
return False
28+
29+
30+
def list_inactive_workspaces(last_seen_seconds: int = 10) -> list[str]:
31+
"""
32+
Returns a sorted list[str] of inactive workspaces. Takes
33+
`last_seen_seconds`, which corresponds to the time at which the last
34+
connected worker was seen before the workspace is considered inactive.
35+
Defaults to 10.
36+
"""
37+
return sorted(
38+
[name for name in server.state.workspaces.keys() if name not in list_active_workspaces(last_seen_seconds)]
39+
)
40+
41+
42+
def count_of_workspaces_caught() -> int:
43+
"""
44+
Returns the number of workspaces which are caught
45+
"""
46+
return len([k for k, v in server.state.workspaces.items() if v.semaphores.caught])

0 commit comments

Comments
 (0)