Skip to content

Commit

Permalink
Merge pull request #1625 from keboola/jt-PSGO-386-sandboxes-service
Browse files Browse the repository at this point in the history
feat: Implement appconfig package
  • Loading branch information
jachym-tousek-keboola committed Mar 6, 2024
2 parents a678e0c + cdfa7cd commit b0e6d92
Show file tree
Hide file tree
Showing 5 changed files with 549 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/oauth2-proxy/oauth2-proxy/v7 v7.5.1
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021
github.com/prometheus/client_golang v1.18.0
github.com/relvacode/iso8601 v1.4.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021 h1:0XM1XL/OFFJjXsYXlG30spTkV/E9+gmd5GD1w2HE8xM=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/alertmanager v0.24.0/go.mod h1:r6fy/D7FRuZh5YbnX6J3MBY0eI4Pb5yPYS7/bPSXXqI=
Expand Down
122 changes: 122 additions & 0 deletions internal/pkg/service/appproxy/appconfig/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package appconfig

import (
"context"
"fmt"
"net/http"
"time"

"github.com/keboola/go-client/pkg/request"
"github.com/pquerna/cachecontrol/cacheobject"
)

type AppProxyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
UpstreamAppHost string `json:"upstreamAppHost"`
AuthProviders []AuthProvider `json:"authProviders"`
AuthRules []AuthRule `json:"authRules"`
eTag string
maxAge time.Duration
}

type AuthProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
IssuerURL string `json:"issuerUrl"`
AllowedRoles []string `json:"allowedRoles"`
}

type AuthRule struct {
Type string `json:"type"`
Value string `json:"value"`
Auth []string `json:"auth"`
}

func GetAppProxyConfig(sender request.Sender, appID string, eTag string) request.APIRequest[*AppProxyConfig] {
result := &AppProxyConfig{}
req := request.NewHTTPRequest(sender).
WithError(&SandboxesError{}).
WithResult(result).
WithGet("apps/{appId}/proxy-config").
AndPathParam("appId", appID).
AndHeader("If-None-Match", eTag).
WithOnSuccess(func(ctx context.Context, response request.HTTPResponse) error {
// Use id as fallback until name is added to Sandboxes API
if result.Name == "" {
result.Name = result.ID
}
for i, provider := range result.AuthProviders {
if provider.Name == "" {
result.AuthProviders[i].Name = provider.ID
}
}

// Add ETag to result
result.eTag = response.ResponseHeader().Get("ETag")

// Process Cache-Control header
cacheControl := response.ResponseHeader().Get("Cache-Control")
if cacheControl == "" {
return nil
}

cacheDirectives, err := cacheobject.ParseResponseCacheControl(cacheControl)
if err != nil {
return err
}

if cacheDirectives.NoStore || cacheDirectives.NoCache != nil {
return nil
}

result.maxAge = time.Second * time.Duration(cacheDirectives.MaxAge)
return nil
})
return request.NewAPIRequest(result, req)
}

// SandboxesError represents the structure of API error.
type SandboxesError struct {
Message string `json:"error"`
ExceptionID string `json:"exceptionId"`
request *http.Request
response *http.Response
}

func (e *SandboxesError) Error() string {
return fmt.Sprintf("sandboxes api error[%d]: %s", e.StatusCode(), e.Message)
}

// ErrorName returns a human-readable name of the error.
func (e *SandboxesError) ErrorName() string {
return http.StatusText(e.StatusCode())
}

// ErrorUserMessage returns error message for end user.
func (e *SandboxesError) ErrorUserMessage() string {
return e.Message
}

// ErrorExceptionID returns exception ID to find details in logs.
func (e *SandboxesError) ErrorExceptionID() string {
return e.ExceptionID
}

// StatusCode returns HTTP status code.
func (e *SandboxesError) StatusCode() int {
return e.response.StatusCode
}

// SetRequest method allows injection of HTTP request to the error, it implements client.errorWithRequest.
func (e *SandboxesError) SetRequest(request *http.Request) {
e.request = request
}

// SetResponse method allows injection of HTTP response to the error, it implements client.errorWithResponse.
func (e *SandboxesError) SetResponse(response *http.Response) {
e.response = response
}
106 changes: 106 additions & 0 deletions internal/pkg/service/appproxy/appconfig/appconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package appconfig

import (
"context"
"net/http"
"time"

"github.com/benbjohnson/clock"
"github.com/keboola/go-client/pkg/client"
"github.com/keboola/go-client/pkg/request"
"go.opentelemetry.io/otel/attribute"

"github.com/keboola/keboola-as-code/internal/pkg/log"
"github.com/keboola/keboola-as-code/internal/pkg/utils/errors"
)

type Loader struct {
logger log.Logger
clock clock.Clock
sender request.Sender
cache map[string]cacheItem
}

type cacheItem struct {
config AppProxyConfig
eTag string
expiresAt time.Time
}

func NewLoader(logger log.Logger, clock clock.Clock, baseURL string) *Loader {
return &Loader{
logger: logger,
clock: clock,
sender: client.New().WithBaseURL(baseURL),
cache: make(map[string]cacheItem),
}
}

const staleCacheFallbackDuration = time.Hour

func (l *Loader) LoadConfig(ctx context.Context, appID string) (AppProxyConfig, error) {
var config *AppProxyConfig
var err error
now := l.clock.Now()

if item, ok := l.cache[appID]; ok {
// Return config from cache if still valid
if now.Before(item.expiresAt) {
return item.config, nil
}

// API request with cached ETag
config, err = GetAppProxyConfig(l.sender, appID, item.eTag).Send(ctx)
if err != nil {
return l.handleError(ctx, appID, now, err, &item)
}

// Update expiration and use the cached config if ETag is still the same
if config.eTag == item.eTag {
l.cache[appID] = cacheItem{
config: item.config,
eTag: item.eTag,
expiresAt: now.Add(config.maxAge),
}
return item.config, nil
}
} else {
// API request without ETag because cache is empty
config, err = GetAppProxyConfig(l.sender, appID, "").Send(ctx)
if err != nil {
return l.handleError(ctx, appID, now, err, nil)
}
}

// Save result to cache
l.cache[appID] = cacheItem{
config: *config,
eTag: config.eTag,
expiresAt: now.Add(config.maxAge),
}
return *config, nil
}

func (l *Loader) handleError(ctx context.Context, appID string, now time.Time, err error, fallbackItem *cacheItem) (AppProxyConfig, error) {
var sandboxesError *SandboxesError
errors.As(err, &sandboxesError)
if sandboxesError != nil && sandboxesError.StatusCode() == http.StatusNotFound {
return AppProxyConfig{}, err
}

logger := l.logger
if sandboxesError != nil {
logger = l.logger.With(attribute.String("exceptionId", sandboxesError.ExceptionID))
}

// An error other than 404 is considered a temporary failure. Keep using the stale cache for staleCacheFallbackDuration as fallback.
if fallbackItem != nil && now.Before(fallbackItem.expiresAt.Add(staleCacheFallbackDuration)) {
logger.Warnf(ctx, `Using stale cache for app "%s": %s`, appID, err.Error())

return fallbackItem.config, nil
}

logger.Errorf(ctx, `Failed loading config for app "%s": %s`, appID, err.Error())

return AppProxyConfig{}, err
}

0 comments on commit b0e6d92

Please sign in to comment.