Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce project event webhook #1113

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions api/types/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ type Project struct {
// AuthWebhookMethods is the methods that run the authorization webhook.
AuthWebhookMethods []string `json:"auth_webhook_methods"`

// EventWebhookURL is the url of the project webhook.
EventWebhookURL string `json:"event_webhook_url"`

// EventWebhookMethods is the methods that run the project webhook.
EventWebhookMethods []string `json:"event_webhook_methods"`

// ClientDeactivateThreshold is the time after which clients in
// specific project are considered deactivate for housekeeping.
ClientDeactivateThreshold string `bson:"client_deactivate_threshold"`
Expand Down Expand Up @@ -73,3 +79,22 @@ func (p *Project) RequireAuth(method Method) bool {

return false
}

// RequireEventWebhook returns whether the given method requires webhook.
func (p *Project) RequireEventWebhook(method ProjectEvent) bool {
if len(p.EventWebhookURL) == 0 {
return false
}

if len(p.EventWebhookMethods) == 0 {
return false
}

for _, m := range p.EventWebhookMethods {
if ProjectEvent(m) == method {
return true
}
}

return false
}
40 changes: 40 additions & 0 deletions api/types/project_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2024 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package types

// ProjectEvent represents the event of the project.
type ProjectEvent string

// ProjectEvent represents the event of the project.
const (
DocumentCreated ProjectEvent = "DocumentCreated"
DocumentRemoved ProjectEvent = "DocumentRemoved"
)

// WebhookAttribute represents the attribute of the webhook.
type WebhookAttribute struct {
DocumentKey string `json:"documentKey"`
ClientKey string `json:"clientKey"`
IssuedAt string `json:"issuedAt"`
}

// WebhookRequest represents the request of the webhook.
type WebhookRequest struct {
Type ProjectEvent `json:"type"`
Attributes WebhookAttribute `json:"attributes"`
}
38 changes: 34 additions & 4 deletions cmd/yorkie/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ var (
mongoYorkieDatabase string
mongoPingTimeout time.Duration

authWebhookMaxWaitInterval time.Duration
authWebhookCacheAuthTTL time.Duration
authWebhookCacheUnauthTTL time.Duration
projectInfoCacheTTL time.Duration
authWebhookMaxWaitInterval time.Duration
authWebhookCacheAuthTTL time.Duration
authWebhookCacheUnauthTTL time.Duration
eventWebhookMaxWaitInterval time.Duration
eventWebhookBaseWaitInterval time.Duration
eventWebhookRequestTimeout time.Duration
projectInfoCacheTTL time.Duration

conf = server.NewConfig()
)
Expand All @@ -67,6 +70,9 @@ func newServerCmd() *cobra.Command {
conf.Backend.AuthWebhookMaxWaitInterval = authWebhookMaxWaitInterval.String()
conf.Backend.AuthWebhookCacheAuthTTL = authWebhookCacheAuthTTL.String()
conf.Backend.AuthWebhookCacheUnauthTTL = authWebhookCacheUnauthTTL.String()
conf.Backend.EventWebhookMaxWaitInterval = eventWebhookMaxWaitInterval.String()
conf.Backend.EventWebhookBaseWaitInterval = eventWebhookBaseWaitInterval.String()
conf.Backend.EventWebhookRequestTimeout = eventWebhookRequestTimeout.String()
conf.Backend.ProjectInfoCacheTTL = projectInfoCacheTTL.String()

conf.Housekeeping.Interval = housekeepingInterval.String()
Expand Down Expand Up @@ -327,6 +333,30 @@ func init() {
server.DefaultAuthWebhookCacheUnauthTTL,
"TTL value to set when caching unauthorized webhook response.",
)
cmd.Flags().Uint64Var(
&conf.Backend.EventWebhookMaxRetries,
"project-event-webhook-max-retries",
server.DefaultEventWebhookMaxRetries,
"Maximum number of retries for a project webhook.",
)
cmd.Flags().DurationVar(
&eventWebhookBaseWaitInterval,
"project-event-webhook-base-wait-interval",
server.DefaultEventWebhookBaseWaitInterval,
"Base wait interval for retrying exponential backoff the project webhook.",
)
cmd.Flags().DurationVar(
&eventWebhookMaxWaitInterval,
"project-event-webhook-max-wait-interval",
server.DefaultEventWebhookMaxWaitInterval,
"Maximum wait interval for project webhook.",
)
cmd.Flags().DurationVar(
&eventWebhookRequestTimeout,
"project-event-webhook-request-timeout",
server.DefaultEventWebhookTimeout,
"Time to wait for a response from the project webhook.",
)
cmd.Flags().IntVar(
&conf.Backend.ProjectInfoCacheSize,
"project-info-cache-size",
Expand Down
78 changes: 78 additions & 0 deletions pkg/webhook/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2024 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Package webhook provides an webhook utilities.
package webhook

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"time"
)

// HMACTransport is a http.RoundTripper that adds an X-Signature
// header to each request using an HMAC-SHA256 signature.
type HMACTransport struct {
PrivateKey string
}

// GenerateHMACSignature computes an HMAC-SHA256 signature for the given data
// using the specified secret key.
func GenerateHMACSignature(secret string, data []byte) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}

// RoundTrip implements the http.RoundTripper interface. It reads the request body
// to compute the HMAC signature, sets the "X-Signature" header, and restores
// the body for use by subsequent transports or handlers.
func (t *HMACTransport) RoundTrip(r *http.Request) (*http.Response, error) {
reqCopy := r.Clone(r.Context())

rawBody, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request: %w", err)
}
reqCopy.Body = io.NopCloser(bytes.NewBuffer(rawBody))

signature := GenerateHMACSignature(t.PrivateKey, rawBody)
reqCopy.Header.Set("X-Signature", signature)

resp, err := http.DefaultTransport.RoundTrip(reqCopy)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}

return resp, nil
}

// NewClient creates an *http.Client configured with a custom HMAC transport
// and a specified timeout. The transport will add an "X-Signature" header to
// every request using the provided private key.
func NewClient(timeout time.Duration, privateKey string) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &HMACTransport{
PrivateKey: privateKey,
},
}
}
106 changes: 106 additions & 0 deletions pkg/webhook/retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2024 The Yorkie Authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package webhook

import (
"context"
"errors"
"fmt"
"math"
"net/http"
"syscall"
gotime "time"
)

var (
// ErrUnexpectedStatusCode is returned when the webhook returns an unexpected status code.
ErrUnexpectedStatusCode = errors.New("unexpected status code from webhook")

// ErrMaxRetriesExceeded indicates we have retried up to the specified max retries
// without a successful outcome.
ErrMaxRetriesExceeded = errors.New("exponential backoff: maximum retries exceeded")
)

// WithExponentialBackoff retries the given webhookFn with exponential backoff.
func WithExponentialBackoff(
ctx context.Context,
maxRetries uint64,
baseInterval,
maxInterval gotime.Duration,
webhookFn func() (int, error),
) error {
start := gotime.Now()

var (
retries uint64
statusCode int
)

for retries < maxRetries {
statusCode, err := webhookFn()
if !shouldRetry(statusCode, err) {
if errors.Is(err, ErrUnexpectedStatusCode) {
return fmt.Errorf("%d: %w", statusCode, ErrUnexpectedStatusCode)
}

return err
}

waitBeforeRetry := waitInterval(retries, baseInterval, maxInterval)

select {
case <-ctx.Done():
return ctx.Err()
case <-gotime.After(waitBeforeRetry):
}

retries++
}

return fmt.Errorf(
"maximum retries (%d) exceeded after %s; last status code = %d: %w",
maxRetries,
gotime.Since(start),
statusCode,
ErrMaxRetriesExceeded,
)
}

// waitInterval returns the interval of given retries. it returns maxWaitInterval
func waitInterval(retries uint64, baseInterval, maxWaitInterval gotime.Duration) gotime.Duration {
interval := gotime.Duration(math.Pow(2, float64(retries))) * baseInterval
if maxWaitInterval < interval {
return maxWaitInterval
}

return interval
}

// shouldRetry returns true if the given error should be retried.
// Refer to https://github.com/kubernetes/kubernetes/search?q=DefaultShouldRetry
func shouldRetry(statusCode int, err error) bool {
// If the connection is reset, we should retry.
var errno syscall.Errno
if errors.As(err, &errno) {
return errno == syscall.ECONNRESET
}

return statusCode == http.StatusInternalServerError ||
statusCode == http.StatusServiceUnavailable ||
statusCode == http.StatusGatewayTimeout ||
statusCode == http.StatusTooManyRequests
}
Comment on lines +93 to +106
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add 4xx handling
shouldRetry currently returns true for only high-level server errors like 500 or 503. In certain scenarios (e.g., 429 Too Many Requests), the function also returns true, which is fine. However, you may need logic for other 4xx status codes if your service wants clients to back off, especially if a rate-limit or temporary ban is in place.

45 changes: 45 additions & 0 deletions server/backend/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ type Config struct {
// AuthWebhookCacheUnauthTTL is the TTL value to set when caching the unauthorized result.
AuthWebhookCacheUnauthTTL string `yaml:"AuthWebhookCacheUnauthTTL"`

// EventWebhookMaxRetries is the max count that retries the project event webhook.
EventWebhookMaxRetries uint64 `yaml:"EventWebhookMaxRetries"`

// EventWebhookBaseWaitInterval is the base of retrying exponential backoff the project event webhook.
EventWebhookBaseWaitInterval string `yaml:"EventWebhookBaseWaitInterval"`

// EventWebhookMaxWaitInterval is the max interval that waits before retrying the project event webhook.
EventWebhookMaxWaitInterval string `yaml:"EventWebhookMaxWaitInterval"`

// EventWebhookRequestTimeout is the time that waits time for response the project webhook.
EventWebhookRequestTimeout string `yaml:"EventWebhookRequestTimeout"`

// ProjectInfoCacheSize is the cache size of the project info.
ProjectInfoCacheSize int `yaml:"ProjectInfoCacheSize"`

Expand Down Expand Up @@ -175,6 +187,39 @@ func (c *Config) ParseAuthWebhookCacheUnauthTTL() time.Duration {
return result
}

// ParseProjectWebhookMaxWaitInterval returns max wait interval.
func (c *Config) ParseProjectWebhookMaxWaitInterval() time.Duration {
result, err := time.ParseDuration(c.EventWebhookMaxWaitInterval)
if err != nil {
fmt.Fprintln(os.Stderr, "parse project webhook max wait interval: %w", err)
os.Exit(1)
}

return result
}

// ParseProjectWebhookBaseWaitInterval returns base wait interval.
func (c *Config) ParseProjectWebhookBaseWaitInterval() time.Duration {
result, err := time.ParseDuration(c.EventWebhookBaseWaitInterval)
if err != nil {
fmt.Fprintln(os.Stderr, "parse project webhook max wait interval: %w", err)
os.Exit(1)
}

return result
}

// ParseProjectWebhookTimeout returns timeout for request.
func (c *Config) ParseProjectWebhookTimeout() time.Duration {
result, err := time.ParseDuration(c.EventWebhookRequestTimeout)
if err != nil {
fmt.Fprintln(os.Stderr, "parse project webhook max wait interval: %w", err)
os.Exit(1)
}

return result
}

// ParseProjectInfoCacheTTL returns TTL for project info cache.
func (c *Config) ParseProjectInfoCacheTTL() time.Duration {
result, err := time.ParseDuration(c.ProjectInfoCacheTTL)
Expand Down
Loading
Loading