Skip to content

Commit

Permalink
Merge pull request #9 from volmedo/kubernetize
Browse files Browse the repository at this point in the history
Prepare the application to run in a kubernetes cluster
  • Loading branch information
volmedo authored Jun 13, 2019
2 parents 8c9d6d7 + 099d493 commit 5e029e5
Show file tree
Hide file tree
Showing 13 changed files with 367 additions and 23 deletions.
21 changes: 19 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,36 @@ language: go
go:
- 1.12.x

cache:
directories:
- $HOME/.cache/go-build
- $HOME/gopath/pkg/mod

services:
- docker

env:
- GO111MODULE=on
- GO111MODULE=on KIND_VER=v0.3.0

install:
- curl -Lo $HOME/bin/kind https://github.com/kubernetes-sigs/kind/releases/download/$KIND_VER/kind-linux-amd64
- chmod +x $HOME/bin/kind
- kind version
- curl -Lo $HOME/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl
- chmod +x $HOME/bin/kubectl
- kubectl version --client --short

install: true
before_script:
- docker login --username $DOCKER_USERNAME --password $DOCKER_PASSWORD

script:
- make lint
- make test.unit
- make test.integration
- make build
- make docker.build
- make docker.push
- make test.e2e.k8s
- make terraform.chkfmt
- make terraform.init
- make terraform.keygen
Expand Down
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# build
FROM golang:1.12.5 as builder
RUN adduser --disabled-password --gecos "" papiuser
WORKDIR /papi
COPY ./go.mod .
COPY ./go.sum .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /papi/srv ./cmd/server

# deploy
FROM scratch
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /papi/srv /papi/srv
COPY --from=builder /papi/pkg/service/migrations /papi/migrations
USER papiuser
ENTRYPOINT ["/papi/srv", "-migrations=/papi/migrations"]
CMD ["-port=8080"]
34 changes: 33 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ TERRAFORM = docker run --rm -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -v "$(

SRV_BIN_NAME ?= papisrv

PAPI_IMG_TAG ?= test

KIND_CLUSTER = papi

swagger.validate:
$(SWAGGER) validate $(SPEC)

Expand All @@ -57,6 +61,7 @@ lint:

test.unit:
$(GO) test -v -race ./$(PKG)/service
$(GO) test -v -race ./$(CMD)/server

# The exit code of the test command is saved in a variable to call POSTGRES_STOP
# no matter if tests fails or not but make the final exit code of the command depend
Expand Down Expand Up @@ -98,6 +103,32 @@ test.e2e.local:
$(POSTGRES_STOP) ;\
exit $$TEST_RESULT

docker.build:
docker build -t volmedo/papi:$(PAPI_IMG_TAG) .

docker.push:
docker push volmedo/papi:$(PAPI_IMG_TAG)

test.e2e.k8s:
kind create cluster --name $(KIND_CLUSTER) --wait 5m
export KUBECONFIG="$$(kind get kubeconfig-path --name $(KIND_CLUSTER))" ;\
kubectl apply -f k8s/ ;\
kubectl wait --for condition=Ready pod -l tier=backend ;\
PROXY_PORT=8000 ;\
kubectl proxy --port=$$PROXY_PORT & \
PROXY_PID=$$! ;\
$(GO) test -v -race ./$(E2E) \
-host=localhost \
-port=$$PROXY_PORT \
-api-path=/api/v1/namespaces/default/services/api/proxy/v1 \
-health-path=/api/v1/namespaces/default/services/api/proxy/health ;\
TEST_RESULT=$$? ;\
kill $$PROXY_PID ;\
kubectl delete -f k8s/ ;\
unset KUBECONFIG ;\
kind delete cluster --name $(KIND_CLUSTER) ;\
exit $$TEST_RESULT

test.e2e:
$(TERRAFORM) output > tf.out ;\
HOST=$$(awk '/srv-ip/{print $$NF}' tf.out) ;\
Expand Down Expand Up @@ -164,7 +195,8 @@ clean:

.PHONY: $(patsubst %,swagger.%,validate clean generate.client generate.server)
.PHONY: lint
.PHONY: $(patsubst %,test.%,unit integration e2e.local e2e)
.PHONY: $(patsubst %,test.%,unit integration e2e.local e2e.k8s e2e)
.PHONY: $(patsubst %,docker.%,build push)
.PHONY: $(patsubst %,terraform.%,keygen init chkfmt validate apply output destroy)
.PHONY: clean

Expand Down
18 changes: 18 additions & 0 deletions cmd/server/middleware.go → cmd/server/handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"database/sql"
"fmt"
"net/http"
"time"
Expand Down Expand Up @@ -57,3 +58,20 @@ func newRecoverableHandler(handler http.Handler) http.Handler {
rec := recovery.New()
return rec.Handler(handler)
}

// newHealthHandler returns a basic health endpoint that can be used in readiness
// and liveness probes. It checks moving parts to report general availability
// of the service (currently, the connection with the DB is the only moving part).
// The health endpoint returns a 200 response when the service is available
// and a 500 one when it is not working correctly
func newHealthHandler(db *sql.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
w.WriteHeader(500)
fmt.Fprintf(w, "cannot connect with DB: %v", err)
} else {
w.WriteHeader(200)
fmt.Fprint(w, "ok")
}
})
}
50 changes: 50 additions & 0 deletions cmd/server/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"

"github.com/DATA-DOG/go-sqlmock"
_ "github.com/lib/pq"
)

func TestHealth(t *testing.T) {
goodConn, _, err := sqlmock.New()
if err != nil {
t.Fatalf("Error creating mock connection: %v", err)
}

badConn, err := sql.Open("postgres", "")
if err != nil {
t.Fatalf("Error creating bad connection: %v", err)
}

tests := map[string]struct {
conn *sql.DB
wantCode int
}{
"healthy": {
conn: goodConn,
wantCode: http.StatusOK,
},
"unhealthy": {
conn: badConn,
wantCode: http.StatusInternalServerError,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
resp := httptest.NewRecorder()
handler := newHealthHandler(tc.conn)
handler.ServeHTTP(resp, req)

if resp.Code != tc.wantCode {
t.Fatalf("want %d but got %d", tc.wantCode, resp.Code)
}
})
}
}
28 changes: 18 additions & 10 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package main

import (
"flag"
"fmt"
"log"
"net/http"
"os"

"github.com/namsral/flag"

"github.com/volmedo/pAPI/pkg/restapi"
"github.com/volmedo/pAPI/pkg/service"
Expand All @@ -14,17 +16,22 @@ func main() {
var dbHost, dbUser, dbPass, dbName, migrationsPath string
var port, dbPort int
var rps int64
flag.IntVar(&port, "port", 8080, "Port where the server is listening for connections.")
flag.Int64Var(&rps, "rps", 100, "Rate limit expressed in requests per second (per client)")

flag.StringVar(&dbHost, "dbhost", "localhost", "Address of the server that hosts the DB")
flag.IntVar(&dbPort, "dbport", 5432, "Port where the DB server is listening for connections")
flag.StringVar(&dbUser, "dbuser", "postgres", "User to use when accessing the DB")
flag.StringVar(&dbPass, "dbpass", "postgres", "Password to use when accessing the DB")
flag.StringVar(&dbName, "dbname", "postgres", "Name of the DB to connect to")
flag.StringVar(&migrationsPath, "migrations", "./migrations", "Path to the folder that contains the migration files")
// Use "PAPI" as prefix for env variables to avoid potential clashes
fs := flag.NewFlagSetWithEnvPrefix(os.Args[0], "PAPI", flag.ExitOnError)

fs.IntVar(&port, "port", 8080, "Port where the server is listening for connections.")
fs.Int64Var(&rps, "rps", 100, "Rate limit expressed in requests per second (per client)")

fs.StringVar(&dbHost, "dbhost", "localhost", "Address of the server that hosts the DB")
fs.IntVar(&dbPort, "dbport", 5432, "Port where the DB server is listening for connections")
fs.StringVar(&dbUser, "dbuser", "postgres", "User to use when accessing the DB")
fs.StringVar(&dbPass, "dbpass", "postgres", "Password to use when accessing the DB")
fs.StringVar(&dbName, "dbname", "postgres", "Name of the DB to connect to")
fs.StringVar(&migrationsPath, "migrations", "./migrations", "Path to the folder that contains the migration files")

flag.Parse()
// Ignore errors; fs is set for ExitOnError
_ = fs.Parse(os.Args[1:])

// Setup DB
dbConf := &service.DBConfig{
Expand Down Expand Up @@ -63,6 +70,7 @@ func main() {
apiHandler = newRecoverableHandler(apiHandler)

mux := http.NewServeMux()
mux.Handle("/health", newHealthHandler(db))
mux.Handle("/metrics", prometheusHandler)
mux.Handle("/", apiHandler)

Expand Down
61 changes: 52 additions & 9 deletions e2e_test/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"reflect"
Expand All @@ -24,10 +25,11 @@ import (
)

var (
scheme string
host string
port int
basePath string
scheme string
host string
port int
apiPath string
healthPath string

opt = godog.Options{
Output: colors.Colored(os.Stdout),
Expand All @@ -44,13 +46,19 @@ type Client struct {
lastResponse interface{}
lastError error
registeredIDs map[strfmt.UUID]struct{} // This property allows for cleaning after each scenario
healthURL *url.URL
}

func newClient(apiURL *url.URL) *Client {
func newClient(apiURL, healthURL *url.URL) *Client {
conf := client.Config{URL: apiURL}
payments := client.New(conf)
registeredIDs := make(map[strfmt.UUID]struct{})
return &Client{payments.Payments, nil, nil, registeredIDs}
return &Client{
Client: payments.Payments,
lastResponse: nil,
lastError: nil,
registeredIDs: registeredIDs,
healthURL: healthURL}
}

func (c *Client) thereArePaymentsWithIDs(ids *gherkin.DataTable) error {
Expand Down Expand Up @@ -299,11 +307,27 @@ func (c *Client) theResponseContainsAListOfPaymentsWithIDs(ids *gherkin.DataTabl
return nil
}

// ping checks if the API is ready by sending a request to its health endpoint
func (c *Client) ping() error {
resp, err := http.Get(c.healthURL.String())
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return errors.New("API not available")
}

return nil
}

func TestMain(m *testing.M) {
flag.StringVar(&scheme, "scheme", "http", "Scheme to use to communicate with the server ('http' or 'https')")
flag.StringVar(&host, "host", client.DefaultHost, "Address or URL of the server serving the Payments API (such as 'localhost' or 'api.example.com')")
flag.IntVar(&port, "port", 8080, "Port where the server is listening for connections")
flag.StringVar(&basePath, "base-path", client.DefaultBasePath, "Base path for API endpoints")
flag.StringVar(&apiPath, "api-path", client.DefaultBasePath, "Base path for API endpoints")
flag.StringVar(&healthPath, "health-path", "/health", "Path to the API's health endpoint")

flag.Parse()
opt.Paths = flag.Args()
Expand All @@ -322,9 +346,14 @@ func FeatureContext(s *godog.Suite) {
apiURL := &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", host, port),
Path: basePath,
Path: apiPath,
}
client := newClient(apiURL)
healthURL := &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", host, port),
Path: healthPath,
}
client := newClient(apiURL, healthURL)

s.Step(`^there are payments with IDs:$`, client.thereArePaymentsWithIDs)
s.Step(`^the response contains a list of payments with the following IDs:$`, client.theResponseContainsAListOfPaymentsWithIDs)
Expand All @@ -338,6 +367,20 @@ func FeatureContext(s *godog.Suite) {
s.Step(`^I get a[n]? "([^"]*)" response$`, client.iGetAResponse)
s.Step(`^the response contains a payment described in JSON as:$`, client.theResponseContainsAPaymentDescribedInJSONAs)

// Wait for the application to settle before running the test suite
s.BeforeSuite(func() {
max_retries := 20
err := client.ping()
for i := 0; i < max_retries && err != nil; i++ {
time.Sleep(1 * time.Second)
err = client.ping()
}

if err != nil {
panic(fmt.Sprintf("API is not ready after %d seconds: %v", max_retries, err))
}
})

// Ensure there are no payments in the server before each scenario
s.BeforeScenario(func(interface{}) {
for id := range client.registeredIDs {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/google/go-cmp v0.3.0
github.com/lib/pq v1.1.1
github.com/mitchellh/copystructure v1.0.0
github.com/namsral/flag v1.7.4-pre
github.com/prometheus/client_golang v0.9.3
github.com/slok/go-http-metrics v0.4.0
github.com/ulule/limiter/v3 v3.2.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
github.com/mongodb/mongo-go-driver v0.3.0/go.mod h1:NK/HWDIIZkaYsnYa0hmtP443T5ELr0KDecmIioVuuyU=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA=
github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs=
github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
Expand Down
10 changes: 10 additions & 0 deletions k8s/config-maps.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: db-config
labels:
app: papi
data:
host: db
port: "5432"
dbname: papi_db
Loading

0 comments on commit 5e029e5

Please sign in to comment.