diff --git a/.github/workflows/zhook.yml b/.github/workflows/zhook.yml index 95a9c38..b9f4b8b 100644 --- a/.github/workflows/zhook.yml +++ b/.github/workflows/zhook.yml @@ -17,35 +17,148 @@ on: jobs: mattermost-ziti-webhook: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: POST Webhook steps: - - uses: actions/checkout@v4 - - name: run hook directly + - name: Debug Environment + uses: hmarr/debug-action@v3 + + - name: Install Debug Tools + shell: bash + run: sudo apt-get install --yes valgrind gdb + + - name: Checkout + uses: actions/checkout@v4 + + - name: Run Python Script Directly + if: | + github.repository_owner == 'openziti' + && ((github.event_name != 'pull_request_review') + || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved')) + env: + INPUT_ZITIID: ${{ secrets.ZITI_MATTERMOST_IDENTITY }} + INPUT_WEBHOOKURL: ${{ secrets.ZHOOK_URL_DEV_NOTIFICATIONS }} + INPUT_EVENTJSON: ${{ toJson(github.event) }} + INPUT_SENDERUSERNAME: GitHubZ + INPUT_SENDERICONURL: https://github.com/fluidicon.png + INPUT_ZITILOGLEVEL: 6 + shell: bash + run: | + set -o pipefail + set -o xtrace + pip install --user --upgrade --requirement ./requirements.txt + # in case valgrind catches a segfault, it will write a core file in ./vgcore.%p + valgrind \ + --verbose \ + --log-file=${GITHUB_WORKSPACE}/direct-valgrind-%p-%n.log \ + --leak-check=yes \ + python ./zhook.py + + - name: Run in Docker with Core Dumps if: | github.repository_owner == 'openziti' && ((github.event_name != 'pull_request_review') || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved')) + shell: bash env: INPUT_ZITIID: ${{ secrets.ZITI_MATTERMOST_IDENTITY }} - INPUT_WEBHOOKURL: ${{ secrets.ZHOOK_URL }} + INPUT_WEBHOOKURL: ${{ secrets.ZHOOK_URL_DEV_NOTIFICATIONS }} INPUT_EVENTJSON: ${{ toJson(github.event) }} INPUT_SENDERUSERNAME: GitHubZ - INPUT_DESTCHANNEL: dev-notifications INPUT_SENDERICONURL: https://github.com/fluidicon.png + INPUT_ZITILOGLEVEL: 6 run: | - pip install --upgrade requests openziti - python ./zhook.py + set -o pipefail + set -o xtrace - - uses: ./ # use self to bring the pain forward - name: run action + # Base64 encode JSON to avoid whitespace issues in env file + ENCODED_JSON=$(echo "${INPUT_EVENTJSON}" | base64 -w 0) + + cat > /tmp/docker.env << EOF + INPUT_ZITIID=${INPUT_ZITIID} + INPUT_WEBHOOKURL=${INPUT_WEBHOOKURL} + INPUT_EVENTJSON_B64=${ENCODED_JSON} + INPUT_SENDERUSERNAME=${INPUT_SENDERUSERNAME} + INPUT_SENDERICONURL=${INPUT_SENDERICONURL} + INPUT_ZITILOGLEVEL=${INPUT_ZITILOGLEVEL} + GITHUB_WORKSPACE=${GITHUB_WORKSPACE} + GITHUB_EVENT_NAME=${GITHUB_EVENT_NAME} + GITHUB_ACTION_REPOSITORY=${GITHUB_ACTION_REPOSITORY} + EOF + + # configure the kernel to write core dumps to the workspace directory that is writable by the container in case there is a segfault valgrind cannot catch in a vgcore.%p + sudo sysctl -w kernel.core_pattern="${GITHUB_WORKSPACE}/core.%e.%p.%t" + + # build the action's container image so we can source it for the debug image + docker build -t zhook-action . + docker build -t zhook-action-dbg -f debug.Dockerfile . + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + --workdir "${GITHUB_WORKSPACE}" \ + --env-file /tmp/docker.env \ + --entrypoint=/bin/bash \ + zhook-action-dbg -euxo pipefail -c ' + ulimit -c unlimited; + exec valgrind \ + --verbose \ + --log-file=${GITHUB_WORKSPACE}/docker-valgrind-%p-%n.log \ + --leak-check=yes \ + python /app/zhook.py; + ' + + - uses: ./ + name: Run as a GH Action from the Local Checkout if: | github.repository_owner == 'openziti' && ((github.event_name != 'pull_request_review') || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved')) with: zitiId: ${{ secrets.ZITI_MATTERMOST_IDENTITY }} - webhookUrl: ${{ secrets.ZHOOK_URL }} + webhookUrl: ${{ secrets.ZHOOK_URL_DEV_NOTIFICATIONS }} eventJson: ${{ toJson(github.event) }} - senderUsername: "GitHubZ" - destChannel: "dev-notifications" + senderUsername: GitHubZ + senderIconUrl: https://github.com/fluidicon.png + zitiLogLevel: 6 + + - name: Print Debug Info + if: always() + shell: bash + run: | + set -o xtrace + set +o errexit + echo "DEBUG: PYTHONPATH=${PYTHONPATH:-}" + echo "DEBUG: PATH=${PATH:-}" + echo "DEBUG: LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" + # list non-git files in the two uppermost levels of the workspace directory hierarchy + find . -maxdepth 2 -path './.git' -prune -o -print + find $(python -c "import site; print(site.USER_SITE)") -path "*/openziti*" -name "*.so*" -type f -print0 | xargs -0r ldd + + shopt -s nullglob + # find valgrind logs from both execution steps + typeset -a VALGRIND_LOGS=(${GITHUB_WORKSPACE}/*-valgrind-*.log) + if (( ${#VALGRIND_LOGS[@]} )); then + for LOG in "${VALGRIND_LOGS[@]}"; do + if [ -s "$LOG" ]; then + echo "DEBUG: Valgrind log: $LOG" + cat "$LOG" + echo "--- End of $(basename "$LOG") ---" + fi + done + else + echo "DEBUG: No Valgrind logs found" + fi + + # find core dumps produced by the kernel and valgrind + typeset -a CORES=(${GITHUB_WORKSPACE}/core.* ${GITHUB_WORKSPACE}/vgcore.*) + shopt -u nullglob + if (( ${#CORES[@]} )); then + for CORE in "${CORES[@]}"; do + if [ -s "$CORE" ]; then + echo "DEBUG: Core dump: $CORE" + EXECUTABLE=$(basename "$CORE" | cut -d. -f2) + gdb -q $(realpath $(which "$EXECUTABLE")) -c "$CORE" --ex bt --ex exit + fi + done + else + echo "DEBUG: No core dumps found" + fi diff --git a/Dockerfile b/Dockerfile index 64f6e94..21ff2b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ FROM python:3-slim AS builder -RUN pip install --target=/app requests openziti +COPY requirements.txt /tmp/requirements.txt +RUN pip install --target=/app --requirement /tmp/requirements.txt # https://github.com/GoogleContainerTools/distroless FROM gcr.io/distroless/python3-debian12 COPY --from=builder /app /app -COPY ./zhook.py /app/zhook.py +COPY --chmod=0755 ./zhook.py /app/zhook.py WORKDIR /app ENV PYTHONPATH=/app -ENV ZITI_LOG=6 -ENV TLSUV_DEBUG=6 + CMD ["/app/zhook.py"] diff --git a/README.md b/README.md index cf12ece..9e16ffd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # ziti-mattermost-action-py + GitHub Action that posts to a Mattermost webhook endpoint over OpenZiti This GitHub workflow action uses [Ziti Python SDK](https://github.com/openziti/ziti-sdk-py) to post an event's payload information to a [Mattermost](https://mattermost.com/) instance over a `Ziti` connection. This allows the Mattermost server to remain private, i.e. not directly exposed to the internet. @@ -38,7 +39,6 @@ jobs: eventJson: ${{ toJson(github.event) }} senderUsername: "GitHubZ" - destChannel: "github-notifications" ``` ### Inputs diff --git a/action.yml b/action.yml index 4930fd8..536ab34 100644 --- a/action.yml +++ b/action.yml @@ -1,30 +1,34 @@ -name: 'Ziti Mattermost Action - Python' -description: 'POST to Mattermost Webhook endpoint over a Ziti network' +name: Ziti Mattermost Action - Python +description: POST to Mattermost Webhook endpoint over a Ziti network branding: - icon: 'zap' - color: 'red' + icon: zap + color: red inputs: zitiId: - description: 'Identity JSON for an enrolled Ziti endpoint' + description: Identity JSON for an enrolled Ziti endpoint required: true webhookUrl: - description: 'URL for posting the payload' + description: Mattermost-channel-specific URL for posting the event required: true eventJson: - description: 'GitHub event JSON (github.event)' + description: GitHub event JSON (github.event) required: true senderUsername: - description: 'Mattermost username' + description: Mattermost username required: false - default: "GithubZ" + default: GithubZ senderIconUrl: - description: 'Mattermost user icon URL' + description: Mattermost user icon URL required: false - default: "https://github.com/fluidicon.png" + default: https://github.com/fluidicon.png destChannel: - description: 'Mattermost channel' + description: Mattermost channel (ignored because incoming webhooks are locked to a channel) required: false - default: "dev-notifications" + default: null + zitiLogLevel: + description: Ziti log level + required: false + default: 3 runs: - using: "docker" - image: "Dockerfile" + using: docker + image: Dockerfile diff --git a/debug.Dockerfile b/debug.Dockerfile new file mode 100644 index 0000000..3e7bdf2 --- /dev/null +++ b/debug.Dockerfile @@ -0,0 +1,7 @@ +FROM zhook-action AS distroless + +FROM python:3-slim AS debug + +COPY --from=distroless /app /app + +RUN apt-get update && apt-get install -y valgrind diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f42d9f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +openziti \ No newline at end of file diff --git a/zhook.py b/zhook.py index 44bc9bd..437245e 100644 --- a/zhook.py +++ b/zhook.py @@ -1,7 +1,10 @@ +#!/usr/bin/env python3 + import requests import openziti import json import os +import base64 class MattermostWebhookBody: @@ -11,6 +14,7 @@ class MattermostWebhookBody: issueThumbnail = "https://github.com/openziti/branding/blob/main/images/ziggy/closeups/Ziggy-has-an-Idea-Closeup.png?raw=true" # releaseThumbnail = "https://github.com/openziti/branding/blob/main/images/ziggy/png/Ziggy-Cash-Money-Closeup.png?raw=true" releaseThumbnail = "https://github.com/openziti/branding/blob/main/images/ziggy/closeups/Ziggy-Parties-Closeup.png?raw=true" + fipsReleaseThumbnail = "https://github.com/openziti/branding/blob/main/images/ziggy/closeups/Ziggy-The-Cop-Closeup.png?raw=true" watchThumbnail = "https://github.com/openziti/branding/blob/main/images/ziggy/closeups/Ziggy-is-Star-Struck.png?raw=true" prColor = "#32CD32" @@ -20,10 +24,9 @@ class MattermostWebhookBody: todoColor = "#FFFFFF" watchColor = "#FFD700" - def __init__(self, username, icon, channel, eventName, eventJsonStr, actionRepo): + def __init__(self, username, icon, eventName, eventJsonStr, actionRepo): self.username = username self.icon = icon - self.channel = channel self.eventName = eventName.lower() self.eventJsonStr = eventJsonStr self.actionRepo = actionRepo @@ -36,7 +39,6 @@ def __init__(self, username, icon, channel, eventName, eventJsonStr, actionRepo) # "icon_url": self.icon, "username": self.senderJson['login'], "icon_url": self.senderJson['avatar_url'], - "channel": self.channel, "props": {"card": f"```json\n{self.eventJsonStr}\n```"}, } @@ -70,6 +72,14 @@ def __init__(self, username, icon, channel, eventName, eventJsonStr, actionRepo) self.addForkDetails() elif eventName == "release": self.addReleaseDetails() + elif eventName == "repository_dispatch": + event_type = self.eventJson.get("action", None) + if event_type == "ziti_release": + self.addFipsPreReleaseDetails() + elif event_type == "ziti_promote_stable": + self.addFipsPromoteStableDetails() + else: + self.addRepositoryDispatchGenericDetails() # fallback elif eventName == "watch": self.addWatchDetails() else: @@ -274,6 +284,42 @@ def addReleaseDetails(self): self.attachment["text"] = bodyText + def addFipsPreReleaseDetails(self): + # Pre-release announcement (ziti_release) + payload = self.eventJson.get("client_payload", {}) + version = payload.get("version") + if not version: + self.attachment["text"] = "[ziti-fips] Pre-release published, but version not found in event." + return + repo = self.repoJson["full_name"] + release_url = f"https://github.com/{repo}/releases/tag/v{version}" + self.body["text"] = f"FIPS Pre-release published in [{repo}](https://github.com/{repo})" + self.attachment["color"] = self.releaseColor + self.attachment["thumb_url"] = self.fipsReleaseThumbnail + self.attachment["text"] = f"FIPS Pre-release [{version}]({release_url}) is now available." + + def addFipsPromoteStableDetails(self): + # Promotion to stable announcement (ziti_promote_stable) + payload = self.eventJson.get("client_payload", {}) + version = payload.get("version") + if not version: + self.attachment["text"] = "[ziti-fips] Stable promotion, but version not found in event." + return + repo = self.repoJson["full_name"] + release_url = f"https://github.com/{repo}/releases/tag/v{version}" + self.body["text"] = f"FIPS Release promoted to stable in [{repo}](https://github.com/{repo})" + self.attachment["color"] = self.releaseColor + self.attachment["thumb_url"] = self.fipsReleaseThumbnail + self.attachment["text"] = f"FIPS Release [{version}]({release_url}) has been promoted to stable." + + def addRepositoryDispatchGenericDetails(self): + event_type = self.eventJson.get("action", None) + payload = self.eventJson.get("client_payload", {}) + repo = self.repoJson["full_name"] + self.body["text"] = f"Repository dispatch event received by [{repo}](https://github.com/{repo})" + self.attachment["color"] = self.releaseColor + self.attachment["text"] = f"Repository dispatch event type: `{event_type}`\nPayload: ```json\n{json.dumps(payload, indent=2)}\n```" + def addWatchDetails(self): self.body["text"] = f"{self.createTitle()} #stargazer" login = self.senderJson["login"] @@ -351,12 +397,25 @@ def dumpJson(self): if __name__ == '__main__': url = os.getenv("INPUT_WEBHOOKURL") - eventJsonStr = os.getenv("INPUT_EVENTJSON") + + # Handle both base64-encoded and direct JSON input + eventJsonB64 = os.getenv("INPUT_EVENTJSON_B64") + if eventJsonB64: + try: + eventJsonStr = base64.b64decode(eventJsonB64).decode('utf-8') + except Exception as e: + print(f"Failed to decode base64 event JSON: {e}") + eventJsonStr = os.getenv("INPUT_EVENTJSON") + else: + eventJsonStr = os.getenv("INPUT_EVENTJSON") username = os.getenv("INPUT_SENDERUSERNAME") icon = os.getenv("INPUT_SENDERICONURL") - channel = os.getenv("INPUT_DESTCHANNEL") actionRepo = os.getenv("GITHUB_ACTION_REPOSITORY") eventName = os.getenv("GITHUB_EVENT_NAME") + zitiLogLevel = os.getenv("INPUT_ZITILOGLEVEL") + if zitiLogLevel is not None: + os.environ["ZITI_LOG"] = zitiLogLevel + os.environ["TLSUV_DEBUG"] = zitiLogLevel # Setup Ziti identity zitiJwt = os.getenv("INPUT_ZITIJWT") @@ -369,14 +428,68 @@ def dumpJson(self): print("ERROR: no Ziti identity provided, set INPUT_ZITIID or INPUT_ZITIJWT") exit(1) + def generate_json_schema(obj, max_depth=10, current_depth=0): + """Generate a schema representation of a JSON object by inferring types from values.""" + if current_depth >= max_depth: + return "" + + if obj is None: + return "null" + elif isinstance(obj, bool): + return "boolean" + elif isinstance(obj, int): + return "integer" + elif isinstance(obj, float): + return "number" + elif isinstance(obj, str): + return "string" + elif isinstance(obj, list): + if len(obj) == 0: + return "array[]" + # Get schema of first element as representative + element_schema = generate_json_schema(obj[0], max_depth, current_depth + 1) + return f"array[{element_schema}]" + elif isinstance(obj, dict): + schema = {} + for key, value in obj.items(): + schema[key] = generate_json_schema(value, max_depth, current_depth + 1) + return schema + else: + return f"unknown_type({type(obj).__name__})" + + # Accept only inline JSON for zitiId; do not interpret as file path or base64 + def _safe_hint(s): + if s is None: + return "" + l = len(s) + head = s[:8].replace('\n', ' ') + return f"len={l}, startswith='{head}...'" + + try: + zitiIdJson = json.loads(zitiId) + zitiIdContent = zitiId + except Exception as e: + print("ERROR: zitiId must be inline JSON (not a file path or base64).") + print(f"DEBUG: INPUT_ZITIID hint: {_safe_hint(zitiId)}") + print(f"DEBUG: json error: {e}") + exit(1) + idFilename = "id.json" with open(idFilename, 'w') as f: - f.write(zitiId) + f.write(zitiIdContent) + + # Load the identity file after it's been written and closed + try: openziti.load(idFilename) + except Exception as e: + print(f"ERROR: Failed to load Ziti identity: {e}") + schema = generate_json_schema(zitiIdJson) + print(f"DEBUG: zitiId schema for troubleshooting: {json.dumps(schema, indent=2)}") + raise e # Create webhook body try: - mwb = MattermostWebhookBody(username, icon, channel, eventName, eventJsonStr, actionRepo) + mwb = MattermostWebhookBody(username, icon, eventName, eventJsonStr, actionRepo) except Exception as e: print(f"Exception creating webhook body: {e}") raise e