Add cache clearing cloud-function source code.
This commit is contained in:
parent
6e4ccbff33
commit
d738758705
|
@ -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 ###
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
*_test.go
|
||||
Makefile
|
||||
.envrc
|
||||
*.yaml
|
||||
README.md
|
||||
vendor/*
|
|
@ -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'
|
|
@ -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.
|
|
@ -0,0 +1,3 @@
|
|||
CF_ZONE: <cloudflare-zone-id>
|
||||
CF_TOKEN: <cloudflare-api-token>
|
||||
JWT_SECRET: <256-bit-netlify-jwt-secret>
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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")))
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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`))
|
||||
}
|
Loading…
Reference in New Issue