Skip to content

Commit

Permalink
Merge pull request #112 from gympass/PE1-1888/origin-headers
Browse files Browse the repository at this point in the history
[PE1-1888] feat: support custom headers for origins
  • Loading branch information
LCaparelli authored Dec 14, 2023
2 parents 1a96332 + 2c3e1e9 commit 246bc0a
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 3 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The following annotation controls how origins and behaviors are attached to Clou
mykey1: myvalue1
mykey2: myvalue2
```
- `cdn-origin-controller.gympass.com/cf.origin-headers`: HTTP headers to be added to each request made for an origin. Refer to the [dedicated section](#custom-headers) for more details.

The controller needs permission to manipulate the CloudFront distributions. A [sample IAM Policy](docs/iam_policy.json) is provided with the necessary IAM actions.

Expand Down Expand Up @@ -103,6 +104,32 @@ TLS will automatically be enabled if the `CF_SECURITY_POLICY` env var is set, an

The controller will automatically search for TLS certificates in [AWS ACM](https://aws.amazon.com/certificate-manager/). If it finds a certificate matching any of the Distribution's alternate domain names, it will bind that certificate to the Distribution.

## Custom Headers

CloudFront allows you to specify headers that should be added to each request for a given origin. For example:

```yaml
kind: Ingress
metadata:
annotations:
cdn-origin-controller.gympass.com/cf.origin-headers: "static=value,dynamic={{origin.host}}"
```

This configures two HTTP headers:

- "static": which is mapped to the value "value"
- "dynamic": which uses a template to calculate the value during runtime. This is useful for fields which are not known beforehand, such as the origin's host

Currently supported template values:

| field | description |
| ----------- | ----------------------- |
| origin.host | The host of this origin |

### Custom Headers on user-supplied origins/behaviors

You can also use this feature with user-supplied origins/behaviors. Refer to the [dedicated section](#user-supplied-originbehavior-configuration).

## Behavior ordering

During reconciliation, the controller will assemble desired behaviors based on all
Expand Down Expand Up @@ -201,9 +228,14 @@ metadata:
behaviors:
- path: /bar
- path: /bar/*
headers:
static: value
dynamic: '{{origin.host}}'
```

> **IMPORTANT**: when using the `headers` field, make sure you add quotes when using templates, to preven YAML parsing errors. (`'{{origin.host}}'`, not `{{origin.host}}`). Check the [dedicated section](#custom-headers) for all available template values.

The `.host` is the hostname of the origin you're configuring.

The `.behaviors` field is a list of objects representing the cache behaviors that should be configured. It contains a required string `path`, and an optional `functionAssociation` that is defined as shown [here](#function-associations).
Expand All @@ -224,6 +256,7 @@ The table below maps remaining available fields of an entry in this list to an a
| .viewerFunctionARN | cdn-origin-controller.gympass.com/cf.viewer-function-arn | deprecated, prefer defining associtions in .behaviors[].functionAssociations |
| .cachePolicy | cdn-origin-controller.gympass.com/cf.cache-policy | - |
| .webACLARN | cdn-origin-controller.gympass.com/cf.web-acl-arn | - |
| .headers | cdn-origin-controller.gympass.com/cf.origin-headers | - |

### Bucket origin access

Expand Down
17 changes: 16 additions & 1 deletion internal/cloudfront/aws_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func newAWSOrigin(o Origin) *cloudfront.Origin {
}

return &cloudfront.Origin{
CustomHeaders: &cloudfront.CustomHeaders{Quantity: aws.Int64(0)},
CustomHeaders: newCustomHeaders(o),
CustomOriginConfig: customOriginConfig,
DomainName: aws.String(o.Host),
Id: aws.String(o.Host),
Expand All @@ -160,6 +160,21 @@ func newAWSOrigin(o Origin) *cloudfront.Origin {
}
}

func newCustomHeaders(o Origin) *cloudfront.CustomHeaders {
var items []*cloudfront.OriginCustomHeader
for k, v := range o.Headers() {
items = append(items, &cloudfront.OriginCustomHeader{
HeaderName: aws.String(k),
HeaderValue: aws.String(v),
})
}

return &cloudfront.CustomHeaders{
Items: items,
Quantity: aws.Int64(int64(len(items))),
}
}

func newCacheBehavior(b Behavior) *cloudfront.CacheBehavior {
cb := baseCacheBehavior(b)
var cfFunctions []Function
Expand Down
50 changes: 49 additions & 1 deletion internal/cloudfront/origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
package cloudfront

import (
"strings"

"github.com/Gympass/cdn-origin-controller/internal/config"
"github.com/Gympass/cdn-origin-controller/internal/k8s"
)
Expand All @@ -30,9 +32,37 @@ const (
)

const (
defaultResponseTimeout = 30
defaultResponseTimeout = 30
templateOriginHeadersHost = "{{origin.host}}"
)

// originHeaders represents pairs of HTTP header key/values that should be added to requests to the origin
type originHeaders struct {
originHost string
headers map[string]string
}

func newOriginHeaders(originHost string, headers map[string]string) originHeaders {
return originHeaders{
originHost: originHost,
headers: headers,
}
}

func (h originHeaders) get() map[string]string {
if h.headers == nil {
return nil
}

result := make(map[string]string, len(h.headers))
for k, v := range h.headers {
v = strings.ReplaceAll(v, templateOriginHeadersHost, h.originHost)
result[k] = v
}

return result
}

// Origin represents a CloudFront Origin and aggregates Behaviors associated with it
type Origin struct {
// Host is the origin's hostname
Expand All @@ -45,13 +75,22 @@ type Origin struct {
Access string
// OAC configures Access Origin Control for this Origin
OAC OAC

headers originHeaders
}

// HasEqualParameters returns whether both Origins have the same parameters. It ignores differences in Behaviors
func (o Origin) HasEqualParameters(o2 Origin) bool {
return o.Host == o2.Host && o.ResponseTimeout == o2.ResponseTimeout && o.Access == o2.Access && o.OAC == o2.OAC
}

// Headers returns the headers that are bound to this Origin.
// The stored value may be changed in order to use values that are known
// only at runtime, such as the Origin's host.
func (o Origin) Headers() map[string]string {
return o.headers.get()
}

func (o Origin) isBucketBased() bool {
return o.Access == OriginAccessBucket
}
Expand All @@ -73,6 +112,7 @@ type Behavior struct {
// OriginBuilder allows the construction of an Origin
type OriginBuilder struct {
host string
headers map[string]string
requestPolicy string
distributionName string
cachePolicy string
Expand Down Expand Up @@ -138,13 +178,21 @@ func (b OriginBuilder) WithResponseTimeout(rpTimeout int64) OriginBuilder {
return b
}

// WithOriginHeaders associates a map of HTTP headers that CloudFront should add on every request to the Origin
func (b OriginBuilder) WithOriginHeaders(headers map[string]string) OriginBuilder {
b.headers = headers
return b
}

// Build creates an Origin based on configuration made so far
func (b OriginBuilder) Build() Origin {
origin := Origin{
Host: b.host,
ResponseTimeout: b.respTimeout,
}

origin.headers = newOriginHeaders(b.host, b.headers)

origin = b.addBehaviors(origin)

origin = b.addCachePolicyBehaviors(origin)
Expand Down
23 changes: 23 additions & 0 deletions internal/cloudfront/origin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,26 @@ func (s *OriginTestSuite) TestNewOriginBuilder_TestHasDifferentParameters() {
s.False(o.HasEqualParameters(o3))
s.True(o.HasEqualParameters(o))
}

func (s *OriginTestSuite) TestNewOriginBuilder_OriginHeadersAreNotPassedAndShouldBeNil() {
o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg).
Build()

s.Nil(o.Headers())
}

func (s *OriginTestSuite) TestNewOriginBuilder_StaticOriginHeadersArePassedAndShouldBePresentAtResult() {
o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg).
WithOriginHeaders(map[string]string{"key": "val"}).
Build()

s.Equal(map[string]string{"key": "val"}, o.Headers())
}

func (s *OriginTestSuite) TestNewOriginBuilder_DynamicOriginHeadersArePassedAndShouldBeTemplatedAtResult() {
o := NewOriginBuilder("dist", "origin.com", "Public", s.cfg).
WithOriginHeaders(map[string]string{"key": "{{origin.host}}"}).
Build()

s.Equal(map[string]string{"key": "origin.com"}, o.Headers())
}
3 changes: 2 additions & 1 deletion internal/cloudfront/service_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ func newOrigin(ing k8s.CDNIngress, cfg config.Config, shared k8s.SharedIngressPa
builder := NewOriginBuilder(ing.Group, ing.OriginHost, ing.OriginAccess, cfg).
WithResponseTimeout(ing.OriginRespTimeout).
WithRequestPolicy(ing.OriginReqPolicy).
WithCachePolicy(ing.CachePolicy)
WithCachePolicy(ing.CachePolicy).
WithOriginHeaders(ing.OriginHeaders)

for _, p := range shared.PathsFromOrigin(ing.OriginHost) {
for _, pp := range pathPatternsForPath(p) {
Expand Down
42 changes: 42 additions & 0 deletions internal/k8s/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
cfAlternateDomainNamesAnnotation = "cdn-origin-controller.gympass.com/cf.alternate-domain-names"
cfWebACLARNAnnotation = "cdn-origin-controller.gympass.com/cf.web-acl-arn"
cfTagsAnnotation = "cdn-origin-controller.gympass.com/cf.tags"
cfOrigHeadersAnnotation = "cdn-origin-controller.gympass.com/cf.origin-headers"
)

// Path represents a path item within an Ingress
Expand All @@ -67,6 +68,7 @@ type CDNIngress struct {
Group string
UnmergedPaths []Path
OriginReqPolicy string
OriginHeaders map[string]string
CachePolicy string
OriginRespTimeout int64
AlternateDomainNames []string
Expand Down Expand Up @@ -209,6 +211,11 @@ func NewCDNIngressFromV1(ctx context.Context, ing *networkingv1.Ingress, class C
return CDNIngress{}, err
}

headers, err := headersV1(ing)
if err != nil {
return CDNIngress{}, err
}

result := CDNIngress{
NamespacedName: types.NamespacedName{
Namespace: ing.Namespace,
Expand All @@ -217,6 +224,7 @@ func NewCDNIngressFromV1(ctx context.Context, ing *networkingv1.Ingress, class C
Group: groupAnnotationValue(ing),
UnmergedPaths: paths,
OriginReqPolicy: originReqPolicy(ing),
OriginHeaders: headers,
CachePolicy: cachePolicy(ing),
OriginRespTimeout: originRespTimeout(ing),
AlternateDomainNames: alternateDomainNames(ing),
Expand Down Expand Up @@ -299,6 +307,40 @@ func pathsForFunctionAssociations(ctx context.Context, ing *networkingv1.Ingress
return paths
}

func headersV1(ing *networkingv1.Ingress) (map[string]string, error) {
val, ok := ing.GetAnnotations()[cfOrigHeadersAnnotation]
if !ok {
return nil, nil
}

headers, err := parseOriginHeaders(val)
if err != nil {
return nil, fmt.Errorf("parsing origin headers from annotation %q: %v", cfViewerFnAnnotation, err)
}

return headers, nil
}

func parseOriginHeaders(rawHeaders string) (map[string]string, error) {
if len(rawHeaders) == 0 {
return nil, nil
}

headers := strings.Split(rawHeaders, ",")

result := make(map[string]string)
for _, kv := range headers {
kvParts := strings.Split(kv, "=")
if len(kvParts) != 2 || kvParts[0] == "" || kvParts[1] == "" {
return nil, fmt.Errorf("informed origin header does not follow 'key=value' format: %s", kv)
}

result[kvParts[0]] = kvParts[1]
}

return result, nil
}

func viewerFnARN(obj client.Object) string {
return obj.GetAnnotations()[cfViewerFnAnnotation]
}
Expand Down
Loading

0 comments on commit 246bc0a

Please sign in to comment.