├── .gitignore ├── Makefile ├── README.md ├── docker-compose.yml ├── go.mod ├── go.sum ├── helm ├── .helmignore ├── Chart.yaml ├── README.md ├── charts │ ├── cassandra │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── backup │ │ │ │ ├── cronjob.yaml │ │ │ │ └── rbac.yaml │ │ │ ├── configmap.yaml │ │ │ ├── pdb.yaml │ │ │ ├── service.yaml │ │ │ └── statefulset.yaml │ │ └── values.yaml │ ├── feeds │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── followers │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── gateway │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── rabbitmq │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── configuration.yaml │ │ │ ├── ingress.yaml │ │ │ ├── role.yaml │ │ │ ├── rolebinding.yaml │ │ │ ├── secrets.yaml │ │ │ ├── serviceaccount.yaml │ │ │ ├── servicemonitor.yaml │ │ │ ├── statefulset.yaml │ │ │ ├── svc-headless.yaml │ │ │ └── svc.yaml │ │ ├── values-production.yaml │ │ └── values.yaml │ ├── traefik │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── OWNERS │ │ ├── README.md │ │ ├── ci │ │ │ └── ci-values.yaml │ │ ├── templates │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── acme-pvc.yaml │ │ │ ├── config-files.yaml │ │ │ ├── configmap.yaml │ │ │ ├── dashboard-ingress.yaml │ │ │ ├── dashboard-service.yaml │ │ │ ├── default-cert-secret.yaml │ │ │ ├── deployment.yaml │ │ │ ├── dns-provider-secret.yaml │ │ │ ├── hpa.yaml │ │ │ ├── poddisruptionbudget.yaml │ │ │ ├── rbac.yaml │ │ │ ├── secret-files.yaml │ │ │ ├── service.yaml │ │ │ ├── storeconfig-job.yaml │ │ │ └── tests │ │ │ │ ├── test-configmap.yaml │ │ │ │ └── test.yaml │ │ └── values.yaml │ ├── tweets │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ └── users │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ └── service.yaml │ │ └── values.yaml ├── templates │ ├── _env.tpl │ ├── _helpers.tpl │ └── ingress.yaml └── values.yaml ├── k8s ├── README.md ├── create-storage-gce.yaml ├── role-tiller.yaml └── rolebinding-tiller.yaml ├── migrations ├── 0001-initial.cql ├── 0002-users.cql ├── 0003-tweets.cql ├── 0004-followers.cql └── 0005-feeds.cql ├── scripts ├── build-and-push-docker-images.sh ├── delete-evicted-pods.sh ├── gofmt.sh ├── migrate.sh ├── rabbitmq-health-check.sh ├── run-all.sh ├── run-integration-tests.sh └── setup-k8s.sh ├── services ├── common │ ├── amqp │ │ ├── amqp.go │ │ └── routing_keys.go │ ├── auth │ │ └── tokens.go │ ├── cassandra │ │ └── cassandra.go │ ├── config │ │ ├── config.go │ │ └── service_config.go │ ├── env │ │ └── env.go │ ├── healthz │ │ └── healthz.go │ ├── logger │ │ └── logger.go │ ├── metrics │ │ └── metrics.go │ ├── service │ │ ├── repository.go │ │ └── service.go │ └── types │ │ ├── feeds.go │ │ ├── followers.go │ │ ├── tweets.go │ │ └── users.go ├── feeds │ ├── Dockerfile │ ├── README.md │ ├── cmd │ │ └── main.go │ └── internal │ │ ├── handlers.go │ │ ├── repository.go │ │ └── routes.go ├── followers │ ├── Dockerfile │ ├── README.md │ ├── cmd │ │ └── main.go │ └── internal │ │ ├── handlers.go │ │ ├── repository.go │ │ └── routes.go ├── gateway │ ├── Dockerfile │ ├── README.md │ ├── cmd │ │ └── main.go │ └── internal │ │ ├── core │ │ ├── config.go │ │ ├── error.go │ │ ├── gateway.go │ │ ├── http.go │ │ ├── logger.go │ │ ├── middlewares.go │ │ ├── router.go │ │ └── util.go │ │ ├── feeds │ │ ├── handlers.go │ │ └── routes.go │ │ ├── followers │ │ ├── handlers.go │ │ └── routes.go │ │ ├── tweets │ │ ├── handlers.go │ │ └── routes.go │ │ └── users │ │ ├── handlers.go │ │ └── routes.go ├── ready │ ├── Dockerfile │ ├── README.md │ └── cmd │ │ └── main.go ├── tweets │ ├── Dockerfile │ ├── README.md │ ├── cmd │ │ └── main.go │ └── internal │ │ ├── handlers.go │ │ ├── repository.go │ │ └── routes.go └── users │ ├── Dockerfile │ ├── README.md │ ├── cmd │ └── main.go │ └── internal │ ├── handlers.go │ ├── repository.go │ └── routes.go └── tests ├── README.md ├── helpers └── integration_test_suite.go └── integration ├── feeds_test.go ├── followers_test.go ├── tweets_test.go └── users_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | vendor 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | *.test 9 | *.out -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: migrate up build format run test setup-k8s helm-install helm-debug helm-purge helm-upgrade docker-build 2 | 3 | migrate: 4 | @scripts/migrate.sh 5 | 6 | up: 7 | @docker-compose -f docker-compose.yml up 8 | 9 | run: 10 | @scripts/run-all.sh 11 | 12 | build: 13 | @go build -ldflags="-s -w" -o bin/feeds services/feeds/cmd/main.go 14 | @go build -ldflags="-s -w" -o bin/followers services/followers/cmd/main.go 15 | @go build -ldflags="-s -w" -o bin/gateway services/gateway/cmd/main.go 16 | @go build -ldflags="-s -w" -o bin/tweets services/tweets/cmd/main.go 17 | @go build -ldflags="-s -w" -o bin/users services/users/cmd/main.go 18 | 19 | format: 20 | @scripts/gofmt.sh 21 | 22 | test: 23 | @scripts/run-integration-tests.sh 24 | 25 | setup-k8s: 26 | @scripts/setup-k8s.sh 27 | 28 | helm-install: 29 | @helm install ./helm --name=twtr-dev --tiller-namespace=twtr-dev --namespace=twtr-dev 30 | 31 | helm-upgrade: 32 | @helm upgrade twtr-dev ./helm --tiller-namespace=twtr-dev --namespace=twtr-dev 33 | 34 | helm-debug: 35 | @helm install --dry-run --debug ./helm --name=twtr-dev --tiller-namespace=twtr-dev --namespace=twtr-dev 36 | 37 | helm-purge: 38 | @helm ls --all --short --tiller-namespace=twtr-dev | xargs -L1 helm delete --purge --tiller-namespace=twtr-dev 39 | 40 | docker-build-push: 41 | @scripts/build-and-push-docker-images.sh 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter-go 2 | 3 | Twitter-go is an application api intended to back a minimal feature set of twitter. Its development serves as a fun learning exercise to explore an event driven microservice architecture, using Go. I've been curious about Go for a variety of reasons (performance, native type system, binaries, pragmatic ecosystem) and want to explore it and its ecosystem. 4 | 5 | Moreover, microservice backends are becoming more ubiquitous due to the organizational benefits they offer (independent deployability, independent scalability, fault tolerance), and an event driven architecture is a common way of developing loosely coupled services. 6 | 7 | Further, writing the infrastructure for managing and deploying a microservice backend likely will and has proven to be a fantastic learning exercise for crystalizing knowledge I've acquired about Kubernetes, Docker and Helm. 8 | 9 | ## What is the app? 10 | 11 | - A user makes post 12 | - A user has a viewable list of their own posts 13 | - A user can subscribe to other users 14 | - A user has an activity feed of those they follow's posts (chronological order) 15 | 16 | ### This app needs to provide the ability: 17 | 18 | - To create a user 19 | - To login a user 20 | - To get a list of a user's posts 21 | - To follow other users 22 | - To retrieve an aggregated activity feed of posts from those they follow 23 | 24 | ### Ergo, service breakdown: 25 | 26 | - API gateway (Entry point into the backend; maps http to n rpc calls) 27 | - User service (CRUD for users; user authorization) 28 | - Tweet service (Adding to user tweet list; "my tweets") 29 | - Follower service (Managing user - user follows/followers relationships; "my followers/follower count") 30 | - Feed service (Aggregating user activity feed; "my feed") 31 | 32 | ### Service philosophy: 33 | 34 | - Services are responsible solely for their domain (biz logic, tables) 35 | - Services will publish events about their domain for other services to subscribe to as required 36 | - Services that require data not belonging to their domain will embrace denormalization and eventual consistency 37 | - Services should be written as dumb as possible and avoid pre-emptive abstractions; YAGNI 38 | 39 | ### Technical choices: 40 | 41 | - Go for gateway and application code 42 | - RabbitMQ for a message bus (rpc, pub/sub) 43 | - Cassandra for a NoSQL datastore 44 | - Docker, Kubernetes and Helm for deployment 45 | - Some bash scripts for convenience, glueing things 46 | 47 | ## How do I get it running on k8s? 48 | 49 | It's been a while (a few months) since I initially set up and deployed this project. However, I'll do my best to recount the steps required at a high level. 50 | 51 | Requirements: 52 | - An adequately provisioned k8s cluster, running on GCP (I believe my set up for dev had 3 nodes and 8gb of RAM; the default options for new projects). 53 | - `kubectl` on your local machine, properly configured to talk to your k8s cluster. 54 | - `Docker` on your local machine. 55 | - `cqlsh` on your local machine. 56 | 57 | First step is installing helm onto your cluster. The helm documentation will be more helpful than myself for this, but the gist is installing helm onto your local machine (e.g through `brew install helm`), and then running `scripts/setup-k8s.sh`, which will create a new namespace (`twtr-dev`) on your cluster and give tiller (the server side part of helm) permissions to manage this namespace. This, while not totally necessary, is important: in a production application, we probably don't want to give tiller free reign over an entire cluster. By giving tiller permissions only to the namespace where our application will reside, we remove a lot of surface area for security vulnerabilities or developer mistakes. 58 | 59 | After that, the hard part is over. You'll now need to modify some of my scripts in the `scripts` directory and charts in the `helm` directory to replace my google project id with yours (`:%s/precise-clock-244301/YOUR_PROJECT_ID`). This is so we can build and upload docker images to google's container registry, and pull those images down later when we deploy the application. Once you've modified all references to the google project id, you can run `make docker-build-push` and grab a coffee while we build all the go services and push them up. 60 | 61 | Now, coffee in hand, you should be able to run `make helm-install`. In retrospective, there is no container to create the Cassandra keyspace and run the migrations as a job, so you'll likely have to `kubectl port-forward twtr-dev-cassandra-0 9200:9200 9042:9042` and run `make migrate` so Cassandra has a keyspace and the proper tables. In a production set up, we'd have an initContainer configured to check for any new keyspaces/migrations and run them prior to any services being allowed to run in k8s. If things bork because of this, run `make helm-purge` once you've run the migrations and then `make helm-install` again. Since Cassandra has a pvc configured, the keyspace + tables will be there the second time around, and you can ignore it. 62 | 63 | Following this, you should see the following output from `kubectl get pods` 64 | 65 | ``` 66 | tiller-deploy-74d85979b6-xq579 1/1 Running 0 20d 67 | twtr-dev-cassandra-0 1/1 Running 0 5d16h 68 | twtr-dev-feeds-5b64b5f899-d9g2k 1/1 Running 0 5d16h 69 | twtr-dev-followers-748b8d7b45-xlhsz 1/1 Running 0 5d16h 70 | twtr-dev-gateway-6fc7d454d6-9fqtm 1/1 Running 0 5d16h 71 | twtr-dev-rabbitmq-0 1/1 Running 0 5d16h 72 | twtr-dev-traefik-75b84ddd85-n65bc 1/1 Running 0 5d16h 73 | twtr-dev-tweets-6476944c94-5d7jk 1/1 Running 0 5d16h 74 | twtr-dev-users-64955776fb-ff6jb 1/1 Running 0 5d16h 75 | ``` 76 | 77 | If you want to actually send requests to the api, check what the ip of your ingress controller is via `kubectl get services | grep traefik` and observe the address of the `EXTERNAL IP` 78 | 79 | ``` 80 | twtr-dev-traefik LoadBalancer 10.48.13.194 35.231.22.185 80:30004/TCP,443:31478/TCP 5d16h 81 | ``` 82 | 83 | Then, modify your `/etc/hosts` to point `twtr-dev.com` and `traefik.dashboard.com` (if you care about it) to that ip address: 84 | 85 | ``` 86 | /etc/hosts 87 | 88 | 127.0.0.1 localhost 89 | 255.255.255.255 broadcasthost 90 | ::1 localhost 91 | 127.0.0.1 192.168.0.143 92 | 35.231.22.185 traefik.dashboard.com 93 | 35.231.22.185 twtr-dev.com 94 | ``` 95 | 96 | To confirm things are working, run `curl -i http://twtr-dev.com/healthz` and you should see an `ok` response in the response body. Congratulations! 97 | 98 | ## How do I get it running locally for development? 99 | 100 | This is much easier than configuring it for production. Assuming you have Docker and `cqlsh` on your local machine, run the following commands: 101 | 102 | - `make up` 103 | - `make migrate` 104 | - `make run` 105 | 106 | To run the integration tests, run `make test`. 107 | 108 | You should be able to run `curl -i http://localhost:3002/healthz` to confirm things are working. 109 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | cassandra: 4 | image: "cassandra:latest" 5 | environment: 6 | - Dcassandra.ignore_dc=true 7 | - MAX_HEAP_SIZE=256M 8 | - HEAP_NEWSIZE=64M 9 | ports: 10 | - "9042:9042" 11 | - "9200:9200" 12 | volumes: 13 | - cassandradata:/cassandra 14 | rabbitmq: 15 | image: "rabbitmq:3-management" 16 | hostname: "rabbitmq" 17 | environment: 18 | RABBITMQ_ERLANG_COOKIE: "SWQOKODSQALRPCLNMEQG" 19 | RABBITMQ_DEFAULT_USER: "rabbitmq" 20 | RABBITMQ_DEFAULT_PASS: "rabbitmq" 21 | RABBITMQ_DEFAULT_VHOST: "/" 22 | ports: 23 | - "15672:15672" 24 | - "5672:5672" 25 | labels: 26 | NAME: "rabbitmq1" 27 | 28 | volumes: 29 | cassandradata: 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module twitter-go 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/gocql/gocql v0.0.0-20190423091413-b99afaf3b163 8 | github.com/google/uuid v1.1.1 9 | github.com/gorilla/handlers v1.4.0 10 | github.com/gorilla/mux v1.7.1 11 | github.com/laaksomavrick/goals-api v0.0.0-20181206032021-ded7f87c6c00 12 | github.com/lib/pq v1.1.1 // indirect 13 | github.com/prometheus/client_golang v1.0.0 14 | github.com/sirupsen/logrus v1.4.2 15 | github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94 16 | github.com/stretchr/testify v1.3.0 17 | golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 18 | ) 19 | -------------------------------------------------------------------------------- /helm/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: twitter-go's helm chart 3 | name: twtr 4 | version: 0.1.0 -------------------------------------------------------------------------------- /helm/README.md: -------------------------------------------------------------------------------- 1 | # Helm 2 | 3 | This directory and its subdirectories contain a set of helm charts, each of which configures a component of the kubernetes cluster required for the twitter-go backend. 4 | These components fall into a few categories: 5 | 6 | * Business logic/domain level services, (e.g the users service or the tweets service) 7 | * Data storage (cassandra) 8 | * Messaging (rabbitmq) 9 | * Ingress/load balancing (traefik) 10 | 11 | Helm was chosen because it grants us: 12 | 13 | * access to a templating system for repetitive configuration (DRY) 14 | * a set of community tailored charts for common infrastructure components (quick wins) 15 | * the ability to version our deployments 16 | * simple deployments and deployment updates 17 | -------------------------------------------------------------------------------- /helm/charts/cassandra/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: cassandra 3 | version: 0.13.1 4 | appVersion: 3.11.3 5 | description: Apache Cassandra is a free and open-source distributed database management 6 | system designed to handle large amounts of data across many commodity servers, providing 7 | high availability with no single point of failure. 8 | icon: https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Cassandra_logo.svg/330px-Cassandra_logo.svg.png 9 | keywords: 10 | - cassandra 11 | - database 12 | - nosql 13 | home: http://cassandra.apache.org 14 | maintainers: 15 | - name: KongZ 16 | email: goonohc@gmail.com 17 | - name: maorfr 18 | email: maor.friedman@redhat.com 19 | engine: gotpl 20 | -------------------------------------------------------------------------------- /helm/charts/cassandra/README.md: -------------------------------------------------------------------------------- 1 | # Cassandra 2 | 3 | The helm chart was forked from https://github.com/helm/charts/tree/master/incubator/cassandra. See this url for configuration options. Additionally, https://kubernetes.io/docs/tutorials/stateful-application/cassandra/ serves as a lower level resource for deploying and verifying cassandra on a kubernetes cluster. -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | Cassandra CQL can be accessed via port {{ .Values.config.ports.cql }} on the following DNS name from within your cluster: 2 | Cassandra Thrift can be accessed via port {{ .Values.config.ports.thrift }} on the following DNS name from within your cluster: 3 | 4 | If you want to connect to the remote instance with your local Cassandra CQL cli. To forward the API port to localhost:9042 run the following: 5 | - kubectl port-forward --namespace {{ .Release.Namespace }} $(kubectl get pods --namespace {{ .Release.Namespace }} -l app={{ template "cassandra.name" . }},release={{ .Release.Name }} -o jsonpath='{ .items[0].metadata.name }') 9042:{{ .Values.config.ports.cql }} 6 | 7 | If you want to connect to the Cassandra CQL run the following: 8 | {{- if contains "NodePort" .Values.service.type }} 9 | - export CQL_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "cassandra.fullname" . }}) 10 | - export CQL_HOST=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | - cqlsh $CQL_HOST $CQL_PORT 12 | 13 | {{- else if contains "LoadBalancer" .Values.service.type }} 14 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 15 | Watch the status with: 'kubectl get svc --namespace {{ .Release.Namespace }} -w {{ template "cassandra.fullname" . }}' 16 | - export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "cassandra.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 17 | - echo cqlsh $SERVICE_IP 18 | {{- else if contains "ClusterIP" .Values.service.type }} 19 | - kubectl port-forward --namespace {{ .Release.Namespace }} $(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ template "cassandra.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 9042:{{ .Values.config.ports.cql }} 20 | echo cqlsh 127.0.0.1 9042 21 | {{- end }} 22 | 23 | You can also see the cluster status by run the following: 24 | - kubectl exec -it --namespace {{ .Release.Namespace }} $(kubectl get pods --namespace {{ .Release.Namespace }} -l app={{ template "cassandra.name" . }},release={{ .Release.Name }} -o jsonpath='{.items[0].metadata.name}') nodetool status 25 | 26 | To tail the logs for the Cassandra pod run the following: 27 | - kubectl logs -f --namespace {{ .Release.Namespace }} $(kubectl get pods --namespace {{ .Release.Namespace }} -l app={{ template "cassandra.name" . }},release={{ .Release.Name }} -o jsonpath='{ .items[0].metadata.name }') 28 | 29 | {{- if not .Values.persistence.enabled }} 30 | 31 | Note that the cluster is running with node-local storage instead of PersistentVolumes. In order to prevent data loss, 32 | pods will be decommissioned upon termination. Decommissioning may take some time, so you might also want to adjust the 33 | pod termination gace period, which is currently set to {{ .Values.podSettings.terminationGracePeriodSeconds }} seconds. 34 | 35 | {{- end}} 36 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "cassandra.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "cassandra.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "cassandra.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Create the name of the service account to use 36 | */}} 37 | {{- define "cassandra.serviceAccountName" -}} 38 | {{- if .Values.serviceAccount.create -}} 39 | {{ default (include "cassandra.fullname" .) .Values.serviceAccount.name }} 40 | {{- else -}} 41 | {{ default "default" .Values.serviceAccount.name }} 42 | {{- end -}} 43 | {{- end -}} 44 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/backup/cronjob.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.backup.enabled }} 2 | {{- $release := .Release }} 3 | {{- $values := .Values }} 4 | {{- $backup := $values.backup }} 5 | {{- range $index, $schedule := $backup.schedule }} 6 | --- 7 | apiVersion: batch/v1beta1 8 | kind: CronJob 9 | metadata: 10 | name: {{ template "cassandra.fullname" $ }}-backup-{{ $schedule.keyspace | replace "_" "-" }} 11 | labels: 12 | app: {{ template "cassandra.name" $ }}-cain 13 | chart: {{ template "cassandra.chart" $ }} 14 | release: "{{ $release.Name }}" 15 | heritage: "{{ $release.Service }}" 16 | spec: 17 | schedule: {{ $schedule.cron | quote }} 18 | concurrencyPolicy: Forbid 19 | startingDeadlineSeconds: 120 20 | jobTemplate: 21 | spec: 22 | template: 23 | metadata: 24 | annotations: 25 | {{ toYaml $backup.annotations }} 26 | spec: 27 | restartPolicy: OnFailure 28 | serviceAccountName: {{ template "cassandra.serviceAccountName" $ }} 29 | containers: 30 | - name: cassandra-backup 31 | image: "{{ $backup.image.repository }}:{{ $backup.image.tag }}" 32 | command: ["cain"] 33 | args: 34 | - backup 35 | - --namespace 36 | - {{ $release.Namespace }} 37 | - --selector 38 | - release={{ $release.Name }},app={{ template "cassandra.name" $ }} 39 | - --keyspace 40 | - {{ $schedule.keyspace }} 41 | - --dst 42 | - {{ $backup.destination }} 43 | {{- with $backup.extraArgs }} 44 | {{ toYaml . | indent 12 }} 45 | {{- end }} 46 | env: 47 | {{- if $backup.google.serviceAccountSecret }} 48 | - name: GOOGLE_APPLICATION_CREDENTIALS 49 | value: "/etc/secrets/google/credentials.json" 50 | {{- end }} 51 | {{- with $backup.env }} 52 | {{ toYaml . | indent 12 }} 53 | {{- end }} 54 | {{- with $backup.resources }} 55 | resources: 56 | {{ toYaml . | indent 14 }} 57 | {{- end }} 58 | {{- if $backup.google.serviceAccountSecret }} 59 | volumeMounts: 60 | - name: google-service-account 61 | mountPath: /etc/secrets/google/ 62 | {{- end }} 63 | {{- if $backup.google.serviceAccountSecret }} 64 | volumes: 65 | - name: google-service-account 66 | secret: 67 | secretName: {{ $backup.google.serviceAccountSecret | quote }} 68 | {{- end }} 69 | affinity: 70 | podAffinity: 71 | preferredDuringSchedulingIgnoredDuringExecution: 72 | - labelSelector: 73 | matchExpressions: 74 | - key: app 75 | operator: In 76 | values: 77 | - {{ template "cassandra.fullname" $ }} 78 | - key: release 79 | operator: In 80 | values: 81 | - {{ $release.Name }} 82 | topologyKey: "kubernetes.io/hostname" 83 | {{- with $values.tolerations }} 84 | tolerations: 85 | {{ toYaml . | indent 10 }} 86 | {{- end }} 87 | {{- end }} 88 | {{- end }} 89 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/backup/rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.backup.enabled }} 2 | {{- if .Values.serviceAccount.create }} 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | name: {{ template "cassandra.serviceAccountName" . }} 7 | labels: 8 | app: {{ template "cassandra.name" . }} 9 | chart: {{ template "cassandra.chart" . }} 10 | release: "{{ .Release.Name }}" 11 | heritage: "{{ .Release.Service }}" 12 | --- 13 | {{- end }} 14 | {{- if .Values.rbac.create }} 15 | apiVersion: rbac.authorization.k8s.io/v1 16 | kind: Role 17 | metadata: 18 | name: {{ template "cassandra.fullname" . }}-backup 19 | labels: 20 | app: {{ template "cassandra.name" . }} 21 | chart: {{ template "cassandra.chart" . }} 22 | release: "{{ .Release.Name }}" 23 | heritage: "{{ .Release.Service }}" 24 | rules: 25 | - apiGroups: [""] 26 | resources: ["pods", "pods/log"] 27 | verbs: ["get", "list"] 28 | - apiGroups: [""] 29 | resources: ["pods/exec"] 30 | verbs: ["create"] 31 | --- 32 | apiVersion: rbac.authorization.k8s.io/v1 33 | kind: RoleBinding 34 | metadata: 35 | name: {{ template "cassandra.fullname" . }}-backup 36 | labels: 37 | app: {{ template "cassandra.name" . }} 38 | chart: {{ template "cassandra.chart" . }} 39 | release: "{{ .Release.Name }}" 40 | heritage: "{{ .Release.Service }}" 41 | roleRef: 42 | apiGroup: rbac.authorization.k8s.io 43 | kind: Role 44 | name: {{ template "cassandra.fullname" . }}-backup 45 | subjects: 46 | - kind: ServiceAccount 47 | name: {{ template "cassandra.serviceAccountName" . }} 48 | namespace: {{ .Release.Namespace }} 49 | {{- end }} 50 | {{- end }} 51 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.configOverrides }} 2 | kind: ConfigMap 3 | apiVersion: v1 4 | metadata: 5 | name: {{ template "cassandra.name" . }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app: {{ template "cassandra.name" . }} 9 | chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} 10 | release: {{ .Release.Name }} 11 | heritage: {{ .Release.Service }} 12 | data: 13 | {{ toYaml .Values.configOverrides | indent 2 }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/pdb.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.podDisruptionBudget -}} 2 | apiVersion: policy/v1beta1 3 | kind: PodDisruptionBudget 4 | metadata: 5 | labels: 6 | app: {{ template "cassandra.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 8 | heritage: {{ .Release.Service }} 9 | release: {{ .Release.Name }} 10 | name: {{ template "cassandra.fullname" . }} 11 | spec: 12 | selector: 13 | matchLabels: 14 | app: {{ template "cassandra.name" . }} 15 | release: {{ .Release.Name }} 16 | {{ toYaml .Values.podDisruptionBudget | indent 2 }} 17 | {{- end -}} 18 | -------------------------------------------------------------------------------- /helm/charts/cassandra/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "cassandra.fullname" . }} 5 | labels: 6 | app: {{ template "cassandra.name" . }} 7 | chart: {{ template "cassandra.chart" . }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | spec: 11 | clusterIP: None 12 | type: {{ .Values.service.type }} 13 | ports: 14 | - name: intra 15 | port: 7000 16 | targetPort: 7000 17 | - name: tls 18 | port: 7001 19 | targetPort: 7001 20 | - name: jmx 21 | port: 7199 22 | targetPort: 7199 23 | - name: cql 24 | port: {{ default 9042 .Values.config.ports.cql }} 25 | targetPort: {{ default 9042 .Values.config.ports.cql }} 26 | - name: thrift 27 | port: {{ default 9160 .Values.config.ports.thrift }} 28 | targetPort: {{ default 9160 .Values.config.ports.thrift }} 29 | {{- if .Values.config.ports.agent }} 30 | - name: agent 31 | port: {{ .Values.config.ports.agent }} 32 | targetPort: {{ .Values.config.ports.agent }} 33 | {{- end }} 34 | selector: 35 | app: {{ template "cassandra.name" . }} 36 | release: {{ .Release.Name }} 37 | -------------------------------------------------------------------------------- /helm/charts/cassandra/values.yaml: -------------------------------------------------------------------------------- 1 | ## Cassandra image version 2 | ## ref: https://hub.docker.com/r/library/cassandra/ 3 | image: 4 | repo: cassandra 5 | tag: 3.11.3 6 | pullPolicy: IfNotPresent 7 | ## Specify ImagePullSecrets for Pods 8 | ## ref: https://kubernetes.io/docs/concepts/containers/images/#specifying-imagepullsecrets-on-a-pod 9 | # pullSecrets: myregistrykey 10 | 11 | ## Specify a service type 12 | ## ref: http://kubernetes.io/docs/user-guide/services/ 13 | service: 14 | type: ClusterIP 15 | 16 | ## Use an alternate scheduler, e.g. "stork". 17 | ## ref: https://kubernetes.io/docs/tasks/administer-cluster/configure-multiple-schedulers/ 18 | ## 19 | # schedulerName: 20 | 21 | ## Persist data to a persistent volume 22 | persistence: 23 | enabled: true 24 | ## cassandra data Persistent Volume Storage Class 25 | ## If defined, storageClassName: 26 | ## If set to "-", storageClassName: "", which disables dynamic provisioning 27 | ## If undefined (the default) or set to null, no storageClassName spec is 28 | ## set, choosing the default provisioner. (gp2 on AWS, standard on 29 | ## GKE, AWS & OpenStack) 30 | ## 31 | # storageClass: "-" 32 | accessMode: ReadWriteOnce 33 | size: 10Gi 34 | 35 | ## Configure resource requests and limits 36 | ## ref: http://kubernetes.io/docs/user-guide/compute-resources/ 37 | ## Minimum memory for development is 4GB and 2 CPU cores 38 | ## Minimum memory for production is 8GB and 4 CPU cores 39 | ## ref: http://docs.datastax.com/en/archived/cassandra/2.0/cassandra/architecture/architecturePlanningHardware_c.html 40 | resources: {} 41 | # requests: 42 | # memory: 4Gi 43 | # cpu: 2 44 | # limits: 45 | # memory: 4Gi 46 | # cpu: 2 47 | 48 | ## Change cassandra configuration parameters below: 49 | ## ref: http://docs.datastax.com/en/cassandra/3.0/cassandra/configuration/configCassandra_yaml.html 50 | ## Recommended max heap size is 1/2 of system memory 51 | ## Recommended heap new size is 1/4 of max heap size 52 | ## ref: http://docs.datastax.com/en/cassandra/3.0/cassandra/operations/opsTuneJVM.html 53 | config: 54 | cluster_domain: cluster.local 55 | cluster_name: cassandra 56 | # Note: in a real application, this should be bigger than 1 57 | cluster_size: 1 58 | seed_size: 2 59 | num_tokens: 256 60 | # If you want Cassandra to use this datacenter and rack name, 61 | # you need to set endpoint_snitch to GossipingPropertyFileSnitch. 62 | # Otherwise, these values are ignored and datacenter1 and rack1 63 | # are used. 64 | dc_name: DC1 65 | rack_name: RAC1 66 | endpoint_snitch: SimpleSnitch 67 | max_heap_size: 1024M 68 | heap_new_size: 200M 69 | start_rpc: false 70 | ports: 71 | cql: 9042 72 | thrift: 9160 73 | # If a JVM Agent is in place 74 | # agent: 61621 75 | 76 | ## Cassandra config files overrides 77 | configOverrides: {} 78 | 79 | ## Cassandra docker command overrides 80 | commandOverrides: [] 81 | 82 | ## Cassandra docker args overrides 83 | argsOverrides: [] 84 | 85 | ## Custom env variables. 86 | ## ref: https://hub.docker.com/_/cassandra/ 87 | env: {} 88 | 89 | ## Liveness and Readiness probe values. 90 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/ 91 | livenessProbe: 92 | initialDelaySeconds: 90 93 | periodSeconds: 30 94 | timeoutSeconds: 5 95 | successThreshold: 1 96 | failureThreshold: 3 97 | readinessProbe: 98 | initialDelaySeconds: 90 99 | periodSeconds: 30 100 | timeoutSeconds: 5 101 | successThreshold: 1 102 | failureThreshold: 3 103 | address: "${POD_IP}" 104 | 105 | ## Configure node selector. Edit code below for adding selector to pods 106 | ## ref: https://kubernetes.io/docs/user-guide/node-selection/ 107 | # selector: 108 | # nodeSelector: 109 | # cloud.google.com/gke-nodepool: pool-db 110 | 111 | ## Additional pod annotations 112 | ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ 113 | podAnnotations: {} 114 | 115 | ## Additional pod labels 116 | ## ref: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ 117 | podLabels: {} 118 | 119 | ## Additional pod-level settings 120 | podSettings: 121 | # Change this to give pods more time to properly leave the cluster when not using persistent storage. 122 | terminationGracePeriodSeconds: 30 123 | 124 | ## Pod distruption budget 125 | podDisruptionBudget: {} 126 | # maxUnavailable: 1 127 | # minAvailable: 2 128 | 129 | podManagementPolicy: OrderedReady 130 | updateStrategy: 131 | type: OnDelete 132 | 133 | ## Pod Security Context 134 | securityContext: 135 | enabled: false 136 | fsGroup: 999 137 | runAsUser: 999 138 | 139 | ## Affinity for pod assignment 140 | ## Ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity 141 | affinity: {} 142 | 143 | ## Node tolerations for pod assignment 144 | ## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ 145 | tolerations: [] 146 | 147 | rbac: 148 | # Specifies whether RBAC resources should be created 149 | create: true 150 | 151 | serviceAccount: 152 | # Specifies whether a ServiceAccount should be created 153 | create: true 154 | # The name of the ServiceAccount to use. 155 | # If not set and create is true, a name is generated using the fullname template 156 | # name: 157 | 158 | # Use host network for Cassandra pods 159 | # You must pass seed list into config.seeds property if set to true 160 | hostNetwork: false 161 | 162 | ## Backup cronjob configuration 163 | ## Ref: https://github.com/maorfr/cain 164 | backup: 165 | enabled: false 166 | 167 | # Schedule to run jobs. Must be in cron time format 168 | # Ref: https://crontab.guru/ 169 | schedule: 170 | - keyspace: keyspace1 171 | cron: "0 7 * * *" 172 | - keyspace: keyspace2 173 | cron: "30 7 * * *" 174 | 175 | annotations: 176 | # Example for authorization to AWS S3 using kube2iam 177 | # Can also be done using environment variables 178 | iam.amazonaws.com/role: cain 179 | 180 | image: 181 | repository: maorfr/cain 182 | tag: 0.6.0 183 | 184 | # Additional arguments for cain 185 | # Ref: https://github.com/maorfr/cain#usage 186 | extraArgs: [] 187 | 188 | # Add additional environment variables 189 | env: 190 | # Example environment variable required for AWS credentials chain 191 | - name: AWS_REGION 192 | value: us-east-1 193 | 194 | resources: 195 | requests: 196 | memory: 1Gi 197 | cpu: 1 198 | limits: 199 | memory: 1Gi 200 | cpu: 1 201 | 202 | # Name of the secret containing the credentials of the service account used by GOOGLE_APPLICATION_CREDENTIALS, as a credentials.json file 203 | # google: 204 | # serviceAccountSecret: 205 | 206 | # Destination to store the backup artifacts 207 | # Supported cloud storage services: AWS S3, Minio S3, Azure Blob Storage, Google Cloud Storage 208 | # Additional support can added. Visit this repository for details 209 | # Ref: https://github.com/maorfr/skbn 210 | destination: s3://bucket/cassandra 211 | 212 | ## Cassandra exported configuration 213 | ## ref: https://github.com/criteo/cassandra_exporter 214 | exporter: 215 | enabled: false 216 | image: 217 | repo: criteord/cassandra_exporter 218 | tag: 2.0.2 219 | port: 5556 220 | jvmOpts: "" 221 | resources: {} 222 | # limits: 223 | # cpu: 1 224 | # memory: 1Gi 225 | # requests: 226 | # cpu: 1 227 | # memory: 1Gi 228 | -------------------------------------------------------------------------------- /helm/charts/feeds/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/charts/feeds/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: feeds 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /helm/charts/feeds/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "feeds.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "feeds.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "feeds.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "feeds.labels" -}} 38 | app.kubernetes.io/name: {{ include "feeds.name" . }} 39 | helm.sh/chart: {{ include "feeds.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /helm/charts/feeds/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "feeds.fullname" . }} 5 | labels: 6 | {{ include "feeds.labels" . | indent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ include "feeds.name" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ include "feeds.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | spec: 19 | initContainers: 20 | {{ include "initContainers.ready" . | indent 6 }} 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | containers: 26 | {{ include "twtr.containers" . | indent 8 }} 27 | env: 28 | {{ include "twtr.env" . | indent 12 }} 29 | {{- with .Values.nodeSelector }} 30 | nodeSelector: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | {{- with .Values.affinity }} 34 | affinity: 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | {{- with .Values.tolerations }} 38 | tolerations: 39 | {{- toYaml . | nindent 8 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /helm/charts/feeds/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "feeds.fullname" . }} 5 | labels: 6 | {{ include "feeds.labels" . | indent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | app.kubernetes.io/name: {{ include "feeds.name" . }} 16 | app.kubernetes.io/instance: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /helm/charts/feeds/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: gcr.io/precise-clock-244301/twtr-feeds 9 | tag: latest 10 | # Convenience for development; don't want to set up CI/CD for the moment 11 | # pullPolicy: IfNotPresent 12 | pullPolicy: Always 13 | 14 | service: 15 | type: ClusterIP 16 | port: "3000" 17 | 18 | livenessProbe: 19 | initialDelaySeconds: 45 20 | periodSeconds: 10 21 | failureThreshold: 6 22 | httpGet: 23 | path: /healthz 24 | port: 3000 25 | 26 | readinessProbe: 27 | initialDelaySeconds: 45 28 | periodSeconds: 10 29 | failureThreshold: 6 30 | httpGet: 31 | path: /healthz 32 | port: 3000 -------------------------------------------------------------------------------- /helm/charts/followers/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/charts/followers/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: followers 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /helm/charts/followers/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "followers.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "followers.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "followers.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "followers.labels" -}} 38 | app.kubernetes.io/name: {{ include "followers.name" . }} 39 | helm.sh/chart: {{ include "followers.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /helm/charts/followers/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "followers.fullname" . }} 5 | labels: 6 | {{ include "followers.labels" . | indent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ include "followers.name" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ include "followers.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | spec: 19 | initContainers: 20 | {{ include "initContainers.ready" . | indent 6 }} 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | containers: 26 | {{ include "twtr.containers" . | indent 8 }} 27 | env: 28 | {{ include "twtr.env" . | indent 12 }} 29 | {{- with .Values.nodeSelector }} 30 | nodeSelector: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | {{- with .Values.affinity }} 34 | affinity: 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | {{- with .Values.tolerations }} 38 | tolerations: 39 | {{- toYaml . | nindent 8 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /helm/charts/followers/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "followers.fullname" . }} 5 | labels: 6 | {{ include "followers.labels" . | indent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | app.kubernetes.io/name: {{ include "followers.name" . }} 16 | app.kubernetes.io/instance: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /helm/charts/followers/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: gcr.io/precise-clock-244301/twtr-followers 9 | tag: latest 10 | # Convenience for development; don't want to set up CI/CD for the moment 11 | # pullPolicy: IfNotPresent 12 | pullPolicy: Always 13 | 14 | service: 15 | type: ClusterIP 16 | port: "3000" 17 | 18 | livenessProbe: 19 | initialDelaySeconds: 45 20 | periodSeconds: 10 21 | failureThreshold: 6 22 | httpGet: 23 | path: /healthz 24 | port: 3000 25 | 26 | readinessProbe: 27 | initialDelaySeconds: 45 28 | periodSeconds: 10 29 | failureThreshold: 6 30 | httpGet: 31 | path: /healthz 32 | port: 3000 -------------------------------------------------------------------------------- /helm/charts/gateway/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/charts/gateway/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: gateway 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /helm/charts/gateway/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "gateway.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "gateway.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "gateway.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "gateway.labels" -}} 38 | app.kubernetes.io/name: {{ include "gateway.name" . }} 39 | helm.sh/chart: {{ include "gateway.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /helm/charts/gateway/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "gateway.fullname" . }} 5 | labels: 6 | {{ include "gateway.labels" . | indent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ include "gateway.name" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ include "gateway.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | spec: 19 | initContainers: 20 | {{ include "initContainers.ready" . | indent 6 }} 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | containers: 26 | {{ include "twtr.containers" . | indent 8 }} 27 | env: 28 | {{ include "twtr.env" . | indent 12 }} 29 | {{- with .Values.nodeSelector }} 30 | nodeSelector: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | {{- with .Values.affinity }} 34 | affinity: 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | {{- with .Values.tolerations }} 38 | tolerations: 39 | {{- toYaml . | nindent 8 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /helm/charts/gateway/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "gateway.fullname" . }} 5 | labels: 6 | {{ include "gateway.labels" . | indent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | app.kubernetes.io/name: {{ include "gateway.name" . }} 16 | app.kubernetes.io/instance: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /helm/charts/gateway/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: gcr.io/precise-clock-244301/twtr-gateway 9 | tag: latest 10 | # Convenience for development; don't want to set up CI/CD for the moment 11 | # pullPolicy: IfNotPresent 12 | pullPolicy: Always 13 | 14 | service: 15 | type: ClusterIP 16 | port: "3000" 17 | 18 | livenessProbe: 19 | initialDelaySeconds: 45 20 | periodSeconds: 10 21 | failureThreshold: 6 22 | httpGet: 23 | path: /healthz 24 | port: 3000 25 | 26 | readinessProbe: 27 | initialDelaySeconds: 45 28 | periodSeconds: 10 29 | failureThreshold: 6 30 | httpGet: 31 | path: /healthz 32 | port: 3000 -------------------------------------------------------------------------------- /helm/charts/rabbitmq/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: rabbitmq 3 | version: 6.1.0 4 | appVersion: 3.7.15 5 | description: Open source message broker software that implements the Advanced Message Queuing Protocol (AMQP) 6 | keywords: 7 | - rabbitmq 8 | - message queue 9 | - AMQP 10 | home: https://www.rabbitmq.com 11 | icon: https://bitnami.com/assets/stacks/rabbitmq/img/rabbitmq-stack-220x234.png 12 | sources: 13 | - https://github.com/bitnami/bitnami-docker-rabbitmq 14 | maintainers: 15 | - name: Bitnami 16 | email: containers@bitnami.com 17 | - name: desaintmartin 18 | email: cedric@desaintmartin.fr 19 | engine: gotpl 20 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/README.md: -------------------------------------------------------------------------------- 1 | # Rabbitmq 2 | 3 | The helm chart was forked from https://github.com/helm/charts/tree/master/stable/rabbitmq. See this url for configuration options. -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 2 | ** Please be patient while the chart is being deployed ** 3 | 4 | Credentials: 5 | 6 | Username : {{ .Values.rabbitmq.username }} 7 | echo "Password : $(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "rabbitmq.fullname" . }} -o jsonpath="{.data.rabbitmq-password}" | base64 --decode)" 8 | echo "ErLang Cookie : $(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "rabbitmq.fullname" . }} -o jsonpath="{.data.rabbitmq-erlang-cookie}" | base64 --decode)" 9 | 10 | RabbitMQ can be accessed within the cluster on port {{ .Values.service.nodePort }} at {{ template "rabbitmq.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local 11 | 12 | To access for outside the cluster, perform the following steps: 13 | 14 | {{- if contains "NodePort" .Values.service.type }} 15 | 16 | Obtain the NodePort IP and ports: 17 | 18 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 19 | export NODE_PORT_AMQP=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[1].nodePort}" services {{ template "rabbitmq.fullname" . }}) 20 | export NODE_PORT_STATS=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[3].nodePort}" services {{ template "rabbitmq.fullname" . }}) 21 | 22 | To Access the RabbitMQ AMQP port: 23 | 24 | echo "URL : amqp://$NODE_IP:$NODE_PORT_AMQP/" 25 | 26 | To Access the RabbitMQ Management interface: 27 | 28 | echo "URL : http://$NODE_IP:$NODE_PORT_STATS/" 29 | 30 | {{- else if contains "LoadBalancer" .Values.service.type }} 31 | 32 | Obtain the LoadBalancer IP: 33 | 34 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 35 | Watch the status with: 'kubectl get svc --namespace {{ .Release.Namespace }} -w {{ template "rabbitmq.fullname" . }}' 36 | 37 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "rabbitmq.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 38 | 39 | To Access the RabbitMQ AMQP port: 40 | 41 | echo "URL : amqp://$SERVICE_IP:{{ .Values.service.port }}/" 42 | 43 | To Access the RabbitMQ Management interface: 44 | 45 | echo "URL : http://$SERVICE_IP:{{ .Values.service.managerPort }}/" 46 | 47 | {{- else if contains "ClusterIP" .Values.service.type }} 48 | 49 | To Access the RabbitMQ AMQP port: 50 | 51 | kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ template "rabbitmq.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} 52 | echo "URL : amqp://127.0.0.1:{{ .Values.service.port }}/" 53 | 54 | To Access the RabbitMQ Management interface: 55 | 56 | kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ template "rabbitmq.fullname" . }} {{ .Values.service.managerPort }}:{{ .Values.service.managerPort }} 57 | echo "URL : http://127.0.0.1:{{ .Values.service.managerPort }}/" 58 | 59 | {{- end }} 60 | 61 | {{- if and (contains "bitnami/" .Values.image.repository) (not (.Values.image.tag | toString | regexFind "-r\\d+$|sha256:")) }} 62 | 63 | WARNING: Rolling tag detected ({{ .Values.image.repository }}:{{ .Values.image.tag }}), please note that it is strongly recommended to avoid using rolling tags in a production environment. 64 | +info https://docs.bitnami.com/containers/how-to/understand-rolling-tags-containers/ 65 | 66 | {{- end }} 67 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "rabbitmq.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "rabbitmq.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "rabbitmq.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Return the proper RabbitMQ plugin list 36 | */}} 37 | {{- define "rabbitmq.plugins" -}} 38 | {{- $plugins := .Values.rabbitmq.plugins | replace " " ", " -}} 39 | {{- if .Values.rabbitmq.extraPlugins -}} 40 | {{- $extraPlugins := .Values.rabbitmq.extraPlugins | replace " " ", " -}} 41 | {{- printf "[%s, %s]." $plugins $extraPlugins | indent 4 -}} 42 | {{- else -}} 43 | {{- printf "[%s]." $plugins | indent 4 -}} 44 | {{- end -}} 45 | {{- end -}} 46 | 47 | {{/* 48 | Return the proper RabbitMQ image name 49 | */}} 50 | {{- define "rabbitmq.image" -}} 51 | {{- $registryName := .Values.image.registry -}} 52 | {{- $repositoryName := .Values.image.repository -}} 53 | {{- $tag := .Values.image.tag | toString -}} 54 | {{/* 55 | Helm 2.11 supports the assignment of a value to a variable defined in a different scope, 56 | but Helm 2.9 and 2.10 doesn't support it, so we need to implement this if-else logic. 57 | Also, we can't use a single if because lazy evaluation is not an option 58 | */}} 59 | {{- if .Values.global }} 60 | {{- if .Values.global.imageRegistry }} 61 | {{- printf "%s/%s:%s" .Values.global.imageRegistry $repositoryName $tag -}} 62 | {{- else -}} 63 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 64 | {{- end -}} 65 | {{- else -}} 66 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 67 | {{- end -}} 68 | {{- end -}} 69 | 70 | {{/* 71 | Return the proper metrics image name 72 | */}} 73 | {{- define "rabbitmq.metrics.image" -}} 74 | {{- $registryName := .Values.metrics.image.registry -}} 75 | {{- $repositoryName := .Values.metrics.image.repository -}} 76 | {{- $tag := .Values.metrics.image.tag | toString -}} 77 | {{/* 78 | Helm 2.11 supports the assignment of a value to a variable defined in a different scope, 79 | but Helm 2.9 and 2.10 doesn't support it, so we need to implement this if-else logic. 80 | Also, we can't use a single if because lazy evaluation is not an option 81 | */}} 82 | {{- if .Values.global }} 83 | {{- if .Values.global.imageRegistry }} 84 | {{- printf "%s/%s:%s" .Values.global.imageRegistry $repositoryName $tag -}} 85 | {{- else -}} 86 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 87 | {{- end -}} 88 | {{- else -}} 89 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 90 | {{- end -}} 91 | {{- end -}} 92 | 93 | {{/* 94 | Get the password secret. 95 | */}} 96 | {{- define "rabbitmq.secretPasswordName" -}} 97 | {{- if .Values.rabbitmq.existingPasswordSecret -}} 98 | {{- printf "%s" .Values.rabbitmq.existingPasswordSecret -}} 99 | {{- else -}} 100 | {{- printf "%s" (include "rabbitmq.fullname" .) -}} 101 | {{- end -}} 102 | {{- end -}} 103 | 104 | {{/* 105 | Get the erlang secret. 106 | */}} 107 | {{- define "rabbitmq.secretErlangName" -}} 108 | {{- if .Values.rabbitmq.existingErlangSecret -}} 109 | {{- printf "%s" .Values.rabbitmq.existingErlangSecret -}} 110 | {{- else -}} 111 | {{- printf "%s" (include "rabbitmq.fullname" .) -}} 112 | {{- end -}} 113 | {{- end -}} 114 | 115 | {{/* 116 | Return the proper Docker Image Registry Secret Names 117 | */}} 118 | {{- define "rabbitmq.imagePullSecrets" -}} 119 | {{/* 120 | Helm 2.11 supports the assignment of a value to a variable defined in a different scope, 121 | but Helm 2.9 and 2.10 does not support it, so we need to implement this if-else logic. 122 | Also, we can not use a single if because lazy evaluation is not an option 123 | */}} 124 | {{- if .Values.global }} 125 | {{- if .Values.global.imagePullSecrets }} 126 | imagePullSecrets: 127 | {{- range .Values.global.imagePullSecrets }} 128 | - name: {{ . }} 129 | {{- end }} 130 | {{- else if or .Values.image.pullSecrets .Values.metrics.image.pullSecrets .Values.volumePermissions.image.pullSecrets }} 131 | imagePullSecrets: 132 | {{- range .Values.image.pullSecrets }} 133 | - name: {{ . }} 134 | {{- end }} 135 | {{- range .Values.metrics.image.pullSecrets }} 136 | - name: {{ . }} 137 | {{- end }} 138 | {{- range .Values.volumePermissions.image.pullSecrets }} 139 | - name: {{ . }} 140 | {{- end }} 141 | {{- end -}} 142 | {{- else if or .Values.image.pullSecrets .Values.metrics.image.pullSecrets .Values.volumePermissions.image.pullSecrets }} 143 | imagePullSecrets: 144 | {{- range .Values.image.pullSecrets }} 145 | - name: {{ . }} 146 | {{- end }} 147 | {{- range .Values.metrics.image.pullSecrets }} 148 | - name: {{ . }} 149 | {{- end }} 150 | {{- range .Values.volumePermissions.image.pullSecrets }} 151 | - name: {{ . }} 152 | {{- end }} 153 | {{- end -}} 154 | {{- end -}} 155 | 156 | {{/* 157 | Return the proper image name (for the init container volume-permissions image) 158 | */}} 159 | {{- define "rabbitmq.volumePermissions.image" -}} 160 | {{- $registryName := .Values.volumePermissions.image.registry -}} 161 | {{- $repositoryName := .Values.volumePermissions.image.repository -}} 162 | {{- $tag := .Values.volumePermissions.image.tag | toString -}} 163 | {{/* 164 | Helm 2.11 supports the assignment of a value to a variable defined in a different scope, 165 | but Helm 2.9 and 2.10 doesn't support it, so we need to implement this if-else logic. 166 | Also, we can't use a single if because lazy evaluation is not an option 167 | */}} 168 | {{- if .Values.global }} 169 | {{- if .Values.global.imageRegistry }} 170 | {{- printf "%s/%s:%s" .Values.global.imageRegistry $repositoryName $tag -}} 171 | {{- else -}} 172 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 173 | {{- end -}} 174 | {{- else -}} 175 | {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} 176 | {{- end -}} 177 | {{- end -}} 178 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/configuration.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Release.Name }}-rabbitmq-config 5 | labels: 6 | app: {{ template "rabbitmq.name" . }} 7 | chart: {{ template "rabbitmq.chart" . }} 8 | release: "{{ .Release.Name }}" 9 | heritage: "{{ .Release.Service }}" 10 | data: 11 | url: amqp://{{ .Values.rabbitmq.username }}:{{ .Values.rabbitmq.password}}@{{ .Release.Name }}-rabbitmq 12 | port: "5672" 13 | enabled_plugins: |- 14 | {{ template "rabbitmq.plugins" . }} 15 | rabbitmq.conf: |- 16 | ##username and password 17 | default_user={{.Values.rabbitmq.username}} 18 | default_pass={{.Values.rabbitmq.password}} 19 | {{ .Values.rabbitmq.configuration | indent 4 }} 20 | {{ .Values.rabbitmq.extraConfiguration | indent 4 }} 21 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled }} 2 | apiVersion: extensions/v1beta1 3 | kind: Ingress 4 | metadata: 5 | name: "{{ template "rabbitmq.fullname" . }}" 6 | labels: 7 | app: "{{ template "rabbitmq.name" . }}" 8 | chart: "{{ template "rabbitmq.chart" . }}" 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | annotations: 12 | {{- if .Values.ingress.tls }} 13 | ingress.kubernetes.io/secure-backends: "true" 14 | {{- end }} 15 | {{- range $key, $value := .Values.ingress.annotations }} 16 | {{ $key }}: {{ $value | quote }} 17 | {{- end }} 18 | spec: 19 | rules: 20 | {{- if .Values.ingress.hostName }} 21 | - host: {{ .Values.ingress.hostName }} 22 | http: 23 | {{- else }} 24 | - http: 25 | {{- end }} 26 | paths: 27 | - path: {{ .Values.ingress.path }} 28 | backend: 29 | serviceName: {{ template "rabbitmq.fullname" . }} 30 | servicePort: {{ .Values.service.managerPort }} 31 | {{- if .Values.ingress.tls }} 32 | tls: 33 | - hosts: 34 | {{- if .Values.ingress.hostName }} 35 | - {{ .Values.ingress.hostName }} 36 | secretName: {{ .Values.ingress.tlsSecret }} 37 | {{- else}} 38 | - secretName: {{ .Values.ingress.tlsSecret }} 39 | {{- end }} 40 | {{- end }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/role.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbacEnabled }} 2 | kind: Role 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | name: {{ template "rabbitmq.fullname" . }}-endpoint-reader 6 | labels: 7 | app: {{ template "rabbitmq.name" . }} 8 | chart: {{ template "rabbitmq.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | rules: 12 | - apiGroups: [""] 13 | resources: ["endpoints"] 14 | verbs: ["get"] 15 | {{- end }} 16 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/rolebinding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbacEnabled }} 2 | kind: RoleBinding 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | name: {{ template "rabbitmq.fullname" . }}-endpoint-reader 6 | labels: 7 | app: {{ template "rabbitmq.name" . }} 8 | chart: {{ template "rabbitmq.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | subjects: 12 | - kind: ServiceAccount 13 | name: {{ template "rabbitmq.fullname" . }} 14 | roleRef: 15 | apiGroup: rbac.authorization.k8s.io 16 | kind: Role 17 | name: {{ template "rabbitmq.fullname" . }}-endpoint-reader 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | {{ if or (not .Values.rabbitmq.existingErlangSecret) (not .Values.rabbitmq.existingPasswordSecret) }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "rabbitmq.fullname" . }} 6 | labels: 7 | app: {{ template "rabbitmq.name" . }} 8 | chart: {{ template "rabbitmq.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | type: Opaque 12 | data: 13 | {{ if not .Values.rabbitmq.existingPasswordSecret }}{{ if .Values.rabbitmq.password }} 14 | rabbitmq-password: {{ .Values.rabbitmq.password | b64enc | quote }} 15 | {{ else }} 16 | rabbitmq-password: {{ randAlphaNum 10 | b64enc | quote }} 17 | {{ end }}{{ end }} 18 | {{ if not .Values.rabbitmq.existingErlangSecret }}{{ if .Values.rabbitmq.erlangCookie }} 19 | rabbitmq-erlang-cookie: {{ .Values.rabbitmq.erlangCookie | b64enc | quote }} 20 | {{ else }} 21 | rabbitmq-erlang-cookie: {{ randAlphaNum 32 | b64enc | quote }} 22 | {{ end }}{{ end }} 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbacEnabled }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ template "rabbitmq.fullname" . }} 6 | labels: 7 | app: {{ template "rabbitmq.name" . }} 8 | chart: {{ template "rabbitmq.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | {{- end }} 12 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ template "rabbitmq.fullname" . }} 6 | {{- if .Values.metrics.serviceMonitor.namespace }} 7 | namespace: {{ .Values.metrics.serviceMonitor.namespace }} 8 | {{- end }} 9 | labels: 10 | app: {{ template "rabbitmq.name" . }} 11 | chart: {{ template "rabbitmq.chart" . }} 12 | heritage: "{{ .Release.Service }}" 13 | release: "{{ .Release.Name }}" 14 | {{- if .Values.metrics.serviceMonitor.additionalLabels }} 15 | {{ toYaml .Values.metrics.serviceMonitor.additionalLabels | indent 4 }} 16 | {{- end }} 17 | spec: 18 | endpoints: 19 | - port: metrics 20 | interval: {{ .Values.metrics.serviceMonitor.interval }} 21 | {{- if .Values.metrics.serviceMonitor.scrapeTimeout }} 22 | scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} 23 | {{- end }} 24 | honorLabels: {{ .Values.metrics.serviceMonitor.honorLabels }} 25 | {{- if .Values.metrics.serviceMonitor.relabellings }} 26 | metricRelabelings: 27 | {{ toYaml .Values.metrics.serviceMonitor.relabellings | indent 6 }} 28 | {{- end }} 29 | namespaceSelector: 30 | matchNames: 31 | - {{ .Release.Namespace }} 32 | selector: 33 | matchLabels: 34 | app: {{ template "rabbitmq.name" . }} 35 | release: "{{ .Release.Name }}" 36 | {{- end }} 37 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/svc-headless.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "rabbitmq.fullname" . }}-headless 5 | labels: 6 | app: {{ template "rabbitmq.name" . }} 7 | chart: {{ template "rabbitmq.chart" . }} 8 | release: "{{ .Release.Name }}" 9 | heritage: "{{ .Release.Service }}" 10 | spec: 11 | clusterIP: None 12 | ports: 13 | - name: epmd 14 | port: 4369 15 | targetPort: epmd 16 | - name: amqp 17 | port: {{ .Values.service.port }} 18 | targetPort: amqp 19 | - name: dist 20 | port: {{ .Values.service.distPort }} 21 | targetPort: dist 22 | - name: stats 23 | port: {{ .Values.service.managerPort }} 24 | targetPort: stats 25 | selector: 26 | app: {{ template "rabbitmq.name" . }} 27 | release: "{{ .Release.Name }}" 28 | -------------------------------------------------------------------------------- /helm/charts/rabbitmq/templates/svc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "rabbitmq.fullname" . }} 5 | labels: 6 | app: {{ template "rabbitmq.name" . }} 7 | chart: {{ template "rabbitmq.chart" . }} 8 | release: "{{ .Release.Name }}" 9 | heritage: "{{ .Release.Service }}" 10 | {{- if or .Values.service.annotations .Values.metrics.enabled }} 11 | annotations: 12 | {{- end }} 13 | {{- if .Values.service.annotations }} 14 | {{ toYaml .Values.service.annotations | indent 4 }} 15 | {{- end }} 16 | {{- if .Values.metrics.enabled }} 17 | {{ toYaml .Values.metrics.annotations | indent 4 }} 18 | {{- end }} 19 | spec: 20 | type: {{ .Values.service.type }} 21 | {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerSourceRanges }} 22 | loadBalancerSourceRanges: 23 | {{ with .Values.service.loadBalancerSourceRanges }} 24 | {{ toYaml . | indent 4 }} 25 | {{- end }} 26 | {{- end }} 27 | ports: 28 | - name: epmd 29 | port: 4369 30 | targetPort: epmd 31 | - name: amqp 32 | port: {{ .Values.service.port }} 33 | targetPort: amqp 34 | {{- if (and (eq .Values.service.type "NodePort") (not (empty .Values.service.nodePort))) }} 35 | nodePort: {{ .Values.service.nodePort }} 36 | {{- end }} 37 | - name: dist 38 | port: {{ .Values.service.distPort }} 39 | targetPort: dist 40 | - name: stats 41 | port: {{ .Values.service.managerPort }} 42 | targetPort: stats 43 | {{- if .Values.metrics.enabled }} 44 | - name: metrics 45 | port: 9090 46 | targetPort: metrics 47 | {{- end }} 48 | selector: 49 | app: {{ template "rabbitmq.name" . }} 50 | release: "{{ .Release.Name }}" 51 | -------------------------------------------------------------------------------- /helm/charts/traefik/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | # OWNERS file for Kubernetes 23 | OWNERS 24 | -------------------------------------------------------------------------------- /helm/charts/traefik/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: traefik 3 | version: 1.69.1 4 | appVersion: 1.7.12 5 | description: A Traefik based Kubernetes ingress controller with Let's Encrypt support 6 | keywords: 7 | - traefik 8 | - ingress 9 | - acme 10 | - letsencrypt 11 | home: https://traefik.io/ 12 | sources: 13 | - https://github.com/containous/traefik 14 | - https://github.com/helm/charts/tree/master/stable/traefik 15 | maintainers: 16 | - name: krancour 17 | email: kent.rancourt@microsoft.com 18 | - name: emilevauge 19 | email: emile@vauge.com 20 | - name: dtomcej 21 | email: daniel.tomcej@gmail.com 22 | - name: ldez 23 | email: ludovic@containo.us 24 | engine: gotpl 25 | icon: https://docs.traefik.io/img/traefik.logo.png 26 | -------------------------------------------------------------------------------- /helm/charts/traefik/OWNERS: -------------------------------------------------------------------------------- 1 | approvers: 2 | - emilevauge 3 | - dtomcej 4 | - timoreimann 5 | - vdemeester 6 | - nmengin 7 | - mmatur 8 | - ldez 9 | - Juliens 10 | - jbdoumenjou 11 | - geraldcroes 12 | - SantoDE 13 | - errm 14 | reviewers: 15 | - emilevauge 16 | - dtomcej 17 | - timoreimann 18 | - vdemeester 19 | - nmengin 20 | - mmatur 21 | - ldez 22 | - Juliens 23 | - jbdoumenjou 24 | - geraldcroes 25 | - SantoDE 26 | - errm 27 | -------------------------------------------------------------------------------- /helm/charts/traefik/README.md: -------------------------------------------------------------------------------- 1 | # Traefik 2 | 3 | The helm chart was forked from https://github.com/helm/charts/tree/master/stable/traefik. See this url for configuration options. -------------------------------------------------------------------------------- /helm/charts/traefik/ci/ci-values.yaml: -------------------------------------------------------------------------------- 1 | serviceType: NodePort 2 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | {{- if eq .Values.serviceType "LoadBalancer" }} 2 | 1. Get Traefik's load balancer IP/hostname: 3 | 4 | NOTE: It may take a few minutes for this to become available. 5 | 6 | You can watch the status by running: 7 | 8 | $ kubectl get svc {{ template "traefik.fullname" . }} --namespace {{ .Release.Namespace }} -w 9 | 10 | Once 'EXTERNAL-IP' is no longer '': 11 | 12 | $ kubectl describe svc {{ template "traefik.fullname" . }} --namespace {{ .Release.Namespace }} | grep Ingress | awk '{print $3}' 13 | 14 | 2. Configure DNS records corresponding to Kubernetes ingress resources to point to the load balancer IP/hostname found in step 1 15 | {{- end }} 16 | {{- if eq .Values.serviceType "NodePort" }} 17 | {{- if (and (not (empty .Values.service.nodePorts.https)) (not (empty .Values.service.nodePorts.http)))}} 18 | 1. Traefik is listening on the following ports on the host machine: 19 | 20 | http - {{ .Values.service.nodePorts.http }} 21 | https - {{ .Values.service.nodePorts.https }} 22 | {{- else }} 23 | 1. Traefik has been started. You can find out the port numbers being used by traefik by running: 24 | 25 | $ kubectl describe svc {{ template "traefik.fullname" . }} --namespace {{ .Release.Namespace }} 26 | 27 | {{- end }} 28 | 29 | 2. Configure DNS records corresponding to Kubernetes ingress resources to point to the NODE_IP/NODE_HOST 30 | {{- end }} 31 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | 3 | {{/* 4 | Expand the name of the chart. 5 | */}} 6 | {{- define "traefik.name" -}} 7 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 8 | {{- end -}} 9 | 10 | 11 | {{/* 12 | Create a default fully qualified app name. 13 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 14 | If release name contains chart name it will be used as a full name. 15 | */}} 16 | {{- define "traefik.fullname" -}} 17 | {{- if .Values.fullnameOverride -}} 18 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 19 | {{- else -}} 20 | {{- $name := default .Chart.Name .Values.nameOverride -}} 21 | {{- if contains $name .Release.Name -}} 22 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 23 | {{- else -}} 24 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 25 | {{- end -}} 26 | {{- end -}} 27 | {{- end -}} 28 | 29 | {{/* 30 | Create chart name and version as used by the chart label. 31 | */}} 32 | {{- define "traefik.chart" -}} 33 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 34 | {{- end -}} 35 | 36 | {{/* 37 | Create the block for the ProxyProtocol's Trusted IPs. 38 | */}} 39 | {{- define "traefik.trustedips" -}} 40 | trustedIPs = [ 41 | {{- range $idx, $ips := .Values.proxyProtocol.trustedIPs }} 42 | {{- if $idx }}, {{ end }} 43 | {{- $ips | quote }} 44 | {{- end -}} 45 | ] 46 | {{- end -}} 47 | 48 | {{/* 49 | Create the block for the forwardedHeaders's Trusted IPs. 50 | */}} 51 | {{- define "traefik.forwardedHeadersTrustedIPs" -}} 52 | trustedIPs = [ 53 | {{- range $idx, $ips := .Values.forwardedHeaders.trustedIPs }} 54 | {{- if $idx }}, {{ end }} 55 | {{- $ips | quote }} 56 | {{- end -}} 57 | ] 58 | {{- end -}} 59 | 60 | {{/* 61 | Create the block for whiteListSourceRange. 62 | */}} 63 | {{- define "traefik.whiteListSourceRange" -}} 64 | whiteListSourceRange = [ 65 | {{- range $idx, $ips := .Values.whiteListSourceRange }} 66 | {{- if $idx }}, {{ end }} 67 | {{- $ips | quote }} 68 | {{- end -}} 69 | ] 70 | {{- end -}} 71 | 72 | {{/* 73 | Create the block for acme.domains. 74 | */}} 75 | {{- define "traefik.acme.domains" -}} 76 | {{- range $idx, $value := .Values.acme.domains.domainsList }} 77 | {{- if $value.main }} 78 | [[acme.domains]] 79 | main = {{- range $mainIdx, $mainValue := $value }} {{ $mainValue | quote }}{{- end -}} 80 | {{- end -}} 81 | {{- if $value.sans }} 82 | sans = [ 83 | {{- range $sansIdx, $domains := $value.sans }} 84 | {{- if $sansIdx }}, {{ end }} 85 | {{- $domains | quote }} 86 | {{- end -}} 87 | ] 88 | {{- end -}} 89 | {{- end -}} 90 | {{- end -}} 91 | 92 | {{/* 93 | Create the block for acme.resolvers. 94 | */}} 95 | {{- define "traefik.acme.dnsResolvers" -}} 96 | resolvers = [ 97 | {{- range $idx, $ips := .Values.acme.resolvers }} 98 | {{- if $idx }},{{ end }} 99 | {{- $ips | quote }} 100 | {{- end -}} 101 | ] 102 | {{- end -}} 103 | 104 | {{/* 105 | Create custom cipherSuites block 106 | */}} 107 | {{- define "traefik.ssl.cipherSuites" -}} 108 | cipherSuites = [ 109 | {{- range $idx, $cipher := .Values.ssl.cipherSuites }} 110 | {{- if $idx }},{{ end }} 111 | {{ $cipher | quote }} 112 | {{- end }} 113 | ] 114 | {{- end -}} 115 | 116 | Create the block for RootCAs. 117 | */}} 118 | {{- define "traefik.rootCAs" -}} 119 | rootCAs = [ 120 | {{- range $idx, $ca := .Values.rootCAs }} 121 | {{- if $idx }}, {{ end }} 122 | {{- $ca | quote }} 123 | {{- end -}} 124 | ] 125 | {{- end -}} 126 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/acme-pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.acme.enabled .Values.acme.persistence.enabled (not .Values.acme.persistence.existingClaim) }} 2 | kind: PersistentVolumeClaim 3 | apiVersion: v1 4 | metadata: 5 | {{- if .Values.acme.persistence.annotations }} 6 | annotations: 7 | {{ toYaml .Values.acme.persistence.annotations | indent 4 }} 8 | {{- end }} 9 | name: {{ template "traefik.fullname" . }}-acme 10 | labels: 11 | app: {{ template "traefik.name" . }} 12 | chart: {{ template "traefik.chart" . }} 13 | release: "{{ .Release.Name }}" 14 | heritage: "{{ .Release.Service }}" 15 | spec: 16 | accessModes: 17 | - {{ .Values.acme.persistence.accessMode | quote }} 18 | resources: 19 | requests: 20 | storage: {{ .Values.acme.persistence.size | quote }} 21 | {{- if .Values.acme.persistence.storageClass }} 22 | {{- if (eq "-" .Values.acme.persistence.storageClass) }} 23 | storageClassName: "" 24 | {{- else }} 25 | storageClassName: "{{ .Values.acme.persistence.storageClass }}" 26 | {{- end }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/config-files.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.configFiles }} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-configs 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | data: 12 | {{- range $filename, $fileContents := .Values.configFiles }} 13 | {{ $filename }}: |- 14 | {{ $fileContents | indent 4 }} 15 | {{- end }} 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/dashboard-ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.dashboard.enabled }} 2 | apiVersion: extensions/v1beta1 3 | kind: Ingress 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-dashboard 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | {{- if .Values.dashboard.ingress }} 12 | {{- range $key, $value := .Values.dashboard.ingress.labels }} 13 | {{ $key }}: {{ $value | quote }} 14 | {{- end }} 15 | {{- end }} 16 | annotations: 17 | {{- if .Values.dashboard.ingress }} 18 | {{- range $key, $value := .Values.dashboard.ingress.annotations }} 19 | {{ $key }}: {{ $value | quote }} 20 | {{- end }} 21 | {{- end }} 22 | spec: 23 | rules: 24 | - host: {{ .Values.dashboard.domain }} 25 | http: 26 | paths: 27 | - backend: 28 | serviceName: {{ template "traefik.fullname" . }}-dashboard 29 | servicePort: dashboard-http 30 | {{- if .Values.dashboard.ingress.tls }} 31 | tls: 32 | {{ toYaml .Values.dashboard.ingress.tls | indent 4 }} 33 | {{- end -}} 34 | {{- end }} 35 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/dashboard-service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.dashboard.enabled }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-dashboard 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | annotations: 12 | {{- if .Values.dashboard.service }} 13 | {{- range $key, $value := .Values.dashboard.service.annotations }} 14 | {{ $key }}: {{ $value | quote }} 15 | {{- end }} 16 | {{- end }} 17 | spec: 18 | type: {{ .Values.dashboard.serviceType | default ("ClusterIP") }} 19 | selector: 20 | app: {{ template "traefik.name" . }} 21 | release: {{ .Release.Name }} 22 | ports: 23 | - name: dashboard-http 24 | port: 80 25 | targetPort: 8080 26 | {{- end }} 27 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/default-cert-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ssl.enabled }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-default-cert 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | type: Opaque 12 | data: 13 | {{- if .Values.ssl.generateTLS }} 14 | {{- $ca := genCA "default-ca" 365 }} 15 | {{- $cn := default "example.com" .Values.ssl.defaultCN }} 16 | {{- $server := genSignedCert $cn ( default nil .Values.ssl.defaultIPList ) ( default nil .Values.ssl.defaultSANList ) 365 $ca }} 17 | tls.crt: {{ $server.Cert | b64enc }} 18 | tls.key: {{ $server.Key | b64enc }} 19 | {{- else }} 20 | tls.crt: {{ .Values.ssl.defaultCert }} 21 | tls.key: {{ .Values.ssl.defaultKey }} 22 | {{- end }} 23 | {{- end }} 24 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/dns-provider-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.acme.enabled (eq .Values.acme.challengeType "dns-01") .Values.acme.dnsProvider.name (not .Values.acme.dnsProvider.existingSecretName) }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-dnsprovider-config 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | type: Opaque 12 | data: 13 | {{- range $k, $v := (index .Values.acme.dnsProvider .Values.acme.dnsProvider.name) }} 14 | {{- if $v }} 15 | {{ $k }}: {{ $v | b64enc | quote }} 16 | {{- end }} 17 | {{- end }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ template "traefik.fullname" . }} 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | spec: 12 | scaleTargetRef: 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | name: {{ template "traefik.fullname" . }} 16 | minReplicas: {{ .Values.autoscaling.minReplicas }} 17 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 18 | metrics: 19 | {{ toYaml .Values.autoscaling.metrics | indent 4 }} 20 | {{- end }} 21 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/poddisruptionbudget.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.podDisruptionBudget -}} 2 | apiVersion: policy/v1beta1 3 | kind: PodDisruptionBudget 4 | metadata: 5 | name: {{ template "traefik.fullname" . }} 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: {{ .Release.Name | quote }} 10 | heritage: {{ .Release.Service | quote }} 11 | spec: 12 | selector: 13 | matchLabels: 14 | app: {{ template "traefik.name" . }} 15 | release: {{ .Release.Name }} 16 | {{ toYaml .Values.podDisruptionBudget | indent 2 }} 17 | {{- end -}} 18 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled }} 2 | kind: ServiceAccount 3 | apiVersion: v1 4 | metadata: 5 | name: {{ template "traefik.fullname" . }} 6 | namespace: {{ .Release.Namespace }} 7 | --- 8 | kind: Role 9 | apiVersion: rbac.authorization.k8s.io/v1beta1 10 | metadata: 11 | name: {{ template "traefik.fullname" . }} 12 | namespace: {{ .Release.Namespace }} 13 | rules: 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - pods 18 | - services 19 | - endpoints 20 | - secrets 21 | verbs: 22 | - get 23 | - list 24 | - watch 25 | - apiGroups: 26 | - extensions 27 | resources: 28 | - ingresses 29 | verbs: 30 | - get 31 | - list 32 | - watch 33 | - apiGroups: 34 | - extensions 35 | resources: 36 | - ingresses/status 37 | verbs: 38 | - update 39 | --- 40 | kind: RoleBinding 41 | apiVersion: rbac.authorization.k8s.io/v1 42 | metadata: 43 | name: {{ template "traefik.fullname" . }} 44 | namespace: {{ .Release.Namespace }} 45 | roleRef: 46 | apiGroup: rbac.authorization.k8s.io 47 | kind: Role 48 | name: {{ template "traefik.fullname" . }} 49 | subjects: 50 | - kind: ServiceAccount 51 | name: {{ template "traefik.fullname" . }} 52 | namespace: {{ .Release.Namespace }} 53 | {{- end }} 54 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/secret-files.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.secretFiles }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "traefik.fullname" . }}-secrets 6 | labels: 7 | app: {{ template "traefik.name" . }} 8 | chart: {{ template "traefik.chart" . }} 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | type: Opaque 12 | data: 13 | {{- range $filename, $fileContents := .Values.secretFiles }} 14 | {{ $filename }}: {{ $fileContents | b64enc | quote }} 15 | {{- end }} 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "traefik.fullname" . }} 5 | labels: 6 | app: {{ template "traefik.name" . }} 7 | chart: {{ template "traefik.chart" . }} 8 | release: {{ .Release.Name | quote }} 9 | heritage: {{ .Release.Service | quote }} 10 | {{- if .Values.service }} 11 | {{- range $key, $value := .Values.service.labels }} 12 | {{ $key }}: {{ $value | quote }} 13 | {{- end }} 14 | {{- end }} 15 | annotations: 16 | {{- if .Values.service }} 17 | {{- range $key, $value := .Values.service.annotations }} 18 | {{ $key }}: {{ $value | quote }} 19 | {{- end }} 20 | {{- end }} 21 | spec: 22 | type: {{ .Values.serviceType }} 23 | {{- if .Values.loadBalancerIP }} 24 | loadBalancerIP: {{ .Values.loadBalancerIP }} 25 | {{- end }} 26 | {{- if .Values.externalIP }} 27 | externalIPs: 28 | - {{ .Values.externalIP }} 29 | {{- end }} 30 | {{- if .Values.loadBalancerSourceRanges }} 31 | loadBalancerSourceRanges: 32 | {{- range $cidr := .Values.loadBalancerSourceRanges }} 33 | - {{ $cidr }} 34 | {{- end }} 35 | {{- end }} 36 | {{- if .Values.externalTrafficPolicy }} 37 | externalTrafficPolicy: {{ .Values.externalTrafficPolicy }} 38 | {{- end }} 39 | selector: 40 | app: {{ template "traefik.name" . }} 41 | release: {{ .Release.Name }} 42 | ports: 43 | - port: 80 44 | name: http 45 | {{- if (and (eq .Values.serviceType "NodePort") (not (empty .Values.service.nodePorts.http)))}} 46 | nodePort: {{ .Values.service.nodePorts.http }} 47 | {{- end }} 48 | targetPort: http 49 | - port: 443 50 | name: https 51 | {{- if (and (eq .Values.serviceType "NodePort") (not (empty .Values.service.nodePorts.https)))}} 52 | nodePort: {{ .Values.service.nodePorts.https }} 53 | {{- end }} 54 | {{- if not .Values.ssl.enabled }} 55 | targetPort: httpn 56 | {{- end }} 57 | {{- if (and (.Values.metrics.prometheus.enabled) (not (.Values.metrics.prometheus.restrictAccess)))}} 58 | - port: 8080 59 | name: metrics 60 | targetPort: dash 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/storeconfig-job.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.kvprovider.storeAcme }} 2 | apiVersion: batch/v1 3 | kind: Job 4 | metadata: 5 | name: "storeconfig-job-{{ .Release.Revision }}" 6 | annotations: 7 | "helm.sh/hook": post-install,post-upgrade 8 | "helm.sh/hook-delete-policy": "hook-succeeded,before-hook-creation" 9 | labels: 10 | chart: {{ template "traefik.chart" . }} 11 | app: {{ template "traefik.name" . }} 12 | spec: 13 | template: 14 | metadata: 15 | name: "storeconfig-job-{{ .Release.Revision }}" 16 | labels: 17 | app: {{ template "traefik.name" . }} 18 | chart: {{ template "traefik.chart" . }} 19 | spec: 20 | restartPolicy: Never 21 | containers: 22 | - name: storeconfig-job 23 | image: "{{ .Values.image }}:{{ .Values.imageTag }}" 24 | args: 25 | - storeconfig 26 | - --configfile=/config/traefik.toml 27 | volumeMounts: 28 | - mountPath: /config 29 | name: config 30 | - mountPath: /acme 31 | name: acme 32 | volumes: 33 | - name: config 34 | configMap: 35 | name: {{ template "traefik.fullname" . }} 36 | - name: acme 37 | {{- if .Values.acme.persistence.enabled }} 38 | persistentVolumeClaim: 39 | claimName: {{ .Values.acme.persistence.existingClaim | default (printf "%s-acme" (include "traefik.fullname" .)) }} 40 | {{- else }} 41 | emptyDir: {} 42 | {{- end }} 43 | {{- end }} 44 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/tests/test-configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ template "traefik.fullname" . }}-test 5 | labels: 6 | app: {{ template "traefik.fullname" . }} 7 | chart: {{ template "traefik.chart" . }} 8 | heritage: "{{ .Release.Service }}" 9 | release: "{{ .Release.Name }}" 10 | data: 11 | run.sh: |- 12 | @test "Test Access" { 13 | curl -D - http://{{ template "traefik.fullname" . }}/ 14 | } 15 | 16 | -------------------------------------------------------------------------------- /helm/charts/traefik/templates/tests/test.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: {{ template "traefik.fullname" . }}-test 5 | labels: 6 | app: {{ template "traefik.fullname" . }} 7 | chart: {{ template "traefik.chart" . }} 8 | heritage: "{{ .Release.Service }}" 9 | release: "{{ .Release.Name }}" 10 | annotations: 11 | "helm.sh/hook": test-success 12 | spec: 13 | initContainers: 14 | - name: test-framework 15 | image: "{{ .Values.testFramework.image}}:{{ .Values.testFramework.tag }}" 16 | command: 17 | - "bash" 18 | - "-c" 19 | - | 20 | set -ex 21 | # copy bats to tools dir 22 | cp -R /usr/local/libexec/ /tools/bats/ 23 | volumeMounts: 24 | - mountPath: /tools 25 | name: tools 26 | containers: 27 | - name: {{ .Release.Name }}-test 28 | image: "{{ .Values.testFramework.image}}:{{ .Values.testFramework.tag }}" 29 | command: ["/tools/bats/bats", "-t", "/tests/run.sh"] 30 | volumeMounts: 31 | - mountPath: /tests 32 | name: tests 33 | readOnly: true 34 | - mountPath: /tools 35 | name: tools 36 | volumes: 37 | - name: tests 38 | configMap: 39 | name: {{ template "traefik.fullname" . }}-test 40 | - name: tools 41 | emptyDir: {} 42 | restartPolicy: Never 43 | -------------------------------------------------------------------------------- /helm/charts/tweets/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/charts/tweets/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: tweets 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /helm/charts/tweets/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "tweets.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "tweets.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "tweets.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "tweets.labels" -}} 38 | app.kubernetes.io/name: {{ include "tweets.name" . }} 39 | helm.sh/chart: {{ include "tweets.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /helm/charts/tweets/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "tweets.fullname" . }} 5 | labels: 6 | {{ include "tweets.labels" . | indent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ include "tweets.name" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ include "tweets.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | spec: 19 | initContainers: 20 | {{ include "initContainers.ready" . | indent 6 }} 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | containers: 26 | {{ include "twtr.containers" . | indent 8 }} 27 | env: 28 | {{ include "twtr.env" . | indent 12 }} 29 | {{- with .Values.nodeSelector }} 30 | nodeSelector: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | {{- with .Values.affinity }} 34 | affinity: 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | {{- with .Values.tolerations }} 38 | tolerations: 39 | {{- toYaml . | nindent 8 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /helm/charts/tweets/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "tweets.fullname" . }} 5 | labels: 6 | {{ include "tweets.labels" . | indent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | app.kubernetes.io/name: {{ include "tweets.name" . }} 16 | app.kubernetes.io/instance: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /helm/charts/tweets/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: gcr.io/precise-clock-244301/twtr-tweets 9 | tag: latest 10 | # Convenience for development; don't want to set up CI/CD for the moment 11 | # pullPolicy: IfNotPresent 12 | pullPolicy: Always 13 | 14 | service: 15 | type: ClusterIP 16 | port: "3000" 17 | 18 | livenessProbe: 19 | initialDelaySeconds: 45 20 | periodSeconds: 10 21 | failureThreshold: 6 22 | httpGet: 23 | path: /healthz 24 | port: 3000 25 | 26 | readinessProbe: 27 | initialDelaySeconds: 45 28 | periodSeconds: 10 29 | failureThreshold: 6 30 | httpGet: 31 | path: /healthz 32 | port: 3000 -------------------------------------------------------------------------------- /helm/charts/users/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/charts/users/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: users 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /helm/charts/users/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "users.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "users.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "users.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "users.labels" -}} 38 | app.kubernetes.io/name: {{ include "users.name" . }} 39 | helm.sh/chart: {{ include "users.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /helm/charts/users/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "users.fullname" . }} 5 | labels: 6 | {{ include "users.labels" . | indent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: {{ include "users.name" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: {{ include "users.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | spec: 19 | initContainers: 20 | {{ include "initContainers.ready" . | indent 6 }} 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | containers: 26 | {{ include "twtr.containers" . | indent 8 }} 27 | env: 28 | {{ include "twtr.env" . | indent 12 }} 29 | {{- with .Values.nodeSelector }} 30 | nodeSelector: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | {{- with .Values.affinity }} 34 | affinity: 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | {{- with .Values.tolerations }} 38 | tolerations: 39 | {{- toYaml . | nindent 8 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /helm/charts/users/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "users.fullname" . }} 5 | labels: 6 | {{ include "users.labels" . | indent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | app.kubernetes.io/name: {{ include "users.name" . }} 16 | app.kubernetes.io/instance: {{ .Release.Name }} 17 | -------------------------------------------------------------------------------- /helm/charts/users/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for gateway. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: gcr.io/precise-clock-244301/twtr-users 9 | tag: latest 10 | # Convenience for development; don't want to set up CI/CD for the moment 11 | # pullPolicy: IfNotPresent 12 | pullPolicy: Always 13 | 14 | service: 15 | type: ClusterIP 16 | port: "3000" 17 | 18 | livenessProbe: 19 | initialDelaySeconds: 45 20 | periodSeconds: 10 21 | failureThreshold: 6 22 | httpGet: 23 | path: /healthz 24 | port: 3000 25 | 26 | readinessProbe: 27 | initialDelaySeconds: 45 28 | periodSeconds: 10 29 | failureThreshold: 6 30 | httpGet: 31 | path: /healthz 32 | port: 3000 -------------------------------------------------------------------------------- /helm/templates/_env.tpl: -------------------------------------------------------------------------------- 1 | {{- define "twtr.env" -}} 2 | - name: GO_ENV 3 | value: {{ .Values.goEnv | default "production" | quote }} 4 | - name: AMQP_URL 5 | valueFrom: 6 | configMapKeyRef: 7 | name: {{ .Release.Name }}-rabbitmq-config 8 | key: url 9 | - name: CASSANDRA_URL 10 | value: {{ .Release.Name }}-cassandra 11 | - name: CASSANDRA_KEYSPACE 12 | value: {{ .Values.cassandraKeyspace | default "twtr" | quote }} 13 | - name: PORT 14 | value: {{ .Values.service.port | default "3000" | quote }} 15 | {{- end -}} -------------------------------------------------------------------------------- /helm/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "helm.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "helm.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "helm.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "helm.labels" -}} 38 | app.kubernetes.io/name: {{ include "helm.name" . }} 39 | helm.sh/chart: {{ include "helm.chart" . }} 40 | app.kubernetes.io/instance: {{ .Release.Name }} 41 | {{- if .Chart.AppVersion }} 42 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 43 | {{- end }} 44 | app.kubernetes.io/managed-by: {{ .Release.Service }} 45 | {{- end -}} 46 | 47 | {{- define "initContainers.ready" -}} 48 | - name: init-ready 49 | image: gcr.io/precise-clock-244301/twtr-ready 50 | env: 51 | {{ include "twtr.env" . | indent 2}} 52 | {{- end -}} 53 | 54 | {{- define "twtr.containers" -}} 55 | - name: {{ .Chart.Name }} 56 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 57 | imagePullPolicy: {{ .Values.image.pullPolicy }} 58 | ports: 59 | - name: http 60 | containerPort: 3000 61 | protocol: TCP 62 | livenessProbe: 63 | initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} 64 | periodSeconds: {{ .Values.livenessProbe.periodSeconds }} 65 | failureThreshold: {{ .Values.livenessProbe.failureThreshold }} 66 | httpGet: 67 | path: {{ .Values.livenessProbe.httpGet.path }} 68 | port: {{ .Values.livenessProbe.httpGet.port }} 69 | readinessProbe: 70 | initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} 71 | periodSeconds: {{ .Values.readinessProbe.periodSeconds }} 72 | failureThreshold: {{ .Values.readinessProbe.failureThreshold }} 73 | httpGet: 74 | path: {{ .Values.readinessProbe.httpGet.path }} 75 | port: {{ .Values.readinessProbe.httpGet.port }} 76 | {{- end -}} -------------------------------------------------------------------------------- /helm/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "helm.fullname" . -}} 3 | apiVersion: extensions/v1beta1 4 | kind: Ingress 5 | metadata: 6 | name: {{ $fullName }} 7 | labels: 8 | {{ include "helm.labels" . | indent 4 }} 9 | {{- with .Values.ingress.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | {{- if .Values.ingress.tls }} 15 | tls: 16 | {{- range .Values.ingress.tls }} 17 | - hosts: 18 | {{- range .hosts }} 19 | - {{ . | quote }} 20 | {{- end }} 21 | secretName: {{ .secretName }} 22 | {{- end }} 23 | {{- end }} 24 | rules: 25 | - host: twtr-dev.com # In /etc/hosts, this maps to my cluster's ip 26 | http: 27 | paths: 28 | - path: / 29 | backend: 30 | serviceName: {{ .Release.Name }}-gateway 31 | servicePort: http 32 | - host: traefik.dashboard.com 33 | http: 34 | paths: 35 | - path: / 36 | backend: 37 | serviceName: {{ .Release.Name }}-traefik-dashboard 38 | servicePort: 80 39 | {{- end }} 40 | -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Define global configuration values here 2 | 3 | ingress: 4 | enabled: true 5 | annotations: 6 | kubernetes.io/ingress.class: traefik 7 | traefik.frontend.rule.type: PathPrefixStrip 8 | 9 | persistence: 10 | enabled: true 11 | -------------------------------------------------------------------------------- /k8s/README.md: -------------------------------------------------------------------------------- 1 | # k8s 2 | 3 | This folder contains a few yaml files used in `scripts/setup-k8s.sh` to configure a kubernetes cluster with the appropriate roles for tiller, which is a piece of software 4 | required by helm on the cluster for managing releases. -------------------------------------------------------------------------------- /k8s/create-storage-gce.yaml: -------------------------------------------------------------------------------- 1 | kind: StorageClass 2 | apiVersion: storage.k8s.io/v1 3 | metadata: 4 | name: generic 5 | provisioner: kubernetes.io/gce-pd 6 | parameters: 7 | type: pd-ssd -------------------------------------------------------------------------------- /k8s/role-tiller.yaml: -------------------------------------------------------------------------------- 1 | # Defines a role allowing tiller to manage all resources in the twtr-dev namespace 2 | kind: Role 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | metadata: 5 | name: tiller-manager 6 | namespace: twtr-dev 7 | rules: 8 | - apiGroups: ["", "batch", "extensions", "apps", "roles", "rbac.authorization.k8s.io"] 9 | resources: ["*"] 10 | verbs: ["*"] -------------------------------------------------------------------------------- /k8s/rolebinding-tiller.yaml: -------------------------------------------------------------------------------- 1 | kind: RoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: tiller-binding 5 | namespace: twtr-dev 6 | subjects: 7 | - kind: ServiceAccount 8 | name: tiller 9 | namespace: twtr-dev 10 | roleRef: 11 | kind: Role 12 | name: tiller-manager 13 | apiGroup: rbac.authorization.k8s.io -------------------------------------------------------------------------------- /migrations/0001-initial.cql: -------------------------------------------------------------------------------- 1 | DROP KEYSPACE IF EXISTS twtr; 2 | 3 | CREATE KEYSPACE twtr WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}; -------------------------------------------------------------------------------- /migrations/0002-users.cql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS twtr.users; 2 | 3 | CREATE TABLE twtr.users ( 4 | username text PRIMARY KEY, 5 | email text, 6 | password text, 7 | refresh_token text 8 | ); -------------------------------------------------------------------------------- /migrations/0003-tweets.cql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS twtr.tweets; 2 | DROP TABLE IF EXISTS twtr.tweets_by_user; 3 | 4 | CREATE TABLE twtr.tweets ( 5 | id uuid PRIMARY KEY, 6 | username text, 7 | content text, 8 | created_at timestamp, 9 | ); 10 | 11 | 12 | CREATE TABLE twtr.tweets_by_user ( 13 | id uuid, 14 | username text, 15 | content text, 16 | created_at timestamp, 17 | PRIMARY KEY (username, created_at) 18 | ); -------------------------------------------------------------------------------- /migrations/0004-followers.cql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS twtr.user_followers; 2 | DROP TABLE IF EXISTS twtr.user_followings; 3 | 4 | CREATE TABLE twtr.user_followers ( 5 | username text, 6 | follower_username text, 7 | PRIMARY KEY (username, follower_username) 8 | ); 9 | 10 | CREATE TABLE twtr.user_followings ( 11 | username text, 12 | following_username text, 13 | PRIMARY KEY (username, following_username) 14 | ); -------------------------------------------------------------------------------- /migrations/0005-feeds.cql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS twtr.feed_items; 2 | 3 | CREATE TABLE twtr.feed_items ( 4 | username text, 5 | tweet_id uuid, 6 | tweet_username text, 7 | tweet_content text, 8 | tweet_created_at timestamp, 9 | PRIMARY KEY (username, tweet_created_at) 10 | ); -------------------------------------------------------------------------------- /scripts/build-and-push-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ABS_PATH=$(cd "$(dirname "$1")"; pwd -P)$(basename "$1") || exit 4 | HOST_NAME=gcr.io 5 | GOOGLE_PROJECT_ID=precise-clock-244301 6 | TAG=latest 7 | 8 | echo 'Building docker images...' 9 | 10 | for i in $(ls -v ${ABS_PATH}/services/*/Dockerfile); do 11 | IMAGE_NAME=twtr-$(basename $(dirname $i)) 12 | docker build -f $i -t ${IMAGE_NAME} . 13 | docker tag ${IMAGE_NAME} ${HOST_NAME}/${GOOGLE_PROJECT_ID}/${IMAGE_NAME}:${TAG} 14 | docker push ${HOST_NAME}/${GOOGLE_PROJECT_ID}/${IMAGE_NAME} 15 | done; 16 | 17 | echo 'Done building docker images.' -------------------------------------------------------------------------------- /scripts/delete-evicted-pods.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | kubectl get pods | grep Evicted | awk '{print $1}' | xargs kubectl delete pod -------------------------------------------------------------------------------- /scripts/gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ABS_PATH=$(cd "$(dirname "$1")"; pwd -P)/$(basename "$1") || exit 4 | 5 | echo 'Formatting with gofmt...' 6 | 7 | for i in $(find ${ABS_PATH} -name \*.go); 8 | do gofmt -w $i; 9 | done; 10 | 11 | echo 'Formatting done.' -------------------------------------------------------------------------------- /scripts/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ABS_PATH=$(cd "$(dirname "$1")"; pwd -P)$(basename "$1") || exit 4 | 5 | echo 'Running migrations...' 6 | 7 | for i in $(ls -v ${ABS_PATH}/migrations/*.cql); 8 | do cat $i | cqlsh; 9 | done; 10 | 11 | echo 'Migrations done.' -------------------------------------------------------------------------------- /scripts/rabbitmq-health-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Scratch file for figuring out rabbit init container. 4 | # Not in use, see ready service. Keeping around for posterity and as a testament to my efforts. 5 | 6 | i=0; 7 | while [ $i -le 10 ]; do 8 | RABBIT_STATE=$(curl --max-time 8 --silent --show-error --fail ${AMQP_MGMT_URL}); 9 | if [[ $RABBIT_STATE == *no* ]]; then 10 | echo ok; 11 | exit 0; 12 | fi 13 | echo waiting...; 14 | ((i++)); 15 | sleep $i; 16 | done; 17 | exit 1; -------------------------------------------------------------------------------- /scripts/run-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | trap 'killall' INT 4 | 5 | killall() { 6 | trap '' INT TERM # ignore INT and TERM while shutting down 7 | echo "**** Shutting down... ****" # added double quotes 8 | kill -TERM 0 # fixed order, send TERM not INT 9 | wait 10 | echo DONE 11 | } 12 | 13 | ABS_PATH=$(cd "$(dirname "$1")"; pwd -P)$(basename "$1") || exit 14 | 15 | echo 'Booting go services...' 16 | 17 | HTTP_PORT=3000 18 | for i in $(find ${ABS_PATH}/services/**/cmd/*.go); do 19 | echo "Booting $i" 20 | # Don't want to run everything on the same port for local dev (addr already in use) 21 | PORT=${HTTP_PORT} go run $i & 22 | let HTTP_PORT=${HTTP_PORT}+1 23 | done; 24 | 25 | cat -------------------------------------------------------------------------------- /scripts/run-integration-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ABS_PATH=$(cd "$(dirname "$1")"; pwd -P)$(basename "$1") || exit 4 | 5 | echo 'Running integration tests...' 6 | 7 | for i in $(ls -v ${ABS_PATH}/tests/integration/*_test.go); 8 | do go test -v -count=1 -p=1 $i; 9 | done; 10 | 11 | echo 'Integration tests done.' -------------------------------------------------------------------------------- /scripts/setup-k8s.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script assumes that the user has kubectl set up in their path, and that kubectl is configured properly. 4 | 5 | # Set up k8s namespace 6 | kubectl create namespace twtr-dev 7 | 8 | # Set up tiller with RBAC to this namespace 9 | kubectl create serviceaccount tiller --namespace twtr-dev 10 | kubectl create -f role-tiller.yaml 11 | kubectl create -f rolebinding-tiller.yaml 12 | helm init --service-account tiller --tiller-namespace twtr-dev 13 | 14 | # Create a storage class for persistent volumes 15 | kubectl create -f create-storage-gce.yaml -------------------------------------------------------------------------------- /services/common/amqp/routing_keys.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | const ( 4 | CreateUserKey = "twtr.users.create" 5 | AuthorizeUserKey = "twtr.*.authorize" 6 | ExistsUserKey = "twtr.*.exists" 7 | CreateTweetKey = "twtr.*.tweets.create" 8 | CreatedTweetKey = "twtr.*.tweets.created" 9 | GetAllUserTweetsKey = "twtr.*.tweets.get" 10 | FollowUserKey = "twtr.*.follow.create" 11 | GetAllUserFollowers = "twtr.*.followers.get" 12 | GetMyFeedKey = "twtr.*.feed.me" 13 | ) 14 | 15 | // InterpolateRoutingKey replaces all asterisks present in the function argument key 16 | // with the values of the function argument values. Doesn't do any error handling, so 17 | // use wisely 18 | func interpolateRoutingKey(key string, values []string) string { 19 | if len(values) == 0 { 20 | return key 21 | } 22 | 23 | interpolated := "" 24 | 25 | for _, byte := range key { 26 | character := string(byte) 27 | if character == "*" { 28 | // pop 29 | value := values[0] 30 | values = values[1:] 31 | interpolated += value 32 | } else { 33 | interpolated += character 34 | } 35 | } 36 | return interpolated 37 | } 38 | -------------------------------------------------------------------------------- /services/common/auth/tokens.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | jwt "github.com/dgrijalva/jwt-go" 7 | ) 8 | 9 | // GenerateToken returns a jwt given a username (for verifying user permissions) and secret 10 | func GenerateToken(username string, hmacSecretString string) (string, error) { 11 | hmacSecret := []byte(hmacSecretString) 12 | // TODO: make this token expire after 60s depending on env 13 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 14 | "username": username, 15 | "nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(), 16 | }) 17 | tokenString, err := token.SignedString(hmacSecret) 18 | return tokenString, err 19 | } 20 | -------------------------------------------------------------------------------- /services/common/cassandra/cassandra.go: -------------------------------------------------------------------------------- 1 | package cassandra 2 | 3 | import ( 4 | "github.com/gocql/gocql" 5 | ) 6 | 7 | // TODO: handle disconnects from cassandra 8 | 9 | // Client represents an active session (connection) to a particular cassandra cluster 10 | type Client struct { 11 | cluster *gocql.ClusterConfig 12 | Session *gocql.Session 13 | } 14 | 15 | // NewClient synchronously constructs a cassandra.Client 16 | func NewClient(host string, keyspace string) (*Client, error) { 17 | cluster := gocql.NewCluster(host) 18 | cluster.Keyspace = keyspace 19 | cluster.Consistency = gocql.Quorum 20 | session, err := cluster.CreateSession() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return &Client{ 26 | cluster: cluster, 27 | Session: session, 28 | }, nil 29 | } 30 | -------------------------------------------------------------------------------- /services/common/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // Config defines the shape of the configuration values used across the api 8 | type Config struct { 9 | Env string 10 | Port string 11 | AmqpURL string 12 | AmqpPort string 13 | LogLevel string 14 | } 15 | 16 | // NewConfig returns the default configuration values used across the api 17 | func NewConfig() *Config { 18 | // set defaults - these can be overwritten via command line 19 | 20 | if os.Getenv("GO_ENV") == "" { 21 | os.Setenv("GO_ENV", "development") 22 | } 23 | 24 | if os.Getenv("PORT") == "" { 25 | os.Setenv("PORT", "3000") 26 | } 27 | 28 | if os.Getenv("AMQP_URL") == "" { 29 | os.Setenv("AMQP_URL", "amqp://rabbitmq:rabbitmq@localhost:5672") 30 | } 31 | 32 | if os.Getenv("LOG_LEVEL") == "" { 33 | os.Setenv("LOG_LEVEL", "debug") 34 | } 35 | 36 | return &Config{ 37 | Env: os.Getenv("GO_ENV"), 38 | Port: os.Getenv("PORT"), 39 | AmqpURL: os.Getenv("AMQP_URL"), 40 | AmqpPort: os.Getenv("AMQP_PORT"), 41 | LogLevel: os.Getenv("LOG_LEVEL"), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /services/common/config/service_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // ServiceConfig defines the shape of the configuration values used in backend services 8 | type ServiceConfig struct { 9 | *Config 10 | CassandraURL string 11 | CassandraKeyspace string 12 | HMACSecret string 13 | } 14 | 15 | // NewServiceConfig initializes the default service configuration values 16 | func NewServiceConfig() *ServiceConfig { 17 | if os.Getenv("GO_ENV") == "" { 18 | os.Setenv("GO_ENV", "development") 19 | } 20 | 21 | if os.Getenv("PORT") == "" { 22 | os.Setenv("PORT", "3000") 23 | } 24 | 25 | if os.Getenv("AMQP_URL") == "" { 26 | os.Setenv("AMQP_URL", "amqp://rabbitmq:rabbitmq@localhost:5672") 27 | } 28 | 29 | if os.Getenv("LOG_LEVEL") == "" { 30 | os.Setenv("LOG_LEVEL", "debug") 31 | } 32 | 33 | if os.Getenv("CASSANDRA_URL") == "" { 34 | os.Setenv("CASSANDRA_URL", "127.0.0.1") 35 | } 36 | 37 | if os.Getenv("CASSANDRA_KEYSPACE") == "" { 38 | os.Setenv("CASSANDRA_KEYSPACE", "twtr") 39 | } 40 | 41 | if os.Getenv("HMAC_SECRET") == "" { 42 | os.Setenv("HMAC_SECRET", "hmacsecret") 43 | } 44 | 45 | return &ServiceConfig{ 46 | Config: &Config{ 47 | Env: os.Getenv("GO_ENV"), 48 | Port: os.Getenv("PORT"), 49 | AmqpURL: os.Getenv("AMQP_URL"), 50 | AmqpPort: os.Getenv("AMQP_PORT"), 51 | LogLevel: os.Getenv("LOG_LEVEL"), 52 | }, 53 | CassandraURL: os.Getenv("CASSANDRA_URL"), 54 | CassandraKeyspace: os.Getenv("CASSANDRA_KEYSPACE"), 55 | HMACSecret: os.Getenv("HMAC_SECRET"), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /services/common/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import "os" 4 | 5 | // GetEnv checks whether an environment variable is set, returning a fallback value 6 | // if the environment variable is not set 7 | func GetEnv(key, fallback string) string { 8 | if value, ok := os.LookupEnv(key); ok { 9 | return value 10 | } 11 | return fallback 12 | } 13 | -------------------------------------------------------------------------------- /services/common/healthz/healthz.go: -------------------------------------------------------------------------------- 1 | package healthz 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "twitter-go/services/common/logger" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // WireToRouter wires a health check route so k8s knows when a service is dead 12 | func WireToRouter(router *mux.Router) { 13 | var healthz http.HandlerFunc 14 | healthz = func(w http.ResponseWriter, r *http.Request) { 15 | logger.Info(logger.Loggable{ 16 | Message: "Responding to health check", 17 | Data: nil, 18 | }) 19 | fmt.Fprint(w, "ok") 20 | } 21 | router.Methods("GET").Path("/healthz").Handler(healthz) 22 | } 23 | -------------------------------------------------------------------------------- /services/common/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | "time" 8 | "twitter-go/services/common/env" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var logLevelMappingTable = map[string]int{ 14 | "debug": 0, 15 | "info": 1, 16 | "warning": 2, 17 | "error": 3, 18 | } 19 | 20 | // Loggable defines the shape of a message output to std 21 | type Loggable struct { 22 | Message string 23 | Data interface{} 24 | } 25 | 26 | // Init initializes the logger configuration 27 | func Init() {} 28 | 29 | // Debug logs loggable data with a level of debug 30 | func Debug(loggable Loggable) { 31 | ok := logForLevel("Debug", loggable) 32 | if ok { 33 | data := convertDataToMap(loggable.Data) 34 | log.WithFields(data).Debug(loggable.Message) 35 | } 36 | } 37 | 38 | // Info logs loggable data with a level of info 39 | func Info(loggable Loggable) { 40 | ok := logForLevel("Info", loggable) 41 | if ok { 42 | data := convertDataToMap(loggable.Data) 43 | log.WithFields(data).Info(loggable.Message) 44 | } 45 | } 46 | 47 | // Warning logs loggable data with a level of warning 48 | func Warning(loggable Loggable) { 49 | ok := logForLevel("Warning", loggable) 50 | if ok { 51 | data := convertDataToMap(loggable.Data) 52 | log.WithFields(data).Warn(loggable.Message) 53 | } 54 | } 55 | 56 | // Error logs loggable data with a level of error 57 | func Error(loggable Loggable) { 58 | ok := logForLevel("Error", loggable) 59 | if ok { 60 | data := convertDataToMap(loggable.Data) 61 | log.WithFields(data).Error(loggable.Message) 62 | } 63 | } 64 | 65 | func logForLevel(logLevel string, loggable Loggable) bool { 66 | envLogLevel := env.GetEnv("LOG_LEVEL", "debug") 67 | envLogLevelInt := logLevelMappingTable[envLogLevel] 68 | logLevelInt := logLevelMappingTable[logLevel] 69 | return envLogLevelInt <= logLevelInt 70 | } 71 | 72 | func convertDataToMap(data interface{}) map[string]interface{} { 73 | if data == nil { 74 | return map[string]interface{}{} 75 | } 76 | 77 | klass := reflect.TypeOf(data).Kind() 78 | 79 | if klass != reflect.Map && klass != reflect.Struct && klass != reflect.Slice && klass != reflect.String { 80 | log.Printf("Unrecognized type in convertDataToMap: %s", klass) 81 | return map[string]interface{}{} 82 | } 83 | 84 | if klass == reflect.String { 85 | // TODO fix this for queries 86 | // Currently making logs for query strings ugly 87 | strings.Replace(data.(string), "\n", "", -1) 88 | data = map[string]interface{}{"data": data} 89 | } 90 | 91 | var serialized map[string]interface{} 92 | 93 | jsonString, err := json.Marshal(data) 94 | 95 | if err != nil { 96 | log.Printf("Something went wrong in convertDataToMap for: %s", klass) 97 | log.Printf(err.Error()) 98 | return map[string]interface{}{} 99 | } 100 | 101 | json.Unmarshal(jsonString, &serialized) 102 | 103 | if serialized != nil { 104 | serialized["timestamp"] = time.Now().UTC() 105 | } 106 | 107 | return serialized 108 | } 109 | -------------------------------------------------------------------------------- /services/common/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/prometheus/client_golang/prometheus/promhttp" 6 | ) 7 | 8 | // WireToRouter wires a metrics route to a metrics handler 9 | func WireToRouter(router *mux.Router) { 10 | router.Methods("GET").Path("/metrics").Handler(promhttp.Handler()) 11 | } 12 | -------------------------------------------------------------------------------- /services/common/service/repository.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "twitter-go/services/common/cassandra" 5 | "twitter-go/services/common/logger" 6 | 7 | "github.com/gocql/gocql" 8 | ) 9 | 10 | // Repository is a wrapper around gocql with a set of convenience functions 11 | // for common operations 12 | type Repository struct { 13 | Cassandra *cassandra.Client 14 | } 15 | 16 | // Query wraps makes a query gocql can understand, adding a log message for auditing 17 | func (r *Repository) Query(queryString string, values ...interface{}) gocql.Query { 18 | query := r.Cassandra.Session.Query(queryString, values...) 19 | logger.Info(logger.Loggable{Message: "Executing query", Data: query.String()}) 20 | return *query 21 | } 22 | -------------------------------------------------------------------------------- /services/common/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "twitter-go/services/common/amqp" 8 | "twitter-go/services/common/cassandra" 9 | "twitter-go/services/common/config" 10 | "twitter-go/services/common/healthz" 11 | "twitter-go/services/common/metrics" 12 | 13 | "github.com/gorilla/mux" 14 | ) 15 | 16 | // ReplyFunc defines the shape of a handler for a rpc request 17 | type ReplyFunc func(s *Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) 18 | 19 | // Repliers is an array of Repliers 20 | type Repliers []Replier 21 | 22 | // Replier defines the shape of a replier, associating a routing key to a handler which 23 | // will issue a reply to incoming requests 24 | type Replier struct { 25 | RoutingKey string 26 | Handler ReplyFunc 27 | } 28 | 29 | // ConsumeFunc defines the shape of a handler for a broadcasted message 30 | type ConsumeFunc func(s *Service) func([]byte) 31 | 32 | // Consumers is an array of Consumers 33 | type Consumers []Consumer 34 | 35 | // Consumer defines the shape of a consumer, associating a routing key to a handler which 36 | // handle an incoming request without replying 37 | type Consumer struct { 38 | RoutingKey string 39 | Handler ConsumeFunc 40 | } 41 | 42 | // Service defines the common components of most microservices in the backend 43 | type Service struct { 44 | Name string 45 | Config *config.ServiceConfig 46 | Amqp *amqp.Client 47 | Cassandra *cassandra.Client 48 | } 49 | 50 | // NewService constructs a new service 51 | func NewService(name string, amqp *amqp.Client, cassandra *cassandra.Client, config *config.ServiceConfig) *Service { 52 | return &Service{ 53 | Name: name, 54 | Amqp: amqp, 55 | Cassandra: cassandra, 56 | Config: config, 57 | } 58 | } 59 | 60 | // Init initializes the service, wiring repliers and consumers alongside serving metrics and health checks 61 | func (s *Service) Init(repliers Repliers, consumers Consumers) { 62 | s.wire(repliers, consumers) 63 | s.serve() 64 | } 65 | 66 | func (s *Service) wire(repliers Repliers, consumers Consumers) { 67 | for _, replier := range repliers { 68 | s.Amqp.DirectReply(replier.RoutingKey, replier.Handler(s)) 69 | } 70 | 71 | for _, consumer := range consumers { 72 | s.Amqp.ConsumeFromTopic(consumer.RoutingKey, consumer.Handler(s)) 73 | } 74 | } 75 | 76 | func (s *Service) serve() { 77 | if s.Config.Env != "testing" { 78 | log.Printf("%s listening", s.Name) 79 | } 80 | 81 | port := fmt.Sprintf(":%s", s.Config.Port) 82 | router := mux.NewRouter().StrictSlash(true) 83 | 84 | healthz.WireToRouter(router) 85 | metrics.WireToRouter(router) 86 | 87 | log.Fatal(http.ListenAndServe(port, router)) 88 | } 89 | -------------------------------------------------------------------------------- /services/common/types/feeds.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gocql/gocql" 7 | ) 8 | 9 | // GetMyFeed defines the shape of a request to get the logged in user's feed 10 | type GetMyFeed struct { 11 | Username string `json:"username"` 12 | } 13 | 14 | // AddTweetToFeed defines the shape of a request to propagate a tweet creation to all the 15 | // tweeter's followers 16 | type AddTweetToFeed struct { 17 | TweetUsername string `json:"username"` 18 | TweetContent string `json:"content"` 19 | TweetID gocql.UUID `json:"id"` 20 | TweetCreatedAt time.Time `json:"createdAt"` 21 | } 22 | 23 | // Feed is an array of feed items 24 | type Feed []FeedItem 25 | 26 | // FeedItem defines the shape of a feed item record in cassandra 27 | type FeedItem struct { 28 | ID gocql.UUID `json:"id"` 29 | Username string `json:"username"` 30 | Content string `json:"content"` 31 | CreatedAt time.Time `json:"createdAt"` 32 | } 33 | -------------------------------------------------------------------------------- /services/common/types/followers.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "errors" 4 | 5 | // FollowUser defines the shape of a request to follow a user 6 | type FollowUser struct { 7 | Username string `json:"username"` 8 | FollowingUsername string `json:"followingUsername"` 9 | } 10 | 11 | // Validate validates a follower user request 12 | func (dto *FollowUser) Validate() error { 13 | if dto.Username == "" { 14 | return errors.New("username is a required field") 15 | } 16 | 17 | if dto.FollowingUsername == "" { 18 | return errors.New("followingUsername is a required field") 19 | } 20 | 21 | if dto.Username == dto.FollowingUsername { 22 | return errors.New("followingUsername cannot be the same as username") 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // GetUserFollowers defines the shape of a request to retrieve all followers of a user 29 | type GetUserFollowers struct { 30 | Username string `json:"username"` 31 | } 32 | 33 | // Follower defines the shape of a follower 34 | type Follower struct { 35 | Username string `json:"username"` 36 | } 37 | 38 | // Followers is an array of followers 39 | type Followers []Follower 40 | -------------------------------------------------------------------------------- /services/common/types/tweets.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/gocql/gocql" 8 | ) 9 | 10 | // CreateTweet defines the shape of a request to create a tweet 11 | type CreateTweet struct { 12 | Username string 13 | Content string `json:"content"` 14 | } 15 | 16 | // Validate validates the create tweet request 17 | func (dto *CreateTweet) Validate() error { 18 | if dto.Username == "" { 19 | return errors.New("username is a required field") 20 | } 21 | 22 | if dto.Content == "" { 23 | return errors.New("content is a required field") 24 | } 25 | 26 | return nil 27 | } 28 | 29 | // GetAllUserTweets defines the shape of a request to get all tweets made by a particular user 30 | type GetAllUserTweets struct { 31 | Username string `json:"username"` 32 | } 33 | 34 | // Validate valides the get all user tweets request 35 | func (dto *GetAllUserTweets) Validate() error { 36 | if dto.Username == "" { 37 | return errors.New("username is a required field") 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // Tweet defines the shape of a tweet 44 | type Tweet struct { 45 | ID gocql.UUID `json:"id"` 46 | Username string `json:"username"` 47 | CreatedAt time.Time `json:"createdAt"` 48 | Content string `json:"content"` 49 | } 50 | 51 | // PrepareForInsert prepares a tweet to be inserted to the database, 52 | // setting the ID and createdAt fields (cassandra grants us very little ;) 53 | func (tweet *Tweet) PrepareForInsert() { 54 | tweet.ID, _ = gocql.RandomUUID() 55 | tweet.CreatedAt = time.Now().UTC() 56 | } 57 | -------------------------------------------------------------------------------- /services/common/types/users.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "github.com/google/uuid" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | var rxEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 12 | 13 | // CreateUser defines the shape of the dto used to create a new user 14 | type CreateUser struct { 15 | Username string `json:"username"` 16 | Password string `json:"password"` 17 | PasswordConfirmation string `json:"passwordConfirmation"` 18 | Email string `json:"email"` 19 | DisplayName string `json:"displayName"` 20 | } 21 | 22 | // Validate validates that the dto is well formed for entry into the system 23 | func (dto *CreateUser) Validate() error { 24 | 25 | if dto.Username == "" { 26 | return errors.New("username is a required field") 27 | } 28 | 29 | if len(dto.Username) < 1 || len(dto.Username) > 16 { 30 | return errors.New("username must be between 1 and 16 characters in length") 31 | } 32 | 33 | if dto.Password == "" { 34 | return errors.New("password is a required field") 35 | } 36 | 37 | if len(dto.Password) < 8 || len(dto.Password) > 32 { 38 | return errors.New("password must be between 8 and 32 characters in length") 39 | } 40 | 41 | if dto.Password != dto.PasswordConfirmation { 42 | return errors.New("password and password confirmation must be the same") 43 | } 44 | 45 | if dto.Email == "" { 46 | return errors.New("email is a required field") 47 | } 48 | 49 | if len(dto.Email) > 254 || !rxEmail.MatchString(dto.Email) { 50 | return errors.New("email is invalid") 51 | } 52 | 53 | if dto.DisplayName == "" { 54 | return errors.New("displayName is a required field") 55 | } 56 | 57 | if len(dto.DisplayName) < 1 || len(dto.DisplayName) > 16 { 58 | return errors.New("displayName must be between 1 and 16 characters in length") 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // AuthenticateUser defines the shape of the dto used to authenticate a user 65 | type AuthenticateUser struct { 66 | Username string `json:"username"` 67 | Password string `json:"password"` 68 | } 69 | 70 | // Validate validates that the dto is well formed for entry into the system 71 | func (dto *AuthenticateUser) Validate() error { 72 | 73 | if dto.Username == "" { 74 | return errors.New("username is a required field") 75 | } 76 | 77 | if dto.Password == "" { 78 | return errors.New("password is a required field") 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // ExistsUser defines the shape of a request to check whether a user exists or not 85 | type ExistsUser struct { 86 | Username string `json:"username"` 87 | } 88 | 89 | // User defines the shape of a user in the database 90 | type User struct { 91 | Username string `json:"username"` 92 | Email string `json:"email"` 93 | Password string `json:",omitempty"` 94 | AccessToken string `json:"accessToken"` 95 | RefreshToken string `json:"refreshToken"` 96 | } 97 | 98 | // PrepareForInsert sets derived properties pre database insertion, e.g hashing the set password and initializing 99 | // a new refresh token 100 | func (u *User) PrepareForInsert() error { 101 | password := []byte(u.Password) 102 | hashedPassword, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) 103 | if err != nil { 104 | return err 105 | } 106 | refreshToken := uuid.New().String() 107 | u.Password = string(hashedPassword) 108 | u.RefreshToken = refreshToken 109 | return nil 110 | } 111 | 112 | // Sanitize sanitizes a user for reads. We don't want passwords being sent back directly or output in logs 113 | func (u *User) Sanitize() { 114 | u.Password = "" 115 | } 116 | 117 | // CompareHashAndPassword checks whether a hash matches a password. Used for authentication. 118 | func (u *User) CompareHashAndPassword(password string) error { 119 | p := []byte(password) 120 | hp := []byte(u.Password) 121 | return bcrypt.CompareHashAndPassword(hp, p) 122 | } 123 | 124 | // Authorize defines the shape of a request to authorize a user 125 | type Authorize struct { 126 | Username string `json:"username"` 127 | Password string `json:",omitempty"` 128 | } 129 | 130 | // Authorized defines the shape of a response to authorize a user 131 | type Authorized struct { 132 | AccessToken string `json:"accessToken"` 133 | RefreshToken string `json:"refreshToken"` 134 | } 135 | 136 | // DoesExist defines the shape of a request to check whether a user exists or not 137 | type DoesExist struct { 138 | Username string `json:"username"` 139 | } 140 | 141 | // Exists defines the shape of a response to whether or not a user exists 142 | type Exists struct { 143 | Exists bool `json:"exists"` 144 | } 145 | -------------------------------------------------------------------------------- /services/feeds/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/feeds services/feeds 9 | RUN go build -ldflags="-s -w" -o app services/feeds/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/feeds/README.md: -------------------------------------------------------------------------------- 1 | ## Feeds service 2 | 3 | The feeds service provides functionality to retrieve a user's feed alongside asynchronously aggregating subscribed user tweets to a user's feed. Plainly put: it lets you get your feed, and it handles creating and updating all user feeds. This is known as a _fan-out on write_ strategy, in contrast to a fan-out on read strategy. 4 | 5 | For the expected application workload (0, given this is a toy application for learning purposes), either choice would be fine. Fan out on write is typically best suited for users who have a limited number of subscribers (e.g, 100 writes vs 1,000,000 writes on tweet), and makes querying a user's feed blazing fast (grab whatever is in the database at the moment). For a real-world twitter app, they likely use a combination of read and write strategies given the performance heuristics: users with many subscribers have their tweets added to a feed at read with another query, and users with few subscribers have their tweets added to a feed on write. 6 | 7 | The application is consciously accepting that there may be a delay between a tweet being made, and that tweet appearing in a user's feed. Moreover, a user's feed will still be viewable even if other services are down (e.g, creating a tweet). 8 | 9 | #### What is a feed in the data model? 10 | 11 | ``` 12 | twtr.feed_items ( 13 | username text, 14 | tweet_id uuid, 15 | tweet_username text, 16 | tweet_content text, 17 | tweet_created_at timestamp, 18 | PRIMARY KEY (username, tweet_created_at) 19 | ); 20 | ``` 21 | 22 | 23 | #### Access patterns 24 | 25 | - Retrieve a user's feed 26 | - Write a tweet to all relevant feeds -------------------------------------------------------------------------------- /services/feeds/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/config" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/service" 9 | "twitter-go/services/feeds/internal" 10 | ) 11 | 12 | func main() { 13 | 14 | logger.Init() 15 | 16 | config := config.NewServiceConfig() 17 | 18 | amqp, err := amqp.NewClient(config.AmqpURL) 19 | 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | cassandra, err := cassandra.NewClient(config.CassandraURL, config.CassandraKeyspace) 25 | 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | svc := service.NewService("Feeds", amqp, cassandra, config) 31 | 32 | svc.Init(internal.Repliers, internal.Consumers) 33 | } 34 | -------------------------------------------------------------------------------- /services/feeds/internal/handlers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "twitter-go/services/common/amqp" 6 | "twitter-go/services/common/logger" 7 | "twitter-go/services/common/service" 8 | "twitter-go/services/common/types" 9 | ) 10 | 11 | // GetMyFeedHandler handles requests to retrieve an activty feed for a particular user 12 | func GetMyFeedHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 13 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 14 | var getMyFeed types.GetMyFeed 15 | 16 | if err := json.Unmarshal(msg, &getMyFeed); err != nil { 17 | return amqp.HandleInternalServiceError(err, nil) 18 | } 19 | 20 | logger.Info(logger.Loggable{Message: "Retrieving user feed", Data: getMyFeed}) 21 | 22 | repo := NewRepository(s.Cassandra) 23 | feed, err := repo.GetFeed(getMyFeed.Username) 24 | if err != nil { 25 | return amqp.HandleInternalServiceError(err, getMyFeed) 26 | } 27 | 28 | body, err := json.Marshal(feed) 29 | if err != nil { 30 | return amqp.HandleInternalServiceError(err, feed) 31 | } 32 | 33 | logger.Info(logger.Loggable{Message: "Retrieving user feed ok", Data: feed}) 34 | 35 | return &amqp.OkResponse{Body: body}, nil 36 | } 37 | } 38 | 39 | // AddTweetToFeedHandler handles broadcasts to add a tweet to the feed of 40 | // all followers of a particular user 41 | func AddTweetToFeedHandler(s *service.Service) func([]byte) { 42 | return func(msg []byte) { 43 | var addTweetToFeed types.AddTweetToFeed 44 | 45 | if err := json.Unmarshal(msg, &addTweetToFeed); err != nil { 46 | logger.Error(logger.Loggable{ 47 | Message: err.Error(), 48 | }) 49 | return 50 | } 51 | 52 | logger.Info(logger.Loggable{Message: "Adding tweet to feeds", Data: addTweetToFeed}) 53 | 54 | // find all users subscribed to tweetUsername 55 | getUserFollowers := types.GetUserFollowers{Username: addTweetToFeed.TweetUsername} 56 | 57 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.GetAllUserFollowers, []string{getUserFollowers.Username}, getUserFollowers) 58 | 59 | if errorResponse != nil { 60 | logger.Error(logger.Loggable{ 61 | Message: errorResponse.Message, 62 | Data: getUserFollowers, 63 | }) 64 | return 65 | } 66 | 67 | followers := types.Followers{} 68 | 69 | if err := json.Unmarshal(okResponse.Body, &followers); err != nil { 70 | logger.Error(logger.Loggable{ 71 | Message: err.Error(), 72 | }) 73 | return 74 | } 75 | 76 | logger.Info(logger.Loggable{Message: "Received getAllUserFollowers response", Data: followers}) 77 | 78 | // for each user, upsert the tweet to their feed 79 | repo := NewRepository(s.Cassandra) 80 | 81 | for _, follower := range followers { 82 | feedItem := types.FeedItem{ 83 | Username: addTweetToFeed.TweetUsername, 84 | ID: addTweetToFeed.TweetID, 85 | Content: addTweetToFeed.TweetContent, 86 | CreatedAt: addTweetToFeed.TweetCreatedAt, 87 | } 88 | err := repo.WriteToFeed(follower.Username, feedItem) 89 | if err != nil { 90 | logger.Error(logger.Loggable{ 91 | Message: err.Error(), 92 | Data: feedItem, 93 | }) 94 | } 95 | } 96 | 97 | logger.Info(logger.Loggable{Message: "Adding tweet to feeds ok", Data: nil}) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /services/feeds/internal/repository.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "time" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/service" 7 | "twitter-go/services/common/types" 8 | 9 | "github.com/gocql/gocql" 10 | ) 11 | 12 | // Repository is the feed service's wrapper around database access 13 | type Repository struct { 14 | service.Repository 15 | } 16 | 17 | // NewRepository constructs a new repository 18 | func NewRepository(cassandra *cassandra.Client) *Repository { 19 | return &Repository{ 20 | service.Repository{ 21 | Cassandra: cassandra, 22 | }, 23 | } 24 | } 25 | 26 | // GetFeed retrieves the feed of tweets for a particular user 27 | func (r *Repository) GetFeed(feedUsername string) (feed types.Feed, err error) { 28 | var id gocql.UUID 29 | var username string 30 | var content string 31 | var createdAt time.Time 32 | 33 | query := r.Query(` 34 | SELECT 35 | tweet_id, 36 | tweet_username, 37 | tweet_content, 38 | tweet_created_at 39 | FROM 40 | feed_items 41 | WHERE 42 | username = ?`, 43 | feedUsername, 44 | ) 45 | 46 | iter := query.Iter() 47 | 48 | for iter.Scan(&id, &username, &content, &createdAt) { 49 | feedItem := types.FeedItem{ 50 | ID: id, 51 | Username: username, 52 | Content: content, 53 | CreatedAt: createdAt, 54 | } 55 | feed = append(feed, feedItem) 56 | } 57 | if err := iter.Close(); err != nil { 58 | return nil, err 59 | } 60 | 61 | if feed == nil { 62 | feed = types.Feed{} 63 | } 64 | 65 | return feed, nil 66 | } 67 | 68 | // WriteToFeed writes a feed item to a particular user's feed 69 | func (r *Repository) WriteToFeed(followerUsername string, item types.FeedItem) error { 70 | query := r.Query(` 71 | INSERT INTO feed_items 72 | (username, tweet_created_at, tweet_content, tweet_id, tweet_username) 73 | VALUES 74 | (?, ?, ?, ?, ?) 75 | `, 76 | followerUsername, item.CreatedAt, item.Content, item.ID.String(), item.Username, 77 | ) 78 | 79 | err := query.Exec() 80 | 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /services/feeds/internal/routes.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/service" 6 | ) 7 | 8 | // Repliers maps routing keys to handlers 9 | var Repliers = service.Repliers{ 10 | service.Replier{ 11 | RoutingKey: amqp.GetMyFeedKey, 12 | Handler: GetMyFeedHandler, 13 | }, 14 | } 15 | 16 | // Consumers maps routing keys to consumers 17 | var Consumers = service.Consumers{ 18 | service.Consumer{ 19 | RoutingKey: amqp.CreatedTweetKey, 20 | Handler: AddTweetToFeedHandler, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /services/followers/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/followers services/followers 9 | RUN go build -ldflags="-s -w" -o app services/followers/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/followers/README.md: -------------------------------------------------------------------------------- 1 | ## Followers service 2 | 3 | The followers service provides functionality for managing user-following and user-follower relationships. Each user follows 0 or more users, subscribing to their tweets for their tweet feed. Correspondingly, each user also has 0 or more followers 4 | 5 | #### What is a follower/following in the data model? 6 | 7 | ``` 8 | user_followers ( 9 | username text, 10 | follower_username text 11 | PRIMARY KEY (username, follower_username) 12 | ); 13 | 14 | user_followings ( 15 | username text, 16 | following_username text 17 | PRIMARY KEY (username, follower_username) 18 | ); 19 | ``` 20 | 21 | #### Access patterns 22 | 23 | - Retrieve all the users a user is following from the `user_followers` table 24 | - E.g, to support a "my followers" list 25 | 26 | - Retrieve a count of the number of users a user is following from the `user_followers` 27 | - E.g, to support a count of the # of followers a user has 28 | 29 | - Retrieve all the users following a user from the `user_followings` table 30 | - E.g, to support a "i'm following" list 31 | - E.g, for finding all the users subscribed to a particular user's tweets 32 | 33 | - Retreive a count of the number of users following a user from the `user_followings` table 34 | - E.g, to support a count of the # of users a particular user is following -------------------------------------------------------------------------------- /services/followers/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/config" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/service" 9 | "twitter-go/services/followers/internal" 10 | ) 11 | 12 | func main() { 13 | 14 | logger.Init() 15 | 16 | config := config.NewServiceConfig() 17 | 18 | amqp, err := amqp.NewClient(config.AmqpURL) 19 | 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | cassandra, err := cassandra.NewClient(config.CassandraURL, config.CassandraKeyspace) 25 | 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | svc := service.NewService("Followers", amqp, cassandra, config) 31 | 32 | svc.Init(internal.Repliers, service.Consumers{}) 33 | } 34 | -------------------------------------------------------------------------------- /services/followers/internal/handlers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "twitter-go/services/common/amqp" 6 | "twitter-go/services/common/logger" 7 | "twitter-go/services/common/service" 8 | "twitter-go/services/common/types" 9 | ) 10 | 11 | // FollowUserHandler handles requests to follow a user 12 | func FollowUserHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 13 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 14 | var followUser types.FollowUser 15 | 16 | if err := json.Unmarshal(msg, &followUser); err != nil { 17 | return amqp.HandleInternalServiceError(err, nil) 18 | } 19 | 20 | logger.Info(logger.Loggable{Message: "Following user", Data: followUser}) 21 | 22 | repo := NewRepository(s.Cassandra) 23 | 24 | err := repo.FollowUser(followUser.Username, followUser.FollowingUsername) 25 | if err != nil { 26 | return amqp.HandleInternalServiceError(err, followUser) 27 | } 28 | 29 | body, err := json.Marshal(followUser) 30 | if err != nil { 31 | return amqp.HandleInternalServiceError(err, followUser) 32 | } 33 | 34 | logger.Info(logger.Loggable{Message: "Following user ok", Data: nil}) 35 | 36 | return &amqp.OkResponse{Body: body}, nil 37 | } 38 | } 39 | 40 | // GetUserFollowersHandler handles requests to retrieve all followers of a user 41 | func GetUserFollowersHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 42 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 43 | var getUserFollowers types.GetUserFollowers 44 | 45 | if err := json.Unmarshal(msg, &getUserFollowers); err != nil { 46 | return amqp.HandleInternalServiceError(err, getUserFollowers) 47 | } 48 | 49 | logger.Info(logger.Loggable{Message: "Getting user followers", Data: getUserFollowers}) 50 | 51 | repo := NewRepository(s.Cassandra) 52 | 53 | followers, err := repo.GetUserFollowers(getUserFollowers.Username) 54 | if err != nil { 55 | return amqp.HandleInternalServiceError(err, getUserFollowers) 56 | } 57 | 58 | body, err := json.Marshal(followers) 59 | if err != nil { 60 | return amqp.HandleInternalServiceError(err, getUserFollowers) 61 | } 62 | 63 | logger.Info(logger.Loggable{Message: "Get user followers ok", Data: followers}) 64 | 65 | return &amqp.OkResponse{Body: body}, nil 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /services/followers/internal/repository.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/cassandra" 5 | "twitter-go/services/common/service" 6 | "twitter-go/services/common/types" 7 | ) 8 | 9 | // Repository is the followers service's wrapper around database access 10 | type Repository struct { 11 | service.Repository 12 | } 13 | 14 | // NewRepository constructs a new repository 15 | func NewRepository(cassandra *cassandra.Client) *Repository { 16 | return &Repository{ 17 | service.Repository{ 18 | Cassandra: cassandra, 19 | }, 20 | } 21 | } 22 | 23 | // FollowUser registeres a follower and followee in the database, for two particular users 24 | func (r *Repository) FollowUser(username string, followingUsername string) error { 25 | query := r.Query( 26 | "INSERT INTO user_followings (username, following_username) VALUES (?, ?)", 27 | username, followingUsername, 28 | ) 29 | 30 | err := query.Exec() 31 | 32 | if err != nil { 33 | return err 34 | } 35 | 36 | query = r.Query( 37 | "INSERT INTO user_followers (username, follower_username) VALUES (?, ?)", 38 | followingUsername, username, 39 | ) 40 | 41 | err = query.Exec() 42 | 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // GetUserFollowers retrieves all the usernames of a user's followers 51 | func (r *Repository) GetUserFollowers(followedUsername string) (followers types.Followers, err error) { 52 | var username string 53 | 54 | query := r.Query(` 55 | SELECT 56 | follower_username 57 | FROM 58 | user_followers 59 | WHERE 60 | username = ? 61 | `, 62 | followedUsername, 63 | ) 64 | 65 | iter := query.Iter() 66 | 67 | for iter.Scan(&username) { 68 | follower := types.Follower{ 69 | Username: username, 70 | } 71 | followers = append(followers, follower) 72 | } 73 | 74 | if err := iter.Close(); err != nil { 75 | return nil, err 76 | } 77 | 78 | if followers == nil { 79 | followers = types.Followers{} 80 | } 81 | 82 | return followers, nil 83 | } 84 | -------------------------------------------------------------------------------- /services/followers/internal/routes.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/service" 6 | ) 7 | 8 | // Repliers maps routing keys to handlers 9 | var Repliers = service.Repliers{ 10 | service.Replier{ 11 | RoutingKey: amqp.FollowUserKey, 12 | Handler: FollowUserHandler, 13 | }, 14 | service.Replier{ 15 | RoutingKey: amqp.GetAllUserFollowers, 16 | Handler: GetUserFollowersHandler, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /services/gateway/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/gateway services/gateway 9 | RUN go build -ldflags="-s -w" -o app services/gateway/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/gateway/README.md: -------------------------------------------------------------------------------- 1 | ## API Gateway 2 | 3 | The API gateway is the entry point to the backend. It defines the api surface for the system, assembles responses via rpc to one or many services, and handles global concerns such as authorization. Having an API gateway makes service discovery simple - only the gateway needs to be exposed to the public internet, and all rpc calls can be dispatched to the event bus (RabbitMQ), which will handle routing and response logic. However, it does introduce a single point of failure in the system - if the gateway goes down, the backend will no longer be able to respond to incoming requests. 4 | 5 | #### API Schema 6 | 7 | ##### Users 8 | 9 | - `POST /users` 10 | - Create a new user 11 | - `POST /users/authorize` 12 | - Authorize a user (username + password login flow) 13 | - `POST /users/reauthorize` 14 | - TODO 15 | 16 | ##### Tweets 17 | 18 | - `POST /tweets` 19 | - Create a new tweet for the logged in user 20 | - `GET /tweets/me` 21 | - Get tweets posted by the logged in user 22 | - `GET /tweets/$username` 23 | - Get tweets posted by the specified $username 24 | 25 | ##### Followers 26 | 27 | - `POST /follow` 28 | - Follow a user 29 | - `GET /followers` 30 | - TODO 31 | - `GET /followers/count` 32 | - TODO 33 | - `GET /following` 34 | - TODO 35 | - `GET /following/count` 36 | - TODO 37 | 38 | ##### Feeds 39 | 40 | - `GET /feeds/me` 41 | - Retrieve a user's feed, which is a collection of tweets from those they've subscribed to -------------------------------------------------------------------------------- /services/gateway/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/logger" 6 | "twitter-go/services/gateway/internal/core" 7 | "twitter-go/services/gateway/internal/feeds" 8 | "twitter-go/services/gateway/internal/followers" 9 | "twitter-go/services/gateway/internal/tweets" 10 | "twitter-go/services/gateway/internal/users" 11 | ) 12 | 13 | func main() { 14 | 15 | logger.Init() 16 | 17 | // load all the required env values 18 | config := core.NewConfig() 19 | 20 | router := core.NewRouter() 21 | 22 | amqp, err := amqp.NewClient(config.AmqpURL) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | // initialize the gateway object 28 | // values in this struct are available to all handlers 29 | gateway := core.NewGateway(router, amqp, config) 30 | 31 | // initialize exported routes from packages 32 | routes := []core.Routes{ 33 | users.Routes, 34 | tweets.Routes, 35 | followers.Routes, 36 | feeds.Routes, 37 | } 38 | var appRoutes []core.Route 39 | for _, r := range routes { 40 | appRoutes = append(appRoutes, r...) 41 | } 42 | 43 | // initialize the application given our routes 44 | gateway.Init(appRoutes) 45 | } 46 | -------------------------------------------------------------------------------- /services/gateway/internal/core/config.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "twitter-go/services/common/config" 6 | ) 7 | 8 | // GatewayConfig defines the shape of the configuration values used across the api 9 | type GatewayConfig struct { 10 | *config.Config 11 | HmacSecret []byte 12 | } 13 | 14 | // NewConfig returns the default configuration values used across the api 15 | func NewConfig() *GatewayConfig { 16 | // set defaults - these can be overwritten via command line 17 | 18 | if os.Getenv("GO_ENV") == "" { 19 | os.Setenv("GO_ENV", "development") 20 | } 21 | 22 | if os.Getenv("PORT") == "" { 23 | os.Setenv("PORT", "3000") 24 | } 25 | 26 | if os.Getenv("AMQP_URL") == "" { 27 | os.Setenv("AMQP_URL", "amqp://rabbitmq:rabbitmq@localhost:5672") 28 | } 29 | 30 | if os.Getenv("LOG_LEVEL") == "" { 31 | os.Setenv("LOG_LEVEL", "debug") 32 | } 33 | 34 | if os.Getenv("HMAC_SECRET") == "" { 35 | // TODO: real hmac secret in helm/ENV 36 | os.Setenv("HMAC_SECRET", "hmacsecret") 37 | } 38 | 39 | return &GatewayConfig{ 40 | Config: &config.Config{ 41 | Env: os.Getenv("GO_ENV"), 42 | Port: os.Getenv("PORT"), 43 | AmqpURL: os.Getenv("AMQP_URL"), 44 | LogLevel: os.Getenv("LOG_LEVEL"), 45 | }, 46 | HmacSecret: []byte(os.Getenv("HMAC_SECRET")), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /services/gateway/internal/core/error.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | const ( 4 | // UnprocessableEntity maps to the default string for a 412 response 5 | UnprocessableEntity = "Unprocessable request sent." 6 | // BadRequest maps to the default string for a 400 response 7 | BadRequest = "Bad request sent." 8 | // Forbidden maps to the default string for a 403 response 9 | Forbidden = "Forbidden." 10 | // InternalServerError maps to the default string for a 500 response 11 | InternalServerError = "Something went wrong." 12 | // NotFound maps to the default string for a 401 response 13 | NotFound = "Resource not found." 14 | ) 15 | -------------------------------------------------------------------------------- /services/gateway/internal/core/gateway.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "twitter-go/services/common/amqp" 8 | "twitter-go/services/common/healthz" 9 | "twitter-go/services/common/metrics" 10 | 11 | "github.com/gorilla/handlers" 12 | "github.com/gorilla/mux" 13 | ) 14 | 15 | // Gateway holds the essential shared dependencies of the service 16 | type Gateway struct { 17 | Router *mux.Router 18 | GatewayConfig *GatewayConfig 19 | Amqp *amqp.Client 20 | } 21 | 22 | // NewGateway constructs a new instance of a server 23 | func NewGateway(router *mux.Router, amqp *amqp.Client, config *GatewayConfig) *Gateway { 24 | return &Gateway{ 25 | Router: router, 26 | Amqp: amqp, 27 | GatewayConfig: config, 28 | } 29 | } 30 | 31 | // Init applies the middleware stack, registers route handlers, and serves the application 32 | func (s *Gateway) Init(routes Routes) { 33 | s.wire(routes) 34 | s.serve() 35 | } 36 | 37 | func (s *Gateway) serve() { 38 | port := fmt.Sprintf(":%s", s.GatewayConfig.Port) 39 | if s.GatewayConfig.Env != "testing" { 40 | fmt.Printf("Gateway listening on port: %s\n", port) 41 | } 42 | log.Fatal(http.ListenAndServe(port, s.Router)) 43 | } 44 | 45 | func (s *Gateway) wire(routes Routes) { 46 | for _, route := range routes { 47 | handler := Chain(route.HandlerFunc(s), CheckAuthentication(route.AuthRequired, s.GatewayConfig.HmacSecret), LogRequest(route.Name)) 48 | 49 | s.Router. 50 | Methods(route.Method). 51 | Path(route.Pattern). 52 | Name(route.Name). 53 | Handler(handler) 54 | 55 | headersOk := handlers.AllowedHeaders([]string{"Authorization"}) 56 | originsOk := handlers.AllowedOrigins([]string{"*"}) 57 | methodsOk := handlers.AllowedMethods([]string{"GET", "POST", "OPTIONS"}) 58 | 59 | handlers.CORS(originsOk, headersOk, methodsOk)(s.Router) 60 | } 61 | 62 | healthz.WireToRouter(s.Router) 63 | metrics.WireToRouter(s.Router) 64 | } 65 | -------------------------------------------------------------------------------- /services/gateway/internal/core/http.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type apiResponse struct { 9 | Status int `json:"status"` 10 | Data interface{} `json:"data"` 11 | Error string `json:"error"` 12 | } 13 | 14 | // Ok issues a 200 response in a uniform format across the api 15 | func Ok(w http.ResponseWriter, data interface{}) { 16 | status := 200 17 | response := apiResponse{ 18 | Status: status, 19 | Data: data, 20 | } 21 | w.Header().Set("Content-Type", "application/json") 22 | w.WriteHeader(status) 23 | json.NewEncoder(w).Encode(response) 24 | } 25 | 26 | // Error issues a 4xx or 5xx response in a uniform format across the api 27 | func Error(w http.ResponseWriter, status int, err string) { 28 | response := apiResponse{ 29 | Status: status, 30 | Error: err, 31 | } 32 | w.Header().Set("Content-Type", "application/json") 33 | w.WriteHeader(status) 34 | json.NewEncoder(w).Encode(response) 35 | } 36 | -------------------------------------------------------------------------------- /services/gateway/internal/core/logger.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "net/http" 4 | 5 | // HTTPLogWriter writes logs 6 | type HTTPLogWriter struct { 7 | http.ResponseWriter 8 | status int 9 | length int 10 | body []byte 11 | } 12 | 13 | // WriteHeader writes to an response's header 14 | func (w *HTTPLogWriter) WriteHeader(status int) { 15 | w.status = status 16 | w.ResponseWriter.WriteHeader(status) 17 | } 18 | 19 | // Write writes a response 20 | func (w *HTTPLogWriter) Write(body []byte) (int, error) { 21 | if w.status == 0 { 22 | w.status = 200 23 | } 24 | n, err := w.ResponseWriter.Write(body) 25 | w.length += n 26 | w.body = body 27 | return n, err 28 | } 29 | -------------------------------------------------------------------------------- /services/gateway/internal/core/middlewares.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "twitter-go/services/common/logger" 12 | 13 | jwt "github.com/dgrijalva/jwt-go" 14 | ) 15 | 16 | // Middleware defines the type signature for a middleware 17 | type Middleware func(http.HandlerFunc) http.HandlerFunc 18 | 19 | // Chain applies middlewares to a http.HandlerFunc 20 | func Chain(f http.HandlerFunc, middlewares ...Middleware) http.HandlerFunc { 21 | for _, m := range middlewares { 22 | f = m(f) 23 | } 24 | return f 25 | } 26 | 27 | // CheckAuthentication parses the Authorization header for a valid token, 28 | // populating the request context with the username encoded in the token 29 | func CheckAuthentication(authRequired bool, hmacSecret []byte) Middleware { 30 | return func(f http.HandlerFunc) http.HandlerFunc { 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | // TODO: flag to disable for dev 33 | // check if route is guarded by require auth 34 | if authRequired == false { 35 | f(w, r) 36 | return 37 | } 38 | 39 | // get token 40 | bearerTokenString := r.Header.Get("Authorization") 41 | if bearerTokenString == "" { 42 | Error(w, http.StatusUnauthorized, Forbidden) 43 | return 44 | } 45 | split := strings.Split(bearerTokenString, "Bearer ") 46 | if len(split) < 2 { 47 | Error(w, http.StatusUnauthorized, Forbidden) 48 | return 49 | } 50 | token := split[1] 51 | 52 | // parse token 53 | username, err := parseToken(token, hmacSecret) 54 | if err != nil { 55 | Error(w, http.StatusInternalServerError, InternalServerError) 56 | return 57 | } 58 | 59 | // attach user obj to request for req.usermame 60 | ctx := context.WithValue(r.Context(), "username", username) 61 | 62 | // next 63 | f(w, r.WithContext(ctx)) 64 | } 65 | } 66 | 67 | } 68 | 69 | // LogRequest writes request and response metadata to std output 70 | func LogRequest(name string) Middleware { 71 | return func(f http.HandlerFunc) http.HandlerFunc { 72 | return func(w http.ResponseWriter, r *http.Request) { 73 | start := time.Now() 74 | lw := HTTPLogWriter{ResponseWriter: w} 75 | f(&lw, r) 76 | duration := time.Since(start) 77 | username := r.Context().Value("username") 78 | info := logger.Loggable{ 79 | Data: map[string]interface{}{ 80 | "host": r.Host, 81 | "remoteAddr": r.RemoteAddr, 82 | "method": r.Method, 83 | "requestURI": r.RequestURI, 84 | "userAgent": r.Header.Get("User-Agent"), 85 | "responseDuration": duration, 86 | "username": username, 87 | "responseBody": string(lw.body), 88 | "responseStatus": lw.status, 89 | "length": lw.length, 90 | }, 91 | Message: "Logging HTTP request.", 92 | } 93 | logger.Info(info) 94 | } 95 | } 96 | } 97 | 98 | func parseToken(tokenString string, hmacSecret []byte) (username string, err error) { 99 | hmacFunc := func(token *jwt.Token) (interface{}, error) { 100 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 101 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 102 | } 103 | return hmacSecret, nil 104 | } 105 | 106 | token, err := jwt.Parse(tokenString, hmacFunc) 107 | if err != nil { 108 | return username, errors.New("Error parsing token") 109 | } 110 | 111 | claims, ok := token.Claims.(jwt.MapClaims) 112 | if !ok { 113 | return username, errors.New("Error parsing token") 114 | } 115 | 116 | username = string(claims["username"].(string)) 117 | if err != nil { 118 | return username, err 119 | } 120 | 121 | return username, nil 122 | } 123 | -------------------------------------------------------------------------------- /services/gateway/internal/core/router.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | // GatewayFunc defines the shape of handler fns on routes. 10 | // Gateway is injected for common access to routes/db/logger/etc 11 | type GatewayFunc func(s *Gateway) http.HandlerFunc 12 | 13 | // Route defines the shape of a route 14 | type Route struct { 15 | Name string 16 | Method string 17 | Pattern string 18 | AuthRequired bool 19 | HandlerFunc GatewayFunc 20 | } 21 | 22 | // Routes defines the shape of an array of routes 23 | type Routes []Route 24 | 25 | // NewRouter returns a router ptr 26 | func NewRouter() *mux.Router { 27 | return mux.NewRouter().StrictSlash(true) 28 | } 29 | -------------------------------------------------------------------------------- /services/gateway/internal/core/util.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "net/http" 4 | 5 | // GetUsernameFromRequest retrieves the username from the current request's context 6 | func GetUsernameFromRequest(r *http.Request) string { 7 | return r.Context().Value("username").(string) 8 | } 9 | -------------------------------------------------------------------------------- /services/gateway/internal/feeds/handlers.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "twitter-go/services/common/amqp" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/types" 9 | "twitter-go/services/gateway/internal/core" 10 | ) 11 | 12 | // GetMyFeedHandler provides a HandlerFunc for retrieving a user's feed 13 | func GetMyFeedHandler(s *core.Gateway) http.HandlerFunc { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | jwtUsername := core.GetUsernameFromRequest(r) 16 | 17 | getFeedDto := types.GetMyFeed{Username: jwtUsername} 18 | 19 | logger.Info(logger.Loggable{Message: "Getting user feed", Data: getFeedDto}) 20 | 21 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.GetMyFeedKey, []string{getFeedDto.Username}, getFeedDto) 22 | 23 | if errorResponse != nil { 24 | core.Error(w, errorResponse.Status, errorResponse.Message) 25 | return 26 | } 27 | 28 | feed := make([]map[string]interface{}, 0) 29 | 30 | if err := json.Unmarshal(okResponse.Body, &feed); err != nil { 31 | core.Error(w, http.StatusInternalServerError, core.InternalServerError) 32 | return 33 | } 34 | 35 | core.Ok(w, feed) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /services/gateway/internal/feeds/routes.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import "twitter-go/services/gateway/internal/core" 4 | 5 | // Routes defines the shape of all the routes for the feeds package 6 | var Routes = core.Routes{ 7 | core.Route{ 8 | Name: "GetMyFeed", 9 | Method: "GET", 10 | Pattern: "/feeds/me", 11 | AuthRequired: true, 12 | HandlerFunc: GetMyFeedHandler, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /services/gateway/internal/followers/handlers.go: -------------------------------------------------------------------------------- 1 | package followers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "twitter-go/services/common/amqp" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/types" 9 | "twitter-go/services/gateway/internal/core" 10 | ) 11 | 12 | // FollowUserHandler provides a HandlerFunc for following a user 13 | func FollowUserHandler(s *core.Gateway) http.HandlerFunc { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | jwtUsername := core.GetUsernameFromRequest(r) 16 | followUserDto := types.FollowUser{} 17 | 18 | defer r.Body.Close() 19 | if err := json.NewDecoder(r.Body).Decode(&followUserDto); err != nil { 20 | core.Error(w, http.StatusBadRequest, core.BadRequest) 21 | return 22 | } 23 | 24 | followUserDto.Username = jwtUsername 25 | 26 | logger.Info(logger.Loggable{Message: "Follow user request", Data: followUserDto}) 27 | 28 | if err := followUserDto.Validate(); err != nil { 29 | core.Error(w, http.StatusUnprocessableEntity, err.Error()) 30 | return 31 | } 32 | 33 | existsUserDto := types.DoesExist{ 34 | Username: followUserDto.FollowingUsername, 35 | } 36 | 37 | // Check that the user exists 38 | _, errorResponse := s.Amqp.DirectRequest(amqp.ExistsUserKey, []string{followUserDto.Username}, existsUserDto) 39 | 40 | if errorResponse != nil { 41 | core.Error(w, errorResponse.Status, errorResponse.Message) 42 | return 43 | } 44 | 45 | // Follow the user 46 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.FollowUserKey, []string{jwtUsername}, followUserDto) 47 | 48 | if errorResponse != nil { 49 | core.Error(w, errorResponse.Status, errorResponse.Message) 50 | return 51 | } 52 | 53 | relationship := make(map[string]interface{}) 54 | 55 | if err := json.Unmarshal(okResponse.Body, &relationship); err != nil { 56 | core.Error(w, http.StatusUnprocessableEntity, core.UnprocessableEntity) 57 | return 58 | } 59 | 60 | core.Ok(w, relationship) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /services/gateway/internal/followers/routes.go: -------------------------------------------------------------------------------- 1 | package followers 2 | 3 | import "twitter-go/services/gateway/internal/core" 4 | 5 | // Routes defines the shape of all the routes for the users package 6 | var Routes = core.Routes{ 7 | core.Route{ 8 | Name: "FollowUser", 9 | Method: "POST", 10 | Pattern: "/follow", 11 | AuthRequired: true, 12 | HandlerFunc: FollowUserHandler, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /services/gateway/internal/tweets/handlers.go: -------------------------------------------------------------------------------- 1 | package tweets 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "twitter-go/services/common/amqp" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/types" 9 | "twitter-go/services/gateway/internal/core" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | // CreateTweetHandler provides a HandlerFunc for creating a tweet 15 | func CreateTweetHandler(s *core.Gateway) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | createTweetDto := types.CreateTweet{} 18 | jwtUsername := core.GetUsernameFromRequest(r) 19 | 20 | defer r.Body.Close() 21 | if err := json.NewDecoder(r.Body).Decode(&createTweetDto); err != nil { 22 | core.Error(w, http.StatusBadRequest, core.BadRequest) 23 | return 24 | } 25 | 26 | if jwtUsername == "" { 27 | core.Error(w, http.StatusForbidden, core.Forbidden) 28 | return 29 | } 30 | 31 | createTweetDto.Username = jwtUsername 32 | 33 | if err := createTweetDto.Validate(); err != nil { 34 | core.Error(w, http.StatusUnprocessableEntity, err.Error()) 35 | return 36 | } 37 | 38 | logger.Info(logger.Loggable{Message: "Create tweet request", Data: createTweetDto}) 39 | 40 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.CreateTweetKey, []string{createTweetDto.Username}, createTweetDto) 41 | 42 | if errorResponse != nil { 43 | core.Error(w, errorResponse.Status, errorResponse.Message) 44 | return 45 | } 46 | 47 | tweet := make(map[string]interface{}) 48 | if err := json.Unmarshal(okResponse.Body, &tweet); err != nil { 49 | core.Error(w, http.StatusInternalServerError, core.InternalServerError) 50 | return 51 | } 52 | 53 | core.Ok(w, tweet) 54 | } 55 | } 56 | 57 | // GetAllUserTweetsHandler provides a HandlerFunc for retrieving all tweets made by a user 58 | func GetAllUserTweetsHandler(s *core.Gateway) http.HandlerFunc { 59 | return func(w http.ResponseWriter, r *http.Request) { 60 | vars := mux.Vars(r) 61 | username := vars["username"] 62 | 63 | getAllUserTweetsDto := types.GetAllUserTweets{ 64 | Username: username, 65 | } 66 | 67 | existsUserDto := types.DoesExist{ 68 | Username: username, 69 | } 70 | 71 | if err := getAllUserTweetsDto.Validate(); err != nil { 72 | core.Error(w, http.StatusBadRequest, core.BadRequest) 73 | return 74 | } 75 | 76 | // Check that the user exists 77 | _, errorResponse := s.Amqp.DirectRequest(amqp.ExistsUserKey, []string{getAllUserTweetsDto.Username}, existsUserDto) 78 | 79 | if errorResponse != nil { 80 | core.Error(w, errorResponse.Status, errorResponse.Message) 81 | return 82 | } 83 | 84 | logger.Info(logger.Loggable{Message: "Get all user tweets request", Data: getAllUserTweetsDto}) 85 | 86 | // Get that user's tweets 87 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.GetAllUserTweetsKey, []string{getAllUserTweetsDto.Username}, getAllUserTweetsDto) 88 | 89 | if errorResponse != nil { 90 | core.Error(w, errorResponse.Status, errorResponse.Message) 91 | return 92 | } 93 | 94 | tweets := make([]map[string]interface{}, 0) 95 | 96 | if err := json.Unmarshal(okResponse.Body, &tweets); err != nil { 97 | core.Error(w, http.StatusInternalServerError, core.InternalServerError) 98 | return 99 | } 100 | 101 | core.Ok(w, tweets) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /services/gateway/internal/tweets/routes.go: -------------------------------------------------------------------------------- 1 | package tweets 2 | 3 | import "twitter-go/services/gateway/internal/core" 4 | 5 | // Routes defines the shape of all the routes for the users package 6 | var Routes = core.Routes{ 7 | core.Route{ 8 | Name: "CreateTweet", 9 | Method: "POST", 10 | Pattern: "/tweets", 11 | AuthRequired: true, 12 | HandlerFunc: CreateTweetHandler, 13 | }, 14 | core.Route{ 15 | Name: "GetAllUserTweets", 16 | Method: "GET", 17 | Pattern: "/tweets/{username}", 18 | AuthRequired: false, 19 | HandlerFunc: GetAllUserTweetsHandler, 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /services/gateway/internal/users/handlers.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "twitter-go/services/common/amqp" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/types" 9 | "twitter-go/services/gateway/internal/core" 10 | ) 11 | 12 | // CreateUserHandler provides a HandlerFunc for creating a new user. 13 | func CreateUserHandler(s *core.Gateway) http.HandlerFunc { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | createUserDto := types.CreateUser{} 16 | 17 | defer r.Body.Close() 18 | if err := json.NewDecoder(r.Body).Decode(&createUserDto); err != nil { 19 | core.Error(w, http.StatusBadRequest, core.BadRequest) 20 | return 21 | } 22 | 23 | if err := createUserDto.Validate(); err != nil { 24 | core.Error(w, http.StatusUnprocessableEntity, err.Error()) 25 | return 26 | } 27 | 28 | logger.Info(logger.Loggable{Message: "Create user request", Data: nil}) 29 | 30 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.CreateUserKey, []string{}, createUserDto) 31 | 32 | if errorResponse != nil { 33 | core.Error(w, errorResponse.Status, errorResponse.Message) 34 | return 35 | } 36 | 37 | user := make(map[string]interface{}) 38 | 39 | if err := json.Unmarshal(okResponse.Body, &user); err != nil { 40 | core.Error(w, http.StatusUnprocessableEntity, core.UnprocessableEntity) 41 | return 42 | } 43 | 44 | core.Ok(w, user) 45 | } 46 | } 47 | 48 | // AuthorizeHandler provides a HandlerFunc for the app authorization flow 49 | func AuthorizeHandler(s *core.Gateway) http.HandlerFunc { 50 | return func(w http.ResponseWriter, r *http.Request) { 51 | authenticateUserDto := types.AuthenticateUser{} 52 | 53 | defer r.Body.Close() 54 | if err := json.NewDecoder(r.Body).Decode(&authenticateUserDto); err != nil { 55 | core.Error(w, http.StatusBadRequest, core.BadRequest) 56 | return 57 | } 58 | 59 | if err := authenticateUserDto.Validate(); err != nil { 60 | core.Error(w, http.StatusUnprocessableEntity, err.Error()) 61 | return 62 | } 63 | 64 | logger.Info(logger.Loggable{Message: "Authorize user request", Data: nil}) 65 | 66 | okResponse, errorResponse := s.Amqp.DirectRequest(amqp.AuthorizeUserKey, []string{authenticateUserDto.Username}, authenticateUserDto) 67 | 68 | if errorResponse != nil { 69 | core.Error(w, errorResponse.Status, errorResponse.Message) 70 | return 71 | } 72 | 73 | authorization := make(map[string]interface{}) 74 | if err := json.Unmarshal(okResponse.Body, &authorization); err != nil { 75 | core.Error(w, http.StatusUnprocessableEntity, core.UnprocessableEntity) 76 | return 77 | } 78 | 79 | core.Ok(w, authorization) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /services/gateway/internal/users/routes.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "twitter-go/services/gateway/internal/core" 4 | 5 | // Routes defines the shape of all the routes for the users package 6 | var Routes = core.Routes{ 7 | core.Route{ 8 | Name: "CreateUser", 9 | Method: "POST", 10 | Pattern: "/users", 11 | AuthRequired: false, 12 | HandlerFunc: CreateUserHandler, 13 | }, 14 | core.Route{ 15 | Name: "AuthenticateUser", 16 | Method: "POST", 17 | Pattern: "/users/authorize", 18 | AuthRequired: false, 19 | HandlerFunc: AuthorizeHandler, 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /services/ready/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/ready services/ready 9 | RUN go build -ldflags="-s -w" -o app services/ready/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/ready/README.md: -------------------------------------------------------------------------------- 1 | ## Ready service 2 | 3 | This service is used as an init container during deployments to confirm RabbitMQ and Cassandra are ready to start accepting connections. My bash skills are lacking, so I opted to do this in Go :). As a consequence, it is nothing fancy. -------------------------------------------------------------------------------- /services/ready/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | "twitter-go/services/common/cassandra" 8 | 9 | "github.com/streadway/amqp" 10 | ) 11 | 12 | func main() { 13 | var amqpConn *amqp.Connection 14 | var cassandraClient *cassandra.Client 15 | 16 | amqpURL := os.Getenv("AMQP_URL") 17 | cassandraURL := os.Getenv("CASSANDRA_URL") 18 | cassandraKeyspace := os.Getenv("CASSANDRA_KEYSPACE") 19 | 20 | if amqpURL == "" { 21 | log.Fatalf("AMQP_URL not supplied, got %s", amqpURL) 22 | } 23 | 24 | if cassandraURL == "" { 25 | log.Fatalf("CASSANDRA_URL not supplied, got %s", cassandraURL) 26 | } 27 | 28 | if cassandraKeyspace == "" { 29 | log.Fatalf("CASSANDRA_KEYSPACE not supplied, got %s", cassandraKeyspace) 30 | } 31 | 32 | log.Printf("AMQP_URL: %s", amqpURL) 33 | log.Printf("CASSANDRA_URL: %s", cassandraURL) 34 | 35 | // Give ourselves 200 seconds (20 * 10s) - should we make this configurable? 36 | for i := 0; i < 20; i++ { 37 | if amqpConn == nil { 38 | log.Print("Attempting to dial rabbit") 39 | amqpConn, _ = amqp.Dial(amqpURL) 40 | if amqpConn != nil { 41 | log.Print("Rabbit OK") 42 | } 43 | } 44 | 45 | if cassandraClient == nil { 46 | log.Print("Attempting to connect to cassandra") 47 | cassandraClient, _ = cassandra.NewClient(cassandraURL, cassandraKeyspace) 48 | if cassandraClient != nil { 49 | log.Print("Cassandra OK") 50 | } 51 | } 52 | 53 | if amqpConn != nil && cassandraClient != nil { 54 | log.Print("Connected to rabbitmq and cassandra, exiting") 55 | os.Exit(0) 56 | } 57 | 58 | log.Printf("Sleeping...") 59 | time.Sleep(10 * time.Second) 60 | } 61 | 62 | log.Fatal("Failed to establish both connections, exiting") 63 | os.Exit(1) 64 | } 65 | -------------------------------------------------------------------------------- /services/tweets/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/tweets services/tweets 9 | RUN go build -ldflags="-s -w" -o app services/tweets/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/tweets/README.md: -------------------------------------------------------------------------------- 1 | ## Tweets service 2 | 3 | The tweets service provides functionality for creating and retrieving tweets. 4 | 5 | #### What is a tweet in the data model? 6 | 7 | ``` 8 | tweets ( 9 | id uuid PRIMARY KEY, 10 | username text, 11 | content text, 12 | created_at timestamp, 13 | ); 14 | 15 | tweets_by_user ( 16 | id uuid, 17 | username text, 18 | content text, 19 | created_at timestamp, 20 | PRIMARY KEY (username, created_at) 21 | ); 22 | ``` 23 | 24 | #### Access patterns 25 | 26 | - Get a tweet by ID from the `tweets` table 27 | - E.g, to see a tweet and its comments should this be supported 28 | - Get all tweets by username, sorted by created_at from the `tweets_by_user` table 29 | - E.g, to support a "my tweets" page for a given user 30 | 31 | -------------------------------------------------------------------------------- /services/tweets/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/config" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/service" 9 | "twitter-go/services/tweets/internal" 10 | ) 11 | 12 | func main() { 13 | 14 | logger.Init() 15 | 16 | config := config.NewServiceConfig() 17 | 18 | amqp, err := amqp.NewClient(config.AmqpURL) 19 | 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | cassandra, err := cassandra.NewClient(config.CassandraURL, config.CassandraKeyspace) 25 | 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | svc := service.NewService("Tweets", amqp, cassandra, config) 31 | 32 | svc.Init(internal.Repliers, service.Consumers{}) 33 | } 34 | -------------------------------------------------------------------------------- /services/tweets/internal/handlers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "twitter-go/services/common/amqp" 6 | "twitter-go/services/common/logger" 7 | "twitter-go/services/common/service" 8 | "twitter-go/services/common/types" 9 | ) 10 | 11 | // CreateHandler handles creating a tweet record 12 | func CreateHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 13 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 14 | var tweet types.Tweet 15 | 16 | if err := json.Unmarshal(msg, &tweet); err != nil { 17 | return amqp.HandleInternalServiceError(err, nil) 18 | } 19 | 20 | logger.Info(logger.Loggable{Message: "Creating tweet", Data: tweet}) 21 | 22 | tweet.PrepareForInsert() 23 | 24 | repo := NewRepository(s.Cassandra) 25 | if err := repo.Insert(tweet); err != nil { 26 | return amqp.HandleInternalServiceError(err, tweet) 27 | } 28 | 29 | logger.Info(logger.Loggable{Message: "Publishing tweet created event", Data: tweet}) 30 | 31 | s.Amqp.PublishToTopic(amqp.CreatedTweetKey, []string{tweet.Username}, tweet) 32 | 33 | body, _ := json.Marshal(tweet) 34 | 35 | logger.Info(logger.Loggable{Message: "Create tweet ok", Data: nil}) 36 | 37 | return &amqp.OkResponse{Body: body}, nil 38 | } 39 | } 40 | 41 | // GetAllHandler handles returning all tweets for a given username 42 | func GetAllHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 43 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 44 | var req types.GetAllUserTweets 45 | 46 | if err := json.Unmarshal(msg, &req); err != nil { 47 | return amqp.HandleInternalServiceError(err, req) 48 | } 49 | 50 | logger.Info(logger.Loggable{Message: "Getting all tweets", Data: req}) 51 | 52 | repo := NewRepository(s.Cassandra) 53 | 54 | tweets, err := repo.GetAll(req.Username) 55 | if err != nil { 56 | return amqp.HandleInternalServiceError(err, req) 57 | } 58 | 59 | body, _ := json.Marshal(tweets) 60 | 61 | logger.Info(logger.Loggable{Message: "Get all tweets ok", Data: tweets}) 62 | 63 | return &amqp.OkResponse{Body: body}, nil 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /services/tweets/internal/repository.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "time" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/service" 7 | "twitter-go/services/common/types" 8 | 9 | "github.com/gocql/gocql" 10 | ) 11 | 12 | // Repository is the tweet service's wrapper around database access 13 | type Repository struct { 14 | service.Repository 15 | } 16 | 17 | // NewRepository constructs a new repository 18 | func NewRepository(cassandra *cassandra.Client) *Repository { 19 | return &Repository{ 20 | service.Repository{ 21 | Cassandra: cassandra, 22 | }, 23 | } 24 | } 25 | 26 | // Insert creates tweet records to all relevant tables 27 | func (r *Repository) Insert(t types.Tweet) error { 28 | query := r.Query("INSERT INTO tweets (id, username, created_at, content) VALUES (?, ?, ?, ?)", t.ID.String(), t.Username, t.CreatedAt, t.Content) 29 | 30 | err := query.Exec() 31 | 32 | if err != nil { 33 | return err 34 | } 35 | 36 | query = r.Query("INSERT INTO tweets_by_user (id, username, created_at, content) VALUES (?, ?, ?, ?)", t.ID.String(), t.Username, t.CreatedAt, t.Content) 37 | 38 | err = query.Exec() 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // GetAll returns all tweets for the given username 48 | func (r *Repository) GetAll(username string) (tweets []types.Tweet, err error) { 49 | var id gocql.UUID 50 | var content string 51 | var createdAt time.Time 52 | 53 | query := r.Query("SELECT id, username, content, created_at FROM tweets_by_user WHERE username = ?", username) 54 | 55 | iter := query.Iter() 56 | 57 | for iter.Scan(&id, &username, &content, &createdAt) { 58 | tweet := types.Tweet{ 59 | ID: id, 60 | Username: username, 61 | Content: content, 62 | CreatedAt: createdAt, 63 | } 64 | tweets = append(tweets, tweet) 65 | } 66 | 67 | if err := iter.Close(); err != nil { 68 | return nil, err 69 | } 70 | 71 | // if none found, make it an empty array 72 | if tweets == nil { 73 | tweets = []types.Tweet{} 74 | } 75 | 76 | return tweets, nil 77 | } 78 | -------------------------------------------------------------------------------- /services/tweets/internal/routes.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/service" 6 | ) 7 | 8 | // Repliers maps routing keys to handlers 9 | var Repliers = service.Repliers{ 10 | service.Replier{ 11 | RoutingKey: amqp.CreateTweetKey, 12 | Handler: CreateHandler, 13 | }, 14 | service.Replier{ 15 | RoutingKey: amqp.GetAllUserTweetsKey, 16 | Handler: GetAllHandler, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /services/users/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM golang:1.12-alpine AS builder 3 | WORKDIR /app 4 | # https://github.com/docker-library/golang/issues/209 5 | RUN apk add --no-cache git 6 | COPY go.mod go.sum ./ 7 | COPY services/common services/common 8 | COPY services/users services/users 9 | RUN go build -ldflags="-s -w" -o app services/users/cmd/main.go 10 | 11 | # App 12 | FROM alpine AS final 13 | WORKDIR /app 14 | COPY --from=builder /app /app 15 | EXPOSE 3000 16 | ENTRYPOINT ./app -------------------------------------------------------------------------------- /services/users/README.md: -------------------------------------------------------------------------------- 1 | ## Users service 2 | 3 | The users service provides an interface for creating and authenticating accounts for the application. A user is the central domain unit of the application - it defines authorization roles (user can only create posts for themselves for example), authentication (someone can only access the app if they have a user record and valid password for that user record), and acts as a reference for most/all data generated in the application (a user has many posts, has many followers, has followed many users, etc). 4 | 5 | #### What is a user in the data model? 6 | 7 | ``` 8 | { 9 | username: text (pk), 10 | email: text (uniq), 11 | password: text, 12 | refresh_token: text 13 | } 14 | ``` 15 | 16 | #### Access patterns 17 | 18 | - Create a new user 19 | - Retrieve or issue a user's access token via a "login" (username and password) 20 | - TODO: Retrieve or issue a user's access token via a refresh token 21 | -------------------------------------------------------------------------------- /services/users/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/cassandra" 6 | "twitter-go/services/common/config" 7 | "twitter-go/services/common/logger" 8 | "twitter-go/services/common/service" 9 | "twitter-go/services/users/internal" 10 | ) 11 | 12 | func main() { 13 | logger.Init() 14 | 15 | config := config.NewServiceConfig() 16 | 17 | amqp, err := amqp.NewClient(config.AmqpURL) 18 | 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | cassandra, err := cassandra.NewClient(config.CassandraURL, config.CassandraKeyspace) 24 | 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | svc := service.NewService("Users", amqp, cassandra, config) 30 | 31 | svc.Init(internal.Repliers, service.Consumers{}) 32 | } 33 | -------------------------------------------------------------------------------- /services/users/internal/handlers.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "twitter-go/services/common/amqp" 7 | "twitter-go/services/common/auth" 8 | "twitter-go/services/common/logger" 9 | "twitter-go/services/common/service" 10 | "twitter-go/services/common/types" 11 | ) 12 | 13 | // CreateHandler handles creating a user record 14 | func CreateHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 15 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 16 | var user types.User 17 | 18 | if err := json.Unmarshal(msg, &user); err != nil { 19 | return amqp.HandleInternalServiceError(err, nil) 20 | } 21 | 22 | repo := NewRepository(s.Cassandra) 23 | 24 | exists, err := repo.Exists(user.Username) 25 | 26 | if err != nil { 27 | return amqp.HandleInternalServiceError(err, user) 28 | } 29 | 30 | if exists == true { 31 | return nil, &amqp.ErrorResponse{Message: "User already exists", Status: http.StatusConflict} 32 | } 33 | 34 | if err := user.PrepareForInsert(); err != nil { 35 | return amqp.HandleInternalServiceError(err, user) 36 | } 37 | 38 | if err := repo.Insert(user); err != nil { 39 | return amqp.HandleInternalServiceError(err, user) 40 | } 41 | 42 | accessToken, err := auth.GenerateToken(user.Username, s.Config.HMACSecret) 43 | if err != nil { 44 | return amqp.HandleInternalServiceError(err, user) 45 | } 46 | 47 | user.AccessToken = accessToken 48 | user.Sanitize() 49 | 50 | // Don't want to log user password ;) 51 | logger.Info(logger.Loggable{Message: "Create user ok", Data: user}) 52 | 53 | body, _ := json.Marshal(user) 54 | 55 | return &amqp.OkResponse{Body: body}, nil 56 | } 57 | 58 | } 59 | 60 | // AuthorizeHandler handles authorizing a user given their username and password 61 | func AuthorizeHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 62 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 63 | var authorizeDto types.Authorize 64 | 65 | if err := json.Unmarshal(msg, &authorizeDto); err != nil { 66 | return amqp.HandleInternalServiceError(err, nil) 67 | } 68 | 69 | logger.Info( 70 | logger.Loggable{ 71 | Message: "Authorizing user", 72 | Data: map[string]interface{}{"username": authorizeDto.Username}, 73 | }, 74 | ) 75 | 76 | // find user from given username 77 | repo := NewRepository(s.Cassandra) 78 | userRecord, err := repo.FindByUsername(authorizeDto.Username) 79 | if err != nil { 80 | return nil, &amqp.ErrorResponse{Message: "Not found", Status: http.StatusNotFound} 81 | } 82 | 83 | // compare password against hash 84 | if err := userRecord.CompareHashAndPassword(authorizeDto.Password); err != nil { 85 | return nil, &amqp.ErrorResponse{Message: "Invalid password provided", Status: http.StatusUnprocessableEntity} 86 | } 87 | 88 | // return new accessToken and refreshToken from record 89 | accessToken, err := auth.GenerateToken(authorizeDto.Username, s.Config.HMACSecret) 90 | if err != nil { 91 | return amqp.HandleInternalServiceError(err, authorizeDto) 92 | } 93 | 94 | authorized := types.Authorized{ 95 | RefreshToken: userRecord.RefreshToken, 96 | AccessToken: accessToken, 97 | } 98 | 99 | body, _ := json.Marshal(authorized) 100 | 101 | logger.Info( 102 | logger.Loggable{ 103 | Message: "Authorize user ok", 104 | Data: map[string]interface{}{"username": authorizeDto.Username}, 105 | }, 106 | ) 107 | 108 | return &amqp.OkResponse{Body: body}, nil 109 | } 110 | } 111 | 112 | // ExistsHandler verifies a user exists in the system 113 | func ExistsHandler(s *service.Service) func([]byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 114 | return func(msg []byte) (*amqp.OkResponse, *amqp.ErrorResponse) { 115 | var existsDto types.DoesExist 116 | 117 | if err := json.Unmarshal(msg, &existsDto); err != nil { 118 | return amqp.HandleInternalServiceError(err, nil) 119 | } 120 | 121 | logger.Info(logger.Loggable{Message: "Checking that user exists", Data: existsDto}) 122 | 123 | repo := NewRepository(s.Cassandra) 124 | 125 | exists, err := repo.Exists(existsDto.Username) 126 | 127 | if err != nil { 128 | return amqp.HandleInternalServiceError(err, existsDto) 129 | } 130 | 131 | if exists == false { 132 | return nil, &amqp.ErrorResponse{Message: "User not found", Status: http.StatusNotFound} 133 | } 134 | 135 | existsResponse := types.Exists{ 136 | Exists: true, 137 | } 138 | 139 | body, _ := json.Marshal(existsResponse) 140 | 141 | logger.Info(logger.Loggable{Message: "User exists ok", Data: existsResponse}) 142 | 143 | return &amqp.OkResponse{Body: body}, nil 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /services/users/internal/repository.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/cassandra" 5 | "twitter-go/services/common/service" 6 | "twitter-go/services/common/types" 7 | ) 8 | 9 | // Repository is the user service's wrapper around database access 10 | type Repository struct { 11 | service.Repository 12 | } 13 | 14 | // NewRepository constructs a new repository 15 | func NewRepository(cassandra *cassandra.Client) *Repository { 16 | return &Repository{ 17 | service.Repository{ 18 | Cassandra: cassandra, 19 | }, 20 | } 21 | } 22 | 23 | // Insert writes a new user to the database 24 | func (r *Repository) Insert(u types.User) error { 25 | query := r.Query("INSERT INTO users (username, email, password, refresh_token) VALUES (?, ?, ?, ?)", u.Username, u.Email, u.Password, u.RefreshToken) 26 | 27 | err := query.Exec() 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // FindByUsername retrieves a user record by username 37 | func (r *Repository) FindByUsername(username string) (types.User, error) { 38 | var user types.User 39 | var password string 40 | var refreshToken string 41 | var email string 42 | 43 | query := r.Query("SELECT password, email, refresh_token FROM users WHERE username = ?", username) 44 | 45 | err := query.Scan(&password, &email, &refreshToken) 46 | 47 | if err != nil { 48 | return user, err 49 | } 50 | 51 | user.Username = username 52 | user.Password = password 53 | user.Email = email 54 | user.RefreshToken = refreshToken 55 | 56 | return user, nil 57 | } 58 | 59 | // Exists checks whether the given user exists in the users table 60 | func (r *Repository) Exists(username string) (bool, error) { 61 | count := 0 62 | 63 | query := r.Query("SELECT count(*) FROM users WHERE username = ?", username) 64 | 65 | err := query.Scan(&count) 66 | 67 | if err != nil { 68 | return false, err 69 | } 70 | 71 | if count == 0 { 72 | return false, nil 73 | } 74 | 75 | return true, nil 76 | } 77 | -------------------------------------------------------------------------------- /services/users/internal/routes.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "twitter-go/services/common/amqp" 5 | "twitter-go/services/common/service" 6 | ) 7 | 8 | // Repliers maps routing keys to handlers 9 | var Repliers = service.Repliers{ 10 | service.Replier{ 11 | RoutingKey: amqp.CreateUserKey, 12 | Handler: CreateHandler, 13 | }, 14 | service.Replier{ 15 | RoutingKey: amqp.AuthorizeUserKey, 16 | Handler: AuthorizeHandler, 17 | }, 18 | service.Replier{ 19 | RoutingKey: amqp.ExistsUserKey, 20 | Handler: ExistsHandler, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | ## Tests 2 | 3 | The integration test suite validates the application functions correctly. The tests generally send a request to the API gateway and observe the response, verifying the payload has the correct values where appropriate and the status code of the response. 4 | 5 | Because of this, it should be easy to at minimum maintain the API contract and essential behaviour while adding new functionality or refactoring existing functionality. A developer will know when they've broken the API contract, or caused an error. 6 | 7 | #### Running the tests 8 | 9 | First, start cassandra and rabbit locally: 10 | 11 | `make up` 12 | 13 | Then, migrate the database so cassandra has the proper keyspace and tables: 14 | 15 | `make migrate` 16 | 17 | Then, run the test suite: 18 | 19 | `make test` -------------------------------------------------------------------------------- /tests/helpers/integration_test_suite.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "twitter-go/services/common/cassandra" 9 | "twitter-go/services/common/env" 10 | 11 | "github.com/stretchr/testify/suite" 12 | ) 13 | 14 | type IntegrationTestSuite struct { 15 | suite.Suite 16 | cassandra *cassandra.Client 17 | Host string 18 | Port string 19 | } 20 | 21 | // Singleton for shared access during local dev (helps boot up times) 22 | var singletonClient *cassandra.Client 23 | 24 | // Init initializes the test suite 25 | func (suite *IntegrationTestSuite) Init(host string, port string) error { 26 | suite.Host = host 27 | suite.Port = port 28 | if singletonClient == nil { 29 | cassandraURL := env.GetEnv("CASSANDRA_URL", "127.0.0.1") 30 | cassandraKeyspace := env.GetEnv("CASSANDRA_KEYSPACE", "twtr") 31 | cassandra, err := cassandra.NewClient(cassandraURL, cassandraKeyspace) 32 | if err != nil { 33 | return err 34 | } 35 | singletonClient = cassandra 36 | suite.cassandra = singletonClient 37 | } else { 38 | return nil 39 | } 40 | return nil 41 | } 42 | 43 | // Truncate truncates the specified tables in cassandra 44 | func (suite *IntegrationTestSuite) Truncate(tables []string) error { 45 | for _, table := range tables { 46 | query := fmt.Sprintf("TRUNCATE %s", table) 47 | err := suite.cassandra.Session.Query(query).Exec() 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | // GetBaseURL retrieves the base url for the current test suite for http requests 56 | func (suite *IntegrationTestSuite) GetBaseURL() string { 57 | return fmt.Sprintf("http://%s:%s", suite.Host, suite.Port) 58 | } 59 | 60 | // GetBaseURLWithSuffix retrieves the base url for the current test suite with a suffix for http requests 61 | func (suite *IntegrationTestSuite) GetBaseURLWithSuffix(suffix string) string { 62 | return fmt.Sprintf("http://%s:%s%s", suite.Host, suite.Port, suffix) 63 | } 64 | 65 | // CreateUserViaHTTP creates a user via http. This function is used across all tests, 66 | // as the application is predicated around users 67 | func (suite *IntegrationTestSuite) CreateUserViaHTTP(request map[string]string) (statusCode int, createUserResponse map[string]interface{}) { 68 | marshalled, err := json.Marshal(request) 69 | body := bytes.NewBuffer(marshalled) 70 | 71 | resp, err := http.Post((suite.GetBaseURLWithSuffix("/users")), "application/json", body) 72 | if err != nil { 73 | suite.Fail("Received no response from /users") 74 | } 75 | 76 | defer resp.Body.Close() 77 | 78 | if err := json.NewDecoder(resp.Body).Decode(&createUserResponse); err != nil { 79 | suite.Fail("Failed parsing response body") 80 | } 81 | 82 | return resp.StatusCode, createUserResponse 83 | } 84 | -------------------------------------------------------------------------------- /tests/integration/feeds_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "twitter-go/tests/helpers" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | type FeedsTestSuite struct { 13 | helpers.IntegrationTestSuite 14 | UserA map[string]interface{} 15 | UserB map[string]interface{} 16 | } 17 | 18 | func (suite *FeedsTestSuite) SetupSuite() { 19 | // TODO: have this be set by an ENV when k8s is up; test against k8s 20 | // Will need to create a new keyspace + tables for above use case to not blow up prod? 21 | suite.Init("localhost", "3002") 22 | suite.Truncate([]string{"users", "feed_items"}) 23 | 24 | // Create a new user 25 | statusCode, userA := suite.CreateUserViaHTTP(map[string]string{ 26 | "username": "username", 27 | "password": "password", 28 | "passwordConfirmation": "password", 29 | "email": "email@gmail.com", 30 | "displayName": "displayName", 31 | }) 32 | 33 | if statusCode != 200 { 34 | suite.Fail("Unable to create a user for feeds_test") 35 | } 36 | 37 | suite.UserA = userA["data"].(map[string]interface{}) 38 | } 39 | 40 | func (suite *FeedsTestSuite) TestGetFeedSuccess() { 41 | accessToken := suite.UserA["accessToken"].(string) 42 | statusCode := suite.getFeedViaHTTP(accessToken) 43 | assert.Equal(suite.T(), 200, statusCode) 44 | } 45 | 46 | func (suite *FeedsTestSuite) TestGetFeedNotAuthorized() { 47 | accessToken := "" 48 | statusCode := suite.getFeedViaHTTP(accessToken) 49 | assert.Equal(suite.T(), 401, statusCode) 50 | } 51 | 52 | func TestFeedsTestSuite(t *testing.T) { 53 | suite.Run(t, new(FeedsTestSuite)) 54 | } 55 | 56 | func (suite *FeedsTestSuite) getFeedViaHTTP(accessToken string) (statusCode int) { 57 | req, _ := http.NewRequest("GET", suite.GetBaseURLWithSuffix("/feeds/me"), nil) 58 | 59 | req.Header.Add("Authorization", "Bearer "+accessToken) 60 | 61 | client := &http.Client{} 62 | resp, err := client.Do(req) 63 | 64 | if err != nil { 65 | suite.Fail("Received no response from /feeds/me") 66 | } 67 | 68 | defer resp.Body.Close() 69 | 70 | return resp.StatusCode 71 | } 72 | -------------------------------------------------------------------------------- /tests/integration/followers_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | "twitter-go/tests/helpers" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/suite" 12 | ) 13 | 14 | type FollowersTestSuite struct { 15 | helpers.IntegrationTestSuite 16 | UserA map[string]interface{} 17 | UserB map[string]interface{} 18 | } 19 | 20 | func (suite *FollowersTestSuite) SetupSuite() { 21 | // TODO: have this be set by an ENV when k8s is up; test against k8s 22 | // Will need to create a new keyspace + tables for above use case to not blow up prod? 23 | suite.Init("localhost", "3002") 24 | suite.Truncate([]string{"users", "user_followers", "user_followings"}) 25 | 26 | // Create a new user 27 | statusCode, userA := suite.CreateUserViaHTTP(map[string]string{ 28 | "username": "username", 29 | "password": "password", 30 | "passwordConfirmation": "password", 31 | "email": "email@gmail.com", 32 | "displayName": "displayName", 33 | }) 34 | 35 | if statusCode != 200 { 36 | suite.Fail("Unable to create a user for followers_test") 37 | } 38 | 39 | suite.UserA = userA["data"].(map[string]interface{}) 40 | 41 | // Create another new user 42 | statusCode, userB := suite.CreateUserViaHTTP(map[string]string{ 43 | "username": "username2", 44 | "password": "password", 45 | "passwordConfirmation": "password", 46 | "email": "email2@gmail.com", 47 | "displayName": "displayName2", 48 | }) 49 | 50 | if statusCode != 200 { 51 | suite.Fail("Unable to create a user for tweets_test") 52 | } 53 | 54 | suite.UserB = userB["data"].(map[string]interface{}) 55 | } 56 | 57 | func (suite *FollowersTestSuite) TestFollowerUserSuccess() { 58 | accessToken := suite.UserA["accessToken"].(string) 59 | followingUsername := suite.UserB["username"].(string) 60 | statusCode, createTweetResponse := suite.followViaHTTP(map[string]string{ 61 | "followingUsername": followingUsername, 62 | }, accessToken) 63 | 64 | assert.Equal(suite.T(), 200, statusCode) 65 | assert.NotNil(suite.T(), createTweetResponse["data"].(map[string]interface{})["followingUsername"]) 66 | assert.NotNil(suite.T(), createTweetResponse["data"].(map[string]interface{})["username"]) 67 | } 68 | 69 | func (suite *FollowersTestSuite) TestFollowNotAuthorized() { 70 | accessToken := "" 71 | statusCode, _ := suite.followViaHTTP(map[string]string{}, accessToken) 72 | assert.Equal(suite.T(), 401, statusCode) 73 | } 74 | 75 | func (suite *FollowersTestSuite) TestFollowNotFound() { 76 | accessToken := suite.UserA["accessToken"].(string) 77 | followingUsername := "404" 78 | statusCode, _ := suite.followViaHTTP(map[string]string{ 79 | "followingUsername": followingUsername, 80 | }, accessToken) 81 | 82 | assert.Equal(suite.T(), 404, statusCode) 83 | } 84 | 85 | func (suite *FollowersTestSuite) TestFollowSameUsername() { 86 | accessToken := suite.UserA["accessToken"].(string) 87 | followingUsername := suite.UserA["username"].(string) // UserA, not B 88 | statusCode, _ := suite.followViaHTTP(map[string]string{ 89 | "followingUsername": followingUsername, 90 | }, accessToken) 91 | 92 | assert.Equal(suite.T(), 422, statusCode) 93 | } 94 | 95 | func (suite *FollowersTestSuite) TestFollowBadRequest() { 96 | accessToken := suite.UserA["accessToken"].(string) 97 | statusCode, _ := suite.followViaHTTP(map[string]string{ 98 | "followingUsername": "", 99 | }, accessToken) 100 | 101 | assert.Equal(suite.T(), 422, statusCode) 102 | } 103 | 104 | func TestFollowersTestSuite(t *testing.T) { 105 | suite.Run(t, new(FollowersTestSuite)) 106 | } 107 | 108 | func (suite *FollowersTestSuite) followViaHTTP(request map[string]string, accessToken string) (statusCode int, createTweetResponse map[string]interface{}) { 109 | marshalled, _ := json.Marshal(request) 110 | body := bytes.NewBuffer(marshalled) 111 | req, _ := http.NewRequest("POST", suite.GetBaseURLWithSuffix("/follow"), body) 112 | 113 | req.Header.Add("Authorization", "Bearer "+accessToken) 114 | 115 | client := &http.Client{} 116 | resp, err := client.Do(req) 117 | 118 | if err != nil { 119 | suite.Fail("Received no response from /follow") 120 | } 121 | 122 | defer resp.Body.Close() 123 | 124 | if err := json.NewDecoder(resp.Body).Decode(&createTweetResponse); err != nil { 125 | suite.Fail("Failed parsing response body") 126 | } 127 | 128 | return resp.StatusCode, createTweetResponse 129 | } 130 | -------------------------------------------------------------------------------- /tests/integration/tweets_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "testing" 9 | "twitter-go/tests/helpers" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | type TweetsTestSuite struct { 16 | helpers.IntegrationTestSuite 17 | UserA map[string]interface{} 18 | UserB map[string]interface{} 19 | } 20 | 21 | func (suite *TweetsTestSuite) SetupSuite() { 22 | // TODO: have this be set by an ENV when k8s is up; test against k8s 23 | // Will need to create a new keyspace + tables for above use case to not blow up prod? 24 | suite.Init("localhost", "3002") 25 | suite.Truncate([]string{"users", "tweets", "tweets_by_user"}) 26 | 27 | // Create a new user 28 | statusCode, userA := suite.CreateUserViaHTTP(map[string]string{ 29 | "username": "username", 30 | "password": "password", 31 | "passwordConfirmation": "password", 32 | "email": "email@gmail.com", 33 | "displayName": "displayName", 34 | }) 35 | 36 | if statusCode != 200 { 37 | suite.Fail("Unable to create a user for tweets_test") 38 | } 39 | 40 | suite.UserA = userA["data"].(map[string]interface{}) 41 | 42 | // Create another new user 43 | statusCode, userB := suite.CreateUserViaHTTP(map[string]string{ 44 | "username": "username2", 45 | "password": "password", 46 | "passwordConfirmation": "password", 47 | "email": "email2@gmail.com", 48 | "displayName": "displayName2", 49 | }) 50 | 51 | if statusCode != 200 { 52 | suite.Fail("Unable to create a user for tweets_test") 53 | } 54 | 55 | suite.UserB = userB["data"].(map[string]interface{}) 56 | } 57 | 58 | func (suite *TweetsTestSuite) TestCreateTweetSuccess() { 59 | accessToken := suite.UserA["accessToken"].(string) 60 | statusCode, createTweetResponse := suite.createTweetViaHTTP(map[string]string{ 61 | "content": "this is a tweet", 62 | }, accessToken) 63 | 64 | assert.Equal(suite.T(), 200, statusCode) 65 | assert.NotNil(suite.T(), createTweetResponse["data"].(map[string]interface{})["content"]) 66 | assert.NotNil(suite.T(), createTweetResponse["data"].(map[string]interface{})["id"]) 67 | } 68 | 69 | func (suite *TweetsTestSuite) TestCreateTweetNotAuthorized() { 70 | accessToken := "" 71 | statusCode, _ := suite.createTweetViaHTTP(map[string]string{ 72 | "content": "this is a tweet", 73 | }, accessToken) 74 | 75 | assert.Equal(suite.T(), 401, statusCode) 76 | } 77 | 78 | func (suite *TweetsTestSuite) TestCreateTweetInvalid() { 79 | accessToken := suite.UserA["accessToken"].(string) 80 | statusCode, _ := suite.createTweetViaHTTP(map[string]string{ 81 | "content": "", 82 | }, accessToken) 83 | 84 | assert.Equal(suite.T(), 422, statusCode) 85 | } 86 | 87 | func (suite *TweetsTestSuite) TestGetTweetsSuccess() { 88 | username := suite.UserA["username"].(string) 89 | statusCode, _ := suite.getTweetsViaHTTP(username) 90 | assert.Equal(suite.T(), 200, statusCode) 91 | } 92 | 93 | func (suite *TweetsTestSuite) TestGetTweetsNotFound() { 94 | statusCode, _ := suite.getTweetsViaHTTP("someOtherUsername") 95 | assert.Equal(suite.T(), 404, statusCode) 96 | } 97 | 98 | func TestTweetsTestSuite(t *testing.T) { 99 | suite.Run(t, new(TweetsTestSuite)) 100 | } 101 | 102 | func (suite *TweetsTestSuite) createTweetViaHTTP(request map[string]string, accessToken string) (statusCode int, createTweetResponse map[string]interface{}) { 103 | marshalled, _ := json.Marshal(request) 104 | body := bytes.NewBuffer(marshalled) 105 | req, _ := http.NewRequest("POST", suite.GetBaseURLWithSuffix("/tweets"), body) 106 | 107 | req.Header.Add("Authorization", "Bearer "+accessToken) 108 | 109 | client := &http.Client{} 110 | resp, err := client.Do(req) 111 | 112 | if err != nil { 113 | suite.Fail("Received no response from /tweets") 114 | } 115 | 116 | defer resp.Body.Close() 117 | 118 | if err := json.NewDecoder(resp.Body).Decode(&createTweetResponse); err != nil { 119 | suite.Fail("Failed parsing response body") 120 | } 121 | 122 | return resp.StatusCode, createTweetResponse 123 | } 124 | 125 | func (suite *TweetsTestSuite) getTweetsViaHTTP(username string) (statusCode int, getTweetsResponse map[string]interface{}) { 126 | url := fmt.Sprintf("/tweets/%s", username) 127 | resp, err := http.Get(suite.GetBaseURLWithSuffix(url)) 128 | if err != nil { 129 | suite.Fail("Received no response from /tweets") 130 | } 131 | 132 | defer resp.Body.Close() 133 | 134 | _ = json.NewDecoder(resp.Body).Decode(&getTweetsResponse) 135 | 136 | return resp.StatusCode, getTweetsResponse 137 | } 138 | -------------------------------------------------------------------------------- /tests/integration/users_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | "twitter-go/tests/helpers" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/suite" 12 | ) 13 | 14 | type UsersTestSuite struct { 15 | helpers.IntegrationTestSuite 16 | } 17 | 18 | func (suite *UsersTestSuite) SetupSuite() { 19 | // TODO: have this be set by an ENV when k8s is up; test against k8s 20 | // Will need to create a new keyspace + tables for above use case to not blow up prod? 21 | suite.Init("localhost", "3002") 22 | suite.Truncate([]string{"users"}) 23 | } 24 | 25 | func (suite *UsersTestSuite) TestCreateUserSuccess() { 26 | statusCode, createUserResponse := suite.CreateUserViaHTTP(map[string]string{ 27 | "username": "username", 28 | "password": "password", 29 | "passwordConfirmation": "password", 30 | "email": "email@gmail.com", 31 | "displayName": "displayName", 32 | }) 33 | 34 | assert.Equal(suite.T(), 200, statusCode) 35 | assert.NotNil(suite.T(), createUserResponse["data"].(map[string]interface{})["accessToken"]) 36 | assert.NotNil(suite.T(), createUserResponse["data"].(map[string]interface{})["refreshToken"]) 37 | } 38 | 39 | func (suite *UsersTestSuite) TestCreateUserBadRequestFailure() { 40 | statusCode, _ := suite.CreateUserViaHTTP(map[string]string{}) 41 | assert.Equal(suite.T(), 422, statusCode) 42 | } 43 | 44 | func (suite *UsersTestSuite) TestCreateUserAlreadyExistsFailure() { 45 | request := map[string]string{ 46 | "username": "anotherUsername", 47 | "password": "password", 48 | "passwordConfirmation": "password", 49 | "email": "email@gmail.com", 50 | "displayName": "displayName", 51 | } 52 | statusCodeUnique, _ := suite.CreateUserViaHTTP(request) 53 | statusCodeDuplicate, _ := suite.CreateUserViaHTTP(request) 54 | 55 | assert.Equal(suite.T(), 200, statusCodeUnique) 56 | assert.Equal(suite.T(), 409, statusCodeDuplicate) 57 | } 58 | 59 | func (suite *UsersTestSuite) TestAuthenticateUserSuccess() { 60 | username := "username2" 61 | password := "password" 62 | 63 | _, _ = suite.CreateUserViaHTTP(map[string]string{ 64 | "username": username, 65 | "password": password, 66 | "passwordConfirmation": "password", 67 | "email": "email@gmail.com", 68 | "displayName": "displayName", 69 | }) 70 | 71 | statusCode, authenticateUserResponse := suite.authenticateUserViaHTTP(map[string]string{ 72 | "username": username, 73 | "password": password, 74 | }) 75 | 76 | assert.Equal(suite.T(), 200, statusCode) 77 | assert.NotNil(suite.T(), authenticateUserResponse["data"].(map[string]interface{})["accessToken"]) 78 | assert.NotNil(suite.T(), authenticateUserResponse["data"].(map[string]interface{})["refreshToken"]) 79 | } 80 | 81 | func (suite *UsersTestSuite) TestAuthenticateUserBadInputFailure() { 82 | 83 | statusCode, _ := suite.authenticateUserViaHTTP(map[string]string{}) 84 | 85 | assert.Equal(suite.T(), 422, statusCode) 86 | } 87 | 88 | func (suite *UsersTestSuite) TestAuthenticateUserBadPasswordFailure() { 89 | username := "username3" 90 | password := "password" 91 | badPassword := "aardvark" 92 | 93 | _, _ = suite.CreateUserViaHTTP(map[string]string{ 94 | "username": username, 95 | "password": password, 96 | "passwordConfirmation": "password", 97 | "email": "email@gmail.com", 98 | "displayName": "displayName", 99 | }) 100 | 101 | statusCode, _ := suite.authenticateUserViaHTTP(map[string]string{ 102 | "username": username, 103 | "password": badPassword, 104 | }) 105 | 106 | assert.Equal(suite.T(), 422, statusCode) 107 | } 108 | 109 | func (suite *UsersTestSuite) TestAuthenticateUserMissingUserFailure() { 110 | username := "someoneWhoDoesntExist" 111 | password := "password" 112 | 113 | statusCode, _ := suite.authenticateUserViaHTTP(map[string]string{ 114 | "username": username, 115 | "password": password, 116 | }) 117 | 118 | assert.Equal(suite.T(), 404, statusCode) 119 | } 120 | 121 | func TestUsersTestSuite(t *testing.T) { 122 | suite.Run(t, new(UsersTestSuite)) 123 | } 124 | 125 | func (suite *UsersTestSuite) authenticateUserViaHTTP(request map[string]string) (statusCode int, authenticateUserResponse map[string]interface{}) { 126 | marshalled, err := json.Marshal(request) 127 | body := bytes.NewBuffer(marshalled) 128 | 129 | resp, err := http.Post((suite.GetBaseURLWithSuffix("/users/authorize")), "application/json", body) 130 | if err != nil { 131 | suite.Fail("Received no response from /authorize") 132 | } 133 | 134 | defer resp.Body.Close() 135 | 136 | if err := json.NewDecoder(resp.Body).Decode(&authenticateUserResponse); err != nil { 137 | suite.Fail("Failed parsing response body") 138 | } 139 | 140 | return resp.StatusCode, authenticateUserResponse 141 | } 142 | --------------------------------------------------------------------------------