Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
104 changes: 104 additions & 0 deletions .github/workflows/compose-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Compose smoke

# Brings up the one-command demo stack (deploy/docker-compose.demo.yml) on a
# clean runner and checks it works end to end: the dashboard answers and the
# pre-enrolled worker is online. Guards the demo against rot.

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
demo-stack:
name: Demo compose stack starts and enrols
runs-on: ubuntu-latest
timeout-minutes: 20
env:
COMPOSE: docker compose -f deploy/docker-compose.demo.yml
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Start the stack
run: |
echo "STARTED_AT=$(date +%s)" >> "$GITHUB_ENV"
# Not `up --wait`: the stack has one-shot services (certs, seed)
# that exit by design, which --wait can treat as a failure.
# `up -d` still waits on depends_on conditions (server healthy, seed
# completed) and exits non-zero if one is not met in time, e.g. while
# SQL Server warms up or a service restarts. The polling steps below
# are the real gate, so record the exit code and state, then go on.
rc=0
$COMPOSE up -d || rc=$?
if [ "$rc" -ne 0 ]; then
echo "::warning::compose up exited $rc; continuing to the readiness checks"
$COMPOSE ps -a
$COMPOSE logs --no-color --tail=50 worker server seed certs || true
fi

- name: Wait for the dashboard
run: |
for i in $(seq 1 120); do
if curl -fsS -o /dev/null http://localhost:8080/api/auth/config; then
echo "Dashboard answered after ~$((i * 5))s of polling"
exit 0
fi
sleep 5
done
echo "Dashboard did not answer on http://localhost:8080 in 10 minutes" >&2
exit 1

- name: Dashboard serves HTML
run: |
curl -fsS http://localhost:8080/ | grep -qi '<html'

- name: Wait for the worker to enrol and come online
run: |
jar="$RUNNER_TEMP/cookies.txt"
api() { curl -fsS -b "$jar" -c "$jar" -H 'content-type: application/json' \
-H "x-rsagent-csrf: $(awk '$6=="rsagent_csrf"{print $7}' "$jar")" "$@"; }
api http://localhost:8080/api/auth/config > /dev/null
api -X POST http://localhost:8080/api/auth/login \
-d '{"username":"admin","password":"rsagent-demo"}' > /dev/null
for i in $(seq 1 90); do
if api http://localhost:8080/api/workers \
| jq -e '.workers | any(.hostName == "DEMO-SQLHOST01" and .online == true)' > /dev/null; then
echo "Worker DEMO-SQLHOST01 is online"
exit 0
fi
sleep 5
done
echo "Worker DEMO-SQLHOST01 never came online" >&2
api http://localhost:8080/api/workers | jq . >&2 || true
exit 1

- name: Record startup duration
if: success()
run: |
secs=$(( $(date +%s) - STARTED_AT ))
{
echo "### Demo stack startup"
echo
echo "Dashboard reachable and worker enrolled in **${secs}s** from \`compose up\`."
} >> "$GITHUB_STEP_SUMMARY"

- name: Logs and container state
if: failure()
run: |
$COMPOSE ps -a
echo "::group::worker logs"
$COMPOSE logs --no-color --tail=200 worker || true
echo "::endgroup::"
$COMPOSE logs --no-color --tail=200

- name: Tear down
if: always()
run: $COMPOSE down -v
114 changes: 114 additions & 0 deletions .github/workflows/readme-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: README screenshots

# Manual only. Starts the one-command demo stack (deploy/docker-compose.demo.yml),
# drives the dashboard with Playwright and uploads the README screenshots
# (docs/demo.md "Notes for screenshots") as a workflow artifact.

on:
workflow_dispatch:

permissions:
contents: read

jobs:
screenshots:
name: Capture README screenshots
runs-on: ubuntu-latest
timeout-minutes: 40
env:
COMPOSE: docker compose -f deploy/docker-compose.demo.yml
NODE_VERSION: '24'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- run: pnpm install --frozen-lockfile --filter @remote-sql-agent/dashboard...

- name: Install Playwright browser
run: pnpm --filter @remote-sql-agent/dashboard exec playwright install --with-deps chromium

- name: Start the stack
run: $COMPOSE up -d

- name: Wait for the dashboard
run: |
for i in $(seq 1 120); do
if curl -fsS -o /dev/null http://localhost:8080/api/auth/config; then
echo "Dashboard answered after ~$((i * 5))s of polling"
exit 0
fi
sleep 5
done
echo "Dashboard did not answer on http://localhost:8080 in 10 minutes" >&2
exit 1

- name: Wait for the worker to come online
run: |
jar="$RUNNER_TEMP/cookies.txt"
api() { curl -fsS -b "$jar" -c "$jar" -H 'content-type: application/json' \
-H "x-rsagent-csrf: $(awk '$6=="rsagent_csrf"{print $7}' "$jar")" "$@"; }
api http://localhost:8080/api/auth/config > /dev/null
api -X POST http://localhost:8080/api/auth/login \
-d '{"username":"admin","password":"rsagent-demo"}' > /dev/null
for i in $(seq 1 90); do
if api http://localhost:8080/api/workers \
| jq -e '.workers | any(.hostName == "DEMO-SQLHOST01" and .online == true)' > /dev/null; then
echo "Worker DEMO-SQLHOST01 is online"
exit 0
fi
sleep 5
done
echo "Worker DEMO-SQLHOST01 never came online" >&2
exit 1

