diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 13dc313f..cc3314bc 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -7,6 +7,11 @@ # - workflow_call from release.yml: the semver tags and `latest` are published, # and the manifest list digest is returned so the caller can attest and sign it. # +# A fourth path lives in build-demo/merge-demo: on a published, non-prerelease +# release only, the same Dockerfile is built again with DEMO_MODE=true and +# published as the single tag `:demo` (read-only public demo). It never touches +# jobs.merge.outputs, which release.yml reads to attest and sign the normal image. +# # Security model: no default token permissions, every action pinned to a full # commit SHA, checkout without persisted credentials, dynamic values passed # through env: rather than interpolated into run: blocks, and no build cache on @@ -245,6 +250,109 @@ jobs: if-no-files-found: error retention-days: 1 + # Second variant of the same image: identical layers except the Go binary, + # linked with DEMO_MODE=true so it refuses every mutating request. Kept as + # its own job instead of a matrix dimension on `build` above, because that + # job already branches on PR-vs-push and on whether to scan; a third axis + # would multiply those branches instead of adding one readable block, and + # would risk the demo variant sneaking into steps.build.outputs.digest that + # `merge` reads. A workflow_call inherits the caller's event, so the `if:` + # only matches the call from release.yml. + build-demo: + name: Build demo (${{ matrix.platform }}) + if: github.event_name == 'release' && !github.event.release.prerelease + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: amd64 + runner: ubuntu-24.04 + - platform: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Harden the runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Validate required secrets + env: + LICENSE_PUBLIC_KEY: ${{ vars.LICENSE_PUBLIC_KEY }} + run: | + if [ -z "$LICENSE_PUBLIC_KEY" ]; then + echo "::error::LICENSE_PUBLIC_KEY secret is not set. Aborting build." + exit 1 + fi + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Compute build metadata + run: | + echo "BUILD_VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Not scanned separately: same base image, same dependencies, same + # Dockerfile as the normal image, which the pull_request gate above + # already scans on every change. The only difference DEMO_MODE makes is + # one linked-in string constant in the Go binary. Releases carry no + # Trivy step for the normal image either (see the `build` job above), + # so this keeps the same scanning posture as its normal counterpart. + # No cache on this path either: it publishes, same rule as `build`. + - name: Build and push by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/${{ matrix.platform }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + provenance: false + no-cache: true + build-args: | + VERSION=${{ env.BUILD_VERSION }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ env.BUILD_DATE }} + LICENSE_PUBLIC_KEY=${{ vars.LICENSE_PUBLIC_KEY }} + DEMO_MODE=true + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/demo-digests + touch "/tmp/demo-digests/${DIGEST#sha256:}" + + # Named demo-digests-*, not digests-demo-*: the merge job above downloads + # by the glob digests-*, which is unanchored on the right and would + # therefore also match digests-demo-amd64. demo-digests-* starts with a + # different literal, so it cannot match that glob, and merge-demo's own + # glob (demo-digests-*) cannot match the normal digests-* artifacts either. + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: demo-digests-${{ matrix.platform }} + path: /tmp/demo-digests/* + if-no-files-found: error + retention-days: 1 + merge: name: Merge manifests runs-on: ubuntu-24.04 @@ -326,3 +434,56 @@ jobs: IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} VERSION: ${{ steps.meta.outputs.version }} run: docker buildx imagetools inspect "${IMAGE_REF}:${VERSION}" + + # Kept separate from `merge` on purpose: this job has no `outputs:` at all, + # so it cannot become a source for jobs.merge.outputs.* by accident (those + # feed the top-level outputs: block that release.yml reads to attest and + # sign the normal image). No `if:` needed: when build-demo is skipped, the + # default `success()` condition skips this job too. + merge-demo: + name: Merge demo manifests + runs-on: ubuntu-24.04 + timeout-minutes: 15 + needs: build-demo + permissions: + contents: read + packages: write + steps: + - name: Harden the runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: demo-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Single fixed tag: the demo image never carries a sha, a semver or + # latest, so there is no metadata-action tag list to compute here. + - name: Create manifest list and push + working-directory: /tmp/digests + env: + IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + # shellcheck disable=SC2046 + docker buildx imagetools create \ + -t "${IMAGE_REF}:demo" \ + $(printf "${IMAGE_REF}@sha256:%s " *) + + - name: Inspect image + env: + IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: docker buildx imagetools inspect "${IMAGE_REF}:demo" diff --git a/Dockerfile b/Dockerfile index 5fac8f22..15178d29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ ARG VERSION=dev ARG COMMIT=unknown ARG BUILD_DATE=unknown ARG LICENSE_PUBLIC_KEY +ARG DEMO_MODE=false RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ @@ -34,7 +35,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -X main.version=${VERSION} \ -X main.commit=${COMMIT} \ -X main.buildDate=${BUILD_DATE} \ - -X main.publicKeyB64=${LICENSE_PUBLIC_KEY}" \ + -X main.publicKeyB64=${LICENSE_PUBLIC_KEY} \ + -X main.demoMode=${DEMO_MODE}" \ -o /out/maintenant \ ./cmd/maintenant diff --git a/cmd/maintenant/main.go b/cmd/maintenant/main.go index 2d3cd4d8..fbacdc2e 100644 --- a/cmd/maintenant/main.go +++ b/cmd/maintenant/main.go @@ -31,6 +31,7 @@ var ( commit = "unknown" buildDate = "unknown" publicKeyB64 = "" + demoMode = "" ) // defaultAgentDataDir is where an agent keeps its identity and liveness file @@ -80,6 +81,7 @@ func main() { cfg.Commit = commit cfg.BuildDate = buildDate cfg.PublicKeyB64 = publicKeyB64 + cfg.DemoMode = demoMode == "true" if err := app.MergeArgsIntoConfig(&cfg, visited); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(2) @@ -96,6 +98,9 @@ func main() { logger.Warn("insecure TLS verification disabled — do not use in production") } logger.Info("maintenant starting", "version", version, "commit", commit, "build_date", buildDate, "mode", cfg.Mode) + if cfg.DemoMode { + logger.Info("demo mode enabled: instance is read-only") + } // Loaded before anything can probe: both server and agent modes run from // here, and a bad path must stop the process rather than turn every TLS @@ -108,6 +113,11 @@ func main() { logger.Info("extra CA bundle trusted", "path", cfg.CACertFile) } + if err := cfg.ValidateDemo(os.Getenv); err != nil { + logger.Error("invalid demo configuration", "error", err) + os.Exit(1) + } + // Checked before the agent branch: an agent handed a connection string // must refuse it, not ignore it (FR-003, FR-030). if err := cfg.ValidateStorage(); err != nil { diff --git a/docs/api/reference.md b/docs/api/reference.md index 6251e0ba..7f782605 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -83,6 +83,18 @@ host or any credential. | `GET` | `/api/v1/heartbeats/{id}/pings` | List raw pings | | `GET` | `/api/v1/heartbeats/{id}/uptime/daily` | Daily uptime percentages | +### Outbound Heartbeats + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/outbound-heartbeats` | List all targets | +| `POST` | `/api/v1/outbound-heartbeats` | Create a target | +| `PUT` | `/api/v1/outbound-heartbeats/{id}` | Update a target | +| `DELETE` | `/api/v1/outbound-heartbeats/{id}` | Delete a target | +| `POST` | `/api/v1/outbound-heartbeats/{id}/send` | Send a ping now | + +See [Outbound Heartbeats](../features/heartbeats.md#outbound-heartbeats). + ### Ping Endpoints (Public) These routes do not require authentication: diff --git a/docs/features/heartbeats.md b/docs/features/heartbeats.md index 0a9f752e..60f363af 100644 --- a/docs/features/heartbeats.md +++ b/docs/features/heartbeats.md @@ -144,6 +144,54 @@ POST /api/v1/heartbeats/{id}/resume --- +## Outbound Heartbeats + +A monitoring tool cannot report its own outage. Outbound heartbeats solve this by letting another maintenant instance watch this one: this instance pings a heartbeat monitor on the other instance at a fixed interval, and if the pings stop, the other instance raises a `deadline_missed` alert. + +### Setting It Up + +1. On the **other** instance, create a heartbeat monitor with an interval that matches the one you will use below, plus some grace time. Copy its ping URL. +2. On **this** instance, open **Heartbeats → Outgoing** and click **New target**. +3. Paste the ping URL, give the target a name and choose an interval. +4. Click **Send now** to check the target right away. The row shows the HTTP status it received, or the error. + +Two instances can watch each other this way, each with one incoming and one outgoing heartbeat. + +### How Sends Work + +- Each target is called with a `GET` request, with a 10-second timeout and the `User-Agent` `maintenant/ outbound-heartbeat`. +- The interval goes from 30 seconds to 24 hours. +- A 2xx response counts as a success. Any other status, or a network error, is recorded as the target's last error and logged as a warning. +- A disabled target keeps its settings but is no longer called. + +!!! note "HTTPS and public addresses only" + Targets must use `https://` and resolve to a public address. Loopback and + private ranges are refused when the target is saved and again when it is + called, so the feature cannot be used to reach services on your internal + network. The other instance therefore has to be reachable over HTTPS from + this one. + +### API + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/outbound-heartbeats` | List all targets | +| `POST` | `/api/v1/outbound-heartbeats` | Create a target | +| `PUT` | `/api/v1/outbound-heartbeats/{id}` | Update a target | +| `DELETE` | `/api/v1/outbound-heartbeats/{id}` | Delete a target | +| `POST` | `/api/v1/outbound-heartbeats/{id}/send` | Send a ping now | + +```json +{ + "name": "Watched by monitoring.example.com", + "url": "https://monitoring.example.com/ping/4f7c2d9e-1b3a-4c5d-8e6f-0a1b2c3d4e5f", + "interval_seconds": 60, + "enabled": true +} +``` + +--- + ## Public Ping Endpoints The `/ping/` routes are designed to be publicly accessible. They do not require authentication, since your cron jobs and external services need to reach them directly. diff --git a/docs/guides/swarm.md b/docs/guides/swarm.md index 909b57b7..9ece5536 100644 --- a/docs/guides/swarm.md +++ b/docs/guides/swarm.md @@ -32,7 +32,7 @@ maintenant uses the Docker SDK Swarm API, which is available in: | Docker Engine | >= 19.03 | | Docker Desktop | >= 3.0 | -Any Docker Engine version that supports Swarm mode is compatible. The Swarm API has been stable since Docker 1.12, but Docker 19.03+ is recommended for security and feature completeness. +The daemon must expose Docker API 1.40 or later: older engines are refused. --- diff --git a/docs/install.md b/docs/install.md index 8db030f8..bdfc2993 100644 --- a/docs/install.md +++ b/docs/install.md @@ -21,6 +21,7 @@ curl -fsSL https://install.maintenant.dev | sudo bash - `install` (coreutils) and `useradd` - `systemctl`, unless installing with `--no-service` - Root access (`sudo`) +- To monitor containers: Docker Engine 19.03 or later (API 1.40) --- diff --git a/frontend/src/components/DemoModeBanner.vue b/frontend/src/components/DemoModeBanner.vue new file mode 100644 index 00000000..e122135b --- /dev/null +++ b/frontend/src/components/DemoModeBanner.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/components/ToastContainer.vue b/frontend/src/components/ToastContainer.vue index b28da166..ed13a591 100644 --- a/frontend/src/components/ToastContainer.vue +++ b/frontend/src/components/ToastContainer.vue @@ -42,7 +42,10 @@ const iconMap = { :class="`toast--${toast.type}`" > - {{ toast.message }} +
+ {{ toast.title }} + {{ toast.message }} +
diff --git a/frontend/src/components/__tests__/DemoModeBanner.spec.ts b/frontend/src/components/__tests__/DemoModeBanner.spec.ts new file mode 100644 index 00000000..636122e3 --- /dev/null +++ b/frontend/src/components/__tests__/DemoModeBanner.spec.ts @@ -0,0 +1,32 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. See COMMERCIAL-LICENSE.md. + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import DemoModeBanner from '@/components/DemoModeBanner.vue' + +const isDemo = ref(false) + +vi.mock('@/composables/useEdition', () => ({ + useEdition: () => ({ isDemo }), +})) + +describe('DemoModeBanner', () => { + it('renders nothing when the instance is not in demo mode', () => { + isDemo.value = false + const wrapper = mount(DemoModeBanner) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('renders a non-dismissible banner when the instance is in demo mode', () => { + isDemo.value = true + const wrapper = mount(DemoModeBanner) + const banner = wrapper.find('[role="alert"]') + expect(banner.exists()).toBe(true) + expect(banner.text()).toContain('Demo mode: read-only.') + expect(wrapper.find('button[aria-label="Dismiss"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/composables/__tests__/useToast.spec.ts b/frontend/src/composables/__tests__/useToast.spec.ts new file mode 100644 index 00000000..617ccd8b --- /dev/null +++ b/frontend/src/composables/__tests__/useToast.spec.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. See COMMERCIAL-LICENSE.md. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { showToast, useToast } from '../useToast' + +beforeEach(() => { + useToast().toasts.value = [] + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('showToast', () => { + it('adds a toast', () => { + showToast('hello', 'info') + expect(useToast().toasts.value).toHaveLength(1) + expect(useToast().toasts.value[0]?.message).toBe('hello') + }) + + it('carries an optional title alongside the message', () => { + showToast('body', 'warning', 5000, { title: 'Demo mode' }) + expect(useToast().toasts.value[0]?.title).toBe('Demo mode') + expect(useToast().toasts.value[0]?.message).toBe('body') + }) + + it('does not stack a second toast sharing the same dedupeKey', () => { + showToast('first', 'warning', 5000, { dedupeKey: 'demo-mode' }) + showToast('second', 'warning', 5000, { dedupeKey: 'demo-mode' }) + expect(useToast().toasts.value).toHaveLength(1) + expect(useToast().toasts.value[0]?.message).toBe('first') + }) + + it('lets a different dedupeKey through', () => { + showToast('first', 'warning', 5000, { dedupeKey: 'demo-mode' }) + showToast('second', 'warning', 5000, { dedupeKey: 'storage-outage' }) + expect(useToast().toasts.value).toHaveLength(2) + }) + + it('allows a new toast once the deduped one has expired', () => { + showToast('first', 'warning', 1000, { dedupeKey: 'demo-mode' }) + vi.advanceTimersByTime(1000) + showToast('second', 'warning', 1000, { dedupeKey: 'demo-mode' }) + expect(useToast().toasts.value).toHaveLength(1) + expect(useToast().toasts.value[0]?.message).toBe('second') + }) +}) diff --git a/frontend/src/composables/useEdition.ts b/frontend/src/composables/useEdition.ts index 8a34ad2d..39bd72cb 100644 --- a/frontend/src/composables/useEdition.ts +++ b/frontend/src/composables/useEdition.ts @@ -120,6 +120,7 @@ export function useEdition() { const isPro = computed(() => editionName.value === 'pro') const isPersonal = computed(() => editionName.value === 'personal') + const isDemo = computed(() => edition.value?.demo === true) /** * Community is now a positive test, not the absence of Pro. An unknown @@ -281,6 +282,7 @@ export function useEdition() { editionRank, isPro, isPersonal, + isDemo, isPaid, isCommunity, requiredEditionFor, diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts index c2b951f6..98b0ce77 100644 --- a/frontend/src/composables/useToast.ts +++ b/frontend/src/composables/useToast.ts @@ -13,17 +13,30 @@ import { ref } from 'vue' export interface Toast { id: number + title?: string message: string type: 'info' | 'success' | 'warning' duration: number + dedupeKey?: string +} + +export interface ShowToastOptions { + title?: string + dedupeKey?: string } const toasts = ref([]) let nextId = 0 -export function showToast(message: string, type: Toast['type'] = 'info', duration = 5000) { +export function showToast( + message: string, + type: Toast['type'] = 'info', + duration = 5000, + options?: ShowToastOptions, +) { + if (options?.dedupeKey && toasts.value.some((t) => t.dedupeKey === options.dedupeKey)) return const id = nextId++ - toasts.value.push({ id, message, type, duration }) + toasts.value.push({ id, title: options?.title, message, type, duration, dedupeKey: options?.dedupeKey }) setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, duration) diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue index 283a2845..1933fab9 100644 --- a/frontend/src/layouts/DefaultLayout.vue +++ b/frontend/src/layouts/DefaultLayout.vue @@ -8,6 +8,7 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import { computed, onMounted, provide, ref, watch } from 'vue' import AppHeader from '@/components/AppHeader.vue' import EditionBadge from '@/components/EditionBadge.vue' +import DemoModeBanner from '@/components/DemoModeBanner.vue' import AlertBanner from '@/components/ui/AlertBanner.vue' import DetailSlideOver from '@/components/DetailSlideOver.vue' import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' @@ -341,6 +342,8 @@ watch(
+ + ({ - get: () => (route.query.tab === 'outgoing' ? 'outgoing' : 'incoming'), + get: () => (route.query.tab === 'outgoing' && !isDemo.value ? 'outgoing' : 'incoming'), set: (tab) => router.replace({ query: { ...route.query, tab: tab === 'outgoing' ? 'outgoing' : undefined } }), }) @@ -54,7 +55,6 @@ const tabOptions = [ const store = useHeartbeatsStore() const prefs = usePreferencesStore() const { openDetail } = inject(detailSlideOverKey)! -const { getQuota, reload } = useEdition() const quota = getQuota('heartbeats') const showCreateForm = ref(false) @@ -262,7 +262,7 @@ async function handleCreate() { -
+
diff --git a/frontend/src/pages/__tests__/HeartbeatsPage.spec.ts b/frontend/src/pages/__tests__/HeartbeatsPage.spec.ts new file mode 100644 index 00000000..82eb6a09 --- /dev/null +++ b/frontend/src/pages/__tests__/HeartbeatsPage.spec.ts @@ -0,0 +1,67 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. See COMMERCIAL-LICENSE.md. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { shallowMount } from '@vue/test-utils' +import { ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import HeartbeatsPage from '@/pages/HeartbeatsPage.vue' +import { detailSlideOverKey } from '@/composables/useDetailSlideOver' + +const isDemo = ref(false) +const route = { query: { tab: 'outgoing' } as Record } + +vi.mock('@/composables/useEdition', () => ({ + useEdition: () => ({ + isDemo, + reload: vi.fn(), + getQuota: () => ref({ used: 0, limit: 0, isUnlimited: true, isAtLimit: false, nearLimit: false }), + }), +})) + +vi.mock('vue-router', () => ({ + useRoute: () => route, + useRouter: () => ({ replace: vi.fn() }), +})) + +vi.mock('@/stores/heartbeats', () => ({ + useHeartbeatsStore: () => ({ + heartbeats: [], + loading: false, + error: null, + fetchHeartbeats: vi.fn(), + connectSSE: vi.fn(), + disconnectSSE: vi.fn(), + }), +})) + +function mountPage() { + return shallowMount(HeartbeatsPage, { + global: { + provide: { [detailSlideOverKey as symbol]: { openDetail: vi.fn() } }, + stubs: { 'router-link': true }, + }, + }) +} + +describe('HeartbeatsPage outgoing tab', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('offers the outgoing tab outside demo mode', () => { + isDemo.value = false + const wrapper = mountPage() + expect(wrapper.findComponent({ name: 'SegmentedToggle' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'OutboundHeartbeatsPanel' }).exists()).toBe(true) + }) + + it('hides the outgoing tab in demo mode, even when the URL asks for it', () => { + isDemo.value = true + const wrapper = mountPage() + expect(wrapper.findComponent({ name: 'SegmentedToggle' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'OutboundHeartbeatsPanel' }).exists()).toBe(false) + }) +}) diff --git a/frontend/src/services/__tests__/apiFetch.spec.ts b/frontend/src/services/__tests__/apiFetch.spec.ts new file mode 100644 index 00000000..934abc4b --- /dev/null +++ b/frontend/src/services/__tests__/apiFetch.spec.ts @@ -0,0 +1,74 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// +// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) +// or a commercial license. See COMMERCIAL-LICENSE.md. + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const showToast = vi.hoisted(() => vi.fn()) +vi.mock('@/composables/useToast', () => ({ showToast })) + +import { apiFetch, ApiError } from '../apiFetch' + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +beforeEach(() => { + showToast.mockReset() + vi.stubGlobal('fetch', vi.fn()) +}) + +describe('ApiError.isDemoRefusal', () => { + it('is true for a DEMO_MODE refusal', () => { + expect(new ApiError(403, { code: 'DEMO_MODE', message: 'x' }, 'x').isDemoRefusal).toBe(true) + }) + + it('is false for any other refusal', () => { + expect( + new ApiError(403, { code: 'EDITION_REQUIRED', message: 'x' }, 'x').isDemoRefusal, + ).toBe(false) + expect(new ApiError(403, null, 'x').isDemoRefusal).toBe(false) + }) +}) + +describe('a DEMO_MODE refusal from the API', () => { + const demoBody = { + error: { + code: 'DEMO_MODE', + message: 'Demo mode: this instance is read-only, changes are disabled.', + }, + } + + it('still throws, so the caller can revert its optimistic update', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(403, demoBody)) + await expect( + apiFetch('/api/v1/containers/x/restart', { method: 'POST' }), + ).rejects.toBeInstanceOf(ApiError) + }) + + it('raises exactly one global toast', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(403, demoBody)) + await expect( + apiFetch('/api/v1/containers/x/restart', { method: 'POST' }), + ).rejects.toThrow() + expect(showToast).toHaveBeenCalledTimes(1) + expect(showToast).toHaveBeenCalledWith( + 'This instance is read-only. Changes are disabled.', + 'warning', + 5000, + { title: 'Demo mode', dedupeKey: 'demo-mode' }, + ) + }) + + it('does not toast on an unrelated 403', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(403, { error: { code: 'EDITION_REQUIRED', message: 'x' } }), + ) + await expect(apiFetch('/api/v1/x')).rejects.toBeInstanceOf(ApiError) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/services/apiFetch.ts b/frontend/src/services/apiFetch.ts index b13b2827..86e09395 100644 --- a/frontend/src/services/apiFetch.ts +++ b/frontend/src/services/apiFetch.ts @@ -15,6 +15,7 @@ import { AuthChallengeError, isAuthChallenge, probeAuth, reportAuthChallenge } from './authGuard' import type { ApiErrorDetail } from './editionApi' +import { showToast } from '@/composables/useToast' /** * An API error that keeps the structured body instead of flattening it to a @@ -42,6 +43,11 @@ export class ApiError extends Error { return this.code === 'EDITION_REQUIRED' } + /** True for the refusal a demo instance sends back to every mutation. */ + get isDemoRefusal(): boolean { + return this.code === 'DEMO_MODE' + } + /** True for the refusals that mean "you have reached a cap". */ get isQuotaRefusal(): boolean { return this.code === 'QUOTA_EXCEEDED' || this.code === 'HOST_LIMIT_REACHED' @@ -58,10 +64,17 @@ export class ApiError extends Error { } } -async function toApiError(res: Response): Promise { +export async function toApiError(res: Response): Promise { const body = await res.json().catch(() => ({})) const detail = (body?.error ?? null) as ApiErrorDetail | null - return new ApiError(res.status, detail, detail?.message || `HTTP ${res.status}`) + const err = new ApiError(res.status, detail, detail?.message || `HTTP ${res.status}`) + if (err.isDemoRefusal) { + showToast('This instance is read-only. Changes are disabled.', 'warning', 5000, { + title: 'Demo mode', + dedupeKey: 'demo-mode', + }) + } + return err } // Sentinel agent id the backend sends for server-local entities. Treat it as diff --git a/frontend/src/services/containerApi.ts b/frontend/src/services/containerApi.ts index e867d7b2..f7135e50 100644 --- a/frontend/src/services/containerApi.ts +++ b/frontend/src/services/containerApi.ts @@ -110,7 +110,7 @@ export interface ListTransitionsParams { offset?: number } -import { apiFetch, guardedFetch } from './apiFetch' +import { apiFetch, apiFetchVoid } from './apiFetch' function fetchJSON(url: string): Promise { return apiFetch(url) @@ -129,8 +129,8 @@ export function getContainer(id: string): Promise { return fetchJSON(`${API_BASE}/containers/${id}`) } -export async function deleteContainer(id: string): Promise { - await guardedFetch(`${API_BASE}/containers/${id}`, { method: 'DELETE' }) +export function deleteContainer(id: string): Promise { + return apiFetchVoid(`${API_BASE}/containers/${id}`, { method: 'DELETE' }) } export function listTransitions( diff --git a/frontend/src/services/editionApi.ts b/frontend/src/services/editionApi.ts index 875aeb45..ce104fd2 100644 --- a/frontend/src/services/editionApi.ts +++ b/frontend/src/services/editionApi.ts @@ -63,6 +63,7 @@ export interface EditionResponse { quotas?: Partial> /** Absent on an engine older than the tiered history: no catalogue, no cap. */ resource_history?: ResourceHistoryContract + demo?: boolean } /** diff --git a/frontend/src/services/personalizationApi.ts b/frontend/src/services/personalizationApi.ts index 77790e0c..e41a4837 100644 --- a/frontend/src/services/personalizationApi.ts +++ b/frontend/src/services/personalizationApi.ts @@ -1,4 +1,4 @@ -import { guardedFetch } from './apiFetch' +import { guardedFetch, toApiError } from './apiFetch' const BASE = '/api/v1/status-page' @@ -79,10 +79,7 @@ async function request(method: string, url: string, body?: unknown): Promise< headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'unknown_error' })) - throw new Error(err?.error?.message || err?.message || `HTTP ${res.status}`) - } + if (!res.ok) throw await toApiError(res) if (res.status === 204) return undefined as T return res.json() } @@ -98,10 +95,7 @@ export const personalizationApi = { form.append('file', file) if (altText !== undefined) form.append('alt_text', altText) const res = await guardedFetch(`${BASE}/assets/${role}`, { method: 'PUT', body: form }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err?.error?.message || `HTTP ${res.status}`) - } + if (!res.ok) throw await toApiError(res) return res.json() }, diff --git a/internal/agentserver/integration_test.go b/internal/agentserver/integration_test.go index ab701059..5594a5d1 100644 --- a/internal/agentserver/integration_test.go +++ b/internal/agentserver/integration_test.go @@ -620,3 +620,30 @@ func TestIntegration_LogsCommandRoundTrip(t *testing.T) { _, err = sessions.FetchLogs(ctx, "11111111-0000-0000-0000-000000000000", "ctr", 100, false) assert.ErrorIs(t, err, agentserver.ErrAgentNotConnected) } + +func TestIntegration_DemoMode_RefusesEveryRPC(t *testing.T) { + withMultiHostEdition(t) + + addr, clientTLS := startTestServer(t, agentserver.Deps{ + AgentStore: store.NewAgentStore(openIntegrationDB(t)), + Broadcaster: noopBroadcaster{}, + Sessions: agentserver.NewSessions(slog.Default(), noopBroadcaster{}), + Logger: slog.Default(), + DemoMode: true, + }) + client := dialGRPC(t, addr, clientTLS) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.RegisterAgent(ctx, &agentpb.RegisterRequest{}) + require.Error(t, err) + assert.Equal(t, codes.PermissionDenied, grpcstatus.Code(err)) + assert.Contains(t, grpcstatus.Convert(err).Message(), "demo mode") + + stream, err := client.Push(ctx) + require.NoError(t, err) + _, err = stream.Recv() + require.Error(t, err) + assert.Equal(t, codes.PermissionDenied, grpcstatus.Code(err)) +} diff --git a/internal/agentserver/server.go b/internal/agentserver/server.go index 1cfa0030..cdd0ca6d 100644 --- a/internal/agentserver/server.go +++ b/internal/agentserver/server.go @@ -44,6 +44,7 @@ type Deps struct { Limiter *Limiter Dispatcher *Dispatcher Logger *slog.Logger + DemoMode bool } // Server wraps the gRPC server lifecycle for the Ingest service. @@ -80,6 +81,12 @@ func (s *Server) Start(ctx context.Context, listen string, tlsCfg *tls.Config) e grpc.ChainUnaryInterceptor(recoveryUnaryInterceptor(s.deps.Logger)), grpc.ChainStreamInterceptor(recoveryStreamInterceptor(s.deps.Logger)), } + if s.deps.DemoMode { + opts = append(opts, + grpc.ChainUnaryInterceptor(demoModeUnaryInterceptor), + grpc.ChainStreamInterceptor(demoModeStreamInterceptor), + ) + } if tlsCfg != nil { opts = append(opts, grpc.Creds(credentials.NewTLS(tlsCfg))) } @@ -114,6 +121,16 @@ func (s *Server) Stop() { } } +var errDemoMode = grpcstatus.Error(codes.PermissionDenied, "demo mode: this server does not accept agents") + +func demoModeUnaryInterceptor(context.Context, any, *grpc.UnaryServerInfo, grpc.UnaryHandler) (any, error) { + return nil, errDemoMode +} + +func demoModeStreamInterceptor(any, grpc.ServerStream, *grpc.StreamServerInfo, grpc.StreamHandler) error { + return errDemoMode +} + // recoveryUnaryInterceptor recovers any panic raised by a unary handler, // logs it with a stack trace, and returns a generic Internal error. func recoveryUnaryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor { diff --git a/internal/api/v1/edition_conformance_test.go b/internal/api/v1/edition_conformance_test.go index 18887127..c5819059 100644 --- a/internal/api/v1/edition_conformance_test.go +++ b/internal/api/v1/edition_conformance_test.go @@ -2,6 +2,8 @@ package v1 import ( "encoding/json" + "io" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/kolapsis/maintenant/internal/extension" + "github.com/kolapsis/maintenant/internal/outbound" ) // This file is SC-003: for the 3 editions × 21 capabilities, the answer must be @@ -185,6 +188,54 @@ func TestConformance_HistoryWindowsMatchWhatTheEndpointsAccept(t *testing.T) { // TestConformance_QuotasReportRealUsage: `used` used to be pinned to 0 on Pro, // so an instance running fifty endpoints reported none. Every edition now // counts, and the limit always comes from extension.Limit. +func TestNewRouter_WiresDemoModeIntoTheEditionEndpoint(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + r := NewRouter(HandlerDeps{Logger: logger, DemoMode: true}) + + rec := httptest.NewRecorder() + r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/edition", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, true, body["demo"]) +} + +func TestNewRouter_DemoModeDropsOutboundHeartbeats(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := outbound.NewService(outbound.Deps{Store: &memOutboundStore{items: map[string]outbound.OutboundHeartbeat{}}}) + + for _, demo := range []bool{true, false} { + t.Run(map[bool]string{true: "demo_on", false: "demo_off"}[demo], func(t *testing.T) { + r := NewRouter(HandlerDeps{Logger: logger, DemoMode: demo, Outbound: svc}) + rec := httptest.NewRecorder() + r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/outbound-heartbeats", nil)) + if demo { + assert.Equal(t, http.StatusNotFound, rec.Code) + } else { + assert.Equal(t, http.StatusOK, rec.Code) + } + }) + } +} + +func TestEdition_ExposesDemoMode(t *testing.T) { + withEdition(t, extension.Community) + + for _, demo := range []bool{true, false} { + t.Run(map[bool]string{true: "demo_on", false: "demo_off"}[demo], func(t *testing.T) { + r := &Router{demoMode: demo} + rec := httptest.NewRecorder() + r.handleGetEdition(true, HandlerDeps{})(rec, httptest.NewRequest("GET", "/api/v1/edition", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, demo, body["demo"]) + }) + } +} + func TestConformance_QuotasReportRealUsage(t *testing.T) { for _, edition := range []extension.Edition{extension.Community, extension.Personal, extension.Pro} { t.Run(string(edition), func(t *testing.T) { diff --git a/internal/api/v1/middleware.go b/internal/api/v1/middleware.go index c3877297..cb63b2bc 100644 --- a/internal/api/v1/middleware.go +++ b/internal/api/v1/middleware.go @@ -2,6 +2,7 @@ package v1 import ( "context" + "crypto/subtle" "fmt" "log/slog" "net/http" @@ -153,6 +154,58 @@ func bodyLimit(maxBytes int64, next http.Handler) http.Handler { }) } +const ( + demoModeMessage = "Demo mode: this instance is read-only, changes are disabled." + demoModeClosedMessage = "Demo mode: this endpoint is disabled." +) + +func demoModeClosed(path string) bool { + switch { + case strings.HasPrefix(path, "/ping/"): + return true + case path == "/mcp" || strings.HasPrefix(path, "/mcp/"): + return true + case strings.HasPrefix(path, "/oauth/"): + return true + case strings.HasPrefix(path, "/.well-known/oauth-"): + return true + default: + return false + } +} + +// DemoTokenHeader carries the token that lets the demo driver write to a demo instance. +const DemoTokenHeader = "X-Maintenant-Demo-Token" // #nosec G101 -- a header name, not a credential. + +// DemoModeGuard closes the ingestion and MCP surfaces and refuses every mutating request with a 403 DEMO_MODE when demoMode is on, except for requests carrying token. +func DemoModeGuard(demoMode bool, token string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if !demoMode { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get(DemoTokenHeader)), []byte(token)) == 1 { + next.ServeHTTP(w, r) + return + } + if demoModeClosed(r.URL.Path) { + WriteError(w, http.StatusForbidden, "DEMO_MODE", demoModeClosedMessage) + return + } + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + if r.URL.Path == "/api/v1/escalation-policies/overlap-probe" { + next.ServeHTTP(w, r) + return + } + WriteError(w, http.StatusForbidden, "DEMO_MODE", demoModeMessage) + }) + } +} + // requireCapability wraps a handler to reject requests the running edition does // not open. The refusal names the capability and the edition that would grant // it, so the interface can build its message and its upgrade link without diff --git a/internal/api/v1/middleware_test.go b/internal/api/v1/middleware_test.go index 7ad033a1..60d59cc9 100644 --- a/internal/api/v1/middleware_test.go +++ b/internal/api/v1/middleware_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -393,3 +394,110 @@ func TestMiddlewareChain_Integration(t *testing.T) { assert.Equal(t, capturedRequestID, rec.Header().Get("X-Request-ID")) assert.Equal(t, "https://test.com", rec.Header().Get("Access-Control-Allow-Origin")) } + +// --------------------------------------------------------------------------- +// DemoModeGuard middleware +// --------------------------------------------------------------------------- + +func TestDemoModeGuard_Matrix(t *testing.T) { + tests := []struct { + name string + demoMode bool + method string + path string + wantPassed bool + wantMsg string + }{ + {"get passes in demo", true, http.MethodGet, "/api/v1/containers", true, ""}, + {"head passes in demo", true, http.MethodHead, "/api/v1/containers", true, ""}, + {"options passes in demo", true, http.MethodOptions, "/api/v1/containers", true, ""}, + {"post refused in demo", true, http.MethodPost, "/api/v1/containers", false, demoModeMessage}, + {"put refused in demo", true, http.MethodPut, "/api/v1/endpoints/1", false, demoModeMessage}, + {"patch refused in demo", true, http.MethodPatch, "/api/v1/agents/1", false, demoModeMessage}, + {"delete refused in demo", true, http.MethodDelete, "/api/v1/endpoints/1", false, demoModeMessage}, + {"ping post closed in demo", true, http.MethodPost, "/ping/x", false, demoModeClosedMessage}, + {"ping get closed in demo", true, http.MethodGet, "/ping/x", false, demoModeClosedMessage}, + {"mcp post closed in demo", true, http.MethodPost, "/mcp", false, demoModeClosedMessage}, + {"mcp get closed in demo", true, http.MethodGet, "/mcp/x", false, demoModeClosedMessage}, + {"oauth post closed in demo", true, http.MethodPost, "/oauth/token", false, demoModeClosedMessage}, + {"oauth get closed in demo", true, http.MethodGet, "/oauth/authorize", false, demoModeClosedMessage}, + {"oauth metadata closed in demo", true, http.MethodGet, "/.well-known/oauth-authorization-server", false, demoModeClosedMessage}, + {"overlap probe post passes in demo", true, http.MethodPost, "/api/v1/escalation-policies/overlap-probe", true, ""}, + {"post passes when demo off", false, http.MethodPost, "/api/v1/containers", true, ""}, + {"ping passes when demo off", false, http.MethodPost, "/ping/x", true, ""}, + {"mcp passes when demo off", false, http.MethodPost, "/mcp", true, ""}, + {"delete passes when demo off", false, http.MethodDelete, "/api/v1/endpoints/1", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + handler := DemoModeGuard(tt.demoMode, "")(inner) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + + if tt.wantPassed { + assert.True(t, called, "the handler must run") + assert.Equal(t, http.StatusOK, rec.Code) + return + } + + require.False(t, called, "the handler must not run") + assert.Equal(t, http.StatusForbidden, rec.Code) + + var body ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "DEMO_MODE", body.Error.Code) + assert.Equal(t, tt.wantMsg, body.Error.Message) + }) + } +} + +func TestDemoModeGuard_Disabled_ReturnsNextUnchanged(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := DemoModeGuard(false, "")(inner) + + assert.Equal(t, reflect.ValueOf(inner).Pointer(), reflect.ValueOf(handler).Pointer(), + "a disabled guard must not wrap the handler") +} + +func TestDemoModeGuard_Token(t *testing.T) { + tests := []struct { + name string + configured string + sent string + path string + wantPassed bool + }{ + {"matching token writes", "s3cret", "s3cret", "/api/v1/heartbeats", true}, + {"matching token pings", "s3cret", "s3cret", "/ping/x", true}, + {"wrong token refused", "s3cret", "nope", "/api/v1/heartbeats", false}, + {"missing token refused", "s3cret", "", "/ping/x", false}, + {"empty configured token never matches", "", "", "/api/v1/heartbeats", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true }) + req := httptest.NewRequest(http.MethodPost, tt.path, nil) + if tt.sent != "" { + req.Header.Set(DemoTokenHeader, tt.sent) + } + rec := httptest.NewRecorder() + DemoModeGuard(true, tt.configured)(inner).ServeHTTP(rec, req) + assert.Equal(t, tt.wantPassed, called) + if !tt.wantPassed { + assert.Equal(t, http.StatusForbidden, rec.Code) + } + }) + } +} diff --git a/internal/api/v1/router.go b/internal/api/v1/router.go index bb088219..988b5151 100644 --- a/internal/api/v1/router.go +++ b/internal/api/v1/router.go @@ -156,6 +156,7 @@ type HandlerDeps struct { AllowPrivateWebhooks bool // dev only: skip HTTPS + SSRF check on webhook URLs StatusURL string TrustedProxies []netip.Prefix + DemoMode bool } // agentStoreDirectory adapts the sqlite agent store to AgentDirectory so the @@ -189,6 +190,7 @@ type Router struct { buildVersion string organisationName string statusURL string + demoMode bool } // NewRouter creates a new API v1 router from the unified HandlerDeps. @@ -209,6 +211,7 @@ func NewRouter(d HandlerDeps) *Router { buildVersion: d.BuildVersion, organisationName: d.OrganisationName, statusURL: d.StatusURL, + demoMode: d.DemoMode, } // Webhook management @@ -285,7 +288,7 @@ func NewRouter(d HandlerDeps) *Router { r.mux.HandleFunc("POST /ping/{uuid}", ph.HandlePing) } - if d.Outbound != nil { + if d.Outbound != nil && !d.DemoMode { oh := NewOutboundHeartbeatHandler(d.Outbound) r.mux.HandleFunc("GET /api/v1/outbound-heartbeats", oh.HandleList) r.mux.HandleFunc("POST /api/v1/outbound-heartbeats", oh.HandleCreate) @@ -857,6 +860,7 @@ func (r *Router) handleGetEdition(smtpConfigured bool, d HandlerDeps) http.Handl "edition": string(extension.CurrentEdition()), "organisation_name": r.organisationName, "status_url": r.statusURL, + "demo": r.demoMode, "features": features, "feature_editions": featureEditions, "quotas": r.computeQuotas(ctx, d), diff --git a/internal/app/app.go b/internal/app/app.go index 6500aa89..fe3bf8c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -458,6 +458,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { Sessions: a.agentSessions, Broadcaster: &sseBroadcaster{broker: a.broker}, Limiter: agentserver.NewLimiter(cfg.MultiHost.AgentRateLimitPerSecond), + DemoMode: cfg.DemoMode, Dispatcher: agentserver.NewDispatcher(agentserver.DispatchDeps{ Container: a.containerSvc, Inventory: a.containerSvc, @@ -680,6 +681,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { OrganisationName: cfg.OrgName, AllowPrivateWebhooks: cfg.AllowPrivateWebhooks, TrustedProxies: trustedProxies, + DemoMode: cfg.DemoMode, }) // --- MCP Server --- @@ -718,8 +720,9 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { Broadcast: func(eventType string, data any) { a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) }, - Version: cfg.Version, - Logger: logger.With("component", "mcp"), + Version: cfg.Version, + Logger: logger.With("component", "mcp"), + DemoMode: cfg.DemoMode, } a.mcpServer = mcp.NewServer(mcpSvc) @@ -838,7 +841,9 @@ func (a *App) Start(ctx context.Context) error { a.notifier.Start(ctx) a.endpointSvc.Start(ctx) a.heartbeatSvc.StartDeadlineChecker(ctx) - a.outboundSvc.Start(ctx) + if !a.cfg.DemoMode { + a.outboundSvc.Start(ctx) + } // Telemetry: best-effort. Self-exits on ctx cancellation; panics are // contained inside the package (FR-009/FR-011/FR-012). @@ -942,7 +947,7 @@ func (a *App) Start(ctx context.Context) error { // Embedded agent (mode=server + --embedded-agent + Pro). // Starts a local agent goroutine that connects to the local gRPC endpoint. - if a.cfg.Mode == "server" && a.cfg.MultiHost.EmbeddedAgent && a.multihostPlanAllowed() { + if a.cfg.Mode == "server" && a.cfg.MultiHost.EmbeddedAgent && a.multihostPlanAllowed() && !a.cfg.DemoMode { a.startEmbeddedAgent(ctx) } diff --git a/internal/app/config.go b/internal/app/config.go index 795b2b56..3f16ee27 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -102,6 +102,8 @@ type Config struct { Commit string BuildDate string PublicKeyB64 string + DemoMode bool + DemoToken string } // MultiHostConfig holds multi-server agent configuration (Pro only). @@ -292,6 +294,21 @@ func (c Config) ValidateHTTP() error { return nil } +// ErrDemoRuntime is returned when a demo build is pointed at anything but a remote Docker endpoint. +var ErrDemoRuntime = errors.New("demo mode only monitors a remote Docker endpoint: set DOCKER_HOST=tcp://host:port, and leave KUBERNETES_SERVICE_HOST and KUBECONFIG unset") + +// ValidateDemo refuses a demo build that could reach a local Docker socket or a Kubernetes cluster. +func (c Config) ValidateDemo(getenv func(string) string) error { + if !c.DemoMode { + return nil + } + if !strings.HasPrefix(getenv("DOCKER_HOST"), "tcp://") || + getenv("KUBERNETES_SERVICE_HOST") != "" || getenv("KUBECONFIG") != "" { + return ErrDemoRuntime + } + return nil +} + // DefaultAddr is the HTTP listen address used when MAINTENANT_ADDR is unset. const DefaultAddr = "127.0.0.1:8080" @@ -328,6 +345,7 @@ func ConfigFromEnv() Config { CACertFile: os.Getenv("MAINTENANT_CA_CERT"), OrgName: envOr("MAINTENANT_ORGANISATION_NAME", "Maintenant"), + DemoToken: os.Getenv("MAINTENANT_DEMO_TOKEN"), StatusURL: os.Getenv("MAINTENANT_STATUS_URL"), K8sNamespaces: os.Getenv("MAINTENANT_K8S_NAMESPACES"), diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 68cc6362..82e45a92 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -236,3 +236,32 @@ func TestAgentSpoolFlagRefusesNegative(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, ErrAgentSpoolSetting) } + +func TestValidateDemo(t *testing.T) { + env := func(kv map[string]string) func(string) string { + return func(k string) string { return kv[k] } + } + tests := []struct { + name string + demo bool + env map[string]string + wantErr bool + }{ + {"normal build ignores the runtime", false, map[string]string{}, false}, + {"remote docker accepted", true, map[string]string{"DOCKER_HOST": "tcp://dind:2375"}, false}, + {"default local socket refused", true, map[string]string{}, true}, + {"unix socket refused", true, map[string]string{"DOCKER_HOST": "unix:///var/run/docker.sock"}, true}, + {"in-cluster kubernetes refused", true, map[string]string{"DOCKER_HOST": "tcp://dind:2375", "KUBERNETES_SERVICE_HOST": "10.0.0.1"}, true}, + {"kubeconfig refused", true, map[string]string{"DOCKER_HOST": "tcp://dind:2375", "KUBECONFIG": "/root/.kube/config"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Config{DemoMode: tt.demo}.ValidateDemo(env(tt.env)) + if tt.wantErr { + require.ErrorIs(t, err, ErrDemoRuntime) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/app/http.go b/internal/app/http.go index 968236d3..10bd0aa6 100644 --- a/internal/app/http.go +++ b/internal/app/http.go @@ -24,6 +24,7 @@ import ( "time" "github.com/kolapsis/maintenant/cmd/maintenant/web" + v1 "github.com/kolapsis/maintenant/internal/api/v1" mcpoauth "github.com/kolapsis/maintenant/internal/mcp/oauth" "github.com/kolapsis/maintenant/internal/store" mcpauth "github.com/modelcontextprotocol/go-sdk/auth" @@ -198,7 +199,7 @@ func SecurityHeaders(csp string) func(http.Handler) http.Handler { func (a *App) buildHTTPServer() *http.Server { topMux := http.NewServeMux() - if a.cfg.MCP.Enabled { + if a.cfg.MCP.Enabled && !a.cfg.DemoMode { mcpHTTPHandler := gomcp.NewStreamableHTTPHandler(func(_ *http.Request) *gomcp.Server { return a.mcpServer }, nil) @@ -262,7 +263,7 @@ func (a *App) buildHTTPServer() *http.Server { topMux.Handle("/", SPAHandler(a.router.Handler(), a.logger)) handler := SecurityHeaders(contentSecurityPolicy(indexHTML))( - WithRequestTimeout(topMux, 10*time.Second)) + WithRequestTimeout(v1.DemoModeGuard(a.cfg.DemoMode, a.cfg.DemoToken)(topMux), 10*time.Second)) return &http.Server{ Addr: a.cfg.Addr, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 3986a7d7..f2002190 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -119,8 +119,9 @@ type Services struct { // on the page sees an MCP write without reloading. Broadcast func(eventType string, data any) - Version string - Logger *slog.Logger + Version string + Logger *slog.Logger + DemoMode bool } func (s *Services) logger() *slog.Logger { @@ -130,6 +131,13 @@ func (s *Services) logger() *slog.Logger { return slog.Default() } +func addTool[In, Out any](server *gomcp.Server, svc *Services, t *gomcp.Tool, h gomcp.ToolHandlerFor[In, Out]) { + if svc.DemoMode && (t.Annotations == nil || !t.Annotations.ReadOnlyHint) { + return + } + gomcp.AddTool(server, t, h) +} + // NewServer creates and configures an MCP server with all maintenant tools registered. func NewServer(svc *Services) *gomcp.Server { server := gomcp.NewServer(&gomcp.Implementation{ diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 6d509310..8a7a5341 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -174,21 +174,27 @@ func TestNewServer_ReadToolsAreReadOnly(t *testing.T) { require.NoError(t, err) readOnlyTools := map[string]bool{ - "list_containers": true, - "get_container": true, - "get_container_logs": true, - "list_alerts": true, - "get_resources": true, - "get_top_consumers": true, - "list_endpoints": true, - "get_endpoint_history": true, - "list_heartbeats": true, - "list_certificates": true, - "get_updates": true, - "get_health": true, - "list_channels": true, - "get_channel": true, - "get_edition": true, + "list_containers": true, + "get_container": true, + "get_container_logs": true, + "list_alerts": true, + "get_resources": true, + "get_top_consumers": true, + "list_endpoints": true, + "get_endpoint_history": true, + "list_heartbeats": true, + "list_certificates": true, + "get_updates": true, + "get_health": true, + "list_channels": true, + "get_channel": true, + "get_edition": true, + "list_escalation_policies": true, + "get_escalation_policy": true, + "list_alert_escalation_runs": true, + "get_escalation_run": true, + "list_triggers": true, + "get_trigger": true, } for _, tool := range result.Tools { @@ -198,3 +204,75 @@ func TestNewServer_ReadToolsAreReadOnly(t *testing.T) { } } } + +func newTestServerWithDemoMode(t *testing.T, demoMode bool) *gomcp.Server { + t.Helper() + + svc := &Services{ + Version: "1.0.0-test", + Logger: slog.Default(), + DemoMode: demoMode, + } + + var server *gomcp.Server + panicked := true + func() { + defer func() { + if r := recover(); r != nil { + t.Skipf("NewServer panics due to go-sdk v1.4.0 jsonschema tag parsing: %v", r) + } + }() + server = NewServer(svc) + panicked = false + }() + if panicked { + t.SkipNow() + } + return server +} + +func listToolNames(t *testing.T, server *gomcp.Server) map[string]*gomcp.Tool { + t.Helper() + + ct, st := gomcp.NewInMemoryTransports() + ctx := context.Background() + + ss, err := server.Connect(ctx, st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + client := gomcp.NewClient(&gomcp.Implementation{ + Name: "test-client", + Version: "0.0.1", + }, nil) + cs, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + result, err := cs.ListTools(ctx, nil) + require.NoError(t, err) + + tools := make(map[string]*gomcp.Tool, len(result.Tools)) + for _, tool := range result.Tools { + tools[tool.Name] = tool + } + return tools +} + +func TestNewServer_DemoMode_OnlyListsReadOnlyTools(t *testing.T) { + server := newTestServerWithDemoMode(t, true) + require.NotNil(t, server) + + for name, tool := range listToolNames(t, server) { + require.NotNil(t, tool.Annotations, "demo mode listed %q without annotations", name) + assert.True(t, tool.Annotations.ReadOnlyHint, "demo mode listed a non-read-only tool %q", name) + } + assert.Len(t, listToolNames(t, server), 34, "demo mode must list exactly the read-only tools") +} + +func TestNewServer_NormalMode_ListsEveryTool(t *testing.T) { + server := newTestServerWithDemoMode(t, false) + require.NotNil(t, server) + + assert.Len(t, listToolNames(t, server), 51, "normal mode must list every registered tool") +} diff --git a/internal/mcp/tools_channels.go b/internal/mcp/tools_channels.go index eebaa30e..2544f0ba 100644 --- a/internal/mcp/tools_channels.go +++ b/internal/mcp/tools_channels.go @@ -16,34 +16,34 @@ import ( ) func registerChannelTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_channels", Description: "List notification channels. Channels are silent on their own: an alert reaches one only through an alert trigger or an escalation level. Secrets are never returned.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listChannelsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_channel", Description: "Get a single notification channel by ID, with its delivery health and the triggers routing to it.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getChannelHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "create_channel", Description: "Create a notification channel. The webhook type is open in every edition; " + gatedChannelTypes() + ".", }, createChannelHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "update_channel", Description: "Update a notification channel. Omitted fields keep their stored value; a stored secret is never cleared this way.", }, updateChannelHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "delete_channel", Description: "Delete a notification channel. Triggers and escalation levels pointing at it lose that destination.", }, deleteChannelHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "test_channel", Description: "Send a test notification through a channel and report whether it was delivered.", }, testChannelHandler(svc)) diff --git a/internal/mcp/tools_edition.go b/internal/mcp/tools_edition.go index 7076cfa7..55ac1649 100644 --- a/internal/mcp/tools_edition.go +++ b/internal/mcp/tools_edition.go @@ -9,7 +9,7 @@ import ( ) func registerEditionTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_edition", Description: "Report the running edition, which capability each edition opens, the quota usage of capped resources, and the resource-history windows. Call this before offering a feature that may be gated, instead of asking the operator which edition they run.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, diff --git a/internal/mcp/tools_escalation.go b/internal/mcp/tools_escalation.go index deb7e95e..e9a126a5 100644 --- a/internal/mcp/tools_escalation.go +++ b/internal/mcp/tools_escalation.go @@ -34,42 +34,46 @@ import ( ) func registerEscalationTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_escalation_policies", Description: "List all escalation policies with their filters and levels." + requires(extension.CapAlertEscalation), + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listEscalationPoliciesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_escalation_policy", Description: "Get a single escalation policy by ID." + requires(extension.CapAlertEscalation), + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getEscalationPolicyHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "create_escalation_policy", Description: "Create a new escalation policy." + requires(extension.CapAlertEscalation), }, createEscalationPolicyHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "delete_escalation_policy", Description: "Delete an escalation policy and stop any active runs." + requires(extension.CapAlertEscalation), }, deleteEscalationPolicyHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_alert_escalation_runs", Description: "List escalation runs attached to a specific alert." + requires(extension.CapAlertEscalation), + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listAlertEscalationRunsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_escalation_run", Description: "Get full detail of an escalation run including all deliveries." + requires(extension.CapAlertEscalation), + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getEscalationRunHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "update_escalation_policy", Description: "Update an existing escalation policy (name, filters, levels, active)." + requires(extension.CapAlertEscalation), }, updateEscalationPolicyHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "set_escalation_policy_active", Description: "Activate or deactivate an escalation policy." + requires(extension.CapAlertEscalation), }, setEscalationPolicyActiveHandler(svc)) diff --git a/internal/mcp/tools_kubernetes.go b/internal/mcp/tools_kubernetes.go index c84b2f9b..cb69f71a 100644 --- a/internal/mcp/tools_kubernetes.go +++ b/internal/mcp/tools_kubernetes.go @@ -22,25 +22,25 @@ import ( ) func registerKubernetesTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_kubernetes_namespaces", Description: "List Kubernetes namespaces known to maintenant across monitored clusters.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listKubernetesNamespacesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_kubernetes_workloads", Description: "List Kubernetes workloads (Deployments, StatefulSets, DaemonSets...) grouped by namespace, with ready/desired replica counts and status.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listKubernetesWorkloadsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_kubernetes_pods", Description: "List Kubernetes pods with their status, restart count and node, optionally filtered by namespace, workload, node or status.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listKubernetesPodsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_kubernetes_nodes", Description: "List Kubernetes nodes with their roles, conditions, capacity and running pod counts.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, diff --git a/internal/mcp/tools_read.go b/internal/mcp/tools_read.go index b680dfb7..c4d9cd92 100644 --- a/internal/mcp/tools_read.go +++ b/internal/mcp/tools_read.go @@ -31,79 +31,79 @@ import ( ) func registerReadTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_containers", Description: "List all monitored containers with their current state (running, stopped, restarting), health status, and basic metadata.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listContainersHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_container", Description: "Get detailed information about a specific container including state, health, image, labels, and recent state transitions.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getContainerHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_container_logs", Description: "Get recent log lines from a container's stdout/stderr output.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getContainerLogsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_alerts", Description: "List alerts. By default returns only active (unresolved) alerts. Set active_only to false to also return recent resolved and silenced alerts (last 100).", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listAlertsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_resources", Description: "Get current host resource metrics summary including CPU usage, memory usage, network I/O, and disk usage.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getResourcesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_top_consumers", Description: "Get containers ranked by resource consumption (CPU or memory), useful for identifying resource-heavy containers.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getTopConsumersHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_endpoints", Description: "List all monitored HTTP/TCP endpoints with their current status (up/down), response time, and uptime percentage.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listEndpointsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_endpoint_history", Description: "Get detailed check history for a specific endpoint, including response times, status codes, and error messages.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getEndpointHistoryHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_heartbeats", Description: "List all heartbeat/cron monitors with their current status, last ping time, expected period, and grace period.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listHeartbeatsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_certificates", Description: "List all monitored TLS certificates with expiration dates, issuer, chain validity, and days until expiry.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listCertificatesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_updates", Description: "List available image updates for monitored containers, and the operating system of every monitored host with its end-of-support status (supported, ending_soon, ended, unknown, untracked) and dates.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getUpdatesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_health", Description: "Check maintenant's own health status, version, and runtime information.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getHealthHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_agents", Description: "List all registered remote agents with their connection state, runtime type, hostname, and label." + requires(extension.CapMultihost), Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, diff --git a/internal/mcp/tools_security.go b/internal/mcp/tools_security.go index f588545f..6d5ad19d 100644 --- a/internal/mcp/tools_security.go +++ b/internal/mcp/tools_security.go @@ -23,25 +23,25 @@ import ( ) func registerSecurityTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_security_insights", Description: "List security insights (dangerous runtime configurations) across containers, or for a single container. Includes a severity summary.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getSecurityInsightsHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_cve", Description: "List active CVE vulnerabilities detected in container images, optionally filtered by container or minimum severity. For a single container the response carries the CVE evaluation state: an empty list only means \"no known CVEs\" when the state is \"evaluated\"." + requires(extension.CapCVEEnrichment), Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listCVEHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_risk_scores", Description: "List image update risk scores per container (0-100) with their risk level." + requires(extension.CapRiskScoring), Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listRiskScoresHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_security_posture", Description: "Get the infrastructure security posture score, or the posture of a single container." + requires(extension.CapSecurityPosture), Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, diff --git a/internal/mcp/tools_swarm.go b/internal/mcp/tools_swarm.go index 37dc3633..0d5e00d1 100644 --- a/internal/mcp/tools_swarm.go +++ b/internal/mcp/tools_swarm.go @@ -19,25 +19,25 @@ import ( ) func registerSwarmTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_swarm_info", Description: "Get Docker Swarm cluster info (manager/worker counts, whether this node is a manager). Reports inactive when Swarm is not detected.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getSwarmInfoHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_swarm_services", Description: "List Docker Swarm services with their image, mode, and desired/running replica counts.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listSwarmServicesHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_swarm_tasks", Description: "List Docker Swarm tasks (the running units of a service), optionally filtered by service, with their state and node.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listSwarmTasksHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_swarm_nodes", Description: "List Docker Swarm nodes with their role, status, availability and task count.", Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, diff --git a/internal/mcp/tools_triggers.go b/internal/mcp/tools_triggers.go index 1d4e06cf..e5748be0 100644 --- a/internal/mcp/tools_triggers.go +++ b/internal/mcp/tools_triggers.go @@ -32,27 +32,29 @@ import ( ) func registerTriggerTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "list_triggers", Description: "List all alert triggers (filter rules + channel destinations).", + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, listTriggersHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "get_trigger", Description: "Get a single alert trigger by ID.", + Annotations: &gomcp.ToolAnnotations{ReadOnlyHint: true}, }, getTriggerHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "create_trigger", Description: "Create a new alert trigger, notifying recoveries by default. Scope and tag filters" + requires(extension.CapAlertAdvancedFilters), }, createTriggerHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "update_trigger", Description: "Update an existing alert trigger (last-write-wins). Scope and tag filters" + requires(extension.CapAlertAdvancedFilters), }, updateTriggerHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "delete_trigger", Description: "Delete an alert trigger. The linked channels are not deleted.", }, deleteTriggerHandler(svc)) diff --git a/internal/mcp/tools_write.go b/internal/mcp/tools_write.go index 04d8954b..9ccf8fd7 100644 --- a/internal/mcp/tools_write.go +++ b/internal/mcp/tools_write.go @@ -25,32 +25,32 @@ import ( ) func registerWriteTools(server *gomcp.Server, svc *Services) { - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "acknowledge_alert", Description: "Acknowledge an active alert so it stops escalating.", }, acknowledgeAlertHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "create_incident", Description: "Create a new incident on the status page." + requires(extension.CapIncidents), }, createIncidentHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "update_incident", Description: "Post a status update to an existing status page incident." + requires(extension.CapIncidents), }, updateIncidentHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "create_maintenance", Description: "Schedule a maintenance window on the status page." + requires(extension.CapMaintenanceWindows), }, createMaintenanceHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "pause_monitor", Description: "Pause a heartbeat monitor to temporarily stop alerting. Only heartbeat monitors are supported.", }, pauseMonitorHandler(svc)) - gomcp.AddTool(server, &gomcp.Tool{ + addTool(server, svc, &gomcp.Tool{ Name: "resume_monitor", Description: "Resume a paused heartbeat monitor. Only heartbeat monitors are supported.", }, resumeMonitorHandler(svc))