Skip to content

feat(reconcile): add a Webhook kind#1772

Open
rohilsurana wants to merge 15 commits into
mainfrom
feat/reconcile-webhook-kind
Open

feat(reconcile): add a Webhook kind#1772
rohilsurana wants to merge 15 commits into
mainfrom
feat/reconcile-webhook-kind

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

Adds a Webhook kind to the reconcile flow, so webhook endpoints are managed from a
desired-state file like platform users, permissions, roles, and preferences. Also adds the
server-side validation that lets the kind round-trip cleanly.

How the kind behaves

Follows the rules in RFC 0001. A webhook is an object keyed by its URL.

  • The URL is the identity and never changes. A missing endpoint fails the plan; deleting one
    needs delete: true.
  • Managed fields: description, subscribed events, and state. Each states the whole desired
    value under the one field model: a field written in the file is used as-is, and an omitted
    field takes its default, nothing is merged from the server.
    • subscribed_events is the full desired set: an empty or omitted list means all events (the
      server default), deduplicated and compared as a set.
    • description defaults to empty, so omitting it clears any description on the server.
    • state defaults to enabled, the state the server gives a new endpoint, so omitting it
      converges the endpoint to enabled.
  • The signing secret is server-owned. The server generates it on create and never returns it
    on read, so it is never in the file, a plan, or an export.

Files from frontier export write every managed field, so the normal edit-the-export workflow
is unaffected. Only a hand-trimmed file that drops a field sees it reset to the default (an
omitted state re-enables a disabled endpoint, an omitted description clears it). This mirrors
the Role kind's field model from #1779.

Server-side validation (closes the round-trip gap)

The reconcile flow treats the URL as an endpoint's identity and manages state as
enabled/disabled. For export to round-trip over every reachable server state, the server must
not hold values the reconciler cannot represent. So the webhook service now validates on create
and update:

  • the URL is a valid absolute URL that uses http or https,
  • the state is enabled or disabled (or empty, which defaults to enabled),
  • the URL is unique across endpoints (it is the identity).

The URL is trimmed before it is validated, stored, and compared, so the server and the
reconciler agree on the same value. Invalid input returns InvalidArgument, and a duplicate
URL returns AlreadyExists. The handler maps webhook errors to status codes across create,
update, list, and delete, so for example deleting a missing webhook reads as NotFound
instead of a generic internal error.

Uniqueness is enforced in the service. A DB unique index on webhook_endpoints.url would be a
stronger guarantee (the service check is a best-effort fast path with a clear error, and has a
narrow check-then-write race); that index is a follow-up, since it is a migration with
prod-data implications. Either way, a server that already holds duplicate or malformed rows
from before this change would need a one-time cleanup.

Changes

  • internal/reconcile/webhook.go, webhook_reconciler.go: the kind, its diff (each field
    resolved to the whole desired value over its default), export, the per-document Validate,
    and one shared step that trims, validates, and dedupes the URLs.
  • cmd/reconcile.go: register the kind and update the help text.
  • core/webhook/service.go: validate the URL (absolute, http/https), trim it, validate state,
    enforce URL uniqueness, with a service test.
  • internal/api/v1beta1connect/webhook.go: map validation errors to InvalidArgument /
    AlreadyExists / NotFound across create, update, list, and delete.
  • Docs: a Webhook section in the reconcile guide and the CLI reference kinds list.

Testing

  • go test ./internal/reconcile/... ./core/webhook/... passes, including the diff, apply,
    export round-trip, dedup, URL trimming, the http(s) scheme check, the description and state
    default-converging cases, and the server-side validation cases.
  • go build, go vet, gofmt, and golangci-lint are clean.

Not in this PR

Two SSRF and uniqueness hardening items from review are deferred on purpose:

  • A DB unique index on the webhook URL, to replace the service-level check-then-write with a
    hard constraint. It is a migration with prod-data implications, so it is a separate change.
  • Blocking internal destinations (loopback, private, link-local) at send time. This is on the
    pre-existing delivery path and is a security change of its own, best done in a dedicated PR.
    This PR does restrict the URL scheme to http(s).

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Jul 22, 2026 5:15pm

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added Webhook support to export/reconcile workflows, including desired-state YAML, planning/apply (with dry-run), and exporting current webhook configuration.
    • Webhook resources are now included as a supported reconcile kind.
  • Bug Fixes / Improvements

    • Webhook endpoint URLs are now trimmed and must be absolute http/https URLs.
    • Enforces uniqueness of webhook URLs and improves conflict/deletion behavior messaging in reconciliation.
    • Webhook API errors now map to more accurate Connect status codes.
  • Tests

    • Added webhook validation, uniqueness, diffing, reconciliation, and export round-trip coverage.
  • Documentation

    • Updated reconcile and CLI docs to document Webhook kind and delete: true deletion semantics.

Walkthrough

