Skip to main content

Phase 69 β€” minicloud-plane β€” Level 4 API Integration + Backstage

minicloud-plane is a custom Go service that bridges Plane CE into the rest of the platform. It provides two capabilities:

  1. REST proxy API β€” authenticated Plane data over a clean JSON API, consumed by Backstage
  2. Webhook β†’ NATS bridge β€” Plane events (issue created/updated/deleted) published to NATS for downstream consumers

This is a Level 4 integration β€” original code that wraps an existing service via its public API, rather than forking or patching it.


The Four Levels of Open-Source Customization​

Understanding this taxonomy clarifies the architectural choice:

LevelWhat you doExample in minicloudWhen to choose
L1 β€” Deploy as-ishelm install, no source changesNATS, Longhorn, Grafana, ArgoCD~80% of tools
L2 β€” Custom image wrapperYour Dockerfile FROM upstream, add filesminicloud-backstage (CA cert + plugins), open-webui (CA cert + BM25)Need files inside the container
L3 β€” Fork + patchClone upstream, apply patches, maintain a fork(avoided in minicloud)Upstream won't merge your fix
L4 β€” API integration serviceNew service that calls the upstream's APIminicloud-plane (this phase)Tool exposes a stable API; you want to connect it to other systems

Why not L2 for Plane? A custom Plane image would let you add CA certs or patch Python files β€” but the real need is connecting Plane to Backstage and NATS, which are external systems. That connection has to live somewhere outside Plane. L4 is the right layer.

Why not L3? Forking Plane means rebasing on every release. Plane CE releases roughly monthly. The integration surface (REST API + webhooks) is stable and versioned β€” no reason to take on that maintenance burden.


Architecture​

Plane CE (plane namespace)
β”‚
β”‚ HMAC-signed webhooks (X-Plane-Signature)
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ minicloud-plane-dev namespace β”‚
β”‚ β”‚
β”‚ minicloud-plane (Go service) β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ /webhook β”‚ β”‚ /api/ β”‚ β”‚
β”‚ β”‚ handler β”‚ β”‚ REST proxy β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β–Ό β–Ό
NATS (messaging ns) Backstage (via proxy)
plane.<ws>.<event> /api/proxy/minicloud-plane/

Service endpoints:

PathPurpose
GET /healthLiveness/readiness probe
POST /webhookReceives Plane webhook events
GET /api/projectsReturns all workspace projects
GET /api/projects/{id}/issuesReturns issues for a project

Repository β€” andrelair-platform/minicloud-plane​

minicloud-plane/
β”œβ”€β”€ cmd/server/main.go # entrypoint: wires all handlers
β”œβ”€β”€ internal/
β”‚ β”œβ”€β”€ api/handler.go # REST proxy (projects, issues)
β”‚ β”œβ”€β”€ nats/publisher.go # NATS publisher
β”‚ β”œβ”€β”€ plane/
β”‚ β”‚ β”œβ”€β”€ client.go # Plane API client (X-Api-Key auth)
β”‚ β”‚ └── types.go # Project, Issue, WebhookEvent structs
β”‚ └── webhook/handler.go # HMAC verification + NATS publish
β”œβ”€β”€ Containerfile # Go 1.25, distroless/static:nonroot
└── .github/workflows/ci.yml # test β†’ build β†’ Trivy β†’ Cosign β†’ gitops

Key Gotchas Encountered​

1. Plane has two Django modules with different auth​

plane.app β†’ /api/ β†’ Authorization: Bearer <session-token>
plane.api β†’ /api/v1/ β†’ X-Api-Key: <api-key>

The API key integration always uses /api/v1/ with X-Api-Key. Using /api/ or X-Api-Token returns 401/403. Source: /code/plane/app/middleware/api_authentication.py.

2. NATS service is in the messaging namespace​

The NATS Helm release is named nats but deployed in namespace messaging. The internal DNS name is:

nats://nats.messaging.svc.cluster.local:4222

Not nats.nats.svc.cluster.local. The pod CrashLoopBackOff'ed until this was corrected in both deployment.yaml and the Go default in cmd/server/main.go.

3. Webhook signature β€” plain hex, not sha256= prefixed​

Plane's webhook task generates the signature as:

# /code/plane/bgtasks/webhook_task.py
signature = hmac_signature.hexdigest()

No prefix β€” it sends the raw hex digest in X-Plane-Signature. Our initial handler expected sha256=<hex> (the GitHub-style format). Fix:

// internal/webhook/handler.go
func (h *Handler) verifySignature(sig string, body []byte) bool {
mac := hmac.New(sha256.New, []byte(h.secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil)) // no "sha256=" prefix
return hmac.Equal([]byte(sig), []byte(expected))
}

4. Plane SSRF protection blocks internal webhook targets​

validate_url() in /code/plane/utils/ip_address.py rejects RFC1918 addresses and .svc.cluster.local hostnames. Escape hatch: set WEBHOOK_ALLOWED_HOSTS in the Plane worker environment (see Phase 68).

5. Workspace must exist before API calls work​

Plane returns 403 on all /api/v1/workspaces/ calls until the onboarding flow is completed. If Plane was installed without visiting the UI, bootstrap via Django shell (see Phase 68).

6. ESO secret refresh β€” 1h default TTL​

After updating a Vault secret (e.g., PLANE_URL changed from public to internal URL), the ESO ExternalSecret doesn't refresh immediately. Force-refresh:

kubectl annotate externalsecret minicloud-plane-secrets \
-n minicloud-plane-dev \
force-sync=$(date +%s) --overwrite

Vault Secrets​

secret/platform/minicloud-plane
β”œβ”€β”€ PLANE_URL http://plane-api.plane.svc.cluster.local (internal)
β”œβ”€β”€ PLANE_TOKEN <api-key from Django shell>
β”œβ”€β”€ PLANE_WORKSPACE andrelair
└── PLANE_WEBHOOK_SECRET <random hex β€” matches Plane webhook config>

Set with:

vault kv put secret/platform/minicloud-plane \
PLANE_URL="http://plane-api.plane.svc.cluster.local" \
PLANE_TOKEN="<token>" \
PLANE_WORKSPACE="andrelair" \
PLANE_WEBHOOK_SECRET="<secret>"

Vault Policy + Kubernetes Auth​

