Skip to content

Commit

Permalink
boardwalkd (feat): add Slack commands for catching/releasing workspaces
Browse files Browse the repository at this point in the history
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 committed May 23, 2024
1 parent 79e9f4b commit b640f01
Show file tree
Hide file tree
Showing 13 changed files with 1,218 additions and 92 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.11 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.11-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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# Boardwalk
![>](src/boardwalkd/static/boardwalk_icon.jpg)

<style>
img[alt$=">"] {
float: right;
width: 25%;
}
</style>

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.18"
version = "0.8.19-rc.0"
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: BoardwalkTest
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)
35 changes: 29 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,23 @@ 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(
"--tls-crt",
help=("Path to TLS certificate chain file for use along with --tls-port"),
Expand Down Expand Up @@ -155,6 +172,8 @@ def serve(
port: int | None,
slack_error_webhook_url: str,
slack_webhook_url: str,
slack_app_token: str,
slack_bot_token: str,
tls_crt: str | None,
tls_key: str | None,
tls_port: int | None,
Expand All @@ -173,17 +192,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 +213,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,6 +225,8 @@ 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,
tls_crt_path=tls_crt,
Expand Down
19 changes: 18 additions & 1 deletion src/boardwalkd/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@

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


Expand Down Expand Up @@ -272,7 +274,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 @@ -944,6 +946,8 @@ 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,
Expand Down Expand Up @@ -988,4 +992,17 @@ 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

# 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 b640f01

Please sign in to comment.