Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions .github/workflows/cf-log-bridge-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
name: CF log bridge deploy to STACKIT Cloud Foundry

on:
push:
branches: [staging, main]
paths: ["infra/cf-log-bridge/**", ".github/workflows/cf-log-bridge-deploy.yml"]
workflow_dispatch:

permissions:
contents: read
packages: read

env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/baergpt-log-bridge
# Suffix must match the CF space names and the drain URL built in
# infra/terraform/cloud-foundry/log-drain.tf.
APP_NAME: baergpt-log-bridge-${{ github.ref_name == 'main' && 'prod' || 'staging' }}

jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: cf-log-bridge-deploy-${{ github.ref }}
cancel-in-progress: false
# workflow_dispatch has no branches filter, so guard the ref here.
if: ${{ github.ref_name == 'staging' || github.ref_name == 'main' }}
environment: ${{ github.ref_name == 'main' && 'Production' || 'Staging' }}

steps:
- name: Checkout code ⬇️
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0, pinned to commit sha to avoid sha-hulud like attacks
with:
persist-credentials: "false"

- name: Setup environment ⚙️
uses: ./.github/actions/setup
with:
install-1password: "true"

# Same formula as cf-log-bridge-image.yml, so it resolves to the same tag.
- name: Compute image tag 🏷️
id: tag
run: |
set -euo pipefail
echo "tag=content-sha-${{ hashFiles('infra/cf-log-bridge/vector.yaml', 'infra/cf-log-bridge/Dockerfile') }}" >> "$GITHUB_OUTPUT"

- name: Set 1Password item IDs based on branch 🌿
id: env-config
run: |
set -euo pipefail
if [[ "${{ github.ref_name }}" == "main" ]]; then
echo "cf_item_id=${{ secrets.OP_CF_DEPLOY_PRODUCTION_ENV_ITEM_ID }}" >> "$GITHUB_OUTPUT"
echo "app_item_id=${{ secrets.OP_LOG_BRIDGE_PRODUCTION_ENV_ITEM_ID }}" >> "$GITHUB_OUTPUT"
else
echo "cf_item_id=${{ secrets.OP_CF_DEPLOY_STAGING_ENV_ITEM_ID }}" >> "$GITHUB_OUTPUT"
echo "app_item_id=${{ secrets.OP_LOG_BRIDGE_STAGING_ENV_ITEM_ID }}" >> "$GITHUB_OUTPUT"
fi

- name: Fetch STACKIT Cloud Foundry credentials from 1Password 🔑
run: |
set -euo pipefail
op read "op://${{ secrets.OP_VAULT_ID }}/${{ steps.env-config.outputs.cf_item_id }}/notesPlain" > cf.env
while IFS= read -r line || [ -n "$line" ]; do
[[ -z "$line" || "$line" == \#* ]] && continue
key="${line%%=*}"
value="${line#*=}"
echo "::add-mask::$value"
echo "$key=$value" >> "$GITHUB_ENV"
done < cf.env
rm cf.env
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}

# Both workflows fire on the same push, so the build may still be running.
- name: Wait for image to exist in GHCR 🔍
run: |
set -euo pipefail
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
image="${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}"
timeout=600
counter=0
until docker manifest inspect "$image" >/dev/null 2>&1; do
if [ $counter -ge $timeout ]; then
echo "::error::Image $image not found in GHCR after ${timeout}s." \
"Ensure 'CF log bridge image build and push' ran for this commit."
exit 1
fi
echo "Waiting for $image to be available on GHCR... (${counter}s)"
sleep 10
counter=$((counter + 10))
done
echo "Image $image found."

- name: Install cf CLI ⚙️
run: |
set -euo pipefail
curl -sL "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=v8&source=github" \
| tar -zx
sudo mv cf8 /usr/local/bin/cf

Comment on lines +94 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files matching workflow:"
git ls-files .github/workflows | sed -n '1,120p'

echo
echo "Target workflow relevant lines:"
if [ -f .github/workflows/cf-log-bridge-deploy.yml ]; then
  nl -ba .github/workflows/cf-log-bridge-deploy.yml | sed -n '1,150p'
else
  echo "missing target workflow"
fi

echo
echo "Cloud Foundry installs/usages in workflows:"
rg -n "packages.cloudfoundry.org|cf8|cf |OPER|CF_|login|cloud found|Cloud Foundry|GITHUB_ENV|1Password|op " .github/workflows || true

Repository: technologiestiftung/baergpt

Length of output: 862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Target workflow lines:"
awk '{printf "%6d\t%s\n", NR, $0}' .github/workflows/cf-log-bridge-deploy.yml | sed -n '1,150p'

echo
echo "Relevant install/login/search patterns:"
grep -nE "packages.cloudfoundry.org|cf8|cf |login|OPER|CF_|GITHUB_ENV|op |1Password|permissions:|env:|environment:" .github/workflows/cf-log-bridge-deploy.yml || true

Repository: technologiestiftung/baergpt

Length of output: 9647


Other (CWE-494): Download of Code Without Integrity Check

Reachability: External

Verify the Cloud Foundry CLI artifact before execution.

The workflow stores Cloud Foundry credentials in GITHUB_ENV before downloading the CLI, and the install step fetches a floating v8 archive via tar, then installs cf8 globally. A compromised or replayed archive can execute under the deployment identity. Pin an exact cf release and check its published checksum or signature before extraction/install.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cf-log-bridge-deploy.yml around lines 94 - 100, Update the
“Install cf CLI” step to use an exact Cloud Foundry CLI release rather than the
floating v8 URL, and verify the downloaded artifact against its published
checksum or signature before extracting it. Ensure verification fails the
workflow before any archive contents are extracted or cf8 is moved into
/usr/local/bin.

- name: Target STACKIT Cloud Foundry 🎯
run: |
set -euo pipefail
cf api "$CF_API_ENDPOINT"
cf auth
cf target -o "$CF_ORG" -s "$CF_SPACE"

# KEY=VALUE lines in the item's notes. See infra/cf-log-bridge/README.md.
- name: Fetch bridge env vars from 1Password 🔑
run: |
set -euo pipefail
op read "op://${{ secrets.OP_VAULT_ID }}/${{ steps.env-config.outputs.app_item_id }}/notesPlain" > bridge.env
: > infra/cf-log-bridge/app.env
while IFS= read -r line || [ -n "$line" ]; do
[[ -z "$line" || "$line" == \#* ]] && continue
key="${line%%=*}"
value="${line#*=}"
echo "::add-mask::$value"
echo "$key=$value" >> infra/cf-log-bridge/app.env
done < bridge.env
rm bridge.env
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}

# cf set-env needs the app to exist, so the first deploy creates it with
# --no-start. Unsetting first means keys removed from 1Password do not linger.
- name: Sync bridge env vars 🔄
id: sync
working-directory: infra/cf-log-bridge
env:
CF_DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if ! cf app "${{ env.APP_NAME }}" >/dev/null 2>&1; then
echo "bootstrapped=true" >> "$GITHUB_OUTPUT"
cf push \
-f manifest.yml \
--no-start \
--var app_name="${{ env.APP_NAME }}" \
--var image="${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" \
--var docker_username="${{ github.actor }}"
fi

guid=$(cf app "${{ env.APP_NAME }}" --guid)
# Assigned first: a substitution inside `for ... in` discards its exit status, so
# a failed cf curl would silently skip the unset and leave stale vars behind.
keys=$(cf curl "/v3/apps/$guid/environment_variables" | jq -r '.var | keys[]')
for key in $keys; do
cf unset-env "${{ env.APP_NAME }}" "$key" >/dev/null
done

while IFS= read -r line || [ -n "$line" ]; do
key="${line%%=*}"
value="${line#*=}"
cf set-env "${{ env.APP_NAME }}" "$key" "$value" >/dev/null
done < app.env

rm -f app.env

# Rolling keeps the old version serving the drain until the new one is up. The
# first deploy has nothing to roll over, hence the flag from the sync step.
- name: Push image to Cloud Foundry 🚀
working-directory: infra/cf-log-bridge
env:
CF_DOCKER_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cf push \
-f manifest.yml \
${{ steps.sync.outputs.bootstrapped != 'true' && '--strategy rolling' || '' }} \
--var app_name="${{ env.APP_NAME }}" \
--var image="${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" \
--var docker_username="${{ github.actor }}" \
|| { cf cancel-deployment "${{ env.APP_NAME }}" 2>/dev/null || true; exit 1; }

- name: Dump recent app logs on failure 🪵
if: ${{ failure() && !cancelled() }}
run: cf logs "${{ env.APP_NAME }}" --recent
70 changes: 70 additions & 0 deletions .github/workflows/cf-log-bridge-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: CF log bridge image build and push to registry

on:
pull_request:
branches: [staging, main]
paths: ["infra/cf-log-bridge/**", ".github/workflows/cf-log-bridge-image.yml"]
push:
branches: [staging, main]
paths: ["infra/cf-log-bridge/**", ".github/workflows/cf-log-bridge-image.yml"]
workflow_dispatch:

permissions:
contents: read
packages: write
Comment on lines +3 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file =="
if [ -f .github/workflows/cf-log-bridge-image.yml ]; then
  nl -ba .github/workflows/cf-log-bridge-image.yml | sed -n '1,140p'
else
  echo "missing .github/workflows/cf-log-bridge-image.yml"
  git ls-files .github/workflows | sed -n '1,120p'
fi

echo
echo "== related references =="
rg -n "cf-log-bridge|docker/build-push-action|GITHUB_TOKEN|GHCR|packages: write|pull_request|workflow_dispatch" .github/workflows -S || true

Repository: technologiestiftung/baergpt

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file =="
if [ -f .github/workflows/cf-log-bridge-image.yml ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' .github/workflows/cf-log-bridge-image.yml | sed -n '1,140p'
else
  echo "missing .github/workflows/cf-log-bridge-image.yml"
  git ls-files .github/workflows | sed -n '1,120p'
fi

echo
echo "== related references =="
rg -n "cf-log-bridge|docker/build-push-action|GITHUB_TOKEN|GHCR|packages: write|pull_request|workflow_dispatch" .github/workflows -S || true

Repository: technologiestiftung/baergpt

Length of output: 9147


🌐 Web query:

GitHub pull requests from forks secrets.GITHUB_TOKEN read-only packages write permissions

💡 Result:

When a workflow is triggered by a pull request from a public fork, GitHub security policies enforce a read-only GITHUB_TOKEN, regardless of the permissions configured in your workflow file [1][2][3]. This restriction is a security boundary designed to prevent untrusted code submitted via forks from executing with write access to your repository [4][2]. Key details regarding this behavior include: 1. Restriction on Write Access: For pull requests from forks using the standard pull_request event, all requested write permissions (such as packages: write or contents: write) are automatically downgraded to read-only [1][5][6]. You cannot override this downgrade using the permissions key in your YAML file [2][3]. 2. Exception for pull_request_target: The pull_request_target event is an exception to this rule [6][7]. Because it runs in the context of the base repository's default branch rather than the fork's merge commit, it is granted the repository's standard permissions, which can include write access [4][2]. Important Security Warning: While pull_request_target allows for write permissions, it should be used with caution [3]. Because it executes code from the base repository's default branch, you must ensure you are not checking out or executing untrusted code from the pull request itself in a way that could lead to security vulnerabilities [4][2][3]. 3. Recommended Patterns: To handle tasks like publishing packages or commenting on pull requests from forks, the industry-standard approach is often to use a two-workflow split [2]: - An untrusted pull_request workflow that performs builds and uploads artifacts. - A separate, trusted workflow_run or pull_request_target job that consumes those artifacts and performs the authorized write actions [2]. For further configuration, repository administrators can control whether fork pull requests are allowed to send write tokens to workflows at all via the repository's Actions settings under "Workflow permissions" [8][5], though this still respects the fundamental security downgrade for the pull_request event [5].

Citations:


🌐 Web query:

docker/build-push-action v7 not authenticated without login registry GHCR GITHUB_TOKEN permissions packages write

💡 Result:

To use docker/build-push-action v7 to push images to the GitHub Container Registry (GHCR) without manually running a separate docker/login-action, you must configure the workflow to authenticate using the GitHub Actions GITHUB_TOKEN and ensure the appropriate permissions are set [1]. Authentication Setup: You can authenticate to GHCR using the GITHUB_TOKEN provided by the workflow environment [1][2]. The action and the underlying Buildx setup will use this token to authenticate against ghcr.io if configured correctly [1]. Permissions Required: Your workflow must have explicit write permissions for the packages scope [1][3][4]. This is configured in the permissions block of your workflow file: permissions: contents: read packages: write Implementation: Because docker/build-push-action relies on Docker Buildx, you must ensure Buildx is set up before the build step using docker/setup-buildx-action [5][4]. While docker/login-action is commonly used, you can authenticate against GHCR by passing the GITHUB_TOKEN to the login step: - name: Login to GHCR uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} Although you asked to push without login, the docker/build-push-action requires an active Docker daemon authentication to push to a private registry [5][6]. Using docker/login-action with the GITHUB_TOKEN is the standard, secure way to provide this authentication without needing a long-lived Personal Access Token (PAT) [1]. Ensuring your workflow has packages: write is the critical security requirement for this token to successfully push images to your GHCR repository [1][4].

Citations:


Do not publish images during pull_request runs.

secrets.GITHUB_TOKEN is read-only for fork pull requests. When a new content tag is encountered, GitHub Container Registry login fails before docker/build-push-action reaches the build job. Use a build-only path for pull_request, and keep the GHCR login/existence check/push for push or workflow_dispatch.

[low_effort And_high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cf-log-bridge-image.yml around lines 3 - 14, Update the
workflow triggers and job conditions in the cf-log-bridge image workflow so
pull_request runs only build the image without GHCR login, existence checks, or
publishing. Keep the GHCR authentication, tag existence handling, and image push
enabled for push and workflow_dispatch runs, using the workflow’s existing job
and step symbols.


env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/baergpt-log-bridge

jobs:
build-and-push:
runs-on: ubuntu-latest

steps:
- name: Checkout code ⬇️
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: "2"

# The image is the upstream Vector release plus this directory's config, so the
# config alone determines whether a rebuild is needed. Fixtures and the local
# harness are excluded: they never enter the image.
- name: Compute image tag 🏷️
id: tag
run: |
echo "tag=content-sha-${{ hashFiles('infra/cf-log-bridge/vector.yaml', 'infra/cf-log-bridge/Dockerfile') }}" >> "$GITHUB_OUTPUT"

- name: Log in to GitHub Container Registry 🔑
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Runner's default docker driver can't export cache (type=gha); switches
# to the docker-container driver, which supports --cache-to.
- name: Set up Docker Buildx ⚙️
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Check if image already exists in GHCR 🔍
id: check
run: |
if docker manifest inspect ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Build and push image 🐳
if: steps.check.outputs.exists != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: infra/cf-log-bridge
file: infra/cf-log-bridge/Dockerfile
platforms: linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
${{ env.IMAGE_NAME }}:commit-${{ github.sha }}
3 changes: 3 additions & 0 deletions apps/backend/manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ applications:
health-check-http-endpoint: /health
health-check-invocation-timeout: 10
default-route: true
# Created by infra/terraform/cloud-foundry/log-drain.tf
services:
- stackit-drain
# env vars (API keys, DB URL, etc.) aren't set here — the deployment workflow
# syncs them onto the app via `cf set-env` from a 1Password item on every
# deploy, so the same image runs across environments — see
Expand Down
10 changes: 10 additions & 0 deletions infra/cf-log-bridge/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM --platform=linux/amd64 timberio/vector:0.57.0-debian

Check warning on line 1 in infra/cf-log-bridge/Dockerfile

View workflow job for this annotation

GitHub Actions / build-and-push

FROM --platform flag should not use a constant value

FromPlatformFlagConstDisallowed: FROM --platform flag should not use constant value "linux/amd64" More info: https://docs.docker.com/go/dockerfile/rule/from-platform-flag-const-disallowed/

COPY vector.yaml /etc/vector/vector.yaml

EXPOSE 8080

# Vector does not substitute env vars into its config unless this flag is set: an
# endpoint written as ${SOME_URL} would stay that literal text instead of becoming the
# value. Baked in here rather than set as an app env var so a deploy cannot omit it.
CMD ["--config", "/etc/vector/vector.yaml", "--dangerously-allow-env-var-interpolation"]
103 changes: 103 additions & 0 deletions infra/cf-log-bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# CF log bridge

Cloud Foundry can forward an app's logs to a URL you nominate, as syslog over HTTPS.
STACKIT Observability does not accept syslog, so this Vector app sits in between:
it receives the drain, and pushes logs to Loki and container metrics to Prometheus
remote-write.

One instance runs per CF space, named `baergpt-log-bridge-<space>`. `default-route: true`
turns that name into the route, and a route belongs to one space only, so the suffix is
what keeps staging and prod apart. The deploy workflow and
`infra/terraform/cloud-foundry/log-drain.tf` build the name separately and must agree.

The drain itself is a `stackit-drain` service defined in
`infra/terraform/cloud-foundry/log-drain.tf`, and apps opt in by naming it in their own
manifest.

**Never bind this app to `stackit-drain`.** Its own output would be drained back into
itself, and the resulting amplification trips the platform's limit of 1000 log-lines per
second per instance, after which lines are dropped for every app on that cell.

Design notes: `docs/superpowers/specs/2026-07-30-cloud-foundry-observability-design.md`.

## Environment

Set by `.github/workflows/cf-log-bridge-deploy.yml` from a 1Password item, which is the
single source of truth: the workflow unsets every existing variable before re-setting.

| Variable | Notes |
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
| `BAERGPT_ENV` | `staging` or `production`. Becomes the `source` label as `cf-<env>` |
| `DRAIN_USERNAME`, `DRAIN_PASSWORD` | Basic auth on the inbound endpoint. Must match the credentials in the drain URL |
| `STACKIT_OBSERVABILITY_USERNAME`, `..._PASSWORD` | From the Observability instance's Credentials tab |
| `STACKIT_OBSERVABILITY_LOGS_ENDPOINT` | Base URL only. Vector appends `/loki/api/v1/push` |
| `STACKIT_OBSERVABILITY_METRICS_ENDPOINT` | Used verbatim, including `/api/v1/receive` |
| `VECTOR_LOG` | `warn` in deployed environments |

The two endpoints are handled in opposite ways. Copying the portal's logs URL verbatim
gives a doubled path and a 404.

## Output

Loki labels: `source`, `app_name`, `space_name`, `source_type`, `instance_id`, `level`.

`process_instance_id` and the cell `index` stay on the event and are deliberately not
promoted. They are per-container and per-cell, and unbounded label values break Loki.
Same reasoning as `container.id` in `infra/supabase/MONITORING.md`.

`source` is load-bearing. Alert rules select on it, and a rule matching no series applies
cleanly and never fires.

Metrics are named `cf_<name>`: `cf_cpu`, `cf_cpu_entitlement`, `cf_memory`,
`cf_memory_quota`, `cf_disk`, `cf_disk_quota`, `cf_rx_bytes`, `cf_tx_bytes`,
`cf_log_rate`, `cf_log_rate_limit`, `cf_container_age`. All are gauges. `rx_bytes` and
`tx_bytes` are cumulative, so `rate()` works, but a container restart appears as a drop
rather than a counter reset.

STACKIT's ingest adds its own `receive` and `tenant_id` labels. Do not select on them.

## Frame shape

Useful when changing the remap in `vector.yaml`. Everything CF sends is an RFC5424
syslog frame:

```
<14>1 2026-07-30T12:08:43Z baergpt.staging.log-emitter 6c5662d9-... [APP/PROC/WEB/0] - [tags@47450 ...] drain-probe
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ └─ message
│ │ │ │ │ │ │ └─ structured data
│ │ │ │ │ │ └─ msgid: unused by CF
│ │ │ │ │ └─ procid: source type and instance
│ │ │ │ └─ app-name: the app GUID
│ │ │ └─ hostname: org.space.app
│ │ └─ timestamp
│ └─ version
└─ PRI: facility x 8 + severity
```

PRI packs two numbers into one: `14` is facility 1 (user) times 8 plus severity 6 (info).
Vector decodes it to a `severity` string, which becomes the `level` label — a line written
to stderr arrives as `<11>`, severity 3, and is labelled `error` with no work from the app.

Structured data is exposed under its full element id: `."tags@47450"`, `."gauge@47450"`,
`."counter@47450"`. Vector also sets its own top-level `source_type` (`http_server`), so
CF's is carried as `cf_source_type` and renamed in the Loki sink's label map.

Logs and metrics share one connection and differ in four fields:

| | Log frame | Metric frame |
| ------------------------------------- | ------------------------------------------- | ---------------------- |
| `message` | the line as written | empty |
| `procid` | source type (`[APP/PROC/WEB/0]`, `[RTR/5]`) | instance index (`[0]`) |
| `."tags@47450".source_type` | the clean source type | absent |
| `."gauge@47450"` / `."counter@47450"` | absent | the value, as a string |

## Access control

CF authenticates using credentials in the drain URL's userinfo
(`https://user:pass@<bridge-route>/`). That is its only mechanism, so the same pair must
be set on this app and in the drain URL. Rotating means changing both together.

This is load-bearing rather than defence in depth: Loki labels are built from fields in
the received frame, so an open endpoint would let anyone forge structured data and mint
arbitrary label values in an instance shared by every environment.
15 changes: 15 additions & 0 deletions infra/cf-log-bridge/manifest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ((image)), ((docker_username)) and ((app_name)) are injected at push time via
# `cf push --var ...`. Runtime env is set by the deploy workflow from 1Password, not
# here.
applications:
- name: ((app_name))
docker:
image: ((image))
username: ((docker_username))
instances: 1
memory: 256M
disk_quota: 512M
# A port check is a TCP connect. An http check would send a request, get 401 from
# Vector's basic auth, and mark the app crashed.
health-check-type: port
default-route: true
Loading