The ESO ClusterSecretStore vault-backend uses the external-secrets-reader policy (secret/data/platform/* wildcard), so no additional policy was needed. A dedicated Kubernetes auth role was created for the service's own service account:

vault write auth/kubernetes/role/minicloud-plane \
bound_service_account_names=minicloud-plane \
bound_service_account_namespaces=minicloud-plane-dev,minicloud-plane-staging,minicloud-plane-prod \
policies=external-secrets-reader \
ttl=1h

CI Pipeline​

push to dev β†’ test β†’ build β†’ Trivy (CRITICAL=0) β†’ push Harbor (dev-<sha>)
β†’ bump gitops overlays/dev/kustomization.yaml

push to main β†’ test β†’ build β†’ Trivy β†’ push Harbor (<sha>)
β†’ Cosign sign (keyless, GitHub OIDC β†’ Sigstore)
β†’ syft SBOM β†’ cosign attach sbom
β†’ bump gitops overlays/prod/kustomization.yaml (GPG signed)

CVE-2025-68121: Found during first Trivy scan. Go stdlib crypto/tls CRITICAL vulnerability. Fixed by bumping Go from 1.23 β†’ 1.25 in Containerfile, ci.yml, and go.mod.

Kustomize install fix: The install_kustomize.sh from the kustomize master branch is flaky (tar fails when the download path doesn't match). Fixed by switching to imranismail/setup-kustomize@v2 action with kustomize-version: "5.6.0".


NATS Subject Format​

Events are published to:

plane.<workspace_slug>.<event>.<action>

Example:

plane.andrelair.issue.created (865 bytes)
plane.andrelair.issue.updated
plane.andrelair.cycle.created

Subscribe to all Plane events for a workspace:

# From nats-box in messaging namespace
kubectl exec -n messaging -it deploy/nats-box -- \
nats sub "plane.andrelair.>"

Verified Pipeline​

Plane issue created via UI/API
β†’ plane-worker dispatches webhook_send_task (Celery)
β†’ POST to minicloud-plane /webhook (with X-Plane-Signature header)
β†’ HMAC-SHA256 verified (plain hex)
β†’ Event parsed: event=issue action=created
β†’ Published to NATS: plane.andrelair.issue.created (865 bytes)

Pod log:

2026/07/15 05:58:08 webhook: event=issue action=created actor=
2026/07/15 05:58:08 published plane.andrelair.issue.created (865 bytes)

GitOps Layout (minicloud-gitops)​

services/minicloud-plane/
β”œβ”€β”€ base/
β”‚ β”œβ”€β”€ kustomization.yaml
β”‚ β”œβ”€β”€ deployment.yaml # envFrom: minicloud-plane-secrets + NATS_URL env
β”‚ β”œβ”€β”€ service.yaml
β”‚ └── serviceaccount.yaml
└── overlays/
β”œβ”€β”€ dev/ # auto-sync, CI bumps newTag
β”œβ”€β”€ staging/ # manual sync (manual PR to promote)
└── prod/ # manual sync, CI bumps newTag on main push

ArgoCD application: minicloud-plane-dev (auto-sync) in project minicloud-platform.


Backstage Integration β€” @internal/plugin-minicloud-plane​

A local Backstage frontend plugin (plugins/minicloud-plane/ in minicloud-backstage) adds a "Plane Issues" tab to any catalog entity with the plane.io/project-id annotation.

How it works​

Backstage frontend
β†’ EntityPlaneIssuesContent component
β†’ reads entity.metadata.annotations['plane.io/project-id'] (e.g. "PT")
β†’ PlaneApiClient.getProjects() β†’ /api/proxy/minicloud-plane/api/projects
β†’ finds project by id or identifier
β†’ PlaneApiClient.getIssues(id) β†’ /api/proxy/minicloud-plane/api/projects/{id}/issues
β†’ renders table: # | Title | Priority | State

Proxy config​

Production config lives in minicloud-gitops/helm-values/backstage-values.yaml (the ConfigMap source β€” app-config.yaml in the image is never loaded in production):

# minicloud-gitops/helm-values/backstage-values.yaml β†’ appConfig
proxy:
endpoints:
'/minicloud-plane':
target: 'http://minicloud-plane.minicloud-plane-dev.svc.cluster.local:8080'
changeOrigin: true

For local dev, app-config.yaml has:

proxy:
endpoints:
'/minicloud-plane':
target: 'http://localhost:8080' # port-forward the svc locally
# Port-forward to test without cluster access
kubectl --context minicloud port-forward -n minicloud-plane-dev svc/minicloud-plane 8080:8080

Plugin registration​

// packages/app/src/App.tsx
import minicloudPlanePlugin from '@internal/plugin-minicloud-plane';

export default createApp({
features: [..., minicloudPlanePlugin, ...],
});

Wiring entities​

Add to any catalog-info.yaml:

metadata:
annotations:
plane.io/project-id: "PT" # project identifier OR UUID

Value can be:

  • The project identifier (e.g. PT, MINI) β€” human-readable, shown in the Plane UI
  • The project UUID (e.g. 870d8973-8525-4d5d-9bab-43a4d6a517f1)

The plugin tries both, so either works.

Currently wired entities​

EntityAnnotation valueProject
platform-demoPTPlatform Tasks
minicloud-planePTPlatform Tasks

Backstage Catalog Entity​

minicloud-plane/catalog-info.yaml is a multi-document YAML file containing both a Component and an API entity. This populates:

  • The Provided APIs tab on the minicloud-plane component page
  • The Consumed APIs tab on the minicloud-backstage component page
  • The APIs section in the Backstage left nav

Component β†’ API relationship​

# minicloud-plane/catalog-info.yaml (Component doc)
spec:
providesApis:
- minicloud-plane-api
---
# Same file (API doc)
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: minicloud-plane-api
spec:
type: openapi
definition: |
openapi: "3.0.3"
# ... full spec inline (health, /api/projects, /api/projects/{id}/issues)
# minicloud-backstage/catalog-info.yaml
spec:
consumesApis:
- minicloud-plane-api

Kubernetes tab​

The Backstage Kubernetes plugin associates cluster resources with a component via the backstage.io/kubernetes-id pod label. This label is set in the deployment pod template:

# minicloud-gitops/services/minicloud-plane/base/deployment.yaml
spec:
template:
metadata:
labels:
app: minicloud-plane
backstage.io/kubernetes-id: minicloud-plane

Without this label, the Kubernetes tab shows "No resources on any known clusters" even though the pod is running.


Quick Reference​

# Check pod health
kubectl get pod -n minicloud-plane-dev

# Test API (port-forward)
kubectl port-forward -n minicloud-plane-dev svc/minicloud-plane 8080:8080 &
curl -s http://localhost:8080/health
curl -s http://localhost:8080/api/projects | jq '.[].name'
curl -s http://localhost:8080/api/projects/PT/issues | jq 'length'
kill %1

# Watch NATS events
kubectl exec -n messaging -it deploy/nats-box -- \
nats sub "plane.andrelair.>"

# Force ESO secret refresh after Vault update
kubectl annotate externalsecret minicloud-plane-secrets \
-n minicloud-plane-dev force-sync=$(date +%s) --overwrite

# CI status
gh run list --repo andrelair-platform/minicloud-plane --limit 5