From 5429503a95dd62f77dd80615571b3b7cd8fad614 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Fri, 19 Jun 2026 13:09:51 +0200 Subject: [PATCH 1/6] Initial webhook --- .configs/gqlgen.yaml | 1 + .configs/sqlc.yaml | 9 + internal/activitylog/filter.go | 184 +- internal/auth/authz/queries.go | 12 + internal/cmd/api/api.go | 6 + internal/cmd/api/http.go | 3 + .../database/migrations/0071_webhooks.sql | 151 ++ .../deploymentactivity/activitylog.go | 2 +- internal/github/repository/activitylog.go | 4 +- internal/graph/gengql/complexity.go | 9 + internal/graph/gengql/prelude.generated.go | 11 + internal/graph/gengql/root_.generated.go | 821 +++++++ internal/graph/gengql/schema.generated.go | 373 +++ internal/graph/gengql/teams.generated.go | 120 + internal/graph/gengql/webhooks.generated.go | 2002 +++++++++++++++++ internal/graph/schema/webhooks.graphqls | 265 +++ internal/graph/webhooks.resolvers.go | 74 + .../kubernetes/event/pubsublog/activitylog.go | 2 +- .../persistence/opensearch/activitylog.go | 10 +- internal/persistence/postgres/activitylog.go | 4 +- internal/persistence/valkey/activitylog.go | 10 +- internal/reconciler/activitylog.go | 6 +- internal/serviceaccount/activitylog.go | 20 +- internal/team/activitylog.go | 16 +- internal/tunnel/activitylog.go | 4 +- internal/unleash/activitylog.go | 6 +- internal/vulnerability/activitylog.go | 2 +- internal/webhook/README.md | 109 + internal/webhook/cloudevents.go | 86 + internal/webhook/dataloader.go | 73 + internal/webhook/dispatcher.go | 294 +++ internal/webhook/model.go | 136 ++ internal/webhook/node.go | 46 + internal/webhook/queries.go | 264 +++ internal/webhook/queries/webhook.sql | 248 ++ internal/webhook/signer.go | 14 + internal/webhook/webhooksql/db.go | 30 + internal/webhook/webhooksql/models.go | 120 + internal/webhook/webhooksql/querier.go | 35 + internal/webhook/webhooksql/webhook.sql.go | 722 ++++++ internal/workload/application/activitylog.go | 12 +- internal/workload/config/activitylog.go | 6 +- internal/workload/job/activitylog.go | 12 +- internal/workload/secret/activitylog.go | 14 +- 44 files changed, 6274 insertions(+), 74 deletions(-) create mode 100644 internal/database/migrations/0071_webhooks.sql create mode 100644 internal/graph/gengql/webhooks.generated.go create mode 100644 internal/graph/schema/webhooks.graphqls create mode 100644 internal/graph/webhooks.resolvers.go create mode 100644 internal/webhook/README.md create mode 100644 internal/webhook/cloudevents.go create mode 100644 internal/webhook/dataloader.go create mode 100644 internal/webhook/dispatcher.go create mode 100644 internal/webhook/model.go create mode 100644 internal/webhook/node.go create mode 100644 internal/webhook/queries.go create mode 100644 internal/webhook/queries/webhook.sql create mode 100644 internal/webhook/signer.go create mode 100644 internal/webhook/webhooksql/db.go create mode 100644 internal/webhook/webhooksql/models.go create mode 100644 internal/webhook/webhooksql/querier.go create mode 100644 internal/webhook/webhooksql/webhook.sql.go diff --git a/.configs/gqlgen.yaml b/.configs/gqlgen.yaml index c88200ccf..6e80db3d4 100644 --- a/.configs/gqlgen.yaml +++ b/.configs/gqlgen.yaml @@ -81,6 +81,7 @@ autobind: - "github.com/nais/api/internal/workload/config" - "github.com/nais/api/internal/workload/instancegroup" - "github.com/nais/api/internal/workload/secret" + - "github.com/nais/api/internal/webhook" # Don't generate Get functions for fields included in the GraphQL interfaces omit_getters: true diff --git a/.configs/sqlc.yaml b/.configs/sqlc.yaml index 253278864..4879825fa 100644 --- a/.configs/sqlc.yaml +++ b/.configs/sqlc.yaml @@ -232,3 +232,12 @@ sql: <<: *default_go package: "restteamsapisql" out: "../internal/rest/restteamsapi/restteamsapisql" + + - <<: *default_domain + name: "Webhook SQL" + queries: "../internal/webhook/queries" + gen: + go: + <<: *default_go + package: "webhooksql" + out: "../internal/webhook/webhooksql" diff --git a/internal/activitylog/filter.go b/internal/activitylog/filter.go index cd7a4413f..81f805d64 100644 --- a/internal/activitylog/filter.go +++ b/internal/activitylog/filter.go @@ -2,6 +2,7 @@ package activitylog import ( "slices" + "strings" "github.com/jackc/pgx/v5/pgtype" ) @@ -16,26 +17,191 @@ var knownFilters = map[ActivityLogActivityType]filter{} // reverseFilters maps "resource_type:action" strings to their ActivityLogActivityType values. var reverseFilters = map[string][]ActivityLogActivityType{} -func RegisterFilter(activityType ActivityLogActivityType, action ActivityLogEntryAction, resourceType ActivityLogEntryResourceType) { +// WebhookEventTypeInfo describes a single webhook-subscribable event type. +type WebhookEventTypeInfo struct { + // Type is the identifier used in webhook subscription event_types (e.g. "TEAM_MEMBER_ADDED"). + Type ActivityLogActivityType `json:"type"` + // CloudEventType is the CloudEvents-spec type string (e.g. "io.nais.team.member.added"). + CloudEventType string `json:"cloudEventType"` + // Description is a human-readable summary of the event. + Description string `json:"description"` + // Group is a logical grouping label for the event type (e.g. "Team", "Service Account"). + Group string `json:"group"` + // TeamScoped indicates if this event type can be subscribed to by team-scoped webhooks. + TeamScoped bool `json:"teamScoped"` +} + +type ActivityTypeOption func(*WebhookEventTypeInfo) + +// WithDescription sets a custom description for the event type. +func WithDescription(desc string) ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.Description = desc + } +} + +// WithGroup sets a custom group label for the event type. +func WithGroup(group string) ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.Group = group + } +} + +// GlobalOnly marks the event type as global-only (not team-scoped). +func GlobalOnly() ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.TeamScoped = false + } +} + +// eventTypeInfos stores metadata for all registered activity types. +var eventTypeInfos = map[ActivityLogActivityType]WebhookEventTypeInfo{} + +// groupPrefixes maps known multi-word prefixes to their display group names. +var groupPrefixes = map[string]string{ + "SERVICE_ACCOUNT": "Service Account", + "GENERIC_KUBERNETES_RESOURCE": "Kubernetes", + "OPENSEARCH": "OpenSearch", + "JOB_RUN": "Job", +} + +// autoGroupAndDescription derives a display group and description from an activity type name. +// Example: "TEAM_MEMBER_ADDED" → group "Team", description "Team member added". +func autoGroupAndDescription(at ActivityLogActivityType) (description, group string) { + s := string(at) + + // Check multi-word prefix overrides first (longest match wins) + longestPrefix := "" + longestGroup := "" + for prefix, grp := range groupPrefixes { + if (s == prefix || strings.HasPrefix(s, prefix+"_")) && len(prefix) > len(longestPrefix) { + longestPrefix = prefix + longestGroup = grp + } + } + if longestGroup != "" { + group = longestGroup + } else { + // Single-word group: first token, title-cased + idx := strings.Index(s, "_") + if idx < 0 { + group = titleCase(s) + } else { + group = titleCase(s[:idx]) + } + } + + words := strings.Split(strings.ToLower(s), "_") + description = strings.Join(words, " ") + // Title-case first word only for sentence-style description + if len(description) > 0 { + description = strings.ToUpper(description[:1]) + description[1:] + } + return +} + +func titleCase(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +// CloudEventType converts an ActivityLogActivityType to a CloudEvents-spec type string. +// Example: "TEAM_MEMBER_ADDED" → "io.nais.team.member.added". +func CloudEventType(at ActivityLogActivityType) string { + lower := strings.ToLower(string(at)) + dotted := strings.ReplaceAll(lower, "_", ".") + return "io.nais." + dotted +} + +// KnownEventTypes returns metadata for all registered activity log event types, suitable +// for exposing as a webhook event type catalogue. +func KnownEventTypes() []WebhookEventTypeInfo { + result := make([]WebhookEventTypeInfo, 0, len(knownFilters)) + for at := range knownFilters { + info, ok := eventTypeInfos[at] + if !ok { + desc, grp := autoGroupAndDescription(at) + info = WebhookEventTypeInfo{ + Type: at, + CloudEventType: CloudEventType(at), + Description: desc, + Group: grp, + TeamScoped: true, + } + } + result = append(result, info) + } + slices.SortFunc(result, func(a, b WebhookEventTypeInfo) int { + if a.Group != b.Group { + return strings.Compare(a.Group, b.Group) + } + return strings.Compare(string(a.Type), string(b.Type)) + }) + return result +} + +// IsTeamScoped returns true if the event type is subscribable by team webhooks. +func IsTeamScoped(at ActivityLogActivityType) bool { + info, ok := eventTypeInfos[at] + if !ok { + return true + } + return info.TeamScoped +} + +// IsValidActivityType returns true if the event type is '*' or a registered activity type. +func IsValidActivityType(at string) bool { + if at == "*" { + return true + } + _, ok := knownFilters[ActivityLogActivityType(at)] + return ok +} + +// RegisterActivityType registers an activity log activity type, configuring its action, +// resourceType mapping, and optional webhook options. +func RegisterActivityType(activityType ActivityLogActivityType, action ActivityLogEntryAction, resourceType ActivityLogEntryResourceType, opts ...ActivityTypeOption) { if f, ok := knownFilters[activityType]; ok { if f.action == action { - // If the activity type is already registered with the same action, append the resource type f.resourceType = append(f.resourceType, resourceType) - // Make sure the resource type slice is unique slices.Sort(f.resourceType) f.resourceType = slices.Compact(f.resourceType) knownFilters[activityType] = f rebuildReverseFilters() - return + } else { + panic("activity type already registered: " + string(activityType) + " with action " + string(f.action)) + } + } else { + knownFilters[activityType] = filter{ + action: action, + resourceType: []ActivityLogEntryResourceType{resourceType}, } - panic("filter already registered: " + string(activityType) + " with action " + string(f.action)) + rebuildReverseFilters() } - knownFilters[activityType] = filter{ - action: action, - resourceType: []ActivityLogEntryResourceType{resourceType}, + + // Default teamScoped based on resourceType + teamScoped := true + if resourceType == "RECONCILER" || resourceType == "CLUSTER_AUDIT" { + teamScoped = false + } + + desc, grp := autoGroupAndDescription(activityType) + info := &WebhookEventTypeInfo{ + Type: activityType, + CloudEventType: CloudEventType(activityType), + Description: desc, + Group: grp, + TeamScoped: teamScoped, } - rebuildReverseFilters() + + for _, opt := range opts { + opt(info) + } + + eventTypeInfos[activityType] = *info } func rebuildReverseFilters() { diff --git a/internal/auth/authz/queries.go b/internal/auth/authz/queries.go index 7527e99bf..874f729eb 100644 --- a/internal/auth/authz/queries.go +++ b/internal/auth/authz/queries.go @@ -328,6 +328,18 @@ func CanCreateTunnel(ctx context.Context, teamSlug slug.Slug) error { return requireTeamAuthorization(ctx, teamSlug, "tunnels:create") } +func CanCreateWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:create", teamSlug) +} + +func CanUpdateWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:update", teamSlug) +} + +func CanDeleteWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:delete", teamSlug) +} + func RequireGlobalAdmin(ctx context.Context) error { if ActorFromContext(ctx).User.IsAdmin() { return nil diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 38b8e97e8..906482654 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -42,6 +42,7 @@ import ( fakehookd "github.com/nais/api/internal/thirdparty/hookd/fake" "github.com/nais/api/internal/unleash" "github.com/nais/api/internal/vulnerability" + "github.com/nais/api/internal/webhook" "github.com/sethvargo/go-envconfig" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" @@ -255,6 +256,10 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { notifier := notify.New(pool, log.WithField("subsystem", "notifier")) go notifier.Run(ctx) + // Webhook dispatcher — drains the webhook_events outbox table on PG NOTIFY + webhookDispatcher := webhook.NewDispatcher(pool, notifier, "https://"+cfg.TenantDomain+"/api", log) + go webhookDispatcher.Run(ctx) + if !cfg.Fakes.WithFakeKubernetes { k8sClients, err := kubernetes.NewClientSets(clusterConfig) if err != nil { @@ -329,6 +334,7 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { lokiClient, cfg.AuditLog.ProjectID, cfg.AuditLog.Location, + webhookDispatcher, log.WithField("subsystem", "http"), ) if err != nil { diff --git a/internal/cmd/api/http.go b/internal/cmd/api/http.go index 155180265..bc503859f 100644 --- a/internal/cmd/api/http.go +++ b/internal/cmd/api/http.go @@ -56,6 +56,7 @@ import ( "github.com/nais/api/internal/usersync" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" + "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" @@ -198,6 +199,7 @@ func ConfigureGraph( lokiClient loki.Client, auditLogProjectID string, auditLogLocation string, + webhookDispatcher *webhook.Dispatcher, log logrus.FieldLogger, ) (func(http.Handler) http.Handler, error) { logStep := func(name string, fn func() error) error { @@ -378,6 +380,7 @@ func ConfigureGraph( ctx = tunnel.WithLoaders(ctx, tunnel.NewLoaders(watchers.TunnelWatcher)) ctx = logging.NewPackageContext(ctx, tenantName, defaultLogDestinations) ctx = environment.NewLoaderContext(ctx, pool) + ctx = webhook.NewLoaderContext(ctx, pool, webhookDispatcher) ctx = feature.NewLoaderContext( ctx, watchers.UnleashWatcher.Enabled(), diff --git a/internal/database/migrations/0071_webhooks.sql b/internal/database/migrations/0071_webhooks.sql new file mode 100644 index 000000000..73cfdaa82 --- /dev/null +++ b/internal/database/migrations/0071_webhooks.sql @@ -0,0 +1,151 @@ +-- +goose Up +CREATE TABLE webhook_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + team_slug slug REFERENCES teams (slug) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + event_types TEXT[] NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + consecutive_failures INT NOT NULL DEFAULT 0, + disabled_at TIMESTAMPTZ, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE TRIGGER webhook_subscriptions_updated_at BEFORE +UPDATE ON webhook_subscriptions FOR EACH ROW +EXECUTE FUNCTION set_updated_at () +; + +CREATE INDEX idx_webhook_subscriptions_team ON webhook_subscriptions (team_slug) +; + +CREATE INDEX idx_webhook_subscriptions_global ON webhook_subscriptions (id) +WHERE + team_slug IS NULL +; + +CREATE INDEX idx_webhook_subscriptions_enabled ON webhook_subscriptions (enabled) +WHERE + enabled = TRUE +; + +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + request_body JSONB NOT NULL, + response_status INT, + response_body TEXT, + duration_ms INT NOT NULL, + success BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE INDEX idx_webhook_deliveries_subscription ON webhook_deliveries (subscription_id, created_at DESC) +; + +-- Outbox table for durable webhook event processing. +-- Rows are inserted by a trigger on activity_log_entries and consumed by the dispatcher. +CREATE TYPE webhook_event_status AS ENUM('pending', 'completed', 'failed') +; + +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + activity_log_entries_id UUID NOT NULL REFERENCES activity_log_entries (id) ON DELETE CASCADE, + status webhook_event_status NOT NULL DEFAULT 'pending', + retry_count INT NOT NULL DEFAULT 0, + run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE INDEX idx_webhook_events_pending ON webhook_events (run_at ASC) +WHERE + status = 'pending' +; + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION webhook_events_notify () RETURNS trigger AS $$ +BEGIN + INSERT INTO webhook_events (activity_log_entries_id) + VALUES ( + NEW.id + ); + + PERFORM pg_notify('api_notify', jsonb_build_object('table', 'webhook_events', 'op', 'INSERT', 'data', '{}'::jsonb)::text); + RETURN NULL; +END; +$$ LANGUAGE plpgsql +; + +-- +goose StatementEnd +CREATE TRIGGER activity_log_webhook_notify +AFTER INSERT ON activity_log_entries FOR EACH ROW +EXECUTE FUNCTION webhook_events_notify () +; + +INSERT INTO + authorizations (name, description) +VALUES + ( + 'webhooks:create', + 'Permission to create webhook subscriptions.' + ), + ( + 'webhooks:update', + 'Permission to update webhook subscriptions.' + ), + ( + 'webhooks:delete', + 'Permission to delete webhook subscriptions.' + ) +; + +INSERT INTO + role_authorizations (role_name, authorization_name) +VALUES + ('Team owner', 'webhooks:create'), + ('Team owner', 'webhooks:update'), + ('Team owner', 'webhooks:delete') +; + +-- +goose Down +DROP TRIGGER IF EXISTS activity_log_webhook_notify ON activity_log_entries +; + +DROP FUNCTION IF EXISTS webhook_events_notify +; + +DROP TABLE IF EXISTS webhook_events +; + +DROP TYPE IF EXISTS webhook_event_status +; + +DELETE FROM role_authorizations +WHERE + authorization_name IN ( + 'webhooks:create', + 'webhooks:update', + 'webhooks:delete' + ) +; + +DELETE FROM authorizations +WHERE + name IN ( + 'webhooks:create', + 'webhooks:update', + 'webhooks:delete' + ) +; + +DROP TABLE IF EXISTS webhook_deliveries +; + +DROP TABLE IF EXISTS webhook_subscriptions +; diff --git a/internal/deployment/deploymentactivity/activitylog.go b/internal/deployment/deploymentactivity/activitylog.go index 25474d278..5dc94cd80 100644 --- a/internal/deployment/deploymentactivity/activitylog.go +++ b/internal/deployment/deploymentactivity/activitylog.go @@ -23,7 +23,7 @@ func init() { } }) - activitylog.RegisterFilter("TEAM_DEPLOY_KEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeDeployKey) + activitylog.RegisterActivityType("TEAM_DEPLOY_KEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeDeployKey) } type TeamDeployKeyUpdatedActivityLogEntry struct { diff --git a/internal/github/repository/activitylog.go b/internal/github/repository/activitylog.go index d3ce74e62..c26cafe84 100644 --- a/internal/github/repository/activitylog.go +++ b/internal/github/repository/activitylog.go @@ -27,8 +27,8 @@ func init() { } }) - activitylog.RegisterFilter("REPOSITORY_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeRepository) - activitylog.RegisterFilter("REPOSITORY_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeRepository) + activitylog.RegisterActivityType("REPOSITORY_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeRepository) + activitylog.RegisterActivityType("REPOSITORY_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeRepository) } type RepositoryAddedActivityLogEntry struct { diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index 8069f3e30..15735720c 100644 --- a/internal/graph/gengql/complexity.go +++ b/internal/graph/gengql/complexity.go @@ -133,6 +133,9 @@ func NewComplexityRoot() ComplexityRoot { c.Query.Deployments = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *deployment.DeploymentOrder, filter *deployment.DeploymentFilter) int { return cursorComplexity(first, last) * childComplexity } + c.Query.GlobalWebhooks = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } c.Query.Reconcilers = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } @@ -247,6 +250,9 @@ func NewComplexityRoot() ComplexityRoot { c.Team.VulnerabilitySummaries = func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) int { return cursorComplexity(first, last) * childComplexity } + c.Team.Webhooks = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } c.Team.Workloads = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) int { return cursorComplexity(first, last) * childComplexity } @@ -271,6 +277,9 @@ func NewComplexityRoot() ComplexityRoot { c.ValkeyMaintenance.Updates = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } + c.WebhookSubscription.Deliveries = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } return c } diff --git a/internal/graph/gengql/prelude.generated.go b/internal/graph/gengql/prelude.generated.go index b1c4cbc55..80688d81a 100644 --- a/internal/graph/gengql/prelude.generated.go +++ b/internal/graph/gengql/prelude.generated.go @@ -11,6 +11,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" + "github.com/nais/api/internal/activitylog" "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/persistence/opensearch" "github.com/vektah/gqlparser/v2/ast" @@ -1640,6 +1641,16 @@ func (ec *executionContext) marshalNInt2ᚕintᚄ(ctx context.Context, sel ast.S return ret } +func (ec *executionContext) unmarshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx context.Context, v any) (activitylog.ActivityLogActivityType, error) { + var res activitylog.ActivityLogActivityType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx context.Context, sel ast.SelectionSet, v activitylog.ActivityLogActivityType) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index cda1b6d50..244243a76 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -40,6 +40,7 @@ import ( "github.com/nais/api/internal/user" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" + "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" @@ -149,6 +150,7 @@ type ResolverRoot interface { ValkeyIssue() ValkeyIssueResolver ValkeyMaintenance() ValkeyMaintenanceResolver VulnerableImageIssue() VulnerableImageIssueResolver + WebhookSubscription() WebhookSubscriptionResolver WorkloadCost() WorkloadCostResolver WorkloadCostSample() WorkloadCostSampleResolver WorkloadProblemIssue() WorkloadProblemIssueResolver @@ -750,6 +752,10 @@ type ComplexityRoot struct { Valkey func(childComplexity int) int } + CreateWebhookPayload struct { + Webhook func(childComplexity int) int + } + CredentialsActivityLogEntry struct { Actor func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -824,6 +830,10 @@ type ComplexityRoot struct { ValkeyDeleted func(childComplexity int) int } + DeleteWebhookPayload struct { + WebhookID func(childComplexity int) int + } + Deployment struct { CommitSha func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -1536,6 +1546,7 @@ type ComplexityRoot struct { CreateUnleashForTeam func(childComplexity int, input unleash.CreateUnleashForTeamInput) int CreateValkey func(childComplexity int, input valkey.CreateValkeyInput) int CreateValkeyCredentials func(childComplexity int, input valkey.CreateValkeyCredentialsInput) int + CreateWebhook func(childComplexity int, input webhook.CreateWebhookInput) int DeleteApplication func(childComplexity int, input application.DeleteApplicationInput) int DeleteConfig func(childComplexity int, input config.DeleteConfigInput) int DeleteJob func(childComplexity int, input job.DeleteJobInput) int @@ -1548,6 +1559,7 @@ type ComplexityRoot struct { DeleteTunnel func(childComplexity int, input tunnel.DeleteTunnelInput) int DeleteUnleashInstance func(childComplexity int, input unleash.DeleteUnleashInstanceInput) int DeleteValkey func(childComplexity int, input valkey.DeleteValkeyInput) int + DeleteWebhook func(childComplexity int, input webhook.DeleteWebhookInput) int DisableReconciler func(childComplexity int, input reconciler.DisableReconcilerInput) int EnableReconciler func(childComplexity int, input reconciler.EnableReconcilerInput) int GrantPostgresAccess func(childComplexity int, input postgres.GrantPostgresAccessInput) int @@ -1578,6 +1590,7 @@ type ComplexityRoot struct { UpdateTeamEnvironment func(childComplexity int, input team.UpdateTeamEnvironmentInput) int UpdateUnleashInstance func(childComplexity int, input unleash.UpdateUnleashInstanceInput) int UpdateValkey func(childComplexity int, input valkey.UpdateValkeyInput) int + UpdateWebhook func(childComplexity int, input webhook.UpdateWebhookInput) int ViewSecretValues func(childComplexity int, input secret.ViewSecretValuesInput) int } @@ -1889,6 +1902,7 @@ type ComplexityRoot struct { Environment func(childComplexity int, name string) int Environments func(childComplexity int, orderBy *environment.EnvironmentOrder) int Features func(childComplexity int) int + GlobalWebhooks func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int ImageVulnerabilityHistory func(childComplexity int, from scalar.Date) int Me func(childComplexity int) int Node func(childComplexity int, id ident.Ident) int @@ -1906,6 +1920,7 @@ type ComplexityRoot struct { Users func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *user.UserOrder) int VulnerabilityFixHistory func(childComplexity int, from scalar.Date) int VulnerabilitySummary func(childComplexity int) int + WebhookEventTypes func(childComplexity int) int } Reconciler struct { @@ -2742,6 +2757,7 @@ type ComplexityRoot struct { VulnerabilityFixHistory func(childComplexity int, from scalar.Date) int VulnerabilitySummaries func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) int VulnerabilitySummary func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter) int + Webhooks func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int WorkloadUtilization func(childComplexity int, resourceType utilization.UtilizationResourceType) int Workloads func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) int } @@ -3325,6 +3341,10 @@ type ComplexityRoot struct { Valkey func(childComplexity int) int } + UpdateWebhookPayload struct { + Webhook func(childComplexity int) int + } + User struct { Email func(childComplexity int) int ExternalID func(childComplexity int) int @@ -3587,6 +3607,62 @@ type ComplexityRoot struct { Workload func(childComplexity int) int } + WebhookDelivery struct { + CreatedAt func(childComplexity int) int + DurationMs func(childComplexity int) int + EventType func(childComplexity int) int + ID func(childComplexity int) int + RequestBody func(childComplexity int) int + ResponseBody func(childComplexity int) int + ResponseStatus func(childComplexity int) int + Success func(childComplexity int) int + } + + WebhookDeliveryConnection struct { + Edges func(childComplexity int) int + Nodes func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + WebhookDeliveryEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + + WebhookEventTypeInfo struct { + CloudEventType func(childComplexity int) int + Description func(childComplexity int) int + Group func(childComplexity int) int + TeamScoped func(childComplexity int) int + Type func(childComplexity int) int + } + + WebhookSubscription struct { + ConsecutiveFailures func(childComplexity int) int + CreatedAt func(childComplexity int) int + CreatedBy func(childComplexity int) int + Deliveries func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int + DisabledAt func(childComplexity int) int + Enabled func(childComplexity int) int + EventTypes func(childComplexity int) int + ID func(childComplexity int) int + MaskedSecret func(childComplexity int) int + TeamSlug func(childComplexity int) int + URL func(childComplexity int) int + UpdatedAt func(childComplexity int) int + } + + WebhookSubscriptionConnection struct { + Edges func(childComplexity int) int + Nodes func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + WebhookSubscriptionEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + WorkloadConnection struct { Edges func(childComplexity int) int Nodes func(childComplexity int) int @@ -6088,6 +6164,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.CreateValkeyPayload.Valkey(childComplexity), true + case "CreateWebhookPayload.webhook": + if e.ComplexityRoot.CreateWebhookPayload.Webhook == nil { + break + } + + return e.ComplexityRoot.CreateWebhookPayload.Webhook(childComplexity), true + case "CredentialsActivityLogEntry.actor": if e.ComplexityRoot.CredentialsActivityLogEntry.Actor == nil { break @@ -6291,6 +6374,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.DeleteValkeyPayload.ValkeyDeleted(childComplexity), true + case "DeleteWebhookPayload.webhookID": + if e.ComplexityRoot.DeleteWebhookPayload.WebhookID == nil { + break + } + + return e.ComplexityRoot.DeleteWebhookPayload.WebhookID(childComplexity), true + case "Deployment.commitSha": if e.ComplexityRoot.Deployment.CommitSha == nil { break @@ -9399,6 +9489,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.CreateValkeyCredentials(childComplexity, args["input"].(valkey.CreateValkeyCredentialsInput)), true + case "Mutation.createWebhook": + if e.ComplexityRoot.Mutation.CreateWebhook == nil { + break + } + + args, err := ec.field_Mutation_createWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateWebhook(childComplexity, args["input"].(webhook.CreateWebhookInput)), true + case "Mutation.deleteApplication": if e.ComplexityRoot.Mutation.DeleteApplication == nil { break @@ -9543,6 +9645,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.DeleteValkey(childComplexity, args["input"].(valkey.DeleteValkeyInput)), true + case "Mutation.deleteWebhook": + if e.ComplexityRoot.Mutation.DeleteWebhook == nil { + break + } + + args, err := ec.field_Mutation_deleteWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteWebhook(childComplexity, args["input"].(webhook.DeleteWebhookInput)), true + case "Mutation.disableReconciler": if e.ComplexityRoot.Mutation.DisableReconciler == nil { break @@ -9903,6 +10017,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.UpdateValkey(childComplexity, args["input"].(valkey.UpdateValkeyInput)), true + case "Mutation.updateWebhook": + if e.ComplexityRoot.Mutation.UpdateWebhook == nil { + break + } + + args, err := ec.field_Mutation_updateWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateWebhook(childComplexity, args["input"].(webhook.UpdateWebhookInput)), true + case "Mutation.viewSecretValues": if e.ComplexityRoot.Mutation.ViewSecretValues == nil { break @@ -11284,6 +11410,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.Features(childComplexity), true + case "Query.globalWebhooks": + if e.ComplexityRoot.Query.GlobalWebhooks == nil { + break + } + + args, err := ec.field_Query_globalWebhooks_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.GlobalWebhooks(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + case "Query.imageVulnerabilityHistory": if e.ComplexityRoot.Query.ImageVulnerabilityHistory == nil { break @@ -11473,6 +11611,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.VulnerabilitySummary(childComplexity), true + case "Query.webhookEventTypes": + if e.ComplexityRoot.Query.WebhookEventTypes == nil { + break + } + + return e.ComplexityRoot.Query.WebhookEventTypes(childComplexity), true + case "Reconciler.activityLog": if e.ComplexityRoot.Reconciler.ActivityLog == nil { break @@ -15210,6 +15355,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Team.VulnerabilitySummary(childComplexity, args["filter"].(*vulnerability.TeamVulnerabilitySummaryFilter)), true + case "Team.webhooks": + if e.ComplexityRoot.Team.Webhooks == nil { + break + } + + args, err := ec.field_Team_webhooks_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Team.Webhooks(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + case "Team.workloadUtilization": if e.ComplexityRoot.Team.WorkloadUtilization == nil { break @@ -17587,6 +17744,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.UpdateValkeyPayload.Valkey(childComplexity), true + case "UpdateWebhookPayload.webhook": + if e.ComplexityRoot.UpdateWebhookPayload.Webhook == nil { + break + } + + return e.ComplexityRoot.UpdateWebhookPayload.Webhook(childComplexity), true + case "User.email": if e.ComplexityRoot.User.Email == nil { break @@ -18711,6 +18875,256 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.VulnerableImageIssue.Workload(childComplexity), true + case "WebhookDelivery.createdAt": + if e.ComplexityRoot.WebhookDelivery.CreatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.CreatedAt(childComplexity), true + + case "WebhookDelivery.durationMs": + if e.ComplexityRoot.WebhookDelivery.DurationMs == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.DurationMs(childComplexity), true + + case "WebhookDelivery.eventType": + if e.ComplexityRoot.WebhookDelivery.EventType == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.EventType(childComplexity), true + + case "WebhookDelivery.id": + if e.ComplexityRoot.WebhookDelivery.ID == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ID(childComplexity), true + + case "WebhookDelivery.requestBody": + if e.ComplexityRoot.WebhookDelivery.RequestBody == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.RequestBody(childComplexity), true + + case "WebhookDelivery.responseBody": + if e.ComplexityRoot.WebhookDelivery.ResponseBody == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ResponseBody(childComplexity), true + + case "WebhookDelivery.responseStatus": + if e.ComplexityRoot.WebhookDelivery.ResponseStatus == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ResponseStatus(childComplexity), true + + case "WebhookDelivery.success": + if e.ComplexityRoot.WebhookDelivery.Success == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.Success(childComplexity), true + + case "WebhookDeliveryConnection.edges": + if e.ComplexityRoot.WebhookDeliveryConnection.Edges == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.Edges(childComplexity), true + + case "WebhookDeliveryConnection.nodes": + if e.ComplexityRoot.WebhookDeliveryConnection.Nodes == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.Nodes(childComplexity), true + + case "WebhookDeliveryConnection.pageInfo": + if e.ComplexityRoot.WebhookDeliveryConnection.PageInfo == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.PageInfo(childComplexity), true + + case "WebhookDeliveryEdge.cursor": + if e.ComplexityRoot.WebhookDeliveryEdge.Cursor == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryEdge.Cursor(childComplexity), true + + case "WebhookDeliveryEdge.node": + if e.ComplexityRoot.WebhookDeliveryEdge.Node == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryEdge.Node(childComplexity), true + + case "WebhookEventTypeInfo.cloudEventType": + if e.ComplexityRoot.WebhookEventTypeInfo.CloudEventType == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.CloudEventType(childComplexity), true + + case "WebhookEventTypeInfo.description": + if e.ComplexityRoot.WebhookEventTypeInfo.Description == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Description(childComplexity), true + + case "WebhookEventTypeInfo.group": + if e.ComplexityRoot.WebhookEventTypeInfo.Group == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Group(childComplexity), true + + case "WebhookEventTypeInfo.teamScoped": + if e.ComplexityRoot.WebhookEventTypeInfo.TeamScoped == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.TeamScoped(childComplexity), true + + case "WebhookEventTypeInfo.type": + if e.ComplexityRoot.WebhookEventTypeInfo.Type == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Type(childComplexity), true + + case "WebhookSubscription.consecutiveFailures": + if e.ComplexityRoot.WebhookSubscription.ConsecutiveFailures == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.ConsecutiveFailures(childComplexity), true + + case "WebhookSubscription.createdAt": + if e.ComplexityRoot.WebhookSubscription.CreatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.CreatedAt(childComplexity), true + + case "WebhookSubscription.createdBy": + if e.ComplexityRoot.WebhookSubscription.CreatedBy == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.CreatedBy(childComplexity), true + + case "WebhookSubscription.deliveries": + if e.ComplexityRoot.WebhookSubscription.Deliveries == nil { + break + } + + args, err := ec.field_WebhookSubscription_deliveries_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.WebhookSubscription.Deliveries(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + + case "WebhookSubscription.disabledAt": + if e.ComplexityRoot.WebhookSubscription.DisabledAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.DisabledAt(childComplexity), true + + case "WebhookSubscription.enabled": + if e.ComplexityRoot.WebhookSubscription.Enabled == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.Enabled(childComplexity), true + + case "WebhookSubscription.eventTypes": + if e.ComplexityRoot.WebhookSubscription.EventTypes == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.EventTypes(childComplexity), true + + case "WebhookSubscription.id": + if e.ComplexityRoot.WebhookSubscription.ID == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.ID(childComplexity), true + + case "WebhookSubscription.maskedSecret": + if e.ComplexityRoot.WebhookSubscription.MaskedSecret == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.MaskedSecret(childComplexity), true + + case "WebhookSubscription.teamSlug": + if e.ComplexityRoot.WebhookSubscription.TeamSlug == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.TeamSlug(childComplexity), true + + case "WebhookSubscription.url": + if e.ComplexityRoot.WebhookSubscription.URL == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.URL(childComplexity), true + + case "WebhookSubscription.updatedAt": + if e.ComplexityRoot.WebhookSubscription.UpdatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.UpdatedAt(childComplexity), true + + case "WebhookSubscriptionConnection.edges": + if e.ComplexityRoot.WebhookSubscriptionConnection.Edges == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.Edges(childComplexity), true + + case "WebhookSubscriptionConnection.nodes": + if e.ComplexityRoot.WebhookSubscriptionConnection.Nodes == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.Nodes(childComplexity), true + + case "WebhookSubscriptionConnection.pageInfo": + if e.ComplexityRoot.WebhookSubscriptionConnection.PageInfo == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.PageInfo(childComplexity), true + + case "WebhookSubscriptionEdge.cursor": + if e.ComplexityRoot.WebhookSubscriptionEdge.Cursor == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionEdge.Cursor(childComplexity), true + + case "WebhookSubscriptionEdge.node": + if e.ComplexityRoot.WebhookSubscriptionEdge.Node == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionEdge.Node(childComplexity), true + case "WorkloadConnection.edges": if e.ComplexityRoot.WorkloadConnection.Edges == nil { break @@ -19195,6 +19609,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCreateUnleashForTeamInput, ec.unmarshalInputCreateValkeyCredentialsInput, ec.unmarshalInputCreateValkeyInput, + ec.unmarshalInputCreateWebhookInput, ec.unmarshalInputDeleteApplicationInput, ec.unmarshalInputDeleteConfigInput, ec.unmarshalInputDeleteJobInput, @@ -19207,6 +19622,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDeleteTunnelInput, ec.unmarshalInputDeleteUnleashInstanceInput, ec.unmarshalInputDeleteValkeyInput, + ec.unmarshalInputDeleteWebhookInput, ec.unmarshalInputDeploymentFilter, ec.unmarshalInputDeploymentOrder, ec.unmarshalInputDisableReconcilerInput, @@ -19283,6 +19699,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateTeamInput, ec.unmarshalInputUpdateUnleashInstanceInput, ec.unmarshalInputUpdateValkeyInput, + ec.unmarshalInputUpdateWebhookInput, ec.unmarshalInputUpdateWorkloadEnvironmentVariableInput, ec.unmarshalInputUserOrder, ec.unmarshalInputUserTeamOrder, @@ -32130,6 +32547,272 @@ enum SBOMStatus { "SBOM generation failed." FAILED } +`, BuiltIn: false}, + {Name: "../schema/webhooks.graphqls", Input: `""" +A webhook subscription that receives HTTP callbacks when activity log events occur. +Webhooks can be scoped to a specific team or registered globally. +""" +type WebhookSubscription implements Node { + "Globally unique ID of the webhook subscription." + id: ID! + + "The team this webhook is scoped to. Null for global webhooks." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The event types this webhook is subscribed to. Use '*' to subscribe to all events." + eventTypes: [String!]! + + "Whether the webhook is currently enabled." + enabled: Boolean! + + "Number of consecutive delivery failures. Resets to 0 on a successful delivery." + consecutiveFailures: Int! + + "When the webhook was automatically disabled due to repeated failures. Null if not auto-disabled." + disabledAt: Time + + "The identity of the user who created this webhook." + createdBy: String! + + "When the webhook was created." + createdAt: Time! + + "When the webhook was last updated." + updatedAt: Time! + + "The masked signing secret. Only the last 4 characters are visible." + maskedSecret: String! + + "Recent delivery attempts for this webhook." + deliveries( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookDeliveryConnection! +} + +"A paginated list of webhook subscriptions." +type WebhookSubscriptionConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook subscriptions in this page." + nodes: [WebhookSubscription!]! + + "The webhook subscription edges in this page." + edges: [WebhookSubscriptionEdge!]! +} + +"An edge in a webhook subscription connection." +type WebhookSubscriptionEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook subscription at this edge." + node: WebhookSubscription! +} + +""" +A record of a webhook delivery attempt, including the request sent and the response received. +""" +type WebhookDelivery implements Node { + "Globally unique ID of the delivery." + id: ID! + + "The event type that triggered this delivery." + eventType: String! + + "The CloudEvents JSON payload that was sent." + requestBody: String! + + "The HTTP status code returned by the webhook endpoint. Null if the request failed before receiving a response." + responseStatus: Int + + "The response body returned by the webhook endpoint. Null if the request failed." + responseBody: String + + "How long the delivery took in milliseconds." + durationMs: Int! + + "Whether the delivery was successful (HTTP 2xx response)." + success: Boolean! + + "When the delivery was attempted." + createdAt: Time! +} + +"A paginated list of webhook deliveries." +type WebhookDeliveryConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook deliveries in this page." + nodes: [WebhookDelivery!]! + + "The webhook delivery edges in this page." + edges: [WebhookDeliveryEdge!]! +} + +"An edge in a webhook delivery connection." +type WebhookDeliveryEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook delivery at this edge." + node: WebhookDelivery! +} + +extend type Team { + "Webhook subscriptions registered for this team." + webhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! +} + +extend type Query { + "List all globally registered webhook subscriptions. Only accessible by admins." + globalWebhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! + + """ + List all supported webhook event types with human-readable descriptions and grouping. + Use the 'type' field value when registering event_types on a webhook subscription. + """ + webhookEventTypes: [WebhookEventTypeInfo!]! +} + +""" +Metadata about a webhook-subscribable event type. +""" +type WebhookEventTypeInfo { + "The identifier to use in webhook subscription eventTypes (e.g. 'TEAM_MEMBER_ADDED')." + type: String! + + "The CloudEvents 1.0 type string that will appear in delivered payloads (e.g. 'io.nais.team.member.added')." + cloudEventType: String! + + "A human-readable description of the event (e.g. 'Team member added')." + description: String! + + "Logical group for UI display (e.g. 'Team', 'Service Account')." + group: String! + + "Indicates if this event type is subscribable by team-scoped webhooks." + teamScoped: Boolean! +} + +extend type Mutation { + """ + Create a new webhook subscription. + + If a team slug is provided, the webhook will only receive events for that team. + If no team slug is provided, the webhook is global and receives all events (admin only). + """ + createWebhook(input: CreateWebhookInput!): CreateWebhookPayload! + + """ + Update an existing webhook subscription. + + Can be used to change the URL, secret, event types, or enabled status. + """ + updateWebhook(input: UpdateWebhookInput!): UpdateWebhookPayload! + + """ + Delete a webhook subscription. + + All associated delivery records will also be deleted. + """ + deleteWebhook(input: DeleteWebhookInput!): DeleteWebhookPayload! +} + +"Input for creating a new webhook subscription." +input CreateWebhookInput { + "The team slug to scope this webhook to. Omit for a global webhook." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The secret used for HMAC-SHA256 signing of webhook payloads." + secret: String! + + "The event types to subscribe to. Use '*' to subscribe to all events." + eventTypes: [String!]! +} + +"Payload returned after creating a webhook." +type CreateWebhookPayload { + "The created webhook subscription." + webhook: WebhookSubscription! +} + +"Input for updating an existing webhook subscription." +input UpdateWebhookInput { + "The ID of the webhook subscription to update." + id: ID! + + "The new URL for the webhook. Null to keep the current value." + url: String + + "The new secret for signing. Null to keep the current value." + secret: String + + "The new event types to subscribe to. Null to keep the current value." + eventTypes: [String!] + + "Whether the webhook should be enabled. Null to keep the current value." + enabled: Boolean +} + +"Payload returned after updating a webhook." +type UpdateWebhookPayload { + "The updated webhook subscription." + webhook: WebhookSubscription! +} + +"Input for deleting a webhook subscription." +input DeleteWebhookInput { + "The ID of the webhook subscription to delete." + id: ID! +} + +"Payload returned after deleting a webhook." +type DeleteWebhookPayload { + "The ID of the deleted webhook subscription." + webhookID: ID! +} `, BuiltIn: false}, {Name: "../schema/workloads.graphqls", Input: `extend type Team { """ @@ -33562,6 +34245,14 @@ func (ec *executionContext) childFields_CreateValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type CreateValkeyPayload", field.Name) } +func (ec *executionContext) childFields_CreateWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhook": + return ec.fieldContext_CreateWebhookPayload_webhook(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CreateWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_CredentialsActivityLogEntryData(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "permission": @@ -33686,6 +34377,14 @@ func (ec *executionContext) childFields_DeleteValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type DeleteValkeyPayload", field.Name) } +func (ec *executionContext) childFields_DeleteWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhookID": + return ec.fieldContext_DeleteWebhookPayload_webhookID(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DeleteWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_Deployment(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "id": @@ -36174,6 +36873,8 @@ func (ec *executionContext) childFields_Team(ctx context.Context, field graphql. return ec.fieldContext_Team_vulnerabilitySummary(ctx, field) case "vulnerabilitySummaries": return ec.fieldContext_Team_vulnerabilitySummaries(ctx, field) + case "webhooks": + return ec.fieldContext_Team_webhooks(ctx, field) case "workloads": return ec.fieldContext_Team_workloads(ctx, field) } @@ -36984,6 +37685,14 @@ func (ec *executionContext) childFields_UpdateValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type UpdateValkeyPayload", field.Name) } +func (ec *executionContext) childFields_UpdateWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhook": + return ec.fieldContext_UpdateWebhookPayload_webhook(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdateWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_User(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "id": @@ -37320,6 +38029,118 @@ func (ec *executionContext) childFields_VulnerabilityFixSample(ctx context.Conte return nil, fmt.Errorf("no field named %q was found under type VulnerabilityFixSample", field.Name) } +func (ec *executionContext) childFields_WebhookDelivery(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_WebhookDelivery_id(ctx, field) + case "eventType": + return ec.fieldContext_WebhookDelivery_eventType(ctx, field) + case "requestBody": + return ec.fieldContext_WebhookDelivery_requestBody(ctx, field) + case "responseStatus": + return ec.fieldContext_WebhookDelivery_responseStatus(ctx, field) + case "responseBody": + return ec.fieldContext_WebhookDelivery_responseBody(ctx, field) + case "durationMs": + return ec.fieldContext_WebhookDelivery_durationMs(ctx, field) + case "success": + return ec.fieldContext_WebhookDelivery_success(ctx, field) + case "createdAt": + return ec.fieldContext_WebhookDelivery_createdAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDelivery", field.Name) +} + +func (ec *executionContext) childFields_WebhookDeliveryConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "pageInfo": + return ec.fieldContext_WebhookDeliveryConnection_pageInfo(ctx, field) + case "nodes": + return ec.fieldContext_WebhookDeliveryConnection_nodes(ctx, field) + case "edges": + return ec.fieldContext_WebhookDeliveryConnection_edges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDeliveryConnection", field.Name) +} + +func (ec *executionContext) childFields_WebhookDeliveryEdge(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "cursor": + return ec.fieldContext_WebhookDeliveryEdge_cursor(ctx, field) + case "node": + return ec.fieldContext_WebhookDeliveryEdge_node(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDeliveryEdge", field.Name) +} + +func (ec *executionContext) childFields_WebhookEventTypeInfo(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "type": + return ec.fieldContext_WebhookEventTypeInfo_type(ctx, field) + case "cloudEventType": + return ec.fieldContext_WebhookEventTypeInfo_cloudEventType(ctx, field) + case "description": + return ec.fieldContext_WebhookEventTypeInfo_description(ctx, field) + case "group": + return ec.fieldContext_WebhookEventTypeInfo_group(ctx, field) + case "teamScoped": + return ec.fieldContext_WebhookEventTypeInfo_teamScoped(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookEventTypeInfo", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscription(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_WebhookSubscription_id(ctx, field) + case "teamSlug": + return ec.fieldContext_WebhookSubscription_teamSlug(ctx, field) + case "url": + return ec.fieldContext_WebhookSubscription_url(ctx, field) + case "eventTypes": + return ec.fieldContext_WebhookSubscription_eventTypes(ctx, field) + case "enabled": + return ec.fieldContext_WebhookSubscription_enabled(ctx, field) + case "consecutiveFailures": + return ec.fieldContext_WebhookSubscription_consecutiveFailures(ctx, field) + case "disabledAt": + return ec.fieldContext_WebhookSubscription_disabledAt(ctx, field) + case "createdBy": + return ec.fieldContext_WebhookSubscription_createdBy(ctx, field) + case "createdAt": + return ec.fieldContext_WebhookSubscription_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_WebhookSubscription_updatedAt(ctx, field) + case "maskedSecret": + return ec.fieldContext_WebhookSubscription_maskedSecret(ctx, field) + case "deliveries": + return ec.fieldContext_WebhookSubscription_deliveries(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscription", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscriptionConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "pageInfo": + return ec.fieldContext_WebhookSubscriptionConnection_pageInfo(ctx, field) + case "nodes": + return ec.fieldContext_WebhookSubscriptionConnection_nodes(ctx, field) + case "edges": + return ec.fieldContext_WebhookSubscriptionConnection_edges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscriptionConnection", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscriptionEdge(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "cursor": + return ec.fieldContext_WebhookSubscriptionEdge_cursor(ctx, field) + case "node": + return ec.fieldContext_WebhookSubscriptionEdge_node(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscriptionEdge", field.Name) +} + func (ec *executionContext) childFields_WorkloadConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "pageInfo": diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 98dbd2564..80bc0c175 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -51,6 +51,7 @@ import ( "github.com/nais/api/internal/usersync" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" + "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" @@ -130,6 +131,9 @@ type MutationResolver interface { DeleteValkey(ctx context.Context, input valkey.DeleteValkeyInput) (*valkey.DeleteValkeyPayload, error) CreateValkeyCredentials(ctx context.Context, input valkey.CreateValkeyCredentialsInput) (*valkey.CreateValkeyCredentialsPayload, error) UpdateImageVulnerability(ctx context.Context, input vulnerability.UpdateImageVulnerabilityInput) (*vulnerability.UpdateImageVulnerabilityPayload, error) + CreateWebhook(ctx context.Context, input webhook.CreateWebhookInput) (*webhook.CreateWebhookPayload, error) + UpdateWebhook(ctx context.Context, input webhook.UpdateWebhookInput) (*webhook.UpdateWebhookPayload, error) + DeleteWebhook(ctx context.Context, input webhook.DeleteWebhookInput) (*webhook.DeleteWebhookPayload, error) } type QueryResolver interface { Node(ctx context.Context, id ident.Ident) (model.Node, error) @@ -158,6 +162,8 @@ type QueryResolver interface { VulnerabilityFixHistory(ctx context.Context, from scalar.Date) (*vulnerability.VulnerabilityFixHistory, error) CVE(ctx context.Context, identifier string) (*vulnerability.CVE, error) Cves(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.CVEOrder) (*pagination.Connection[*vulnerability.CVE], error) + GlobalWebhooks(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) + WebhookEventTypes(ctx context.Context) ([]*activitylog.WebhookEventTypeInfo, error) } type SubscriptionResolver interface { Log(ctx context.Context, filter loki.LogSubscriptionFilter) (<-chan *loki.LogLine, error) @@ -476,6 +482,20 @@ func (ec *executionContext) field_Mutation_createValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_createWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { + return ec.unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteApplication_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -644,6 +664,20 @@ func (ec *executionContext) field_Mutation_deleteValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_deleteWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { + return ec.unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_disableReconciler_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1064,6 +1098,20 @@ func (ec *executionContext) field_Mutation_updateValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_updateWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { + return ec.unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_viewSecretValues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1302,6 +1350,44 @@ func (ec *executionContext) field_Query_environments_args(ctx context.Context, r return args, nil } +func (ec *executionContext) field_Query_globalWebhooks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + func (ec *executionContext) field_Query_imageVulnerabilityHistory_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4602,6 +4688,138 @@ func (ec *executionContext) fieldContext_Mutation_updateImageVulnerability(ctx c return fc, nil } +func (ec *executionContext) _Mutation_createWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_createWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateWebhook(ctx, fc.Args["input"].(webhook.CreateWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { + return ec.marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_createWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_CreateWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_updateWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_updateWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateWebhook(ctx, fc.Args["input"].(webhook.UpdateWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { + return ec.marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_updateWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_UpdateWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_deleteWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteWebhook(ctx, fc.Args["input"].(webhook.DeleteWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { + return ec.marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_deleteWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_DeleteWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *pagination.PageInfo) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -5838,6 +6056,82 @@ func (ec *executionContext) fieldContext_Query_cves(ctx context.Context, field g return fc, nil } +func (ec *executionContext) _Query_globalWebhooks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_globalWebhooks(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().GlobalWebhooks(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_globalWebhooks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_globalWebhooks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_webhookEventTypes(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_webhookEventTypes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().WebhookEventTypes(ctx) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*activitylog.WebhookEventTypeInfo) graphql.Marshaler { + return ec.marshalNWebhookEventTypeInfo2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfoᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_webhookEventTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookEventTypeInfo(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6754,6 +7048,20 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._WorkloadVulnerabilitySummary(ctx, sel, obj) + case webhook.WebhookSubscription: + return ec._WebhookSubscription(ctx, sel, &obj) + case *webhook.WebhookSubscription: + if obj == nil { + return graphql.Null + } + return ec._WebhookSubscription(ctx, sel, obj) + case webhook.WebhookDelivery: + return ec._WebhookDelivery(ctx, sel, &obj) + case *webhook.WebhookDelivery: + if obj == nil { + return graphql.Null + } + return ec._WebhookDelivery(ctx, sel, obj) case usersync.UserSyncLogEntry: if obj == nil { return graphql.Null @@ -7457,6 +7765,27 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updateWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8130,6 +8459,50 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "globalWebhooks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_globalWebhooks(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "webhookEventTypes": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_webhookEventTypes(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { diff --git a/internal/graph/gengql/teams.generated.go b/internal/graph/gengql/teams.generated.go index 0480e03c0..64e4c1050 100644 --- a/internal/graph/gengql/teams.generated.go +++ b/internal/graph/gengql/teams.generated.go @@ -36,6 +36,7 @@ import ( "github.com/nais/api/internal/user" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" + "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" @@ -86,6 +87,7 @@ type TeamResolver interface { VulnerabilityFixHistory(ctx context.Context, obj *team.Team, from scalar.Date) (*vulnerability.VulnerabilityFixHistory, error) VulnerabilitySummary(ctx context.Context, obj *team.Team, filter *vulnerability.TeamVulnerabilitySummaryFilter) (*vulnerability.TeamVulnerabilitySummary, error) VulnerabilitySummaries(ctx context.Context, obj *team.Team, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) (*pagination.Connection[*vulnerability.WorkloadVulnerabilitySummary], error) + Webhooks(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) Workloads(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) (*pagination.Connection[workload.Workload], error) } type TeamDeleteKeyResolver interface { @@ -1434,6 +1436,44 @@ func (ec *executionContext) field_Team_vulnerabilitySummary_args(ctx context.Con return args, nil } +func (ec *executionContext) field_Team_webhooks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + func (ec *executionContext) field_Team_workloadUtilization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -3277,6 +3317,50 @@ func (ec *executionContext) fieldContext_Team_vulnerabilitySummaries(ctx context return fc, nil } +func (ec *executionContext) _Team_webhooks(ctx context.Context, field graphql.CollectedField, obj *team.Team) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Team_webhooks(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Team().Webhooks(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Team_webhooks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Team", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Team_webhooks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Team_workloads(ctx context.Context, field graphql.CollectedField, obj *team.Team) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9368,6 +9452,42 @@ func (ec *executionContext) _Team(ctx context.Context, sel ast.SelectionSet, obj continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "webhooks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Team_webhooks(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "workloads": field := field diff --git a/internal/graph/gengql/webhooks.generated.go b/internal/graph/gengql/webhooks.generated.go new file mode 100644 index 000000000..8b5295e3b --- /dev/null +++ b/internal/graph/gengql/webhooks.generated.go @@ -0,0 +1,2002 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package gengql + +import ( + "context" + "errors" + "math" + "strconv" + "sync/atomic" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/webhook" + "github.com/vektah/gqlparser/v2/ast" +) + +// region ************************** generated!.gotpl ************************** + +type WebhookSubscriptionResolver interface { + MaskedSecret(ctx context.Context, obj *webhook.WebhookSubscription) (string, error) + Deliveries(ctx context.Context, obj *webhook.WebhookSubscription, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookDelivery], error) +} + +// endregion ************************** generated!.gotpl ************************** + +// region ***************************** args.gotpl ***************************** + +func (ec *executionContext) field_WebhookSubscription_deliveries_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + +// endregion ***************************** args.gotpl ***************************** + +// region ************************** directives.gotpl ************************** + +// endregion ************************** directives.gotpl ************************** + +// region **************************** field.gotpl ***************************** + +func (ec *executionContext) _CreateWebhookPayload_webhook(ctx context.Context, field graphql.CollectedField, obj *webhook.CreateWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_CreateWebhookPayload_webhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Webhook, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_CreateWebhookPayload_webhook(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreateWebhookPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _DeleteWebhookPayload_webhookID(ctx context.Context, field graphql.CollectedField, obj *webhook.DeleteWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_DeleteWebhookPayload_webhookID(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.WebhookID, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_DeleteWebhookPayload_webhookID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("DeleteWebhookPayload", field, false, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _UpdateWebhookPayload_webhook(ctx context.Context, field graphql.CollectedField, obj *webhook.UpdateWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_UpdateWebhookPayload_webhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Webhook, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_UpdateWebhookPayload_webhook(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateWebhookPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDelivery_id(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_eventType(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_eventType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EventType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_eventType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_requestBody(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_requestBody(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.RequestBody, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_requestBody(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_responseStatus(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_responseStatus(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResponseStatus, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *int) graphql.Marshaler { + return ec.marshalOInt2ᚖint(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_responseStatus(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_responseBody(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_responseBody(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResponseBody, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_responseBody(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_durationMs(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_durationMs(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.DurationMs, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_durationMs(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_success(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_success(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Success, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_createdAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookDeliveryConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_pageInfo(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.PageInfo, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.PageInfo) graphql.Marshaler { + return ec.marshalNPageInfo2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐPageInfo(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PageInfo(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_nodes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Nodes(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { + return ec.marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDeliveryᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDelivery(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryConnection_edges(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_edges(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Edges, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec.marshalNWebhookDeliveryEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDeliveryEdge(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryEdge_cursor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Cursor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.Cursor) graphql.Marshaler { + return ec.marshalNCursor2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDeliveryEdge", field, false, false, errors.New("field of type Cursor does not have child fields")) +} + +func (ec *executionContext) _WebhookDeliveryEdge_node(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryEdge_node(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Node, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDelivery(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookEventTypeInfo_type(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_type(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v activitylog.ActivityLogActivityType) graphql.Marshaler { + return ec.marshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_cloudEventType(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_cloudEventType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CloudEventType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_cloudEventType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_description(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_description(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_group(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_group(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Group, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_group(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_teamScoped(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_teamScoped(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamScoped, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_teamScoped(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_id(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_teamSlug(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_teamSlug(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamSlug, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *slug.Slug) graphql.Marshaler { + return ec.marshalOSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_teamSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Slug does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_url(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_url(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.URL, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_eventTypes(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_eventTypes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EventTypes, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []string) graphql.Marshaler { + return ec.marshalNString2ᚕstringᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_eventTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_enabled(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_enabled(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Enabled, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_enabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_consecutiveFailures(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_consecutiveFailures(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ConsecutiveFailures, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_consecutiveFailures(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_disabledAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_disabledAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.DisabledAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *time.Time) graphql.Marshaler { + return ec.marshalOTime2ᚖtimeᚐTime(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_disabledAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_createdBy(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_createdBy(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedBy, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_createdBy(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_createdAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_updatedAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_updatedAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_maskedSecret(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_maskedSecret(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.WebhookSubscription().MaskedSecret(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_maskedSecret(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, true, true, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_deliveries(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_deliveries(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.WebhookSubscription().Deliveries(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec.marshalNWebhookDeliveryConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_deliveries(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscription", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDeliveryConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_WebhookSubscription_deliveries_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_pageInfo(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.PageInfo, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.PageInfo) graphql.Marshaler { + return ec.marshalNPageInfo2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐPageInfo(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PageInfo(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_nodes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Nodes(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscriptionᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_edges(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_edges(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Edges, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionEdge(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionEdge_cursor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Cursor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.Cursor) graphql.Marshaler { + return ec.marshalNCursor2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscriptionEdge", field, false, false, errors.New("field of type Cursor does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscriptionEdge_node(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionEdge_node(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Node, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// region **************************** input.gotpl ***************************** + +func (ec *executionContext) unmarshalInputCreateWebhookInput(ctx context.Context, obj any) (webhook.CreateWebhookInput, error) { + var it webhook.CreateWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"teamSlug", "url", "secret", "eventTypes"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "teamSlug": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlug")) + data, err := ec.unmarshalOSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, v) + if err != nil { + return it, err + } + it.TeamSlug = data + case "url": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("url")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.URL = data + case "secret": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("secret")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Secret = data + case "eventTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("eventTypes")) + data, err := ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.EventTypes = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputDeleteWebhookInput(ctx context.Context, obj any) (webhook.DeleteWebhookInput, error) { + var it webhook.DeleteWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, v) + if err != nil { + return it, err + } + it.ID = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputUpdateWebhookInput(ctx context.Context, obj any) (webhook.UpdateWebhookInput, error) { + var it webhook.UpdateWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id", "url", "secret", "eventTypes", "enabled"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, v) + if err != nil { + return it, err + } + it.ID = data + case "url": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("url")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.URL = data + case "secret": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("secret")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Secret = data + case "eventTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("eventTypes")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.EventTypes = data + case "enabled": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("enabled")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.Enabled = data + } + } + return it, nil +} + +// endregion **************************** input.gotpl ***************************** + +// region ************************** interface.gotpl *************************** + +// endregion ************************** interface.gotpl *************************** + +// region **************************** object.gotpl **************************** + +var createWebhookPayloadImplementors = []string{"CreateWebhookPayload"} + +func (ec *executionContext) _CreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.CreateWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, createWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreateWebhookPayload") + case "webhook": + out.Values[i] = ec._CreateWebhookPayload_webhook(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var deleteWebhookPayloadImplementors = []string{"DeleteWebhookPayload"} + +func (ec *executionContext) _DeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.DeleteWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deleteWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteWebhookPayload") + case "webhookID": + out.Values[i] = ec._DeleteWebhookPayload_webhookID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var updateWebhookPayloadImplementors = []string{"UpdateWebhookPayload"} + +func (ec *executionContext) _UpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.UpdateWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdateWebhookPayload") + case "webhook": + out.Values[i] = ec._UpdateWebhookPayload_webhook(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryImplementors = []string{"WebhookDelivery", "Node"} + +func (ec *executionContext) _WebhookDelivery(ctx context.Context, sel ast.SelectionSet, obj *webhook.WebhookDelivery) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDelivery") + case "id": + out.Values[i] = ec._WebhookDelivery_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "eventType": + out.Values[i] = ec._WebhookDelivery_eventType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "requestBody": + out.Values[i] = ec._WebhookDelivery_requestBody(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "responseStatus": + out.Values[i] = ec._WebhookDelivery_responseStatus(ctx, field, obj) + case "responseBody": + out.Values[i] = ec._WebhookDelivery_responseBody(ctx, field, obj) + case "durationMs": + out.Values[i] = ec._WebhookDelivery_durationMs(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "success": + out.Values[i] = ec._WebhookDelivery_success(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "createdAt": + out.Values[i] = ec._WebhookDelivery_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryConnectionImplementors = []string{"WebhookDeliveryConnection"} + +func (ec *executionContext) _WebhookDeliveryConnection(ctx context.Context, sel ast.SelectionSet, obj *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDeliveryConnection") + case "pageInfo": + out.Values[i] = ec._WebhookDeliveryConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "nodes": + out.Values[i] = ec._WebhookDeliveryConnection_nodes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "edges": + out.Values[i] = ec._WebhookDeliveryConnection_edges(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryEdgeImplementors = []string{"WebhookDeliveryEdge"} + +func (ec *executionContext) _WebhookDeliveryEdge(ctx context.Context, sel ast.SelectionSet, obj *pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDeliveryEdge") + case "cursor": + out.Values[i] = ec._WebhookDeliveryEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "node": + out.Values[i] = ec._WebhookDeliveryEdge_node(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookEventTypeInfoImplementors = []string{"WebhookEventTypeInfo"} + +func (ec *executionContext) _WebhookEventTypeInfo(ctx context.Context, sel ast.SelectionSet, obj *activitylog.WebhookEventTypeInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookEventTypeInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookEventTypeInfo") + case "type": + out.Values[i] = ec._WebhookEventTypeInfo_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "cloudEventType": + out.Values[i] = ec._WebhookEventTypeInfo_cloudEventType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec._WebhookEventTypeInfo_description(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "group": + out.Values[i] = ec._WebhookEventTypeInfo_group(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "teamScoped": + out.Values[i] = ec._WebhookEventTypeInfo_teamScoped(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionImplementors = []string{"WebhookSubscription", "Node"} + +func (ec *executionContext) _WebhookSubscription(ctx context.Context, sel ast.SelectionSet, obj *webhook.WebhookSubscription) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscription") + case "id": + out.Values[i] = ec._WebhookSubscription_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "teamSlug": + out.Values[i] = ec._WebhookSubscription_teamSlug(ctx, field, obj) + case "url": + out.Values[i] = ec._WebhookSubscription_url(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "eventTypes": + out.Values[i] = ec._WebhookSubscription_eventTypes(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "enabled": + out.Values[i] = ec._WebhookSubscription_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "consecutiveFailures": + out.Values[i] = ec._WebhookSubscription_consecutiveFailures(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "disabledAt": + out.Values[i] = ec._WebhookSubscription_disabledAt(ctx, field, obj) + case "createdBy": + out.Values[i] = ec._WebhookSubscription_createdBy(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdAt": + out.Values[i] = ec._WebhookSubscription_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "updatedAt": + out.Values[i] = ec._WebhookSubscription_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "maskedSecret": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._WebhookSubscription_maskedSecret(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "deliveries": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._WebhookSubscription_deliveries(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionConnectionImplementors = []string{"WebhookSubscriptionConnection"} + +func (ec *executionContext) _WebhookSubscriptionConnection(ctx context.Context, sel ast.SelectionSet, obj *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscriptionConnection") + case "pageInfo": + out.Values[i] = ec._WebhookSubscriptionConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "nodes": + out.Values[i] = ec._WebhookSubscriptionConnection_nodes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "edges": + out.Values[i] = ec._WebhookSubscriptionConnection_edges(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionEdgeImplementors = []string{"WebhookSubscriptionEdge"} + +func (ec *executionContext) _WebhookSubscriptionEdge(ctx context.Context, sel ast.SelectionSet, obj *pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscriptionEdge") + case "cursor": + out.Values[i] = ec._WebhookSubscriptionEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "node": + out.Values[i] = ec._WebhookSubscriptionEdge_node(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +// endregion **************************** object.gotpl **************************** + +// region ***************************** type.gotpl ***************************** + +func (ec *executionContext) unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookInput(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { + res, err := ec.unmarshalInputCreateWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCreateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.CreateWebhookPayload) graphql.Marshaler { + return ec._CreateWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CreateWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookInput(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { + res, err := ec.unmarshalInputDeleteWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNDeleteWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.DeleteWebhookPayload) graphql.Marshaler { + return ec._DeleteWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DeleteWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookInput(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { + res, err := ec.unmarshalInputUpdateWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUpdateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.UpdateWebhookPayload) graphql.Marshaler { + return ec._UpdateWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UpdateWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDeliveryᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookDelivery(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDeliveryConnection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec._WebhookDeliveryConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookDeliveryConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookDeliveryConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDeliveryEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx context.Context, sel ast.SelectionSet, v pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec._WebhookDeliveryEdge(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookDeliveryEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookDeliveryEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookEventTypeInfo2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfoᚄ(ctx context.Context, sel ast.SelectionSet, v []*activitylog.WebhookEventTypeInfo) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookEventTypeInfo2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfo(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookEventTypeInfo2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfo(ctx context.Context, sel ast.SelectionSet, v *activitylog.WebhookEventTypeInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookEventTypeInfo(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscriptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookSubscription(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionConnection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec._WebhookSubscriptionConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookSubscriptionConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx context.Context, sel ast.SelectionSet, v pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec._WebhookSubscriptionEdge(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookSubscriptionEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +// endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/schema/webhooks.graphqls b/internal/graph/schema/webhooks.graphqls new file mode 100644 index 000000000..37d37c850 --- /dev/null +++ b/internal/graph/schema/webhooks.graphqls @@ -0,0 +1,265 @@ +""" +A webhook subscription that receives HTTP callbacks when activity log events occur. +Webhooks can be scoped to a specific team or registered globally. +""" +type WebhookSubscription implements Node { + "Globally unique ID of the webhook subscription." + id: ID! + + "The team this webhook is scoped to. Null for global webhooks." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The event types this webhook is subscribed to. Use '*' to subscribe to all events." + eventTypes: [String!]! + + "Whether the webhook is currently enabled." + enabled: Boolean! + + "Number of consecutive delivery failures. Resets to 0 on a successful delivery." + consecutiveFailures: Int! + + "When the webhook was automatically disabled due to repeated failures. Null if not auto-disabled." + disabledAt: Time + + "The identity of the user who created this webhook." + createdBy: String! + + "When the webhook was created." + createdAt: Time! + + "When the webhook was last updated." + updatedAt: Time! + + "The masked signing secret. Only the last 4 characters are visible." + maskedSecret: String! + + "Recent delivery attempts for this webhook." + deliveries( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookDeliveryConnection! +} + +"A paginated list of webhook subscriptions." +type WebhookSubscriptionConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook subscriptions in this page." + nodes: [WebhookSubscription!]! + + "The webhook subscription edges in this page." + edges: [WebhookSubscriptionEdge!]! +} + +"An edge in a webhook subscription connection." +type WebhookSubscriptionEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook subscription at this edge." + node: WebhookSubscription! +} + +""" +A record of a webhook delivery attempt, including the request sent and the response received. +""" +type WebhookDelivery implements Node { + "Globally unique ID of the delivery." + id: ID! + + "The event type that triggered this delivery." + eventType: String! + + "The CloudEvents JSON payload that was sent." + requestBody: String! + + "The HTTP status code returned by the webhook endpoint. Null if the request failed before receiving a response." + responseStatus: Int + + "The response body returned by the webhook endpoint. Null if the request failed." + responseBody: String + + "How long the delivery took in milliseconds." + durationMs: Int! + + "Whether the delivery was successful (HTTP 2xx response)." + success: Boolean! + + "When the delivery was attempted." + createdAt: Time! +} + +"A paginated list of webhook deliveries." +type WebhookDeliveryConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook deliveries in this page." + nodes: [WebhookDelivery!]! + + "The webhook delivery edges in this page." + edges: [WebhookDeliveryEdge!]! +} + +"An edge in a webhook delivery connection." +type WebhookDeliveryEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook delivery at this edge." + node: WebhookDelivery! +} + +extend type Team { + "Webhook subscriptions registered for this team." + webhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! +} + +extend type Query { + "List all globally registered webhook subscriptions. Only accessible by admins." + globalWebhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! + + """ + List all supported webhook event types with human-readable descriptions and grouping. + Use the 'type' field value when registering event_types on a webhook subscription. + """ + webhookEventTypes: [WebhookEventTypeInfo!]! +} + +""" +Metadata about a webhook-subscribable event type. +""" +type WebhookEventTypeInfo { + "The identifier to use in webhook subscription eventTypes (e.g. 'TEAM_MEMBER_ADDED')." + type: String! + + "The CloudEvents 1.0 type string that will appear in delivered payloads (e.g. 'io.nais.team.member.added')." + cloudEventType: String! + + "A human-readable description of the event (e.g. 'Team member added')." + description: String! + + "Logical group for UI display (e.g. 'Team', 'Service Account')." + group: String! + + "Indicates if this event type is subscribable by team-scoped webhooks." + teamScoped: Boolean! +} + +extend type Mutation { + """ + Create a new webhook subscription. + + If a team slug is provided, the webhook will only receive events for that team. + If no team slug is provided, the webhook is global and receives all events (admin only). + """ + createWebhook(input: CreateWebhookInput!): CreateWebhookPayload! + + """ + Update an existing webhook subscription. + + Can be used to change the URL, secret, event types, or enabled status. + """ + updateWebhook(input: UpdateWebhookInput!): UpdateWebhookPayload! + + """ + Delete a webhook subscription. + + All associated delivery records will also be deleted. + """ + deleteWebhook(input: DeleteWebhookInput!): DeleteWebhookPayload! +} + +"Input for creating a new webhook subscription." +input CreateWebhookInput { + "The team slug to scope this webhook to. Omit for a global webhook." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The secret used for HMAC-SHA256 signing of webhook payloads." + secret: String! + + "The event types to subscribe to. Use '*' to subscribe to all events." + eventTypes: [String!]! +} + +"Payload returned after creating a webhook." +type CreateWebhookPayload { + "The created webhook subscription." + webhook: WebhookSubscription! +} + +"Input for updating an existing webhook subscription." +input UpdateWebhookInput { + "The ID of the webhook subscription to update." + id: ID! + + "The new URL for the webhook. Null to keep the current value." + url: String + + "The new secret for signing. Null to keep the current value." + secret: String + + "The new event types to subscribe to. Null to keep the current value." + eventTypes: [String!] + + "Whether the webhook should be enabled. Null to keep the current value." + enabled: Boolean +} + +"Payload returned after updating a webhook." +type UpdateWebhookPayload { + "The updated webhook subscription." + webhook: WebhookSubscription! +} + +"Input for deleting a webhook subscription." +input DeleteWebhookInput { + "The ID of the webhook subscription to delete." + id: ID! +} + +"Payload returned after deleting a webhook." +type DeleteWebhookPayload { + "The ID of the deleted webhook subscription." + webhookID: ID! +} diff --git a/internal/graph/webhooks.resolvers.go b/internal/graph/webhooks.resolvers.go new file mode 100644 index 000000000..edb48a034 --- /dev/null +++ b/internal/graph/webhooks.resolvers.go @@ -0,0 +1,74 @@ +package graph + +import ( + "context" + + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/gengql" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/team" + "github.com/nais/api/internal/webhook" +) + +func (r *mutationResolver) CreateWebhook(ctx context.Context, input webhook.CreateWebhookInput) (*webhook.CreateWebhookPayload, error) { + return webhook.Create(ctx, input) +} + +func (r *mutationResolver) UpdateWebhook(ctx context.Context, input webhook.UpdateWebhookInput) (*webhook.UpdateWebhookPayload, error) { + return webhook.Update(ctx, input) +} + +func (r *mutationResolver) DeleteWebhook(ctx context.Context, input webhook.DeleteWebhookInput) (*webhook.DeleteWebhookPayload, error) { + return webhook.Delete(ctx, input) +} + +func (r *queryResolver) GlobalWebhooks(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) { + if err := authz.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListGlobal(ctx, page) +} + +func (r *queryResolver) WebhookEventTypes(ctx context.Context) ([]*activitylog.WebhookEventTypeInfo, error) { + all := activitylog.KnownEventTypes() + result := make([]*activitylog.WebhookEventTypeInfo, len(all)) + for i := range all { + result[i] = &all[i] + } + return result, nil +} + +func (r *teamResolver) Webhooks(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) { + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListForTeam(ctx, obj.Slug, page) +} + +func (r *webhookSubscriptionResolver) MaskedSecret(ctx context.Context, obj *webhook.WebhookSubscription) (string, error) { + return webhook.MaskedSecret(obj.Secret), nil +} + +func (r *webhookSubscriptionResolver) Deliveries(ctx context.Context, obj *webhook.WebhookSubscription, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookDelivery], error) { + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListDeliveries(ctx, obj.UUID, page) +} + +func (r *Resolver) WebhookSubscription() gengql.WebhookSubscriptionResolver { + return &webhookSubscriptionResolver{r} +} + +type webhookSubscriptionResolver struct{ *Resolver } diff --git a/internal/kubernetes/event/pubsublog/activitylog.go b/internal/kubernetes/event/pubsublog/activitylog.go index f46ab08dd..01ec0d0bc 100644 --- a/internal/kubernetes/event/pubsublog/activitylog.go +++ b/internal/kubernetes/event/pubsublog/activitylog.go @@ -13,7 +13,7 @@ const ( ) func init() { - activitylog.RegisterFilter(activityLogActivityTypeClusterAudit, activityLogEntryActionClusterAudit, ActivityLogEntryResourceTypeClusterAudit) + activitylog.RegisterActivityType(activityLogActivityTypeClusterAudit, activityLogEntryActionClusterAudit, ActivityLogEntryResourceTypeClusterAudit, activitylog.GlobalOnly()) activitylog.RegisterTransformer(ActivityLogEntryResourceTypeClusterAudit, func(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { data, err := activitylog.UnmarshalData[ClusterAuditActivityLogEntryData](entry) diff --git a/internal/persistence/opensearch/activitylog.go b/internal/persistence/opensearch/activitylog.go index 0eb52c712..b8da123d8 100644 --- a/internal/persistence/opensearch/activitylog.go +++ b/internal/persistence/opensearch/activitylog.go @@ -43,11 +43,11 @@ func init() { } }) - activitylog.RegisterFilter("OPENSEARCH_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType("OPENSEARCH_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType("OPENSEARCH_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType("OPENSEARCH_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType("OPENSEARCH_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch) } type OpenSearchCreatedActivityLogEntry struct { diff --git a/internal/persistence/postgres/activitylog.go b/internal/persistence/postgres/activitylog.go index 44371b3b6..3eab15cc2 100644 --- a/internal/persistence/postgres/activitylog.go +++ b/internal/persistence/postgres/activitylog.go @@ -40,8 +40,8 @@ func init() { } }) - activitylog.RegisterFilter("POSTGRES_GRANT_ACCESS", activityLogEntryActionGrantAccess, activityLogEntryResourceTypePostgres) - activitylog.RegisterFilter("POSTGRES_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypePostgres) + activitylog.RegisterActivityType("POSTGRES_GRANT_ACCESS", activityLogEntryActionGrantAccess, activityLogEntryResourceTypePostgres) + activitylog.RegisterActivityType("POSTGRES_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypePostgres) } type PostgresDeletedActivityLogEntry struct { diff --git a/internal/persistence/valkey/activitylog.go b/internal/persistence/valkey/activitylog.go index 2a944b10c..47b9d0052 100644 --- a/internal/persistence/valkey/activitylog.go +++ b/internal/persistence/valkey/activitylog.go @@ -44,11 +44,11 @@ func init() { } }) - activitylog.RegisterFilter("VALKEY_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType("VALKEY_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType("VALKEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType("VALKEY_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType("VALKEY_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey) } type ValkeyCreatedActivityLogEntry struct { diff --git a/internal/reconciler/activitylog.go b/internal/reconciler/activitylog.go index fd7c8f062..48d5a1518 100644 --- a/internal/reconciler/activitylog.go +++ b/internal/reconciler/activitylog.go @@ -44,9 +44,9 @@ func init() { } }) - activitylog.RegisterFilter("RECONCILER_ENABLED", activityLogEntryActionEnableReconciler, ActivityLogEntryResourceTypeReconciler) - activitylog.RegisterFilter("RECONCILER_DISABLED", activityLogEntryActionDisableReconciler, ActivityLogEntryResourceTypeReconciler) - activitylog.RegisterFilter("RECONCILER_CONFIGURED", activityLogEntryActionConfigureReconciler, ActivityLogEntryResourceTypeReconciler) + activitylog.RegisterActivityType("RECONCILER_ENABLED", activityLogEntryActionEnableReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) + activitylog.RegisterActivityType("RECONCILER_DISABLED", activityLogEntryActionDisableReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) + activitylog.RegisterActivityType("RECONCILER_CONFIGURED", activityLogEntryActionConfigureReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) } type ReconcilerEnabledActivityLogEntry struct { diff --git a/internal/serviceaccount/activitylog.go b/internal/serviceaccount/activitylog.go index c99626236..daf0e31ad 100644 --- a/internal/serviceaccount/activitylog.go +++ b/internal/serviceaccount/activitylog.go @@ -136,16 +136,16 @@ func init() { } }) - activitylog.RegisterFilter("SERVICE_ACCOUNT_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_CREATED", activityLogEntryActionCreateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_UPDATED", activityLogEntryActionUpdateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_DELETED", activityLogEntryActionDeleteServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_ROLE_ASSIGNED", activityLogEntryActionAssignServiceAccountRole, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_ROLE_REVOKED", activityLogEntryActionRevokeServiceAccountRole, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", activityLogEntryActionAddServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", activityLogEntryActionRemoveServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_CREATED", activityLogEntryActionCreateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_UPDATED", activityLogEntryActionUpdateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_DELETED", activityLogEntryActionDeleteServiceAccountToken, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_ROLE_ASSIGNED", activityLogEntryActionAssignServiceAccountRole, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_ROLE_REVOKED", activityLogEntryActionRevokeServiceAccountRole, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", activityLogEntryActionAddServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType("SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", activityLogEntryActionRemoveServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) } type RoleAssignedToServiceAccountActivityLogEntry struct { diff --git a/internal/team/activitylog.go b/internal/team/activitylog.go index a9b3548d9..9e2e225d3 100644 --- a/internal/team/activitylog.go +++ b/internal/team/activitylog.go @@ -97,14 +97,14 @@ func init() { } }) - activitylog.RegisterFilter("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_CREATE_DELETE_KEY", activityLogEntryActionCreateDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_SET_ROLE", activityLogEntryActionSetMemberRole, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_ENVIRONMENT_UPDATED", activityLogEntryActionUpdateEnvironment, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_CREATE_DELETE_KEY", activityLogEntryActionCreateDeleteKey, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_MEMBER_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_MEMBER_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_MEMBER_SET_ROLE", activityLogEntryActionSetMemberRole, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_ENVIRONMENT_UPDATED", activityLogEntryActionUpdateEnvironment, activityLogEntryResourceTypeTeam) } type TeamCreatedActivityLogEntry struct { diff --git a/internal/tunnel/activitylog.go b/internal/tunnel/activitylog.go index e3550c4c4..5e979e019 100644 --- a/internal/tunnel/activitylog.go +++ b/internal/tunnel/activitylog.go @@ -44,8 +44,8 @@ func init() { } }) - activitylog.RegisterFilter("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel) - activitylog.RegisterFilter("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel) + activitylog.RegisterActivityType("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel) + activitylog.RegisterActivityType("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel) } type tunnelCreatedData struct { diff --git a/internal/unleash/activitylog.go b/internal/unleash/activitylog.go index 0a07557f6..7a0b25b93 100644 --- a/internal/unleash/activitylog.go +++ b/internal/unleash/activitylog.go @@ -43,9 +43,9 @@ func init() { } }) - activitylog.RegisterFilter("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterFilter("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterFilter("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash) } type UnleashInstanceCreatedActivityLogEntry struct { diff --git a/internal/vulnerability/activitylog.go b/internal/vulnerability/activitylog.go index 16fe963dc..b13947512 100644 --- a/internal/vulnerability/activitylog.go +++ b/internal/vulnerability/activitylog.go @@ -27,7 +27,7 @@ func init() { } }) - activitylog.RegisterFilter("VULNERABILITY_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeVulnerability) + activitylog.RegisterActivityType("VULNERABILITY_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeVulnerability) } type VulnerabilityUpdatedActivityLogEntry struct { diff --git a/internal/webhook/README.md b/internal/webhook/README.md new file mode 100644 index 000000000..2848bbf01 --- /dev/null +++ b/internal/webhook/README.md @@ -0,0 +1,109 @@ +# Webhook System + +Sends HTTP callbacks to user-registered endpoints when activity log events occur. +Supports team-scoped and global subscriptions, HMAC-signed CloudEvents 1.0 payloads, +durable delivery via a PostgreSQL outbox, and automatic retry with exponential backoff. + +## Flow + +```mermaid +sequenceDiagram + participant App as Application code + participant AL as activity_log_entries + participant WE as webhook_events (outbox) + participant D as Dispatcher + participant Sub as Subscriber endpoint + + App->>AL: INSERT (any domain action) + AL->>WE: Trigger copies row + pg_notify('api_notify') + D-->>WE: LISTEN / 30s poll fallback + D->>WE: SELECT … FOR UPDATE SKIP LOCKED (claim batch) + D->>Sub: HTTP POST CloudEvent (HMAC-signed) + alt 2xx + D->>WE: status = 'completed' + D->>webhook_subscriptions: reset consecutive_failures + else failure / timeout + D->>WE: requeue with run_at = NOW() + backoff + D->>webhook_subscriptions: increment consecutive_failures + note over D: auto-disable after 10 consecutive failures + end +``` + +## Key components + +| File | Responsibility | +| ---------------- | ------------------------------------------------------------------- | +| `dispatcher.go` | Outbox consumer: LISTEN/NOTIFY + poll, claim events, deliver, retry | +| `cloudevents.go` | Build CloudEvents 1.0 envelope; derive `type` from activity type | +| `signer.go` | HMAC-SHA256 payload signing (`X-Webhook-Signature` header) | +| `model.go` | Domain types; `MatchesEvent` subscription/event matching logic | +| `queries.go` | CRUD operations with authorisation | +| `dataloader.go` | Context-scoped DB + dispatcher access | + +## Database tables + +- **`webhook_subscriptions`** — registered endpoints (URL, secret, event_types, team scope) +- **`webhook_events`** — lightweight outbox; each row is just a reference (`activity_log_entries_id`) to the source event, inserted by a PostgreSQL trigger on `activity_log_entries`. No data duplication. +- **`webhook_deliveries`** — audit log of every delivery attempt + +## Event types + +Event types are driven by `activitylog.RegisterActivityType` calls throughout the codebase. +Every registered activity type is automatically available as a subscribable event type. Option functions allow customising descriptions, grouping, or scope. + +```go +// Any domain package's init(): +activitylog.RegisterActivityType( + "TEAM_MEMBER_ADDED", + activitylog.ActivityLogEntryActionAdded, + resourceType, + activitylog.WithDescription("A user was added to the team"), // Custom description + activitylog.WithGroup("Team"), // Custom UI grouping +) + +// Global/admin-only event types can be marked so team-scoped webhooks cannot subscribe to them: +activitylog.RegisterActivityType( + "RECONCILER_ENABLED", + action, + resourceType, + activitylog.GlobalOnly(), +) +``` + +The `webhookEventTypes` GraphQL query exposes the full catalogue with descriptions, groups, and `teamScoped` status. + +Subscription `event_types` accepts activity type names (e.g. `TEAM_MEMBER_ADDED`) or `*` for all events. If a team-scoped webhook tries to subscribe to a `GlobalOnly` event type, creation/update will fail validation. + +## CloudEvents type mapping + +The PostgreSQL trigger stores events as `RESOURCE_TYPE:ACTION` (e.g. `TEAM:ADDED`). +The dispatcher resolves this to an `ActivityLogActivityType` via `LookupActivityTypes`, then converts +it to a CloudEvents-spec type string: + +``` +TEAM_MEMBER_ADDED → io.nais.team.member.added +POSTGRES_DELETED → io.nais.postgres.deleted +``` + +## Retry schedule + +| Attempt | Delay | +| ------- | -------- | +| 1 | 1 min | +| 2 | 5 min | +| 3 | 15 min | +| 4 | 1 hour | +| 5 | 4 hours | +| 6 | 8 hours | +| 7 | 12 hours | + +After 7 failed attempts the event is marked `failed`. After 10 consecutive failures across any +events, the subscription is automatically disabled (`enabled = false`, `disabled_at` set). + +## Authorisation + +| Action | Allowed | +| ------------------- | ------------------------------------------ | +| Team-scoped webhook | Team owner | +| Global webhook | Admin (Go-level check, not a DB role) | +| Update / delete | Owner of the subscription's team, or admin | diff --git a/internal/webhook/cloudevents.go b/internal/webhook/cloudevents.go new file mode 100644 index 000000000..bb3059e7b --- /dev/null +++ b/internal/webhook/cloudevents.go @@ -0,0 +1,86 @@ +package webhook + +import ( + "encoding/json" + "strings" + "time" + + "github.com/google/uuid" + "github.com/nais/api/internal/activitylog" +) + +// CloudEvent represents a CloudEvents 1.0 envelope. +type CloudEvent struct { + SpecVersion string `json:"specversion"` + ID string `json:"id"` + Source string `json:"source"` + Type string `json:"type"` + Subject string `json:"subject,omitempty"` + Time string `json:"time"` + DataContentType string `json:"datacontenttype"` + Data json.RawMessage `json:"data"` +} + +// CloudEventData is the data payload within a CloudEvent. +type CloudEventData struct { + Actor string `json:"actor"` + ResourceType string `json:"resourceType"` + ResourceName string `json:"resourceName"` + TeamSlug *string `json:"teamSlug,omitempty"` + Environment *string `json:"environment,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// cloudEventTypeFromEvent returns the CloudEvents-spec type for an event. +// If the event has resolved ActivityTypes, the first one is used; otherwise the raw +// event type is lowercased and dot-separated (e.g. "ping" → "io.nais.ping"). +func cloudEventTypeFromEvent(event WebhookEvent) string { + if len(event.ActivityTypes) > 0 { + return activitylog.CloudEventType(activitylog.ActivityLogActivityType(event.ActivityTypes[0])) + } + // Synthetic events (e.g. "ping") — just prefix with io.nais. + lower := strings.ToLower(event.RawEventType) + dotted := strings.ReplaceAll(lower, "_", ".") + return "io.nais." + dotted +} + +// BuildCloudEvent creates a CloudEvents 1.0 envelope from a webhook event. +func BuildCloudEvent(source string, event WebhookEvent) ([]byte, error) { + var teamSlug *string + if event.TeamSlug != nil { + s := event.TeamSlug.String() + teamSlug = &s + } + + eventData := CloudEventData{ + Actor: event.Actor, + ResourceType: event.ResourceType, + ResourceName: event.ResourceName, + TeamSlug: teamSlug, + Environment: event.Environment, + Data: event.Data, + } + + dataBytes, err := json.Marshal(eventData) + if err != nil { + return nil, err + } + + subject := event.ResourceName + if event.TeamSlug != nil { + subject = event.TeamSlug.String() + "/" + event.ResourceName + } + + ce := CloudEvent{ + SpecVersion: "1.0", + ID: uuid.New().String(), + Source: source, + Type: cloudEventTypeFromEvent(event), + Subject: subject, + Time: time.Now().UTC().Format(time.RFC3339), + DataContentType: "application/json", + Data: dataBytes, + } + + return json.Marshal(ce) +} diff --git a/internal/webhook/dataloader.go b/internal/webhook/dataloader.go new file mode 100644 index 000000000..187a7f6cd --- /dev/null +++ b/internal/webhook/dataloader.go @@ -0,0 +1,73 @@ +package webhook + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/database" + "github.com/nais/api/internal/graph/loader" + "github.com/nais/api/internal/webhook/webhooksql" + "github.com/vikstrous/dataloadgen" +) + +type ctxKey int + +const loadersKey ctxKey = iota + +func NewLoaderContext(ctx context.Context, dbConn *pgxpool.Pool, dispatcher *Dispatcher) context.Context { + return context.WithValue(ctx, loadersKey, newLoaders(dbConn, dispatcher)) +} + +func fromContext(ctx context.Context) *loaders { + return ctx.Value(loadersKey).(*loaders) +} + +type loaders struct { + internalQuerier *webhooksql.Queries + subscriptionLoader *dataloadgen.Loader[uuid.UUID, *WebhookSubscription] + deliveryLoader *dataloadgen.Loader[uuid.UUID, *WebhookDelivery] + dispatcher *Dispatcher +} + +func newLoaders(dbConn *pgxpool.Pool, dispatcher *Dispatcher) *loaders { + db := webhooksql.New(dbConn) + + subLoader := &subscriptionDataloader{db: db} + delLoader := &deliveryDataloader{db: db} + + return &loaders{ + internalQuerier: db, + subscriptionLoader: dataloadgen.NewLoader(subLoader.get, loader.DefaultDataLoaderOptions...), + deliveryLoader: dataloadgen.NewLoader(delLoader.get, loader.DefaultDataLoaderOptions...), + dispatcher: dispatcher, + } +} + +type subscriptionDataloader struct { + db webhooksql.Querier +} + +func (l *subscriptionDataloader) get(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, []error) { + makeKey := func(obj *WebhookSubscription) uuid.UUID { return obj.UUID } + return loader.LoadModels(ctx, ids, l.db.ListSubscriptionsByIDs, toGraphSubscription, makeKey) +} + +type deliveryDataloader struct { + db webhooksql.Querier +} + +func (l *deliveryDataloader) get(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, []error) { + makeKey := func(obj *WebhookDelivery) uuid.UUID { return obj.UUID } + return loader.LoadModels(ctx, ids, l.db.ListDeliveriesByIDs, toGraphDelivery, makeKey) +} + +func db(ctx context.Context) *webhooksql.Queries { + l := fromContext(ctx) + + if tx := database.TransactionFromContext(ctx); tx != nil { + return l.internalQuerier.WithTx(tx) + } + + return l.internalQuerier +} diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go new file mode 100644 index 000000000..e2bfdf4d6 --- /dev/null +++ b/internal/webhook/dispatcher.go @@ -0,0 +1,294 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/database/notify" + "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/webhook/webhooksql" + "github.com/sirupsen/logrus" +) + +const ( + defaultTimeout = 10 * time.Second + eventBatchSize = 50 + pollInterval = 30 * time.Second + maxRetryCount = 7 // ~24h total with exponential backoff + disableThreshold = 10 // auto-disable after 10 consecutive failures + userAgentHeader = "Nais-API-Webhook/1.0" + signatureHeader = "X-Webhook-Signature" + contentTypeHeader = "application/cloudevents+json" +) + +// retryBackoffs defines how long to wait before retrying at each retry_count. +// Total span: ~24 hours. +var retryBackoffs = []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 15 * time.Minute, + 1 * time.Hour, + 4 * time.Hour, + 8 * time.Hour, + 12 * time.Hour, +} + +// Dispatcher processes webhook events from the outbox table and delivers them to subscribers. +type Dispatcher struct { + pool *pgxpool.Pool + notifier *notify.Notifier + log logrus.FieldLogger + source string + httpClient *http.Client +} + +// NewDispatcher creates a new webhook dispatcher that drains events from the outbox table. +func NewDispatcher(pool *pgxpool.Pool, notifier *notify.Notifier, source string, log logrus.FieldLogger) *Dispatcher { + return &Dispatcher{ + pool: pool, + notifier: notifier, + log: log.WithField("subsystem", "webhook_dispatcher"), + source: source, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +// Run starts the dispatcher. It listens for PG NOTIFY on "webhook_events" and +// periodically polls for unprocessed events. Blocks until ctx is cancelled. +func (d *Dispatcher) Run(ctx context.Context) { + ch := d.notifier.Listen("webhook_events") + + // Process any events that were queued before we started + d.drainOutbox(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ch: + d.drainOutbox(ctx) + case <-time.After(pollInterval): + // Safety net: poll periodically in case a notification was missed + // or to pick up events whose run_at has arrived + d.drainOutbox(ctx) + } + } +} + +func (d *Dispatcher) drainOutbox(ctx context.Context) { + q := webhooksql.New(d.pool) + + for { + events, err := q.ClaimPendingEvents(ctx, eventBatchSize) + if err != nil { + d.log.WithError(err).Error("claiming pending webhook events") + return + } + + if len(events) == 0 { + return + } + + for _, evt := range events { + d.processEvent(ctx, q, &evt.WebhookEvent, &evt.ActivityLogEntry) + } + } +} + +func (d *Dispatcher) processEvent(ctx context.Context, q *webhooksql.Queries, evt *webhooksql.WebhookEvent, a *webhooksql.ActivityLogEntry) { + subs, err := q.ListEnabledSubscriptions(ctx) + if err != nil { + d.log.WithError(err).Error("listing enabled webhook subscriptions") + return + } + + // Resolve "RESOURCE_TYPE:ACTION" → ActivityLogActivityType names + // (e.g. ResourceType="TEAM", Action="ADDED" → ["TEAM_MEMBER_ADDED"]). + rawEventType := a.ResourceType + ":" + a.Action + resolved := activitylog.LookupActivityTypes(a.ResourceType, a.Action) + activityTypes := make([]string, len(resolved)) + for i, at := range resolved { + activityTypes[i] = string(at) + } + // Fall back to raw type if no mapping is registered, so the event is still deliverable. + if len(activityTypes) == 0 { + activityTypes = []string{rawEventType} + } + + var teamSlug *slug.Slug + if a.TeamSlug != nil { + s := slug.Slug(*a.TeamSlug) + teamSlug = &s + } + + event := WebhookEvent{ + ActivityTypes: activityTypes, + RawEventType: rawEventType, + TeamSlug: teamSlug, + Actor: a.Actor, + ResourceType: a.ResourceType, + ResourceName: a.ResourceName, + Environment: a.Environment, + Data: a.Data, + } + + payload, err := BuildCloudEvent(d.source, event) + if err != nil { + d.log.WithError(err).Error("building CloudEvent payload") + return + } + + // Use the first resolved activity type as the delivery event type label. + deliveryEventType := activityTypes[0] + + anyFailed := false + for _, sub := range subs { + graphSub := toGraphSubscription(sub) + if !graphSub.MatchesEvent(event) { + continue + } + + success := d.deliver(ctx, q, sub, deliveryEventType, payload) + if !success { + anyFailed = true + } + } + + // If any delivery failed, requeue with exponential backoff or mark as permanently failed. + if anyFailed { + nextRetry := int(evt.RetryCount) + 1 + if nextRetry <= maxRetryCount { + backoff := retryBackoffs[min(nextRetry-1, len(retryBackoffs)-1)] + runAt := time.Now().Add(backoff) + if err := q.RequeueEvent(ctx, webhooksql.RequeueEventParams{ + ID: evt.ID, + RetryCount: int32(nextRetry), + RunAt: pgtype.Timestamptz{Time: runAt, Valid: true}, + }); err != nil { + d.log.WithError(err).Error("requeueing webhook event") + } + } else { + if err := q.MarkEventFailed(ctx, evt.ID); err != nil { + d.log.WithError(err).Error("marking webhook event as failed") + } + } + } +} + +func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *webhooksql.WebhookSubscription, eventType string, payload []byte) bool { + signature := SignPayload(sub.Secret, payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Url, bytes.NewReader(payload)) + if err != nil { + d.log.WithError(err).WithField("subscription_id", sub.ID).Error("creating HTTP request") + return false + } + + req.Header.Set("Content-Type", contentTypeHeader) + req.Header.Set("User-Agent", userAgentHeader) + req.Header.Set(signatureHeader, signature) + + start := time.Now() + resp, err := d.httpClient.Do(req) + durationMs := int32(time.Since(start).Milliseconds()) + + var ( + responseStatus *int32 + responseBody *string + success bool + ) + + if err != nil { + errMsg := err.Error() + responseBody = &errMsg + } else { + defer resp.Body.Close() + status := int32(resp.StatusCode) + responseStatus = &status + success = resp.StatusCode >= 200 && resp.StatusCode < 300 + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*10)) // 10KB max + if readErr == nil { + bodyStr := string(body) + responseBody = &bodyStr + } + } + + // Record delivery attempt + if _, recordErr := q.CreateDelivery(ctx, webhooksql.CreateDeliveryParams{ + SubscriptionID: sub.ID, + EventType: eventType, + RequestBody: payload, + ResponseStatus: responseStatus, + ResponseBody: responseBody, + DurationMs: durationMs, + Success: success, + }); recordErr != nil { + d.log.WithError(recordErr).Error("recording webhook delivery") + } + + // Track consecutive failures for auto-disable + if success { + if sub.ConsecutiveFailures > 0 { + if err := q.ResetConsecutiveFailures(ctx, sub.ID); err != nil { + d.log.WithError(err).Error("resetting consecutive failures") + } + } + } else { + updated, err := q.IncrementConsecutiveFailures(ctx, sub.ID) + if err != nil { + d.log.WithError(err).Error("incrementing consecutive failures") + } else if updated.ConsecutiveFailures >= disableThreshold { + d.log.WithField("subscription_id", sub.ID).Warn("auto-disabling webhook subscription after repeated failures") + if err := q.DisableSubscription(ctx, sub.ID); err != nil { + d.log.WithError(err).Error("disabling webhook subscription") + } + } + } + + return success +} + +// SerializeEventData is a helper to serialize event data to JSON for the webhook payload. +func SerializeEventData(data any) ([]byte, error) { + if data == nil { + return nil, nil + } + return json.Marshal(data) +} + +// Ping sends a test ping payload to the given subscription and records the delivery. +// Used to verify connectivity when a new webhook is registered. +func (d *Dispatcher) Ping(ctx context.Context, sub *WebhookSubscription) error { + pingEvent := WebhookEvent{ + RawEventType: "ping", + TeamSlug: sub.TeamSlug, + Actor: "system", + ResourceType: "webhook", + ResourceName: sub.UUID.String(), + } + + payload, err := BuildCloudEvent(d.source, pingEvent) + if err != nil { + return fmt.Errorf("building ping CloudEvent: %w", err) + } + + q := webhooksql.New(d.pool) + dbSub := &webhooksql.WebhookSubscription{ + ID: sub.UUID, + Url: sub.URL, + Secret: sub.Secret, + } + d.deliver(ctx, q, dbSub, "ping", payload) + return nil +} diff --git a/internal/webhook/model.go b/internal/webhook/model.go new file mode 100644 index 000000000..989f973b1 --- /dev/null +++ b/internal/webhook/model.go @@ -0,0 +1,136 @@ +package webhook + +import ( + "time" + + "github.com/google/uuid" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" +) + +type WebhookSubscription struct { + UUID uuid.UUID `json:"id"` + TeamSlug *slug.Slug `json:"teamSlug,omitempty"` + URL string `json:"url"` + Secret string `json:"-"` + EventTypes []string `json:"eventTypes"` + Enabled bool `json:"enabled"` + ConsecutiveFailures int `json:"consecutiveFailures"` + DisabledAt *time.Time `json:"disabledAt,omitempty"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (WebhookSubscription) IsNode() {} + +func (w WebhookSubscription) GetID() ident.Ident { + return newSubscriptionIdent(w.UUID) +} + +type ( + WebhookSubscriptionConnection = pagination.Connection[*WebhookSubscription] + WebhookSubscriptionEdge = pagination.Edge[*WebhookSubscription] +) + +type WebhookDelivery struct { + UUID uuid.UUID `json:"id"` + SubscriptionID uuid.UUID `json:"subscriptionID"` + EventType string `json:"eventType"` + RequestBody string `json:"requestBody"` + ResponseStatus *int `json:"responseStatus,omitempty"` + ResponseBody *string `json:"responseBody,omitempty"` + DurationMs int `json:"durationMs"` + Success bool `json:"success"` + CreatedAt time.Time `json:"createdAt"` +} + +func (WebhookDelivery) IsNode() {} + +func (w WebhookDelivery) GetID() ident.Ident { + return newDeliveryIdent(w.UUID) +} + +type ( + WebhookDeliveryConnection = pagination.Connection[*WebhookDelivery] + WebhookDeliveryEdge = pagination.Edge[*WebhookDelivery] +) + +type CreateWebhookInput struct { + TeamSlug *slug.Slug `json:"teamSlug,omitempty"` + URL string `json:"url"` + Secret string `json:"secret"` + EventTypes []string `json:"eventTypes"` +} + +type CreateWebhookPayload struct { + Webhook *WebhookSubscription `json:"webhook"` +} + +type UpdateWebhookInput struct { + ID ident.Ident `json:"id"` + URL *string `json:"url,omitempty"` + Secret *string `json:"secret,omitempty"` + EventTypes []string `json:"eventTypes,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type UpdateWebhookPayload struct { + Webhook *WebhookSubscription `json:"webhook"` +} + +type DeleteWebhookInput struct { + ID ident.Ident `json:"id"` +} + +type DeleteWebhookPayload struct { + WebhookID ident.Ident `json:"webhookID"` +} + +// WebhookEvent is an internal event passed to the dispatcher when an activity log entry is created. +type WebhookEvent struct { + // ActivityTypes holds the resolved ActivityLogActivityType values for this event + // (e.g. ["TEAM_MEMBER_ADDED"]). Populated by the dispatcher via LookupActivityTypes. + // For synthetic events such as "ping", this may be left nil. + ActivityTypes []string + // RawEventType is the raw "RESOURCE_TYPE:ACTION" string stored in the outbox. + RawEventType string + TeamSlug *slug.Slug + Actor string + ResourceType string + ResourceName string + Environment *string + Data []byte +} + +func (w *WebhookSubscription) MatchesEvent(event WebhookEvent) bool { + if !w.Enabled { + return false + } + + // Global webhooks (no team) match all events. + // Team-scoped webhooks only match events for that team. + if w.TeamSlug != nil { + if event.TeamSlug == nil || *w.TeamSlug != *event.TeamSlug { + return false + } + } + + for _, subType := range w.EventTypes { + if subType == "*" { + return true + } + for _, at := range event.ActivityTypes { + if subType == at { + return true + } + } + } + + return false +} + +// Node interface compatibility +func (w WebhookSubscription) ID() ident.Ident { return w.GetID() } +func (w WebhookDelivery) ID() ident.Ident { return w.GetID() } diff --git a/internal/webhook/node.go b/internal/webhook/node.go new file mode 100644 index 000000000..8d55d8dba --- /dev/null +++ b/internal/webhook/node.go @@ -0,0 +1,46 @@ +package webhook + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/nais/api/internal/graph/ident" +) + +type identType int + +const ( + identWebhookSubscription identType = iota + identWebhookDelivery +) + +func init() { + ident.RegisterIdentType(identWebhookSubscription, "WHS", GetSubscriptionByIdent) + ident.RegisterIdentType(identWebhookDelivery, "WHD", GetDeliveryByIdent) +} + +func newSubscriptionIdent(id uuid.UUID) ident.Ident { + return ident.NewIdent(identWebhookSubscription, id.String()) +} + +func newDeliveryIdent(id uuid.UUID) ident.Ident { + return ident.NewIdent(identWebhookDelivery, id.String()) +} + +func parseSubscriptionIdent(id ident.Ident) (uuid.UUID, error) { + parts := id.Parts() + if len(parts) != 1 { + return uuid.Nil, fmt.Errorf("invalid webhook subscription ident") + } + + return uuid.Parse(parts[0]) +} + +func parseDeliveryIdent(id ident.Ident) (uuid.UUID, error) { + parts := id.Parts() + if len(parts) != 1 { + return uuid.Nil, fmt.Errorf("invalid webhook delivery ident") + } + + return uuid.Parse(parts[0]) +} diff --git a/internal/webhook/queries.go b/internal/webhook/queries.go new file mode 100644 index 000000000..14b72011a --- /dev/null +++ b/internal/webhook/queries.go @@ -0,0 +1,264 @@ +package webhook + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" + "github.com/nais/api/internal/webhook/webhooksql" +) + +func GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + return fromContext(ctx).subscriptionLoader.Load(ctx, id) +} + +func GetSubscriptionByIdent(ctx context.Context, id ident.Ident) (*WebhookSubscription, error) { + uid, err := parseSubscriptionIdent(id) + if err != nil { + return nil, err + } + return GetSubscription(ctx, uid) +} + +func GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) { + return fromContext(ctx).deliveryLoader.Load(ctx, id) +} + +func GetDeliveryByIdent(ctx context.Context, id ident.Ident) (*WebhookDelivery, error) { + uid, err := parseDeliveryIdent(id) + if err != nil { + return nil, err + } + return GetDelivery(ctx, uid) +} + +func Create(ctx context.Context, input CreateWebhookInput) (*CreateWebhookPayload, error) { + actor := authz.ActorFromContext(ctx) + + if err := authz.CanCreateWebhook(ctx, input.TeamSlug); err != nil { + return nil, err + } + + if err := validateEventTypes(input.TeamSlug, input.EventTypes); err != nil { + return nil, err + } + + row, err := db(ctx).CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + TeamSlug: input.TeamSlug, + Url: input.URL, + Secret: input.Secret, + EventTypes: input.EventTypes, + CreatedBy: actor.User.Identity(), + }) + if err != nil { + return nil, fmt.Errorf("creating webhook subscription: %w", err) + } + + sub := toGraphSubscription(row) + + if d := fromContext(ctx).dispatcher; d != nil { + // Ping errors are non-fatal and already recorded as a delivery entry + _ = d.Ping(ctx, sub) + } + + return &CreateWebhookPayload{ + Webhook: sub, + }, nil +} + +func Update(ctx context.Context, input UpdateWebhookInput) (*UpdateWebhookPayload, error) { + uid, err := parseSubscriptionIdent(input.ID) + if err != nil { + return nil, err + } + + existing, err := GetSubscription(ctx, uid) + if err != nil { + return nil, err + } + + if err := authz.CanUpdateWebhook(ctx, existing.TeamSlug); err != nil { + return nil, err + } + + if input.EventTypes != nil { + if err := validateEventTypes(existing.TeamSlug, input.EventTypes); err != nil { + return nil, err + } + } + + row, err := db(ctx).UpdateSubscription(ctx, webhooksql.UpdateSubscriptionParams{ + ID: uid, + Url: input.URL, + Secret: input.Secret, + EventTypes: input.EventTypes, + Enabled: input.Enabled, + }) + if err != nil { + return nil, fmt.Errorf("updating webhook subscription: %w", err) + } + + return &UpdateWebhookPayload{ + Webhook: toGraphSubscription(row), + }, nil +} + +func Delete(ctx context.Context, input DeleteWebhookInput) (*DeleteWebhookPayload, error) { + uid, err := parseSubscriptionIdent(input.ID) + if err != nil { + return nil, err + } + + existing, err := GetSubscription(ctx, uid) + if err != nil { + return nil, err + } + + if err := authz.CanDeleteWebhook(ctx, existing.TeamSlug); err != nil { + return nil, err + } + + if err := db(ctx).DeleteSubscription(ctx, uid); err != nil { + return nil, fmt.Errorf("deleting webhook subscription: %w", err) + } + + return &DeleteWebhookPayload{ + WebhookID: input.ID, + }, nil +} + +func ListForTeam(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagination) (*WebhookSubscriptionConnection, error) { + q := db(ctx) + + rows, err := q.ListSubscriptionsForTeam(ctx, webhooksql.ListSubscriptionsForTeamParams{ + TeamSlug: &teamSlug, + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListSubscriptionsForTeamRow) *WebhookSubscription { + return toGraphSubscription(&row.WebhookSubscription) + }), nil +} + +func ListGlobal(ctx context.Context, page *pagination.Pagination) (*WebhookSubscriptionConnection, error) { + q := db(ctx) + + rows, err := q.ListGlobalSubscriptions(ctx, webhooksql.ListGlobalSubscriptionsParams{ + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListGlobalSubscriptionsRow) *WebhookSubscription { + return toGraphSubscription(&row.WebhookSubscription) + }), nil +} + +func ListDeliveries(ctx context.Context, subscriptionID uuid.UUID, page *pagination.Pagination) (*WebhookDeliveryConnection, error) { + q := db(ctx) + + rows, err := q.ListDeliveriesForSubscription(ctx, webhooksql.ListDeliveriesForSubscriptionParams{ + SubscriptionID: subscriptionID, + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListDeliveriesForSubscriptionRow) *WebhookDelivery { + return toGraphDelivery(&row.WebhookDelivery) + }), nil +} + +func toGraphSubscription(row *webhooksql.WebhookSubscription) *WebhookSubscription { + var disabledAt *time.Time + if row.DisabledAt.Valid { + disabledAt = &row.DisabledAt.Time + } + + return &WebhookSubscription{ + UUID: row.ID, + TeamSlug: row.TeamSlug, + URL: row.Url, + Secret: row.Secret, + EventTypes: row.EventTypes, + Enabled: row.Enabled, + ConsecutiveFailures: int(row.ConsecutiveFailures), + DisabledAt: disabledAt, + CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func toGraphDelivery(row *webhooksql.WebhookDelivery) *WebhookDelivery { + body := string(row.RequestBody) + + var respStatus *int + if row.ResponseStatus != nil { + s := int(*row.ResponseStatus) + respStatus = &s + } + + return &WebhookDelivery{ + UUID: row.ID, + SubscriptionID: row.SubscriptionID, + EventType: row.EventType, + RequestBody: body, + ResponseStatus: respStatus, + ResponseBody: row.ResponseBody, + DurationMs: int(row.DurationMs), + Success: row.Success, + CreatedAt: row.CreatedAt.Time, + } +} + +// MaskedSecret returns the secret with all but the last 4 characters masked. +func MaskedSecret(secret string) string { + if len(secret) <= 4 { + return "****" + } + return "****" + secret[len(secret)-4:] +} + +func validateEventTypes(teamSlug *slug.Slug, eventTypes []string) error { + for _, et := range eventTypes { + if !activitylog.IsValidActivityType(et) { + return fmt.Errorf("invalid event type: %q", et) + } + if teamSlug != nil && et != "*" { + if !activitylog.IsTeamScoped(activitylog.ActivityLogActivityType(et)) { + return fmt.Errorf("event type %q is global-only and cannot be subscribed to by a team-scoped webhook", et) + } + } + } + return nil +} diff --git a/internal/webhook/queries/webhook.sql b/internal/webhook/queries/webhook.sql new file mode 100644 index 000000000..1013a698e --- /dev/null +++ b/internal/webhook/queries/webhook.sql @@ -0,0 +1,248 @@ +-- name: CreateSubscription :one +INSERT INTO + webhook_subscriptions (team_slug, url, secret, event_types, created_by) +VALUES + ( + @team_slug, + @url, + @secret, + @event_types, + @created_by + ) +RETURNING + * +; + +-- name: UpdateSubscription :one +UPDATE webhook_subscriptions +SET + url = COALESCE(sqlc.narg(url), url), + secret = COALESCE(sqlc.narg(secret), secret), + event_types = COALESCE(sqlc.narg(event_types), event_types), + enabled = COALESCE(sqlc.narg(enabled), enabled) +WHERE + id = @id +RETURNING + * +; + +-- name: DeleteSubscription :exec +DELETE FROM webhook_subscriptions +WHERE + id = @id +; + +-- name: GetSubscription :one +SELECT + * +FROM + webhook_subscriptions +WHERE + id = @id +; + +-- name: ListSubscriptionsByIDs :many +SELECT + * +FROM + webhook_subscriptions +WHERE + id = ANY (@ids::UUID[]) +ORDER BY + created_at DESC +; + +-- name: ListSubscriptionsForTeam :many +SELECT + sqlc.embed(webhook_subscriptions), + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug = @team_slug +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListGlobalSubscriptions :many +SELECT + sqlc.embed(webhook_subscriptions), + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug IS NULL +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListEnabledSubscriptions :many +SELECT + * +FROM + webhook_subscriptions +WHERE + enabled = TRUE +ORDER BY + created_at DESC +; + +-- name: IncrementConsecutiveFailures :one +UPDATE webhook_subscriptions +SET + consecutive_failures = consecutive_failures + 1 +WHERE + id = @id +RETURNING + * +; + +-- name: ResetConsecutiveFailures :exec +UPDATE webhook_subscriptions +SET + consecutive_failures = 0 +WHERE + id = @id +; + +-- name: DisableSubscription :exec +UPDATE webhook_subscriptions +SET + enabled = FALSE, + disabled_at = NOW() +WHERE + id = @id +; + +-- name: CreateDelivery :one +INSERT INTO + webhook_deliveries ( + subscription_id, + event_type, + request_body, + response_status, + response_body, + duration_ms, + success + ) +VALUES + ( + @subscription_id, + @event_type, + @request_body, + @response_status, + @response_body, + @duration_ms, + @success + ) +RETURNING + * +; + +-- name: GetDelivery :one +SELECT + * +FROM + webhook_deliveries +WHERE + id = @id +; + +-- name: ListDeliveriesByIDs :many +SELECT + * +FROM + webhook_deliveries +WHERE + id = ANY (@ids::UUID[]) +ORDER BY + created_at DESC +; + +-- name: ListDeliveriesForSubscription :many +SELECT + sqlc.embed(webhook_deliveries), + COUNT(*) OVER () AS total_count +FROM + webhook_deliveries +WHERE + subscription_id = @subscription_id +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: PruneDeliveries :exec +DELETE FROM webhook_deliveries +WHERE + created_at < @before +; + +-- name: ClaimPendingEvents :many +WITH + updated_events AS ( + UPDATE webhook_events + SET + status = 'completed' + WHERE + id IN ( + SELECT + id + FROM + webhook_events + WHERE + status = 'pending' + AND run_at <= NOW() + ORDER BY + run_at ASC + LIMIT + @batch_size + FOR UPDATE + SKIP LOCKED + ) + RETURNING + * + ) +SELECT + sqlc.embed(webhook_events), + sqlc.embed(activity_log_entries) +FROM + updated_events webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +; + +-- name: RequeueEvent :exec +UPDATE webhook_events +SET + status = 'pending', + retry_count = @retry_count, + run_at = @run_at +WHERE + id = @id +; + +-- name: MarkEventFailed :exec +UPDATE webhook_events +SET + status = 'failed' +WHERE + id = @id +; + +-- name: PruneOldEvents :exec +DELETE FROM webhook_events +WHERE + created_at < @before + AND status IN ('completed', 'failed') +; diff --git a/internal/webhook/signer.go b/internal/webhook/signer.go new file mode 100644 index 000000000..8164dca06 --- /dev/null +++ b/internal/webhook/signer.go @@ -0,0 +1,14 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// SignPayload computes an HMAC-SHA256 signature of the payload using the given secret. +func SignPayload(secret string, payload []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/webhook/webhooksql/db.go b/internal/webhook/webhooksql/db.go new file mode 100644 index 000000000..f57c68ec3 --- /dev/null +++ b/internal/webhook/webhooksql/db.go @@ -0,0 +1,30 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/webhook/webhooksql/models.go b/internal/webhook/webhooksql/models.go new file mode 100644 index 000000000..26b21b6e7 --- /dev/null +++ b/internal/webhook/webhooksql/models.go @@ -0,0 +1,120 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "database/sql/driver" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/slug" +) + +type WebhookEventStatus string + +const ( + WebhookEventStatusPending WebhookEventStatus = "pending" + WebhookEventStatusCompleted WebhookEventStatus = "completed" + WebhookEventStatusFailed WebhookEventStatus = "failed" +) + +func (e *WebhookEventStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WebhookEventStatus(s) + case string: + *e = WebhookEventStatus(s) + default: + return fmt.Errorf("unsupported scan type for WebhookEventStatus: %T", src) + } + return nil +} + +type NullWebhookEventStatus struct { + WebhookEventStatus WebhookEventStatus + Valid bool // Valid is true if WebhookEventStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWebhookEventStatus) Scan(value interface{}) error { + if value == nil { + ns.WebhookEventStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WebhookEventStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWebhookEventStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WebhookEventStatus), nil +} + +func (e WebhookEventStatus) Valid() bool { + switch e { + case WebhookEventStatusPending, + WebhookEventStatusCompleted, + WebhookEventStatusFailed: + return true + } + return false +} + +func AllWebhookEventStatusValues() []WebhookEventStatus { + return []WebhookEventStatus{ + WebhookEventStatusPending, + WebhookEventStatusCompleted, + WebhookEventStatusFailed, + } +} + +type ActivityLogEntry struct { + ID uuid.UUID + CreatedAt pgtype.Timestamptz + Actor string + Action string + ResourceType string + ResourceName string + TeamSlug *slug.Slug + Data []byte + Environment *string +} + +type WebhookDelivery struct { + ID uuid.UUID + SubscriptionID uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool + CreatedAt pgtype.Timestamptz +} + +type WebhookEvent struct { + ID uuid.UUID + ActivityLogEntriesID uuid.UUID + Status WebhookEventStatus + RetryCount int32 + RunAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz +} + +type WebhookSubscription struct { + ID uuid.UUID + TeamSlug *slug.Slug + Url string + Secret string + EventTypes []string + Enabled bool + ConsecutiveFailures int32 + DisabledAt pgtype.Timestamptz + CreatedBy string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/webhook/webhooksql/querier.go b/internal/webhook/webhooksql/querier.go new file mode 100644 index 000000000..757965b0f --- /dev/null +++ b/internal/webhook/webhooksql/querier.go @@ -0,0 +1,35 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + ClaimPendingEvents(ctx context.Context, batchSize int32) ([]*ClaimPendingEventsRow, error) + CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) + CreateSubscription(ctx context.Context, arg CreateSubscriptionParams) (*WebhookSubscription, error) + DeleteSubscription(ctx context.Context, id uuid.UUID) error + DisableSubscription(ctx context.Context, id uuid.UUID) error + GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) + GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) + IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) + ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, error) + ListDeliveriesForSubscription(ctx context.Context, arg ListDeliveriesForSubscriptionParams) ([]*ListDeliveriesForSubscriptionRow, error) + ListEnabledSubscriptions(ctx context.Context) ([]*WebhookSubscription, error) + ListGlobalSubscriptions(ctx context.Context, arg ListGlobalSubscriptionsParams) ([]*ListGlobalSubscriptionsRow, error) + ListSubscriptionsByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, error) + ListSubscriptionsForTeam(ctx context.Context, arg ListSubscriptionsForTeamParams) ([]*ListSubscriptionsForTeamRow, error) + MarkEventFailed(ctx context.Context, id uuid.UUID) error + PruneDeliveries(ctx context.Context, before pgtype.Timestamptz) error + PruneOldEvents(ctx context.Context, before pgtype.Timestamptz) error + RequeueEvent(ctx context.Context, arg RequeueEventParams) error + ResetConsecutiveFailures(ctx context.Context, id uuid.UUID) error + UpdateSubscription(ctx context.Context, arg UpdateSubscriptionParams) (*WebhookSubscription, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/webhook/webhooksql/webhook.sql.go b/internal/webhook/webhooksql/webhook.sql.go new file mode 100644 index 000000000..5c0b2c274 --- /dev/null +++ b/internal/webhook/webhooksql/webhook.sql.go @@ -0,0 +1,722 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: webhook.sql + +package webhooksql + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/slug" +) + +const claimPendingEvents = `-- name: ClaimPendingEvents :many +WITH updated_events AS ( + UPDATE webhook_events + SET + status = 'completed' + WHERE + id IN ( + SELECT + id + FROM + webhook_events + WHERE + status = 'pending' + AND run_at <= NOW() + ORDER BY + run_at ASC + LIMIT + $1 + FOR UPDATE + SKIP LOCKED + ) + RETURNING + id, activity_log_entries_id, status, retry_count, run_at, created_at +) +SELECT + webhook_events.id, webhook_events.activity_log_entries_id, webhook_events.status, webhook_events.retry_count, webhook_events.run_at, webhook_events.created_at, + activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment +FROM + updated_events webhook_events +JOIN + activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +` + +type ClaimPendingEventsRow struct { + WebhookEvent WebhookEvent + ActivityLogEntry ActivityLogEntry +} + +func (q *Queries) ClaimPendingEvents(ctx context.Context, batchSize int32) ([]*ClaimPendingEventsRow, error) { + rows, err := q.db.Query(ctx, claimPendingEvents, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ClaimPendingEventsRow{} + for rows.Next() { + var i ClaimPendingEventsRow + if err := rows.Scan( + &i.WebhookEvent.ID, + &i.WebhookEvent.ActivityLogEntriesID, + &i.WebhookEvent.Status, + &i.WebhookEvent.RetryCount, + &i.WebhookEvent.RunAt, + &i.WebhookEvent.CreatedAt, + &i.ActivityLogEntry.ID, + &i.ActivityLogEntry.CreatedAt, + &i.ActivityLogEntry.Actor, + &i.ActivityLogEntry.Action, + &i.ActivityLogEntry.ResourceType, + &i.ActivityLogEntry.ResourceName, + &i.ActivityLogEntry.TeamSlug, + &i.ActivityLogEntry.Data, + &i.ActivityLogEntry.Environment, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const createDelivery = `-- name: CreateDelivery :one +INSERT INTO + webhook_deliveries ( + subscription_id, + event_type, + request_body, + response_status, + response_body, + duration_ms, + success + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 + ) +RETURNING + id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +` + +type CreateDeliveryParams struct { + SubscriptionID uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool +} + +func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) { + row := q.db.QueryRow(ctx, createDelivery, + arg.SubscriptionID, + arg.EventType, + arg.RequestBody, + arg.ResponseStatus, + arg.ResponseBody, + arg.DurationMs, + arg.Success, + ) + var i WebhookDelivery + err := row.Scan( + &i.ID, + &i.SubscriptionID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ) + return &i, err +} + +const createSubscription = `-- name: CreateSubscription :one +INSERT INTO + webhook_subscriptions (team_slug, url, secret, event_types, created_by) +VALUES + ( + $1, + $2, + $3, + $4, + $5 + ) +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +type CreateSubscriptionParams struct { + TeamSlug *slug.Slug + Url string + Secret string + EventTypes []string + CreatedBy string +} + +func (q *Queries) CreateSubscription(ctx context.Context, arg CreateSubscriptionParams) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, createSubscription, + arg.TeamSlug, + arg.Url, + arg.Secret, + arg.EventTypes, + arg.CreatedBy, + ) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const deleteSubscription = `-- name: DeleteSubscription :exec +DELETE FROM webhook_subscriptions +WHERE + id = $1 +` + +func (q *Queries) DeleteSubscription(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteSubscription, id) + return err +} + +const disableSubscription = `-- name: DisableSubscription :exec +UPDATE webhook_subscriptions +SET + enabled = FALSE, + disabled_at = NOW() +WHERE + id = $1 +` + +func (q *Queries) DisableSubscription(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, disableSubscription, id) + return err +} + +const getDelivery = `-- name: GetDelivery :one +SELECT + id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +FROM + webhook_deliveries +WHERE + id = $1 +` + +func (q *Queries) GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) { + row := q.db.QueryRow(ctx, getDelivery, id) + var i WebhookDelivery + err := row.Scan( + &i.ID, + &i.SubscriptionID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ) + return &i, err +} + +const getSubscription = `-- name: GetSubscription :one +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + id = $1 +` + +func (q *Queries) GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, getSubscription, id) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const incrementConsecutiveFailures = `-- name: IncrementConsecutiveFailures :one +UPDATE webhook_subscriptions +SET + consecutive_failures = consecutive_failures + 1 +WHERE + id = $1 +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +func (q *Queries) IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, incrementConsecutiveFailures, id) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const listDeliveriesByIDs = `-- name: ListDeliveriesByIDs :many +SELECT + id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +FROM + webhook_deliveries +WHERE + id = ANY ($1::UUID[]) +ORDER BY + created_at DESC +` + +func (q *Queries) ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, error) { + rows, err := q.db.Query(ctx, listDeliveriesByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookDelivery{} + for rows.Next() { + var i WebhookDelivery + if err := rows.Scan( + &i.ID, + &i.SubscriptionID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDeliveriesForSubscription = `-- name: ListDeliveriesForSubscription :many +SELECT + webhook_deliveries.id, webhook_deliveries.subscription_id, webhook_deliveries.event_type, webhook_deliveries.request_body, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.duration_ms, webhook_deliveries.success, webhook_deliveries.created_at, + COUNT(*) OVER () AS total_count +FROM + webhook_deliveries +WHERE + subscription_id = $1 +ORDER BY + created_at DESC +LIMIT + $3 +OFFSET + $2 +` + +type ListDeliveriesForSubscriptionParams struct { + SubscriptionID uuid.UUID + Offset int32 + Limit int32 +} + +type ListDeliveriesForSubscriptionRow struct { + WebhookDelivery WebhookDelivery + TotalCount int64 +} + +func (q *Queries) ListDeliveriesForSubscription(ctx context.Context, arg ListDeliveriesForSubscriptionParams) ([]*ListDeliveriesForSubscriptionRow, error) { + rows, err := q.db.Query(ctx, listDeliveriesForSubscription, arg.SubscriptionID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListDeliveriesForSubscriptionRow{} + for rows.Next() { + var i ListDeliveriesForSubscriptionRow + if err := rows.Scan( + &i.WebhookDelivery.ID, + &i.WebhookDelivery.SubscriptionID, + &i.WebhookDelivery.EventType, + &i.WebhookDelivery.RequestBody, + &i.WebhookDelivery.ResponseStatus, + &i.WebhookDelivery.ResponseBody, + &i.WebhookDelivery.DurationMs, + &i.WebhookDelivery.Success, + &i.WebhookDelivery.CreatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledSubscriptions = `-- name: ListEnabledSubscriptions :many +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + enabled = TRUE +ORDER BY + created_at DESC +` + +func (q *Queries) ListEnabledSubscriptions(ctx context.Context) ([]*WebhookSubscription, error) { + rows, err := q.db.Query(ctx, listEnabledSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookSubscription{} + for rows.Next() { + var i WebhookSubscription + if err := rows.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGlobalSubscriptions = `-- name: ListGlobalSubscriptions :many +SELECT + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug IS NULL +ORDER BY + created_at DESC +LIMIT + $2 +OFFSET + $1 +` + +type ListGlobalSubscriptionsParams struct { + Offset int32 + Limit int32 +} + +type ListGlobalSubscriptionsRow struct { + WebhookSubscription WebhookSubscription + TotalCount int64 +} + +func (q *Queries) ListGlobalSubscriptions(ctx context.Context, arg ListGlobalSubscriptionsParams) ([]*ListGlobalSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, listGlobalSubscriptions, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListGlobalSubscriptionsRow{} + for rows.Next() { + var i ListGlobalSubscriptionsRow + if err := rows.Scan( + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubscriptionsByIDs = `-- name: ListSubscriptionsByIDs :many +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + id = ANY ($1::UUID[]) +ORDER BY + created_at DESC +` + +func (q *Queries) ListSubscriptionsByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, error) { + rows, err := q.db.Query(ctx, listSubscriptionsByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookSubscription{} + for rows.Next() { + var i WebhookSubscription + if err := rows.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubscriptionsForTeam = `-- name: ListSubscriptionsForTeam :many +SELECT + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug = $1 +ORDER BY + created_at DESC +LIMIT + $3 +OFFSET + $2 +` + +type ListSubscriptionsForTeamParams struct { + TeamSlug *slug.Slug + Offset int32 + Limit int32 +} + +type ListSubscriptionsForTeamRow struct { + WebhookSubscription WebhookSubscription + TotalCount int64 +} + +func (q *Queries) ListSubscriptionsForTeam(ctx context.Context, arg ListSubscriptionsForTeamParams) ([]*ListSubscriptionsForTeamRow, error) { + rows, err := q.db.Query(ctx, listSubscriptionsForTeam, arg.TeamSlug, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListSubscriptionsForTeamRow{} + for rows.Next() { + var i ListSubscriptionsForTeamRow + if err := rows.Scan( + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markEventFailed = `-- name: MarkEventFailed :exec +UPDATE webhook_events +SET + status = 'failed' +WHERE + id = $1 +` + +func (q *Queries) MarkEventFailed(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, markEventFailed, id) + return err +} + +const pruneDeliveries = `-- name: PruneDeliveries :exec +DELETE FROM webhook_deliveries +WHERE + created_at < $1 +` + +func (q *Queries) PruneDeliveries(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneDeliveries, before) + return err +} + +const pruneOldEvents = `-- name: PruneOldEvents :exec +DELETE FROM webhook_events +WHERE + created_at < $1 + AND status IN ('completed', 'failed') +` + +func (q *Queries) PruneOldEvents(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneOldEvents, before) + return err +} + +const requeueEvent = `-- name: RequeueEvent :exec +UPDATE webhook_events +SET + status = 'pending', + retry_count = $1, + run_at = $2 +WHERE + id = $3 +` + +type RequeueEventParams struct { + RetryCount int32 + RunAt pgtype.Timestamptz + ID uuid.UUID +} + +func (q *Queries) RequeueEvent(ctx context.Context, arg RequeueEventParams) error { + _, err := q.db.Exec(ctx, requeueEvent, arg.RetryCount, arg.RunAt, arg.ID) + return err +} + +const resetConsecutiveFailures = `-- name: ResetConsecutiveFailures :exec +UPDATE webhook_subscriptions +SET + consecutive_failures = 0 +WHERE + id = $1 +` + +func (q *Queries) ResetConsecutiveFailures(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, resetConsecutiveFailures, id) + return err +} + +const updateSubscription = `-- name: UpdateSubscription :one +UPDATE webhook_subscriptions +SET + url = COALESCE($1, url), + secret = COALESCE($2, secret), + event_types = COALESCE($3, event_types), + enabled = COALESCE($4, enabled) +WHERE + id = $5 +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +type UpdateSubscriptionParams struct { + Url *string + Secret *string + EventTypes []string + Enabled *bool + ID uuid.UUID +} + +func (q *Queries) UpdateSubscription(ctx context.Context, arg UpdateSubscriptionParams) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, updateSubscription, + arg.Url, + arg.Secret, + arg.EventTypes, + arg.Enabled, + arg.ID, + ) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/workload/application/activitylog.go b/internal/workload/application/activitylog.go index 5c69abb16..4d5837a5a 100644 --- a/internal/workload/application/activitylog.go +++ b/internal/workload/application/activitylog.go @@ -79,12 +79,12 @@ func init() { } }) - activitylog.RegisterFilter("APPLICATION_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_RESTARTED", activityLogEntryActionRestartApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_SCALED", activityLogEntryActionAutoScaleApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("APPLICATION_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("APPLICATION_RESTARTED", activityLogEntryActionRestartApplication, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("APPLICATION_SCALED", activityLogEntryActionAutoScaleApplication, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType("APPLICATION_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeApplication) } type ApplicationRestartedActivityLogEntry struct { diff --git a/internal/workload/config/activitylog.go b/internal/workload/config/activitylog.go index 2b5d1ef73..bf2e53131 100644 --- a/internal/workload/config/activitylog.go +++ b/internal/workload/config/activitylog.go @@ -36,9 +36,9 @@ func init() { } }) - activitylog.RegisterFilter("CONFIG_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeConfig) - activitylog.RegisterFilter("CONFIG_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeConfig) - activitylog.RegisterFilter("CONFIG_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeConfig) + activitylog.RegisterActivityType("CONFIG_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeConfig) + activitylog.RegisterActivityType("CONFIG_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeConfig) + activitylog.RegisterActivityType("CONFIG_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeConfig) } type ConfigCreatedActivityLogEntry struct { diff --git a/internal/workload/job/activitylog.go b/internal/workload/job/activitylog.go index eb7acb5ac..5f6a1f7a2 100644 --- a/internal/workload/job/activitylog.go +++ b/internal/workload/job/activitylog.go @@ -76,12 +76,12 @@ func init() { } }) - activitylog.RegisterFilter("JOB_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_RUN_DELETED", activityLogEntryActionDeleteJobRun, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_TRIGGERED", activityLogEntryActionTriggerJob, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("JOB_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("JOB_RUN_DELETED", activityLogEntryActionDeleteJobRun, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("JOB_TRIGGERED", activityLogEntryActionTriggerJob, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType("JOB_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeJob) } type JobTriggeredActivityLogEntry struct { diff --git a/internal/workload/secret/activitylog.go b/internal/workload/secret/activitylog.go index 634fe0147..1d9169468 100644 --- a/internal/workload/secret/activitylog.go +++ b/internal/workload/secret/activitylog.go @@ -90,13 +90,13 @@ func init() { } }) - activitylog.RegisterFilter("SECRET_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_ADDED", activityLogEntryActionAddSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_UPDATED", activityLogEntryActionUpdateSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_REMOVED", activityLogEntryActionRemoveSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUES_VIEWED", activityLogEntryActionViewSecretValues, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_VALUE_ADDED", activityLogEntryActionAddSecretValue, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_VALUE_UPDATED", activityLogEntryActionUpdateSecretValue, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_VALUE_REMOVED", activityLogEntryActionRemoveSecretValue, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType("SECRET_VALUES_VIEWED", activityLogEntryActionViewSecretValues, activityLogEntryResourceTypeSecret) } type SecretCreatedActivityLogEntry struct { From badc42044eadd23f415f226ed77307af088f0e54 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Wed, 24 Jun 2026 12:55:00 +0200 Subject: [PATCH 2/6] Metrics --- internal/cmd/api/api.go | 5 +- internal/integration/manager.go | 1 + internal/webhook/README.md | 23 +++++ internal/webhook/dispatcher.go | 50 +++++++++- internal/webhook/metrics.go | 107 +++++++++++++++++++++ internal/webhook/queries/webhook.sql | 12 +++ internal/webhook/webhooksql/querier.go | 1 + internal/webhook/webhooksql/webhook.sql.go | 89 ++++++++++++----- 8 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 internal/webhook/metrics.go diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 906482654..98b281ae5 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -257,7 +257,10 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { go notifier.Run(ctx) // Webhook dispatcher — drains the webhook_events outbox table on PG NOTIFY - webhookDispatcher := webhook.NewDispatcher(pool, notifier, "https://"+cfg.TenantDomain+"/api", log) + webhookDispatcher, err := webhook.NewDispatcher(pool, notifier, "https://"+cfg.TenantDomain+"/api", log) + if err != nil { + return fmt.Errorf("creating webhook dispatcher: %w", err) + } go webhookDispatcher.Run(ctx) if !cfg.Fakes.WithFakeKubernetes { diff --git a/internal/integration/manager.go b/internal/integration/manager.go index e673c6b4f..442b6240c 100644 --- a/internal/integration/manager.go +++ b/internal/integration/manager.go @@ -290,6 +290,7 @@ func newGQLRunner( lokiClient, "test-audit-project", // auditLogProjectID for testing "test-location", // auditLogLocation for testing + nil, // webhookDispatcher log, ) if err != nil { diff --git a/internal/webhook/README.md b/internal/webhook/README.md index 2848bbf01..f8eb0fa54 100644 --- a/internal/webhook/README.md +++ b/internal/webhook/README.md @@ -107,3 +107,26 @@ events, the subscription is automatically disabled (`enabled = false`, `disabled | Team-scoped webhook | Team owner | | Global webhook | Admin (Go-level check, not a DB role) | | Update / delete | Owner of the subscription's team, or admin | + +## Monitoring & Metrics + +The webhook domain exports telemetry using native OpenTelemetry metrics under the meter name `webhook`: + +### PromQL Alerts Examples + +1. **Increasing outbox queue size** (Potential worker blockage or overload): + ```promql + sum(webhook_queue_size{status="pending"}) > 100 + ``` + *Trigger conditions*: Only the **leader pod** queries the database for `webhook_queue_size` to prevent double-counting in multi-replica deployments. + +2. **High webhook delivery failure rate**: + ```promql + sum(rate(webhook_deliveries_total{success="false"}[5m])) / sum(rate(webhook_deliveries_total[5m])) * 100 > 10 + ``` + +3. **Auto-disabled subscriptions rate**: + ```promql + sum(rate(webhook_subscriptions_auto_disabled_total[1h])) > 0 + ``` + diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index e2bfdf4d6..09f24d817 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/jackc/pgx/v5/pgtype" @@ -16,6 +17,8 @@ import ( "github.com/nais/api/internal/slug" "github.com/nais/api/internal/webhook/webhooksql" "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" ) const ( @@ -48,10 +51,17 @@ type Dispatcher struct { log logrus.FieldLogger source string httpClient *http.Client + metrics *webhookMetrics } // NewDispatcher creates a new webhook dispatcher that drains events from the outbox table. -func NewDispatcher(pool *pgxpool.Pool, notifier *notify.Notifier, source string, log logrus.FieldLogger) *Dispatcher { +func NewDispatcher(pool *pgxpool.Pool, notifier *notify.Notifier, source string, log logrus.FieldLogger) (*Dispatcher, error) { + q := webhooksql.New(pool) + m, err := newWebhookMetrics(q) + if err != nil { + return nil, fmt.Errorf("setting up webhook metrics: %w", err) + } + return &Dispatcher{ pool: pool, notifier: notifier, @@ -60,7 +70,8 @@ func NewDispatcher(pool *pgxpool.Pool, notifier *notify.Notifier, source string, httpClient: &http.Client{ Timeout: defaultTimeout, }, - } + metrics: m, + }, nil } // Run starts the dispatcher. It listens for PG NOTIFY on "webhook_events" and @@ -177,11 +188,21 @@ func (d *Dispatcher) processEvent(ctx context.Context, q *webhooksql.Queries, ev }); err != nil { d.log.WithError(err).Error("requeueing webhook event") } + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "requeued"), + )) } else { if err := q.MarkEventFailed(ctx, evt.ID); err != nil { d.log.WithError(err).Error("marking webhook event as failed") } + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "failed"), + )) } + } else { + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "completed"), + )) } } @@ -200,7 +221,8 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we start := time.Now() resp, err := d.httpClient.Do(req) - durationMs := int32(time.Since(start).Milliseconds()) + durationSeconds := time.Since(start).Seconds() + durationMs := int32(durationSeconds * 1000) var ( responseStatus *int32 @@ -208,6 +230,7 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we success bool ) + statusStr := "network_error" if err != nil { errMsg := err.Error() responseBody = &errMsg @@ -215,6 +238,7 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we defer resp.Body.Close() status := int32(resp.StatusCode) responseStatus = &status + statusStr = strconv.Itoa(int(resp.StatusCode)) success = resp.StatusCode >= 200 && resp.StatusCode < 300 body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*10)) // 10KB max @@ -224,6 +248,23 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we } } + successStr := "false" + if success { + successStr = "true" + } + + d.metrics.deliveriesCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("subscription_id", sub.ID.String()), + attribute.String("event_type", eventType), + attribute.String("status_code", statusStr), + attribute.String("success", successStr), + )) + + d.metrics.durationHistogram.Record(ctx, durationSeconds, metric.WithAttributes( + attribute.String("subscription_id", sub.ID.String()), + attribute.String("event_type", eventType), + )) + // Record delivery attempt if _, recordErr := q.CreateDelivery(ctx, webhooksql.CreateDeliveryParams{ SubscriptionID: sub.ID, @@ -253,6 +294,9 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we if err := q.DisableSubscription(ctx, sub.ID); err != nil { d.log.WithError(err).Error("disabling webhook subscription") } + d.metrics.autoDisabledCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("subscription_id", sub.ID.String()), + )) } } diff --git a/internal/webhook/metrics.go b/internal/webhook/metrics.go new file mode 100644 index 000000000..6c8bac465 --- /dev/null +++ b/internal/webhook/metrics.go @@ -0,0 +1,107 @@ +package webhook + +import ( + "context" + "fmt" + + "github.com/nais/api/internal/leaderelection" + "github.com/nais/api/internal/webhook/webhooksql" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type webhookMetrics struct { + deliveriesCounter metric.Int64Counter + durationHistogram metric.Float64Histogram + processedCounter metric.Int64Counter + autoDisabledCounter metric.Int64Counter + queueSizeGauge metric.Int64ObservableGauge +} + +func newWebhookMetrics(q *webhooksql.Queries) (*webhookMetrics, error) { + meter := otel.GetMeterProvider().Meter("webhook") + + deliveriesCounter, err := meter.Int64Counter( + "nais_api_webhook_deliveries_total", + metric.WithDescription("Total number of webhook deliveries attempted."), + ) + if err != nil { + return nil, fmt.Errorf("create deliveries counter: %w", err) + } + + durationHistogram, err := meter.Float64Histogram( + "nais_api_webhook_delivery_duration_seconds", + metric.WithDescription("Webhook delivery latency in seconds."), + metric.WithExplicitBucketBoundaries(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10), + ) + if err != nil { + return nil, fmt.Errorf("create duration histogram: %w", err) + } + + processedCounter, err := meter.Int64Counter( + "nais_api_webhook_events_processed_total", + metric.WithDescription("Total number of outbox webhook events processed by the dispatcher."), + ) + if err != nil { + return nil, fmt.Errorf("create processed counter: %w", err) + } + + autoDisabledCounter, err := meter.Int64Counter( + "nais_api_webhook_subscriptions_auto_disabled_total", + metric.WithDescription("Total number of webhook subscriptions automatically disabled due to consecutive failures."), + ) + if err != nil { + return nil, fmt.Errorf("create auto-disabled counter: %w", err) + } + + m := &webhookMetrics{ + deliveriesCounter: deliveriesCounter, + durationHistogram: durationHistogram, + processedCounter: processedCounter, + autoDisabledCounter: autoDisabledCounter, + } + + // Register the asynchronous gauge for queue size (runs on demand when scraped) + queueSizeGauge, err := meter.Int64ObservableGauge( + "nais_api_webhook_queue_size", + metric.WithDescription("Current size of the webhook outbox queue grouped by status."), + metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { + // ONLY the leader pod queries the database to avoid replica double-counting + if !leaderelection.IsLeader() { + return nil + } + + rows, err := q.GetQueueSizeByStatus(ctx) + if err != nil { + return err + } + + // Active map to track existing statuses to report (reporting 0 if none exist is helpful) + statuses := map[webhooksql.WebhookEventStatus]int64{ + webhooksql.WebhookEventStatusPending: 0, + webhooksql.WebhookEventStatusCompleted: 0, + webhooksql.WebhookEventStatusFailed: 0, + } + + for _, row := range rows { + statuses[row.Status] = row.Count + } + + for status, count := range statuses { + observer.Observe(count, metric.WithAttributes( + attribute.String("status", string(status)), + )) + } + + return nil + }), + ) + if err != nil { + return nil, fmt.Errorf("create queue size gauge: %w", err) + } + + m.queueSizeGauge = queueSizeGauge + + return m, nil +} diff --git a/internal/webhook/queries/webhook.sql b/internal/webhook/queries/webhook.sql index 1013a698e..347e76b8a 100644 --- a/internal/webhook/queries/webhook.sql +++ b/internal/webhook/queries/webhook.sql @@ -246,3 +246,15 @@ WHERE created_at < @before AND status IN ('completed', 'failed') ; + +-- name: GetQueueSizeByStatus :many +SELECT + status, + COUNT(*) AS count +FROM + webhook_events +GROUP BY + status +ORDER BY + status +; diff --git a/internal/webhook/webhooksql/querier.go b/internal/webhook/webhooksql/querier.go index 757965b0f..691421e68 100644 --- a/internal/webhook/webhooksql/querier.go +++ b/internal/webhook/webhooksql/querier.go @@ -16,6 +16,7 @@ type Querier interface { DeleteSubscription(ctx context.Context, id uuid.UUID) error DisableSubscription(ctx context.Context, id uuid.UUID) error GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) + GetQueueSizeByStatus(ctx context.Context) ([]*GetQueueSizeByStatusRow, error) GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, error) diff --git a/internal/webhook/webhooksql/webhook.sql.go b/internal/webhook/webhooksql/webhook.sql.go index 5c0b2c274..088882539 100644 --- a/internal/webhook/webhooksql/webhook.sql.go +++ b/internal/webhook/webhooksql/webhook.sql.go @@ -12,36 +12,36 @@ import ( ) const claimPendingEvents = `-- name: ClaimPendingEvents :many -WITH updated_events AS ( - UPDATE webhook_events - SET - status = 'completed' - WHERE - id IN ( - SELECT - id - FROM - webhook_events - WHERE - status = 'pending' - AND run_at <= NOW() - ORDER BY - run_at ASC - LIMIT - $1 - FOR UPDATE - SKIP LOCKED - ) - RETURNING - id, activity_log_entries_id, status, retry_count, run_at, created_at -) +WITH + updated_events AS ( + UPDATE webhook_events + SET + status = 'completed' + WHERE + id IN ( + SELECT + id + FROM + webhook_events + WHERE + status = 'pending' + AND run_at <= NOW() + ORDER BY + run_at ASC + LIMIT + $1 + FOR UPDATE + SKIP LOCKED + ) + RETURNING + id, activity_log_entries_id, status, retry_count, run_at, created_at + ) SELECT webhook_events.id, webhook_events.activity_log_entries_id, webhook_events.status, webhook_events.retry_count, webhook_events.run_at, webhook_events.created_at, activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment FROM - updated_events webhook_events -JOIN - activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id + updated_events webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id ` type ClaimPendingEventsRow struct { @@ -244,6 +244,43 @@ func (q *Queries) GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelive return &i, err } +const getQueueSizeByStatus = `-- name: GetQueueSizeByStatus :many +SELECT + status, + COUNT(*) AS count +FROM + webhook_events +GROUP BY + status +ORDER BY + status +` + +type GetQueueSizeByStatusRow struct { + Status WebhookEventStatus + Count int64 +} + +func (q *Queries) GetQueueSizeByStatus(ctx context.Context) ([]*GetQueueSizeByStatusRow, error) { + rows, err := q.db.Query(ctx, getQueueSizeByStatus) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*GetQueueSizeByStatusRow{} + for rows.Next() { + var i GetQueueSizeByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getSubscription = `-- name: GetSubscription :one SELECT id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at From 6095e2eb16b206989b8accbe9dc3db0d88d33ca1 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Tue, 30 Jun 2026 15:00:20 +0200 Subject: [PATCH 3/6] changes --- internal/activitylog/filter.go | 23 +++++++++++++------ .../{0071_webhooks.sql => 0072_webhooks.sql} | 0 internal/team/activitylog.go | 4 ++-- internal/tunnel/activitylog.go | 4 ++-- 4 files changed, 20 insertions(+), 11 deletions(-) rename internal/database/migrations/{0071_webhooks.sql => 0072_webhooks.sql} (100%) diff --git a/internal/activitylog/filter.go b/internal/activitylog/filter.go index 81f805d64..0fe19983c 100644 --- a/internal/activitylog/filter.go +++ b/internal/activitylog/filter.go @@ -1,6 +1,7 @@ package activitylog import ( + "fmt" "slices" "strings" @@ -29,6 +30,8 @@ type WebhookEventTypeInfo struct { Group string `json:"group"` // TeamScoped indicates if this event type can be subscribed to by team-scoped webhooks. TeamScoped bool `json:"teamScoped"` + + ignoreWebhook bool // internal flag to indicate that this event type should not be exposed in the webhook catalogue } type ActivityTypeOption func(*WebhookEventTypeInfo) @@ -54,6 +57,13 @@ func GlobalOnly() ActivityTypeOption { } } +// GlobalOnly marks the event type as global-only (not team-scoped). +func IgnoreWebhook() ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.ignoreWebhook = true + } +} + // eventTypeInfos stores metadata for all registered activity types. var eventTypeInfos = map[ActivityLogActivityType]WebhookEventTypeInfo{} @@ -122,6 +132,7 @@ func KnownEventTypes() []WebhookEventTypeInfo { for at := range knownFilters { info, ok := eventTypeInfos[at] if !ok { + fmt.Println("Warning: activity type", at, "is registered but has no WebhookEventTypeInfo; using auto-generated description and group") desc, grp := autoGroupAndDescription(at) info = WebhookEventTypeInfo{ Type: at, @@ -131,6 +142,10 @@ func KnownEventTypes() []WebhookEventTypeInfo { TeamScoped: true, } } + + if info.ignoreWebhook { + continue + } result = append(result, info) } slices.SortFunc(result, func(a, b WebhookEventTypeInfo) int { @@ -182,19 +197,13 @@ func RegisterActivityType(activityType ActivityLogActivityType, action ActivityL rebuildReverseFilters() } - // Default teamScoped based on resourceType - teamScoped := true - if resourceType == "RECONCILER" || resourceType == "CLUSTER_AUDIT" { - teamScoped = false - } - desc, grp := autoGroupAndDescription(activityType) info := &WebhookEventTypeInfo{ Type: activityType, CloudEventType: CloudEventType(activityType), Description: desc, Group: grp, - TeamScoped: teamScoped, + TeamScoped: true, } for _, opt := range opts { diff --git a/internal/database/migrations/0071_webhooks.sql b/internal/database/migrations/0072_webhooks.sql similarity index 100% rename from internal/database/migrations/0071_webhooks.sql rename to internal/database/migrations/0072_webhooks.sql diff --git a/internal/team/activitylog.go b/internal/team/activitylog.go index 9e2e225d3..51b398ef7 100644 --- a/internal/team/activitylog.go +++ b/internal/team/activitylog.go @@ -97,10 +97,10 @@ func init() { } }) - activitylog.RegisterActivityType("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam, activitylog.GlobalOnly()) activitylog.RegisterActivityType("TEAM_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeTeam) activitylog.RegisterActivityType("TEAM_CREATE_DELETE_KEY", activityLogEntryActionCreateDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam, activitylog.GlobalOnly()) activitylog.RegisterActivityType("TEAM_MEMBER_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeTeam) activitylog.RegisterActivityType("TEAM_MEMBER_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeTeam) activitylog.RegisterActivityType("TEAM_MEMBER_SET_ROLE", activityLogEntryActionSetMemberRole, activityLogEntryResourceTypeTeam) diff --git a/internal/tunnel/activitylog.go b/internal/tunnel/activitylog.go index 5e979e019..17a4a4b4d 100644 --- a/internal/tunnel/activitylog.go +++ b/internal/tunnel/activitylog.go @@ -44,8 +44,8 @@ func init() { } }) - activitylog.RegisterActivityType("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel) - activitylog.RegisterActivityType("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel) + activitylog.RegisterActivityType("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel, activitylog.IgnoreWebhook()) } type tunnelCreatedData struct { From f08c606f693d8ff57801911d1a436952c1d3eac5 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Thu, 30 Jul 2026 11:08:46 +0200 Subject: [PATCH 4/6] Add descriptions to activity log types --- internal/github/repository/activitylog.go | 14 +++- .../aivencredentials/activitylog.go | 3 +- .../persistence/opensearch/activitylog.go | 37 ++++++++-- internal/persistence/postgres/activitylog.go | 12 +++- internal/persistence/valkey/activitylog.go | 35 ++++++++-- internal/serviceaccount/activitylog.go | 70 ++++++++++++++++--- internal/team/activitylog.go | 58 ++++++++++++--- internal/unleash/activitylog.go | 6 +- internal/vulnerability/activitylog.go | 7 +- internal/webhook/dispatcher.go | 2 - internal/workload/application/activitylog.go | 42 +++++++++-- internal/workload/config/activitylog.go | 21 +++++- internal/workload/job/activitylog.go | 42 +++++++++-- internal/workload/secret/activitylog.go | 49 +++++++++++-- 14 files changed, 336 insertions(+), 62 deletions(-) diff --git a/internal/github/repository/activitylog.go b/internal/github/repository/activitylog.go index c26cafe84..a3a9ffeec 100644 --- a/internal/github/repository/activitylog.go +++ b/internal/github/repository/activitylog.go @@ -27,8 +27,18 @@ func init() { } }) - activitylog.RegisterActivityType("REPOSITORY_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeRepository) - activitylog.RegisterActivityType("REPOSITORY_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeRepository) + activitylog.RegisterActivityType( + "REPOSITORY_ADDED", + activitylog.ActivityLogEntryActionAdded, + activityLogEntryResourceTypeRepository, + activitylog.WithDescription("Triggered when a repository is added to a team."), + ) + activitylog.RegisterActivityType( + "REPOSITORY_REMOVED", + activitylog.ActivityLogEntryActionRemoved, + activityLogEntryResourceTypeRepository, + activitylog.WithDescription("Triggered when a repository is removed from a team."), + ) } type RepositoryAddedActivityLogEntry struct { diff --git a/internal/persistence/aivencredentials/activitylog.go b/internal/persistence/aivencredentials/activitylog.go index 45beeba50..fd2f219dd 100644 --- a/internal/persistence/aivencredentials/activitylog.go +++ b/internal/persistence/aivencredentials/activitylog.go @@ -7,8 +7,7 @@ import ( ) const ( - ActivityLogActivityTypeCredentialsCreated activitylog.ActivityLogActivityType = "CREDENTIALS_CREATED" - ActivityLogEntryActionCredentialsCreated activitylog.ActivityLogEntryAction = "CREDENTIALS_CREATED" + ActivityLogEntryActionCredentialsCreated activitylog.ActivityLogEntryAction = "CREDENTIALS_CREATED" ) func GetActivityLogEntry(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { diff --git a/internal/persistence/opensearch/activitylog.go b/internal/persistence/opensearch/activitylog.go index b8da123d8..47a8ef54f 100644 --- a/internal/persistence/opensearch/activitylog.go +++ b/internal/persistence/opensearch/activitylog.go @@ -43,11 +43,38 @@ func init() { } }) - activitylog.RegisterActivityType("OPENSEARCH_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterActivityType("OPENSEARCH_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterActivityType("OPENSEARCH_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterActivityType("OPENSEARCH_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterActivityType(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType( + "OPENSEARCH_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is created."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is updated."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is deleted."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_MAINTENANCE_STARTED", + servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when service maintenance is started for an OpenSearch instance."), + ) + // TODO(thokra): Inspect if we can just remove aivencredentials.ActivityLogActivityTypeCredentialsCreated + activitylog.RegisterActivityType( + "OPENSEARCH_CREDENTIALS_CREATED", + aivencredentials.ActivityLogEntryActionCredentialsCreated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithGroup("OpenSearch"), + activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance."), + ) } type OpenSearchCreatedActivityLogEntry struct { diff --git a/internal/persistence/postgres/activitylog.go b/internal/persistence/postgres/activitylog.go index 3eab15cc2..651329e7e 100644 --- a/internal/persistence/postgres/activitylog.go +++ b/internal/persistence/postgres/activitylog.go @@ -40,8 +40,16 @@ func init() { } }) - activitylog.RegisterActivityType("POSTGRES_GRANT_ACCESS", activityLogEntryActionGrantAccess, activityLogEntryResourceTypePostgres) - activitylog.RegisterActivityType("POSTGRES_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypePostgres) + activitylog.RegisterActivityType("POSTGRES_GRANT_ACCESS", + activityLogEntryActionGrantAccess, + activityLogEntryResourceTypePostgres, + activitylog.WithDescription("Triggered when user access to a Postgres instance is granted."), + ) + activitylog.RegisterActivityType("POSTGRES_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypePostgres, + activitylog.WithDescription("Triggered when a Postgres instance is deleted."), + ) } type PostgresDeletedActivityLogEntry struct { diff --git a/internal/persistence/valkey/activitylog.go b/internal/persistence/valkey/activitylog.go index 47b9d0052..0e61ec08f 100644 --- a/internal/persistence/valkey/activitylog.go +++ b/internal/persistence/valkey/activitylog.go @@ -44,11 +44,36 @@ func init() { } }) - activitylog.RegisterActivityType("VALKEY_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterActivityType("VALKEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterActivityType("VALKEY_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterActivityType("VALKEY_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterActivityType(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType( + "VALKEY_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is created."), + ) + activitylog.RegisterActivityType( + "VALKEY_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is updated."), + ) + activitylog.RegisterActivityType( + "VALKEY_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is deleted."), + ) + activitylog.RegisterActivityType( + "VALKEY_MAINTENANCE_STARTED", + servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when service maintenance is started for a Valkey."), + ) + activitylog.RegisterActivityType( + "VALKEY_CREDENTIALS_CREATED", + aivencredentials.ActivityLogEntryActionCredentialsCreated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when credentials are created for a Valkey."), + ) } type ValkeyCreatedActivityLogEntry struct { diff --git a/internal/serviceaccount/activitylog.go b/internal/serviceaccount/activitylog.go index daf0e31ad..aebaa5fc7 100644 --- a/internal/serviceaccount/activitylog.go +++ b/internal/serviceaccount/activitylog.go @@ -136,16 +136,66 @@ func init() { } }) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_CREATED", activityLogEntryActionCreateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_UPDATED", activityLogEntryActionUpdateServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_TOKEN_DELETED", activityLogEntryActionDeleteServiceAccountToken, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_ROLE_ASSIGNED", activityLogEntryActionAssignServiceAccountRole, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_ROLE_REVOKED", activityLogEntryActionRevokeServiceAccountRole, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", activityLogEntryActionAddServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) - activitylog.RegisterActivityType("SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", activityLogEntryActionRemoveServiceAccountWorkloadBinding, activityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is created."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is updated."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is deleted."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_CREATED", + activityLogEntryActionCreateServiceAccountToken, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is created."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_UPDATED", + activityLogEntryActionUpdateServiceAccountToken, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is updated."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_DELETED", + activityLogEntryActionDeleteServiceAccountToken, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is deleted."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_ROLE_ASSIGNED", + activityLogEntryActionAssignServiceAccountRole, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a role is assigned to a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_ROLE_REVOKED", + activityLogEntryActionRevokeServiceAccountRole, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a role is revoked from a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", + activityLogEntryActionAddServiceAccountWorkloadBinding, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a workload binding is added to a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", + activityLogEntryActionRemoveServiceAccountWorkloadBinding, + activityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a workload binding is removed from a service account."), + ) } type RoleAssignedToServiceAccountActivityLogEntry struct { diff --git a/internal/team/activitylog.go b/internal/team/activitylog.go index 51b398ef7..ca284eac9 100644 --- a/internal/team/activitylog.go +++ b/internal/team/activitylog.go @@ -97,14 +97,56 @@ func init() { } }) - activitylog.RegisterActivityType("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam, activitylog.GlobalOnly()) - activitylog.RegisterActivityType("TEAM_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_CREATE_DELETE_KEY", activityLogEntryActionCreateDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam, activitylog.GlobalOnly()) - activitylog.RegisterActivityType("TEAM_MEMBER_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_MEMBER_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_MEMBER_SET_ROLE", activityLogEntryActionSetMemberRole, activityLogEntryResourceTypeTeam) - activitylog.RegisterActivityType("TEAM_ENVIRONMENT_UPDATED", activityLogEntryActionUpdateEnvironment, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType( + "TEAM_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeTeam, + activitylog.GlobalOnly(), + activitylog.WithDescription("Triggered when a team is created."), + ) + activitylog.RegisterActivityType( + "TEAM_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a team is updated."), + ) + activitylog.RegisterActivityType( + "TEAM_CREATE_DELETE_KEY", + activityLogEntryActionCreateDeleteKey, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a delete key is created for a team."), + ) + activitylog.RegisterActivityType( + "TEAM_CONFIRM_DELETE_KEY", + activityLogEntryActionConfirmDeleteKey, + activityLogEntryResourceTypeTeam, + activitylog.GlobalOnly(), + activitylog.WithDescription("Triggered when a delete key is confirmed for a team and the team is deleted."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_ADDED", + activitylog.ActivityLogEntryActionAdded, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member is added to a team."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_REMOVED", + activitylog.ActivityLogEntryActionRemoved, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member is removed from a team."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_SET_ROLE", + activityLogEntryActionSetMemberRole, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member's role is modified in a team."), + ) + activitylog.RegisterActivityType( + "TEAM_ENVIRONMENT_UPDATED", + activityLogEntryActionUpdateEnvironment, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a team's environment is updated."), + ) } type TeamCreatedActivityLogEntry struct { diff --git a/internal/unleash/activitylog.go b/internal/unleash/activitylog.go index 7a0b25b93..ab53c2bd0 100644 --- a/internal/unleash/activitylog.go +++ b/internal/unleash/activitylog.go @@ -43,9 +43,9 @@ func init() { } }) - activitylog.RegisterActivityType("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterActivityType("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterActivityType("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) } type UnleashInstanceCreatedActivityLogEntry struct { diff --git a/internal/vulnerability/activitylog.go b/internal/vulnerability/activitylog.go index b13947512..e7eb558e9 100644 --- a/internal/vulnerability/activitylog.go +++ b/internal/vulnerability/activitylog.go @@ -27,7 +27,12 @@ func init() { } }) - activitylog.RegisterActivityType("VULNERABILITY_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeVulnerability) + activitylog.RegisterActivityType( + "VULNERABILITY_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeVulnerability, + activitylog.WithDescription("Triggered when a vulnerability finding is updated by a user."), + ) } type VulnerabilityUpdatedActivityLogEntry struct { diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go index 09f24d817..fd3478276 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/webhook/dispatcher.go @@ -254,14 +254,12 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we } d.metrics.deliveriesCounter.Add(ctx, 1, metric.WithAttributes( - attribute.String("subscription_id", sub.ID.String()), attribute.String("event_type", eventType), attribute.String("status_code", statusStr), attribute.String("success", successStr), )) d.metrics.durationHistogram.Record(ctx, durationSeconds, metric.WithAttributes( - attribute.String("subscription_id", sub.ID.String()), attribute.String("event_type", eventType), )) diff --git a/internal/workload/application/activitylog.go b/internal/workload/application/activitylog.go index 4d5837a5a..d2221e907 100644 --- a/internal/workload/application/activitylog.go +++ b/internal/workload/application/activitylog.go @@ -79,12 +79,42 @@ func init() { } }) - activitylog.RegisterActivityType("APPLICATION_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterActivityType("APPLICATION_RESTARTED", activityLogEntryActionRestartApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterActivityType("APPLICATION_SCALED", activityLogEntryActionAutoScaleApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterActivityType("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterActivityType("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterActivityType("APPLICATION_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType( + "APPLICATION_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is deleted."), + ) + activitylog.RegisterActivityType( + "APPLICATION_RESTARTED", + activityLogEntryActionRestartApplication, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is restarted."), + ) + activitylog.RegisterActivityType( + "APPLICATION_SCALED", + activityLogEntryActionAutoScaleApplication, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is scaled."), + ) + activitylog.RegisterActivityType( + "DEPLOYMENT", + deploymentactivity.ActivityLogEntryActionDeployment, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when a resource is deployed using the nais/deploy action."), + ) + activitylog.RegisterActivityType( + "GENERIC_KUBERNETES_RESOURCE_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when a generic Kubernetes resource is created."), + ) + activitylog.RegisterActivityType( + "APPLICATION_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is updated."), + ) } type ApplicationRestartedActivityLogEntry struct { diff --git a/internal/workload/config/activitylog.go b/internal/workload/config/activitylog.go index bf2e53131..a6b6058a6 100644 --- a/internal/workload/config/activitylog.go +++ b/internal/workload/config/activitylog.go @@ -36,9 +36,24 @@ func init() { } }) - activitylog.RegisterActivityType("CONFIG_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeConfig) - activitylog.RegisterActivityType("CONFIG_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeConfig) - activitylog.RegisterActivityType("CONFIG_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeConfig) + activitylog.RegisterActivityType( + "CONFIG_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is created."), + ) + activitylog.RegisterActivityType( + "CONFIG_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is updated."), + ) + activitylog.RegisterActivityType( + "CONFIG_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is deleted."), + ) } type ConfigCreatedActivityLogEntry struct { diff --git a/internal/workload/job/activitylog.go b/internal/workload/job/activitylog.go index 5f6a1f7a2..8606bf9e9 100644 --- a/internal/workload/job/activitylog.go +++ b/internal/workload/job/activitylog.go @@ -76,12 +76,42 @@ func init() { } }) - activitylog.RegisterActivityType("JOB_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeJob) - activitylog.RegisterActivityType("JOB_RUN_DELETED", activityLogEntryActionDeleteJobRun, ActivityLogEntryResourceTypeJob) - activitylog.RegisterActivityType("JOB_TRIGGERED", activityLogEntryActionTriggerJob, ActivityLogEntryResourceTypeJob) - activitylog.RegisterActivityType("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeJob) - activitylog.RegisterActivityType("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeJob) - activitylog.RegisterActivityType("JOB_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType( + "JOB_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is deleted."), + ) + activitylog.RegisterActivityType( + "JOB_RUN_DELETED", + activityLogEntryActionDeleteJobRun, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job run is deleted."), + ) + activitylog.RegisterActivityType( + "JOB_TRIGGERED", + activityLogEntryActionTriggerJob, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is manually triggered."), + ) + activitylog.RegisterActivityType( + "DEPLOYMENT", + deploymentactivity.ActivityLogEntryActionDeployment, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a resource is deployed using the nais/deploy action."), + ) + activitylog.RegisterActivityType( + "GENERIC_KUBERNETES_RESOURCE_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a generic Kubernetes resource is created."), + ) + activitylog.RegisterActivityType( + "JOB_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is updated."), + ) } type JobTriggeredActivityLogEntry struct { diff --git a/internal/workload/secret/activitylog.go b/internal/workload/secret/activitylog.go index 1d9169468..ab57cf5d6 100644 --- a/internal/workload/secret/activitylog.go +++ b/internal/workload/secret/activitylog.go @@ -90,13 +90,48 @@ func init() { } }) - activitylog.RegisterActivityType("SECRET_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_VALUE_ADDED", activityLogEntryActionAddSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_VALUE_UPDATED", activityLogEntryActionUpdateSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_VALUE_REMOVED", activityLogEntryActionRemoveSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterActivityType("SECRET_VALUES_VIEWED", activityLogEntryActionViewSecretValues, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType( + "SECRET_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is created."), + ) + activitylog.RegisterActivityType( + "SECRET_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is updated."), + ) + activitylog.RegisterActivityType( + "SECRET_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is deleted."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_ADDED", + activityLogEntryActionAddSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is added."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_UPDATED", + activityLogEntryActionUpdateSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is updated."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_REMOVED", + activityLogEntryActionRemoveSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is removed."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUES_VIEWED", + activityLogEntryActionViewSecretValues, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when secret values are viewed."), + ) } type SecretCreatedActivityLogEntry struct { From 81f32e03ee057518e3eee91df90c4bdcb9d91d6e Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Thu, 30 Jul 2026 11:23:51 +0200 Subject: [PATCH 5/6] Move webhooks into activitylog package Fix formatting, and one too hopefull fix for credentials activity type name --- .configs/gqlgen.yaml | 2 +- .configs/sqlc.yaml | 4 +- internal/{ => activitylog}/webhook/README.md | 10 +++-- .../{ => activitylog}/webhook/cloudevents.go | 0 .../{ => activitylog}/webhook/dataloader.go | 2 +- .../{ => activitylog}/webhook/dispatcher.go | 2 +- internal/{ => activitylog}/webhook/metrics.go | 2 +- internal/{ => activitylog}/webhook/model.go | 0 internal/{ => activitylog}/webhook/node.go | 0 internal/{ => activitylog}/webhook/queries.go | 2 +- .../webhook/queries/webhook.sql | 0 internal/{ => activitylog}/webhook/signer.go | 0 .../webhook/webhooksql/db.go | 0 .../webhook/webhooksql/models.go | 0 .../webhook/webhooksql/querier.go | 0 .../webhook/webhooksql/webhook.sql.go | 0 internal/cmd/api/api.go | 2 +- internal/cmd/api/http.go | 2 +- .../database/migrations/0072_webhooks.sql | 10 ++--- internal/graph/gengql/root_.generated.go | 2 +- internal/graph/gengql/schema.generated.go | 14 +++--- internal/graph/gengql/teams.generated.go | 2 +- internal/graph/gengql/webhooks.generated.go | 44 +++++++++---------- internal/graph/webhooks.resolvers.go | 2 +- .../persistence/opensearch/activitylog.go | 6 +-- internal/persistence/valkey/activitylog.go | 4 +- 26 files changed, 56 insertions(+), 56 deletions(-) rename internal/{ => activitylog}/webhook/README.md (97%) rename internal/{ => activitylog}/webhook/cloudevents.go (100%) rename internal/{ => activitylog}/webhook/dataloader.go (97%) rename internal/{ => activitylog}/webhook/dispatcher.go (99%) rename internal/{ => activitylog}/webhook/metrics.go (98%) rename internal/{ => activitylog}/webhook/model.go (100%) rename internal/{ => activitylog}/webhook/node.go (100%) rename internal/{ => activitylog}/webhook/queries.go (99%) rename internal/{ => activitylog}/webhook/queries/webhook.sql (100%) rename internal/{ => activitylog}/webhook/signer.go (100%) rename internal/{ => activitylog}/webhook/webhooksql/db.go (100%) rename internal/{ => activitylog}/webhook/webhooksql/models.go (100%) rename internal/{ => activitylog}/webhook/webhooksql/querier.go (100%) rename internal/{ => activitylog}/webhook/webhooksql/webhook.sql.go (100%) diff --git a/.configs/gqlgen.yaml b/.configs/gqlgen.yaml index 6e80db3d4..a96bbbd95 100644 --- a/.configs/gqlgen.yaml +++ b/.configs/gqlgen.yaml @@ -81,7 +81,7 @@ autobind: - "github.com/nais/api/internal/workload/config" - "github.com/nais/api/internal/workload/instancegroup" - "github.com/nais/api/internal/workload/secret" - - "github.com/nais/api/internal/webhook" + - "github.com/nais/api/internal/activitylog/webhook" # Don't generate Get functions for fields included in the GraphQL interfaces omit_getters: true diff --git a/.configs/sqlc.yaml b/.configs/sqlc.yaml index 4879825fa..f8751b82d 100644 --- a/.configs/sqlc.yaml +++ b/.configs/sqlc.yaml @@ -235,9 +235,9 @@ sql: - <<: *default_domain name: "Webhook SQL" - queries: "../internal/webhook/queries" + queries: "../internal/activitylog/webhook/queries" gen: go: <<: *default_go package: "webhooksql" - out: "../internal/webhook/webhooksql" + out: "../internal/activitylog/webhook/webhooksql" diff --git a/internal/webhook/README.md b/internal/activitylog/webhook/README.md similarity index 97% rename from internal/webhook/README.md rename to internal/activitylog/webhook/README.md index f8eb0fa54..3f95f2f09 100644 --- a/internal/webhook/README.md +++ b/internal/activitylog/webhook/README.md @@ -54,8 +54,8 @@ Every registered activity type is automatically available as a subscribable even ```go // Any domain package's init(): activitylog.RegisterActivityType( - "TEAM_MEMBER_ADDED", - activitylog.ActivityLogEntryActionAdded, + "TEAM_MEMBER_ADDED", + activitylog.ActivityLogEntryActionAdded, resourceType, activitylog.WithDescription("A user was added to the team"), // Custom description activitylog.WithGroup("Team"), // Custom UI grouping @@ -115,12 +115,15 @@ The webhook domain exports telemetry using native OpenTelemetry metrics under th ### PromQL Alerts Examples 1. **Increasing outbox queue size** (Potential worker blockage or overload): + ```promql sum(webhook_queue_size{status="pending"}) > 100 ``` - *Trigger conditions*: Only the **leader pod** queries the database for `webhook_queue_size` to prevent double-counting in multi-replica deployments. + + _Trigger conditions_: Only the **leader pod** queries the database for `webhook_queue_size` to prevent double-counting in multi-replica deployments. 2. **High webhook delivery failure rate**: + ```promql sum(rate(webhook_deliveries_total{success="false"}[5m])) / sum(rate(webhook_deliveries_total[5m])) * 100 > 10 ``` @@ -129,4 +132,3 @@ The webhook domain exports telemetry using native OpenTelemetry metrics under th ```promql sum(rate(webhook_subscriptions_auto_disabled_total[1h])) > 0 ``` - diff --git a/internal/webhook/cloudevents.go b/internal/activitylog/webhook/cloudevents.go similarity index 100% rename from internal/webhook/cloudevents.go rename to internal/activitylog/webhook/cloudevents.go diff --git a/internal/webhook/dataloader.go b/internal/activitylog/webhook/dataloader.go similarity index 97% rename from internal/webhook/dataloader.go rename to internal/activitylog/webhook/dataloader.go index 187a7f6cd..03294f7d5 100644 --- a/internal/webhook/dataloader.go +++ b/internal/activitylog/webhook/dataloader.go @@ -5,9 +5,9 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" "github.com/nais/api/internal/database" "github.com/nais/api/internal/graph/loader" - "github.com/nais/api/internal/webhook/webhooksql" "github.com/vikstrous/dataloadgen" ) diff --git a/internal/webhook/dispatcher.go b/internal/activitylog/webhook/dispatcher.go similarity index 99% rename from internal/webhook/dispatcher.go rename to internal/activitylog/webhook/dispatcher.go index fd3478276..7d46fb701 100644 --- a/internal/webhook/dispatcher.go +++ b/internal/activitylog/webhook/dispatcher.go @@ -13,9 +13,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" "github.com/nais/api/internal/database/notify" "github.com/nais/api/internal/slug" - "github.com/nais/api/internal/webhook/webhooksql" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" diff --git a/internal/webhook/metrics.go b/internal/activitylog/webhook/metrics.go similarity index 98% rename from internal/webhook/metrics.go rename to internal/activitylog/webhook/metrics.go index 6c8bac465..890f9e84c 100644 --- a/internal/webhook/metrics.go +++ b/internal/activitylog/webhook/metrics.go @@ -4,8 +4,8 @@ import ( "context" "fmt" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" "github.com/nais/api/internal/leaderelection" - "github.com/nais/api/internal/webhook/webhooksql" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" diff --git a/internal/webhook/model.go b/internal/activitylog/webhook/model.go similarity index 100% rename from internal/webhook/model.go rename to internal/activitylog/webhook/model.go diff --git a/internal/webhook/node.go b/internal/activitylog/webhook/node.go similarity index 100% rename from internal/webhook/node.go rename to internal/activitylog/webhook/node.go diff --git a/internal/webhook/queries.go b/internal/activitylog/webhook/queries.go similarity index 99% rename from internal/webhook/queries.go rename to internal/activitylog/webhook/queries.go index 14b72011a..f82fab0de 100644 --- a/internal/webhook/queries.go +++ b/internal/activitylog/webhook/queries.go @@ -7,11 +7,11 @@ import ( "github.com/google/uuid" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" - "github.com/nais/api/internal/webhook/webhooksql" ) func GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { diff --git a/internal/webhook/queries/webhook.sql b/internal/activitylog/webhook/queries/webhook.sql similarity index 100% rename from internal/webhook/queries/webhook.sql rename to internal/activitylog/webhook/queries/webhook.sql diff --git a/internal/webhook/signer.go b/internal/activitylog/webhook/signer.go similarity index 100% rename from internal/webhook/signer.go rename to internal/activitylog/webhook/signer.go diff --git a/internal/webhook/webhooksql/db.go b/internal/activitylog/webhook/webhooksql/db.go similarity index 100% rename from internal/webhook/webhooksql/db.go rename to internal/activitylog/webhook/webhooksql/db.go diff --git a/internal/webhook/webhooksql/models.go b/internal/activitylog/webhook/webhooksql/models.go similarity index 100% rename from internal/webhook/webhooksql/models.go rename to internal/activitylog/webhook/webhooksql/models.go diff --git a/internal/webhook/webhooksql/querier.go b/internal/activitylog/webhook/webhooksql/querier.go similarity index 100% rename from internal/webhook/webhooksql/querier.go rename to internal/activitylog/webhook/webhooksql/querier.go diff --git a/internal/webhook/webhooksql/webhook.sql.go b/internal/activitylog/webhook/webhooksql/webhook.sql.go similarity index 100% rename from internal/webhook/webhooksql/webhook.sql.go rename to internal/activitylog/webhook/webhooksql/webhook.sql.go diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 98b281ae5..ac9545961 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -14,6 +14,7 @@ import ( aiven_service "github.com/aiven/go-client-codegen" "github.com/joho/godotenv" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/apply" "github.com/nais/api/internal/auth/authn" "github.com/nais/api/internal/auth/middleware" @@ -42,7 +43,6 @@ import ( fakehookd "github.com/nais/api/internal/thirdparty/hookd/fake" "github.com/nais/api/internal/unleash" "github.com/nais/api/internal/vulnerability" - "github.com/nais/api/internal/webhook" "github.com/sethvargo/go-envconfig" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" diff --git a/internal/cmd/api/http.go b/internal/cmd/api/http.go index bc503859f..58fbff20d 100644 --- a/internal/cmd/api/http.go +++ b/internal/cmd/api/http.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authn" "github.com/nais/api/internal/auth/authz" @@ -56,7 +57,6 @@ import ( "github.com/nais/api/internal/usersync" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" - "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" diff --git a/internal/database/migrations/0072_webhooks.sql b/internal/database/migrations/0072_webhooks.sql index 73cfdaa82..4c8e51fe1 100644 --- a/internal/database/migrations/0072_webhooks.sql +++ b/internal/database/migrations/0072_webhooks.sql @@ -1,6 +1,6 @@ -- +goose Up CREATE TABLE webhook_subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), team_slug slug REFERENCES teams (slug) ON DELETE CASCADE, url TEXT NOT NULL, secret TEXT NOT NULL, @@ -14,8 +14,8 @@ CREATE TABLE webhook_subscriptions ( ) ; -CREATE TRIGGER webhook_subscriptions_updated_at BEFORE -UPDATE ON webhook_subscriptions FOR EACH ROW +CREATE TRIGGER webhook_subscriptions_updated_at +BEFORE UPDATE ON webhook_subscriptions FOR EACH ROW EXECUTE FUNCTION set_updated_at () ; @@ -33,7 +33,7 @@ WHERE ; CREATE TABLE webhook_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, event_type TEXT NOT NULL, request_body JSONB NOT NULL, @@ -54,7 +54,7 @@ CREATE TYPE webhook_event_status AS ENUM('pending', 'completed', 'failed') ; CREATE TABLE webhook_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), activity_log_entries_id UUID NOT NULL REFERENCES activity_log_entries (id) ON DELETE CASCADE, status webhook_event_status NOT NULL DEFAULT 'pending', retry_count INT NOT NULL DEFAULT 0, diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index 244243a76..77c09d83b 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -10,6 +10,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/cost" @@ -40,7 +41,6 @@ import ( "github.com/nais/api/internal/user" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" - "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 80bc0c175..e4389f5d1 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -13,6 +13,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/cost" @@ -51,7 +52,6 @@ import ( "github.com/nais/api/internal/usersync" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" - "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" @@ -487,7 +487,7 @@ func (ec *executionContext) field_Mutation_createWebhook_args(ctx context.Contex args := map[string]any{} arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", func(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { - return ec.unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookInput(ctx, v) + return ec.unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookInput(ctx, v) }) if err != nil { return nil, err @@ -669,7 +669,7 @@ func (ec *executionContext) field_Mutation_deleteWebhook_args(ctx context.Contex args := map[string]any{} arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", func(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { - return ec.unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookInput(ctx, v) + return ec.unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookInput(ctx, v) }) if err != nil { return nil, err @@ -1103,7 +1103,7 @@ func (ec *executionContext) field_Mutation_updateWebhook_args(ctx context.Contex args := map[string]any{} arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", func(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { - return ec.unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookInput(ctx, v) + return ec.unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookInput(ctx, v) }) if err != nil { return nil, err @@ -4702,7 +4702,7 @@ func (ec *executionContext) _Mutation_createWebhook(ctx context.Context, field g }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { - return ec.marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx, selections, v) + return ec.marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx, selections, v) }, true, true, @@ -4746,7 +4746,7 @@ func (ec *executionContext) _Mutation_updateWebhook(ctx context.Context, field g }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { - return ec.marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx, selections, v) + return ec.marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx, selections, v) }, true, true, @@ -4790,7 +4790,7 @@ func (ec *executionContext) _Mutation_deleteWebhook(ctx context.Context, field g }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { - return ec.marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx, selections, v) + return ec.marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx, selections, v) }, true, true, diff --git a/internal/graph/gengql/teams.generated.go b/internal/graph/gengql/teams.generated.go index 64e4c1050..0d8a3d61e 100644 --- a/internal/graph/gengql/teams.generated.go +++ b/internal/graph/gengql/teams.generated.go @@ -12,6 +12,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/cost" "github.com/nais/api/internal/deployment" @@ -36,7 +37,6 @@ import ( "github.com/nais/api/internal/user" "github.com/nais/api/internal/utilization" "github.com/nais/api/internal/vulnerability" - "github.com/nais/api/internal/webhook" "github.com/nais/api/internal/workload" "github.com/nais/api/internal/workload/application" "github.com/nais/api/internal/workload/config" diff --git a/internal/graph/gengql/webhooks.generated.go b/internal/graph/gengql/webhooks.generated.go index 8b5295e3b..9cf41e88e 100644 --- a/internal/graph/gengql/webhooks.generated.go +++ b/internal/graph/gengql/webhooks.generated.go @@ -12,10 +12,10 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/slug" - "github.com/nais/api/internal/webhook" "github.com/vektah/gqlparser/v2/ast" ) @@ -89,7 +89,7 @@ func (ec *executionContext) _CreateWebhookPayload_webhook(ctx context.Context, f }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { - return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) }, true, true, @@ -144,7 +144,7 @@ func (ec *executionContext) _UpdateWebhookPayload_webhook(ctx context.Context, f }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { - return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) }, true, true, @@ -392,7 +392,7 @@ func (ec *executionContext) _WebhookDeliveryConnection_nodes(ctx context.Context }, nil, func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { - return ec.marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDeliveryᚄ(ctx, selections, v) + return ec.marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDeliveryᚄ(ctx, selections, v) }, true, true, @@ -479,7 +479,7 @@ func (ec *executionContext) _WebhookDeliveryEdge_node(ctx context.Context, field }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { - return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx, selections, v) + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx, selections, v) }, true, true, @@ -955,7 +955,7 @@ func (ec *executionContext) _WebhookSubscriptionConnection_nodes(ctx context.Con }, nil, func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { - return ec.marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscriptionᚄ(ctx, selections, v) + return ec.marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscriptionᚄ(ctx, selections, v) }, true, true, @@ -1042,7 +1042,7 @@ func (ec *executionContext) _WebhookSubscriptionEdge_node(ctx context.Context, f }, nil, func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { - return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, selections, v) + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) }, true, true, @@ -1796,16 +1796,16 @@ func (ec *executionContext) _WebhookSubscriptionEdge(ctx context.Context, sel as // region ***************************** type.gotpl ***************************** -func (ec *executionContext) unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookInput(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { +func (ec *executionContext) unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookInput(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { res, err := ec.unmarshalInputCreateWebhookInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNCreateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.CreateWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNCreateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.CreateWebhookPayload) graphql.Marshaler { return ec._CreateWebhookPayload(ctx, sel, &v) } -func (ec *executionContext) marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") @@ -1815,16 +1815,16 @@ func (ec *executionContext) marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnais return ec._CreateWebhookPayload(ctx, sel, v) } -func (ec *executionContext) unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookInput(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { +func (ec *executionContext) unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookInput(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { res, err := ec.unmarshalInputDeleteWebhookInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNDeleteWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.DeleteWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNDeleteWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.DeleteWebhookPayload) graphql.Marshaler { return ec._DeleteWebhookPayload(ctx, sel, &v) } -func (ec *executionContext) marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") @@ -1834,16 +1834,16 @@ func (ec *executionContext) marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnais return ec._DeleteWebhookPayload(ctx, sel, v) } -func (ec *executionContext) unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookInput(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { +func (ec *executionContext) unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookInput(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { res, err := ec.unmarshalInputUpdateWebhookInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNUpdateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.UpdateWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNUpdateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.UpdateWebhookPayload) graphql.Marshaler { return ec._UpdateWebhookPayload(ctx, sel, &v) } -func (ec *executionContext) marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { +func (ec *executionContext) marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") @@ -1853,11 +1853,11 @@ func (ec *executionContext) marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnais return ec._UpdateWebhookPayload(ctx, sel, v) } -func (ec *executionContext) marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDeliveryᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { +func (ec *executionContext) marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDeliveryᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) fc.Result = &v[i] - return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx, sel, v[i]) + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx, sel, v[i]) }) for _, e := range ret { @@ -1869,7 +1869,7 @@ func (ec *executionContext) marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋ return ret } -func (ec *executionContext) marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookDelivery(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { +func (ec *executionContext) marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") @@ -1939,11 +1939,11 @@ func (ec *executionContext) marshalNWebhookEventTypeInfo2ᚖgithubᚗcomᚋnais return ec._WebhookEventTypeInfo(ctx, sel, v) } -func (ec *executionContext) marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscriptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { +func (ec *executionContext) marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscriptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) fc.Result = &v[i] - return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx, sel, v[i]) + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, sel, v[i]) }) for _, e := range ret { @@ -1955,7 +1955,7 @@ func (ec *executionContext) marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnai return ret } -func (ec *executionContext) marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋwebhookᚐWebhookSubscription(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { +func (ec *executionContext) marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") diff --git a/internal/graph/webhooks.resolvers.go b/internal/graph/webhooks.resolvers.go index edb48a034..485d74eb2 100644 --- a/internal/graph/webhooks.resolvers.go +++ b/internal/graph/webhooks.resolvers.go @@ -4,11 +4,11 @@ import ( "context" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/graph/gengql" "github.com/nais/api/internal/graph/pagination" "github.com/nais/api/internal/team" - "github.com/nais/api/internal/webhook" ) func (r *mutationResolver) CreateWebhook(ctx context.Context, input webhook.CreateWebhookInput) (*webhook.CreateWebhookPayload, error) { diff --git a/internal/persistence/opensearch/activitylog.go b/internal/persistence/opensearch/activitylog.go index 47a8ef54f..66df7ce75 100644 --- a/internal/persistence/opensearch/activitylog.go +++ b/internal/persistence/opensearch/activitylog.go @@ -67,13 +67,11 @@ func init() { ActivityLogEntryResourceTypeOpenSearch, activitylog.WithDescription("Triggered when service maintenance is started for an OpenSearch instance."), ) - // TODO(thokra): Inspect if we can just remove aivencredentials.ActivityLogActivityTypeCredentialsCreated activitylog.RegisterActivityType( - "OPENSEARCH_CREDENTIALS_CREATED", + "CREDENTIALS_CREATED", aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch, - activitylog.WithGroup("OpenSearch"), - activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance."), + activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."), ) } diff --git a/internal/persistence/valkey/activitylog.go b/internal/persistence/valkey/activitylog.go index 0e61ec08f..d214d5264 100644 --- a/internal/persistence/valkey/activitylog.go +++ b/internal/persistence/valkey/activitylog.go @@ -69,10 +69,10 @@ func init() { activitylog.WithDescription("Triggered when service maintenance is started for a Valkey."), ) activitylog.RegisterActivityType( - "VALKEY_CREDENTIALS_CREATED", + "CREDENTIALS_CREATED", aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey, - activitylog.WithDescription("Triggered when credentials are created for a Valkey."), + activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."), ) } From d1f60abee22873c8ca74dfa0527f04e36407ddc3 Mon Sep 17 00:00:00 2001 From: Thomas Krampl Date: Thu, 30 Jul 2026 14:04:28 +0200 Subject: [PATCH 6/6] Cleanups --- internal/activitylog/filter.go | 6 +- internal/activitylog/webhook/README.md | 78 ++++-- internal/activitylog/webhook/cleaner.go | 68 +++++ internal/activitylog/webhook/cloudevents.go | 8 +- internal/activitylog/webhook/dispatcher.go | 206 +++++++++++----- internal/activitylog/webhook/metrics.go | 21 +- internal/activitylog/webhook/model.go | 8 +- .../activitylog/webhook/queries/webhook.sql | 87 ++++++- .../activitylog/webhook/webhooksql/models.go | 141 ++++++++--- .../activitylog/webhook/webhooksql/querier.go | 19 +- .../webhook/webhooksql/webhook.sql.go | 233 ++++++++++++++---- internal/cmd/api/api.go | 5 + .../database/migrations/0072_webhooks.sql | 97 ++++---- .../aivencredentials/activitylog.go | 3 +- .../persistence/opensearch/activitylog.go | 2 +- internal/persistence/valkey/activitylog.go | 2 +- 16 files changed, 720 insertions(+), 264 deletions(-) create mode 100644 internal/activitylog/webhook/cleaner.go diff --git a/internal/activitylog/filter.go b/internal/activitylog/filter.go index 0fe19983c..78994a516 100644 --- a/internal/activitylog/filter.go +++ b/internal/activitylog/filter.go @@ -1,11 +1,11 @@ package activitylog import ( - "fmt" "slices" "strings" "github.com/jackc/pgx/v5/pgtype" + "github.com/sirupsen/logrus" ) type filter struct { @@ -57,7 +57,7 @@ func GlobalOnly() ActivityTypeOption { } } -// GlobalOnly marks the event type as global-only (not team-scoped). +// IgnoreWebhook excludes the event type from the webhook event type catalogue entirely. func IgnoreWebhook() ActivityTypeOption { return func(info *WebhookEventTypeInfo) { info.ignoreWebhook = true @@ -132,7 +132,7 @@ func KnownEventTypes() []WebhookEventTypeInfo { for at := range knownFilters { info, ok := eventTypeInfos[at] if !ok { - fmt.Println("Warning: activity type", at, "is registered but has no WebhookEventTypeInfo; using auto-generated description and group") + logrus.WithField("activity_type", at).Warn("activity type registered without webhook event type info; using auto-generated description and group") desc, grp := autoGroupAndDescription(at) info = WebhookEventTypeInfo{ Type: at, diff --git a/internal/activitylog/webhook/README.md b/internal/activitylog/webhook/README.md index 3f95f2f09..7e6168cbf 100644 --- a/internal/activitylog/webhook/README.md +++ b/internal/activitylog/webhook/README.md @@ -6,11 +6,15 @@ durable delivery via a PostgreSQL outbox, and automatic retry with exponential b ## Flow +Processing happens in two stages: outbox events are fanned out into per-subscription +delivery rows, and each delivery row is then retried independently of the others. + ```mermaid sequenceDiagram participant App as Application code participant AL as activity_log_entries participant WE as webhook_events (outbox) + participant ED as webhook_event_deliveries (per-subscriber queue) participant D as Dispatcher participant Sub as Subscriber endpoint @@ -18,12 +22,15 @@ sequenceDiagram AL->>WE: Trigger copies row + pg_notify('api_notify') D-->>WE: LISTEN / 30s poll fallback D->>WE: SELECT … FOR UPDATE SKIP LOCKED (claim batch) + D->>ED: Match against enabled subscriptions, INSERT one row per match + D->>WE: mark fanned-out event 'completed' (same transaction as above) + D->>ED: SELECT … FOR UPDATE SKIP LOCKED (claim batch) D->>Sub: HTTP POST CloudEvent (HMAC-signed) alt 2xx - D->>WE: status = 'completed' + D->>ED: status = 'completed' D->>webhook_subscriptions: reset consecutive_failures else failure / timeout - D->>WE: requeue with run_at = NOW() + backoff + D->>ED: requeue this row with run_at = NOW() + backoff D->>webhook_subscriptions: increment consecutive_failures note over D: auto-disable after 10 consecutive failures end @@ -31,20 +38,46 @@ sequenceDiagram ## Key components -| File | Responsibility | -| ---------------- | ------------------------------------------------------------------- | -| `dispatcher.go` | Outbox consumer: LISTEN/NOTIFY + poll, claim events, deliver, retry | -| `cloudevents.go` | Build CloudEvents 1.0 envelope; derive `type` from activity type | -| `signer.go` | HMAC-SHA256 payload signing (`X-Webhook-Signature` header) | -| `model.go` | Domain types; `MatchesEvent` subscription/event matching logic | -| `queries.go` | CRUD operations with authorisation | -| `dataloader.go` | Context-scoped DB + dispatcher access | +| File | Responsibility | +| ---------------- | ---------------------------------------------------------------------- | +| `dispatcher.go` | Outbox consumer: LISTEN/NOTIFY + poll, fan-out, claim, deliver, retry | +| `cleaner.go` | Daily leader-only pruning of processed outbox/queue and old deliveries | +| `cloudevents.go` | Build CloudEvents 1.0 envelope; derive `type` from activity type | +| `signer.go` | HMAC-SHA256 payload signing (`X-Webhook-Signature` header) | +| `model.go` | Domain types; `MatchesEvent` subscription/event matching logic | +| `queries.go` | CRUD operations with authorisation | +| `dataloader.go` | Context-scoped DB + dispatcher access | ## Database tables - **`webhook_subscriptions`** — registered endpoints (URL, secret, event_types, team scope) -- **`webhook_events`** — lightweight outbox; each row is just a reference (`activity_log_entries_id`) to the source event, inserted by a PostgreSQL trigger on `activity_log_entries`. No data duplication. -- **`webhook_deliveries`** — audit log of every delivery attempt +- **`webhook_events`** — lightweight outbox; each row is just a reference (`activity_log_entries_id`) to the source event, inserted by a PostgreSQL trigger on `activity_log_entries`. No data duplication. A row's job is done once it has been "fanned out" (see below); it doesn't track delivery outcomes itself. +- **`webhook_event_deliveries`** — one row per `(webhook_event, subscription)` match, created by the dispatcher's fan-out step. This is the actual unit of retry: `status`/`retry_count`/`run_at` here are scoped to a single subscriber's delivery of a single event, so retries never touch other subscribers. +- **`webhook_deliveries`** — audit log of every actual HTTP delivery attempt, optionally linked back to the `webhook_event_deliveries` row that produced it. + +### Why two stages? + +Subscription matching (event-type wildcards `*`, team scoping) is application logic that +lives in Go (`activitylog.RegisterActivityType`/`MatchesEvent`), so it can't be done by the +trigger. The trigger only records that an event happened; the dispatcher fans it out into +one delivery row per matching subscription, and retry/backoff bookkeeping happens at that +level. + +## Multi-instance safety + +The dispatcher is started on every API replica (`go webhookDispatcher.Run(ctx)`, unconditional — no leader election). This is safe: + +- Postgres `NOTIFY` on `api_notify` is broadcast to every connection currently `LISTEN`ing on it, so all replicas wake up when new work arrives (plus a 30s poll fallback per replica in case a notification is missed). +- Both the fan-out claim (`ClaimOutboxEventsForFanout`) and the delivery claim (`ClaimPendingDeliveries`) use `SELECT ... FOR UPDATE SKIP LOCKED`. Concurrent replicas racing on these queries can never select the same row — whichever transaction locks a row first "wins" it, and everyone else's `SKIP LOCKED` simply skips it and claims different rows instead. More replicas just means more parallel draining capacity, never duplicate work. +- Fan-out (claiming an event, matching subscriptions, inserting delivery rows, marking the event completed) happens in a single DB transaction. `CreateEventDelivery` is idempotent (`ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING`), so an interrupted or repeated fan-out attempt is safe. +- Delivery marks a `webhook_event_deliveries` row `completed` at claim time, before the HTTP call is made. A replica killed mid-delivery could lose that one delivery without a retry — a known trade-off; a `processing` status with a lease and reaper would close this gap if it becomes a problem in practice. + +## Retention & cleanup + +`RunCleaner` runs once a day on every replica, but only the current leader (via `leaderelection.IsLeader`) actually performs deletes, so pruning happens exactly once cluster-wide per interval: + +- `webhook_events` and `webhook_event_deliveries` (internal processing state) are pruned after 7 days. +- `webhook_deliveries` (the user-facing delivery audit log) is pruned after 30 days. ## Event types @@ -85,6 +118,13 @@ TEAM_MEMBER_ADDED → io.nais.team.member.added POSTGRES_DELETED → io.nais.postgres.deleted ``` +### Idempotency / deduplication + +The CloudEvents `id` field is set to the `webhook_event_deliveries` row's own id, which is +stable across retries of that specific `(event, subscription)` delivery — it does **not** +change if a delivery is retried after a failure. Subscribers that need exactly-once +processing semantics should treat delivery as **at-least-once** and deduplicate on `id`. + ## Retry schedule | Attempt | Delay | @@ -97,8 +137,11 @@ POSTGRES_DELETED → io.nais.postgres.deleted | 6 | 8 hours | | 7 | 12 hours | -After 7 failed attempts the event is marked `failed`. After 10 consecutive failures across any -events, the subscription is automatically disabled (`enabled = false`, `disabled_at` set). +After 7 failed attempts the delivery is marked `failed`. After 10 consecutive failures across +any deliveries, the subscription is automatically disabled (`enabled = false`, `disabled_at` set). +Retries and the failure counter are both scoped per subscriber — one broken subscriber +retrying (and eventually being auto-disabled) has no effect on other subscribers of the +same events. ## Authorisation @@ -114,13 +157,13 @@ The webhook domain exports telemetry using native OpenTelemetry metrics under th ### PromQL Alerts Examples -1. **Increasing outbox queue size** (Potential worker blockage or overload): +1. **Increasing delivery queue size** (Potential worker blockage or overload): ```promql - sum(webhook_queue_size{status="pending"}) > 100 + max(nais_api_webhook_queue_size{status="pending"}) > 100 ``` - _Trigger conditions_: Only the **leader pod** queries the database for `webhook_queue_size` to prevent double-counting in multi-replica deployments. + _Trigger conditions_: `nais_api_webhook_queue_size` reads shared Postgres state (a plain `COUNT(*) GROUP BY status`), so **every** replica reports the identical number — it is not gated behind leader election. Use `max()` or `avg()` when aggregating across replicas, **not `sum()`**, since summing would multiply the true value by the replica count. 2. **High webhook delivery failure rate**: @@ -129,6 +172,7 @@ The webhook domain exports telemetry using native OpenTelemetry metrics under th ``` 3. **Auto-disabled subscriptions rate**: + ```promql sum(rate(webhook_subscriptions_auto_disabled_total[1h])) > 0 ``` diff --git a/internal/activitylog/webhook/cleaner.go b/internal/activitylog/webhook/cleaner.go new file mode 100644 index 000000000..d24f1aaa7 --- /dev/null +++ b/internal/activitylog/webhook/cleaner.go @@ -0,0 +1,68 @@ +package webhook + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/leaderelection" + "github.com/sirupsen/logrus" +) + +const ( + cleanupInterval = 24 * time.Hour + + // queueRetention controls how long completed/failed rows are kept in the internal + // processing tables (webhook_events, webhook_event_deliveries) after they've reached + // a terminal state. + queueRetention = 7 * 24 * time.Hour + + // deliveryRetention controls how long delivery attempts are kept in the user-facing + // audit log (webhook_deliveries). + deliveryRetention = 30 * 24 * time.Hour +) + +// RunCleaner periodically prunes old webhook processing and delivery data. Blocks until +// ctx is cancelled. Only the current leader replica performs deletes; other replicas are +// a no-op on each tick. +func RunCleaner(ctx context.Context, dbtx webhooksql.DBTX, log logrus.FieldLogger) { + q := webhooksql.New(dbtx) + + for { + if err := clean(ctx, q); err != nil { + log.WithError(err).Error("cleaning webhook data") + } + + select { + case <-ctx.Done(): + return + case <-time.After(cleanupInterval): + } + } +} + +func clean(ctx context.Context, q *webhooksql.Queries) error { + if !leaderelection.IsLeader() { + return nil + } + + now := time.Now() + queueBefore := pgtype.Timestamptz{Time: now.Add(-queueRetention), Valid: true} + deliveryBefore := pgtype.Timestamptz{Time: now.Add(-deliveryRetention), Valid: true} + + if err := q.PruneOldOutboxEvents(ctx, queueBefore); err != nil { + return fmt.Errorf("pruning outbox events: %w", err) + } + + if err := q.PruneOldEventDeliveries(ctx, queueBefore); err != nil { + return fmt.Errorf("pruning event deliveries: %w", err) + } + + if err := q.PruneDeliveries(ctx, deliveryBefore); err != nil { + return fmt.Errorf("pruning delivery audit log: %w", err) + } + + return nil +} diff --git a/internal/activitylog/webhook/cloudevents.go b/internal/activitylog/webhook/cloudevents.go index bb3059e7b..0e44bc9f7 100644 --- a/internal/activitylog/webhook/cloudevents.go +++ b/internal/activitylog/webhook/cloudevents.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/nais/api/internal/activitylog" ) @@ -45,7 +44,10 @@ func cloudEventTypeFromEvent(event WebhookEvent) string { } // BuildCloudEvent creates a CloudEvents 1.0 envelope from a webhook event. -func BuildCloudEvent(source string, event WebhookEvent) ([]byte, error) { +// +// id must be stable across redeliveries of the same logical (event, subscriber) delivery +// attempt. +func BuildCloudEvent(source, id string, event WebhookEvent) ([]byte, error) { var teamSlug *string if event.TeamSlug != nil { s := event.TeamSlug.String() @@ -73,7 +75,7 @@ func BuildCloudEvent(source string, event WebhookEvent) ([]byte, error) { ce := CloudEvent{ SpecVersion: "1.0", - ID: uuid.New().String(), + ID: id, Source: source, Type: cloudEventTypeFromEvent(event), Subject: subject, diff --git a/internal/activitylog/webhook/dispatcher.go b/internal/activitylog/webhook/dispatcher.go index 7d46fb701..0222b8758 100644 --- a/internal/activitylog/webhook/dispatcher.go +++ b/internal/activitylog/webhook/dispatcher.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/nais/api/internal/activitylog" @@ -90,48 +91,124 @@ func (d *Dispatcher) Run(ctx context.Context) { d.drainOutbox(ctx) case <-time.After(pollInterval): // Safety net: poll periodically in case a notification was missed - // or to pick up events whose run_at has arrived + // or to pick up events/deliveries whose run_at has arrived. d.drainOutbox(ctx) } } } +// drainOutbox fans pending outbox events out into per-subscription delivery rows, then +// drains and delivers any pending delivery rows. func (d *Dispatcher) drainOutbox(ctx context.Context) { + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) +} + +// fanOutPendingEvents claims batches of outbox events that haven't been matched against +// subscriptions yet, and creates one webhook_event_deliveries row per currently enabled +// subscription that matches. Subscription matching only happens here; every later retry +// operates on a single (event, subscription) delivery row. +func (d *Dispatcher) fanOutPendingEvents(ctx context.Context) { + for { + more, err := d.fanOutBatch(ctx) + if err != nil { + d.log.WithError(err).Error("fanning out webhook outbox events") + return + } + if !more { + return + } + } +} + +func (d *Dispatcher) fanOutBatch(ctx context.Context) (bool, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning fan-out transaction: %w", err) + } + defer tx.Rollback(ctx) // no-op once committed + + q := webhooksql.New(d.pool).WithTx(tx) + + claimed, err := q.ClaimOutboxEventsForFanout(ctx, eventBatchSize) + if err != nil { + return false, fmt.Errorf("claiming outbox events: %w", err) + } + if len(claimed) == 0 { + return false, nil + } + + subs, err := q.ListEnabledSubscriptions(ctx) + if err != nil { + return false, fmt.Errorf("listing enabled webhook subscriptions: %w", err) + } + + ids := make([]uuid.UUID, 0, len(claimed)) + for _, row := range claimed { + ids = append(ids, row.WebhookEvent.ID) + + event := toWebhookEvent(&row.ActivityLogEntry) + for _, sub := range subs { + if !toGraphSubscription(sub).MatchesEvent(event) { + continue + } + + if err := q.CreateEventDelivery(ctx, webhooksql.CreateEventDeliveryParams{ + WebhookEventID: row.WebhookEvent.ID, + SubscriptionID: sub.ID, + }); err != nil { + return false, fmt.Errorf("creating event delivery: %w", err) + } + } + } + + // Fan-out and marking the event completed happen in the same transaction, so a failed + // or interrupted attempt simply leaves the event pending for a later retry. + if err := q.MarkOutboxEventsCompleted(ctx, ids); err != nil { + return false, fmt.Errorf("marking outbox events fanned out: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("committing fan-out transaction: %w", err) + } + + return true, nil +} + +// drainPendingDeliveries claims and processes batches of per-subscription delivery rows. +func (d *Dispatcher) drainPendingDeliveries(ctx context.Context) { q := webhooksql.New(d.pool) for { - events, err := q.ClaimPendingEvents(ctx, eventBatchSize) + deliveries, err := q.ClaimPendingDeliveries(ctx, eventBatchSize) if err != nil { - d.log.WithError(err).Error("claiming pending webhook events") + d.log.WithError(err).Error("claiming pending webhook deliveries") return } - if len(events) == 0 { + if len(deliveries) == 0 { return } - for _, evt := range events { - d.processEvent(ctx, q, &evt.WebhookEvent, &evt.ActivityLogEntry) + for _, row := range deliveries { + d.processDelivery(ctx, q, &row.WebhookEventDelivery, &row.WebhookSubscription, &row.ActivityLogEntry) } } } -func (d *Dispatcher) processEvent(ctx context.Context, q *webhooksql.Queries, evt *webhooksql.WebhookEvent, a *webhooksql.ActivityLogEntry) { - subs, err := q.ListEnabledSubscriptions(ctx) - if err != nil { - d.log.WithError(err).Error("listing enabled webhook subscriptions") - return - } +// toWebhookEvent builds the internal WebhookEvent representation (used both for matching +// subscriptions and for building the CloudEvent payload) from a stored activity log entry. +func toWebhookEvent(a *webhooksql.ActivityLogEntry) WebhookEvent { + rawEventType := a.ResourceType + ":" + a.Action // Resolve "RESOURCE_TYPE:ACTION" → ActivityLogActivityType names // (e.g. ResourceType="TEAM", Action="ADDED" → ["TEAM_MEMBER_ADDED"]). - rawEventType := a.ResourceType + ":" + a.Action resolved := activitylog.LookupActivityTypes(a.ResourceType, a.Action) activityTypes := make([]string, len(resolved)) for i, at := range resolved { activityTypes[i] = string(at) } - // Fall back to raw type if no mapping is registered, so the event is still deliverable. + // Fall back to the raw type if no mapping is registered, so the event is still deliverable. if len(activityTypes) == 0 { activityTypes = []string{rawEventType} } @@ -142,7 +219,7 @@ func (d *Dispatcher) processEvent(ctx context.Context, q *webhooksql.Queries, ev teamSlug = &s } - event := WebhookEvent{ + return WebhookEvent{ ActivityTypes: activityTypes, RawEventType: rawEventType, TeamSlug: teamSlug, @@ -152,61 +229,63 @@ func (d *Dispatcher) processEvent(ctx context.Context, q *webhooksql.Queries, ev Environment: a.Environment, Data: a.Data, } +} + +func (d *Dispatcher) processDelivery(ctx context.Context, q *webhooksql.Queries, del *webhooksql.WebhookEventDelivery, sub *webhooksql.WebhookSubscription, a *webhooksql.ActivityLogEntry) { + event := toWebhookEvent(a) + + // Use the first resolved activity type as the delivery event type label. + eventType := event.RawEventType + if len(event.ActivityTypes) > 0 { + eventType = event.ActivityTypes[0] + } - payload, err := BuildCloudEvent(d.source, event) + // The CloudEvent id is derived from the delivery row's own id, so it stays stable across retries. + payload, err := BuildCloudEvent(d.source, del.ID.String(), event) if err != nil { d.log.WithError(err).Error("building CloudEvent payload") return } - // Use the first resolved activity type as the delivery event type label. - deliveryEventType := activityTypes[0] + success := d.deliver(ctx, q, sub, eventType, payload, &del.ID) - anyFailed := false - for _, sub := range subs { - graphSub := toGraphSubscription(sub) - if !graphSub.MatchesEvent(event) { - continue - } - - success := d.deliver(ctx, q, sub, deliveryEventType, payload) - if !success { - anyFailed = true - } + if success { + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "completed"), + )) + return } - // If any delivery failed, requeue with exponential backoff or mark as permanently failed. - if anyFailed { - nextRetry := int(evt.RetryCount) + 1 - if nextRetry <= maxRetryCount { - backoff := retryBackoffs[min(nextRetry-1, len(retryBackoffs)-1)] - runAt := time.Now().Add(backoff) - if err := q.RequeueEvent(ctx, webhooksql.RequeueEventParams{ - ID: evt.ID, - RetryCount: int32(nextRetry), - RunAt: pgtype.Timestamptz{Time: runAt, Valid: true}, - }); err != nil { - d.log.WithError(err).Error("requeueing webhook event") - } - d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( - attribute.String("status", "requeued"), - )) - } else { - if err := q.MarkEventFailed(ctx, evt.ID); err != nil { - d.log.WithError(err).Error("marking webhook event as failed") - } - d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( - attribute.String("status", "failed"), - )) + // Requeue this delivery with exponential backoff, or mark it permanently failed once + // the retry budget is exhausted. + nextRetry := int(del.RetryCount) + 1 + if nextRetry <= maxRetryCount { + backoff := retryBackoffs[min(nextRetry-1, len(retryBackoffs)-1)] + runAt := time.Now().Add(backoff) + if err := q.RequeueDelivery(ctx, webhooksql.RequeueDeliveryParams{ + ID: del.ID, + RetryCount: int32(nextRetry), + RunAt: pgtype.Timestamptz{Time: runAt, Valid: true}, + }); err != nil { + d.log.WithError(err).Error("requeueing webhook delivery") } + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "requeued"), + )) } else { + if err := q.MarkDeliveryFailed(ctx, del.ID); err != nil { + d.log.WithError(err).Error("marking webhook delivery as failed") + } d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( - attribute.String("status", "completed"), + attribute.String("status", "failed"), )) } } -func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *webhooksql.WebhookSubscription, eventType string, payload []byte) bool { +// deliver sends a single HTTP delivery attempt to sub and records it in the audit log. +// deliveryRowID links the audit row back to the originating webhook_event_deliveries row, +// and is nil for ad hoc deliveries (e.g. Ping) that aren't backed by a queue row. +func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *webhooksql.WebhookSubscription, eventType string, payload []byte, deliveryRowID *uuid.UUID) bool { signature := SignPayload(sub.Secret, payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Url, bytes.NewReader(payload)) @@ -265,13 +344,14 @@ func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *we // Record delivery attempt if _, recordErr := q.CreateDelivery(ctx, webhooksql.CreateDeliveryParams{ - SubscriptionID: sub.ID, - EventType: eventType, - RequestBody: payload, - ResponseStatus: responseStatus, - ResponseBody: responseBody, - DurationMs: durationMs, - Success: success, + SubscriptionID: sub.ID, + WebhookEventDeliveryID: deliveryRowID, + EventType: eventType, + RequestBody: payload, + ResponseStatus: responseStatus, + ResponseBody: responseBody, + DurationMs: durationMs, + Success: success, }); recordErr != nil { d.log.WithError(recordErr).Error("recording webhook delivery") } @@ -320,7 +400,7 @@ func (d *Dispatcher) Ping(ctx context.Context, sub *WebhookSubscription) error { ResourceName: sub.UUID.String(), } - payload, err := BuildCloudEvent(d.source, pingEvent) + payload, err := BuildCloudEvent(d.source, uuid.New().String(), pingEvent) if err != nil { return fmt.Errorf("building ping CloudEvent: %w", err) } @@ -331,6 +411,6 @@ func (d *Dispatcher) Ping(ctx context.Context, sub *WebhookSubscription) error { Url: sub.URL, Secret: sub.Secret, } - d.deliver(ctx, q, dbSub, "ping", payload) + d.deliver(ctx, q, dbSub, "ping", payload, nil) return nil } diff --git a/internal/activitylog/webhook/metrics.go b/internal/activitylog/webhook/metrics.go index 890f9e84c..55fc9dbe0 100644 --- a/internal/activitylog/webhook/metrics.go +++ b/internal/activitylog/webhook/metrics.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/nais/api/internal/activitylog/webhook/webhooksql" - "github.com/nais/api/internal/leaderelection" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -41,7 +40,7 @@ func newWebhookMetrics(q *webhooksql.Queries) (*webhookMetrics, error) { processedCounter, err := meter.Int64Counter( "nais_api_webhook_events_processed_total", - metric.WithDescription("Total number of outbox webhook events processed by the dispatcher."), + metric.WithDescription("Total number of outbox webhook deliveries processed by the dispatcher."), ) if err != nil { return nil, fmt.Errorf("create processed counter: %w", err) @@ -62,26 +61,20 @@ func newWebhookMetrics(q *webhooksql.Queries) (*webhookMetrics, error) { autoDisabledCounter: autoDisabledCounter, } - // Register the asynchronous gauge for queue size (runs on demand when scraped) queueSizeGauge, err := meter.Int64ObservableGauge( "nais_api_webhook_queue_size", - metric.WithDescription("Current size of the webhook outbox queue grouped by status."), + metric.WithDescription("Current size of the webhook delivery queue grouped by status. Reported identically by every replica; aggregate with max()/avg(), not sum()."), metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { - // ONLY the leader pod queries the database to avoid replica double-counting - if !leaderelection.IsLeader() { - return nil - } - rows, err := q.GetQueueSizeByStatus(ctx) if err != nil { return err } - // Active map to track existing statuses to report (reporting 0 if none exist is helpful) - statuses := map[webhooksql.WebhookEventStatus]int64{ - webhooksql.WebhookEventStatusPending: 0, - webhooksql.WebhookEventStatusCompleted: 0, - webhooksql.WebhookEventStatusFailed: 0, + // Ensure every known status is reported, even if its count is currently 0. + statuses := map[webhooksql.WebhookDeliveryStatus]int64{ + webhooksql.WebhookDeliveryStatusPending: 0, + webhooksql.WebhookDeliveryStatusCompleted: 0, + webhooksql.WebhookDeliveryStatusFailed: 0, } for _, row := range rows { diff --git a/internal/activitylog/webhook/model.go b/internal/activitylog/webhook/model.go index 989f973b1..448b8e64d 100644 --- a/internal/activitylog/webhook/model.go +++ b/internal/activitylog/webhook/model.go @@ -25,7 +25,7 @@ type WebhookSubscription struct { func (WebhookSubscription) IsNode() {} -func (w WebhookSubscription) GetID() ident.Ident { +func (w WebhookSubscription) ID() ident.Ident { return newSubscriptionIdent(w.UUID) } @@ -48,7 +48,7 @@ type WebhookDelivery struct { func (WebhookDelivery) IsNode() {} -func (w WebhookDelivery) GetID() ident.Ident { +func (w WebhookDelivery) ID() ident.Ident { return newDeliveryIdent(w.UUID) } @@ -130,7 +130,3 @@ func (w *WebhookSubscription) MatchesEvent(event WebhookEvent) bool { return false } - -// Node interface compatibility -func (w WebhookSubscription) ID() ident.Ident { return w.GetID() } -func (w WebhookDelivery) ID() ident.Ident { return w.GetID() } diff --git a/internal/activitylog/webhook/queries/webhook.sql b/internal/activitylog/webhook/queries/webhook.sql index 347e76b8a..0fca5e54f 100644 --- a/internal/activitylog/webhook/queries/webhook.sql +++ b/internal/activitylog/webhook/queries/webhook.sql @@ -126,6 +126,7 @@ WHERE INSERT INTO webhook_deliveries ( subscription_id, + webhook_event_delivery_id, event_type, request_body, response_status, @@ -136,6 +137,7 @@ INSERT INTO VALUES ( @subscription_id, + sqlc.narg(webhook_event_delivery_id), @event_type, @request_body, @response_status, @@ -189,10 +191,48 @@ WHERE created_at < @before ; --- name: ClaimPendingEvents :many +-- name: ClaimOutboxEventsForFanout :many +-- Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher +-- instances claim batches concurrently without claiming the same row. Rows are marked +-- completed by the caller after fan-out succeeds, not by this query. +SELECT + sqlc.embed(webhook_events), + sqlc.embed(activity_log_entries) +FROM + webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +WHERE + webhook_events.status = 'pending' +ORDER BY + webhook_events.created_at ASC +LIMIT + sqlc.arg('batch_size') +FOR UPDATE OF + webhook_events SKIP LOCKED +; + +-- name: MarkOutboxEventsCompleted :exec +UPDATE webhook_events +SET + status = 'completed' +WHERE + id = ANY (@ids::UUID[]) +; + +-- name: CreateEventDelivery :exec +INSERT INTO + webhook_event_deliveries (webhook_event_id, subscription_id) +VALUES + (@webhook_event_id, @subscription_id) +ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING +; + +-- name: ClaimPendingDeliveries :many +-- Claims per-(event, subscription) delivery rows for processing, using the same +-- FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. WITH - updated_events AS ( - UPDATE webhook_events + claimed_deliveries AS ( + UPDATE webhook_event_deliveries SET status = 'completed' WHERE @@ -200,14 +240,14 @@ WITH SELECT id FROM - webhook_events + webhook_event_deliveries WHERE status = 'pending' AND run_at <= NOW() ORDER BY run_at ASC LIMIT - @batch_size + sqlc.arg('batch_size') FOR UPDATE SKIP LOCKED ) @@ -215,15 +255,18 @@ WITH * ) SELECT - sqlc.embed(webhook_events), + sqlc.embed(webhook_event_deliveries), + sqlc.embed(webhook_subscriptions), sqlc.embed(activity_log_entries) FROM - updated_events webhook_events + claimed_deliveries webhook_event_deliveries + JOIN webhook_subscriptions ON webhook_event_deliveries.subscription_id = webhook_subscriptions.id + JOIN webhook_events ON webhook_event_deliveries.webhook_event_id = webhook_events.id JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id ; --- name: RequeueEvent :exec -UPDATE webhook_events +-- name: RequeueDelivery :exec +UPDATE webhook_event_deliveries SET status = 'pending', retry_count = @retry_count, @@ -232,16 +275,34 @@ WHERE id = @id ; --- name: MarkEventFailed :exec -UPDATE webhook_events +-- name: MarkDeliveryFailed :exec +UPDATE webhook_event_deliveries SET status = 'failed' WHERE id = @id ; --- name: PruneOldEvents :exec +-- name: PruneOldOutboxEvents :exec +-- Prunes outbox events whose deliveries have all reached a terminal state (a delete +-- cascades to webhook_event_deliveries, so pending rows must be excluded). DELETE FROM webhook_events +WHERE + webhook_events.created_at < @before + AND webhook_events.status = 'completed' + AND NOT EXISTS ( + SELECT + 1 + FROM + webhook_event_deliveries + WHERE + webhook_event_deliveries.webhook_event_id = webhook_events.id + AND webhook_event_deliveries.status = 'pending' + ) +; + +-- name: PruneOldEventDeliveries :exec +DELETE FROM webhook_event_deliveries WHERE created_at < @before AND status IN ('completed', 'failed') @@ -252,7 +313,7 @@ SELECT status, COUNT(*) AS count FROM - webhook_events + webhook_event_deliveries GROUP BY status ORDER BY diff --git a/internal/activitylog/webhook/webhooksql/models.go b/internal/activitylog/webhook/webhooksql/models.go index 26b21b6e7..238cb5644 100644 --- a/internal/activitylog/webhook/webhooksql/models.go +++ b/internal/activitylog/webhook/webhooksql/models.go @@ -11,64 +11,122 @@ import ( "github.com/nais/api/internal/slug" ) -type WebhookEventStatus string +type WebhookDeliveryStatus string const ( - WebhookEventStatusPending WebhookEventStatus = "pending" - WebhookEventStatusCompleted WebhookEventStatus = "completed" - WebhookEventStatusFailed WebhookEventStatus = "failed" + WebhookDeliveryStatusPending WebhookDeliveryStatus = "pending" + WebhookDeliveryStatusCompleted WebhookDeliveryStatus = "completed" + WebhookDeliveryStatusFailed WebhookDeliveryStatus = "failed" ) -func (e *WebhookEventStatus) Scan(src interface{}) error { +func (e *WebhookDeliveryStatus) Scan(src interface{}) error { switch s := src.(type) { case []byte: - *e = WebhookEventStatus(s) + *e = WebhookDeliveryStatus(s) case string: - *e = WebhookEventStatus(s) + *e = WebhookDeliveryStatus(s) default: - return fmt.Errorf("unsupported scan type for WebhookEventStatus: %T", src) + return fmt.Errorf("unsupported scan type for WebhookDeliveryStatus: %T", src) } return nil } -type NullWebhookEventStatus struct { - WebhookEventStatus WebhookEventStatus - Valid bool // Valid is true if WebhookEventStatus is not NULL +type NullWebhookDeliveryStatus struct { + WebhookDeliveryStatus WebhookDeliveryStatus + Valid bool // Valid is true if WebhookDeliveryStatus is not NULL } // Scan implements the Scanner interface. -func (ns *NullWebhookEventStatus) Scan(value interface{}) error { +func (ns *NullWebhookDeliveryStatus) Scan(value interface{}) error { if value == nil { - ns.WebhookEventStatus, ns.Valid = "", false + ns.WebhookDeliveryStatus, ns.Valid = "", false return nil } ns.Valid = true - return ns.WebhookEventStatus.Scan(value) + return ns.WebhookDeliveryStatus.Scan(value) } // Value implements the driver Valuer interface. -func (ns NullWebhookEventStatus) Value() (driver.Value, error) { +func (ns NullWebhookDeliveryStatus) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } - return string(ns.WebhookEventStatus), nil + return string(ns.WebhookDeliveryStatus), nil } -func (e WebhookEventStatus) Valid() bool { +func (e WebhookDeliveryStatus) Valid() bool { switch e { - case WebhookEventStatusPending, - WebhookEventStatusCompleted, - WebhookEventStatusFailed: + case WebhookDeliveryStatusPending, + WebhookDeliveryStatusCompleted, + WebhookDeliveryStatusFailed: return true } return false } -func AllWebhookEventStatusValues() []WebhookEventStatus { - return []WebhookEventStatus{ - WebhookEventStatusPending, - WebhookEventStatusCompleted, - WebhookEventStatusFailed, +func AllWebhookDeliveryStatusValues() []WebhookDeliveryStatus { + return []WebhookDeliveryStatus{ + WebhookDeliveryStatusPending, + WebhookDeliveryStatusCompleted, + WebhookDeliveryStatusFailed, + } +} + +type WebhookOutboxStatus string + +const ( + WebhookOutboxStatusPending WebhookOutboxStatus = "pending" + WebhookOutboxStatusCompleted WebhookOutboxStatus = "completed" +) + +func (e *WebhookOutboxStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WebhookOutboxStatus(s) + case string: + *e = WebhookOutboxStatus(s) + default: + return fmt.Errorf("unsupported scan type for WebhookOutboxStatus: %T", src) + } + return nil +} + +type NullWebhookOutboxStatus struct { + WebhookOutboxStatus WebhookOutboxStatus + Valid bool // Valid is true if WebhookOutboxStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWebhookOutboxStatus) Scan(value interface{}) error { + if value == nil { + ns.WebhookOutboxStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WebhookOutboxStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWebhookOutboxStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WebhookOutboxStatus), nil +} + +func (e WebhookOutboxStatus) Valid() bool { + switch e { + case WebhookOutboxStatusPending, + WebhookOutboxStatusCompleted: + return true + } + return false +} + +func AllWebhookOutboxStatusValues() []WebhookOutboxStatus { + return []WebhookOutboxStatus{ + WebhookOutboxStatusPending, + WebhookOutboxStatusCompleted, } } @@ -85,26 +143,35 @@ type ActivityLogEntry struct { } type WebhookDelivery struct { - ID uuid.UUID - SubscriptionID uuid.UUID - EventType string - RequestBody []byte - ResponseStatus *int32 - ResponseBody *string - DurationMs int32 - Success bool - CreatedAt pgtype.Timestamptz + ID uuid.UUID + SubscriptionID uuid.UUID + WebhookEventDeliveryID *uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool + CreatedAt pgtype.Timestamptz } type WebhookEvent struct { ID uuid.UUID ActivityLogEntriesID uuid.UUID - Status WebhookEventStatus - RetryCount int32 - RunAt pgtype.Timestamptz + Status WebhookOutboxStatus CreatedAt pgtype.Timestamptz } +type WebhookEventDelivery struct { + ID uuid.UUID + WebhookEventID uuid.UUID + SubscriptionID uuid.UUID + Status WebhookDeliveryStatus + RetryCount int32 + RunAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz +} + type WebhookSubscription struct { ID uuid.UUID TeamSlug *slug.Slug diff --git a/internal/activitylog/webhook/webhooksql/querier.go b/internal/activitylog/webhook/webhooksql/querier.go index 691421e68..b2fa4311d 100644 --- a/internal/activitylog/webhook/webhooksql/querier.go +++ b/internal/activitylog/webhook/webhooksql/querier.go @@ -10,8 +10,15 @@ import ( ) type Querier interface { - ClaimPendingEvents(ctx context.Context, batchSize int32) ([]*ClaimPendingEventsRow, error) + // Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher + // instances claim batches concurrently without claiming the same row. Rows are marked + // completed by the caller after fan-out succeeds, not by this query. + ClaimOutboxEventsForFanout(ctx context.Context, batchSize int32) ([]*ClaimOutboxEventsForFanoutRow, error) + // Claims per-(event, subscription) delivery rows for processing, using the same + // FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. + ClaimPendingDeliveries(ctx context.Context, batchSize int32) ([]*ClaimPendingDeliveriesRow, error) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) + CreateEventDelivery(ctx context.Context, arg CreateEventDeliveryParams) error CreateSubscription(ctx context.Context, arg CreateSubscriptionParams) (*WebhookSubscription, error) DeleteSubscription(ctx context.Context, id uuid.UUID) error DisableSubscription(ctx context.Context, id uuid.UUID) error @@ -25,10 +32,14 @@ type Querier interface { ListGlobalSubscriptions(ctx context.Context, arg ListGlobalSubscriptionsParams) ([]*ListGlobalSubscriptionsRow, error) ListSubscriptionsByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, error) ListSubscriptionsForTeam(ctx context.Context, arg ListSubscriptionsForTeamParams) ([]*ListSubscriptionsForTeamRow, error) - MarkEventFailed(ctx context.Context, id uuid.UUID) error + MarkDeliveryFailed(ctx context.Context, id uuid.UUID) error + MarkOutboxEventsCompleted(ctx context.Context, ids []uuid.UUID) error PruneDeliveries(ctx context.Context, before pgtype.Timestamptz) error - PruneOldEvents(ctx context.Context, before pgtype.Timestamptz) error - RequeueEvent(ctx context.Context, arg RequeueEventParams) error + PruneOldEventDeliveries(ctx context.Context, before pgtype.Timestamptz) error + // Prunes outbox events whose deliveries have all reached a terminal state (a delete + // cascades to webhook_event_deliveries, so pending rows must be excluded). + PruneOldOutboxEvents(ctx context.Context, before pgtype.Timestamptz) error + RequeueDelivery(ctx context.Context, arg RequeueDeliveryParams) error ResetConsecutiveFailures(ctx context.Context, id uuid.UUID) error UpdateSubscription(ctx context.Context, arg UpdateSubscriptionParams) (*WebhookSubscription, error) } diff --git a/internal/activitylog/webhook/webhooksql/webhook.sql.go b/internal/activitylog/webhook/webhooksql/webhook.sql.go index 088882539..f82ec55d1 100644 --- a/internal/activitylog/webhook/webhooksql/webhook.sql.go +++ b/internal/activitylog/webhook/webhooksql/webhook.sql.go @@ -11,10 +11,69 @@ import ( "github.com/nais/api/internal/slug" ) -const claimPendingEvents = `-- name: ClaimPendingEvents :many +const claimOutboxEventsForFanout = `-- name: ClaimOutboxEventsForFanout :many +SELECT + webhook_events.id, webhook_events.activity_log_entries_id, webhook_events.status, webhook_events.created_at, + activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment +FROM + webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +WHERE + webhook_events.status = 'pending' +ORDER BY + webhook_events.created_at ASC +LIMIT + $1 +FOR UPDATE OF + webhook_events SKIP LOCKED +` + +type ClaimOutboxEventsForFanoutRow struct { + WebhookEvent WebhookEvent + ActivityLogEntry ActivityLogEntry +} + +// Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher +// instances claim batches concurrently without claiming the same row. Rows are marked +// completed by the caller after fan-out succeeds, not by this query. +func (q *Queries) ClaimOutboxEventsForFanout(ctx context.Context, batchSize int32) ([]*ClaimOutboxEventsForFanoutRow, error) { + rows, err := q.db.Query(ctx, claimOutboxEventsForFanout, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ClaimOutboxEventsForFanoutRow{} + for rows.Next() { + var i ClaimOutboxEventsForFanoutRow + if err := rows.Scan( + &i.WebhookEvent.ID, + &i.WebhookEvent.ActivityLogEntriesID, + &i.WebhookEvent.Status, + &i.WebhookEvent.CreatedAt, + &i.ActivityLogEntry.ID, + &i.ActivityLogEntry.CreatedAt, + &i.ActivityLogEntry.Actor, + &i.ActivityLogEntry.Action, + &i.ActivityLogEntry.ResourceType, + &i.ActivityLogEntry.ResourceName, + &i.ActivityLogEntry.TeamSlug, + &i.ActivityLogEntry.Data, + &i.ActivityLogEntry.Environment, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const claimPendingDeliveries = `-- name: ClaimPendingDeliveries :many WITH - updated_events AS ( - UPDATE webhook_events + claimed_deliveries AS ( + UPDATE webhook_event_deliveries SET status = 'completed' WHERE @@ -22,7 +81,7 @@ WITH SELECT id FROM - webhook_events + webhook_event_deliveries WHERE status = 'pending' AND run_at <= NOW() @@ -34,37 +93,55 @@ WITH SKIP LOCKED ) RETURNING - id, activity_log_entries_id, status, retry_count, run_at, created_at + id, webhook_event_id, subscription_id, status, retry_count, run_at, created_at ) SELECT - webhook_events.id, webhook_events.activity_log_entries_id, webhook_events.status, webhook_events.retry_count, webhook_events.run_at, webhook_events.created_at, + webhook_event_deliveries.id, webhook_event_deliveries.webhook_event_id, webhook_event_deliveries.subscription_id, webhook_event_deliveries.status, webhook_event_deliveries.retry_count, webhook_event_deliveries.run_at, webhook_event_deliveries.created_at, + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment FROM - updated_events webhook_events + claimed_deliveries webhook_event_deliveries + JOIN webhook_subscriptions ON webhook_event_deliveries.subscription_id = webhook_subscriptions.id + JOIN webhook_events ON webhook_event_deliveries.webhook_event_id = webhook_events.id JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id ` -type ClaimPendingEventsRow struct { - WebhookEvent WebhookEvent - ActivityLogEntry ActivityLogEntry +type ClaimPendingDeliveriesRow struct { + WebhookEventDelivery WebhookEventDelivery + WebhookSubscription WebhookSubscription + ActivityLogEntry ActivityLogEntry } -func (q *Queries) ClaimPendingEvents(ctx context.Context, batchSize int32) ([]*ClaimPendingEventsRow, error) { - rows, err := q.db.Query(ctx, claimPendingEvents, batchSize) +// Claims per-(event, subscription) delivery rows for processing, using the same +// FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. +func (q *Queries) ClaimPendingDeliveries(ctx context.Context, batchSize int32) ([]*ClaimPendingDeliveriesRow, error) { + rows, err := q.db.Query(ctx, claimPendingDeliveries, batchSize) if err != nil { return nil, err } defer rows.Close() - items := []*ClaimPendingEventsRow{} + items := []*ClaimPendingDeliveriesRow{} for rows.Next() { - var i ClaimPendingEventsRow + var i ClaimPendingDeliveriesRow if err := rows.Scan( - &i.WebhookEvent.ID, - &i.WebhookEvent.ActivityLogEntriesID, - &i.WebhookEvent.Status, - &i.WebhookEvent.RetryCount, - &i.WebhookEvent.RunAt, - &i.WebhookEvent.CreatedAt, + &i.WebhookEventDelivery.ID, + &i.WebhookEventDelivery.WebhookEventID, + &i.WebhookEventDelivery.SubscriptionID, + &i.WebhookEventDelivery.Status, + &i.WebhookEventDelivery.RetryCount, + &i.WebhookEventDelivery.RunAt, + &i.WebhookEventDelivery.CreatedAt, + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, &i.ActivityLogEntry.ID, &i.ActivityLogEntry.CreatedAt, &i.ActivityLogEntry.Actor, @@ -89,6 +166,7 @@ const createDelivery = `-- name: CreateDelivery :one INSERT INTO webhook_deliveries ( subscription_id, + webhook_event_delivery_id, event_type, request_body, response_status, @@ -104,25 +182,28 @@ VALUES $4, $5, $6, - $7 + $7, + $8 ) RETURNING - id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at ` type CreateDeliveryParams struct { - SubscriptionID uuid.UUID - EventType string - RequestBody []byte - ResponseStatus *int32 - ResponseBody *string - DurationMs int32 - Success bool + SubscriptionID uuid.UUID + WebhookEventDeliveryID *uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool } func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) { row := q.db.QueryRow(ctx, createDelivery, arg.SubscriptionID, + arg.WebhookEventDeliveryID, arg.EventType, arg.RequestBody, arg.ResponseStatus, @@ -134,6 +215,7 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) err := row.Scan( &i.ID, &i.SubscriptionID, + &i.WebhookEventDeliveryID, &i.EventType, &i.RequestBody, &i.ResponseStatus, @@ -145,6 +227,24 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) return &i, err } +const createEventDelivery = `-- name: CreateEventDelivery :exec +INSERT INTO + webhook_event_deliveries (webhook_event_id, subscription_id) +VALUES + ($1, $2) +ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING +` + +type CreateEventDeliveryParams struct { + WebhookEventID uuid.UUID + SubscriptionID uuid.UUID +} + +func (q *Queries) CreateEventDelivery(ctx context.Context, arg CreateEventDeliveryParams) error { + _, err := q.db.Exec(ctx, createEventDelivery, arg.WebhookEventID, arg.SubscriptionID) + return err +} + const createSubscription = `-- name: CreateSubscription :one INSERT INTO webhook_subscriptions (team_slug, url, secret, event_types, created_by) @@ -220,7 +320,7 @@ func (q *Queries) DisableSubscription(ctx context.Context, id uuid.UUID) error { const getDelivery = `-- name: GetDelivery :one SELECT - id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at FROM webhook_deliveries WHERE @@ -233,6 +333,7 @@ func (q *Queries) GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelive err := row.Scan( &i.ID, &i.SubscriptionID, + &i.WebhookEventDeliveryID, &i.EventType, &i.RequestBody, &i.ResponseStatus, @@ -249,7 +350,7 @@ SELECT status, COUNT(*) AS count FROM - webhook_events + webhook_event_deliveries GROUP BY status ORDER BY @@ -257,7 +358,7 @@ ORDER BY ` type GetQueueSizeByStatusRow struct { - Status WebhookEventStatus + Status WebhookDeliveryStatus Count int64 } @@ -340,7 +441,7 @@ func (q *Queries) IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID const listDeliveriesByIDs = `-- name: ListDeliveriesByIDs :many SELECT - id, subscription_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at FROM webhook_deliveries WHERE @@ -361,6 +462,7 @@ func (q *Queries) ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]* if err := rows.Scan( &i.ID, &i.SubscriptionID, + &i.WebhookEventDeliveryID, &i.EventType, &i.RequestBody, &i.ResponseStatus, @@ -381,7 +483,7 @@ func (q *Queries) ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]* const listDeliveriesForSubscription = `-- name: ListDeliveriesForSubscription :many SELECT - webhook_deliveries.id, webhook_deliveries.subscription_id, webhook_deliveries.event_type, webhook_deliveries.request_body, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.duration_ms, webhook_deliveries.success, webhook_deliveries.created_at, + webhook_deliveries.id, webhook_deliveries.subscription_id, webhook_deliveries.webhook_event_delivery_id, webhook_deliveries.event_type, webhook_deliveries.request_body, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.duration_ms, webhook_deliveries.success, webhook_deliveries.created_at, COUNT(*) OVER () AS total_count FROM webhook_deliveries @@ -418,6 +520,7 @@ func (q *Queries) ListDeliveriesForSubscription(ctx context.Context, arg ListDel if err := rows.Scan( &i.WebhookDelivery.ID, &i.WebhookDelivery.SubscriptionID, + &i.WebhookDelivery.WebhookEventDeliveryID, &i.WebhookDelivery.EventType, &i.WebhookDelivery.RequestBody, &i.WebhookDelivery.ResponseStatus, @@ -642,16 +745,29 @@ func (q *Queries) ListSubscriptionsForTeam(ctx context.Context, arg ListSubscrip return items, nil } -const markEventFailed = `-- name: MarkEventFailed :exec -UPDATE webhook_events +const markDeliveryFailed = `-- name: MarkDeliveryFailed :exec +UPDATE webhook_event_deliveries SET status = 'failed' WHERE id = $1 ` -func (q *Queries) MarkEventFailed(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, markEventFailed, id) +func (q *Queries) MarkDeliveryFailed(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, markDeliveryFailed, id) + return err +} + +const markOutboxEventsCompleted = `-- name: MarkOutboxEventsCompleted :exec +UPDATE webhook_events +SET + status = 'completed' +WHERE + id = ANY ($1::UUID[]) +` + +func (q *Queries) MarkOutboxEventsCompleted(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.Exec(ctx, markOutboxEventsCompleted, ids) return err } @@ -666,20 +782,43 @@ func (q *Queries) PruneDeliveries(ctx context.Context, before pgtype.Timestamptz return err } -const pruneOldEvents = `-- name: PruneOldEvents :exec -DELETE FROM webhook_events +const pruneOldEventDeliveries = `-- name: PruneOldEventDeliveries :exec +DELETE FROM webhook_event_deliveries WHERE created_at < $1 AND status IN ('completed', 'failed') ` -func (q *Queries) PruneOldEvents(ctx context.Context, before pgtype.Timestamptz) error { - _, err := q.db.Exec(ctx, pruneOldEvents, before) +func (q *Queries) PruneOldEventDeliveries(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneOldEventDeliveries, before) return err } -const requeueEvent = `-- name: RequeueEvent :exec -UPDATE webhook_events +const pruneOldOutboxEvents = `-- name: PruneOldOutboxEvents :exec +DELETE FROM webhook_events +WHERE + webhook_events.created_at < $1 + AND webhook_events.status = 'completed' + AND NOT EXISTS ( + SELECT + 1 + FROM + webhook_event_deliveries + WHERE + webhook_event_deliveries.webhook_event_id = webhook_events.id + AND webhook_event_deliveries.status = 'pending' + ) +` + +// Prunes outbox events whose deliveries have all reached a terminal state (a delete +// cascades to webhook_event_deliveries, so pending rows must be excluded). +func (q *Queries) PruneOldOutboxEvents(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneOldOutboxEvents, before) + return err +} + +const requeueDelivery = `-- name: RequeueDelivery :exec +UPDATE webhook_event_deliveries SET status = 'pending', retry_count = $1, @@ -688,14 +827,14 @@ WHERE id = $3 ` -type RequeueEventParams struct { +type RequeueDeliveryParams struct { RetryCount int32 RunAt pgtype.Timestamptz ID uuid.UUID } -func (q *Queries) RequeueEvent(ctx context.Context, arg RequeueEventParams) error { - _, err := q.db.Exec(ctx, requeueEvent, arg.RetryCount, arg.RunAt, arg.ID) +func (q *Queries) RequeueDelivery(ctx context.Context, arg RequeueDeliveryParams) error { + _, err := q.db.Exec(ctx, requeueDelivery, arg.RetryCount, arg.RunAt, arg.ID) return err } diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index ac9545961..3e6044166 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -422,6 +422,11 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { return nil }) + wg.Go(func() error { + webhook.RunCleaner(ctx, pool, log.WithField("subsystem", "webhook_cleaner")) + return nil + }) + wg.Go(func() error { activitylog.RunRefresher(ctx, pool, log.WithField("subsystem", "activitylog_refresher")) return nil diff --git a/internal/database/migrations/0072_webhooks.sql b/internal/database/migrations/0072_webhooks.sql index 4c8e51fe1..e92011460 100644 --- a/internal/database/migrations/0072_webhooks.sql +++ b/internal/database/migrations/0072_webhooks.sql @@ -32,42 +32,68 @@ WHERE enabled = TRUE ; -CREATE TABLE webhook_deliveries ( +-- Outbox table for durable webhook event processing. Rows are inserted by a trigger on +-- activity_log_entries. Subscription matching (event type wildcards, team scoping) happens +-- in the dispatcher, which fans each row out into per-subscription rows in +-- webhook_event_deliveries below. +CREATE TYPE webhook_outbox_status AS ENUM('pending', 'completed') +; + +CREATE TABLE webhook_events ( id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), - subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - request_body JSONB NOT NULL, - response_status INT, - response_body TEXT, - duration_ms INT NOT NULL, - success BOOLEAN NOT NULL, + activity_log_entries_id UUID NOT NULL REFERENCES activity_log_entries (id) ON DELETE CASCADE, + status webhook_outbox_status NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) ; -CREATE INDEX idx_webhook_deliveries_subscription ON webhook_deliveries (subscription_id, created_at DESC) +CREATE INDEX idx_webhook_events_pending ON webhook_events (created_at ASC) +WHERE + status = 'pending' ; --- Outbox table for durable webhook event processing. --- Rows are inserted by a trigger on activity_log_entries and consumed by the dispatcher. -CREATE TYPE webhook_event_status AS ENUM('pending', 'completed', 'failed') +-- Per-(event, subscription) delivery queue; this is the unit of retry and backoff. +CREATE TYPE webhook_delivery_status AS ENUM('pending', 'completed', 'failed') ; -CREATE TABLE webhook_events ( +CREATE TABLE webhook_event_deliveries ( id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), - activity_log_entries_id UUID NOT NULL REFERENCES activity_log_entries (id) ON DELETE CASCADE, - status webhook_event_status NOT NULL DEFAULT 'pending', + webhook_event_id UUID NOT NULL REFERENCES webhook_events (id) ON DELETE CASCADE, + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, + status webhook_delivery_status NOT NULL DEFAULT 'pending', retry_count INT NOT NULL DEFAULT 0, run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (webhook_event_id, subscription_id) ) ; -CREATE INDEX idx_webhook_events_pending ON webhook_events (run_at ASC) +CREATE INDEX idx_webhook_event_deliveries_pending ON webhook_event_deliveries (run_at ASC) WHERE status = 'pending' ; +CREATE INDEX idx_webhook_event_deliveries_event ON webhook_event_deliveries (webhook_event_id) +; + +-- Audit log of every actual HTTP delivery attempt. +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, + webhook_event_delivery_id UUID REFERENCES webhook_event_deliveries (id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + request_body JSONB NOT NULL, + response_status INT, + response_body TEXT, + duration_ms INT NOT NULL, + success BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE INDEX idx_webhook_deliveries_subscription ON webhook_deliveries (subscription_id, created_at DESC) +; + -- +goose StatementBegin CREATE OR REPLACE FUNCTION webhook_events_notify () RETURNS trigger AS $$ BEGIN @@ -112,40 +138,3 @@ VALUES ('Team owner', 'webhooks:update'), ('Team owner', 'webhooks:delete') ; - --- +goose Down -DROP TRIGGER IF EXISTS activity_log_webhook_notify ON activity_log_entries -; - -DROP FUNCTION IF EXISTS webhook_events_notify -; - -DROP TABLE IF EXISTS webhook_events -; - -DROP TYPE IF EXISTS webhook_event_status -; - -DELETE FROM role_authorizations -WHERE - authorization_name IN ( - 'webhooks:create', - 'webhooks:update', - 'webhooks:delete' - ) -; - -DELETE FROM authorizations -WHERE - name IN ( - 'webhooks:create', - 'webhooks:update', - 'webhooks:delete' - ) -; - -DROP TABLE IF EXISTS webhook_deliveries -; - -DROP TABLE IF EXISTS webhook_subscriptions -; diff --git a/internal/persistence/aivencredentials/activitylog.go b/internal/persistence/aivencredentials/activitylog.go index fd2f219dd..45beeba50 100644 --- a/internal/persistence/aivencredentials/activitylog.go +++ b/internal/persistence/aivencredentials/activitylog.go @@ -7,7 +7,8 @@ import ( ) const ( - ActivityLogEntryActionCredentialsCreated activitylog.ActivityLogEntryAction = "CREDENTIALS_CREATED" + ActivityLogActivityTypeCredentialsCreated activitylog.ActivityLogActivityType = "CREDENTIALS_CREATED" + ActivityLogEntryActionCredentialsCreated activitylog.ActivityLogEntryAction = "CREDENTIALS_CREATED" ) func GetActivityLogEntry(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { diff --git a/internal/persistence/opensearch/activitylog.go b/internal/persistence/opensearch/activitylog.go index 66df7ce75..3ffe3924a 100644 --- a/internal/persistence/opensearch/activitylog.go +++ b/internal/persistence/opensearch/activitylog.go @@ -68,7 +68,7 @@ func init() { activitylog.WithDescription("Triggered when service maintenance is started for an OpenSearch instance."), ) activitylog.RegisterActivityType( - "CREDENTIALS_CREATED", + aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch, activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."), diff --git a/internal/persistence/valkey/activitylog.go b/internal/persistence/valkey/activitylog.go index d214d5264..eb42dff25 100644 --- a/internal/persistence/valkey/activitylog.go +++ b/internal/persistence/valkey/activitylog.go @@ -69,7 +69,7 @@ func init() { activitylog.WithDescription("Triggered when service maintenance is started for a Valkey."), ) activitylog.RegisterActivityType( - "CREDENTIALS_CREATED", + aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey, activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."),