Skip to content
Snippets Groups Projects
Unverified Commit 46ccdec2 authored by Will McCutchen's avatar Will McCutchen Committed by GitHub
Browse files

Drop platform-specific deployment code and configuration (#79)

parent a05101cc
No related branches found
No related tags found
No related merge requests found
......@@ -15,7 +15,6 @@ jobs:
- uses: actions/setup-go@v2
with:
go-version: '1.18'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3.1.0
- uses: golangci/golangci-lint-action@v3.1.0
with:
version: v1.45.2
# This workflow implements continuous delivery with automated testing and fully
# autonomous deploys to production on merge.
name: CD
name: Test
# Translated: "Execute this workflow on pushes to main OR on pull requests
# opened against main"
......@@ -17,32 +15,26 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/setup-go@v2
- uses: actions/setup-go@v2
with:
go-version: '1.18'
- name: Checkout
uses: actions/checkout@v2
- name: Build
run: make build
- uses: actions/checkout@v2
- name: Build docker image
run: make image
- run: make testci
- name: Test
run: git show --stat && make testci
- name: Code coverage
uses: codecov/codecov-action@v1
- uses: codecov/codecov-action@v1
with:
file: ./coverage.txt
regression_test:
- run: make image
regression_tests:
name: Regression Tests
runs-on: ubuntu-latest
strategy:
matrix:
go_version:
......@@ -50,56 +42,10 @@ jobs:
- '1.16'
steps:
- name: Setup
uses: actions/setup-go@v2
- uses: actions/setup-go@v2
with:
go-version: ${{matrix.go_version}}
- name: Checkout
uses: actions/checkout@v2
- name: Build
run: make build
- name: Test
run: make test
production_deploy:
name: Production Deploy
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: [test]
permissions:
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- uses: actions/checkout@v2
- name: Notify start
id: deployment
uses: bobheadxi/deployments@v1
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: production
- name: Deploy
run: "flyctl deploy --strategy rolling"
env:
DOCKER_BUILDKIT: 1
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
- name: Notify finish
uses: bobheadxi/deployments@v1
with:
step: finish
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
token: ${{ secrets.GITHUB_TOKEN }}
env: production
status: ${{ job.status }}
env_url: 'https://httpbingo.org'
- run: make test
.PHONY: clean deploy deps gcloud-auth image imagepush lint run stagedeploy test testci testcover
# The version that will be used in docker tags (e.g. to push a
# go-httpbin:latest image use `make imagepush VERSION=latest)`
VERSION ?= $(shell git rev-parse --short HEAD)
# Override these values to deploy to a different Cloud Run project
GCLOUD_PROJECT ?= httpbingo
GCLOUD_ACCOUNT ?= mccutchen@gmail.com
GCLOUD_REGION ?= us-central1
# The version tag for the Cloud Run deployment (override this to adjust
# pre-production URLs)
GCLOUD_TAG ?= "v-$(VERSION)"
# Run gcloud in a container to avoid needing to install the SDK locally
GCLOUD_COMMAND ?= ./bin/gcloud
# We push docker images to both docker hub and gcr.io
DOCKER_TAG_DOCKERHUB ?= mccutchen/go-httpbin:$(VERSION)
DOCKER_TAG_GCLOUD ?= gcr.io/$(GCLOUD_PROJECT)/go-httpbin:$(VERSION)
DOCKER_TAG ?= mccutchen/go-httpbin:$(VERSION)
# Built binaries will be placed here
DIST_PATH ?= dist
......@@ -41,15 +24,18 @@ GO_SOURCES = $(wildcard **/*.go)
# =============================================================================
build: $(DIST_PATH)/go-httpbin
buildtests: $(DIST_PATH)/go-httpbin.test
$(DIST_PATH)/go-httpbin: $(GO_SOURCES)
mkdir -p $(DIST_PATH)
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
buildtests:
$(DIST_PATH)/go-httpbin.test: $(GO_SOURCES)
CGO_ENABLED=0 go test -ldflags="-s -w" -v -c -o $(DIST_PATH)/go-httpbin.test ./httpbin
clean:
rm -rf $(DIST_PATH) $(COVERAGE_PATH)
.PHONY: clean
# =============================================================================
......@@ -57,6 +43,7 @@ clean:
# =============================================================================
test:
go test $(TEST_ARGS) ./...
.PHONY: test
# Test command to run for continuous integration, which includes code coverage
......@@ -65,15 +52,18 @@ test:
testci: build
go test $(TEST_ARGS) $(COVERAGE_ARGS) ./...
git diff --exit-code
.PHONY: testci
testcover: testci
go tool cover -html=$(COVERAGE_PATH)
.PHONY: testcover
lint: $(TOOL_GOLINT) $(TOOL_STATICCHECK)
test -z "$$(gofmt -d -s -e .)" || (echo "Error: gofmt failed"; gofmt -d -s -e . ; exit 1)
go vet ./...
$(TOOL_GOLINT) -set_exit_status ./...
$(TOOL_STATICCHECK) ./...
.PHONY: lint
# =============================================================================
......@@ -81,61 +71,26 @@ lint: $(TOOL_GOLINT) $(TOOL_STATICCHECK)
# =============================================================================
run: build
$(DIST_PATH)/go-httpbin
.PHONY: run
watch: $(TOOL_REFLEX)
reflex -s -r '\.(go|html)$$' make run
# =============================================================================
# deploy to fly.io
# =============================================================================
deploy:
flyctl deploy --strategy=rolling
# =============================================================================
# deploy to google cloud run
# =============================================================================
deploy-cloud-run: gcloud-auth imagepush
$(GCLOUD_COMMAND) beta run deploy \
$(GCLOUD_PROJECT) \
--image=$(DOCKER_TAG_GCLOUD) \
--revision-suffix=$(VERSION) \
--tag=$(GCLOUD_TAG) \
--project=$(GCLOUD_PROJECT) \
--region=$(GCLOUD_REGION) \
--allow-unauthenticated \
--platform=managed
$(GCLOUD_COMMAND) run services update-traffic --to-latest
stagedeploy-cloud-run: gcloud-auth imagepush
$(GCLOUD_COMMAND) beta run deploy \
$(GCLOUD_PROJECT) \
--image=$(DOCKER_TAG_GCLOUD) \
--revision-suffix=$(VERSION) \
--tag=$(GCLOUD_TAG) \
--project=$(GCLOUD_PROJECT) \
--region=$(GCLOUD_REGION) \
--allow-unauthenticated \
--platform=managed \
--no-traffic
gcloud-auth:
@$(GCLOUD_COMMAND) auth list | grep '^\*' | grep -q $(GCLOUD_ACCOUNT) || $(GCLOUD_COMMAND) auth login $(GCLOUD_ACCOUNT)
@$(GCLOUD_COMMAND) auth print-access-token | docker login -u oauth2accesstoken --password-stdin https://gcr.io
.PHONY: watch
# =============================================================================
# docker images
# =============================================================================
image:
DOCKER_BUILDKIT=1 docker build -t $(DOCKER_TAG_DOCKERHUB) .
DOCKER_BUILDKIT=1 docker build -t $(DOCKER_TAG) .
.PHONY: image
imagepush:
docker buildx create --name httpbin
docker buildx use httpbin
docker buildx build --push --platform linux/amd64,linux/arm64 -t $(DOCKER_TAG_DOCKERHUB) .
docker buildx build --push --platform linux/amd64,linux/arm64 -t $(DOCKER_TAG) .
docker buildx rm httpbin
.PHONY: imagepush
# =============================================================================
......
---
runtime: go113
main: ./cmd/go_httpbin
handlers:
# Always redirect index requests to https
- url: /
script: auto
secure: always
redirect_http_response_code: 301
# Allow requests for any other resources via either http or https
- url: /.+
script: auto
secure: optional
#!/bin/bash
#
# A wrapper that executes the gcloud CLI in a docker container, to avoid
# requiring a local installation.
#
# Adapted from this helpful blog post:
# https://blog.scottlowe.org/2018/09/13/running-gcloud-cli-in-a-docker-container/
GCLOUD_SDK_TAG="312.0.0"
exec docker run \
--rm -it \
--workdir /code \
-v $PWD:/code \
-v $HOME/.config/gcloud:/root/.config/gcloud \
google/cloud-sdk:$GCLOUD_SDK_TAG \
gcloud $*
# What is going on here?
## TL;DR
* `cmd/maincmd` package exposes all of this app's command line functionality in a `Main()`
* `cmd/go-httpbin` and `cmd/go_httpbin` build identical binaries using the
`maincmd` package for backwards compatibility reasons explained below
## Why tho
Originally, this project exposed only one command:
cmd/go-httpbin/main.go
But the dash in that path was incompatible with Google App Engine's naming
restrictions, so in [moving httpbingo.org onto Google App Engine][pr17], that
path was (carelessly) renamed to
cmd/go_httpbin/main.go
_That_ change had a number of unintended consequences:
* It broke existing workflows built around `go get github.com/mccutchen/go-httpbin/cmd/go-httpbin`,
as suggested in the README
* It broke the Makefile, which was still looking for `cmd/go-httpbin`
* It broke the absolute aesthetic truth that CLI binaries should use dashes
instead of underscores for word separators
So, to restore the former behavior while maintaining support for deploying to
App Engine, the actual main functionality was extracted into the `cmd/maincmd`
package here and shared between the other two.
(This is pretty dumb, I know, but it seems to work.)
[pr17]: https://github.com/mccutchen/go-httpbin/pull/17
package main
import "github.com/mccutchen/go-httpbin/v2/cmd/maincmd"
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/mccutchen/go-httpbin/v2/httpbin"
)
const (
defaultHost = "0.0.0.0"
defaultPort = 8080
)
var (
host string
port int
maxBodySize int64
maxDuration time.Duration
httpsCertFile string
httpsKeyFile string
)
func main() {
maincmd.Main()
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
flag.IntVar(&port, "port", defaultPort, "Port to listen on")
flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
flag.Parse()
// Command line flags take precedence over environment vars, so we only
// check for environment vars if we have default values for our command
// line flags.
var err error
if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" {
maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err)
flag.Usage()
os.Exit(1)
}
}
if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" {
maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err)
flag.Usage()
os.Exit(1)
}
}
if host == defaultHost && os.Getenv("HOST") != "" {
host = os.Getenv("HOST")
}
if port == defaultPort && os.Getenv("PORT") != "" {
port, err = strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err)
flag.Usage()
os.Exit(1)
}
}
if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" {
httpsCertFile = os.Getenv("HTTPS_CERT_FILE")
}
if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" {
httpsKeyFile = os.Getenv("HTTPS_KEY_FILE")
}
var serveTLS bool
if httpsCertFile != "" || httpsKeyFile != "" {
serveTLS = true
if httpsCertFile == "" || httpsKeyFile == "" {
fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n")
flag.Usage()
os.Exit(1)
}
}
logger := log.New(os.Stderr, "", 0)
// A hacky log helper function to ensure that shutdown messages are
// formatted the same as other messages. See StdLogObserver in
// httpbin/middleware.go for the format we're matching here.
serverLog := func(msg string, args ...interface{}) {
const (
logFmt = "time=%q msg=%q"
dateFmt = "2006-01-02T15:04:05.9999"
)
logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
}
h := httpbin.New(
httpbin.WithMaxBodySize(maxBodySize),
httpbin.WithMaxDuration(maxDuration),
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
)
listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
server := &http.Server{
Addr: listenAddr,
Handler: h.Handler(),
}
// shutdownCh triggers graceful shutdown on SIGINT or SIGTERM
shutdownCh := make(chan os.Signal, 1)
signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
// exitCh will be closed when it is safe to exit, after graceful shutdown
exitCh := make(chan struct{})
go func() {
sig := <-shutdownCh
serverLog("shutdown started by signal: %s", sig)
shutdownTimeout := maxDuration + 1*time.Second
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
serverLog("shutdown error: %s", err)
}
close(exitCh)
}()
var listenErr error
if serveTLS {
serverLog("go-httpbin listening on https://%s", listenAddr)
listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile)
} else {
serverLog("go-httpbin listening on http://%s", listenAddr)
listenErr = server.ListenAndServe()
}
if listenErr != nil && listenErr != http.ErrServerClosed {
logger.Fatalf("failed to listen: %s", listenErr)
}
<-exitCh
serverLog("shutdown finished")
}
package main
import "github.com/mccutchen/go-httpbin/v2/cmd/maincmd"
func main() {
maincmd.Main()
}
package maincmd
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/mccutchen/go-httpbin/v2/httpbin"
)
const defaultHost = "0.0.0.0"
const defaultPort = 8080
var (
host string
port int
maxBodySize int64
maxDuration time.Duration
httpsCertFile string
httpsKeyFile string
)
// Main implements the go-httpbin CLI's main() function in a reusable way
func Main() {
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
flag.IntVar(&port, "port", defaultPort, "Port to listen on")
flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
flag.Parse()
// Command line flags take precedence over environment vars, so we only
// check for environment vars if we have default values for our command
// line flags.
var err error
if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" {
maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err)
flag.Usage()
os.Exit(1)
}
}
if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" {
maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err)
flag.Usage()
os.Exit(1)
}
}
if host == defaultHost && os.Getenv("HOST") != "" {
host = os.Getenv("HOST")
}
if port == defaultPort && os.Getenv("PORT") != "" {
port, err = strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err)
flag.Usage()
os.Exit(1)
}
}
if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" {
httpsCertFile = os.Getenv("HTTPS_CERT_FILE")
}
if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" {
httpsKeyFile = os.Getenv("HTTPS_KEY_FILE")
}
var serveTLS bool
if httpsCertFile != "" || httpsKeyFile != "" {
serveTLS = true
if httpsCertFile == "" || httpsKeyFile == "" {
fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n")
flag.Usage()
os.Exit(1)
}
}
logger := log.New(os.Stderr, "", 0)
// A hacky log helper function to ensure that shutdown messages are
// formatted the same as other messages. See StdLogObserver in
// httpbin/middleware.go for the format we're matching here.
serverLog := func(msg string, args ...interface{}) {
const (
logFmt = "time=%q msg=%q"
dateFmt = "2006-01-02T15:04:05.9999"
)
logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
}
h := httpbin.New(
httpbin.WithMaxBodySize(maxBodySize),
httpbin.WithMaxDuration(maxDuration),
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
)
listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
server := &http.Server{
Addr: listenAddr,
Handler: h.Handler(),
}
// shutdownCh triggers graceful shutdown on SIGINT or SIGTERM
shutdownCh := make(chan os.Signal, 1)
signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
// exitCh will be closed when it is safe to exit, after graceful shutdown
exitCh := make(chan struct{})
go func() {
sig := <-shutdownCh
serverLog("shutdown started by signal: %s", sig)
shutdownTimeout := maxDuration + 1*time.Second
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
serverLog("shutdown error: %s", err)
}
close(exitCh)
}()
var listenErr error
if serveTLS {
serverLog("go-httpbin listening on https://%s", listenAddr)
listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile)
} else {
serverLog("go-httpbin listening on http://%s", listenAddr)
listenErr = server.ListenAndServe()
}
if listenErr != nil && listenErr != http.ErrServerClosed {
logger.Fatalf("failed to listen: %s", listenErr)
}
<-exitCh
serverLog("shutdown finished")
}
app = "httpbingo"
[[services]]
internal_port = 8080
protocol = "tcp"
[services.concurrency]
hard_limit = 25
soft_limit = 20
[[services.ports]]
handlers = ["http"]
port = "80"
[[services.ports]]
handlers = ["tls", "http"]
port = "443"
[[services.tcp_checks]]
interval = 10000
timeout = 2000
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment