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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ jobs:
working-directory: deployment-pulse-plugin
run: bun install --frozen-lockfile

- name: Test registry publisher
run: |
bun test scripts/publish-plugin.test.ts
deployment-pulse-plugin/node_modules/.bin/tsc --project scripts/tsconfig.json

- name: Test and build Deployment Pulse
working-directory: deployment-pulse-plugin
run: |
Expand Down
41 changes: 31 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name: Release
on:
push:
tags:
- 'v*'
- "v*"
workflow_dispatch:

permissions:
Expand All @@ -21,14 +21,21 @@ jobs:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
suffix: x86_64-linux
os: ubuntu-22.04
suffix: x86_64-linux-gnu
bun_target: bun-linux-x64
- target: x86_64-unknown-linux-musl
os: ubuntu-24.04
suffix: x86_64-linux-musl
bun_target: bun-linux-x64-musl
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
suffix: aarch64-linux
linker: aarch64-linux-gnu-gcc
os: ubuntu-22.04-arm
suffix: aarch64-linux-gnu
bun_target: bun-linux-arm64
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
suffix: aarch64-linux-musl
bun_target: bun-linux-arm64-musl
- target: x86_64-apple-darwin
os: macos-latest
suffix: x86_64-darwin
Expand All @@ -50,16 +57,29 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: 1.98.0
targets: ${{ matrix.target }}

- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14

- name: Install cross-compilation tools (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
- name: Verify tag matches plugin versions
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
manifest_version="$(bun -e 'const value = await Bun.file("deployment-pulse-plugin/registry.json").json(); process.stdout.write(value.version)')"
test "${tag_version}" = "${manifest_version}"
cargo metadata --no-deps --format-version 1 \
| jq -e --arg version "${tag_version}" 'all(.packages[]; .version == $version)'

- name: Install musl toolchain (Linux)
if: contains(matrix.target, 'linux-musl')
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
sudo apt-get install -y musl-tools

- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
Expand All @@ -68,7 +88,8 @@ jobs:

- name: Build all plugins
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
run: cargo build --release --workspace --target ${{ matrix.target }}

- name: Install Deployment Pulse dependencies
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ dist/
.env
.env.*
!.env.example
.registry-secrets/
catalog-*.private.pem

# Logs
*.log
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "Apache-2.0"
authors = ["Temps Contributors"]
Expand Down
12 changes: 9 additions & 3 deletions deployment-pulse-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ read-only API at `/api/x/deployment-pulse/overview`.

## Permissions and data

Deployment Pulse declares no raw API capability, database access, or host-data
access. It reads only the caller-scoped `list_projects` and `list_deployments`
methods exposed by the signed protocol-v2 SDK channel.
Deployment Pulse is an installation-wide dashboard for system administrators.
Its API requires an SDK-verified caller with the effective `system:admin`
permission before querying any project data. Restricted API keys do not gain
access merely because their owner has an administrator role.

It declares no raw API capability, database access, or host-data access. The
legacy `list_projects` and `list_deployments` channel methods span the whole
installation; they are not scoped to the caller. The permission check is
therefore mandatory for this dashboard.

Project links use the public `/projects/<project_slug>` route. Internal project
IDs are never exposed in dashboard URLs.
4 changes: 2 additions & 2 deletions deployment-pulse-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "typescript-deployment-pulse-plugin",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
"embed": "bun run scripts/embed-assets.ts",
"compile": "bun run embed && bun build src/index.ts --compile --outfile dist/temps-deployment-pulse-plugin",
"build": "bun run compile",
"test": "bun test src web/src"
"test": "bun run embed && bun test src web/src"
},
"dependencies": {
"@temps-sdk/plugin": "0.1.0-beta.1"
Expand Down
14 changes: 14 additions & 0 deletions deployment-pulse-plugin/registry.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "deployment-pulse",
"version": "0.1.1",
"binary": "temps-deployment-pulse-plugin",
"title": "Deployment Pulse",
"summary": "Monitor deployment health across every project.",
"description": "A searchable, cross-project deployment health dashboard with failures and active deployments shown first.",
"author": "Temps",
"category": "Observability",
"keywords": ["deployments", "health", "projects"],
"repository": "https://github.com/gotempsh/plugins/tree/main/deployment-pulse-plugin",
"docs_url": "https://github.com/gotempsh/plugins/blob/main/deployment-pulse-plugin/README.md",
"logo_url": null
}
57 changes: 57 additions & 0 deletions deployment-pulse-plugin/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

import { expect, test, mock, spyOn } from "bun:test";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { AuthenticatedCaller, PluginContext, type TempsClient } from "@temps-sdk/plugin";
// Test-only fixture hook: production uses extractAuthContext, never this helper.
import { attachVerifiedCaller } from "../node_modules/@temps-sdk/plugin/dist/auth.js";
import { plugin } from "./index";

async function overview(caller?: AuthenticatedCaller) {
const listProjects = mock(async () => []);
const ctx = new PluginContext({
pluginName: "deployment-pulse", dataDir: "/tmp/test-pulse",
authSecret: "test-only", client: { listProjects } as unknown as TempsClient,
});
const req = new IncomingMessage(new Socket());
req.method = "GET";
req.url = "/overview";
// Forging raw identity headers must never authorize a request.
req.headers = { "x-temps-user-role": "admin", "x-temps-user-permissions": "system:admin" };
if (caller) attachVerifiedCaller(req, caller);
const res = new ServerResponse(req);
const end = spyOn(res, "end").mockImplementation(() => res);
const handler = await plugin.handler(ctx);
await handler(req, res);
end.mockRestore();
req.destroy();
return { status: res.statusCode, calls: listProjects.mock.calls.length };
}

