Provisioning: webhook last event timestamp (#103180)

* Record webhook pinged event

* Add TODO for webhook creation updated

* Hack to wire client

* Revert accidental change in controller

* Wire the client

* Use factory method

* Remove omit empty

* Regenerate client

* Fix compilation

* Every 30 seconds if not pinged

* Move lines around

* Use different approach

* Added as part of the controller

* Exponential backoff for waiting for ping

* More stuff

* Revert changes in controller

* Add separate webhook section in overview

* Change order of translations

* Update ping within 1 minute

* Last event update

* Extract translation

* Display last event in frontend

* Refactor the logic around update

* Fix the type to marshal
This commit is contained in:
Roberto Jiménez Sánchez
2025-04-02 10:09:58 +02:00
committed by GitHub
parent d8c5c2d3b8
commit 04e152f326
10 changed files with 150 additions and 27 deletions
+1
View File
@@ -197,6 +197,7 @@ type WebhookStatus struct {
Secret string `json:"secret,omitempty"`
EncryptedSecret []byte `json:"encryptedSecret,omitempty"`
SubscribedEvents []string `json:"subscribedEvents,omitempty"`
LastEvent int64 `json:"lastEvent,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -2127,6 +2127,12 @@ func schema_pkg_apis_provisioning_v0alpha1_WebhookStatus(ref common.ReferenceCal
},
},
},
"lastEvent": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
Format: "int64",
},
},
},
},
},
@@ -12,6 +12,7 @@ type WebhookStatusApplyConfiguration struct {
Secret *string `json:"secret,omitempty"`
EncryptedSecret []byte `json:"encryptedSecret,omitempty"`
SubscribedEvents []string `json:"subscribedEvents,omitempty"`
LastEvent *int64 `json:"lastEvent,omitempty"`
}
// WebhookStatusApplyConfiguration constructs a declarative configuration of the WebhookStatus type for use with
@@ -63,3 +64,11 @@ func (b *WebhookStatusApplyConfiguration) WithSubscribedEvents(values ...string)
}
return b
}
// WithLastEvent sets the LastEvent field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the LastEvent field is set to the value of the last call.
func (b *WebhookStatusApplyConfiguration) WithLastEvent(value int64) *WebhookStatusApplyConfiguration {
b.LastEvent = &value
return b
}
+13 -8
View File
@@ -33,6 +33,7 @@ import (
"github.com/grafana/grafana/pkg/apiserver/readonly"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned"
client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
informers "github.com/grafana/grafana/pkg/generated/informers/externalversions"
listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/infra/usagestats"
@@ -93,6 +94,7 @@ type APIBuilder struct {
storageStatus dualwrite.Service
unified resource.ResourceClient
secrets secrets.Service
client client.ProvisioningV0alpha1Interface
}
// NewAPIBuilder creates an API builder.
@@ -301,6 +303,10 @@ func (b *APIBuilder) GetGroupVersion() schema.GroupVersion {
return provisioning.SchemeGroupVersion
}
func (b *APIBuilder) GetClient() client.ProvisioningV0alpha1Interface {
return b.client
}
func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
err := provisioning.AddToScheme(scheme)
if err != nil {
@@ -356,11 +362,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
// TODO: Do not set private fields directly, use factory methods.
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{
getter: b,
jobs: b.jobs,
webhooksEnabled: b.isPublic,
}
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = NewWebhookConnector(b, b, b.jobs, b.isPublic)
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{
getter: b,
}
@@ -549,10 +551,13 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories()
go repoInformer.Informer().Run(postStartHookCtx.Context.Done())
b.client = c.ProvisioningV0alpha1()
// We do not have a local client until *GetPostStartHooks*, so we can delay init for some
b.tester = &RepositoryTester{
client: c.ProvisioningV0alpha1(),
client: b.GetClient(),
}
b.repositoryLister = repoInformer.Lister()
exportWorker := export.NewExportWorker(
@@ -561,7 +566,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.parsers,
)
syncWorker := sync.NewSyncWorker(
c.ProvisioningV0alpha1(),
b.GetClient(),
b.parsers,
b.resourceLister,
b.storageStatus,
@@ -588,7 +593,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
go driver.Run(postStartHookCtx.Context)
repoController, err := controller.NewRepositoryController(
c.ProvisioningV0alpha1(),
b.GetClient(),
repoInformer,
b, // repoGetter
b.resourceLister,
+5
View File
@@ -4,6 +4,7 @@ import (
"context"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
@@ -19,3 +20,7 @@ type RepoGetter interface {
// the repository instance may or may not be valid/healthy
AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
}
type ClientGetter interface {
GetClient() client.ProvisioningV0alpha1Interface
}
+55
View File
@@ -2,12 +2,15 @@ package provisioning
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
@@ -24,11 +27,21 @@ const webhookMaxBodySize = 25 * 1024 * 1024
// This only works for github right now
type webhookConnector struct {
client ClientGetter
getter RepoGetter
jobs jobs.Queue
webhooksEnabled bool
}
func NewWebhookConnector(client ClientGetter, getter RepoGetter, jobs jobs.Queue, webhooksEnabled bool) *webhookConnector {
return &webhookConnector{
client: client,
getter: getter,
jobs: jobs,
webhooksEnabled: webhooksEnabled,
}
}
func (*webhookConnector) New() runtime.Object {
return &provisioning.WebhookResponse{}
}
@@ -89,10 +102,17 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
responder.Error(err)
return
}
if rsp == nil {
responder.Error(fmt.Errorf("expecting a response"))
return
}
if err := s.updateLastEvent(ctx, repo, name, namespace); err != nil {
// Continue processing as this is non-critical; the update is purely informational
logger.Error("failed to update last event", "error", err)
}
if rsp.Job != nil {
rsp.Job.Repository = name
job, err := s.jobs.Insert(ctx, namespace, *rsp.Job)
@@ -103,10 +123,45 @@ func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtim
responder.Object(rsp.Code, job)
return
}
responder.Object(rsp.Code, rsp)
}), 30*time.Second), nil
}
// updateLastEvent updates the last event time for the webhook
// This is to provide some visibility that the webhook is still active and working
// It's not a good idea to update the webhook status too often, so we only update it if it's been a while
func (s *webhookConnector) updateLastEvent(ctx context.Context, repo repository.Repository, name, namespace string) error {
client := s.client.GetClient()
if client == nil {
// This would only happen if we wired things up incorrectly
return fmt.Errorf("client is nil")
}
lastEvent := time.UnixMilli(repo.Config().Status.Webhook.LastEvent)
eventAge := time.Since(lastEvent)
if repo.Config().Status.Webhook != nil && (eventAge > time.Minute) {
patchOp := map[string]interface{}{
"op": "replace",
"path": "/status/webhook/lastEvent",
"value": time.Now().UnixMilli(),
}
patch, err := json.Marshal([]map[string]interface{}{patchOp})
if err != nil {
return fmt.Errorf("marshal patch: %w", err)
}
if _, err = client.Repositories(namespace).
Patch(ctx, name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status"); err != nil {
return fmt.Errorf("patch status: %w", err)
}
}
return nil
}
var (
_ rest.Storage = (*webhookConnector)(nil)
_ rest.Connecter = (*webhookConnector)(nil)
@@ -3903,6 +3903,10 @@
"type": "integer",
"format": "int64"
},
"lastEvent": {
"type": "integer",
"format": "int64"
},
"secret": {
"type": "string"
},
@@ -920,6 +920,7 @@ export type SyncStatus = {
export type WebhookStatus = {
encryptedSecret?: string;
id?: number;
lastEvent?: number;
secret?: string;
subscribedEvents?: string[];
url?: string;
@@ -1,18 +1,7 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import {
CellProps,
Stack,
Box,
Text,
LinkButton,
Card,
TextLink,
InteractiveTable,
Grid,
useStyles2,
} from '@grafana/ui';
import { Box, Card, CellProps, Grid, InteractiveTable, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
import { Repository, ResourceCount } from 'app/api/clients/provisioning';
import { Trans } from 'app/core/internationalization';
@@ -25,10 +14,15 @@ import { SyncRepository } from './SyncRepository';
type StatCell<T extends keyof ResourceCount = keyof ResourceCount> = CellProps<ResourceCount, ResourceCount[T]>;
function getColumnCount(hasWebhook: boolean): 3 | 4 {
return hasWebhook ? 4 : 3;
}
export function RepositoryOverview({ repo }: { repo: Repository }) {
const styles = useStyles2(getStyles);
const status = repo.status;
const webhookURL = getWebhookURL(repo);
const columns = getColumnCount(Boolean(repo.status?.webhook));
const resourceColumns = useMemo(
() => [
@@ -54,7 +48,7 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
return (
<Box padding={2}>
<Stack direction="column" gap={2}>
<Grid columns={3} gap={2}>
<Grid columns={columns} gap={2}>
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card.Heading>
@@ -203,14 +197,53 @@ export function RepositoryOverview({ repo }: { repo: Repository }) {
</Card.Description>
<Card.Actions className={styles.actions}>
<SyncRepository repository={repo} />
{webhookURL && (
<TextLink external href={webhookURL} icon="link">
<Trans i18nKey="provisioning.repository-overview.webhook">Webhook</Trans>
</TextLink>
)}
</Card.Actions>
</Card>
</div>
{repo.status?.webhook && (
<div className={styles.cardContainer}>
<Card className={styles.card}>
<Card.Heading>
<Trans i18nKey="provisioning.repository-overview.webhook">Webhook</Trans>
</Card.Heading>
<Card.Description>
<Grid columns={12} gap={1} alignItems="baseline">
<div className={styles.labelColumn}>
<Text color="secondary">
<Trans i18nKey="provisioning.repository-overview.webhook-id">ID:</Trans>
</Text>
</div>
<div className={styles.valueColumn}>
<Text variant="body">{status?.webhook?.id ?? 'N/A'}</Text>
</div>
<div className={styles.labelColumn}>
<Text color="secondary">
<Trans i18nKey="provisioning.repository-overview.webhook-events">Events:</Trans>
</Text>
</div>
<div className={styles.valueColumn}>
<Text variant="body">{status?.webhook?.subscribedEvents?.join(', ') ?? 'N/A'}</Text>
</div>
<div className={styles.labelColumn}>
<Text color="secondary">
<Trans i18nKey="provisioning.repository-overview.webhook-last-event">Last Event:</Trans>
</Text>
</div>
<div className={styles.valueColumn}>
<Text variant="body">{formatTimestamp(status?.webhook?.lastEvent)}</Text>
</div>
</Grid>
</Card.Description>
{webhookURL && (
<Card.Actions className={styles.actions}>
<LinkButton fill="outline" href={webhookURL} icon="external-link-alt">
<Trans i18nKey="provisioning.repository-overview.webhook-url">View Webhook</Trans>
</LinkButton>
</Card.Actions>
)}
</Card>
</div>
)}
</Grid>
<div className={styles.cardContainer}>
<RecentJobs repo={repo} />
+5 -1
View File
@@ -4752,7 +4752,11 @@
"started": "Started:",
"status": "Status:",
"view-folder": "View Folder",
"webhook": "Webhook"
"webhook": "Webhook",
"webhook-events": "Events:",
"webhook-id": "ID:",
"webhook-last-event": "Last Event:",
"webhook-url": "View Webhook"
},
"repository-resources": {
"columns": {