Adds webhook endpoint validation and uniqueness checks, maps webhook service errors to API status codes, introduces webhook desired-state reconciliation and export support, registers the reconciler with the CLI, and documents webhook reconciliation semantics.

Changes

Webhook endpoint handling

Layer / File(s) Summary
Endpoint validation and API errors
core/webhook/service.go, core/webhook/service_test.go, internal/api/v1beta1connect/webhook.go
Endpoint URLs and states are validated before persistence, duplicate URLs are rejected, and webhook service errors map to Connect status codes.

Webhook reconciliation

Layer / File(s) Summary
Webhook desired-state planning
internal/reconcile/role.go, internal/reconcile/webhook.go, internal/reconcile/webhook_test.go
Webhook specs, current-state models, normalization, validation, and ordered add/update/remove planning are implemented and tested.
Reconciliation and export execution
internal/reconcile/webhook_reconciler.go, internal/reconcile/webhook_reconciler_test.go
Webhook list, create, update, delete, dry-run, metadata preservation, and export round-trip behavior are implemented and tested.
CLI registration and documentation
cmd/reconcile.go, docs/content/docs/reconcile.mdx, docs/content/docs/reference/cli.mdx
The webhook reconciler is registered and webhook export, deletion, omission, and desired-state rules are documented.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: whoabhisheksah

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Jul 17, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29941506298

Warning

No base build found for commit c99f1f8 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 46.59%

Details

  • Patch coverage: 40 uncovered changes across 5 files (269 of 309 lines covered, 87.06%).

Uncovered Changes

File Changed Covered %
internal/reconcile/webhook_reconciler.go 113 94 83.19%
internal/api/v1beta1connect/webhook.go 14 0 0.0%
core/webhook/service.go 37 33 89.19%
internal/reconcile/webhook.go 124 122 98.39%
cmd/reconcile.go 7 6 85.71%
Total (6 files) 309 269 87.06%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 38837
Covered Lines: 18094
Line Coverage: 46.59%
Coverage Strength: 14.16 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/api/v1beta1connect/webhook.go (1)

15-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

webhookErrCode isn't applied to ListWebhooks/DeleteWebhook.

CreateWebhook and UpdateWebhook now surface proper status codes via webhookErrCode, but ListWebhooks (line 88) and DeleteWebhook (line 108) still hardcode connect.CodeInternal. A delete of a nonexistent webhook would plausibly return webhook.ErrNotFound from the service and should map to CodeNotFound, matching this handler's own new convention.

♻️ Apply consistently
 	err := h.webhookService.DeleteEndpoint(ctx, webhookID)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
+		return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
 	}
internal/reconcile/webhook.go (1)

81-95: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Extract a shared normalize+validate+dedupe step; also trim URLs to match server normalization. validateWebhookSpec treats a whitespace-only URL as empty but then parses/stores/compares the raw untrimmed value, while core/webhook/service.go's CreateEndpoint/UpdateEndpoint always strings.TrimSpace the URL before validating/storing. A spec URL with incidental whitespace can pass client-side validation yet fail to match the server's (trimmed) URL in diffWebhooks's byURL lookup, producing a spurious "add" that the server then rejects as a duplicate. Separately, the duplicate-URL "seen"-map dedupe loop is written independently in both diffWebhooks and Validate.

  • internal/reconcile/webhook.go#L81-L95: trim s.URL (e.g. s.URL = strings.TrimSpace(s.URL)) before parsing/validating, and use the trimmed value for storage/comparison in diffWebhooks.
  • internal/reconcile/webhook_reconciler.go#L37-L53: replace the standalone validate+seen-map loop with a shared helper (e.g. normalizeAndValidateWebhookSpecs([]WebhookSpec) ([]WebhookSpec, error)) reused by diffWebhooks, so both call sites trim/validate/dedupe identically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5cbe9a70-6de0-457b-bfd2-93c50daea78f

📥 Commits

Reviewing files that changed from the base of the PR and between 9c2a0b0 and 45fd480.

📒 Files selected for processing (11)
  • cmd/reconcile.go
  • core/webhook/service.go
  • core/webhook/service_test.go
  • docs/content/docs/reconcile.mdx
  • docs/content/docs/reference/cli.mdx
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/role.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_reconciler_test.go
  • internal/reconcile/webhook_test.go