function caller(role: "admin" | "reader" | "custom", permissions: string[]) {
return new AuthenticatedCaller({
userId: 1, userEmail: "operator@example.test", role, permissions, requestId: "test",
});
}

test("unverified identity headers cannot read any projects", async () => {
expect(await overview()).toEqual({ status: 401, calls: 0 });
});

test("readers cannot read installation-wide deployment data", async () => {
expect(await overview(caller("reader", ["projects:read"]))).toEqual({ status: 403, calls: 0 });
});

test("admin role cannot bypass narrowed effective permissions", async () => {
expect(await overview(caller("admin", ["projects:read"]))).toEqual({ status: 403, calls: 0 });
});

test("verified system administrators can read the overview", async () => {
expect(await overview(caller("admin", ["system:admin"]))).toEqual({ status: 200, calls: 1 });
});

test("authorization follows effective permission, not the role label", async () => {
expect(await overview(caller("custom", ["system:admin"]))).toEqual({ status: 200, calls: 1 });
});
59 changes: 49 additions & 10 deletions deployment-pulse-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@

import {
createManifest,
extractAuthContext,
runPlugin,
type PluginContext,
type PluginEvent,
type ProjectInfo,
type RequestHandler,
type TempsPlugin,
} from "@temps-sdk/plugin";
import pluginMetadata from "../registry.json";
import { embeddedAssets } from "./_embedded_ui.js";
import { buildOverview, summarizeProject, type ProjectPulse } from "./overview.js";
import {
buildOverview,
summarizeProject,
type ProjectPulse,
} from "./overview.js";

const MAX_CONCURRENT_PROJECT_QUERIES = 6;

Expand All @@ -21,12 +27,21 @@ async function loadProjectPulses(
): Promise<ProjectPulse[]> {
const results: ProjectPulse[] = [];

for (let offset = 0; offset < projects.length; offset += MAX_CONCURRENT_PROJECT_QUERIES) {
const batch = projects.slice(offset, offset + MAX_CONCURRENT_PROJECT_QUERIES);
for (
let offset = 0;
offset < projects.length;
offset += MAX_CONCURRENT_PROJECT_QUERIES
) {
const batch = projects.slice(
offset,
offset + MAX_CONCURRENT_PROJECT_QUERIES,
);
const pulses = await Promise.all(
batch.map(async (project): Promise<ProjectPulse> => {
try {
const deployments = await ctx.temps.listDeployments(project.id, { limit: 10 });
const deployments = await ctx.temps.listDeployments(project.id, {
limit: 10,
});
return summarizeProject(project, deployments);
} catch (error) {
console.error(
Expand All @@ -51,17 +66,21 @@ async function loadProjectPulses(
return results;
}

function json(res: Parameters<RequestHandler>[1], status: number, body: unknown): void {
function json(
res: Parameters<RequestHandler>[1],
status: number,
body: unknown,
): void {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(body));
}

const plugin: TempsPlugin = {
export const plugin: TempsPlugin = {
manifest() {
return createManifest("deployment-pulse", "0.1.0")
return createManifest("deployment-pulse", pluginMetadata.version)
.displayName("Deployment Pulse")
.description("See deployment health across every project at a glance")
.requiresDb(false)
Expand All @@ -83,6 +102,20 @@ const plugin: TempsPlugin = {
return;
}

// Legacy typed channel queries span the installation. Only the SDK's
// verified caller and effective permissions may authorize this view.
const caller = extractAuthContext(req);
if (!caller) {
json(res, 401, { error: "Sign in to view deployment health." });
return;
}
if (!caller.hasPermission("system:admin")) {
json(res, 403, {
error: "System administrator permission is required to view deployment health across all projects.",
});
return;
}

try {
const projects = await ctx.temps.listProjects();
const pulses = await loadProjectPulses(ctx, projects);
Expand All @@ -104,7 +137,9 @@ const plugin: TempsPlugin = {
},

onStart() {
console.error(JSON.stringify({ level: "info", message: "Deployment Pulse started" }));
console.error(
JSON.stringify({ level: "info", message: "Deployment Pulse started" }),
);
},

onEvent(_ctx: PluginContext, event: PluginEvent) {
Expand All @@ -119,8 +154,12 @@ const plugin: TempsPlugin = {
},

onShutdown() {
console.error(JSON.stringify({ level: "info", message: "Deployment Pulse stopped" }));
console.error(
JSON.stringify({ level: "info", message: "Deployment Pulse stopped" }),
);
},
};

await runPlugin(plugin);
if (import.meta.main) {
await runPlugin(plugin);
}
1 change: 1 addition & 0 deletions deployment-pulse-plugin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
Expand Down
3 changes: 3 additions & 0 deletions deployment-pulse-plugin/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export function App() {
background ? setRefreshing(true) : setLoading(true);
try {
const response = await fetch(API_URL, { headers: { Accept: "application/json" } });
if (response.status === 403) {
throw new Error("System administrator permission is required to view deployment health across all projects.");
}
if (!response.ok) throw new Error(`Temps returned ${response.status}`);
setOverview(await response.json());
setError("");
Expand Down
Loading
Loading