Add cache clearing cloud-function source code.

This commit is contained in:
John Hobbs 2020-01-29 17:12:48 -06:00
parent 6e4ccbff33
commit d738758705
No known key found for this signature in database
GPG Key ID: 01FC8AE9E5070C1D
13 changed files with 391 additions and 0 deletions

4
.gitignore vendored
View File

@ -6,6 +6,10 @@ dist/
still/
.cache/
cloud-function/env.yaml
cloud-function/.envrc
cloud-function/vendor/*
# Created by https://www.gitignore.io/api/vim,osx
### Vim ###

View File

@ -0,0 +1,6 @@
*_test.go
Makefile
.envrc
*.yaml
README.md
vendor/*

19
cloud-function/Makefile Normal file
View File

@ -0,0 +1,19 @@
.PHONY: test build deploy
test:
go test -timeout 30s -tags testing -count=1 cloudfunction
build:
go build .
deploy: test build
gcloud functions deploy "$$FUNCTION_NAME" \
--project "$$PROJECT_ID" \
--runtime go111 \
--allow-unauthenticated \
--entry-point=PurgeCloudFlare \
--trigger-http \
--env-vars-file=env.yaml \
--max-instances=5 \
--timeout='30s' \
--memory='128MB'

29
cloud-function/README.md Normal file
View File

@ -0,0 +1,29 @@
# Cult of the Party Parrot Cloud Function
The CotPP website is generated and hosted by Netlify. However, it uses a
prohibitive amount of bandwidth for that platform.
To have our cake and eat it too, CloudFlare is installed in front of Netlify.
However, the aggressive caching of CloudFlare means we do not get fresh content
on new master branch deploys out of the box.
This GCF function is called by a Netlify deploy hook, and purges the CloudFlare
cache.
## Testing
The function has fair coverage. You can execute the test suite with `make test`
## Deploying
To deploy this for your own Netlify hook needs, you will need a few items.
First, copy `example_env.yaml` to `env.yaml` and set the variables inside.
Next, you will need to set two environment variables.
- `FUNCTION_NAME` is the name of the function you want in GCP
- `PROJECT_ID` is the project in GCP you will be deploying to
With all of this configured, `make deploy` will get the code up and running.

View File

@ -0,0 +1,3 @@
CF_ZONE: <cloudflare-zone-id>
CF_TOKEN: <cloudflare-api-token>
JWT_SECRET: <256-bit-netlify-jwt-secret>

82
cloud-function/func.go Normal file
View File

@ -0,0 +1,82 @@
package cloudfunction
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
)
type netlifyWebhook struct {
ID string `json:"id"`
Branch string `json:"branch"`
}
var cloudflareHTTPClient *http.Client = http.DefaultClient
var (
cloudFlareZone string = os.Getenv("CF_ZONE")
cloudFlareAPIToken string = os.Getenv("CF_TOKEN")
)
// PurgeCloudFlare verifies a Netlify deploy hook and flushes an entire
// CloudFlare zone as needed.
func PurgeCloudFlare(w http.ResponseWriter, r *http.Request) {
var bodyBuf bytes.Buffer
tee := io.TeeReader(r.Body, &bodyBuf)
defer r.Body.Close()
dec := json.NewDecoder(tee)
var wh netlifyWebhook
err := dec.Decode(&wh)
if err != nil {
log.Println("error decoding webhook body:", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if wh.Branch != "master" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Ok. Thanks."))
return
}
jwt := r.Header.Get("X-Webhook-Signature")
if jwt == "" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyRequest([]byte(jwt), bodyBuf.Bytes()) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/purge_cache", cloudFlareZone)
req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(`{"purge_everything":true}`)))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cloudFlareAPIToken))
req.Header.Set("Content-Type", "application/json")
resp, err := cloudflareHTTPClient.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(resp.StatusCode)
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
log.Println(string(body))
defer resp.Body.Close()
}
}

143
cloud-function/func_test.go Normal file
View File

@ -0,0 +1,143 @@
package cloudfunction
import (
"bytes"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
func Test_PurgeCloudFlare(t *testing.T) {
t.Run("invalid body", func(t *testing.T) {
buf := bytes.NewBufferString(`this is not json`)
req := httptest.NewRequest(http.MethodPost, "/", buf)
rec := httptest.NewRecorder()
PurgeCloudFlare(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("unexpected response code %d", resp.StatusCode)
}
})
t.Run("ignores non-master branches", func(t *testing.T) {
buf := bytes.NewBufferString(`{"branch":"some-pr-branch"}`)
req := httptest.NewRequest(http.MethodPost, "/", buf)
rec := httptest.NewRecorder()
PurgeCloudFlare(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("unexpected response code %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unexpected error reading response body: %v", err)
}
defer resp.Body.Close()
if string(body) != "Ok. Thanks." {
t.Errorf("unexpected response body, %q", string(body))
}
})
t.Run("rejects missing JWT", func(t *testing.T) {
buf := bytes.NewBufferString(`{"branch":"master"}`)
req := httptest.NewRequest(http.MethodPost, "/", buf)
rec := httptest.NewRecorder()
req.Header.Set("X-Webhook-Signature", "")
PurgeCloudFlare(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("unexpected response code %d", resp.StatusCode)
}
})
t.Run("rejects incorrect JWT", func(t *testing.T) {
buf := bytes.NewBufferString(`{"branch":"master"}`)
req := httptest.NewRequest(http.MethodPost, "/", buf)
rec := httptest.NewRecorder()
req.Header.Set("X-Webhook-Signature", "not.even.close")
PurgeCloudFlare(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("unexpected response code %d", resp.StatusCode)
}
})
t.Run("makes a purge cache request against CloudFlare API", func(t *testing.T) {
purgeHappened := false
cloudflareHTTPClient = NewTestClient(func(r *http.Request) *http.Response {
purgeHappened = true
if r.Method != http.MethodPost {
t.Errorf("unexpected HTTP method: %q", r.Method)
}
if r.Host != "api.cloudflare.com" {
t.Errorf("unexpected HTTP Host value: %q", r.Host)
}
if r.URL.Path != "/client/v4/zones/test-cf-zone/purge_cache" {
t.Errorf("unexpected HTTP request path: %q", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-cloudflare-api-token" {
t.Errorf("incorrect authorization header: %q", r.Header.Get("Authorization"))
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("incorrect content-type header: %q", r.Header.Get("Content-Type"))
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("unexpected error reading body: %v", err)
}
defer r.Body.Close()
if string(body) != `{"purge_everything":true}` {
t.Errorf("unexpected HTTP request body: %q", string(body))
}
return &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`OK`)),
Header: make(http.Header),
}
})
buf := bytes.NewBuffer(validTestBody)
req := httptest.NewRequest(http.MethodPost, "/", buf)
req.Header.Set("X-Webhook-Signature", string(validTestJWT))
rec := httptest.NewRecorder()
PurgeCloudFlare(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("unexpected response code %d", resp.StatusCode)
}
if !purgeHappened {
t.Errorf("purge request did not occur")
}
})
}
// RoundTripFunc .
type RoundTripFunc func(req *http.Request) *http.Response
// RoundTrip .
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
//NewTestClient returns *http.Client with Transport replaced to avoid making real calls
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}

8
cloud-function/go.mod Normal file
View File

@ -0,0 +1,8 @@
module cloudfunction
go 1.13
require (
github.com/gbrlsnchs/jwt v1.1.0 // indirect
github.com/gbrlsnchs/jwt/v3 v3.0.0-rc.1
)

24
cloud-function/go.sum Normal file
View File

@ -0,0 +1,24 @@
github.com/gbrlsnchs/jwt v1.1.0 h1:Gh2CoXcIfk8/LxV8ks0GDOmUDCpVIrw8Oa34Ozmw/10=
github.com/gbrlsnchs/jwt v1.1.0/go.mod h1:p5fttBhRV34dmDL7zpqJ2ctL8yaWm5DJTtBm/evgQ/c=
github.com/gbrlsnchs/jwt/v3 v3.0.0-rc.1 h1:/opyYiz6HZoBVAU8ypemFOTtzuKFE9kiKstP6RYE1Z4=
github.com/gbrlsnchs/jwt/v3 v3.0.0-rc.1/go.mod h1:JEL7eYb4ETfz9AYni+/4BV09MrMgGwju0G/k4XF8QMg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE=
github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad h1:5E5raQxcv+6CZ11RrBYQe5WRbUIWpScjh0kvHZkZIrQ=
golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190918214516-5a1a30219888/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools/gopls v0.1.7/go.mod h1:PE3vTwT0ejw3a2L2fFgSJkxlEbA8Slbk+Lsy9hTmbG8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

13
cloud-function/init.go Normal file
View File

@ -0,0 +1,13 @@
// +build !testing
package cloudfunction
import (
"os"
"github.com/gbrlsnchs/jwt/v3"
)
func init() {
hs = jwt.NewHS256([]byte(os.Getenv("JWT_SECRET")))
}

28
cloud-function/jwt.go Normal file
View File

@ -0,0 +1,28 @@
package cloudfunction
import (
"crypto/sha256"
"fmt"
"github.com/gbrlsnchs/jwt/v3"
)
var hs *jwt.HMACSHA
type netlifyPayload struct {
ISS string `json:"iss"`
Sha256 string `json:"sha256"`
}
func verifyRequest(token []byte, body []byte) bool {
var pl netlifyPayload
_, err := jwt.Verify(token, hs, &pl, jwt.ValidateHeader)
if err != nil {
return false
}
h := sha256.New()
h.Write(body)
return pl.Sha256 == fmt.Sprintf("%x", h.Sum(nil))
}

View File

@ -0,0 +1,11 @@
package cloudfunction
import (
"testing"
)
func Test_VerifyRequest(t *testing.T) {
if !verifyRequest(validTestJWT, validTestBody) {
t.Error("request did not verify, though it was expected to")
}
}

View File

@ -0,0 +1,21 @@
package cloudfunction
import "github.com/gbrlsnchs/jwt/v3"
// These are shared between tests.
var (
validTestBody []byte = []byte(`{"id":"00000008a557c301910f3624","site_id":"00000000-1111-2222-3333-444444444444","build_id":"00000008a557c301910f3625","state":"ready","name":"cultofthepartyparrot","url":"https://cultofthepartyparrot.com","ssl_url":"https://cultofthepartyparrot.com","admin_url":"https://app.netlify.com/sites/cultofthepartyparrot","deploy_url":"http://00000008a557c301910f3624.cultofthepartyparrot.netlify.com","deploy_ssl_url":"https://00000008a557c301910f3624--cultofthepartyparrot.netlify.com","created_at":"2020-01-14T21:28:24.760Z","updated_at":"2020-01-14T21:29:20.321Z","user_id":"000000011111112222222333","error_message":null,"required":[],"required_functions":[],"commit_ref":null,"review_id":null,"branch":"master","commit_url":null,"skipped":null,"locked":null,"log_access_attributes":{"type":"firebase","url":"https://netlify-builds3.firebaseio.com/builds/00000008a557c301910f3625/log","endpoint":"https://netlify-builds3.firebaseio.com","path":"/builds/00000008a557c301910f3625/log","token":"000000011111112222222333333344444445555555666666677777778888888"},"title":null,"review_url":null,"published_at":"2020-01-14T21:29:20.132Z","context":"production","deploy_time":51,"available_functions":[],"summary":{"status":"ready","messages":[{"type":"info","title":"409 new files uploaded","description":"2 generated pages and 407 assets changed.","details":"New pages include:\n- index.html\n- flags.html\n"},{"type":"info","title":"No redirect rules processed","description":"This deploy did not include any redirect rules. [Learn more about redirects](https://www.netlify.com/docs/redirects/).","details":""},{"type":"info","title":"No header rules processed","description":"This deploy did not include any header rules. [Learn more about headers](https://www.netlify.com/docs/headers-and-basic-auth/).","details":""},{"type":"info","title":"All linked resources are secure","description":"Congratulations! No insecure mixed content found in your files.","details":null}]},"screenshot_url":null,"site_capabilities":{"title":"Netlify Team Free","asset_acceleration":true,"form_processing":true,"cdn_propagation":"partial","build_gc_exchange":"buildbot-gc","build_node_pool":"buildbot-external-ssd","domain_aliases":true,"secure_site":false,"prerendering":true,"proxying":true,"ssl":"custom","rate_cents":0,"yearly_rate_cents":0,"cdn_network":"free_cdn_network","ipv6_domain":"cdn.makerloop.com","branch_deploy":true,"managed_dns":true,"geo_ip":true,"split_testing":true,"id":"nf_team_dev","cdn_tier":"reg"},"committer":null,"skipped_log":null,"manual_deploy":false,"file_tracking_optimization":null}`)
validTestJWT []byte = []byte(`eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZXRsaWZ5Iiwic2hhMjU2IjoiM2Q5ZWNhMzFhN2UyMjA4ZWU0YzZmOTcxMDFlMzljNDc1M2FmNDVlNWE2YTQzNDYxMTU3N2UyZDkyOWIwNjIzNSJ9.MdlUcFNbHw2cyS_N4iWy03d3ACOSlibLFhAYf2Rj2TM`)
)
func init() {
// prevent/divert any accidental purge attempts
cloudflareHTTPClient = nil
// Set up known test values
cloudFlareZone = "test-cf-zone"
cloudFlareAPIToken = "test-cloudflare-api-token"
// override package level for testing
hs = jwt.NewHS256([]byte(`partyordiepartyordiepartyordieok`))
}