Skip to content

Commit

Permalink
boardwalkd (feat): add Slack commands for catching/releasing workspac…
Browse files Browse the repository at this point in the history
…es (#75)

Adds Slack integration to `boardwalkd` through means of a Slack App, such that
it is now possible to quickly view the status of workspaces--along with the
logged events--as well as catch or release workspaces from within Slack. This is
intended to augment--not replace--the web dashboard.

Using the Slack [app manifest](https://api.slack.com/reference/manifests)
located within `slack_app_manifest.yaml`, an individual implementing Boardwalk
can quickly conmfigure the required settings for the app. Additional
configuration, if desired, may be done after--or before by directly editing the
manifest--the app is [created within Slack's
system](https://api.slack.com/apps?new_app=1).

Using this feature is done by providing a Slack app and bot token, in the
`BOARDWALKD_SLACK_APP_TOKEN` and `BOARDWALKD_SLACK_BOT_TOKEN` environment
variables.

Features provided by this integration include:
1. A Slack app home page, which displays at a glance statuses for configured
   workspaces
2. The ability to catch or release one, multiple, or all workspaces.
3. Viewing the details of a specified workspace.
4. Adds the following Slack slash-commands:
    - `/brdwlk-version` - Returns the currently running version of Boardwalk.
    - `/brdwlk-catch-release` - Allows one or more workspaces to be caught or
      released from a single modal.
    - `/brdwlk-list` - Lists workspaces with an active worker
  • Loading branch information
asullivan-blze authored Jun 13, 2024
1 parent 68e52ab commit 0f75412
Show file tree
Hide file tree
Showing 13 changed files with 1,239 additions and 94 deletions.
6 changes: 6 additions & 0 deletions ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- Boardwalk icon (`src/boardwalkd/static/boardwalk_icon.jpg`) - Cropped from a
public domain image from
[Ingolfson](https://commons.wikimedia.org/wiki/User:Ingolfson) on [Wikimedia
Commons](https://commons.wikimedia.org/wiki/File:Swampy_But_Pretty_Bog_In_Fiordland_NZ.jpg)
- GitHub Corner code in `src/boardwalkd/templates/base.html` - MIT licensed,
from [tholman/github-corners](https://github.com/tholman/github-corners)
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM python:3.11 AS build
FROM docker.io/python:3.12 AS build
WORKDIR /build
COPY . .
RUN python3 -m pip install --user pipx \
&& PATH=PATH:/root/.local/bin pipx install poetry \
&& PATH=PATH:/root/.local/bin poetry build

FROM python:3.11-slim
FROM docker.io/python:3.12-slim
COPY --from=build /build/dist ./dist
ENV DEBIAN_FRONTEND=noninteractive
RUN groupadd -g 1000 not_root && useradd -u 1000 -g 1000 not_root \
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Boardwalk
<img src="src/boardwalkd/static/boardwalk_icon.jpg" style="width: 25%;" align="right" />

Boardwalk is a linear [Ansible](https://www.ansible.com/) workflow engine. It's
purpose-built to help systems engineers automate low-and-slow background jobs
Expand Down
497 changes: 458 additions & 39 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ requires-python = ">=3.11"

[tool.poetry]
name = "boardwalk"
version = "0.8.19"
version = "0.8.20"
description = "Boardwalk is a linear Ansible workflow engine"
readme = "README.md"
authors = [
Expand Down Expand Up @@ -44,6 +44,8 @@ cryptography = ">=38.0.3"
email-validator = ">=1.3.0" # Required by pydantic to validate emails using EmailStr
pydantic = ">=2.4.2"
tornado = ">=6.2"
slack-bolt = "^1.18.1"
aiohttp = "^3.9.3" # Required by slack-bolt's AsyncApp

[tool.poetry.group.dev.dependencies]
pyright = "==1.1.350"
Expand All @@ -61,6 +63,7 @@ line-length = 120
extend-exclude = [
"typings/*",
]

lint.extend-select = [
"I", # isort (import sorting)
"W", # pycodestyle warnings
Expand Down
51 changes: 51 additions & 0 deletions slack_app_manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
display_information:
name: Boardwalk
description: Boardwalk is a linear Ansible workflow engine.
background_color: "#11359e"
long_description: "Boardwalk is a linear Ansible workflow engine. It's purpose-built to help systems engineers automate low-and-slow background jobs against large numbers of production hosts. It's ideal for rolling-maintenance jobs like kernel and operating system upgrades.\r
\r
This Slack application is intended to serve as a quick interface to one of the more common reasons one might visit the dashboard: that being to catch or release workspaces.\r
\r
License: MIT\r
Source code: https://github.com/Backblaze/boardwalk/"
features:
app_home:
home_tab_enabled: true
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Boardwalk
always_online: true
slash_commands:
- command: /brdwlk-version
description: Get the current version of Boardwalk
should_escape: false
- command: /brdwlk-catch-release
description: Catch or release workspace(s)
should_escape: false
- command: /brdwlk-list
description: List workspaces with an active worker
should_escape: false
oauth_config:
scopes:
bot:
- chat:write
- commands
- im:write
- incoming-webhook
- users:read
- users:read.email
settings:
event_subscriptions:
bot_events:
- app_home_opened
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
61 changes: 18 additions & 43 deletions src/boardwalkd/broadcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
Code for handling server broadcasts
"""

import json
import logging

from tornado.httpclient import AsyncHTTPClient, HTTPError, HTTPRequest
from slack_sdk.models.blocks import (
MarkdownTextObject,
SectionBlock,
)
from slack_sdk.webhook.async_client import AsyncWebhookClient

from boardwalkd.protocol import WorkspaceEvent

Expand All @@ -30,46 +31,20 @@ async def handle_slack_broadcast(
slack_message_severity = ":red_circle: ERROR"
else:
raise ValueError(f"Event severity is invalid: {event.severity}")
slack_message_blocks = {
"blocks": [
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": f"*{slack_message_severity}*",
},
{
"type": "mrkdwn",
"text": f"*<{server_url}#{workspace}|{workspace}>*",
},
],
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"```\n{event.message}\n```",
},
},
]
}
payload = json.dumps(slack_message_blocks)

async def post_msg(url: str):
request = HTTPRequest(
method="POST",
headers={"Content-Type": "application/json"},
body=payload,
url=url,
)
client = AsyncHTTPClient()
try:
await client.fetch(request)
except HTTPError as e:
logging.error(f"slack_webhook:{e}")
slack_message_blocks = [
SectionBlock(
fields=[
MarkdownTextObject(text=f"*{slack_message_severity}*"),
MarkdownTextObject(text=f"*<{server_url}#{workspace}|{workspace}>*"),
]
),
SectionBlock(text=MarkdownTextObject(text=f"```\n{event.message}\n```")),
]

if error_webhook_url and event.severity == "error":
await post_msg(error_webhook_url)
webhook_client = AsyncWebhookClient(url=error_webhook_url)
await webhook_client.send(blocks=slack_message_blocks)
elif webhook_url:
await post_msg(webhook_url)
webhook_client = AsyncWebhookClient(url=webhook_url)
await webhook_client.send(blocks=slack_message_blocks)
47 changes: 41 additions & 6 deletions src/boardwalkd/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def cli():
@cli.command(context_settings=CONTEXT_SETTINGS)
@click.option(
"--auth-expire-days",
help=("The number of days login tokens and user API keys are valid before" " they expire"),
help=("The number of days login tokens and user API keys are valid before they expire"),
type=float,
default=14,
show_default=True,
Expand Down Expand Up @@ -94,7 +94,7 @@ def cli():
)
@click.option(
"--port",
help=("The non-TLS port number the server binds to. --port and/or" " --tls-port must be configured"),
help=("The non-TLS port number the server binds to. --port and/or --tls-port must be configured"),
type=int,
default=None,
)
Expand All @@ -115,6 +115,33 @@ def cli():
default=None,
show_envvar=True,
)
@click.option(
"--slack-app-token",
help=(
"A Slack App Token for the Slack App this Boardwalkd instance is to connect to."
" If specified, --slack-bot-token must also be provided."
),
type=str,
default=None,
show_envvar=True,
)
@click.option(
"--slack-bot-token",
help=("A Slack OAuth Bot Token for the Slack App this Boardwalkd instance is to connect to."),
type=str,
default=None,
show_envvar=True,
)
@click.option(
"--slack-slash-command-prefix",
help=(
"The prefix to use in front of Boardwalk slash commands in Slack (e.g., /PREFIX-version). Needs to match the prefix supplied in the Slack App configuration."
),
type=str,
default="brdwlk",
show_default=True,
show_envvar=True,
)
@click.option(
"--tls-crt",
help=("Path to TLS certificate chain file for use along with --tls-port"),
Expand Down Expand Up @@ -155,6 +182,9 @@ def serve(
port: int | None,
slack_error_webhook_url: str,
slack_webhook_url: str,
slack_app_token: str | None,
slack_bot_token: str | None,
slack_slash_command_prefix: str,
tls_crt: str | None,
tls_key: str | None,
tls_port: int | None,
Expand All @@ -173,17 +203,15 @@ def serve(

# If there is no TLS port then reject setting a TLS key and cert
if (not tls_port) and (tls_crt or tls_key):
raise BoardwalkException("--tls-crt and --tls-key should not be configured" " unless --tls-port is also set")
raise BoardwalkException("--tls-crt and --tls-key should not be configured unless --tls-port is also set")

# Validate TLS configuration (key and cert paths are already validated by click)
if tls_port is not None:
try:
assert tls_crt
assert tls_key
except AssertionError:
raise BoardwalkException(
"--tls-crt and --tls-key paths must be supplied when a" " --tls-port is configured"
)
raise BoardwalkException("--tls-crt and --tls-key paths must be supplied when a --tls-port is configured")

# Validate --owner
if owner:
Expand All @@ -196,6 +224,10 @@ def serve(
else:
owner = "[email protected]"

# Validate Slack app/bot token
if (not slack_bot_token) and slack_app_token:
raise BoardwalkException("If --slack-app-token is supplied, --slack-bot-token must also be supplied")

asyncio.run(
run(
auth_expire_days=auth_expire_days,
Expand All @@ -204,8 +236,11 @@ def serve(
host_header_pattern=host_header_regex,
owner=owner,
port_number=port,
slack_app_token=slack_app_token,
slack_bot_token=slack_bot_token,
slack_error_webhook_url=slack_error_webhook_url,
slack_webhook_url=slack_webhook_url,
slack_slash_command_prefix=slack_slash_command_prefix,
tls_crt_path=tls_crt,
tls_key_path=tls_key,
tls_port_number=tls_port,
Expand Down
27 changes: 24 additions & 3 deletions src/boardwalkd/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@

module_dir = Path(__file__).resolve().parent
state = load_state()
SLACK_TOKENS: dict[str, str | None] = {"app": None, "bot": None}
SLACK_SLASH_COMMAND_PREFIX: str = "brdwlk"
SERVER_URL: str | None = None
atexit.register(state.flush)


Expand Down Expand Up @@ -95,7 +98,7 @@ def ui_method_sha256(handler: UIBaseHandler, value: str) -> str:
def ui_method_secondsdelta(handler: UIBaseHandler, time: datetime) -> float:
"""Custom UI templating method. Accepts a datetime and returns the delta
between time given and now in number of seconds"""
delta = datetime.now(UTC) - time
delta = datetime.now(UTC) - time.replace(tzinfo=UTC)
return delta.total_seconds()


Expand Down Expand Up @@ -272,7 +275,7 @@ async def get(self): # pyright: ignore [reportIncompatibleMethodOverride]
"boardwalk_user",
anon_username,
expires_days=self.settings["auth_expire_days"],
samesite="Strict",
samesite="Lax", # To allow, for example, Slack to open the dashboard in a new window when the link is clicked from the Slack App
secure=True,
)
return self.redirect(
Expand Down Expand Up @@ -439,7 +442,7 @@ def delete(self, workspace: str):
try:
# If the host is possibly still connected we will not delete the
# mutex. Workspaces should send a heartbeat every 5 seconds
delta: timedelta = datetime.now(UTC) - state.workspaces[workspace].last_seen # type: ignore
delta: timedelta = datetime.now(UTC) - state.workspaces[workspace].last_seen.replace(tzinfo=UTC) # type: ignore
if delta.total_seconds() < 10:
return self.send_error(412)
state.workspaces[workspace].semaphores.has_mutex = False
Expand Down Expand Up @@ -945,13 +948,17 @@ async def run(
port_number: int | None,
tls_crt_path: str | None,
tls_key_path: str | None,
slack_app_token: str | None,
slack_bot_token: str | None,
tls_port_number: int | None,
slack_error_webhook_url: str,
slack_webhook_url: str,
slack_slash_command_prefix: str,
url: str,
):
"""Starts the tornado server and IO loop"""
global state
global SLACK_SLASH_COMMAND_PREFIX

app = make_app(
auth_expire_days=auth_expire_days,
Expand Down Expand Up @@ -989,4 +996,18 @@ async def run(
state.users[owner].enabled = True
state.flush()

# If configured, intialize Slack integration
if slack_app_token:
SLACK_TOKENS["app"] = slack_app_token
SLACK_TOKENS["bot"] = slack_bot_token
SLACK_SLASH_COMMAND_PREFIX = slack_slash_command_prefix

# Store the server URL so that other modules can read from it directly
global SERVER_URL
SERVER_URL = url

from boardwalkd import slack

await slack.connect()

await asyncio.Event().wait()
Loading

0 comments on commit 0f75412

Please sign in to comment.