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

feat: Implement appconfig package #1625

Merged
merged 3 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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] {
michaljurecko marked this conversation as resolved.
Show resolved Hide resolved
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)
Comment on lines +62 to +76
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cache-Control header handling:

  • of header is missing or configured to disable caching then don't cache anything
  • if MaxAge is set then use it

return nil
})
return request.NewAPIRequest(result, req)
}

// SandboxesError represents the structure of API error.
type SandboxesError struct {
Message string `json:"error"`
michaljurecko marked this conversation as resolved.
Show resolved Hide resolved
ExceptionID string `json:"exceptionId"`
request *http.Request
response *http.Response
}
Comment on lines +83 to +88
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Difference to QueueError: There was a discrepancy between http code and the code in json response. Pepa said I should use the http code so I removed the code that was handling the code from json.

Copy link
Collaborator

Choose a reason for hiding this comment

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

🤦‍♂️ ok ✔️


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
michaljurecko marked this conversation as resolved.
Show resolved Hide resolved

func (l *Loader) LoadConfig(ctx context.Context, appID string) (AppProxyConfig, error) {
michaljurecko marked this conversation as resolved.
Show resolved Hide resolved
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),
michaljurecko marked this conversation as resolved.
Show resolved Hide resolved
}
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
}
Comment on lines +96 to +101
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pepa wanted me to use the stale cache configuration on error while refreshing the configuration. In my opinion this should only be the case for temporary errors. This is only for errors of sandboxes api itself, invalid configuration is handled elsewhere.

Copy link
Collaborator

Choose a reason for hiding this comment

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

✔️


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

return AppProxyConfig{}, err
}