Files
pulse/.github/workflows/update-demo-server.yml
pulse-triage[bot] ad1cfd33c3 fix(ci): qualify grouped release action pin consumers
The grouped action upgrade leaves signing, network and publication consumer assertions on superseded pins. Align those contracts and check every consumer against reviewed immutable upstream manifests, retaining exact dispatch and release trust boundaries without claiming hosted execution.

Change-source: pulse-maintainer
2026-09-10 00:35:47 +01:00

876 lines
39 KiB
YAML

name: Update Demo Server
on:
workflow_call:
inputs:
tag:
description: 'Stable release tag to deploy, or latest for verification-only checks'
required: true
type: string
target:
description: 'Demo target to deploy'
required: false
default: stable
type: string
verify_only:
description: 'Verify connectivity and the current stable demo without changing the host'
required: false
default: false
type: boolean
activation_convergence_run_id:
description: 'Exact Release Convergence owner. Required for every mutating call.'
required: false
default: ''
type: string
customer_promotion_lease_sha:
description: 'Exact active customer-promotion lease. Required for every mutating call.'
required: false
default: ''
type: string
convergence_owner_asset_name:
description: 'Owner record stored in the exact convergence lease commit.'
required: false
default: ''
type: string
convergence_owner_asset_sha256:
description: 'SHA-256 digest of the commit-bound convergence owner record.'
required: false
default: ''
type: string
permissions:
contents: read
# GitHub environments gate secret access and deployment admission, but do not
# serialize jobs. Share one resource lock with the recovery workflow so two
# release convergences, a verification run, and emergency recovery can never
# operate on the single stable demo host concurrently.
concurrency:
group: stable-demo-runtime
queue: max
cancel-in-progress: false
jobs:
resolve:
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
tag: ${{ steps.target.outputs.tag }}
target: ${{ steps.target.outputs.target }}
environment_name: ${{ steps.target.outputs.environment_name }}
skip: ${{ steps.target.outputs.skip || 'false' }}
mock_nodes: ${{ steps.runtime_profile.outputs.mock_nodes }}
mock_vms_per_node: ${{ steps.runtime_profile.outputs.mock_vms_per_node }}
mock_lxcs_per_node: ${{ steps.runtime_profile.outputs.mock_lxcs_per_node }}
mock_docker_hosts: ${{ steps.runtime_profile.outputs.mock_docker_hosts }}
mock_docker_containers: ${{ steps.runtime_profile.outputs.mock_docker_containers }}
mock_generic_hosts: ${{ steps.runtime_profile.outputs.mock_generic_hosts }}
mock_k8s_clusters: ${{ steps.runtime_profile.outputs.mock_k8s_clusters }}
mock_k8s_nodes: ${{ steps.runtime_profile.outputs.mock_k8s_nodes }}
mock_k8s_pods: ${{ steps.runtime_profile.outputs.mock_k8s_pods }}
mock_k8s_deployments: ${{ steps.runtime_profile.outputs.mock_k8s_deployments }}
mock_seed_duration: ${{ steps.runtime_profile.outputs.mock_seed_duration }}
mock_sample_interval: ${{ steps.runtime_profile.outputs.mock_sample_interval }}
mock_update_interval: ${{ steps.runtime_profile.outputs.mock_update_interval }}
steps:
- name: Resolve target tag and demo environment
id: target
env:
INPUT_TAG: ${{ inputs.tag }}
INPUT_TARGET: ${{ inputs.target }}
VERIFY_ONLY: ${{ inputs.verify_only }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
REQUESTED_TARGET="$INPUT_TARGET"
if [ -z "$TAG" ]; then
echo "::error::A stable release tag is required."
exit 1
fi
if [ "$TAG" = "latest" ]; then
if [ "${VERIFY_ONLY:-false}" != "true" ]; then
echo "::error::The latest alias is allowed only for verification-only checks."
exit 1
fi
TAG="$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name')"
echo "Resolved verification-only target to latest stable release ${TAG}."
fi
VERSION="${TAG#v}"
IS_PRERELEASE=false
if [[ "$VERSION" =~ -(rc|alpha|beta)\.[0-9]+$ ]]; then
IS_PRERELEASE=true
fi
TARGET="${REQUESTED_TARGET:-auto}"
if [ -z "$TARGET" ] || [ "$TARGET" = "auto" ]; then
if [ "$IS_PRERELEASE" = "true" ]; then
python3 scripts/write_github_output.py tag "$TAG"
echo "target=stable" >> "$GITHUB_OUTPUT"
echo "environment_name=demo-stable" >> "$GITHUB_OUTPUT"
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Prerelease demo updates are retired after v6 GA; skipping public demo update for ${TAG}."
exit 0
fi
TARGET="stable"
fi
case "$TARGET" in
stable)
if [ "$IS_PRERELEASE" = "true" ]; then
echo "::error::Stable demo target only accepts stable tags. Refusing prerelease tag ${TAG}."
exit 1
fi
ENVIRONMENT_NAME="demo-stable"
;;
*)
echo "::error::Unsupported demo target: ${TARGET}"
exit 1
;;
esac
python3 scripts/write_github_output.py tag "$TAG"
python3 scripts/write_github_output.py target "$TARGET"
echo "environment_name=$ENVIRONMENT_NAME" >> "$GITHUB_OUTPUT"
echo "Resolved demo deployment: tag=${TAG}, target=${TARGET}, environment=${ENVIRONMENT_NAME}"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Validate governed release line for selected tag
env:
WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
VERSION="${TAG#v}"
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --prune --unshallow origin
fi
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
git fetch --prune origin "${REQUIRED_BRANCH}" --tags
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} does not exist in repository tags."
exit 1
fi
TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then
echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing demo deployment."
exit 1
fi
echo "[OK] ${TAG} validated for governed demo deployment on ${REQUIRED_BRANCH}"
- name: Resolve target-compatible demo runtime profile
id: runtime_profile
env:
WORKFLOW_OUTPUT_1: ${{ steps.target.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
TAG_REF="refs/tags/${TAG}"
PROFILE_OUTPUT="$(bash .github/scripts/resolve-demo-runtime-profile.sh "${TAG_REF}")"
while IFS='=' read -r output_name output_value; do
python3 scripts/write_github_output.py "$output_name" "$output_value"
done <<< "$PROFILE_OUTPUT"
printf 'Resolved target-compatible demo runtime profile for %s:\n%s\n' "$TAG" "$PROFILE_OUTPUT"
update-demo:
needs: resolve
if: needs.resolve.outputs.skip != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
environment: ${{ needs.resolve.outputs.environment_name }}
env:
DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }}
DEMO_LOCAL_BASE_URL: ${{ vars.DEMO_LOCAL_BASE_URL }}
DEMO_PUBLIC_HEALTH_URL: ${{ vars.DEMO_PUBLIC_HEALTH_URL }}
DEMO_SERVICE_NAME: ${{ vars.DEMO_SERVICE_NAME }}
DEMO_AUTH_USER: ${{ vars.DEMO_AUTH_USER }}
DEMO_AUTH_PASS: ${{ vars.DEMO_AUTH_PASS }}
steps:
- name: Check release type
env:
VERIFY_ONLY: ${{ inputs.verify_only }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.target }}
WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.environment_name }}
run: |
echo "Tag: ${WORKFLOW_OUTPUT_1}"
echo "Target: ${WORKFLOW_OUTPUT_2}"
echo "Environment: ${WORKFLOW_OUTPUT_3}"
echo "Verification only: ${VERIFY_ONLY}"
- name: Validate demo environment configuration
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: |
set -euo pipefail
[ -n "$DEMO_SERVER_HOST" ] || { echo "::error::DEMO_SERVER_HOST is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_SERVER_USER" ] || { echo "::error::DEMO_SERVER_USER is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_SERVER_SSH_KEY" ] || { echo "::error::DEMO_SERVER_SSH_KEY is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_EXPECTED_HOSTNAME" ] || { echo "::error::DEMO_EXPECTED_HOSTNAME is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_LOCAL_BASE_URL" ] || { echo "::error::DEMO_LOCAL_BASE_URL is required in the selected demo environment."; exit 1; }
[ -n "$DEMO_PUBLIC_HEALTH_URL" ] || { echo "::error::DEMO_PUBLIC_HEALTH_URL is required in the selected demo environment."; exit 1; }
- name: Require exact committed activation marker for mutation
if: inputs.verify_only != true
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
CONVERGENCE_RUN_ID: ${{ inputs.activation_convergence_run_id }}
LEASE_SHA: ${{ inputs.customer_promotion_lease_sha }}
OWNER_ASSET_NAME: ${{ inputs.convergence_owner_asset_name }}
OWNER_ASSET_SHA256: ${{ inputs.convergence_owner_asset_sha256 }}
run: |
set -euo pipefail
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
[[ ! "${CONVERGENCE_RUN_ID}" =~ ^[0-9]+$ ]] || \
[[ ! "${LEASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || \
[[ ! "${OWNER_ASSET_NAME}" =~ ^release-convergence-owner-${CONVERGENCE_RUN_ID}-[1-9][0-9]*\.json$ ]] || \
[[ ! "${OWNER_ASSET_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "::error::Stable demo mutation requires an exact stable tag, convergence owner, and lease."
exit 1
fi
release_state="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
--jq '[.tag_name, (.draft | tostring), (.prerelease | tostring), (.immutable // false | tostring), (.published_at // "")] | @tsv'
)"
if [ "$(awk -F '\t' '{print $1}' <<<"${release_state}")" != "${TAG}" ] || \
[ "$(awk -F '\t' '{print $2}' <<<"${release_state}")" != "false" ] || \
[ "$(awk -F '\t' '{print $3}' <<<"${release_state}")" != "false" ] || \
[ "$(awk -F '\t' '{print $4}' <<<"${release_state}")" != "true" ] || \
[ -z "$(awk -F '\t' '{print $5}' <<<"${release_state}")" ]; then
echo "::error::Stable demo mutation refuses mutable, inactive, or prerelease tag ${TAG}."
exit 1
fi
marker="$(mktemp)"
owner_record="$(mktemp)"
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
-o "${marker}" \
"https://github.com/${{ github.repository }}/releases/download/${TAG}/release-activation.json"
jq -e \
--arg tag "${TAG}" \
'.schema_version == 1 and .tag == $tag and (.convergence_run_id | test("^[0-9]+$"))' \
"${marker}" >/dev/null
activation_owner_run_id="$(jq -r '.convergence_run_id' "${marker}")"
activation_marker_sha256="$(sha256sum "${marker}" | awk '{print $1}')"
observed_lease_sha="$(
gh api \
"repos/${{ github.repository }}/git/ref/heads/release-customer-promotion-lock" \
--jq '.object.sha'
)"
if [ "${observed_lease_sha}" != "${LEASE_SHA}" ]; then
echo "::error::Stable demo lease is ${observed_lease_sha:-absent}, expected ${LEASE_SHA}."
exit 1
fi
curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \
-o "${owner_record}" \
"https://raw.githubusercontent.com/${{ github.repository }}/${LEASE_SHA}/${OWNER_ASSET_NAME}"
printf '%s %s\n' "${OWNER_ASSET_SHA256}" "${owner_record}" | sha256sum --check --
jq -e \
--arg tag "${TAG}" \
--arg activation_owner_run_id "${activation_owner_run_id}" \
--arg activation_marker_sha256 "${activation_marker_sha256}" \
--arg convergence_run_id "${CONVERGENCE_RUN_ID}" \
--arg owner_asset_name "${OWNER_ASSET_NAME}" \
'.schema_version == 2 and .tag == $tag and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .owner_asset_name == $owner_asset_name' \
"${owner_record}" >/dev/null
rm -f "${marker}" "${owner_record}"
echo "[OK] Stable demo mutation is bound to committed ${TAG} convergence run ${CONVERGENCE_RUN_ID}."
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Set up Go
if: inputs.verify_only != true
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- name: Wait for release assets
if: inputs.verify_only != true
env:
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
echo "Waiting for activated release assets to be available..."
MAX_ATTEMPTS=30
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
echo "Checking for assets (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
CHECKSUMS_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \
"https://github.com/rcourtman/Pulse/releases/download/${TAG}/checksums.txt")
TARBALL_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \
"https://github.com/rcourtman/Pulse/releases/download/${TAG}/pulse-${TAG}-linux-amd64.tar.gz")
echo "checksums.txt: $CHECKSUMS_STATUS, tarball: $TARBALL_STATUS"
if [ "$CHECKSUMS_STATUS" = "200" ] && [ "$TARBALL_STATUS" = "200" ]; then
echo "Release assets are available."
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
echo "Assets not ready yet, waiting 10 seconds..."
sleep 10
fi
done
echo "::error::Timeout waiting for release assets"
exit 1
- name: Materialize tagged installer
if: inputs.verify_only != true
env:
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
TRUSTED_SSH_PUBLIC_KEY="$(
go run ./scripts/release_update_key.go public-key-ssh \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \
--comment pulse-installer
)"
git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh
sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh
grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" /tmp/pulse-install.sh
chmod +x /tmp/pulse-install.sh
- name: Tailscale
id: tailscale
uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:infra
version: '1.94.2'
ping: ${{ secrets.DEMO_SERVER_HOST }}
- name: Diagnose Tailscale setup failure
if: failure() && steps.tailscale.outcome == 'failure'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh diagnose
- name: Verify demo network path
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
run: bash .github/scripts/check-demo-reachability.sh
- name: Setup SSH
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_SSH_KEY: ${{ secrets.DEMO_SERVER_SSH_KEY }}
run: bash .github/scripts/setup-demo-ssh.sh
- name: Verify target host identity
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
REMOTE_HOSTNAME="$(
ssh -i ~/.ssh/id_ed25519 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/known_hosts \
"$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"hostname"
)"
[ "$REMOTE_HOSTNAME" = "$DEMO_EXPECTED_HOSTNAME" ] || {
echo "::error::Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME."
exit 1
}
- name: Resolve demo service identity
id: config
env:
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.target }}
run: |
set -euo pipefail
TARGET="${WORKFLOW_OUTPUT_1}"
SERVICE_NAME="${DEMO_SERVICE_NAME:-}"
if [ -z "$SERVICE_NAME" ]; then
SERVICE_NAME="pulse"
fi
if [ "$TARGET" != "stable" ]; then
echo "::error::Unsupported demo target: ${TARGET}"
exit 1
fi
echo "service_name=$SERVICE_NAME" >> "$GITHUB_OUTPUT"
- name: Check current demo version
id: current
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
TARGET="${WORKFLOW_OUTPUT_1}"
TARGET_STRIPPED="${TARGET#v}"
CURRENT=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/api/version | jq -r .version")
echo "Current demo version: ${CURRENT}"
if [ "$CURRENT" = "$TARGET" ] || [ "$CURRENT" = "$TARGET_STRIPPED" ] || [ "v${CURRENT}" = "$TARGET" ]; then
echo "skip_current=true" >> "$GITHUB_OUTPUT"
echo "Demo target already on the requested version; skipping update."
else
echo "skip_current=false" >> "$GITHUB_OUTPUT"
fi
- name: Refuse mutation during verification-only checks
if: inputs.verify_only == true && steps.current.outputs.skip_current != 'true'
run: |
echo "::error::Verification-only check found a demo version mismatch; refusing to update the host."
exit 1
- name: Prepare demo host storage
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }}
run: |
set -euo pipefail
SERVICE_NAME="${WORKFLOW_OUTPUT_1}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
SERVICE_NAME="$1"
CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}"
BACKUP_PARENT="$(dirname "$CONFIG_DIR")"
BACKUP_BASENAME="$(basename "$CONFIG_DIR")"
KEEP_BACKUPS=2
BACKUP_MARGIN_KB=$((64 * 1024))
echo "Demo host filesystem before cleanup:"
df -h "$BACKUP_PARENT" /tmp || true
mapfile -t old_backups < <(
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \
| sort -nr \
| awk -v keep="$KEEP_BACKUPS" 'NR > keep {sub(/^[^ ]+ /, ""); print}'
)
if ((${#old_backups[@]} > 0)); then
printf 'Removing %d old demo backup directorie(s).\n' "${#old_backups[@]}"
printf '%s\0' "${old_backups[@]}" | sudo xargs -0 rm -rf --
fi
if [[ -d "$CONFIG_DIR" ]]; then
config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
required_kb=$((config_kb + BACKUP_MARGIN_KB))
if ((free_kb < required_kb)); then
mapfile -t remaining_backups < <(
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \
| sort -n \
| awk '{sub(/^[^ ]+ /, ""); print}'
)
for backup_dir in "${remaining_backups[@]}"; do
printf 'Removing demo backup to restore install headroom: %s\n' "$backup_dir"
sudo rm -rf -- "$backup_dir"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
if ((free_kb >= required_kb)); then
break
fi
done
fi
pruned_volatile=false
if ((free_kb < required_kb)); then
echo "Pruning demo volatile runtime stores to restore install headroom."
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl stop "$SERVICE_NAME" || true
fi
mapfile -d '' volatile_paths < <(
sudo find "$CONFIG_DIR" -xdev -type f \( \
-name "metrics.db" -o \
-name "metrics.db-shm" -o \
-name "metrics.db-wal" -o \
-name "notification_queue.db" -o \
-name "notification_queue.db-shm" -o \
-name "notification_queue.db-wal" -o \
-name "deploy.db" -o \
-name "deploy.db-shm" -o \
-name "deploy.db-wal" \
\) -print0 2>/dev/null || true
)
for volatile_path in "${volatile_paths[@]}"; do
printf 'Removing demo volatile store: %s\n' "$(basename "$volatile_path")"
sudo rm -f -- "$volatile_path"
pruned_volatile=true
done
config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')"
free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')"
required_kb=$((config_kb + BACKUP_MARGIN_KB))
fi
if ((free_kb < required_kb)); then
echo "::error::Demo host does not have enough free space to back up $CONFIG_DIR before install."
echo "Config: ${config_kb}KB, available: ${free_kb}KB, required: ${required_kb}KB"
sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort || true
if [[ "$pruned_volatile" == "true" ]] && command -v systemctl >/dev/null 2>&1; then
sudo systemctl start "$SERVICE_NAME" || true
fi
exit 1
fi
fi
echo "Demo host filesystem after cleanup:"
df -h "$BACKUP_PARENT" /tmp || true
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Upload tagged installer
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh"
- name: Update demo server
if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true'
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
WORKFLOW_OUTPUT_2: ${{ steps.config.outputs.service_name }}
run: |
set -euo pipefail
TAG="${WORKFLOW_OUTPUT_1}"
SERVICE_NAME="${WORKFLOW_OUTPUT_2}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
TAG="$1"
SERVICE_NAME="$2"
INSTALLER_ENV=()
if [ -n "$SERVICE_NAME" ]; then
INSTALLER_ENV+=("PULSE_SERVICE_NAME=$SERVICE_NAME")
fi
sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG"
rm -f /tmp/pulse-install.sh
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT"
- name: Restore demo runtime configuration
if: inputs.verify_only != true
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }}
WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.mock_nodes }}
WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.mock_vms_per_node }}
WORKFLOW_OUTPUT_4: ${{ needs.resolve.outputs.mock_lxcs_per_node }}
WORKFLOW_OUTPUT_5: ${{ needs.resolve.outputs.mock_docker_hosts }}
WORKFLOW_OUTPUT_6: ${{ needs.resolve.outputs.mock_docker_containers }}
WORKFLOW_OUTPUT_7: ${{ needs.resolve.outputs.mock_generic_hosts }}
WORKFLOW_OUTPUT_8: ${{ needs.resolve.outputs.mock_k8s_clusters }}
WORKFLOW_OUTPUT_9: ${{ needs.resolve.outputs.mock_k8s_nodes }}
WORKFLOW_OUTPUT_10: ${{ needs.resolve.outputs.mock_k8s_pods }}
WORKFLOW_OUTPUT_11: ${{ needs.resolve.outputs.mock_k8s_deployments }}
WORKFLOW_OUTPUT_12: ${{ needs.resolve.outputs.mock_seed_duration }}
WORKFLOW_OUTPUT_13: ${{ needs.resolve.outputs.mock_sample_interval }}
WORKFLOW_OUTPUT_14: ${{ needs.resolve.outputs.mock_update_interval }}
run: |
set -euo pipefail
SERVICE_NAME="${WORKFLOW_OUTPUT_1}"
MOCK_NODES="${WORKFLOW_OUTPUT_2}"
MOCK_VMS_PER_NODE="${WORKFLOW_OUTPUT_3}"
MOCK_LXCS_PER_NODE="${WORKFLOW_OUTPUT_4}"
MOCK_DOCKER_HOSTS="${WORKFLOW_OUTPUT_5}"
MOCK_DOCKER_CONTAINERS="${WORKFLOW_OUTPUT_6}"
MOCK_GENERIC_HOSTS="${WORKFLOW_OUTPUT_7}"
MOCK_K8S_CLUSTERS="${WORKFLOW_OUTPUT_8}"
MOCK_K8S_NODES="${WORKFLOW_OUTPUT_9}"
MOCK_K8S_PODS="${WORKFLOW_OUTPUT_10}"
MOCK_K8S_DEPLOYMENTS="${WORKFLOW_OUTPUT_11}"
MOCK_SEED_DURATION="${WORKFLOW_OUTPUT_12}"
MOCK_SAMPLE_INTERVAL="${WORKFLOW_OUTPUT_13}"
MOCK_UPDATE_INTERVAL="${WORKFLOW_OUTPUT_14}"
REMOTE_SCRIPT=$(cat <<'EOF'
set -euo pipefail
SERVICE_NAME="${1:-pulse}"
DEMO_LOCAL_BASE_URL="$2"
MOCK_NODES="$3"
MOCK_VMS_PER_NODE="$4"
MOCK_LXCS_PER_NODE="$5"
MOCK_DOCKER_HOSTS="$6"
MOCK_DOCKER_CONTAINERS="$7"
MOCK_GENERIC_HOSTS="$8"
MOCK_K8S_CLUSTERS="$9"
MOCK_K8S_NODES="${10}"
MOCK_K8S_PODS="${11}"
MOCK_K8S_DEPLOYMENTS="${12}"
MOCK_SEED_DURATION="${13}"
MOCK_SAMPLE_INTERVAL="${14}"
MOCK_UPDATE_INTERVAL="${15}"
CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}"
SERVICE_USER="$(systemctl show "$SERVICE_NAME" --property=User --value 2>/dev/null || true)"
SERVICE_GROUP="$(systemctl show "$SERVICE_NAME" --property=Group --value 2>/dev/null || true)"
SERVICE_USER="${SERVICE_USER:-pulse}"
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
resolve_config_dir() {
local fallback="$CONFIG_DIR"
local unit_env=""
local fragment=""
unit_env="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)"
for entry in $unit_env; do
case "$entry" in
PULSE_DATA_DIR=*)
printf '%s\n' "${entry#PULSE_DATA_DIR=}"
return 0
;;
esac
done
fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)"
if [ -n "$fragment" ] && sudo test -r "$fragment"; then
local unit_data_dir
unit_data_dir="$(sudo sed -n -E 's/^Environment="?PULSE_DATA_DIR=([^"]*)"?$/\1/p' "$fragment" | tail -1)"
if [ -n "$unit_data_dir" ]; then
printf '%s\n' "$unit_data_dir"
return 0
fi
fi
printf '%s\n' "$fallback"
}
CONFIG_DIR="$(resolve_config_dir)"
ENV_FILE="$CONFIG_DIR/.env"
set_env_value() {
local key="$1"
local value="$2"
sudo mkdir -p "$CONFIG_DIR"
if sudo test -f "$ENV_FILE"; then
if sudo grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then
sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$ENV_FILE"
else
printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$ENV_FILE" >/dev/null
fi
else
printf '# Pulse demo runtime configuration\n%s=%s\n' "$key" "$value" | sudo tee "$ENV_FILE" >/dev/null
fi
}
ensure_demo_fixture_entitlement() {
local billing_file="$CONFIG_DIR/billing.json"
local tmp_file
tmp_file="$(mktemp)"
sudo mkdir -p "$CONFIG_DIR"
if sudo test -s "$billing_file" && sudo jq '
.capabilities = (((.capabilities // []) + ["demo_fixtures"]) | unique)
| .limits = (.limits // {})
| .meters_enabled = (.meters_enabled // [])
| .plan_version = (if (.plan_version // "") == "" then "community" else .plan_version end)
| .subscription_state = (if (.subscription_state // "") == "" then "active" else .subscription_state end)
| del(.integrity)
' "$billing_file" > "$tmp_file"; then
:
else
cat > "$tmp_file" <<'JSON'
{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}
JSON
fi
sudo tee "$billing_file" < "$tmp_file" >/dev/null
rm -f "$tmp_file"
sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$billing_file" || sudo chown "$SERVICE_USER" "$billing_file" || true
sudo chmod 600 "$billing_file"
echo "Demo fixture entitlement ensured in governed demo billing state."
}
set_env_value DEMO_MODE true
set_env_value PULSE_MOCK_MODE true
set_env_value PULSE_MOCK_NODES "$MOCK_NODES"
set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"
set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"
set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"
set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS"
set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS"
set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS"
set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES"
set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"
set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS"
set_env_value PULSE_MOCK_RANDOM_METRICS true
set_env_value PULSE_MOCK_STOPPED_PERCENT 6
# Never carry the isolated local-dev metrics-store opt-in onto the
# persistent public demo data directory.
set_env_value PULSE_MOCK_SEED_METRICS_STORE false
set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"
set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"
set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"
ensure_demo_fixture_entitlement
sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$ENV_FILE" || sudo chown "$SERVICE_USER" "$ENV_FILE" || true
sudo chmod 600 "$ENV_FILE"
sudo systemctl daemon-reload
sudo systemctl restart "$SERVICE_NAME"
for attempt in $(seq 1 30); do
if curl -fsS "${DEMO_LOCAL_BASE_URL}/api/health" >/dev/null; then
echo "Demo service restarted with governed demo runtime configuration."
exit 0
fi
sleep 2
done
echo "::error::Demo service did not become healthy after restoring demo runtime configuration."
exit 1
EOF
)
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"bash -s -- $(printf '%q ' "$SERVICE_NAME" "$DEMO_LOCAL_BASE_URL" "$MOCK_NODES" "$MOCK_VMS_PER_NODE" "$MOCK_LXCS_PER_NODE" "$MOCK_DOCKER_HOSTS" "$MOCK_DOCKER_CONTAINERS" "$MOCK_GENERIC_HOSTS" "$MOCK_K8S_CLUSTERS" "$MOCK_K8S_NODES" "$MOCK_K8S_PODS" "$MOCK_K8S_DEPLOYMENTS" "$MOCK_SEED_DURATION" "$MOCK_SAMPLE_INTERVAL" "$MOCK_UPDATE_INTERVAL")" <<<"$REMOTE_SCRIPT"
- name: Verify demo runtime
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
sleep 5
VERSION=$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/api/version | jq -r .version")
echo "Demo server is now running version: $VERSION"
TAG="${WORKFLOW_OUTPUT_1}"
TAG_STRIPPED="${TAG#v}"
if [ "$VERSION" != "$TAG" ] && [ "$VERSION" != "$TAG_STRIPPED" ]; then
echo "::error::Version mismatch! Expected $TAG but got $VERSION"
exit 1
fi
AUTH_USER="${DEMO_AUTH_USER:-demo}"
AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
'AUTH_USER='"$(printf '%q' "$AUTH_USER")"' AUTH_PASS='"$(printf '%q' "$AUTH_PASS")"' DEMO_LOCAL_BASE_URL='"$(printf '%q' "$DEMO_LOCAL_BASE_URL")"' bash -s' <<'EOF'
set -euo pipefail
COOKIE_FILE="$(mktemp)"
trap 'rm -f "$COOKIE_FILE"' EXIT
curl -fsS -c "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/login" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"${AUTH_USER}\",\"password\":\"${AUTH_PASS}\"}" > /dev/null
curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/license/runtime-capabilities" >/dev/null
MOCK_ENABLED="false"
RESOURCES="0"
for attempt in $(seq 1 30); do
MOCK_ENABLED=$(curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/system/mock-mode" | jq -r '.enabled // false')
RESOURCES=$(curl -fsS -b "$COOKIE_FILE" "${DEMO_LOCAL_BASE_URL}/api/state" | jq -r '.resources | if type == "array" then length else 0 end')
if [ "$MOCK_ENABLED" = "true" ] && [ "$RESOURCES" -ge 1 ]; then
break
fi
sleep 2
done
echo "Mock mode enabled: $MOCK_ENABLED"
echo "Mock resources detected: $RESOURCES"
if [ "$MOCK_ENABLED" != "true" ]; then
echo "::error::Demo server mock mode did not enable after entitlement sync"
exit 1
fi
if [ "$RESOURCES" -lt 1 ]; then
echo "::error::Demo server updated but canonical mock resources are missing"
exit 1
fi
EOF
echo "Demo server successfully updated and verified."
- name: Verify frontend parity
env:
DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }}
DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }}
run: |
set -euo pipefail
case "$DEMO_PUBLIC_HEALTH_URL" in
*/api/health)
PUBLIC_ROOT_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/"
;;
*)
echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for frontend verification."
exit 1
;;
esac
extract_entry_asset() {
python3 -c 'import re, sys; html = sys.stdin.read(); match = re.search(r"<script\b[^>]*\bsrc=\"(/assets/index-[^\"]*\.js)\"", html); sys.exit(1) if match is None else sys.stdout.write(match.group(1))'
}
if ! REMOTE_ASSET="$(
ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \
"curl -fsS ${DEMO_LOCAL_BASE_URL}/" | extract_entry_asset
)"; then
echo "::error::Failed to resolve the remote frontend entry asset."
exit 1
fi
if ! PUBLIC_ASSET="$(curl -fsS "$PUBLIC_ROOT_URL" | extract_entry_asset)"; then
echo "::error::Failed to resolve the public frontend entry asset."
exit 1
fi
[ "$PUBLIC_ASSET" = "$REMOTE_ASSET" ] || {
echo "::error::Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET."
exit 1
}
- name: Verify public health
run: |
set -euo pipefail
curl -fsS "$DEMO_PUBLIC_HEALTH_URL"
- name: Verify public browser smoke
run: |
set -euo pipefail
case "$DEMO_PUBLIC_HEALTH_URL" in
*/api/health)
export PULSE_PUBLIC_SITE_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/"
;;
*)
echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for browser verification."
exit 1
;;
esac
export PULSE_DEMO_AUTH_USER="${DEMO_AUTH_USER:-demo}"
export PULSE_DEMO_AUTH_PASS="${DEMO_AUTH_PASS:-demo}"
./scripts/run_demo_public_browser_smoke.sh
- name: Cleanup SSH material
if: always()
run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts /tmp/pulse-install.sh