# The drift edit only shows as a second version if the worker has already
# mirrored the job's original definition, so wait for that first.
- name: Wait for the fixture jobs to be mirrored
run: |
jar="$RUNNER_TEMP/cookies.txt"
api() { curl -fsS -b "$jar" -c "$jar" -H 'content-type: application/json' \
-H "x-rsagent-csrf: $(awk '$6=="rsagent_csrf"{print $7}' "$jar")" "$@"; }
for i in $(seq 1 60); do
if api 'http://localhost:8080/api/jobs/groups?by=name' \
| jq -e '.groups | any(.label == "RSAgent Fixture - Heartbeat Log")' > /dev/null; then
echo "Heartbeat Log mirrored"
break
fi
sleep 5
done
# Let the first sync settle so the original definition is stored as version 1.
sleep 45

# Edits the Heartbeat Log step in msdb; the capture script waits for the badge.
- name: Run the drift profile
# --no-deps: the drift service depends on seed, and `run` would start the
# seed container again, which recreates every fixture job and discards
# the original definition the edit is meant to be compared against.
run: $COMPOSE run --no-deps --rm drift

- name: Capture screenshots
run: pnpm --filter @remote-sql-agent/dashboard exec node ../../docs/screenshots/capture.mjs
env:
BASE_URL: http://localhost:8080
OUT_DIR: ${{ github.workspace }}/docs/screenshots/out

- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: readme-screenshots
path: docs/screenshots/out/*.png
if-no-files-found: error

- name: Logs and container state
if: failure()
run: |
$COMPOSE ps -a
$COMPOSE logs --no-color --tail=200

- name: Tear down
if: always()
run: $COMPOSE down -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ test-results/
playwright-report/
blob-report/
.playwright/
docs/screenshots/out/
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@

---

## Screenshots

Captured from the [one-command demo stack](docs/demo.md).

| | |
|---|---|
| ![Estate grid: every instance, worker state and failures at a glance](docs/screenshots/1-estate-grid.png) | ![Job history with step-level failure detail](docs/screenshots/2-failed-job-history.png) |
| **Estate grid.** Every instance, worker state and running jobs. | **Failed job history.** Run and step detail, including retries. |
| ![Cross-server search by text inside job step bodies](docs/screenshots/3-cross-server-search.png) | ![Version history with a step diff](docs/screenshots/4-drift-diff.png) |
| **Cross-server search.** Find a job by text inside any step. | **Drift diff.** An on-server edit shown against the previous version. |
| ![Workers and their capability ceilings](docs/screenshots/5-workers-capabilities.png) | |
| **Workers.** What each worker can actually do. | |

## Why this exists

rsagent exists because our team runs multiple on-premise SQL Server Agent
Expand Down
37 changes: 26 additions & 11 deletions deploy/demo/mint-token.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,33 @@ async function call(path, init = {}) {
});
remember(response);
if (!response.ok) {
throw new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`);
const error = new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`);
error.status = response.status;
throw error;
}
return response.json();
}

await call('/api/auth/config');
await call('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password: adminPassword }),
});
const minted = await call('/api/enrolment-tokens', {
method: 'POST',
body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['readOnly'] }),
});
process.stdout.write(minted.token);
async function mint() {
await call('/api/auth/config');
await call('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password: adminPassword }),
});
const minted = await call('/api/enrolment-tokens', {
method: 'POST',
body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['observe'] }),
});
return minted.token;
}

// Exit 78 (EX_CONFIG) means the server rejected the request (4xx: bad
// credentials, bad capability, rate limited). Retrying cannot fix that, so
// worker-entry.sh stops instead of hammering /api/auth/login. Anything else
// (server still starting, network) exits 1 and is retried by compose.
try {
process.stdout.write(await mint());
} catch (error) {
console.error(`[demo] ${error.message}`);
process.exit(error.status >= 400 && error.status < 500 ? 78 : 1);
}
11 changes: 10 additions & 1 deletion deploy/demo/worker-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ PKG=/app/worker-packages/rsagent-worker-linux.tar.gz
tar -xzf "$PKG" -C "$RUN" ./rsagent-worker.mjs

echo "[demo] minting an enrolment token as the bootstrap admin..."
TOKEN="$(node /demo/mint-token.mjs)"
rc=0
TOKEN="$(node /demo/mint-token.mjs)" || rc=$?
if [ "$rc" -eq 78 ]; then
# The server answered with a 4xx. Retrying cannot help and would only burn
# the login rate limit, so exit 0 (compose's on-failure policy won't restart).
echo "[demo] the control plane rejected the enrolment request (see above); not retrying." >&2
exit 0
elif [ "$rc" -ne 0 ]; then
exit "$rc"
fi

echo "[demo] enrolling ${RSAGENT_WORKER_HOST}..."
node "$RUN/rsagent-worker.mjs" enrol --token "$TOKEN" /demo/worker.demo.yaml
Expand Down
2 changes: 1 addition & 1 deletion deploy/docker-compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ services:
cap_drop:
- ALL
read_only: true
restart: on-failure
restart: on-failure:5
mem_limit: 256m

# --- Drift scenario --------------------------------------------------------
Expand Down
Binary file added docs/screenshots/1-estate-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/2-failed-job-history.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/3-cross-server-search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/4-drift-diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/5-workers-capabilities.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading