Explorar el Código

feat: continuous integration and kubernetes config

    * feat: CI test options
    * fix: flaky test
    * fix: separate services instead of nginx ingress
    * fix: escape special characters in password
    * chore: environment variables in jenkins
    * feat: drop privileges in web dockerfiles
    * feat: initial Jenkinsfile
    * feat: deploy script and added client/other dependencies to manifest
    * feat: more make commands to build/push/lint
    * feat: separate env for CI
    * feat: better dockerfile caching
    * feat: health check on nginx wrapper
    * feat: health routes
    * feat: increased keyboard input FPS to 60
    * feat: multistage build with nginx container serving frontend
    * fix: allow searching with space
Fela Maslen hace 5 años
padre
commit
3810564d53

+ 63 - 0
Jenkinsfile

@@ -0,0 +1,63 @@
+node {
+  checkout scm
+  result = sh(script: "git log -1 | grep '(wip)'", returnStatus: true)
+
+  if (result == 0) {
+    echo "Skipping commit"
+  } else {
+    script {
+      IMAGE_BACKEND = sh(returnStdout: true, script: "cd gmus-backend && make get_image").trim()
+      IMAGE_WEB = sh(returnStdout: true, script: "cd gmus-web && make get_image").trim()
+    }
+
+    stage('Build and push images') {
+      script {
+        docker.withRegistry('https://docker.fela.space', 'docker.fela.space-registry') {
+          sh 'REACT_APP_API_URL=//gmus.fela.space/api make build.docker push'
+        }
+      }
+    }
+
+    docker.withRegistry('https://docker.fela.space', 'docker.fela.space-registry') {
+      docker.image('postgres:10-alpine').withRun('-e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker') { pg ->
+
+        docker.image('postgres:10-alpine').inside("--link ${pg.id}:db") {
+          sh 'while ! psql postgres://docker:docker@db/postgres -c "select 1" > /dev/null 2>&1; do sleep 1; done'
+
+          sh 'psql postgres://docker:docker@db/postgres -c "create database music_player_test;"'
+        }
+
+        docker.image('redis:6-alpine').withRun('') { redis ->
+
+          stage('Lint') {
+            parallel([
+              "gmus-web:lint": {
+                sh "docker run -e 'CI=1' --rm docker.fela.space/gmus-web-builder:latest sh -c 'yarn lint'"
+              },
+              "gmus-backend:lint": {
+                sh "docker run --rm ${IMAGE_BACKEND} sh -c 'make lint'"
+              }
+            ])
+          }
+
+          stage('Test') {
+            parallel([
+              "gmus-web:unit tests": {
+                sh "docker run --rm -e 'CI=1' -e 'REACT_APP_API_URL=http://my-api.url:1234' docker.fela.space/gmus-web-builder:latest sh -c 'yarn test'"
+              },
+              "gmus-backend:tests": {
+                sh "docker run --rm --link ${pg.id}:db --link ${redis.id}:redis ${IMAGE_BACKEND} sh -c 'make test.ci'"
+              }
+            ])
+          }
+        }
+      }
+    }
+
+    stage('Deploy') {
+      if (env.BRANCH_NAME == "master") {
+        sh './k8s/deploy.sh'
+      }
+    }
+  }
+}

+ 8 - 0
Makefile

@@ -7,6 +7,14 @@ build:
 	make build.backend
 	make build.frontend
 
+build.docker:
+	cd gmus-backend && make build.docker
+	cd gmus-web && make build.docker
+
+push:
+	cd gmus-backend && make push
+	cd gmus-web && make push
+
 test.backend:
 	cd gmus-backend && make test
 

+ 11 - 8
gmus-backend/Dockerfile

@@ -1,18 +1,21 @@
 FROM golang:1.15-alpine
 
-RUN apk update && apk add make gcc libc-dev libvorbis-dev
+RUN apk update && apk add make gcc libc-dev libvorbis-dev libvorbis
 
+RUN mkdir /app
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup && chown appuser:appgroup /app
+USER appuser
 WORKDIR /app
-COPY . .
 
-RUN make clean
-RUN make build
+COPY --chown=appuser:appgroup go.mod go.sum ./
+RUN go mod download && go get -u github.com/onsi/ginkgo/ginkgo
 
-RUN apk del make gcc libc-dev libvorbis-dev && \
-  apk update && apk add libvorbis
+COPY --chown=appuser:appgroup Makefile ci.env ./
+COPY --chown=appuser:appgroup migrations ./migrations
+COPY --chown=appuser:appgroup pkg ./pkg
+COPY --chown=appuser:appgroup cmd ./cmd
 
-RUN addgroup -S appgroup && adduser -S appuser -G appgroup
-USER appuser
+RUN make clean && make build
 
 ENV PATH="/app/bin:${PATH}"
 

+ 18 - 1
gmus-backend/Makefile

@@ -1,3 +1,7 @@
+NAME 	:= docker.fela.space/gmus-backend
+TAG 	:= $$(git log -1 --pretty=%H)
+IMG 	:= ${NAME}:${TAG}
+
 build.scan:
 	go build -o bin/gmus.scan ./cmd/gmus.scan
 build.migrate:
@@ -10,14 +14,27 @@ build:
 	make build.migrate
 	make build.server
 
+build.docker:
+	docker build -t ${IMG} .
+
+push:
+	docker push ${IMG}
+
+get_image:
+	@echo ${IMG}
+
 clean:
 	mkdir -p bin && rm -rf ./bin/*
 
 fmt:
-	go fmt github.com/felamaslen/gmus-backend/...
+	@go fmt github.com/felamaslen/gmus-backend/...
+lint:
+	@test -z $(shell gofmt -l .) && echo "Lint ok" || (echo "Found formatting issues; run \"make fmt\" to fix"; exit 1)
 
 test:
 	GO_ENV=test ginkgo ./...
+test.ci:
+	GO_ENV=ci ginkgo --randomizeAllSpecs --randomizeSuites --failOnPending --cover --trace --race --progress --noColor ./...
 
 migrate:
 	go run ./cmd/gmus.migrate

+ 9 - 0
gmus-backend/ci.env

@@ -0,0 +1,9 @@
+POSTGRES_HOST=db
+POSTGRES_DATABASE=music_player_test
+POSTGRES_USER=docker
+POSTGRES_PASSWORD=docker
+POSTGRES_PORT=5432
+REDIS_URL=redis:6379
+LOG_LEVEL=0
+LIBRARY_DIRECTORY=./pkg/read
+PORT=3000

+ 4 - 1
gmus-backend/pkg/config/config.go

@@ -3,6 +3,7 @@ package config
 import (
 	"fmt"
 	"log"
+	"net/url"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -20,6 +21,8 @@ func getEnvFile() (string, bool) {
 	switch goEnv {
 	case "test":
 		return "test.env", true
+	case "ci":
+		return "ci.env", true
 	case "development":
 		return ".env", true
 	default:
@@ -63,7 +66,7 @@ func getDatabaseUrl() string {
 		log.Fatal("Must set POSTGRES_DATABASE")
 	}
 
-	databaseUrl := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", user, password, host, portNumeric, database)
+	databaseUrl := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", user, url.QueryEscape(password), host, portNumeric, database)
 
 	return databaseUrl
 }

+ 2 - 1
gmus-backend/pkg/repository/songs_test.go

@@ -172,8 +172,9 @@ var _ = Describe("songs repository", func() {
 			modifiedBatch[0].Title = "Title A modified"
 
 			BeforeEach(func() {
-				repository.BatchUpsertSongs(db, &songs, 2)
+				setup.PrepareDatabaseForTesting()
 
+				repository.BatchUpsertSongs(db, &songs, 2)
 				repository.BatchUpsertSongs(db, &modifiedBatch, 2)
 
 				db.Select(&result, `

+ 40 - 0
gmus-backend/pkg/server/health.go

@@ -0,0 +1,40 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/felamaslen/gmus-backend/pkg/config"
+	"github.com/felamaslen/gmus-backend/pkg/logger"
+	"github.com/gorilla/mux"
+	"github.com/jmoiron/sqlx"
+)
+
+func healthRoutes(l *logger.Logger, router *mux.Router) {
+	resOk, _ := json.Marshal(map[string]bool{"ok": true})
+	resNotOk, _ := json.Marshal(map[string]bool{"ok": false})
+
+	router.Path("/liveness").Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write(resOk)
+	})
+
+	var isReady = false
+
+	router.Path("/readiness").Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !isReady {
+			db, err := sqlx.Connect("pgx", config.GetConfig().DatabaseUrl)
+			if err == nil {
+				_, err = db.Query("select version()")
+			}
+			if err == nil {
+				isReady = true
+			} else {
+				l.Error("[readiness] Error getting connection: %v\n", err)
+				http.Error(w, "Could not get database connection", http.StatusInternalServerError)
+				w.Write(resNotOk)
+				return
+			}
+		}
+		w.Write(resOk)
+	})
+}

+ 2 - 0
gmus-backend/pkg/server/server.go

@@ -21,6 +21,8 @@ func StartServer() {
 
 	router := mux.NewRouter()
 
+	healthRoutes(l, router)
+
 	initPubsub(l, rdb, router)
 
 	router.Path("/stream").Methods("GET").HandlerFunc(routeHandler(l, rdb, streamSong))

+ 4 - 0
gmus-web/.dockerignore

@@ -0,0 +1,4 @@
+Dockerfile
+.dockerignore
+node_modules
+build

+ 11 - 0
gmus-web/Dockerfile

@@ -0,0 +1,11 @@
+FROM docker.fela.space/gmus-web-builder:latest
+
+FROM nginx:alpine
+
+RUN mkdir /app
+RUN touch /var/run/nginx.pid && chown -R nginx:nginx /app /var/cache/nginx /var/run/nginx.pid
+USER nginx
+WORKDIR /app
+
+COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
+COPY --chown=nginx:nginx --from=0 /app/build .

+ 13 - 0
gmus-web/Makefile

@@ -0,0 +1,13 @@
+NAME 	:= docker.fela.space/gmus-web
+TAG 	:= $$(git log -1 --pretty=%H)
+IMG 	:= ${NAME}:${TAG}
+
+build.docker:
+	docker build -t docker.fela.space/gmus-web-builder:latest -f builder.Dockerfile .
+	docker build -t ${IMG} .
+
+push:
+	docker push ${IMG}
+
+get_image:
+	@echo ${IMG}

+ 15 - 0
gmus-web/builder.Dockerfile

@@ -0,0 +1,15 @@
+FROM node:14-alpine AS builder
+
+RUN mkdir /app
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup && chown appuser:appgroup /app
+USER appuser
+WORKDIR /app
+
+COPY --chown=appuser:appgroup package.json yarn.lock ./
+RUN yarn
+COPY --chown=appuser:appgroup src ./src
+COPY --chown=appuser:appgroup public ./public
+COPY --chown=appuser:appgroup README.md .env.test .prettierrc.js .eslintrc.js tsconfig.json ./
+
+ENV REACT_APP_API_URL=http://localhost:3002
+RUN yarn build

+ 31 - 0
gmus-web/nginx.conf

@@ -0,0 +1,31 @@
+pid /var/run/nginx.pid;
+
+events {
+  worker_connections 768;
+}
+
+http {
+  server {
+    listen 8080;
+
+    root /app;
+
+    location /liveness {
+      access_log off;
+      return 200 "healthy\n";
+    }
+    location /readiness {
+      access_log off;
+      return 200 "healthy\n";
+    }
+
+    location / {
+      try_files $uri $uri/ =404;
+    }
+
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
+      expires 1y;
+      log_not_found off;
+    }
+  }
+}

+ 1 - 1
gmus-web/src/components/ui/cmus/search.tsx

@@ -17,7 +17,7 @@ export const Search: React.FC = () => {
         dispatchUI(searched(null));
       } else if (event.key === 'Backspace') {
         setTerm((last) => last.substring(0, last.length - 1));
-      } else if (/^\w$/.test(event.key)) {
+      } else if (/^[\w\s]$/.test(event.key)) {
         setTerm((last) => `${last}${event.key}`);
       }
     };

+ 1 - 1
gmus-web/src/hooks/vim.ts

@@ -49,7 +49,7 @@ export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>, skip = fals
     [dispatch],
   );
 
-  const listenerThrottled = useThrottleCallback(listener, 20, true);
+  const listenerThrottled = useThrottleCallback(listener, 60, true);
 
   useEffect(() => {
     if (skip) {

+ 22 - 0
k8s/deploy.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+
+set -e
+
+cd $(dirname "$0")
+
+IMAGE_BACKEND=$(make -f ../gmus-backend/Makefile get_image)
+IMAGE_WEB=$(make -f ../gmus-web/Makefile get_image)
+
+namespace="gmus"
+
+cat ./manifest.yml \
+  | sed -e "s/docker\.fela\.space\/gmus-backend\:0/$(echo $IMAGE_BACKEND | sed -e 's/\//\\\//')/g" \
+  | sed -e "s/docker\.fela\.space\/gmus-web\:0/$(echo $IMAGE_WEB | sed -e 's/\//\\\//')/g" \
+  > ./manifest_with_image.yml
+
+echo "Updating deployment..."
+kubectl -n=$namespace apply -f ./manifest_with_image.yml
+
+rm -f manifest_with_image.yml
+
+exit 0

+ 162 - 38
k8s/manifest.yml

@@ -1,46 +1,128 @@
 apiVersion: v1
 kind: Service
 metadata:
-  name: gmusic
+  name: gmus-backend
 spec:
   type: LoadBalancer
   selector:
-    app: gmusic
+    app: gmus-backend
   ports:
     - name: http
       protocol: TCP
       port: 8080
-      targetPort: 80
+      targetPort: 8080
 ---
-apiVersion: networking.k8s.io/v1
-kind: Ingress
+apiVersion: v1
+kind: Service
 metadata:
-  name: gmusic-ingress
-  annotations:
-    nginx.ingress.kubernetes.io/rewrite-target: /
+  name: gmus-web
 spec:
-  rules:
-    - http:
-        paths:
-          - path: /
-            pathType: Prefix
-            backend:
-              service:
-                name: gmusic
-                port:
-                  number: 80
+  type: LoadBalancer
+  selector:
+    app: gmus-web
+  ports:
+    - name: http
+      protocol: TCP
+      port: 8080
+      targetPort: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: gmus-backend
+  labels:
+    app: gmus-backend
+spec:
+  replicas: 2
+  selector:
+    matchLabels:
+      app: gmus-backend
+  template:
+    metadata:
+      labels:
+        app: gmus-backend
+    spec:
+      imagePullSecrets:
+        - name: regcred
+      containers:
+        - name: gmus-backend
+          image: docker.fela.space/gmus-backend:0
+          ports:
+            - containerPort: 8080
+          envFrom:
+            - configMapRef:
+                name: gmus-backend
+          env:
+            - name: GO_ENV
+              value: production
+            - name: POSTGRES_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  name: postgres-pass
+                  key: password
+          livenessProbe:
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            httpGet:
+              path: /liveness
+              port: 8080
+          readinessProbe:
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            httpGet:
+              path: /readiness
+              port: 8080
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: gmus-web
+  labels:
+    app: gmus-web
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: gmus-web
+  template:
+    metadata:
+      labels:
+        app: gmus-web
+    spec:
+      imagePullSecrets:
+        - name: regcred
+      containers:
+        - name: gmus-web
+          image: docker.fela.space/gmus-web:0
+          ports:
+            - containerPort: 8080
+          envFrom:
+            - configMapRef:
+                name: gmus-web
+          livenessProbe:
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            httpGet:
+              path: /liveness
+              port: 8080
+          readinessProbe:
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            httpGet:
+              path: /readiness
+              port: 8080
 ---
 apiVersion: v1
 kind: Service
 metadata:
-  name: gmusic-database
+  name: gmus-database
   labels:
-    app: gmusic-database
+    app: gmus-database
 spec:
   ports:
     - port: 5432
   selector:
-    app: gmusic-database
+    app: gmus-database
     tier: postgres
   clusterIP: None
 ---
@@ -54,9 +136,9 @@ volumeBindingMode: WaitForFirstConsumer
 apiVersion: v1
 kind: PersistentVolume
 metadata:
-  name: postgres-pv
+  name: postgres-pv-gmus
   labels:
-    app: gmusic-database
+    app: gmus-database
 spec:
   storageClassName: manual
   capacity:
@@ -64,14 +146,14 @@ spec:
   accessModes:
     - ReadWriteOnce
   hostPath:
-    path: /var/local/gmusic-database
+    path: /var/local/gmus-database
 ---
 apiVersion: v1
 kind: PersistentVolumeClaim
 metadata:
-  name: postgres-pv-claim
+  name: postgres-pv-claim-gmus
   labels:
-    app: gmusic-database
+    app: gmus-database
 spec:
   storageClassName: manual
   accessModes:
@@ -83,20 +165,20 @@ spec:
 apiVersion: apps/v1
 kind: Deployment
 metadata:
-  name: gmusic-database
+  name: gmus-database
   labels:
-    app: gmusic-database
+    app: gmus-database
 spec:
   selector:
     matchLabels:
-      app: gmusic-database
+      app: gmus-database
       tier: postgres
   strategy:
     type: Recreate
   template:
     metadata:
       labels:
-        app: gmusic-database
+        app: gmus-database
         tier: postgres
     spec:
       containers:
@@ -104,7 +186,7 @@ spec:
           name: postgres
           env:
             - name: POSTGRES_USER
-              value: gmusic
+              value: gmus
             - name: POSTGRES_PASSWORD
               valueFrom:
                 secretKeyRef:
@@ -119,12 +201,52 @@ spec:
       volumes:
         - name: postgres-persistent-storage
           persistentVolumeClaim:
-            claimName: postgres-pv-claim
+            claimName: postgres-pv-claim-gmus
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: gmus-redis
+  labels:
+    app: gmus-redis
+spec:
+  ports:
+    - port: 6379
+  selector:
+    app: gmus-redis
+    tier: redis
+  clusterIP: None
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: gmus-redis
+  labels:
+    app: gmus-redis
+spec:
+  selector:
+    matchLabels:
+      app: gmus-redis
+      tier: redis
+  strategy:
+    type: Recreate
+  template:
+    metadata:
+      labels:
+        app: gmus-redis
+        tier: redis
+    spec:
+      containers:
+        - image: redis:6-alpine
+          name: redis
+          ports:
+            - containerPort: 6379
+              name: redis
 ---
 apiVersion: batch/v1beta1
 kind: CronJob
 metadata:
-  name: gmusic-scan-library
+  name: gmus-scan-library
 spec:
   schedule: "45 5 * * *"
   jobTemplate:
@@ -132,17 +254,19 @@ spec:
       template:
         spec:
           containers:
-            - name: gmusic-scan
-              image: docker.fela.space/gmusic:0
+            - name: gmus-scan
+              image: docker.fela.space/gmus-backend:0
               args:
                 - scan
               envFrom:
                 - configMapRef:
-                    name: gmusic
+                    name: gmus-backend
               env:
-                - name: DATABASE_URL
+                - name: GO_ENV
+                  value: production
+                - name: POSTGRES_PASSWORD
                   valueFrom:
                     secretKeyRef:
                       name: postgres-pass
-                      key: url
+                      key: password
           restartPolicy: Never