Comment thread core/webhook/service.go
Comment on lines +53 to +59
endpoint.URL = strings.TrimSpace(endpoint.URL)
if err := validateEndpoint(endpoint); err != nil {
return Endpoint{}, err
}
if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil {
return Endpoint{}, err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

TOCTOU race on URL uniqueness, plus a full table scan on every write.

ensureURLIsFree lists all endpoints and checks in Go, then Create/UpdateByID runs as a separate, unsynchronized step. Two concurrent CreateEndpoint calls for the same URL can both pass the check and both persist — exactly the "two endpoints sharing a url" scenario that internal/reconcile/webhook.go's diffWebhooks has to special-case as an unrecoverable error ("the url identity is ambiguous... delete the extra one by hand"). This check-then-act pattern is unreliable without a DB-level unique constraint or a lock/transaction spanning the check and the write.

Separately, s.eRepo.List(ctx, EndpointFilter{}) fetches every endpoint on every create/update, which won't scale as the number of webhooks grows.

Recommend enforcing uniqueness with a unique index at the storage layer (with the app-level check kept as a fast-path/better error message), and, if a targeted lookup by URL is available on the repository, using that instead of a full list scan.

Also applies to: 77-83, 108-123

Comment thread core/webhook/service.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/webhook/service.go (1)

53-59: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Canonicalize URLs before enforcing identity uniqueness.

Only surrounding whitespace is normalized here, while ensureURLIsFree compares strings byte-for-byte. Equivalent targets such as https://EXAMPLE.com and https://example.com/ can therefore create multiple records for one webhook destination. Parse and canonicalize the URL once, persist that canonical form, and compare canonical values.

Also applies to: 77-83


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e3aa9d73-003c-438e-8cbc-29f324acdb9c

📥 Commits

Reviewing files that changed from the base of the PR and between 45fd480 and d2832dc.

📒 Files selected for processing (6)
  • core/webhook/service.go
  • core/webhook/service_test.go
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/webhook/service_test.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_test.go

Comment thread core/webhook/service.go
Comment on lines +96 to +105
func validateEndpoint(endpoint Endpoint) error {
u, err := url.Parse(endpoint.URL)
if err != nil || !u.IsAbs() {
return fmt.Errorf("%w: url must be a valid absolute URL", ErrInvalidDetail)
}
// The server dispatches events to this URL, so restrict it to http(s). This
// keeps other schemes (file, gopher, ...) out of the delivery path.
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: url scheme must be http or https", ErrInvalidDetail)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant files and symbols first
git ls-files core/webhook
printf '\n--- outline: core/webhook/service.go ---\n'
ast-grep outline core/webhook/service.go --view expanded || true

printf '\n--- outline: any tests in core/webhook ---\n'
fd -a -t f . core/webhook | sed 's#^\./##' | sort

Repository: raystack/frontier

Length of output: 1864


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the webhook service implementation around validation and uniqueness.
sed -n '1,220p' core/webhook/service.go | cat -n

printf '\n--- search for URL uniqueness and trimming ---\n'
rg -n "ensureURLIsFree|validateEndpoint|TrimSpace|Parse\\(" core/webhook/service.go core/webhook -g '!**/*_test.go'

Repository: raystack/frontier

Length of output: 10217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect tests that may cover webhook URL validation/uniqueness.
fd -a -t f '.*_test\\.go' core/webhook | sort
printf '\n--- relevant test snippets ---\n'
rg -n "http:foo|http:///|IsAbs|Host|ensureURLIsFree|duplicate|TrimSpace" core/webhook -g '*_test.go'

Repository: raystack/frontier

Length of output: 187


Require a host for HTTP(S) webhook URLs.

url.Parse + IsAbs() still accepts values like http:foo or http:///path, so endpoints without a destination host can be saved and only fail during delivery. Reject empty u.Host here.

Webhook description and state used keep-if-omitted: an omitted field kept the server value, so you could not clear a description or reset a disabled endpoint by leaving the field out. That breaks rule 2's one field model. resolve() now lays the file's present fields over the defaults (description empty, state enabled), so an omitted field converges to its default and nothing is merged from the server. Export already drops default-valued fields, so the round-trip still plans zero ops.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b0fc7b71-abab-40ca-b59e-084866d9f105

📥 Commits

Reviewing files that changed from the base of the PR and between d2832dc and e7a4043.

📒 Files selected for processing (11)
  • cmd/reconcile.go
  • core/webhook/service.go
  • core/webhook/service_test.go
  • docs/content/docs/reconcile.mdx
  • docs/content/docs/reference/cli.mdx
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/role.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_reconciler_test.go
  • internal/reconcile/webhook_test.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • internal/reconcile/role.go
  • core/webhook/service_test.go
  • internal/api/v1beta1connect/webhook.go
  • docs/content/docs/reference/cli.mdx
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler_test.go
  • core/webhook/service.go
  • internal/reconcile/webhook_reconciler.go

Comment on lines +216 to +218
- The URL must be a valid absolute URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the HTTP(S)-only URL constraint.

The service validates absolute http:// and https:// URLs, but this wording could imply that other absolute schemes are valid. Please state the supported schemes explicitly.

Proposed wording
-- The URL must be a valid absolute URL, and it is the identity.
+- The URL must be a valid absolute HTTP(S) URL, and it is the identity.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The URL must be a valid absolute URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants