tutorial

How to Monitor Porter with Vigilmon (Bundle Health, Mixin Execution, Credential Store Connectivity)

Porter is the reference implementation of the Cloud Native Application Bundle (CNAB) specification. Instead of shipping a Dockerfile or a Helm chart alone, P...

Porter is the reference implementation of the Cloud Native Application Bundle (CNAB) specification. Instead of shipping a Dockerfile or a Helm chart alone, Porter packages an application together with all the tooling needed to install, upgrade, and uninstall it — mixins for Terraform, Helm, Kubernetes, Azure, AWS, and others — into a single OCI-compatible artifact called a bundle. The bundle is self-contained: it carries the right versions of every CLI tool it needs, the credentials schema it expects, and the exact sequence of steps to run on install, upgrade, or uninstall.

That design solves the "works on my machine" problem for deployment pipelines, but it introduces failure surfaces that simple uptime checks miss. A bundle can fail to pull from the registry before the first step even runs. A credential store can go offline mid-installation, leaving the process to hang rather than fail cleanly. A mixin step can exit non-zero without surfacing a visible alert in your CI system. The Porter controller can enter a deadlock that keeps an installation stuck in the "running" state indefinitely.

This tutorial shows you how to build external monitors for every critical failure surface in a Porter deployment pipeline and wire them into Vigilmon so that your team gets alerted before a failed bundle install silently breaks production.


Why Porter needs external monitoring

Porter's own logging is detailed, but it is local to the machine or pod running the command. There is no built-in health endpoint, no webhook on installation failure, and no automatic escalation when a step hangs. External monitoring fills that gap. The five failure modes worth watching are:

Bundle registry unreachable. Porter pulls bundle images from an OCI registry. If the registry is down, rate-limited, or behind a broken network path, every porter install and porter upgrade call fails before a single mixin executes. You find out when an engineer tries to deploy and gets a timeout.

Credential store disconnected. Porter resolves credentials at install time from a credential set, which can point to Hashicorp Vault, Azure Key Vault, or environment variables. If the credential store endpoint is unreachable when an install starts, the bundle fails immediately. Because this often happens on a schedule (a nightly upgrade job, for example), the failure is discovered hours later.

Mixin execution failure. Each step in a Porter bundle delegates to a mixin — Terraform applies infrastructure, Helm installs the chart, kubectl applies manifests. If a mixin exits non-zero, Porter marks the installation as failed and stops. In a CI pipeline that treats non-zero exit codes as build failures this is caught, but in longer-running controller-based workflows the failure can sit unnoticed in Porter's installation list.

Installation stuck in "running" state. The Porter operator or controller tracks installation state. If the controller pod is evicted or crashes mid-run, the installation record stays in a "running" state permanently. Nothing else can install or upgrade that installation until the state is manually cleared. Automated checks for long-running installations catch this before it blocks a release.

OCI registry authentication expired. Bundle push and pull use an OCI token that can expire. When it does, porter publish and porter install fail with an authentication error. This is common in air-gapped or self-hosted registry setups where token TTLs are short and refresh is manual.


What you'll need

  • Porter installed and configured (porter CLI available, ~/.porter/ initialized)
  • An OCI registry for bundles (Docker Hub, GHCR, or a self-hosted registry such as Harbor or Zot)
  • Python 3.9 or later with fastapi and uvicorn for the health proxy
  • A Vigilmon account at vigilmon.online

Step 1: Monitor the bundle registry

OCI registries expose a /v2/ endpoint that returns HTTP 200 (or 401 with a WWW-Authenticate header) when the registry is healthy. A 401 is acceptable here — it means the registry is reachable and is requesting credentials. A 5xx or a connection timeout means the registry is down.

Test connectivity to the registry from the machine running Porter:

# Replace with your registry host
REGISTRY="registry.example.com"

curl -s -o /dev/null -w "%{http_code}" "https://${REGISTRY}/v2/"
# Expected: 200 or 401

To verify that Porter can actually authenticate and pull metadata, use a dedicated health-check script that attempts to resolve a known bundle manifest by digest:

#!/usr/bin/env bash
# check-registry.sh
# Tests that Porter can authenticate to the OCI registry and read a bundle manifest.

set -euo pipefail

REGISTRY="registry.example.com"
BUNDLE_REF="${REGISTRY}/myorg/my-bundle:v1.0.0"
TIMEOUT=10

# porter explain pulls the bundle.json from the registry without executing anything.
# Exit code 0 means the registry is reachable and the bundle is readable.
if timeout "${TIMEOUT}" porter explain --reference "${BUNDLE_REF}" > /dev/null 2>&1; then
  echo "OK registry reachable and bundle readable"
  exit 0
else
  echo "CRITICAL registry unreachable or bundle missing"
  exit 1
fi

Wrap this script in a small HTTP server so Vigilmon can poll it:

#!/usr/bin/env bash
# registry-health-server.sh
# Runs on port 9101 and exposes /health for Vigilmon to poll.

while true; do
  if bash /opt/porter-checks/check-registry.sh 2>/dev/null; then
    STATUS="200 OK"
    BODY='{"status":"ok","registry":"reachable"}'
  else
    STATUS="503 Service Unavailable"
    BODY='{"status":"error","registry":"unreachable"}'
  fi

  printf "HTTP/1.1 %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" \
    "${STATUS}" "${#BODY}" "${BODY}" | nc -l -p 9101 -q 1
done

For production use, prefer a proper HTTP server (see Step 2) over netcat. This shell approach is useful for quick validation.


Step 2: Build a Porter installation health proxy

This is the most important check. Porter tracks every installation in its store. The porter installations list -o json command returns a JSON array of all installations with their status, last action, and timestamps. A health proxy that inspects this output and returns 503 on problems gives Vigilmon a single endpoint to watch.

Install the dependencies:

pip install fastapi uvicorn

Create the health proxy at /opt/porter-checks/installation_health.py:

#!/usr/bin/env python3
"""
Porter installation health proxy.
Returns 200 when all installations are in a healthy state.
Returns 503 when any installation has failed or is stuck running too long.
"""

import subprocess
import json
import logging
from datetime import datetime, timezone, timedelta
from fastapi import FastAPI, Response

app = FastAPI(title="Porter Installation Health Proxy")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Installations running longer than this threshold are considered stuck.
STUCK_THRESHOLD_MINUTES = 30


def get_installations() -> list[dict]:
    """Run porter installations list and return parsed JSON."""
    result = subprocess.run(
        ["porter", "installations", "list", "-o", "json"],
        capture_output=True,
        text=True,
        timeout=15,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"porter installations list failed: {result.stderr.strip()}"
        )
    return json.loads(result.stdout)


def check_installation(inst: dict) -> dict | None:
    """
    Return a problem description if the installation is unhealthy, else None.
    Unhealthy means: status is 'failed', or status is 'running' and the
    last-modified timestamp is older than STUCK_THRESHOLD_MINUTES.
    """
    name = inst.get("name", "unknown")
    status = inst.get("status", {}).get("action", "")
    result_status = inst.get("status", {}).get("result", {}).get("status", "")

    # A completed but failed installation.
    if result_status == "failure":
        return {
            "installation": name,
            "issue": "last action failed",
            "status": result_status,
        }

    # A running installation that has been running too long.
    if status == "running":
        modified_str = inst.get("status", {}).get("modified", "")
        if modified_str:
            try:
                modified = datetime.fromisoformat(
                    modified_str.replace("Z", "+00:00")
                )
                age = datetime.now(timezone.utc) - modified
                if age > timedelta(minutes=STUCK_THRESHOLD_MINUTES):
                    return {
                        "installation": name,
                        "issue": f"stuck in running state for {int(age.total_seconds() / 60)} minutes",
                        "status": status,
                    }
            except ValueError:
                logger.warning("Could not parse modified timestamp: %s", modified_str)

    return None


@app.get("/health")
def health_check(response: Response):
    """
    GET /health

    Returns 200 with { "status": "ok" } if all Porter installations are healthy.
    Returns 503 with { "status": "error", "problems": [...] } if any are not.
    """
    try:
        installations = get_installations()
    except RuntimeError as exc:
        response.status_code = 503
        return {"status": "error", "problems": [{"issue": str(exc)}]}
    except Exception as exc:
        response.status_code = 503
        return {"status": "error", "problems": [{"issue": f"unexpected error: {exc}"}]}

    problems = []
    for inst in installations:
        problem = check_installation(inst)
        if problem:
            problems.append(problem)

    if problems:
        response.status_code = 503
        return {
            "status": "error",
            "checked": len(installations),
            "problems": problems,
        }

    return {
        "status": "ok",
        "checked": len(installations),
        "problems": [],
    }


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=9102)

Run it as a systemd service or in the background:

python3 /opt/porter-checks/installation_health.py &

# Verify it works:
curl http://localhost:9102/health
# {"status":"ok","checked":3,"problems":[]}

Step 3: Monitor credential store connectivity

Porter resolves credentials from credential sets at install time. If the backing store — Hashicorp Vault or Azure Key Vault — is unreachable, installations fail immediately. Add a health check for the credential store endpoint.

For Hashicorp Vault:

#!/usr/bin/env bash
# check-vault.sh
# Verifies that the Vault cluster is sealed/unsealed and reachable.

VAULT_ADDR="${VAULT_ADDR:-https://vault.example.com}"
TIMEOUT=5

response=$(curl -sf --max-time "${TIMEOUT}" "${VAULT_ADDR}/v1/sys/health" 2>&1)
exit_code=$?

if [ "${exit_code}" -ne 0 ]; then
  echo "CRITICAL Vault unreachable at ${VAULT_ADDR}"
  exit 1
fi

sealed=$(echo "${response}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sealed', True))")

if [ "${sealed}" = "True" ]; then
  echo "CRITICAL Vault is sealed — credentials unavailable"
  exit 1
fi

echo "OK Vault reachable and unsealed"
exit 0

For Azure Key Vault, check the metadata endpoint:

#!/usr/bin/env bash
# check-azure-keyvault.sh

VAULT_NAME="${AZURE_VAULT_NAME:-my-keyvault}"
VAULT_URL="https://${VAULT_NAME}.vault.azure.net"
TIMEOUT=5

http_code=$(curl -s -o /dev/null -w "%{http_code}" \
  --max-time "${TIMEOUT}" \
  "${VAULT_URL}/secrets?api-version=7.4" \
  -H "Authorization: Bearer $(az account get-access-token --resource https://vault.azure.net --query accessToken -o tsv 2>/dev/null)")

if [ "${http_code}" = "200" ] || [ "${http_code}" = "401" ]; then
  # 401 means the endpoint is reachable (RBAC would reject without a token anyway).
  echo "OK Azure Key Vault reachable (HTTP ${http_code})"
  exit 0
else
  echo "CRITICAL Azure Key Vault unreachable or error (HTTP ${http_code})"
  exit 1
fi

Expose the appropriate script through the same FastAPI proxy on a separate route, or serve it from a simple wrapper on port 9103.


Step 4: Set up a smoke-test bundle

A synthetic smoke-test bundle is the most reliable way to verify the end-to-end Porter pipeline — registry pull, credential resolution, mixin execution, and state storage — all in one shot.

Create a minimal Porter bundle at /opt/porter-checks/smoke-bundle/porter.yaml:

schemaVersion: 1.0.0
name: smoke-test
version: 0.1.0
description: "Synthetic smoke test bundle for monitoring the Porter pipeline."
registry: registry.example.com/myorg

mixins:
  - exec

install:
  - exec:
      description: "Smoke test install — verify pipeline is functional"
      command: bash
      arguments:
        - -c
        - echo "smoke-test install OK at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

upgrade:
  - exec:
      description: "Smoke test upgrade"
      command: bash
      arguments:
        - -c
        - echo "smoke-test upgrade OK at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

uninstall:
  - exec:
      description: "Smoke test uninstall"
      command: bash
      arguments:
        - -c
        - echo "smoke-test uninstall OK at $(date -u +%Y-%m-%dT%H:%M:%SZ)"

Build and publish the bundle:

cd /opt/porter-checks/smoke-bundle
porter build
porter publish

Create a wrapper script that installs (or upgrades) the smoke bundle and writes the result to a file that the health endpoint reads:

#!/usr/bin/env bash
# run-smoke-test.sh
# Called by cron every 5 minutes. Writes exit code to /tmp/porter-smoke-status.

set -euo pipefail

BUNDLE_REF="registry.example.com/myorg/smoke-test:v0.1.0"
STATUS_FILE="/tmp/porter-smoke-status"
TIMESTAMP_FILE="/tmp/porter-smoke-timestamp"

if porter installations show smoke-test > /dev/null 2>&1; then
  porter upgrade smoke-test --reference "${BUNDLE_REF}" > /tmp/porter-smoke.log 2>&1
  echo $? > "${STATUS_FILE}"
else
  porter install smoke-test --reference "${BUNDLE_REF}" > /tmp/porter-smoke.log 2>&1
  echo $? > "${STATUS_FILE}"
fi

date -u +%Y-%m-%dT%H:%M:%SZ > "${TIMESTAMP_FILE}"

Add it to cron:

*/5 * * * * /opt/porter-checks/run-smoke-test.sh

Add a /smoke route to the FastAPI proxy that reads the status file:

import os
from pathlib import Path

SMOKE_STATUS_FILE = Path("/tmp/porter-smoke-status")
SMOKE_TIMESTAMP_FILE = Path("/tmp/porter-smoke-timestamp")

# Maximum age before the smoke test result is considered stale.
SMOKE_STALE_MINUTES = 15


@app.get("/smoke")
def smoke_health(response: Response):
    """
    GET /smoke

    Returns 200 if the last smoke-test bundle run succeeded.
    Returns 503 if it failed, never ran, or the result is stale.
    """
    if not SMOKE_STATUS_FILE.exists():
        response.status_code = 503
        return {"status": "error", "issue": "smoke test has never run"}

    exit_code = SMOKE_STATUS_FILE.read_text().strip()
    timestamp_str = (
        SMOKE_TIMESTAMP_FILE.read_text().strip()
        if SMOKE_TIMESTAMP_FILE.exists()
        else None
    )

    if timestamp_str:
        try:
            last_run = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
            age = datetime.now(timezone.utc) - last_run
            if age > timedelta(minutes=SMOKE_STALE_MINUTES):
                response.status_code = 503
                return {
                    "status": "error",
                    "issue": f"smoke test result is stale ({int(age.total_seconds() / 60)} min old)",
                    "last_run": timestamp_str,
                }
        except ValueError:
            pass

    if exit_code != "0":
        response.status_code = 503
        return {
            "status": "error",
            "issue": f"smoke test exited with code {exit_code}",
            "last_run": timestamp_str,
        }

    return {"status": "ok", "exit_code": exit_code, "last_run": timestamp_str}

Step 5: Add Vigilmon monitors

Log in to vigilmon.online and navigate to Monitors > New Monitor.

Monitor 1: Bundle registry connectivity

  • Monitor type: HTTP(S)
  • Name: Porter - Bundle Registry
  • URL: https://registry.example.com/v2/
  • Expected HTTP status: 200 or 401 (use keyword matching: look for the WWW-Authenticate header, or accept any 2xx/4xx and only alert on 5xx and timeouts)
  • Check interval: 60 seconds
  • Timeout: 10 seconds
  • Alert after: 2 consecutive failures

Monitor 2: Porter installation health sweep

  • Monitor type: HTTP(S)
  • Name: Porter - Installation Health
  • URL: http://your-porter-host:9102/health
  • Expected HTTP status: 200
  • Check interval: 5 minutes
  • Timeout: 20 seconds (the porter CLI call can take a few seconds)
  • Alert after: 1 failure

Monitor 3: Credential store

  • Monitor type: HTTP(S)
  • Name: Porter - Vault / Key Vault
  • URL: https://vault.example.com/v1/sys/health
  • Expected HTTP status: 200
  • Check interval: 60 seconds
  • Timeout: 10 seconds
  • Alert after: 2 consecutive failures

Monitor 4: Smoke-test bundle

  • Monitor type: HTTP(S)
  • Name: Porter - Smoke Test Bundle
  • URL: http://your-porter-host:9102/smoke
  • Expected HTTP status: 200
  • Check interval: 5 minutes
  • Timeout: 15 seconds
  • Alert after: 1 failure

For each monitor, configure an alert channel (email, Slack, PagerDuty, or webhook) under Alerts > Notification Channels in the Vigilmon dashboard.


Recommended alert thresholds

| Monitor | Check interval | Alert after | Severity | |---|---|---|---| | Bundle registry /v2/ | 60 s | 2 consecutive failures | Critical | | Installation health sweep | 5 min | 1 failure | Critical | | Vault / Key Vault health | 60 s | 2 consecutive failures | Critical | | Smoke-test bundle exit code | 5 min | 1 failure | Critical | | Registry auth token age | 60 s | 1 failure | Warning |

A few notes on tuning these values:

The bundle registry gets a two-failure buffer because brief DNS hiccups can produce false positives on a 60-second cadence. One missed poll before escalating keeps noise down without letting a real outage go undetected for more than two minutes.

The installation health sweep and smoke test should alert immediately (after one failure) because both indicate something has already gone wrong in the pipeline — not a transient network blip.

If you run Porter in a Kubernetes cluster using the Porter operator, add a separate Vigilmon monitor for the operator pod's readiness endpoint. A crash-looped operator controller is the most common cause of installations stuck in the running state.

Keep the smoke-test bundle as minimal as possible — an exec mixin with a single echo command. The point is to exercise the pull, credential resolution, and state storage path, not to test application logic. Any mixin more complex than exec adds failure modes that belong in your application's own test suite, not in a synthetic pipeline monitor.


Conclusion

Porter's bundle model is powerful precisely because it encapsulates the entire deployment toolchain. That encapsulation also means failures at the infrastructure layer — a down registry, an expired token, a sealed Vault — can silently block every bundle in your catalog. The monitors in this tutorial cover the five most common failure surfaces: registry connectivity, installation state, credential store health, mixin execution (via smoke test), and OCI authentication.

All four Vigilmon monitors can be set up in under ten minutes. The FastAPI proxy at Step 2 is the most valuable single artifact here: it gives you a queryable view of Porter installation state that you can build alerting and dashboards on top of, independent of whatever CI system runs your bundles.

Get started with free monitoring at vigilmon.online.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →