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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user