Skip to content
Merged
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
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@
2. Prefer function head matching to internal case/cond clauses.
3. Never use a nested case when a with expression is possible.
4. Defer to ecto for input validation. You should rarely need to use put_change, trust the builtins.
5. Avoid usage of `if` and `cond` if a more elegant case expression is possible.
5. Avoid usage of `if` and `cond` if a more elegant case expression is possible.

## Broad go guidance

The repo has a substantial amount of go code, all in a go workspace under `go/`. Here are some broad rules for interactin with it:

* You must always format all go code, linters will validate.
* You should prefer using code generators in the Makefiles associated with whatever module you're interacting with. Never manually edit generated files, it'll be overwritten.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
ARG TOOLS_IMAGE=${OS_VARIANT}:${OS_VERSION}
ARG RUNNER_IMAGE=alpine:3.23.4 # TODO: change back to ${OS_VARIANT}:${OS_VERSION}

FROM node:22.22.0-alpine as node

Check warning on line 8 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

WORKDIR /app

Expand All @@ -19,12 +19,12 @@

COPY assets/ ./

ARG VITE_PROD_SECRET_KEY

Check warning on line 22 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "VITE_PROD_SECRET_KEY") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ARG VITE_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN

Check warning on line 24 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "SENTRY_AUTH_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ARG GIT_COMMIT

ENV VITE_PROD_SECRET_KEY=${VITE_PROD_SECRET_KEY} \

Check warning on line 27 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "SENTRY_AUTH_TOKEN") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 27 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "VITE_PROD_SECRET_KEY") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
VITE_GIT_COMMIT=${GIT_COMMIT} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN} \
SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
Expand Down Expand Up @@ -82,10 +82,10 @@

RUN mix do db.certs, agent.chart, sentry.package_source_code, release

FROM alpine:3.21.3 as tools

Check warning on line 85 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

ARG TARGETARCH=amd64
ENV CLI_VERSION=v0.12.51
ENV CLI_VERSION=v0.12.52

COPY AGENT_VERSION AGENT_VERSION

Expand Down Expand Up @@ -146,4 +146,4 @@

EXPOSE 4000 6000 4369 50051

CMD mkdir -p /tmp/sqlite; /opt/app/bin/console start

Check warning on line 149 in Dockerfile

View workflow job for this annotation

GitHub Actions / Test Build Docker image

JSON arguments recommended for ENTRYPOINT/CMD to prevent unintended behavior related to OS signals

JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals More info: https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
36 changes: 36 additions & 0 deletions assets/public/setup-guides/tools/pagerduty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# PagerDuty tool setup

Workbench’s PagerDuty integration calls the **PagerDuty REST API** (`https://api.pagerduty.com`) with a **REST API key**. The backend sends it as `Authorization: Token token=<api_key>`.

Use this when webhook payloads lack incident context (for example `body.details`, notes, or trigger log entry channel details). Agents can fetch that data on demand.

## 1) Create a REST API key

1. In PagerDuty, go to **Integrations → API Access Keys → Create New API Key**.
2. Enter a description and choose read-only if you only need incident lookup.
3. Copy the key immediately — PagerDuty will not show it again.

The key needs permission to read incidents for the services you care about (typically a read-only account or user key is enough).

## 2) Built-in tools

| Built-in tool (name suffix) | PagerDuty API | Purpose |
|-----------------------------|---------------|---------|
| `pagerduty_get_incident_*` | [`GET /incidents/{id}`](https://developer.pagerduty.com/api-reference/367602b1c2e48-get-an-incident) | Full incident record including `body.details` (UI description) |
| `pagerduty_list_incidents_*` | [`GET /incidents`](https://developer.pagerduty.com/api-reference/9d0b0b12e36f9-list-incidents) | Search/filter open or recent incidents |
| `pagerduty_list_incident_notes_*` | [`GET /incidents/{id}/notes`](https://developer.pagerduty.com/api-reference/988fd8460f5f0-list-notes-for-an-incident) | Responder notes |
| `pagerduty_list_incident_log_entries_*` | [`GET /incidents/{id}/log_entries`](https://developer.pagerduty.com/api-reference/3679cad205ac9-list-log-entries-for-an-incident) | Timeline entries; triggers may include `channel.details` |

Pass the incident **id** from webhook payloads (`event.data.id`) or the numeric **incident number** shown in the PagerDuty UI.

## 3) Fill the Workbench tool form

- **API token**: paste the REST API key from step 1.

After saving, associate this tool with a workbench that handles PagerDuty alert workflows.

## Further reading

- [PagerDuty REST API overview](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/)
- [PagerDuty API authentication](https://developer.pagerduty.com/docs/rest-api-v2/authentication/)
- [PagerDuty webhooks (V3)](https://support.pagerduty.com/docs/webhooks)
61 changes: 36 additions & 25 deletions assets/public/setup-guides/webhooks/pagerduty.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,54 @@
# PagerDuty Webhook Setup for Plural

Reference: [PagerDuty - Webhooks](https://support.pagerduty.com/main/docs/webhooks)
References:
- [PagerDuty - Webhooks (V3)](https://support.pagerduty.com/main/docs/webhooks)
- [PagerDuty - Verifying webhook signatures](https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTkz-verifying-signatures)

## 1. Create the webhook in Plural first
PagerDuty is one provider here where the signing secret is generated by PagerDuty, not manually entered at creation time.

Use **Generic Webhooks (v3)** or a **service-scoped V3 webhook subscription**. V1/V2 webhook extensions use a different payload format and are not supported.

## 1. Create webhook in PagerDuty and copy its signing secret

In your PagerDuty account (`https://<your-subdomain>.pagerduty.com/`):

1. Open **Integrations** -> **Generic Webhooks (v3)**
(Alternatively, open a service in **Services** -> **Service Directory**, select the **Integrations** tab, and add a webhook under **Webhooks** to scope events to that service.)
2. Click **New Webhook**
3. Set **Webhook URL** to a temporary placeholder (for example `https://example.com/plural-webhook-placeholder`)
4. Under **Event Subscription**, select incident lifecycle events at minimum:
- `triggered`
- `acknowledged`
- `resolved`
5. Click **Add Webhook**
6. In the confirmation dialog, copy the webhook signing secret immediately — PagerDuty only shows it once. If you lose it, you must regenerate the secret in PagerDuty and update Plural.

## 2. Create the webhook in Plural

In Plural:

1. Set **Type** to `Observability`.
2. Set **Provider** to `PAGERDUTY`.
3. Enter a webhook **Name**.
4. Enter a **Signing secret**.
3. Enter webhook **Name**.
4. Paste the PagerDuty signing secret as Plural **Signing secret**.
5. Click **Create new webhook**.

Copy the generated webhook URL.

## 2. Create webhook subscription in PagerDuty

In your PagerDuty account (`https://<your-subdomain>.pagerduty.com/`):

1. Open webhook subscriptions (generic or service-scoped)
2. Create a new subscription with HTTP delivery
3. Set destination URL to the Plural webhook URL
4. Select service/team/account filter scope
5. Add auth header or Basic Auth using the Plural signing secret (if your PagerDuty webhook mode supports it)
Copy the Plural-generated webhook URL.

## 3. Select event types
## 3. Update PagerDuty webhook URL

Enable incident lifecycle events:
Back in PagerDuty webhook settings:

- `incident.triggered`
- `incident.acknowledged`
- `incident.resolved`
1. Select the webhook URL (or **Manage** -> **Edit**)
2. Replace the placeholder URL with the Plural webhook URL
3. Click **Save Changes**

Include additional incident update events only if needed.
Optional: use **Send Test Event** in PagerDuty to confirm delivery before triggering a real incident.

## 4. Validate

Trigger a test incident in PagerDuty and confirm in Plural:
Trigger a test incident (or send a PagerDuty test event) and confirm in Plural:

- delivery accepted
- auth/secret verification succeeds
- incident state transitions are visible
- request accepted
- `x-pagerduty-signature` verification succeeds
- incident state transitions are visible (`triggered`, `acknowledged`, `resolved`)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
useUpsertObservabilityWebhookMutation,
} from 'generated/graphql'

import { InputRevealer } from 'components/cd/providers/InputRevealer'
import { SecretInputWithGenerate } from 'components/utils/SecretInputWithGenerate'
import { useUpdateState } from 'components/hooks/useUpdateState'
import { bindingToBindingAttributes } from 'components/utils/bindings'
import { GqlError } from 'components/utils/Alert'
Expand Down Expand Up @@ -226,7 +226,8 @@ export function EditObservabilityWebhook({
label="Secret"
required
>
<InputRevealer
<SecretInputWithGenerate
masked
defaultRevealed={false}
value={formState.secret}
onChange={(e) => updateFormState({ secret: e.target.value })}
Expand Down
60 changes: 60 additions & 0 deletions assets/src/components/utils/SecretInputWithGenerate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Flex, IconFrame, Input2, ReloadIcon } from '@pluralsh/design-system'
import { InputRevealer } from 'components/cd/providers/InputRevealer'
import { generateRandomAlphanumeric } from 'utils/generateRandomAlphanumeric'
import { ChangeEvent, ComponentProps, useCallback } from 'react'

type SecretInputWithGenerateProps = {
value: string
onChange: ComponentProps<typeof Input2>['onChange']
masked?: boolean
defaultRevealed?: boolean
} & Omit<ComponentProps<typeof Input2>, 'value' | 'onChange'>

export function SecretInputWithGenerate({
value,
onChange,
masked = false,
defaultRevealed = false,
...props
}: SecretInputWithGenerateProps) {
const handleGenerate = useCallback(() => {
onChange?.({
target: { value: generateRandomAlphanumeric() },
} as ChangeEvent<HTMLInputElement>)
}, [onChange])

return (
<Flex
align="center"
gap="xsmall"
width="100%"
>
<Flex
flex={1}
minWidth={0}
>
{masked ? (
<InputRevealer
defaultRevealed={defaultRevealed}
value={value}
onChange={onChange}
{...props}
/>
) : (
<Input2
value={value}
onChange={onChange}
{...props}
/>
)}
</Flex>
<IconFrame
clickable
type="secondary"
tooltip="Generate secret"
icon={<ReloadIcon />}
onClick={handleGenerate}
/>
</Flex>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const metadataExtractors: Record<WorkbenchToolType, MetadataExtractor> = {
[WorkbenchToolType.Atlassian]: extractAtlassianMetadata,
[WorkbenchToolType.Linear]: extractLinearMetadata,
[WorkbenchToolType.Slack]: extractSlackMetadata,
[WorkbenchToolType.Pagerduty]: extractPagerdutyMetadata,
[WorkbenchToolType.Teams]: extractTeamsMetadata,
[WorkbenchToolType.Mcp]: () => [],
[WorkbenchToolType.Sentry]: extractSentryMetadata,
Expand Down Expand Up @@ -183,6 +184,12 @@ function extractSlackMetadata(
return [{ label: 'URL', value: configuration?.slack?.url }]
}

function extractPagerdutyMetadata(
configuration: WorkbenchToolConfiguration | null
): MetadataRow[] {
return [{ label: 'URL', value: configuration?.pagerduty?.url }]
}

function extractTeamsMetadata(
configuration: WorkbenchToolConfiguration | null
): MetadataRow[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export function WorkbenchesIntegrations() {
size={20}
type={type}
provider={provider}
fullColor
/>
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export function WorkbenchToolCreateOrEdit({
<WorkbenchToolIcon
type={type}
provider={provider}
fullColor
/>
}
textValue={capitalize(provider || type)}
Expand Down
20 changes: 20 additions & 0 deletions assets/src/components/workbenches/tools/WorkbenchToolForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ function teamsConfigurationIsComplete(
return clientId.length > 0 && tenantId.length > 0 && clientSecret.length > 0
}

function pagerdutyConfigurationIsComplete(
c: WorkbenchToolConfigurationAttributes['pagerduty'] | null | undefined
): boolean {
return scmTokenIsSet(c?.apiToken)
}

function scmTokenIsSet(token: string | null | undefined): boolean {
return (token ?? '').trim().length > 0
}
Expand Down Expand Up @@ -194,6 +200,9 @@ export function WorkbenchToolForm({
scmTokenIsSet(state.configuration?.azureDevops?.token)) &&
(type !== WorkbenchToolType.Teams ||
teamsConfigurationIsComplete(state.configuration?.teams)) &&
(type !== WorkbenchToolType.Pagerduty ||
!!tool?.id ||
pagerdutyConfigurationIsComplete(state.configuration?.pagerduty)) &&
(type !== WorkbenchToolType.Sentry ||
!!tool?.id ||
sentryConfigurationIsComplete(state.configuration?.sentry))
Expand Down Expand Up @@ -313,6 +322,16 @@ export function WorkbenchToolForm({
>
{hasUpdates ? 'Cancel' : 'Back'}
</Button>
{currentStep === 'access-policy' ? (
<Button
secondary
type="button"
onClick={() => setCurrentStep('configuration')}
disabled={mutationLoading}
>
Back to configuration
</Button>
) : null}
<Button
disabled={
currentStep === 'configuration'
Expand Down Expand Up @@ -530,6 +549,7 @@ export const INITIAL_TOOL_CONFIG_BY_TYPE: {
},
[WorkbenchToolType.Linear]: () => ({ linear: { accessToken: '' } }),
[WorkbenchToolType.Slack]: () => ({ slack: { botToken: '' } }),
[WorkbenchToolType.Pagerduty]: () => ({ pagerduty: { apiToken: '' } }),
[WorkbenchToolType.Teams]: (config) => {
const { clientId, tenantId } = config?.teams ?? {}
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export function WorkbenchToolFormFields({
return render(type, LinearFormFields)
case WorkbenchToolType.Slack:
return render(type, SlackFormFields)
case WorkbenchToolType.Pagerduty:
return render(type, PagerdutyFormFields)
case WorkbenchToolType.Teams:
return render(type, TeamsFormFields)
case WorkbenchToolType.Exa:
Expand Down Expand Up @@ -480,6 +482,22 @@ function SlackFormFields({
)
}

function PagerdutyFormFields({
config: c,
setConfig: set,
}: ToolFormFieldProps<WorkbenchToolType.Pagerduty>) {
return (
<InputField
label="API token"
hint="PagerDuty REST API key from Integrations → API Access Keys. Used as Token token=… for incident lookups."
required
revealer
value={c.apiToken ?? ''}
onChange={(e) => set({ ...c, apiToken: e.target.value })}
/>
)
}

function TeamsFormFields({
config: c,
setConfig: set,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const TOOL_SETUP_GUIDE_MARKDOWN_PATHS: Partial<
[WorkbenchToolType.Datadog]: '/setup-guides/tools/datadog.md',
[WorkbenchToolType.Linear]: '/setup-guides/tools/linear.md',
[WorkbenchToolType.Slack]: '/setup-guides/tools/slack.md',
[WorkbenchToolType.Pagerduty]: '/setup-guides/tools/pagerduty.md',
[WorkbenchToolType.Atlassian]: '/setup-guides/tools/atlassian.md',
[WorkbenchToolType.Exa]: '/setup-guides/tools/exa.md',
[WorkbenchToolType.Github]: '/setup-guides/tools/github.md',
Expand Down Expand Up @@ -44,6 +45,8 @@ const TOOL_SETUP_GUIDE_DOC_URLS: Partial<Record<WorkbenchToolType, string>> = {
'https://docs.datadoghq.com/account_management/api-app-keys/',
[WorkbenchToolType.Linear]: 'https://linear.app/docs/api-and-webhooks',
[WorkbenchToolType.Slack]: 'https://api.slack.com/authentication/oauth-v2',
[WorkbenchToolType.Pagerduty]:
'https://developer.pagerduty.com/docs/rest-api-v2/authentication/',
[WorkbenchToolType.Atlassian]:
'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/',
[WorkbenchToolType.Exa]: 'https://dashboard.exa.ai/api-keys',
Expand Down
Loading
Loading