├── pkg ├── files │ ├── templates_test │ │ ├── 01_template.yml.tpl │ │ └── 02_template.yml.tpl │ ├── playbook_test.go │ ├── config.go │ ├── playbook.go │ ├── client.go │ └── inventory.go ├── version │ └── version.go ├── api │ ├── namespace_test.go │ ├── api_test.go │ ├── namespace.go │ └── api.go ├── mock │ ├── deployment.go │ ├── statefulset.go │ ├── config.go │ ├── pod.go │ ├── job.go │ ├── service.go │ ├── inventory.go │ ├── playbook.go │ └── namespace.go ├── resource │ ├── deployment.go │ ├── statefulset.go │ ├── job.go │ ├── cluster.go │ ├── pod.go │ ├── service.go │ ├── namespace_test.go │ └── namespace.go ├── http │ ├── infrastructure_handler.go │ ├── middleware.go │ ├── server.go │ ├── resource_handler.go │ └── inventory_handler.go ├── kubernetes │ ├── cluster.go │ ├── deployment.go │ ├── statefulset.go │ ├── pod.go │ ├── job.go │ ├── service.go │ ├── client.go │ └── namespace.go └── playbook │ ├── config_test.go │ ├── playbook.go │ ├── inventory_test.go │ ├── config.go │ └── inventory.go ├── example ├── my-app-playbook │ ├── .gitignore │ ├── defaults.json │ └── templates │ │ ├── api.yaml.tpl │ │ └── front.yaml.tpl ├── my-app │ ├── api │ │ ├── v1 │ │ │ ├── Dockerfile │ │ │ └── app.go │ │ └── v2 │ │ │ ├── Dockerfile │ │ │ └── app.go │ ├── front │ │ ├── Dockerfile │ │ └── web.go │ └── Makefile └── README.md ├── .gitignore ├── Dockerfile.in ├── docs ├── README.md ├── getting-started │ ├── _index.md │ ├── installation.md │ └── usage.md ├── workflow │ ├── _index.md │ ├── REST.md │ └── cli.md ├── introduction │ ├── how-it-works.md │ ├── _index.md │ └── purpose.md └── playbooks │ ├── defaults.md │ ├── inventories.md │ ├── templates.md │ └── _index.md ├── main.go ├── cmd ├── root_test.go ├── version.go ├── serve.go ├── delete_namespace.go ├── get.go ├── reset.go ├── delete.go ├── get_namespaces.go ├── delete_job.go ├── get_services.go ├── create.go ├── apply.go └── root.go ├── scripts └── test-coverage.sh ├── .goreleaser.yml ├── .travis.yml ├── Makefile ├── CONTRIBUTING.MD ├── README.md ├── go.mod ├── install.sh ├── LICENSE └── swagger.json /pkg/files/templates_test/01_template.yml.tpl: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /pkg/files/templates_test/02_template.yml.tpl: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /example/my-app-playbook/.gitignore: -------------------------------------------------------------------------------- 1 | configs/ 2 | inventories/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /blackbeard 3 | /vendor/ 4 | /.go 5 | /.idea/ 6 | /dist/ -------------------------------------------------------------------------------- /Dockerfile.in: -------------------------------------------------------------------------------- 1 | FROM ARG_FROM 2 | 3 | ADD bin/ARG_BIN-ARG_ARCH /ARG_BIN 4 | 5 | USER nobody:nobody 6 | ENTRYPOINT ["/ARG_BIN"] 7 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Blackbeard documentation 2 | 3 | Documentation is available at [blackbeard.netlify.com](https://blackbeard.netlify.com) -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | version = "dev" 5 | ) 6 | 7 | func GetVersion() string { 8 | return version 9 | } 10 | -------------------------------------------------------------------------------- /example/my-app/api/v1/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10.1-alpine3.7 as builder 2 | COPY app.go . 3 | RUN go build -o /app . 4 | 5 | FROM alpine:3.7 6 | CMD ["./app"] 7 | COPY --from=builder /app . 8 | -------------------------------------------------------------------------------- /example/my-app/api/v2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10.1-alpine3.7 as builder 2 | COPY app.go . 3 | RUN go build -o /app . 4 | 5 | FROM alpine:3.7 6 | CMD ["./app"] 7 | COPY --from=builder /app . 8 | -------------------------------------------------------------------------------- /example/my-app/front/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10.1-alpine3.7 as builder 2 | COPY web.go . 3 | RUN go build -o /web . 4 | 5 | FROM alpine:3.7 6 | CMD ["./web"] 7 | COPY --from=builder /web . 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/Meetic/blackbeard/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.NewBlackbeardCommand().Execute(); err != nil { 11 | log.Fatal(err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/my-app-playbook/defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespace" : "default", 3 | "values": { 4 | "api": { 5 | "version": "v1" 6 | }, 7 | "front": { 8 | "version": "v1" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAskForConfirmation(t *testing.T) { 11 | retNo := askForConfirmation("test", strings.NewReader("no\n")) 12 | assert.False(t, retNo) 13 | retYes := askForConfirmation("test", strings.NewReader("yes\n")) 14 | assert.True(t, retYes) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/api/namespace_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestApi_ListNamespaces(t *testing.T) { 10 | namespaces, err := blackbeard.ListNamespaces() 11 | 12 | assert.Nil(t, err) 13 | assert.NotNil(t, namespaces) 14 | assert.Equal(t, "test", namespaces[0].Name) 15 | assert.Equal(t, true, namespaces[0].Managed) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/mock/deployment.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/Meetic/blackbeard/pkg/resource" 7 | ) 8 | 9 | type DeploymentRepository struct { 10 | mock.Mock 11 | } 12 | 13 | func (m *DeploymentRepository) List(namespace string) (resource.Deployments, error) { 14 | args := m.Called(namespace) 15 | return args.Get(0).(resource.Deployments), args.Error(1) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/mock/statefulset.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/Meetic/blackbeard/pkg/resource" 7 | ) 8 | 9 | type StatefulsetRepository struct { 10 | mock.Mock 11 | } 12 | 13 | func (m *StatefulsetRepository) List(namespace string) (resource.Statefulsets, error) { 14 | args := m.Called(namespace) 15 | return args.Get(0).(resource.Statefulsets), args.Error(1) 16 | } 17 | -------------------------------------------------------------------------------- /example/my-app/api/v1/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | const ( 10 | version = 1 11 | ) 12 | 13 | func handler(w http.ResponseWriter, r *http.Request) { 14 | fmt.Fprintf(w, "Hello API version %d\n", version) 15 | } 16 | 17 | func main() { 18 | log.Printf("API version %d is running", version) 19 | http.HandleFunc("/", handler) 20 | http.ListenAndServe(":50051", nil) 21 | } 22 | -------------------------------------------------------------------------------- /example/my-app/api/v2/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | const ( 10 | version = 2 11 | ) 12 | 13 | func handler(w http.ResponseWriter, r *http.Request) { 14 | fmt.Fprintf(w, "Hello API version %d\n", version) 15 | } 16 | 17 | func main() { 18 | log.Printf("API version %d is running", version) 19 | http.HandleFunc("/", handler) 20 | http.ListenAndServe(":50051", nil) 21 | } 22 | -------------------------------------------------------------------------------- /docs/getting-started/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting started" 3 | anchor: "getting-started" 4 | weight: 20 5 | --- 6 | The following section describe how to quickly start to work with Blackbeard. 7 | 8 | [A full example, using 2 applications (an API and a front web app), is available on github](https://github.com/Meetic/blackbeard/blob/master/example). Have a look at it and try it on a local Kubernetes, for example (using docker-or-desktop or mini-kube). -------------------------------------------------------------------------------- /pkg/resource/deployment.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type Deployments []Deployment 4 | 5 | type Deployment struct { 6 | Name string 7 | Status DeploymentStatus 8 | } 9 | 10 | type DeploymentStatus string 11 | 12 | const ( 13 | DeploymentReady DeploymentStatus = "Ready" 14 | DeploymentNotReady DeploymentStatus = "NotReady" 15 | ) 16 | 17 | type DeploymentRepository interface { 18 | List(namespace string) (Deployments, error) 19 | } 20 | -------------------------------------------------------------------------------- /docs/workflow/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Workflow" 3 | anchor: "workflow" 4 | weight: 30 5 | --- 6 | 7 | Working with Blackbeard actually means working with a `playbook`. Blackbeard manage Kubernetes namespaces based on a playbook. 8 | 9 | ![Blackbeard workflow](/img/workflow.png) 10 | 11 | First thing, is to create your own playbook. Once done, you have 2 ways to manage your namespaces using blackbeard : 12 | 13 | * Using CLI 14 | * Using the REST API -------------------------------------------------------------------------------- /pkg/resource/statefulset.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type Statefulsets []Statefulset 4 | 5 | type Statefulset struct { 6 | Name string 7 | Status StatefulsetStatus 8 | } 9 | 10 | type StatefulsetStatus string 11 | 12 | const ( 13 | StatefulsetReady StatefulsetStatus = "Ready" 14 | StatefulsetNotReady StatefulsetStatus = "NotReady" 15 | ) 16 | 17 | type StatefulsetRepository interface { 18 | List(namespace string) (Statefulsets, error) 19 | } 20 | -------------------------------------------------------------------------------- /docs/introduction/how-it-works.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "How it works?" 3 | anchor: "how-it-works" 4 | weight: 12 5 | --- 6 | ![how it works?](/img/blackbeard_mechanism.png) 7 | 8 | ## Playbooks 9 | 10 | Blackbeard use *playbooks* to manage namespaces. A playbook is a collection of kubernetes manifest describing your stack, written as templates. A playbook also require a `default.json`, providing the default values to apply to the templates. 11 | 12 | Playbooks are created as files laid out in a particular directory tree. -------------------------------------------------------------------------------- /pkg/mock/config.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/Meetic/blackbeard/pkg/playbook" 5 | ) 6 | 7 | type configRepository struct{} 8 | 9 | // NewConfigRepository returns a new Mock ConfigRepository 10 | func NewConfigRepository() playbook.ConfigRepository { 11 | return &configRepository{} 12 | } 13 | 14 | func (cr *configRepository) Save(namespace string, configs []playbook.Config) error { 15 | return nil 16 | } 17 | 18 | func (cr *configRepository) Delete(namespace string) error { 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /pkg/mock/pod.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/Meetic/blackbeard/pkg/resource" 7 | ) 8 | 9 | type PodRepository struct { 10 | mock.Mock 11 | } 12 | 13 | // GetPods of all the pods in a given namespace. 14 | // This method returns a Pods slice containing the pod name and the pod status (pod status phase). 15 | func (m *PodRepository) List(n string) (resource.Pods, error) { 16 | args := m.Called(n) 17 | return args.Get(0).(resource.Pods), args.Error(1) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/http/infrastructure_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func (h *Handler) Version(c *gin.Context) { 10 | version, err := h.api.GetVersion() 11 | 12 | if err != nil { 13 | c.JSON(http.StatusBadRequest, gin.H{ 14 | "error": "Unable to get blackbeard version", 15 | "message": err.Error(), 16 | }) 17 | } 18 | 19 | c.JSON(http.StatusOK, version) 20 | } 21 | 22 | func (h *Handler) HealthCheck(c *gin.Context) { 23 | c.Writer.WriteHeader(http.StatusOK) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/mock/job.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/Meetic/blackbeard/pkg/resource" 7 | ) 8 | 9 | type JobRepository struct { 10 | mock.Mock 11 | } 12 | 13 | func (m *JobRepository) List(namespace string) (resource.Jobs, error) { 14 | args := m.Called(namespace) 15 | return args.Get(0).(resource.Jobs), args.Error(1) 16 | } 17 | 18 | func (m *JobRepository) Delete(namespace, resourceName string) error { 19 | args := m.Called(namespace, resourceName) 20 | return args.Error(0) 21 | } 22 | -------------------------------------------------------------------------------- /example/my-app/front/web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "log" 8 | ) 9 | 10 | const ( 11 | apiURL = "http://blackbeard-example-app:50051" 12 | ) 13 | 14 | func handler(w http.ResponseWriter, r *http.Request) { 15 | resp, err := http.Get(apiURL) 16 | if err != nil { 17 | panic(err) 18 | } 19 | defer resp.Body.Close() 20 | if _, err := io.Copy(w, resp.Body); err != nil { 21 | panic(err) 22 | } 23 | 24 | } 25 | 26 | func main() { 27 | log.Print("front web running") 28 | http.HandleFunc("/", handler) 29 | http.ListenAndServe(":8080", nil) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/Meetic/blackbeard/pkg/version" 9 | ) 10 | 11 | var versionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Print blackbeard version", 14 | Long: "This command will print blackbeard version.", 15 | 16 | Run: func(cmd *cobra.Command, args []string) { 17 | runVersion() 18 | }, 19 | } 20 | 21 | func NewVersionCommand() *cobra.Command { 22 | return versionCmd 23 | } 24 | 25 | func runVersion() { 26 | fmt.Println(fmt.Sprintf("blackbeard version %s", version.GetVersion())) 27 | } 28 | -------------------------------------------------------------------------------- /scripts/test-coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "mode: set" > acc.out 3 | for Dir in $(find ./* -maxdepth 10 -type d | grep -v vendor); 4 | do 5 | if ls $Dir/*.go &> /dev/null; 6 | then 7 | echo "Testing $Dir" 8 | go test -v -coverprofile=profile.out $Dir 9 | if [ -f profile.out ] 10 | then 11 | cat profile.out | grep -v "mode: set" >> acc.out 12 | fi 13 | fi 14 | done 15 | goveralls -coverprofile=profile.out -service travis-ci -repotoken $COVERALLS_TOKEN 16 | rm -rf ./profile.out 17 | rm -rf ./acc.out 18 | 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: blackbeard 3 | goos: 4 | - windows 5 | - darwin 6 | - linux 7 | goarch: 8 | - amd64 9 | ldflags: 10 | - -s -w -X github.com/Meetic/blackbeard/pkg/version.version={{.Version}} 11 | # Custom environment variables to be set during the builds. 12 | # Default is empty. 13 | env: 14 | - CGO_ENABLED=0 15 | # archive: 16 | # replacements: 17 | # darwin: macos 18 | # linux: linux 19 | # windows: windows 20 | changelog: 21 | sort: asc 22 | filters: 23 | exclude: 24 | - '^docs:' 25 | - '^test:' 26 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Installation" 3 | anchor: "installation" 4 | weight: 21 5 | --- 6 | ### Recommanded 7 | 8 | The simplest way of installing Blackbeard is to use the installation script : 9 | 10 | ```sh 11 | curl -sf https://raw.githubusercontent.com/Meetic/blackbeard/master/install.sh | sh 12 | ``` 13 | 14 | ### Manually 15 | 16 | Download your preferred flavor from the releases page and install manually. 17 | 18 | ### Using Go Get 19 | 20 | Note : this method requires Go 1.9+ and dep. 21 | 22 | ```sh 23 | go get github.com/Meetic/blackbeard 24 | cd $GOPATH/src/github.com/Meetic/blackbeard 25 | make build 26 | ``` 27 | -------------------------------------------------------------------------------- /pkg/kubernetes/cluster.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "os/exec" 6 | 7 | "github.com/Meetic/blackbeard/pkg/resource" 8 | ) 9 | 10 | type ClusterRepository struct{} 11 | 12 | func NewClusterRepository() resource.ClusterRepository { 13 | return &ClusterRepository{} 14 | } 15 | 16 | func (r ClusterRepository) GetVersion() (*resource.Version, error) { 17 | cmd := exec.Command("/bin/sh", "-c", "kubectl version --output json") 18 | result, err := cmd.Output() 19 | 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | var v resource.Version 25 | 26 | err = json.Unmarshal(result, &v) 27 | 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &v, nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/files/playbook_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/Meetic/blackbeard/pkg/files" 9 | ) 10 | 11 | func TestGetTemplate(t *testing.T) { 12 | r := files.NewPlaybookRepository("templates_test", "templates_test") 13 | 14 | tpls, err := r.GetTemplate() 15 | 16 | assert.Len(t, tpls, 2) 17 | assert.Equal(t, "01_template.yml", tpls[0].Name) 18 | assert.Equal(t, "02_template.yml", tpls[1].Name) 19 | assert.Nil(t, err) 20 | } 21 | 22 | func TestGetTemplateNotFound(t *testing.T) { 23 | r := files.NewPlaybookRepository(".", ".") 24 | 25 | tpls, err := r.GetTemplate() 26 | 27 | assert.Len(t, tpls, 0) 28 | assert.NotNil(t, err) 29 | } 30 | -------------------------------------------------------------------------------- /docs/playbooks/defaults.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "defaults.json" 3 | anchor: "defaults.json" 4 | weight: 43 5 | --- 6 | 7 | The `defaults.json` file is the default inventory. It contains defaults values to apply on the templates. Blackbeard uses it to generated per namespace inventory. 8 | 9 | The only constraint on the `default.json` file is : 10 | 11 | * Must contains a `namespace` key, containing the value "default". 12 | 13 | **Example :** `defaults.json` 14 | 15 | ```json 16 | { 17 | "namespace": "default", 18 | "values": { 19 | "api": { 20 | "version": "1.0.0", 21 | "memoryLimit": "128m" 22 | }, 23 | "front": { 24 | "version": "1.0.0" 25 | } 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /docs/introduction/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "What is Blackbeard?" 3 | anchor: "introduction" 4 | weight: 10 5 | --- 6 | **Blackbeard is a namespace manager for Kubernetes.** :thumbsup: It helps you to develop and test with Kubernetes using namespaces. 7 | 8 | {{% block tip %}} 9 | Kubernetes namespaces provide an easy way to isolate your components, your development environment, or your staging environment. 10 | {{% /block %}} 11 | 12 | Blackbeard helps you to deploy your Kubernetes manifests on multiple namespaces, making each of them running a different version of your microservices. You may use it to manage development environment (one namespace per developer) or for testing purpose (one namespace for each feature to test before deploying in production). -------------------------------------------------------------------------------- /docs/introduction/purpose.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Purpose" 3 | anchor: "purpose" 4 | weight: 11 5 | --- 6 | *When working in a quite large team or, in a quite large project, have you ever experienced difficulties to test multiple features at the same time?* 7 | 8 | Usually, teams have 2 alternatives : 9 | 10 | * Stack "features to test" in a queue and wait for the staging environment to be available; 11 | * Try more or less successfully to create and maintain an "on demand" staging environment system, where each environment is dedicated to test a specified feature. 12 | 13 | Blackbeard helps you to create ephemeral environments where you can test a set of features before pushing in production. 14 | 15 | It also provide a very simple workflow, making things easier if you plan to run automated end-to-end testing. -------------------------------------------------------------------------------- /pkg/resource/job.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type JobService interface { 4 | Delete(namespace, resourceName string) error 5 | } 6 | 7 | type JobRepository interface { 8 | Delete(namespace, resourceName string) error 9 | List(namespace string) (Jobs, error) 10 | } 11 | 12 | type jobService struct { 13 | job JobRepository 14 | } 15 | 16 | type Jobs []Job 17 | 18 | type Job struct { 19 | Name string 20 | Status JobStatus 21 | } 22 | 23 | type JobStatus string 24 | 25 | const ( 26 | JobReady JobStatus = "Ready" 27 | JobNotReady JobStatus = "NotReady" 28 | ) 29 | 30 | func NewJobService(job JobRepository) JobService { 31 | return &jobService{ 32 | job: job, 33 | } 34 | } 35 | 36 | func (js *jobService) Delete(namespace, resourceName string) error { 37 | return js.job.Delete(namespace, resourceName) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/resource/cluster.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | type clusterService struct { 4 | client ClusterRepository 5 | } 6 | 7 | type ClusterRepository interface { 8 | GetVersion() (*Version, error) 9 | } 10 | 11 | type ClusterService interface { 12 | GetVersion() (*Version, error) 13 | } 14 | 15 | func NewClusterService(client ClusterRepository) ClusterService { 16 | return &clusterService{ 17 | client: client, 18 | } 19 | } 20 | 21 | type Version struct { 22 | ClientVersion struct { 23 | Major string `json:"major"` 24 | Minor string `json:"minor"` 25 | } `json:"clientVersion"` 26 | ServerVersion struct { 27 | Major string `json:"major"` 28 | Minor string `json:"minor"` 29 | } `json:"serverVersion"` 30 | } 31 | 32 | func (cs *clusterService) GetVersion() (*Version, error) { 33 | return cs.client.GetVersion() 34 | } 35 | -------------------------------------------------------------------------------- /pkg/playbook/config_test.go: -------------------------------------------------------------------------------- 1 | package playbook_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Meetic/blackbeard/pkg/mock" 7 | "github.com/Meetic/blackbeard/pkg/playbook" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var configs = playbook.NewConfigService(mock.NewConfigRepository(), 12 | playbook.NewPlaybookService(mock.NewPlaybookRepository())) 13 | 14 | func TestGenerateOk(t *testing.T) { 15 | inventories := mock.NewInventoryRepository() 16 | 17 | inv, _ := inventories.Get("test1") 18 | 19 | assert.Nil(t, configs.Generate(inv)) 20 | } 21 | 22 | func TestGenerateEmptyNamespace(t *testing.T) { 23 | 24 | inventories := mock.NewInventoryRepository() 25 | 26 | inv, _ := inventories.Get("") 27 | 28 | assert.Error(t, configs.Generate(inv)) 29 | } 30 | 31 | func TestDeleteOk(t *testing.T) { 32 | assert.Nil(t, configs.Delete("test")) 33 | } 34 | -------------------------------------------------------------------------------- /example/my-app-playbook/templates/api.yaml.tpl: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: blackbeard-example-app 5 | labels: 6 | app: blackbeard-example-app 7 | spec: 8 | clusterIP: None 9 | ports: 10 | - port: 50051 11 | name: blackbeard-example-app 12 | selector: 13 | app: blackbeard-example-app 14 | --- 15 | apiVersion: apps/v1 16 | kind: Deployment 17 | metadata: 18 | name: blackbeard-example-app 19 | labels: 20 | app: blackbeard-example-app 21 | spec: 22 | replicas: 1 23 | selector: 24 | matchLabels: 25 | app: blackbeard-example-app 26 | template: 27 | metadata: 28 | labels: 29 | app: blackbeard-example-app 30 | spec: 31 | containers: 32 | - name: blackbeard-example-app 33 | image: seblegall/blackbeard-example-api:{{.Values.api.version}} 34 | ports: 35 | - containerPort: 50051 36 | -------------------------------------------------------------------------------- /example/my-app/Makefile: -------------------------------------------------------------------------------- 1 | # set default shell 2 | SHELL := $(shell which bash) 3 | ENV = /usr/bin/env 4 | DOCKER_API_V1 = "seblegall/blackbeard-example-api:v1" 5 | DOCKER_API_V2 = "seblegall/blackbeard-example-api:v2" 6 | DOCKER_FRONT = "seblegall/blackbeard-example-front:v1" 7 | 8 | .SHELLFLAGS = -c 9 | 10 | .SILENT: ; # no need for @ 11 | .ONESHELL: ; # recipes execute in same shell 12 | .NOTPARALLEL: ; # wait for this target to finish 13 | .EXPORT_ALL_VARIABLES: ; # send all vars to shell 14 | 15 | .PHONY: all 16 | .DEFAULT: build 17 | 18 | build: ## Build my-app docker images 19 | docker build -t ${DOCKER_API_V1} ./api/v1/ 20 | docker build -t ${DOCKER_API_V2} ./api/v2/ 21 | docker build -t ${DOCKER_FRONT} ./front/ 22 | 23 | push: ## Push my-app docker images to hub.docker.com 24 | docker push ${DOCKER_API_V1} 25 | docker push ${DOCKER_API_V2} 26 | docker push ${DOCKER_FRONT} -------------------------------------------------------------------------------- /example/my-app-playbook/templates/front.yaml.tpl: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: blackbeard-example-web 5 | labels: 6 | app: blackbeard-example-web 7 | spec: 8 | type: NodePort 9 | ports: 10 | - port: 8080 11 | targetPort: 8080 12 | protocol: TCP 13 | name: blackbeard-example-web 14 | selector: 15 | app: blackbeard-example-web 16 | --- 17 | apiVersion: apps/v1 18 | kind: Deployment 19 | metadata: 20 | name: blackbeard-example-web 21 | labels: 22 | app: blackbeard-example-web 23 | spec: 24 | replicas: 1 25 | selector: 26 | matchLabels: 27 | app: blackbeard-example-web 28 | template: 29 | metadata: 30 | labels: 31 | app: blackbeard-example-web 32 | spec: 33 | containers: 34 | - name: blackbeard-example-web 35 | image: seblegall/blackbeard-example-front:{{.Values.front.version}} 36 | ports: 37 | - containerPort: 8080 38 | -------------------------------------------------------------------------------- /docs/workflow/REST.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "HTTP" 3 | anchor: "http" 4 | weight: 31 5 | --- 6 | 7 | Blackbeard also provide a web server and a websocket server exposing a REST api. 8 | 9 | You can launch the Blackbeard server using the command : 10 | 11 | ```sh 12 | blackbeard serve --help 13 | 14 | Usage: 15 | blackbeard serve [flags] 16 | 17 | Flags: 18 | --cors Enable cors 19 | --port string Use a specific port (default "8080") 20 | -h, --help help for serve 21 | 22 | Global Flags: 23 | --config string config file (default is $HOME/.blackbeard.yaml) 24 | --dir string Use the specified dir as root path to execute commands. Default is the current dir. 25 | ``` 26 | 27 | The REST api documentation is written following the [OpenAPI specifications](https://github.com/OAI/OpenAPI-Specification). 28 | 29 | This documentation is available in an HTML format, using Swagger UI. 30 | 31 | {{< oai-spec url="https://raw.githubusercontent.com/Meetic/blackbeard/master/swagger.json">}} 32 | -------------------------------------------------------------------------------- /pkg/resource/pod.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "k8s.io/api/core/v1" 5 | ) 6 | 7 | // Pods represent a list of pods. 8 | type Pods []Pod 9 | 10 | // Pod represent a Kubernetes pod. 11 | // The status is the pod phase status. It could be : 12 | // * running 13 | // * pending 14 | // etc... 15 | type Pod struct { 16 | Name string 17 | Status v1.PodPhase 18 | } 19 | 20 | type podService struct { 21 | pods PodRepository 22 | } 23 | 24 | // PodRepository represents the way Pods are managed 25 | type PodRepository interface { 26 | List(string) (Pods, error) 27 | } 28 | 29 | type PodService interface { 30 | List(string) (Pods, error) 31 | } 32 | 33 | // NewPodService returns a new PodService 34 | func NewPodService(pods PodRepository) PodService { 35 | return &podService{ 36 | pods: pods, 37 | } 38 | } 39 | 40 | // List returns the list of pods in a kubernetes namespace with their associated status. 41 | func (ps *podService) List(namespace string) (Pods, error) { 42 | return ps.pods.List(namespace) 43 | } 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 1.19 3 | before_install: 4 | make dep 5 | install: 6 | make build 7 | script: 8 | make test-cover 9 | 10 | after_success: 11 | make cross-build 12 | 13 | # deploy: 14 | # provider: releases 15 | # file_glob: true 16 | # api_key: 17 | # secure: hWDDkQ6UgPup4fuHfNeibXdrk3aUciAy2OqAxXCeNPJg19dDOc54o/f676kY4LxhwPigiIIUpo7U6o75Jfhqo3Fh0C35dkr8EDDxkaW0U6IuolPMq4RW9ByRVdlyV6pC59kMYvobNqucvld08uIj1rQsA7aIEc8Kz7pDmdGjPl3+7kBF0kl1EKadQShbcQAIO+LCBZh79k7r97PGIf8tnQ6BcC8LcdzqbPFPYYSZZ5rwpD7ULigyXg2oDl2S1aUfBEK2+LCOTUiVVTupeuu05e/Xc9wD1Qtxot5NNThflfWuIge/zy/cFMClbQdb03IRHhgU0e2BRmQaYQKhwARW5nRwUW6LczRQUaVnBSOrdwKUU6iC77FwC9Pk2S8tzpfzCUrW6GjZWC9EfBjzroiqyZMw6wuB6Xk+Q8UyLS2KEU6T5KiiRrblQvsJOE2ut3AoFwDxzElX7bxptn3sRdcqheZlucBDHJa3KqmuKMlN8Uhh/v6sfYEAJrVh+3YM2DGvleQ/JQ9YxS54IRL3iaAeNdzZXiRuLY2SMj+ymbmwjTjP6DudHfp+Esbe5ItXa9W6QGgI3xtwVi1GMpQeP2cHEZJcBHM36wo+GHFKOmRu8mcOtHQxVKnjYHBFMPZOwUX7opAYbAbCem815X05mGaFq/ccdjLC6l6ia12I/TVBs2w= 18 | # file: bin/* 19 | # skip_cleanup: true 20 | # on: 21 | # tags: true 22 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/Meetic/blackbeard/pkg/http" 7 | ) 8 | 9 | // serveCmd represents the serve command 10 | var serveCmd = &cobra.Command{ 11 | Use: "serve", 12 | Short: "Launch the blackbeard server", 13 | Long: `This command run a web server that expose a REST API. 14 | This API let the client use all the features provided by Blackbeard such as create a namespace and apply a change in a inventory.`, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | runServe() 17 | }, 18 | } 19 | 20 | func NewServeCommand() *cobra.Command { 21 | serveCmd.Flags().BoolVar(&cors, "cors", false, "Enable cors") 22 | serveCmd.Flags().IntVar(&port, "port", 8080, "Use a specific port") 23 | 24 | return serveCmd 25 | } 26 | 27 | func runServe() { 28 | files := newFileClient(playbookDir) 29 | 30 | api := newAPI(files, newKubernetesClient()) 31 | 32 | go api.WatchNamespaceDeleted() 33 | 34 | h := http.NewHandler(api, files.ConfigPath(), cors) 35 | s := http.NewServer(h) 36 | 37 | // start http web server 38 | s.Serve(port) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/mock/service.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/Meetic/blackbeard/pkg/resource" 5 | "k8s.io/client-go/kubernetes" 6 | ) 7 | 8 | type serviceRepository struct { 9 | kubernetes kubernetes.Interface 10 | host string 11 | } 12 | 13 | // NewServiceRepository retuns a new ServiceRespository 14 | // It takes as parameter a go-client kubernetes client and the kubernetes cluster host (domain name or ip). 15 | func NewServiceRepository(kubernetes kubernetes.Interface, host string) resource.ServiceRepository { 16 | return &serviceRepository{ 17 | kubernetes: kubernetes, 18 | host: host, 19 | } 20 | } 21 | 22 | // ListExternal returns a list of kubernetes services exposed as NodePort. 23 | func (sr *serviceRepository) ListExternal(n string) ([]resource.Service, error) { 24 | services := []resource.Service{ 25 | { 26 | Name: "testPort", 27 | }, 28 | } 29 | 30 | return services, nil 31 | } 32 | 33 | // ListIngress returns a list of Kubernetes services exposed throw Ingress. 34 | func (sr *serviceRepository) ListIngress(n string) ([]resource.Service, error) { 35 | services := []resource.Service{ 36 | { 37 | Name: "testIngress", 38 | }, 39 | } 40 | 41 | return services, nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/delete_namespace.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // deleteCmd represents the create command 12 | var deleteNamespaceCmd = &cobra.Command{ 13 | Use: "namespace [NAME]", 14 | Short: "Delete a namespace", 15 | Long: `This command delete a namespace and all the associated resources`, 16 | Args: cobra.ExactArgs(1), 17 | Run: func(cmd *cobra.Command, args []string) { 18 | err := runDeleteNamespace(args[0]) 19 | if err != nil { 20 | logrus.Fatal(err.Error()) 21 | } 22 | }, 23 | } 24 | 25 | func NewDeleteNamespaceCommand() *cobra.Command { 26 | return deleteNamespaceCmd 27 | } 28 | 29 | func runDeleteNamespace(namespace string) error { 30 | if !askForConfirmation(fmt.Sprintf("You are about to delete the inventory %s and all its associated files. Are you sure?", namespace), os.Stdin) { 31 | return nil 32 | } 33 | 34 | api := newAPI(newFileClient(playbookDir), newKubernetesClient()) 35 | 36 | err := api.Delete(namespace, false) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | logrus.WithFields(logrus.Fields{ 42 | "namespace": namespace, 43 | }).Info("namespace deleted") 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/kubernetes/deployment.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/client-go/kubernetes" 9 | 10 | "github.com/Meetic/blackbeard/pkg/resource" 11 | ) 12 | 13 | type deploymentRepository struct { 14 | kubernetes.Interface 15 | } 16 | 17 | func NewDeploymentRepository(kubernetes kubernetes.Interface) resource.DeploymentRepository { 18 | return &deploymentRepository{ 19 | kubernetes, 20 | } 21 | } 22 | 23 | // List return a list of deployment with their status Ready or NotReady 24 | func (r *deploymentRepository) List(namespace string) (resource.Deployments, error) { 25 | dl, err := r.AppsV1().Deployments(namespace).List(context.Background(), v1.ListOptions{}) 26 | 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to list deployments: %v", err) 29 | } 30 | 31 | dps := make(resource.Deployments, 0) 32 | 33 | for _, dp := range dl.Items { 34 | status := resource.DeploymentNotReady 35 | 36 | if dp.Status.ReadyReplicas == dp.Status.Replicas { 37 | status = resource.DeploymentReady 38 | } 39 | 40 | dps = append(dps, resource.Deployment{ 41 | Name: dp.Name, 42 | Status: status, 43 | }) 44 | } 45 | 46 | return dps, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/kubernetes/statefulset.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/client-go/kubernetes" 9 | 10 | "github.com/Meetic/blackbeard/pkg/resource" 11 | ) 12 | 13 | type statefulsetRepository struct { 14 | kubernetes.Interface 15 | } 16 | 17 | func NewStatefulsetRepository(kubernetes kubernetes.Interface) resource.StatefulsetRepository { 18 | return &statefulsetRepository{ 19 | kubernetes, 20 | } 21 | } 22 | 23 | // List return a list of statefulset with their status Ready or NotReady 24 | func (r *statefulsetRepository) List(namespace string) (resource.Statefulsets, error) { 25 | sfl, err := r.AppsV1().StatefulSets(namespace).List(context.Background(), v1.ListOptions{}) 26 | 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to list statefulsets: %v", err) 29 | } 30 | 31 | sfs := make(resource.Statefulsets, 0) 32 | 33 | for _, dp := range sfl.Items { 34 | status := resource.StatefulsetNotReady 35 | 36 | if dp.Status.ReadyReplicas == dp.Status.Replicas { 37 | status = resource.StatefulsetReady 38 | } 39 | 40 | sfs = append(sfs, resource.Statefulset{ 41 | Name: dp.Name, 42 | Status: status, 43 | }) 44 | } 45 | 46 | return sfs, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/mock/inventory.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/Meetic/blackbeard/pkg/playbook" 4 | 5 | type inventoryRepository struct{} 6 | 7 | // NewInventoryRepository returns a Mock InventoryRepository 8 | func NewInventoryRepository() playbook.InventoryRepository { 9 | return &inventoryRepository{} 10 | } 11 | 12 | func (ir *inventoryRepository) Get(namespace string) (playbook.Inventory, error) { 13 | playbooks := NewPlaybookRepository() 14 | inv, _ := playbooks.GetDefault() 15 | inv.Namespace = namespace 16 | 17 | return inv, nil 18 | } 19 | 20 | func (ir *inventoryRepository) Create(inventory playbook.Inventory) error { 21 | return nil 22 | } 23 | 24 | func (ir *inventoryRepository) Delete(namespace string) error { 25 | return nil 26 | } 27 | 28 | func (ir *inventoryRepository) Update(namespace string, inv playbook.Inventory) error { 29 | return nil 30 | } 31 | 32 | func (ir *inventoryRepository) Exists(namespace string) bool { 33 | return true 34 | } 35 | 36 | func (ir *inventoryRepository) List() ([]playbook.Inventory, error) { 37 | var inventories []playbook.Inventory 38 | 39 | inv1, _ := ir.Get("test1") 40 | inv2, _ := ir.Get("test2") 41 | 42 | inventories = append(inventories, inv1, inv2) 43 | 44 | return inventories, nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var getCmd = &cobra.Command{ 13 | Use: "get", 14 | Short: "Show informations about a given namespace.", 15 | Long: `This command display informations from a given namespace such as the list of exposed services 16 | or the url where you can join services throw ingress.`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | runGet() 19 | }, 20 | } 21 | 22 | func NewGetCommand() *cobra.Command { 23 | addCommonNamespaceCommandFlags(getCmd) 24 | 25 | getCmd.AddCommand(NewGetNamespacesCommand()) 26 | getCmd.AddCommand(NewGetServicesCommand()) 27 | 28 | return getCmd 29 | } 30 | 31 | func runGet() { 32 | tpl := template.Must(template.New("getCmd").Parse(` 33 | Using the get command without any sub-command makes no sens. Please use one of the following sub-command : 34 | {{range .}} 35 | - {{.}} 36 | {{end}} 37 | `)) 38 | 39 | data := []string{"get services", "get namespaces"} 40 | 41 | contents := bytes.Buffer{} 42 | if err := tpl.Execute(&contents, data); err != nil { 43 | logrus.Fatalf("error when executing template : %v", err) 44 | } 45 | 46 | fmt.Println(contents.String()) 47 | 48 | } 49 | -------------------------------------------------------------------------------- /cmd/reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // resetCmd represents the reset command 11 | var resetCmd = &cobra.Command{ 12 | Use: "reset", 13 | Short: "Reset a namespace based on the template files and the default inventory.", 14 | Long: `This command will override the inventory and the config files for the given namespace and apply the changes into Kubernetes.`, 15 | 16 | Run: func(cmd *cobra.Command, args []string) { 17 | err := runReset(namespace) 18 | if err != nil { 19 | logrus.Fatal(err.Error()) 20 | } 21 | }, 22 | } 23 | 24 | func NewResetCommand() *cobra.Command { 25 | addCommonNamespaceCommandFlags(resetCmd) 26 | return resetCmd 27 | } 28 | 29 | func runReset(namespace string) error { 30 | 31 | if namespace == "" { 32 | return errors.New("you must specified a namespace using the --namespace flag") 33 | } 34 | 35 | files := newFileClient(playbookDir) 36 | 37 | api := newAPI(files, newKubernetesClient()) 38 | 39 | //Reset inventory file 40 | err := api.Reset(namespace, files.ConfigPath()) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | logrus.WithFields(logrus.Fields{ 46 | "namespace": namespace, 47 | }).Info("namespace has been reset successfully") 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /docs/playbooks/inventories.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Inventories" 3 | anchor: "inventories" 4 | weight: 42 5 | --- 6 | 7 | An inventory is a file containing a json object. This object is made available inside the templates by Blackbeard. 8 | 9 | The only constraints on an inventory json object are : 10 | 11 | * An inventory must contains a `namespace` key, containing a string. The string value must be the name of the namespace the inventory is associated to; 12 | * An inventory must contains a `values` key. This key may contains whatever you want. 13 | * An inventory file must be located in the `inventories` directory 14 | * An inventory file must be named after the template : `{{namespace}}_inventory.json` 15 | 16 | **Example** : `john_inventory.json` 17 | 18 | ```json 19 | { 20 | "namespace": "john", 21 | "values": { 22 | "api": { 23 | "version": "1.2.0", 24 | "memoryLimit": "128m" 25 | }, 26 | "front": { 27 | "version": "1.2.1" 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | Inventories are generated from the `defaults.json` file. Blackeard copy the `defaults.json` file content, create a inventory for the given namespace (located in the `inventories` directory), past the content default values and change the `namespace` key value with the corresponding namespace -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # set default shell 2 | SHELL := $(shell which bash) 3 | OSARCH := "linux/amd64 linux/386 windows/amd64 windows/386 darwin/amd64 darwin/386" 4 | ENV = /usr/bin/env 5 | PWD = $(shell pwd) 6 | 7 | .SHELLFLAGS = -c 8 | 9 | .SILENT: ; # no need for @ 10 | .ONESHELL: ; # recipes execute in same shell 11 | .NOTPARALLEL: ; # wait for this target to finish 12 | .EXPORT_ALL_VARIABLES: ; # send all vars to shell 13 | 14 | .PHONY: all 15 | .DEFAULT: build 16 | 17 | help: ## Show Help 18 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 19 | 20 | dep: ## Get build dependencies 21 | go get github.com/mitchellh/gox && \ 22 | go get github.com/mattn/goveralls 23 | 24 | build: ## Build blackbeard 25 | go build 26 | 27 | cross-build: ## Build blackbeard for multiple os/arch 28 | gox -osarch=$(OSARCH) -output "bin/blackbeard_{{.OS}}_{{.Arch}}" 29 | 30 | test: ## Launch tests 31 | go test -v ./... 32 | 33 | test-cover: ## Launch test coverage and send it to coverall 34 | $(ENV) ./scripts/test-coverage.sh 35 | 36 | release: ## Build release 37 | docker run --rm -v $(PWD):/go/src/github.com/Meetic/blackbeard -w /go/src/github.com/Meetic/blackbeard -e GITHUB_TOKEN -t goreleaser/goreleaser:latest release --rm-dist 38 | -------------------------------------------------------------------------------- /pkg/kubernetes/pod.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "github.com/Meetic/blackbeard/pkg/resource" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/client-go/kubernetes" 9 | ) 10 | 11 | type podRepository struct { 12 | kubernetes kubernetes.Interface 13 | } 14 | 15 | // NewPodRepository returns a new PodRepository. 16 | // The parameter is a go-client kubernetes client. 17 | func NewPodRepository(kubernetes kubernetes.Interface) resource.PodRepository { 18 | return &podRepository{ 19 | kubernetes: kubernetes, 20 | } 21 | } 22 | 23 | // GetPods of all the pods in a given namespace. 24 | // This method returns a Pods slice containing the pod name and the pod status (pod status phase). 25 | func (pr *podRepository) List(n string) (resource.Pods, error) { 26 | // get all pods except job or cron jobs in a succeeded state 27 | podsList, err := pr.kubernetes.CoreV1().Pods(n).List( 28 | context.Background(), 29 | metav1.ListOptions{FieldSelector: "status.phase!=Succeeded"}, 30 | ) 31 | 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | var pods resource.Pods 37 | 38 | for _, pod := range podsList.Items { 39 | 40 | pods = append(pods, resource.Pod{ 41 | Name: pod.ObjectMeta.Name, 42 | Status: pod.Status.Phase, 43 | }) 44 | } 45 | 46 | return pods, nil 47 | } 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Installation 4 | 5 | ### Requirements 6 | * go >= 1.8 7 | 8 | ### Go installation 9 | 10 | On Linux, follow the white rabbit : [https://golang.org/doc/install](https://golang.org/doc/install) 11 | 12 | Then, you need to configure what is called a "workspace". 13 | 14 | By default, the workspace is `$HOME/go`. 15 | 16 | If you want to use a different one, you have to set up you GOPATH env var. : [https://github.com/golang/go/wiki/Setting-GOPATH](https://github.com/golang/go/wiki/Setting-GOPATH) 17 | 18 | Last thing : if you want your binary to be executed from anywhere you should also add `$GOPATH/bin` to the PATH env var. 19 | 20 | ### Dependencies installation 21 | 22 | ```sh 23 | make dep 24 | ``` 25 | 26 | This simple make target will `go get` all the tool you need to work on Blackbeard 27 | 28 | ### Build 29 | 30 | ```sh 31 | make 32 | ``` 33 | 34 | ### Tests 35 | ```sh 36 | make test 37 | ``` 38 | 39 | ## Contributing 40 | 41 | All pull request and issue are welcomed. Note that this repo come with Travis-ci integration. 42 | So any PR will have to pass at least the travis job before being merged. 43 | 44 | ### Convention 45 | 46 | The Blackbeard code source tends to follow the Go code convention describe in the [Go code review comments](https://github.com/golang/go/wiki/CodeReviewComments). 47 | -------------------------------------------------------------------------------- /pkg/playbook/playbook.go: -------------------------------------------------------------------------------- 1 | package playbook 2 | 3 | import "text/template" 4 | 5 | // ConfigTemplate represents a set of kubernetes configuration template. 6 | // Usually, Template is expected to be golang template of yaml. 7 | type ConfigTemplate struct { 8 | Name string 9 | Template *template.Template 10 | } 11 | 12 | // PlaybookService represents the way playbook are managed 13 | type PlaybookService interface { 14 | GetDefault() (Inventory, error) 15 | GetTemplate() ([]ConfigTemplate, error) 16 | } 17 | 18 | // PlaybookRepository is an actual implementation of playbook management 19 | type PlaybookRepository interface { 20 | GetDefault() (Inventory, error) 21 | GetTemplate() ([]ConfigTemplate, error) 22 | } 23 | 24 | type playbookService struct { 25 | playbooks PlaybookRepository 26 | } 27 | 28 | // NewPlaybookService returns a new PlaybookService 29 | func NewPlaybookService(playbooks PlaybookRepository) PlaybookService { 30 | return &playbookService{ 31 | playbooks: playbooks, 32 | } 33 | } 34 | 35 | // GetTemplate returns the templates of a playbook 36 | func (ps *playbookService) GetTemplate() ([]ConfigTemplate, error) { 37 | return ps.playbooks.GetTemplate() 38 | } 39 | 40 | // GetDefault returns the default inventory of a playbook 41 | func (ps *playbookService) GetDefault() (Inventory, error) { 42 | return ps.playbooks.GetDefault() 43 | } 44 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // deleteCmd represents the delete command 13 | var deleteCmd = &cobra.Command{ 14 | Use: "delete [command]", 15 | Short: "Delete an object", 16 | Long: `Delete resources by namespace or names. 17 | 18 | Deletetion of a namespace will delete the namespace and remove all his attached object including the intentory attached to it. While removing an object will only supress it form the namespace but keep everything else.`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | runDelete() 21 | }, 22 | } 23 | 24 | func NewDeleteCommand() *cobra.Command { 25 | deleteCmd.AddCommand(NewDeleteJobCommand()) 26 | deleteCmd.AddCommand(NewDeleteNamespaceCommand()) 27 | 28 | return deleteCmd 29 | } 30 | 31 | func runDelete() { 32 | tpl := template.Must(template.New("deleteCmd").Parse(` 33 | Using the get command without any sub-command makes no sens. Please use one of the following sub-command : 34 | {{range . -}} 35 | - {{.}} 36 | {{end -}} 37 | `)) 38 | 39 | data := []string{"delete namespace", "delete job"} 40 | 41 | contents := bytes.Buffer{} 42 | if err := tpl.Execute(&contents, data); err != nil { 43 | logrus.Fatalf("error while executing template : %v", err) 44 | } 45 | 46 | fmt.Println(contents.String()) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/playbook/inventory_test.go: -------------------------------------------------------------------------------- 1 | package playbook_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Meetic/blackbeard/pkg/mock" 7 | "github.com/Meetic/blackbeard/pkg/playbook" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ( 12 | playbooks = playbook.NewPlaybookService(mock.NewPlaybookRepository()) 13 | inventories = playbook.NewInventoryService(mock.NewInventoryRepository(), playbooks) 14 | ) 15 | 16 | func TestCreateOK(t *testing.T) { 17 | inv, err := inventories.Create("test1") 18 | 19 | assert.Equal(t, inv.Namespace, "test1") 20 | assert.Nil(t, err) 21 | } 22 | 23 | func TestCreateEmptyNamespace(t *testing.T) { 24 | _, err := inventories.Create("") 25 | 26 | assert.Error(t, err) 27 | } 28 | 29 | func TestGetOK(t *testing.T) { 30 | inv, _ := inventories.Get("test") 31 | assert.Equal(t, inv.Namespace, "test") 32 | } 33 | 34 | func TestGetEmptyNamespace(t *testing.T) { 35 | _, err := inventories.Get("") 36 | 37 | assert.Error(t, err) 38 | } 39 | 40 | func TestListOk(t *testing.T) { 41 | _, err := inventories.List() 42 | 43 | assert.Nil(t, err) 44 | } 45 | 46 | func TestUpdateOk(t *testing.T) { 47 | def, _ := playbooks.GetDefault() 48 | 49 | assert.Nil(t, inventories.Update("test", def)) 50 | } 51 | 52 | func TestResetOk(t *testing.T) { 53 | inv, err := inventories.Reset("test") 54 | 55 | assert.Equal(t, "test", inv.Namespace) 56 | assert.Nil(t, err) 57 | } 58 | -------------------------------------------------------------------------------- /cmd/get_namespaces.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "text/tabwriter" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var getNamespacesCmd = &cobra.Command{ 14 | Use: "namespaces", 15 | Short: "Show informations about kubernetes namespaces.", 16 | Long: `Show informations about kubernetes namespaces such as names, status (percentage of pods in a running status), 17 | managed or not with the current playbook, etc.`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | err := runGetNamespaces() 20 | if err != nil { 21 | logrus.Fatal(err.Error()) 22 | } 23 | 24 | }, 25 | } 26 | 27 | func NewGetNamespacesCommand() *cobra.Command { 28 | return getNamespacesCmd 29 | } 30 | 31 | func runGetNamespaces() error { 32 | 33 | api := newAPI(newFileClient(playbookDir), newKubernetesClient()) 34 | 35 | namespaces, err := api.ListNamespaces() 36 | if err != nil { 37 | return errors.New(fmt.Sprintf("an error occurend when getting information about namespaces : %v", err)) 38 | } 39 | 40 | w := new(tabwriter.Writer) 41 | w.Init(os.Stdout, 0, 8, 0, '\t', 0) 42 | fmt.Fprintln(w, "Namespace\tPhase\tStatus\tManaged\t") 43 | for _, namespace := range namespaces { 44 | fmt.Fprint(w, fmt.Sprintf("%s\t%s\t%d%%\t%t\t\n", namespace.Name, namespace.Phase, namespace.Status, namespace.Managed)) 45 | } 46 | fmt.Fprintln(w) 47 | w.Flush() 48 | 49 | return nil 50 | 51 | } 52 | -------------------------------------------------------------------------------- /pkg/http/middleware.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "time" 8 | ) 9 | 10 | func jsonLogMiddleware() gin.HandlerFunc { 11 | return gin.LoggerWithConfig(gin.LoggerConfig{ 12 | Formatter: func(param gin.LogFormatterParams) string { 13 | fields := struct { 14 | Doctype string `json:"document_type"` 15 | Time string `json:"time"` 16 | Verb string `json:"verb"` 17 | Request string `json:"request"` 18 | User string `json:"user"` 19 | Httpversion string `json:"http_version"` 20 | Useragent string `json:"user_agent"` 21 | Remoteaddr string `json:"remoteaddr"` 22 | Status int `json:"status"` 23 | Responsetime float64 `json:"response_time"` 24 | Error string `json:"error"` 25 | }{ 26 | Doctype: "accesslog-blackbeard", 27 | Time: param.TimeStamp.Format(time.RFC3339), 28 | Verb: param.Method, 29 | Request: param.Path, 30 | User: param.Request.Header.Get("Remote-User"), 31 | Httpversion: param.Request.Proto, 32 | Useragent: param.Request.UserAgent(), 33 | Remoteaddr: param.ClientIP, 34 | Status: param.StatusCode, 35 | Responsetime: param.Latency.Seconds(), 36 | Error: param.ErrorMessage, 37 | } 38 | 39 | jsonLog, _ := json.Marshal(fields) 40 | 41 | return fmt.Sprintf(string(jsonLog) + "\n") 42 | }, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/delete_job.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var deleteJobCmd = &cobra.Command{ 12 | Use: "job [NAME]", 13 | Short: "Delete a job object from a namespace", 14 | Long: `Delete a job object that started a pod. 15 | 16 | It won't remove any configuration in the inventory. Reapplying the inventory will redeploy it. 17 | 18 | Kubernetes will also remove the pod whatever the status.`, 19 | Args: cobra.ExactArgs(1), 20 | Run: func(cmd *cobra.Command, args []string) { 21 | err := runDeleteJob(args[0]) 22 | if err != nil { 23 | logrus.Fatal(err.Error()) 24 | } 25 | }, 26 | } 27 | 28 | func NewDeleteJobCommand() *cobra.Command { 29 | addCommonNamespaceCommandFlags(deleteJobCmd) 30 | return deleteJobCmd 31 | } 32 | 33 | func runDeleteJob(resource string) error { 34 | if namespace == "" { 35 | // should set the namespace to default namespace value set in the kube/config 36 | return errors.New("you must specified a namespace using the --namespace flag") 37 | } 38 | 39 | api := newAPI(newFileClient(playbookDir), newKubernetesClient()) 40 | err := api.DeleteResource(namespace, resource) 41 | if err != nil { 42 | return errors.New(fmt.Sprintf("an error occurend when removing the job : %v", err)) 43 | } 44 | 45 | logrus.WithFields(logrus.Fields{ 46 | "namespace": namespace, 47 | "job": resource, 48 | }).Info("job deleted") 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /docs/getting-started/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Usage" 3 | anchor: "usage" 4 | weight: 22 5 | --- 6 | 7 | Blackbeard provide a CLI interface in addition to a REST API. This way, you can use it either to run automated tests in a CI pipeline or plug your own UI for manuel deployment purpose. 8 | 9 | {{% block note %}} 10 | Blackbeard requires `kubectl` to be installed and configured to work. 11 | {{% /block %}} 12 | 13 | #### Creating a new isolated env 14 | 15 | ```sh 16 | blackbeard create -n my-feature 17 | ``` 18 | 19 | This command actually *create a namespace* and generate a JSON configuration file containing default values. This file, called an `inventory`, is where you may update the values to apply specifically to your new namespace (such as microservice version) 20 | 21 | #### Applying changes 22 | 23 | ```sh 24 | blackbeard apply -n my-feature 25 | ``` 26 | 27 | This command apply your kubernetes manifest (modified by the values you have put in the generated `inventory` previously) to your newly created namespace 28 | 29 | #### Getting services endpoint / ports 30 | 31 | ```sh 32 | blackbeard get services -n my-feature 33 | ``` 34 | 35 | This step will prompt a list of exposed services in the namespace. If you need to connect to a database in order to test data insertion, it is where you will find useful info. 36 | 37 | #### Getting back to previous state 38 | 39 | ```sh 40 | blackbeard delete namespace my-feature 41 | ``` 42 | 43 | Delete all generated files and delete the namespace. 44 | -------------------------------------------------------------------------------- /docs/playbooks/templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Templates" 3 | anchor: "templates" 4 | weight: 41 5 | --- 6 | 7 | Playbooks expect `templates` and a *default* inventory. 8 | 9 | Templates are very simple. The only constraints are : 10 | 11 | * Templates must contains a valid Kubernetes manifest (yaml) 12 | * Template files must have a `.tpl` extension 13 | 14 | **Example** : `api.yml.tpl` 15 | 16 | ```yaml 17 | --- 18 | kind: Deployment 19 | apiVersion: extensions/v1beta1 20 | metadata: 21 | name: api 22 | spec: 23 | replicas: 1 24 | template: 25 | metadata: 26 | labels: 27 | app: api 28 | spec: 29 | containers: 30 | - name: api 31 | image: myCompany/MyApp:{{.Values.api.version}} 32 | args: ["-Xms{{.Values.api.memoryLimit}}", "-Xmx{{.Values.api.memoryLimit}}", "-Dconfig.resource=config.conf"] 33 | imagePullPolicy: Always 34 | --- 35 | kind: Service 36 | apiVersion: v1 37 | metadata: 38 | name: api 39 | spec: 40 | selector: 41 | app: api 42 | ports: 43 | - protocol: TCP 44 | port: 8080 45 | ``` 46 | 47 | {{% block tip %}} 48 | Under the hood, Blackbeard use [Go templating system](https://golang.org/pkg/text/template/). 49 | 50 | Blackbeard compile templates using the content of the inventory file. Thus, two variables are available inside the template : 51 | 52 | * `.Values` : contains a json object 53 | * `.Namespace` : contains a string 54 | 55 | You can also use this custom functions inside template : 56 | 57 | * `getFile "somefile.yml"` : return file content in string 58 | * `sha256sum` : return sha256 hash of a string 59 | 60 | {{% /block %}} 61 | 62 | 63 | -------------------------------------------------------------------------------- /cmd/get_services.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "text/tabwriter" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var getServicesCmd = &cobra.Command{ 14 | Use: "services", 15 | Short: "Show informations about exposed services from a given namespace.", 16 | Long: `This command display informations from a given namespace such as the list of exposed services 17 | or the url where you can join services throw ingress.`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | err := runGetServices() 20 | if err != nil { 21 | logrus.Fatal(err.Error()) 22 | } 23 | 24 | }, 25 | } 26 | 27 | func NewGetServicesCommand() *cobra.Command { 28 | addCommonNamespaceCommandFlags(getServicesCmd) 29 | return getServicesCmd 30 | } 31 | 32 | func runGetServices() error { 33 | 34 | if namespace == "" { 35 | return errors.New("you must specified a namespace using the --namespace flag") 36 | } 37 | 38 | api := newAPI(newFileClient(playbookDir), newKubernetesClient()) 39 | 40 | // get exposed services (NodePort, LoadBalancer) 41 | services, err := api.ListExposedServices(namespace) 42 | if err != nil { 43 | return errors.New(fmt.Sprintf("an error occurend when getting information about services : %v", err)) 44 | } 45 | 46 | w := new(tabwriter.Writer) 47 | w.Init(os.Stdout, 0, 8, 0, '\t', 0) 48 | fmt.Fprintln(w, "Service Name\tAddress\tPort\tExposed Port\t") 49 | for _, svc := range services { 50 | for _, p := range svc.Ports { 51 | fmt.Fprintf(w, fmt.Sprintf("%s\t%s\t%d\t%d\t\n", svc.Name, svc.Addr, p.Port, p.ExposedPort)) 52 | } 53 | } 54 | fmt.Fprintln(w) 55 | w.Flush() 56 | 57 | return nil 58 | 59 | } 60 | -------------------------------------------------------------------------------- /pkg/kubernetes/job.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/api/batch/v1" 8 | 9 | "github.com/Meetic/blackbeard/pkg/resource" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/client-go/kubernetes" 13 | ) 14 | 15 | type jobRepository struct { 16 | kubernetes kubernetes.Interface 17 | } 18 | 19 | // NewJobRepository returns a new JobRepository. 20 | // The parameter is a go-client kubernetes client. 21 | func NewJobRepository(kubernetes kubernetes.Interface) resource.JobRepository { 22 | return &jobRepository{ 23 | kubernetes: kubernetes, 24 | } 25 | } 26 | 27 | func (c *jobRepository) List(namespace string) (resource.Jobs, error) { 28 | jl, err := c.kubernetes.BatchV1().Jobs(namespace).List(context.Background(), metav1.ListOptions{}) 29 | 30 | if err != nil { 31 | return nil, fmt.Errorf("unable to list jobs: %v", err) 32 | } 33 | 34 | jobs := make(resource.Jobs, 0) 35 | 36 | for _, job := range jl.Items { 37 | status := resource.JobNotReady 38 | 39 | if len(job.Status.Conditions) == 0 { 40 | continue 41 | } 42 | 43 | if job.Status.Conditions[len(job.Status.Conditions)-1].Type == v1.JobComplete { 44 | status = resource.JobReady 45 | } 46 | 47 | jobs = append(jobs, resource.Job{ 48 | Name: job.Name, 49 | Status: status, 50 | }) 51 | } 52 | 53 | return jobs, nil 54 | } 55 | 56 | func (c *jobRepository) Delete(namespace, resourceName string) error { 57 | pp := metav1.DeletePropagationBackground 58 | if err := c.kubernetes.BatchV1().Jobs(namespace).Delete(context.Background(), resourceName, metav1.DeleteOptions{PropagationPolicy: &pp}); err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/mock/playbook.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "encoding/json" 5 | "text/template" 6 | 7 | "github.com/Meetic/blackbeard/pkg/playbook" 8 | ) 9 | 10 | const ( 11 | def = `{ 12 | "namespace": "default", 13 | "values": { 14 | "microservices": [ 15 | { 16 | "name": "api-advertising", 17 | "version": "latest", 18 | "urls": [ 19 | "api-advertising" 20 | ] 21 | }, 22 | { 23 | "name": "api-algo", 24 | "version": "latest", 25 | "urls": [ 26 | "api-algo" 27 | ] 28 | } 29 | ] 30 | } 31 | }` 32 | 33 | tpl = ` 34 | {{range .Values.microservices}} 35 | --- 36 | kind: Deployment 37 | apiVersion: extensions/v1beta1 38 | metadata: 39 | name: {{.name}} 40 | spec: 41 | replicas: 1 42 | template: 43 | metadata: 44 | labels: 45 | app: fpm-{{.name}} 46 | spec: 47 | containers: 48 | - name: {{.name}} 49 | image: docker.io/{{.name}}:{{.version}} 50 | {{end}} 51 | ` 52 | ) 53 | 54 | type playbooks struct{} 55 | 56 | func NewPlaybookRepository() playbook.PlaybookRepository { 57 | return &playbooks{} 58 | } 59 | 60 | func (p *playbooks) GetTemplate() ([]playbook.ConfigTemplate, error) { 61 | 62 | var templates []playbook.ConfigTemplate 63 | 64 | templates = append(templates, playbook.ConfigTemplate{ 65 | Name: "template.yml", 66 | Template: template.Must(template.New("tpl").Parse(tpl)), 67 | }) 68 | 69 | return templates, nil 70 | } 71 | 72 | func (p *playbooks) GetDefault() (playbook.Inventory, error) { 73 | 74 | var inventory playbook.Inventory 75 | 76 | if err := json.Unmarshal([]byte(def), &inventory); err != nil { 77 | return playbook.Inventory{}, playbook.NewErrorReadingDefaultsFile(err) 78 | } 79 | 80 | return inventory, nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/mock/namespace.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/Meetic/blackbeard/pkg/resource" 5 | "k8s.io/client-go/kubernetes" 6 | ) 7 | 8 | type namespaceRepository struct { 9 | kubernetes kubernetes.Interface 10 | createFailure bool 11 | } 12 | 13 | // NewNamespaceRepository returns a new NamespaceRepository. 14 | // The parameter is a go-client Kubernetes client 15 | func NewNamespaceRepository(kubernetes kubernetes.Interface, createFailure bool) resource.NamespaceRepository { 16 | return &namespaceRepository{ 17 | kubernetes: kubernetes, 18 | createFailure: createFailure, 19 | } 20 | } 21 | 22 | // Create creates a namespace 23 | func (ns *namespaceRepository) Create(namespace string) error { 24 | if ns.createFailure { 25 | return resource.ErrorCreateNamespace{Msg: "namespace " + namespace + " already exist"} 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func (ns *namespaceRepository) Get(namespace string) (*resource.Namespace, error) { 32 | return &resource.Namespace{Name: namespace, Phase: "Active", Status: 100}, nil 33 | } 34 | 35 | // Delete deletes a given namespace 36 | func (ns *namespaceRepository) Delete(namespace string) error { 37 | return nil 38 | } 39 | 40 | // List returns a slice of Namespace. 41 | // Name is the namespace name from Kubernetes. 42 | // Phase is the status phase. 43 | // List returns an error if the namespace list could not be get from Kubernetes cluster. 44 | func (ns *namespaceRepository) List() ([]resource.Namespace, error) { 45 | namespaces := []resource.Namespace{ 46 | { 47 | Name: "test", 48 | Phase: "Active", 49 | }, 50 | } 51 | 52 | return namespaces, nil 53 | } 54 | 55 | // ApplyConfig loads configuration files into kubernetes 56 | func (ns *namespaceRepository) ApplyConfig(namespace, configPath string) error { 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /cmd/create.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "path/filepath" 9 | 10 | "github.com/Meetic/blackbeard/pkg/playbook" 11 | "github.com/sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // createCmd represents the create command 16 | var createCmd = &cobra.Command{ 17 | Use: "create", 18 | Short: "Create a namespace and generated a dedicated inventory.", 19 | Long: `This command will generate an inventory file called {{namespace}}_inventory.json 20 | 21 | This file contains all the parameters needed to build a complete Kubernetes configuration. 22 | Feel free to edit this file before applying changes. 23 | `, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | err := runCreate(namespace) 26 | if err != nil { 27 | logrus.Fatal(err.Error()) 28 | } 29 | }, 30 | } 31 | 32 | func NewCreateCommand() *cobra.Command { 33 | addCommonNamespaceCommandFlags(createCmd) 34 | return createCmd 35 | } 36 | 37 | func runCreate(namespace string) error { 38 | 39 | if namespace == "" { 40 | return errors.New("you must specified a namespace using the --namespace flag") 41 | } 42 | 43 | files := newFileClient(playbookDir) 44 | 45 | api := newAPI(files, newKubernetesClient()) 46 | 47 | inv, err := api.Create(namespace) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | tpl := template.Must(template.New("config").Parse(`Namespace for user {{.Inv.Namespace}} has been created ! 53 | 54 | A inventory file has been generated : {{.File}} 55 | Feel free to edit this file to match your desired testing env configuration. 56 | `)) 57 | 58 | message := bytes.Buffer{} 59 | if err := tpl.Execute(&message, struct { 60 | File string 61 | Inv playbook.Inventory 62 | }{ 63 | File: filepath.Join(files.InventoryPath(), inv.Namespace+"_inventory.json"), 64 | Inv: inv, 65 | }); err != nil { 66 | return err 67 | } 68 | 69 | fmt.Println(message.String()) 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "k8s.io/client-go/kubernetes/fake" 8 | 9 | "github.com/Meetic/blackbeard/pkg/api" 10 | "github.com/Meetic/blackbeard/pkg/kubernetes" 11 | "github.com/Meetic/blackbeard/pkg/mock" 12 | "github.com/Meetic/blackbeard/pkg/resource" 13 | ) 14 | 15 | var ( 16 | kube = fake.NewSimpleClientset() 17 | 18 | blackbeard = api.NewApi( 19 | mock.NewInventoryRepository(), 20 | mock.NewConfigRepository(), 21 | mock.NewPlaybookRepository(), 22 | mock.NewNamespaceRepository(kube, false), 23 | kubernetes.NewPodRepository(kube), 24 | kubernetes.NewDeploymentRepository(kube), 25 | kubernetes.NewStatefulsetRepository(kube), 26 | kubernetes.NewServiceRepository(kube, "kube.test"), 27 | kubernetes.NewClusterRepository(), 28 | kubernetes.NewJobRepository(kube), 29 | ) 30 | ) 31 | 32 | type clusterRepositoryMock struct{} 33 | 34 | func (clusterRepositoryMock) GetVersion() (*resource.Version, error) { 35 | return &resource.Version{ 36 | ServerVersion: struct { 37 | Major string `json:"major"` 38 | Minor string `json:"minor"` 39 | }{Major: "1", Minor: "2"}, 40 | ClientVersion: struct { 41 | Major string `json:"major"` 42 | Minor string `json:"minor"` 43 | }{Major: "0", Minor: "9"}, 44 | }, nil 45 | } 46 | 47 | func TestGetVersion(t *testing.T) { 48 | blackbeard = api.NewApi( 49 | mock.NewInventoryRepository(), 50 | mock.NewConfigRepository(), 51 | mock.NewPlaybookRepository(), 52 | mock.NewNamespaceRepository(kube, false), 53 | kubernetes.NewPodRepository(kube), 54 | kubernetes.NewDeploymentRepository(kube), 55 | kubernetes.NewStatefulsetRepository(kube), 56 | kubernetes.NewServiceRepository(kube, "kube.test"), 57 | new(clusterRepositoryMock), 58 | kubernetes.NewJobRepository(kube), 59 | ) 60 | 61 | version, err := blackbeard.GetVersion() 62 | 63 | assert.Nil(t, err) 64 | assert.Equal(t, version, &api.Version{Blackbeard: "dev", Kubernetes: "1.2", Kubectl: "0.9"}) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/gosuri/uiprogress" 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | const ( 13 | defaultTimeout = 5 * time.Minute 14 | ) 15 | 16 | // applyCmd represents the apply command 17 | var applyCmd = &cobra.Command{ 18 | Use: "apply", 19 | Short: "Apply a given inventory to the associated namespace", 20 | Long: `This command will update the configuration files for the given namespace using the inventory file 21 | and apply the changes to the Kubernetes namespace. 22 | `, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | err := runApply(namespace) 25 | if err != nil { 26 | logrus.Fatal(err) 27 | } 28 | 29 | }, 30 | } 31 | 32 | func NewApplyCommand() *cobra.Command { 33 | addCommonNamespaceCommandFlags(applyCmd) 34 | applyCmd.Flags().BoolVar(&wait, "wait", false, "wait until all pods are running") 35 | applyCmd.Flags().DurationVarP(&timeout, "timeout", "t", defaultTimeout, "The max time to wait for pods to be all running.") 36 | 37 | return applyCmd 38 | } 39 | 40 | func runApply(namespace string) error { 41 | 42 | if namespace == "" { 43 | return errors.New("you must specified a namespace using the --namespace flag") 44 | } 45 | 46 | files := newFileClient(playbookDir) 47 | api := newAPI(files, newKubernetesClient()) 48 | 49 | err := api.Apply(namespace, files.ConfigPath()) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | logrus.WithFields(logrus.Fields{ 55 | "namespace": namespace, 56 | }).Info("Playbook has been deployed") 57 | 58 | if wait { 59 | logrus.WithFields(logrus.Fields{ 60 | "namespace": namespace, 61 | }).Info("Waiting for namespace to be ready...") 62 | //init progress bar 63 | uiprogress.Start() 64 | bar := uiprogress.AddBar(100).AppendCompleted().PrependElapsed() 65 | 66 | if err := api.WaitForNamespaceReady(namespace, timeout, bar); err != nil { 67 | return err 68 | } 69 | 70 | logrus.WithFields(logrus.Fields{ 71 | "namespace": namespace, 72 | }).Info("Namespace is ready") 73 | 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/resource/service.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import "fmt" 4 | 5 | // Service represent a kubernetes service 6 | // Name is the service name 7 | // Addr is the domain name where the service can be reach from outside the kubernetes cluster. 8 | // For ingress exposed services it is the domain name declared in the ingress configuration 9 | // for node port exposed services it is the ip / domain name of the cluster. 10 | type Service struct { 11 | Name string `json:"name"` 12 | Ports []Port `json:"ports"` 13 | Addr string `json:"addr"` 14 | } 15 | 16 | // Port represent a kubernetes service port. 17 | // This mean an internal port and a exposed port 18 | type Port struct { 19 | Port int32 `json:"port"` 20 | ExposedPort int32 `json:"exposedPort"` 21 | } 22 | 23 | // ServiceService defines the way kubernetes services are managed 24 | type ServiceService interface { 25 | ListExposed(namespace string) ([]Service, error) 26 | } 27 | 28 | // ServiceRepository defines the way to interact with Kubernetes 29 | type ServiceRepository interface { 30 | ListExternal(n string) ([]Service, error) 31 | ListIngress(n string) ([]Service, error) 32 | } 33 | 34 | type serviceService struct { 35 | services ServiceRepository 36 | } 37 | 38 | // NewServiceService returns a ServicesService 39 | func NewServiceService(services ServiceRepository) ServiceService { 40 | return &serviceService{ 41 | services: services, 42 | } 43 | } 44 | 45 | // ListExposed find services exposed as NodePort and ingress configuration and return 46 | // an array of services containing an URL, the exposed port and the service name. 47 | func (ss *serviceService) ListExposed(namespace string) ([]Service, error) { 48 | 49 | var ( 50 | services []Service 51 | err error 52 | ) 53 | 54 | services, err = ss.services.ListExternal(namespace) 55 | if err != nil { 56 | return nil, fmt.Errorf("unable to list external services %s", err.Error()) 57 | } 58 | 59 | ingress, err := ss.services.ListIngress(namespace) 60 | if err != nil { 61 | return nil, fmt.Errorf("unable to list ingress entries %s", err.Error()) 62 | } 63 | 64 | services = append(services, ingress...) 65 | 66 | return services, nil 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Blackbeard Logo 3 |

Blackbeard

4 |

A namespace manager for Kubernetes.

5 |

6 | 7 | --- 8 | [![Build Status](https://travis-ci.org/Meetic/blackbeard.svg?branch=master)](https://travis-ci.org/Meetic/blackbeard) [![Go Report Card](https://goreportcard.com/badge/github.com/Meetic/blackbeard)](https://goreportcard.com/report/github.com/Meetic/blackbeard) [![GitHub license](https://img.shields.io/github/license/Meetic/blackbeard.svg)](https://github.com/Meetic/blackbeard/blob/master/LICENSE) 9 | [![GitHub release](https://img.shields.io/github/release/Meetic/blackbeard.svg)](https://github.com/Meetic/blackbeard) [![Twitter](https://img.shields.io/twitter/url/https/github.com/Meetic/blackbeard.svg?style=social)](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2FMeetic%2Fblackbeard) 10 | 11 | ## Introduction 12 | 13 | **Blackbeard is a namespace manager for Kubernetes.** It helps you to develop and test with Kubernetes using namespaces. 14 | 15 | Blackbeard helps you to deploy your Kubernetes manifests on multiple namespaces, making each of them running a different version of your microservices. You may use it to manage development environment (one namespace per developer) or for testing purpose (one namespace for each feature to test before deploying in production). 16 | 17 | ## Playbooks 18 | 19 | Blackbeard use *playbooks* to manage namespaces. A playbook is a collection of kubernetes manifest describing your stack, written as templates. A playbook also require a `default.json`, providing the default values to apply to the templates. 20 | 21 | Playbooks are created as files laid out in a particular directory tree. 22 | 23 | ## Requirements 24 | 25 | You must have `kubectl` installed and configured to use Blackbeard 26 | 27 | ## Installation 28 | 29 | ```sh 30 | curl -sf https://raw.githubusercontent.com/Meetic/blackbeard/master/install.sh | sh 31 | ``` 32 | 33 | ## Example 34 | 35 | You may find a fully working example in the [example directory](example/) 36 | 37 | ## Documentation 38 | 39 | Documentation is available on the [Blackbeard website](https://blackbeard.netlify.com) 40 | 41 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Playbook example 2 | 3 | The following example shows a real use case of Blackbeard usage. 4 | 5 | This example describe a technical stack composed of : 6 | 7 | * 2 versions of an API; 8 | * A front-end app. 9 | 10 | ## Build my-app by yourself 11 | 12 | If you want to fully test this example, you may want to build the docker images yourself. To do that, edit the `my-app/Makefile` file and change the following env var : 13 | 14 | ```sh 15 | DOCKER_API_V1 = "seblegall/blackbeard-example-api:v1" 16 | DOCKER_API_V2 = "seblegall/blackbeard-example-api:v2" 17 | DOCKER_FRONT = "seblegall/blackbeard-example-front:v1" 18 | ``` 19 | 20 | *Replace the docker images name using your own docker hub namespace.* 21 | 22 | ## Using blackbeard to deploy my-app 23 | 24 | ### Requirement 25 | 26 | You must have `kubectl` installed and configured and a Kubernetes cluster ready. 27 | 28 | *Tips: On MacOS and Windows, you may use the built-in Kubernetes cluster with docker-for-desktop* 29 | 30 | You must be located in the `playbook` directory : 31 | 32 | ```sh 33 | cd my-app-playbook 34 | ``` 35 | 36 | ### Create a namespace using the v1 api 37 | 38 | ```sh 39 | blackbeard create -n v1 40 | blackbeard apply -n v1 41 | ``` 42 | 43 | Those commands will create a namespace called `v1` and deploy the API (using the v1 version) and the front-end app in this namespace. 44 | 45 | You may check that the api v1 is actually running with this command : 46 | 47 | ```sh 48 | kubectl logs {api_pod_name} -n v1 49 | ``` 50 | 51 | ### Test api v2 in a different namespace 52 | 53 | Now, if you'd like to run the API v2 in a different namespace, you may run : 54 | 55 | ```sh 56 | blackbeard create -n v2 57 | ``` 58 | 59 | Edit the `inventories/v2_inventory.json` file and change the version value. This file now should look like : 60 | 61 | ```json 62 | { 63 | "namespace": "v2", 64 | "values": { 65 | "api": { 66 | "version": "v2" 67 | }, 68 | "front": { 69 | "version": "v1" 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | Finally, apply the changes : 76 | 77 | ```sh 78 | blackbeard apply -n v2 79 | ``` 80 | 81 | Now, you can check that v2 is actually deployed by running the following command : 82 | 83 | ```sh 84 | kubectl logs {api_pod_name} -n v2 85 | ``` 86 | -------------------------------------------------------------------------------- /pkg/api/namespace.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/Meetic/blackbeard/pkg/resource" 8 | ) 9 | 10 | const ( 11 | tickerDuration = 2 * time.Second 12 | ) 13 | 14 | // Namespace represents a kubernetes namespace enrich with informations from the playbook. 15 | type Namespace struct { 16 | //Name is the namespace name 17 | Name string 18 | //Phase is the namespace status phase. It could be "active" or "terminating" 19 | Phase string 20 | //Status is the namespace status. It is a percentage of runnning pods vs all pods in the namespace. 21 | Status int 22 | //Managed is true if the namespace as an associated inventory on the current playbook. False if not. 23 | Managed bool 24 | } 25 | 26 | // ListNamespaces returns a list of Namespace. 27 | // For each kubernetes namespace, it checks if an associated inventory exists. 28 | func (api *api) ListNamespaces() ([]Namespace, error) { 29 | nsList, err := api.namespaces.List() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | var namespaces []Namespace 35 | 36 | for _, ns := range nsList { 37 | 38 | namespace := Namespace{ 39 | Name: ns.Name, 40 | Phase: ns.Phase, 41 | Status: ns.Status, 42 | Managed: false, 43 | } 44 | 45 | if api.inventories.Exists(ns.Name) { 46 | namespace.Managed = true 47 | } 48 | 49 | namespaces = append(namespaces, namespace) 50 | } 51 | 52 | return namespaces, nil 53 | 54 | } 55 | 56 | type progress interface { 57 | Set(int) error 58 | } 59 | 60 | // WaitForNamespaceReady wait until all pods in the specified namespace are ready. 61 | // And error is returned if the timeout is reach. 62 | func (api *api) WaitForNamespaceReady(namespace string, timeout time.Duration, bar progress) error { 63 | 64 | ticker := time.NewTicker(tickerDuration) 65 | timerCh := time.NewTimer(timeout).C 66 | doneCh := make(chan bool) 67 | 68 | go func(bar progress, ns resource.NamespaceService, namespace string) { 69 | for range ticker.C { 70 | status, err := ns.GetStatus(namespace) 71 | if err != nil { 72 | ticker.Stop() 73 | } 74 | bar.Set(status.Status) 75 | if status.Status == 100 { 76 | doneCh <- true 77 | } 78 | } 79 | }(bar, api.namespaces, namespace) 80 | 81 | for { 82 | select { 83 | case <-timerCh: 84 | ticker.Stop() 85 | return fmt.Errorf("time out : Some pods are not yet ready") 86 | case <-doneCh: 87 | return nil 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/files/config.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/Meetic/blackbeard/pkg/playbook" 9 | ) 10 | 11 | const ( 12 | tplSuffix = ".tpl" 13 | ) 14 | 15 | type configs struct { 16 | configPath string 17 | } 18 | 19 | // NewConfigRepository returns a new ConfigRepository 20 | // It takes as parameters the directory where configs are stored. 21 | // Typically, the templates files for a given playbook are in a "templates" directory at the root of the playbook 22 | // and configs are stored in a "configs" directory located at the root of the playbook 23 | func NewConfigRepository(configPath string) playbook.ConfigRepository { 24 | return &configs{ 25 | configPath: configPath, 26 | } 27 | } 28 | 29 | // Save writes kubernetes configs for a given namespace in files. 30 | // files are named after the Config.Name value 31 | func (cr *configs) Save(namespace string, configs []playbook.Config) error { 32 | 33 | //Create config dir for a given namespace 34 | configDir := filepath.Join(cr.configPath, namespace) 35 | if _, err := os.Stat(configDir); os.IsNotExist(err) { 36 | if e := os.Mkdir(configDir, os.ModePerm); e != nil { 37 | return fmt.Errorf("the configs dir '%s' could not be created : %s", configDir, e.Error()) 38 | } 39 | } 40 | 41 | for _, config := range configs { 42 | f, err := os.Create(filepath.Join(configDir, config.Name)) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | _, errorWrite := f.Write([]byte(config.Values)) 48 | if errorWrite != nil { 49 | return errorWrite 50 | } 51 | 52 | f.Close() 53 | } 54 | 55 | return nil 56 | } 57 | 58 | // Delete remove a config directory 59 | // if the specified config dir does not exist, Delete return nil and does nothing. 60 | func (cr *configs) Delete(namespace string) error { 61 | if !cr.exists(namespace) { 62 | return nil 63 | } 64 | return os.RemoveAll(filepath.Join(cr.configPath, namespace)) 65 | } 66 | 67 | // exists return true if a config dir for the given namespace already exist. 68 | // Else, it return false. 69 | func (cr *configs) exists(namespace string) bool { 70 | if _, err := os.Stat(cr.path(namespace)); os.IsNotExist(err) { 71 | return false 72 | } else if err == nil { 73 | return true 74 | } 75 | return false 76 | } 77 | 78 | // path return the config dir path of a given namespace 79 | func (cr *configs) path(namespace string) string { 80 | return filepath.Join(cr.configPath, namespace) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-contrib/cors" 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/Meetic/blackbeard/pkg/api" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Handler actually handle http requests. 14 | // It use a router to map uri to HandlerFunc 15 | type Handler struct { 16 | api api.Api 17 | configPath string 18 | 19 | engine *gin.Engine 20 | } 21 | 22 | // NewHandler create a Handler using defined routes. 23 | // It takes a client as argument in order to be pass to the handler and be accessible to the HandlerFunc 24 | // Typically in a CRUD API, the client manage connections to a storage system. 25 | func NewHandler(api api.Api, configPath string, corsEnable bool) *Handler { 26 | h := &Handler{ 27 | api: api, 28 | configPath: configPath, 29 | } 30 | 31 | h.engine = gin.New() 32 | h.engine.Use(jsonLogMiddleware(), gin.Recovery()) 33 | 34 | if corsEnable == true { 35 | config := cors.DefaultConfig() 36 | config.AllowAllOrigins = true 37 | config.AddAllowHeaders("authorization") 38 | h.engine.Use(cors.New(config)) 39 | logrus.Info("CORS are enabled") 40 | } 41 | 42 | h.engine.GET("/ready", h.HealthCheck) 43 | h.engine.GET("/alive", h.HealthCheck) 44 | h.engine.POST("/inventories", h.Create) 45 | h.engine.GET("/inventories/:namespace", h.Get) 46 | h.engine.GET("/inventories/:namespace/status", h.GetStatus) 47 | h.engine.POST("/inventories/:namespace/reset", h.Reset) 48 | h.engine.GET("/inventories/:namespace/services", h.ListServices) 49 | h.engine.GET("/inventories", h.List) 50 | //h.engine.GET("/inventories/status", h.GetStatuses) 51 | h.engine.GET("/defaults", h.GetDefaults) 52 | h.engine.PUT("/inventories/:namespace", h.Update) 53 | h.engine.DELETE("/inventories/:namespace", h.Delete) 54 | h.engine.DELETE("/resources/:namespace/jobs/:resource", h.DeleteResource) 55 | h.engine.GET("/version", h.Version) 56 | 57 | return h 58 | } 59 | 60 | // Engine returns the defined router for the Handler 61 | func (h *Handler) Engine() *gin.Engine { return h.engine } 62 | 63 | // Server represents a http server that handle request 64 | type Server struct { 65 | handler *Handler 66 | } 67 | 68 | // NewServer return a http server with a given handler 69 | func NewServer(h *Handler) *Server { 70 | return &Server{ 71 | handler: h, 72 | } 73 | } 74 | 75 | // Serve launch the webserver 76 | func (s *Server) Serve(port int) { 77 | s.handler.Engine().Run(fmt.Sprintf(":%d", port)) 78 | } 79 | -------------------------------------------------------------------------------- /docs/playbooks/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Playbooks" 3 | anchor: "playbooks" 4 | weight: 40 5 | --- 6 | 7 | Blackbeard uses `Playbooks` to manage technical stack deployments across multiple namespaces. 8 | 9 | A Playbook is organized as a collection of files inside of a directory. Inside of this directory, Blackbeard will expect a structure that matches this: 10 | 11 | * A `templates` directory, containing Kubernetes manifests, written as templates. Those are typical configuration files for K8s (yaml). 12 | * A `defaults.json` file, defining the default values to apply (to the manifest templates) 13 | * An `inventories` directory that will contains the future inventories (One per namespace). The content of this directory should not be versioned. Inventories are variance of the `defaults.json` file. 14 | * A `configs` directory that will contains the future manifests files (one sub-dir per namespace). The content of this directory should not be versioned as well. Manifests are generated by applying the `inventory` values to the `template` 15 | 16 | By default, Blackbeard will try to use the current directory as a Playbook. You can also specify a default playbook using a configuration file. 17 | 18 | Thus, a Playbook made for an application called *"MyApp"* will look like : 19 | 20 | ```sh 21 | MyApp 22 | ├── README.md 23 | ├── configs #Directory containing generated manifest for each inventory 24 | │ ├── john #john is a namespace 25 | │   │ ├── api.yml #Kubernetes manifest containing deployments and services for the api app. 26 | │ │ └── front.yml #Kubernetes manifest containing deployments and services for the front-end app. 27 | │   └── awesome-feature-to-test #awesome-feature-to-test is a namespace 28 | │   ├── api.yml 29 | │ └── front.yml 30 | ├── defaults.json #Default inventory file 31 | ├── inventories #Directory containing inventories for each namespace 32 | │ ├── john_inventory.json #inventory for john namespace 33 | │   └── awesome-feature-to-test_inventory.json #inventory for awesome-feature-to-test namespace 34 | └── templates #Directory containing the Kubernetes manifest templates 35 | ├── api.yml.tpl 36 | └── front.yml.tpl 37 | ``` 38 | 39 | {{% block info %}} 40 | On this example, you can see the stack contains an API and a front-end application and is deployed on 2 namespaces : 41 | 42 | * One called *john* is used by john for development purpose 43 | * One called *awesome-feature-to-test* is used for testing a feature on an isolated 44 | 45 | {{% /block %}} -------------------------------------------------------------------------------- /pkg/http/resource_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/Meetic/blackbeard/pkg/playbook" 9 | ) 10 | 11 | // ListServices returns the list of exposed services (NodePort and ingress configuration) of a given inventory 12 | func (h *Handler) ListServices(c *gin.Context) { 13 | 14 | services, err := h.api.ListExposedServices(c.Params.ByName("namespace")) 15 | 16 | if err != nil { 17 | if notFound, ok := err.(playbook.ErrorInventoryNotFound); ok { 18 | c.JSON(http.StatusNotFound, gin.H{"error": notFound.Error()}) 19 | return 20 | } 21 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 22 | return 23 | } 24 | 25 | c.JSON(http.StatusOK, services) 26 | } 27 | 28 | // GetStatus returns the namespace status (ready or not) for a given namespace 29 | func (h *Handler) GetStatus(c *gin.Context) { 30 | 31 | _, err := h.api.Inventories().Get(c.Params.ByName("namespace")) 32 | 33 | if err != nil { 34 | if notFound, ok := err.(playbook.ErrorInventoryNotFound); ok { 35 | c.JSON(http.StatusNotFound, gin.H{"error": notFound.Error()}) 36 | return 37 | } 38 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 39 | return 40 | } 41 | 42 | status, err := h.api.Namespaces().GetStatus(c.Params.ByName("namespace")) 43 | 44 | if err != nil { 45 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 46 | return 47 | } 48 | 49 | c.JSON(http.StatusOK, status) 50 | } 51 | 52 | // GetStatuses returns an array of namespaces and their associated status 53 | func (h *Handler) GetStatuses(c *gin.Context) { 54 | 55 | invs, _ := h.api.Inventories().List() 56 | 57 | var statuses []struct { 58 | Namespace string `json:"namespace"` 59 | Status int `json:"status"` 60 | Phase string `json:"phase"` 61 | } 62 | 63 | for _, i := range invs { 64 | s, err := h.api.Namespaces().GetStatus(i.Namespace) 65 | if err != nil { 66 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 67 | return 68 | } 69 | 70 | statuses = append(statuses, struct { 71 | Namespace string `json:"namespace"` 72 | Status int `json:"status"` 73 | Phase string `json:"phase"` 74 | }{ 75 | Namespace: i.Namespace, 76 | Status: s.Status, 77 | Phase: s.Phase, 78 | }) 79 | } 80 | 81 | c.JSON(http.StatusOK, statuses) 82 | } 83 | 84 | // Delete handle the namespace deletion. 85 | func (h *Handler) DeleteResource(c *gin.Context) { 86 | namespace := c.Params.ByName("namespace") 87 | resource := c.Params.ByName("resource") 88 | 89 | //Delete inventory 90 | if err := h.api.DeleteResource(namespace, resource); err != nil { 91 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 92 | return 93 | } 94 | 95 | c.JSON(http.StatusNoContent, nil) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/files/playbook.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "text/template" 9 | 10 | "github.com/Masterminds/sprig" 11 | "github.com/Meetic/blackbeard/pkg/playbook" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type playbooks struct { 16 | templatePath string 17 | defaultsPath string 18 | } 19 | 20 | func NewPlaybookRepository(templatePath, defaultsPath string) playbook.PlaybookRepository { 21 | return &playbooks{ 22 | templatePath, 23 | defaultsPath, 24 | } 25 | } 26 | 27 | // GetTemplate returns the templates from the playbook 28 | func (p *playbooks) GetTemplate() ([]playbook.ConfigTemplate, error) { 29 | 30 | // Get templates list 31 | templates, _ := filepath.Glob(fmt.Sprintf("%s/*%s", p.templatePath, tplSuffix)) 32 | 33 | if templates == nil { 34 | return nil, fmt.Errorf("no template files found in directory %s", p.templatePath) 35 | } 36 | 37 | var cfgTpl []playbook.ConfigTemplate 38 | 39 | for _, templ := range templates { 40 | tpl := template.New(filepath.Base(templ)) 41 | 42 | p.initFuncMap(tpl) // add custom template functions 43 | 44 | tpl, err := tpl.ParseFiles(templ) 45 | if err != nil { 46 | return nil, fmt.Errorf("template cannot parse files: %v", err) 47 | } 48 | 49 | // create config file from tpl by removing the .tpl extension 50 | ext := filepath.Ext(templ) 51 | _, configFile := filepath.Split(templ[0 : len(templ)-len(ext)]) 52 | 53 | config := playbook.ConfigTemplate{ 54 | Name: configFile, 55 | Template: tpl, 56 | } 57 | 58 | cfgTpl = append(cfgTpl, config) 59 | } 60 | 61 | return cfgTpl, nil 62 | } 63 | 64 | // GetDefault reads the default inventory file and return an Inventory where namespace is set to "default" 65 | func (p *playbooks) GetDefault() (playbook.Inventory, error) { 66 | 67 | defaults, err := ioutil.ReadFile(p.defaultsPath) 68 | 69 | if err != nil { 70 | return playbook.Inventory{}, playbook.NewErrorReadingDefaultsFile(err) 71 | } 72 | 73 | var inventory playbook.Inventory 74 | 75 | if err := json.Unmarshal(defaults, &inventory); err != nil { 76 | return playbook.Inventory{}, playbook.NewErrorReadingDefaultsFile(err) 77 | } 78 | 79 | return inventory, nil 80 | } 81 | 82 | func (p *playbooks) initFuncMap(t *template.Template) { 83 | f := sprig.TxtFuncMap() 84 | delete(f, "env") 85 | delete(f, "expandenv") 86 | 87 | funcMap := make(template.FuncMap, 0) 88 | 89 | funcMap["getFile"] = func(filename string) string { 90 | data, err := ioutil.ReadFile(fmt.Sprintf("%s/%s%s", p.templatePath, filename, tplSuffix)) 91 | if err != nil { 92 | logrus.Fatal(fmt.Errorf("template getFile func: %v", err)) 93 | } 94 | return string(data) 95 | } 96 | 97 | for k, v := range funcMap { 98 | f[k] = v 99 | } 100 | 101 | t.Funcs(f) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/playbook/config.go: -------------------------------------------------------------------------------- 1 | package playbook 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | // Config represents a set of kubernetes configuration. 10 | // Usually, Values are expected to be yaml. 11 | type Config struct { 12 | Name string 13 | Values string 14 | } 15 | 16 | // Release represents information related to an inventory release. 17 | // An inventory may evolve with time. We want to keep trace of those evolution 18 | // and we may inject data specific a release in the templates 19 | type Release struct { 20 | Date string `json:"date"` 21 | } 22 | 23 | // InventoryRelease represents an inventory enriched with release data. 24 | type InventoryRelease struct { 25 | Namespace string `json:"namespace"` 26 | Values map[string]interface{} `json:"values"` 27 | Release Release `json:"release"` 28 | } 29 | 30 | // ConfigService define the way configuration are managed 31 | type ConfigService interface { 32 | Generate(Inventory) error 33 | Delete(namespace string) error 34 | } 35 | 36 | // ConfigRepository represents a service that implements configs management 37 | type ConfigRepository interface { 38 | Save(namespace string, configs []Config) error 39 | Delete(namespace string) error 40 | } 41 | 42 | type configService struct { 43 | configs ConfigRepository 44 | playbooks PlaybookService 45 | } 46 | 47 | // NewConfigService creates a ConfigService 48 | func NewConfigService(configs ConfigRepository, playbooks PlaybookService) ConfigService { 49 | return &configService{ 50 | configs, 51 | playbooks, 52 | } 53 | } 54 | 55 | // Generate creates a set of kubernetes configurations by applying an InventoryRelease to 56 | // Templates. It read each template, create an InventoryRelease for the given Inventory 57 | // and apply it to the template in order to generate a set of kubernetes configurations. 58 | func (cs *configService) Generate(inv Inventory) error { 59 | 60 | if inv.Namespace == "" { 61 | return errors.New("an namespace must be specified in the inventory") 62 | } 63 | 64 | tpls, err := cs.playbooks.GetTemplate() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | invRelease := InventoryRelease{ 70 | inv.Namespace, 71 | inv.Values, 72 | Release{ 73 | Date: time.Now().Format("20060102150405"), 74 | }, 75 | } 76 | 77 | var configs []Config 78 | 79 | for _, tpl := range tpls { 80 | 81 | confVal := bytes.Buffer{} 82 | 83 | tpl.Template.Execute(&confVal, invRelease) 84 | 85 | conf := Config{ 86 | Name: tpl.Name, 87 | Values: confVal.String(), 88 | } 89 | 90 | configs = append(configs, conf) 91 | } 92 | 93 | return cs.configs.Save(inv.Namespace, configs) 94 | } 95 | 96 | // Delete delete kubernetes configs for the given namespace. 97 | func (cs *configService) Delete(namespace string) error { 98 | return cs.configs.Delete(namespace) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/kubernetes/service.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/Meetic/blackbeard/pkg/resource" 9 | 10 | "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/client-go/kubernetes" 13 | ) 14 | 15 | type serviceRepository struct { 16 | kubernetes kubernetes.Interface 17 | host string 18 | } 19 | 20 | // NewServiceRepository returns a new ServiceRepository 21 | // It takes as parameter a go-client kubernetes client and the kubernetes cluster host (domain name or ip). 22 | func NewServiceRepository(kubernetes kubernetes.Interface, host string) resource.ServiceRepository { 23 | return &serviceRepository{ 24 | kubernetes: kubernetes, 25 | host: host, 26 | } 27 | } 28 | 29 | // ListExternal returns a list of kubernetes services exposed as NodePort or LoadBalancer. 30 | func (sr *serviceRepository) ListExternal(n string) ([]resource.Service, error) { 31 | // unfortunately, we cant filter service by type using field selector 32 | svcs, err := sr.kubernetes.CoreV1().Services(n).List(context.Background(), metav1.ListOptions{}) 33 | 34 | if err != nil { 35 | return nil, fmt.Errorf("kubernetes api list services : %s", err.Error()) 36 | } 37 | 38 | var services []resource.Service 39 | 40 | for _, svc := range svcs.Items { 41 | if svc.Spec.Type == v1.ServiceTypeNodePort || svc.Spec.Type == v1.ServiceTypeLoadBalancer { 42 | var ports []resource.Port 43 | 44 | for _, p := range svc.Spec.Ports { 45 | ports = append(ports, resource.Port{ 46 | Port: p.Port, 47 | ExposedPort: p.NodePort, 48 | }) 49 | } 50 | 51 | addr := sr.host 52 | 53 | if svc.Spec.Type == v1.ServiceTypeLoadBalancer { 54 | var ips []string 55 | for _, lbi := range svc.Status.LoadBalancer.Ingress { 56 | ips = append(ips, lbi.IP) 57 | } 58 | 59 | addr = strings.Join(ips, ",") 60 | } 61 | 62 | services = append(services, resource.Service{ 63 | Name: svc.Name, 64 | Ports: ports, 65 | Addr: addr, 66 | }) 67 | 68 | } 69 | } 70 | 71 | return services, nil 72 | 73 | } 74 | 75 | // ListIngress returns a list of Kubernetes services exposed throw Ingress. 76 | func (sr *serviceRepository) ListIngress(n string) ([]resource.Service, error) { 77 | ingressList, err := sr.kubernetes.NetworkingV1().Ingresses(n).List(context.Background(), metav1.ListOptions{}) 78 | 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | var services []resource.Service 84 | 85 | for _, ing := range ingressList.Items { 86 | 87 | for _, rules := range ing.Spec.Rules { 88 | for _, path := range rules.HTTP.Paths { 89 | svc := resource.Service{ 90 | Name: path.Backend.Service.Name, 91 | Addr: rules.Host, 92 | Ports: []resource.Port{ 93 | { 94 | Port: path.Backend.Service.Port.Number, 95 | ExposedPort: 80, 96 | }, 97 | }, 98 | } 99 | services = append(services, svc) 100 | } 101 | } 102 | } 103 | 104 | return services, nil 105 | 106 | } 107 | -------------------------------------------------------------------------------- /pkg/files/client.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/Meetic/blackbeard/pkg/playbook" 9 | ) 10 | 11 | const ( 12 | templateDir = "templates" 13 | configDir = "configs" 14 | inventoryDir = "inventories" 15 | defaultFile = "defaults.json" 16 | ) 17 | 18 | type Client struct { 19 | configs playbook.ConfigRepository 20 | inventories playbook.InventoryRepository 21 | playbooks playbook.PlaybookRepository 22 | inventoryPath string 23 | configPath string 24 | } 25 | 26 | func NewClient(wd string) (*Client, error) { 27 | if ok, _ := fileExists(wd); ok != true { 28 | return &Client{}, fmt.Errorf("Your specified working dir does not exit : %s", wd) 29 | } 30 | 31 | templatePath := filepath.Join(wd, templateDir) 32 | configPath := filepath.Join(wd, configDir) 33 | inventoryPath := filepath.Join(wd, inventoryDir) 34 | defaultsPath := filepath.Join(wd, defaultFile) 35 | 36 | if ok, _ := fileExists(templatePath); ok != true { 37 | return &Client{}, fmt.Errorf("A playbook must contains a `%s` dir. No one has been found.\n"+ 38 | "Please check the playbook or change the working directory using the --dir option.", templateDir) 39 | } 40 | 41 | if ok, _ := fileExists(defaultsPath); ok != true { 42 | return &Client{}, fmt.Errorf("Your working directory must contains a `%s` file.\n"+ 43 | "Please check the playbook or change the working directory using the --dir option.", defaultFile) 44 | } 45 | 46 | if ok, _ := fileExists(configPath); ok != true { 47 | if err := os.Mkdir(configPath, 0755); err != nil { 48 | return &Client{}, fmt.Errorf("Impossible to create the %s directory. Please check directory rights.", configDir) 49 | } 50 | } 51 | 52 | if ok, _ := fileExists(inventoryPath); ok != true { 53 | if err := os.Mkdir(inventoryPath, 0755); err != nil { 54 | return &Client{}, fmt.Errorf("Impossible to create the %s directory. Please check directory rights.", inventoryDir) 55 | } 56 | } 57 | 58 | return &Client{ 59 | configs: NewConfigRepository(configPath), 60 | inventories: NewInventoryRepository(inventoryPath), 61 | playbooks: NewPlaybookRepository(templatePath, defaultsPath), 62 | inventoryPath: inventoryPath, 63 | configPath: configPath, 64 | }, nil 65 | } 66 | 67 | func (c *Client) Configs() playbook.ConfigRepository { 68 | return c.configs 69 | } 70 | 71 | func (c *Client) Inventories() playbook.InventoryRepository { 72 | return c.inventories 73 | } 74 | 75 | func (c *Client) Playbooks() playbook.PlaybookRepository { 76 | return c.playbooks 77 | } 78 | 79 | // InventoryPath returns the inventory path for the current playbook 80 | func (c *Client) InventoryPath() string { 81 | return c.inventoryPath 82 | } 83 | 84 | // ConfigPath return the config path for the current playbook 85 | func (c *Client) ConfigPath() string { 86 | return c.configPath 87 | } 88 | 89 | func fileExists(path string) (bool, error) { 90 | if _, err := os.Stat(path); err != nil { 91 | if os.IsNotExist(err) { 92 | return false, nil 93 | } 94 | 95 | return true, err 96 | } 97 | 98 | return true, nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/resource/namespace_test.go: -------------------------------------------------------------------------------- 1 | package resource_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "k8s.io/client-go/kubernetes/fake" 9 | 10 | "github.com/Meetic/blackbeard/pkg/mock" 11 | "github.com/Meetic/blackbeard/pkg/resource" 12 | ) 13 | 14 | var ( 15 | podRepository = new(mock.PodRepository) 16 | deploymentRepository = new(mock.DeploymentRepository) 17 | statefulsetRepository = new(mock.StatefulsetRepository) 18 | jobRepository = new(mock.JobRepository) 19 | ) 20 | 21 | var ( 22 | kube = fake.NewSimpleClientset() 23 | namespaces = resource.NewNamespaceService( 24 | mock.NewNamespaceRepository(kube, false), 25 | podRepository, 26 | deploymentRepository, 27 | statefulsetRepository, 28 | jobRepository, 29 | ) 30 | ) 31 | 32 | func TestGetStatusOk(t *testing.T) { 33 | deploymentRepository. 34 | On("List", "test"). 35 | Return(resource.Deployments{{Name: "app", Status: resource.DeploymentReady}}, nil) 36 | 37 | statefulsetRepository. 38 | On("List", "test"). 39 | Return(resource.Statefulsets{{Name: "app", Status: resource.StatefulsetReady}}, nil) 40 | 41 | jobRepository. 42 | On("List", "test"). 43 | Return(resource.Jobs{{Name: "app", Status: resource.JobReady}}, nil) 44 | 45 | status, err := namespaces.GetStatus("test") 46 | 47 | deploymentRepository.AssertExpectations(t) 48 | statefulsetRepository.AssertExpectations(t) 49 | jobRepository.AssertExpectations(t) 50 | 51 | assert.Nil(t, err) 52 | assert.Equal(t, 100, status.Status) 53 | } 54 | 55 | func TestGetStatusIncomplete(t *testing.T) { 56 | deploymentRepository. 57 | On("List", "testko"). 58 | Return(resource.Deployments{{Name: "app", Status: resource.DeploymentReady}}, nil) 59 | 60 | statefulsetRepository. 61 | On("List", "testko"). 62 | Return(resource.Statefulsets{}, errors.New("some error")) 63 | 64 | jobRepository. 65 | On("List", "testko"). 66 | Return(resource.Jobs{{Name: "app", Status: resource.JobReady}}, nil) 67 | 68 | status, err := namespaces.GetStatus("testko") 69 | 70 | deploymentRepository.AssertExpectations(t) 71 | statefulsetRepository.AssertExpectations(t) 72 | jobRepository.AssertExpectations(t) 73 | 74 | assert.NotNil(t, err) 75 | assert.Equal(t, 0, status.Status) 76 | } 77 | 78 | func TestNamespaceCreate(t *testing.T) { 79 | err := namespaces.Create("mynamespace") 80 | 81 | assert.Nil(t, err) 82 | } 83 | 84 | func TestNamespaceCreateError(t *testing.T) { 85 | namespaces = resource.NewNamespaceService( 86 | mock.NewNamespaceRepository(kube, true), 87 | podRepository, 88 | deploymentRepository, 89 | statefulsetRepository, 90 | jobRepository, 91 | ) 92 | 93 | err := namespaces.Create("foobar") 94 | 95 | assert.Equal(t, resource.ErrorCreateNamespace{Msg: "namespace foobar already exist"}, err) 96 | } 97 | 98 | func TestDelete(t *testing.T) { 99 | err := namespaces.Delete("foobar") 100 | 101 | assert.Nil(t, err) 102 | } 103 | 104 | func TestApplyConfig(t *testing.T) { 105 | err := namespaces.ApplyConfig("foobar", "config") 106 | 107 | assert.Nil(t, err) 108 | } 109 | 110 | func TestList(t *testing.T) { 111 | namespaces, err := namespaces.List() 112 | 113 | expectedNamespaces := []resource.Namespace{ 114 | { 115 | Name: "test", 116 | Phase: "Active", 117 | Status: 100, 118 | }, 119 | } 120 | 121 | assert.Nil(t, err) 122 | assert.Equal(t, expectedNamespaces, namespaces) 123 | } 124 | -------------------------------------------------------------------------------- /pkg/kubernetes/client.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/sirupsen/logrus" 11 | "k8s.io/client-go/kubernetes" 12 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 13 | "k8s.io/client-go/tools/clientcmd" 14 | 15 | "github.com/Meetic/blackbeard/pkg/resource" 16 | ) 17 | 18 | const ( 19 | configDir = ".kube" 20 | configFile = "config" 21 | ) 22 | 23 | type Client struct { 24 | kubernetes kubernetes.Interface 25 | namespaces resource.NamespaceRepository 26 | pods resource.PodRepository 27 | deployments resource.DeploymentRepository 28 | statefulsets resource.StatefulsetRepository 29 | services resource.ServiceRepository 30 | cluster resource.ClusterRepository 31 | jobs resource.JobRepository 32 | } 33 | 34 | // NewClient return a new kubernetes client 35 | func NewClient(configFilePath string) (*Client, error) { 36 | 37 | config, err := clientcmd.BuildConfigFromFlags("", configFilePath) 38 | if err != nil { 39 | return &Client{}, fmt.Errorf("kubernetes client build config : %s", err.Error()) 40 | } 41 | 42 | config.QPS = float32(250) 43 | config.Burst = 500 44 | 45 | clientSet, err := kubernetes.NewForConfig(config) 46 | if err != nil { 47 | return &Client{}, fmt.Errorf("kubernetes new client for config : %s", err.Error()) 48 | } 49 | 50 | return &Client{ 51 | kubernetes: clientSet, 52 | namespaces: NewNamespaceRepository(clientSet), 53 | pods: NewPodRepository(clientSet), 54 | deployments: NewDeploymentRepository(clientSet), 55 | statefulsets: NewStatefulsetRepository(clientSet), 56 | services: NewServiceRepository(clientSet, GetKubernetesHost(configFilePath)), 57 | cluster: NewClusterRepository(), 58 | jobs: NewJobRepository(clientSet), 59 | }, nil 60 | } 61 | 62 | func (c *Client) Jobs() resource.JobRepository { 63 | return c.jobs 64 | } 65 | 66 | func (c *Client) Namespaces() resource.NamespaceRepository { 67 | return c.namespaces 68 | } 69 | 70 | func (c *Client) Pods() resource.PodRepository { 71 | return c.pods 72 | } 73 | 74 | func (c *Client) Services() resource.ServiceRepository { 75 | return c.services 76 | } 77 | 78 | func (c *Client) Cluster() resource.ClusterRepository { 79 | return c.cluster 80 | } 81 | 82 | func (c *Client) Deployments() resource.DeploymentRepository { 83 | return c.deployments 84 | } 85 | 86 | func (c *Client) Statefulsets() resource.StatefulsetRepository { 87 | return c.statefulsets 88 | } 89 | 90 | // KubeConfigDefaultPath return the kubernetes default config path 91 | func KubeConfigDefaultPath() string { 92 | return filepath.Join(homeDir(), configDir, configFile) 93 | } 94 | 95 | func homeDir() string { 96 | if h := os.Getenv("HOME"); h != "" { 97 | return h 98 | } 99 | return os.Getenv("USERPROFILE") // windows 100 | } 101 | 102 | // GetKubernetesHost return the kubernetes cluster domain name used in the ~/.kube/config file 103 | // The returned host takes the form : mydomainname.com 104 | // Notice : this is just the host, without any schema or port. 105 | func GetKubernetesHost(configFilePath string) string { 106 | 107 | config, _ := clientcmd.BuildConfigFromFlags("", configFilePath) 108 | 109 | u, err := url.Parse(config.Host) 110 | if err != nil { 111 | logrus.Fatalf("Impossible to get K8s host : %s", err.Error()) 112 | } 113 | 114 | return strings.Split(u.Host, ":")[0] 115 | } 116 | -------------------------------------------------------------------------------- /docs/workflow/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "CLI" 3 | anchor: "cli" 4 | weight: 31 5 | --- 6 | Blackbeard provide a set of CLI command. 7 | 8 | CLI usage may be use for different purpose. 9 | 10 | Locally, for developers, it is useful for managing multiple namespace either to develop a microservice calling other applications running a specified version, or to test the entire stack using specified version of multiple services in the stack. 11 | 12 | In a CI/CD pipeline, for automated end-to-end testing. 13 | 14 | ### Create a new env 15 | 16 | ```sh 17 | cd {your playbook} 18 | blackbeard create -n {namespace name} 19 | ``` 20 | 21 | * create a Kubernetes namespace; 22 | * generate a `inventory` file for the newly created namespace; 23 | * generate a set of yml `manifest` based on the playbook `templates`. 24 | 25 | ### Update values & apply changes 26 | 27 | ```sh 28 | cd {your playbook}/inventories 29 | ## edit the inventory file you want to update 30 | cd .. 31 | blackbeard apply -n {namespace name} 32 | ``` 33 | 34 | * apply values defined in the `inventory` file to the playbook `templates`; 35 | * update the yml `manifest` using the newly updated values from the `inventory` file; 36 | * run a `kubectl apply` command and apply changes in the manifest to the namespace. 37 | 38 | ### List namespaces 39 | 40 | ```sh 41 | blackbeard get namespaces 42 | ``` 43 | 44 | * prompt a list of available Kubernetes namespace; 45 | 46 | for each namespace : 47 | 48 | * indicate if the namespace is managed by a local `inventory` or not. 49 | * indicate the status of the namespace (aka : percentage of pods in a "running" state) 50 | 51 | Exemple : 52 | 53 | ```sh 54 | Namespace Phase Status Managed 55 | backend Active 100% false 56 | john Active 73% true 57 | default Active 0% false 58 | kevin Active 73% false 59 | team1 Active 73% true 60 | ``` 61 | 62 | ### Get useful informations about services 63 | 64 | ```sh 65 | blackbeard get services -n my-feature 66 | ``` 67 | 68 | * prompt a list of exposed services 69 | 70 | {{% block info %}} 71 | Exposed services are Kubernetes services exposed using `NodePort` or http services exposed via `Ingress` 72 | {{% /block %}} 73 | 74 | 75 | ### Delete specific resources in a namespace 76 | 77 | ```sh 78 | blackbeard delete job {my-resource} -n {namespace-name} 79 | ``` 80 | 81 | * delete the resource associated to your namespace (it can only delete jobs for now) 82 | 83 | ### Get Help 84 | 85 | ```sh 86 | Usage: 87 | blackbeard [command] 88 | 89 | Available Commands: 90 | apply Apply a given inventory to the associated namespace 91 | create Create a namespace and generated a dedicated inventory. 92 | delete Delete a namespace 93 | get Show informations about a given namespace. 94 | help Help about any command 95 | reset Reset a namespace based on the template files and the default inventory. 96 | serve Launch the blackbeard server 97 | version Print blackbeard version 98 | 99 | Flags: 100 | --config string config file (default is $HOME/.blackbeard.yaml) 101 | --dir string Use the specified dir as root path to execute commands. Default is the current dir. 102 | -h, --help help for blackbeard 103 | --kube-config-path string kubectl config file (default "$HOME/.kube/config") 104 | -v, --verbosity string Log level (debug, info, warn, error, fatal, panic (default "info") 105 | 106 | Use "blackbeard [command] --help" for more information about a command. 107 | ``` 108 | -------------------------------------------------------------------------------- /pkg/http/inventory_handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/Meetic/blackbeard/pkg/playbook" 7 | "github.com/Meetic/blackbeard/pkg/resource" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | // createQuery represents the POST payload send to the create handler 12 | type createQuery struct { 13 | Namespace string `json:"namespace" binding:"required"` 14 | } 15 | 16 | // Create handle the namespace creation. 17 | func (h *Handler) Create(c *gin.Context) { 18 | 19 | var createQ createQuery 20 | 21 | if err := c.BindJSON(&createQ); err != nil { 22 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 23 | return 24 | } 25 | 26 | // Create inventory 27 | inv, err := h.api.Create(createQ.Namespace) 28 | 29 | if err != nil { 30 | if alreadyExist, ok := err.(playbook.ErrorInventoryAlreadyExist); ok { 31 | c.JSON(http.StatusBadRequest, gin.H{"error": alreadyExist.Error()}) 32 | return 33 | } 34 | 35 | if namespaceError, ok := err.(resource.ErrorCreateNamespace); ok { 36 | c.JSON(http.StatusBadRequest, gin.H{"error": namespaceError.Error()}) 37 | return 38 | } 39 | 40 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 41 | return 42 | } 43 | 44 | c.JSON(http.StatusCreated, inv) 45 | } 46 | 47 | // Get return an inventory for a given namespace passed has query parameters. 48 | func (h *Handler) Get(c *gin.Context) { 49 | 50 | inv, err := h.api.Inventories().Get(c.Params.ByName("namespace")) 51 | 52 | if err != nil { 53 | if notFound, ok := err.(playbook.ErrorInventoryNotFound); ok { 54 | c.JSON(http.StatusNotFound, gin.H{"error": notFound.Error()}) 55 | return 56 | } 57 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 58 | return 59 | } 60 | 61 | c.JSON(http.StatusOK, inv) 62 | } 63 | 64 | // GetDefaults return default for an inventory 65 | // $ curl -xGET defaults/ 66 | func (h *Handler) GetDefaults(c *gin.Context) { 67 | 68 | inv, err := h.api.Playbooks().GetDefault() 69 | 70 | if err != nil { 71 | c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) 72 | return 73 | } 74 | 75 | c.JSON(http.StatusOK, inv) 76 | } 77 | 78 | // List returns the list of existing inventories. 79 | func (h *Handler) List(c *gin.Context) { 80 | 81 | invList, err := h.api.Inventories().List() 82 | 83 | if err != nil { 84 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 85 | return 86 | } 87 | 88 | c.JSON(http.StatusOK, invList) 89 | } 90 | 91 | // Update will update inventory for a given namespace 92 | func (h *Handler) Update(c *gin.Context) { 93 | 94 | var uQ playbook.Inventory 95 | 96 | if err := c.BindJSON(&uQ); err != nil { 97 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 98 | return 99 | } 100 | 101 | if err := h.api.Update(c.Params.ByName("namespace"), uQ, h.configPath); err != nil { 102 | c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) 103 | return 104 | } 105 | 106 | c.JSON(http.StatusNoContent, nil) 107 | } 108 | 109 | // Reset reset a inventory to default and apply changes into kubernetes 110 | func (h *Handler) Reset(c *gin.Context) { 111 | 112 | n := c.Params.ByName("namespace") 113 | 114 | if err := h.api.Reset(n, h.configPath); err != nil { 115 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 116 | return 117 | } 118 | 119 | c.JSON(http.StatusNoContent, nil) 120 | 121 | } 122 | 123 | // Delete handle the namespace deletion. 124 | func (h *Handler) Delete(c *gin.Context) { 125 | namespace := c.Params.ByName("namespace") 126 | 127 | //Delete inventory 128 | if err := h.api.Delete(namespace, true); err != nil { 129 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 130 | return 131 | } 132 | 133 | c.JSON(http.StatusNoContent, nil) 134 | } 135 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Meetic/blackbeard 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Masterminds/sprig v2.22.0+incompatible 7 | github.com/gin-contrib/cors v1.3.1 8 | github.com/gin-gonic/gin v1.6.3 9 | github.com/gosuri/uiprogress v0.0.1 10 | github.com/sirupsen/logrus v1.7.0 11 | github.com/spf13/cobra v1.1.1 12 | github.com/spf13/viper v1.7.1 13 | github.com/stretchr/testify v1.8.2 14 | k8s.io/api v0.28.5 15 | k8s.io/apimachinery v0.28.5 16 | k8s.io/client-go v0.28.5 17 | ) 18 | 19 | require ( 20 | github.com/Masterminds/goutils v1.1.0 // indirect 21 | github.com/Masterminds/semver v1.5.0 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 24 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 25 | github.com/fsnotify/fsnotify v1.4.9 // indirect 26 | github.com/gin-contrib/sse v0.1.0 // indirect 27 | github.com/go-logr/logr v1.2.4 // indirect 28 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 29 | github.com/go-openapi/jsonreference v0.20.2 // indirect 30 | github.com/go-openapi/swag v0.22.3 // indirect 31 | github.com/go-playground/locales v0.13.0 // indirect 32 | github.com/go-playground/universal-translator v0.17.0 // indirect 33 | github.com/go-playground/validator/v10 v10.2.0 // indirect 34 | github.com/gogo/protobuf v1.3.2 // indirect 35 | github.com/golang/protobuf v1.5.3 // indirect 36 | github.com/google/gnostic-models v0.6.8 // indirect 37 | github.com/google/go-cmp v0.5.9 // indirect 38 | github.com/google/gofuzz v1.2.0 // indirect 39 | github.com/google/uuid v1.3.0 // indirect 40 | github.com/gosuri/uilive v0.0.4 // indirect 41 | github.com/hashicorp/hcl v1.0.0 // indirect 42 | github.com/huandu/xstrings v1.3.2 // indirect 43 | github.com/imdario/mergo v0.3.6 // indirect 44 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/json-iterator/go v1.1.12 // indirect 47 | github.com/leodido/go-urn v1.2.0 // indirect 48 | github.com/magiconair/properties v1.8.1 // indirect 49 | github.com/mailru/easyjson v0.7.7 // indirect 50 | github.com/mattn/go-isatty v0.0.12 // indirect 51 | github.com/mitchellh/copystructure v1.0.0 // indirect 52 | github.com/mitchellh/mapstructure v1.1.2 // indirect 53 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 55 | github.com/modern-go/reflect2 v1.0.2 // indirect 56 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 57 | github.com/pelletier/go-toml v1.2.0 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/pmezard/go-difflib v1.0.0 // indirect 60 | github.com/spf13/afero v1.2.2 // indirect 61 | github.com/spf13/cast v1.3.0 // indirect 62 | github.com/spf13/jwalterweatherman v1.0.0 // indirect 63 | github.com/spf13/pflag v1.0.5 // indirect 64 | github.com/stretchr/objx v0.5.0 // indirect 65 | github.com/subosito/gotenv v1.2.0 // indirect 66 | github.com/ugorji/go/codec v1.1.7 // indirect 67 | golang.org/x/crypto v0.14.0 // indirect 68 | golang.org/x/net v0.17.0 // indirect 69 | golang.org/x/oauth2 v0.8.0 // indirect 70 | golang.org/x/sys v0.13.0 // indirect 71 | golang.org/x/term v0.13.0 // indirect 72 | golang.org/x/text v0.13.0 // indirect 73 | golang.org/x/time v0.3.0 // indirect 74 | google.golang.org/appengine v1.6.7 // indirect 75 | google.golang.org/protobuf v1.31.0 // indirect 76 | gopkg.in/inf.v0 v0.9.1 // indirect 77 | gopkg.in/ini.v1 v1.51.0 // indirect 78 | gopkg.in/yaml.v2 v2.4.0 // indirect 79 | gopkg.in/yaml.v3 v3.0.1 // indirect 80 | k8s.io/klog/v2 v2.100.1 // indirect 81 | k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect 82 | k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect 83 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 84 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 85 | sigs.k8s.io/yaml v1.3.0 // indirect 86 | ) 87 | -------------------------------------------------------------------------------- /pkg/files/inventory.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/Meetic/blackbeard/pkg/playbook" 11 | ) 12 | 13 | const ( 14 | inventoryFileSuffix = "inventory.json" 15 | ) 16 | 17 | type inventories struct { 18 | inventoryPath string 19 | } 20 | 21 | // NewInventoryRepository returns a InventoryRepository 22 | // The parameter is the directory where are stored the inventories 23 | func NewInventoryRepository(inventoryPath string) playbook.InventoryRepository { 24 | return &inventories{ 25 | inventoryPath: inventoryPath, 26 | } 27 | } 28 | 29 | // Get returns an inventory for a given namespace. 30 | // If the inventory cannot be found based on its path, Get returns an empty inventory and an error 31 | func (ir *inventories) Get(namespace string) (playbook.Inventory, error) { 32 | 33 | if !ir.Exists(namespace) { 34 | return playbook.Inventory{}, playbook.NewErrorInventoryNotFound(namespace) 35 | } 36 | 37 | return ir.read(ir.path(namespace)) 38 | } 39 | 40 | // Create writes an inventory file containing the inventory passed as parameter. 41 | func (ir *inventories) Create(inventory playbook.Inventory) error { 42 | 43 | // Check if an inventory file already exist for this namespace 44 | if ir.Exists(inventory.Namespace) { 45 | return playbook.NewErrorInventoryAlreadyExist(inventory.Namespace) 46 | } 47 | 48 | j, _ := json.MarshalIndent(inventory, "", " ") 49 | return ioutil.WriteFile(ir.path(inventory.Namespace), j, 0644) 50 | } 51 | 52 | // Delete remove an inventory file. 53 | // if the specified inventory does not exist, Delete return nil and does nothing. 54 | func (ir *inventories) Delete(namespace string) error { 55 | if !ir.Exists(namespace) { 56 | return nil 57 | } 58 | return os.Remove(ir.path(namespace)) 59 | } 60 | 61 | // Update will update inventory for a given namespace. 62 | // If the namespace in the inventory is not the same as the namespace given as first parameters of Update 63 | // this function will rename the inventory file to match ne new namespace. 64 | func (ir *inventories) Update(namespace string, inv playbook.Inventory) error { 65 | 66 | //check if the namespace name has change 67 | if namespace != inv.Namespace { 68 | //Check if a inventory file already exist for this usr. 69 | if ir.Exists(inv.Namespace) { 70 | return playbook.NewErrorInventoryAlreadyExist(inv.Namespace) 71 | } 72 | err := os.Rename(ir.path(namespace), ir.path(inv.Namespace)) 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | 78 | iJSON, _ := json.MarshalIndent(inv, "", " ") 79 | 80 | err := ioutil.WriteFile(ir.path(inv.Namespace), iJSON, 0644) 81 | 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // List return the list of existing inventories 90 | // If no inventory file exist, the function returns an empty slice. 91 | func (ir *inventories) List() ([]playbook.Inventory, error) { 92 | var inventories []playbook.Inventory 93 | 94 | invFiles, _ := filepath.Glob(filepath.Join(ir.inventoryPath, fmt.Sprintf("*_%s", inventoryFileSuffix))) 95 | 96 | for _, invFile := range invFiles { 97 | inv, err := ir.read(invFile) 98 | if err != nil { 99 | return inventories, err 100 | } 101 | inventories = append(inventories, inv) 102 | } 103 | 104 | return inventories, nil 105 | 106 | } 107 | 108 | func (ir *inventories) read(path string) (playbook.Inventory, error) { 109 | var inv playbook.Inventory 110 | 111 | raw, err := ioutil.ReadFile(path) 112 | if err != nil { 113 | return inv, err 114 | } 115 | 116 | json.Unmarshal(raw, &inv) 117 | return inv, nil 118 | } 119 | 120 | // Exists return true if an inventory for the given namespace already exist. 121 | // Else, it return false. 122 | func (ir *inventories) Exists(namespace string) bool { 123 | if _, err := os.Stat(ir.path(namespace)); os.IsNotExist(err) { 124 | return false 125 | } else if err == nil { 126 | return true 127 | } 128 | return false 129 | } 130 | 131 | // path return the inventory file path of a given namespace 132 | func (ir *inventories) path(namespace string) string { 133 | return filepath.Join(ir.inventoryPath, fmt.Sprintf("%s_%s", namespace, inventoryFileSuffix)) 134 | } 135 | -------------------------------------------------------------------------------- /pkg/kubernetes/namespace.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/sirupsen/logrus" 13 | "k8s.io/api/core/v1" 14 | kerr "k8s.io/apimachinery/pkg/api/errors" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/watch" 17 | "k8s.io/client-go/kubernetes" 18 | 19 | "github.com/Meetic/blackbeard/pkg/resource" 20 | ) 21 | 22 | const ( 23 | timeout = 60 * time.Second 24 | ) 25 | 26 | type namespaceRepository struct { 27 | kubernetes kubernetes.Interface 28 | } 29 | 30 | // NewNamespaceRepository returns a new NamespaceRepository. 31 | // The parameter is a go-client Kubernetes client 32 | func NewNamespaceRepository(kubernetes kubernetes.Interface) resource.NamespaceRepository { 33 | return &namespaceRepository{ 34 | kubernetes: kubernetes, 35 | } 36 | } 37 | 38 | // Create creates a namespace 39 | func (ns *namespaceRepository) Create(namespace string) error { 40 | _, err := ns.kubernetes.CoreV1().Namespaces().Create( 41 | context.Background(), 42 | &v1.Namespace{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: namespace, 45 | Labels: map[string]string{"manager": "blackbeard"}, 46 | }, 47 | }, 48 | metav1.CreateOptions{}, 49 | ) 50 | return err 51 | } 52 | 53 | // Get namespace with status 54 | func (ns *namespaceRepository) Get(namespace string) (*resource.Namespace, error) { 55 | n, err := ns.kubernetes.CoreV1().Namespaces().Get(context.Background(), namespace, metav1.GetOptions{}) 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &resource.Namespace{Name: n.GetName(), Phase: string(n.Status.Phase)}, nil 62 | } 63 | 64 | // Delete deletes a given namespace 65 | func (ns *namespaceRepository) Delete(namespace string) error { 66 | err := ns.kubernetes.CoreV1().Namespaces().Delete(context.Background(), namespace, metav1.DeleteOptions{}) 67 | 68 | switch t := err.(type) { 69 | case *kerr.StatusError: 70 | return nil 71 | case *kerr.UnexpectedObjectError: 72 | return nil 73 | default: 74 | return t 75 | } 76 | } 77 | 78 | // List returns a slice of Namespace. 79 | // Name is the namespace name from Kubernetes. 80 | // Phase is the status phase. 81 | // List returns an error if the namespace list could not be get from Kubernetes cluster. 82 | func (ns *namespaceRepository) List() ([]resource.Namespace, error) { 83 | nsList, err := ns.kubernetes.CoreV1().Namespaces().List( 84 | context.Background(), 85 | metav1.ListOptions{LabelSelector: "manager=blackbeard"}, 86 | ) 87 | 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | var namespaces []resource.Namespace 93 | for _, ns := range nsList.Items { 94 | namespaces = append(namespaces, resource.Namespace{ 95 | Name: ns.GetName(), 96 | Phase: string(ns.Status.Phase), 97 | }) 98 | } 99 | 100 | return namespaces, nil 101 | } 102 | 103 | // ApplyConfig loads configuration files into kubernetes 104 | func (ns *namespaceRepository) ApplyConfig(namespace, configPath string) error { 105 | 106 | err := execute(fmt.Sprintf("kubectl apply -f %s -n %s", filepath.Join(configPath, namespace), namespace), timeout) 107 | if err != nil { 108 | return fmt.Errorf("the namespace could not be configured : %v", err) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | // Watch namespace events and send it to events channel 115 | func (ns *namespaceRepository) Watch(events chan<- resource.NamespaceEvent) error { 116 | 117 | watcher, err := ns.kubernetes.CoreV1().Namespaces().Watch( 118 | context.Background(), 119 | metav1.ListOptions{LabelSelector: "manager=blackbeard"}, 120 | ) 121 | 122 | if err != nil { 123 | logrus.Errorf("error when watching namespace : %s", err.Error()) 124 | return err 125 | } 126 | 127 | for event := range watcher.ResultChan() { 128 | n := event.Object.(*v1.Namespace) 129 | 130 | // prevent publishing event ADDED for previous created namespaces 131 | elapsedTime := time.Now().Sub(n.ObjectMeta.CreationTimestamp.Time) 132 | if elapsedTime > 5*time.Minute && event.Type == watch.Added { 133 | continue 134 | } 135 | 136 | events <- resource.NamespaceEvent{ 137 | Namespace: n.Name, 138 | Type: string(event.Type), 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func execute(c string, t time.Duration) error { 146 | 147 | cmd := exec.Command("/bin/sh", "-c", c) 148 | 149 | cmdReader, err := cmd.StdoutPipe() 150 | if err != nil { 151 | logrus.Warn("Error creating StdoutPipe for Cmd") 152 | return err 153 | } 154 | 155 | scanner := bufio.NewScanner(cmdReader) 156 | go func() { 157 | for scanner.Scan() { 158 | logrus.Info(scanner.Text()) 159 | } 160 | }() 161 | 162 | // Start process. Exit code 127 if process fail to start. 163 | if err := cmd.Start(); err != nil { 164 | logrus.Warn("Error stating Cmd") 165 | return err 166 | } 167 | 168 | var timer *time.Timer 169 | if t > 0 { 170 | timer = time.NewTimer(t) 171 | go func(timer *time.Timer, cmd *exec.Cmd) { 172 | //TODO: use a chan and select pattern to output the error 173 | for range timer.C { 174 | e := cmd.Process.Kill() 175 | if e != nil { 176 | err = errors.New("the command has timeout but the process could not be killed") 177 | } else { 178 | err = errors.New("the command timed out") 179 | } 180 | } 181 | }(timer, cmd) 182 | } 183 | 184 | err = cmd.Wait() 185 | 186 | if t > 0 { 187 | timer.Stop() 188 | } 189 | 190 | if err != nil { 191 | return errors.New("the command did not succeed") 192 | } 193 | 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | 14 | "github.com/Meetic/blackbeard/pkg/api" 15 | "github.com/Meetic/blackbeard/pkg/files" 16 | "github.com/Meetic/blackbeard/pkg/kubernetes" 17 | "github.com/sirupsen/logrus" 18 | ) 19 | 20 | var ( 21 | cfgFile string 22 | playbookDir string 23 | kubectlConfigPath string 24 | v string 25 | namespace string 26 | cors bool 27 | wait bool 28 | timeout time.Duration 29 | port int 30 | ) 31 | 32 | // rootCmd represents the base command when called without any subcommands 33 | var rootCmd = &cobra.Command{ 34 | Use: "blackbeard", 35 | Short: "Blackbeard is a tool that let you create and manage multiple version of the same stack using Kubernetes and namespace", 36 | Long: `Blackbeard let you apply a bunch of configuration files template into different namespaces using some provided values. 37 | 38 | Blackbeard is made to be executed using a directory containing configuration files and directories called a Playbook. 39 | 40 | Using blackbeard and a Playbook, you can easily create a namespace by using the "create" command. 41 | This command will generate an inventory file containing the default configuration for the namespace you are creating. 42 | 43 | Feel free to update this inventory file manually. 44 | 45 | Then Blackbeard configure your namespace using a auto-generated Kubernetes config using the specified inventory file. 46 | This action can be done using the "apply" command. 47 | `, 48 | } 49 | 50 | func NewBlackbeardCommand() *cobra.Command { 51 | 52 | rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { 53 | if err := setUpLogs(os.Stdout, v); err != nil { 54 | return err 55 | } 56 | return nil 57 | } 58 | 59 | rootCmd.AddCommand(NewServeCommand()) 60 | rootCmd.AddCommand(NewApplyCommand()) 61 | rootCmd.AddCommand(NewCreateCommand()) 62 | rootCmd.AddCommand(NewDeleteCommand()) 63 | rootCmd.AddCommand(NewGetCommand()) 64 | rootCmd.AddCommand(NewResetCommand()) 65 | rootCmd.AddCommand(NewVersionCommand()) 66 | 67 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.blackbeard.yaml)") 68 | rootCmd.PersistentFlags().StringVar(&playbookDir, "dir", "", "Use the specified directory as root path to execute commands. Default is the current directory.") 69 | rootCmd.PersistentFlags().StringVar(&kubectlConfigPath, "kube-config-path", kubernetes.KubeConfigDefaultPath(), "kubectl config file") 70 | rootCmd.PersistentFlags().StringVarP(&v, "verbosity", "v", logrus.InfoLevel.String(), "Log level (debug, info, warn, error, fatal, panic") 71 | 72 | viper.BindPFlag("working-dir", rootCmd.PersistentFlags().Lookup("dir")) 73 | 74 | initConfig() 75 | 76 | return rootCmd 77 | 78 | } 79 | 80 | func addCommonNamespaceCommandFlags(cmd *cobra.Command) { 81 | cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "The namespace where to apply configuration") 82 | } 83 | 84 | // initConfig reads in config file and ENV variables if set. 85 | func initConfig() { 86 | if cfgFile != "" { // enable ability to specify config file via flag 87 | viper.SetConfigFile(cfgFile) 88 | } 89 | 90 | viper.SetConfigName(".blackbeard") // name of config file (without extension) 91 | viper.AddConfigPath("$HOME") // adding home directory as first search path 92 | viper.AutomaticEnv() // read in environment variables that match 93 | 94 | //Define current working dir as default value 95 | currentDir, err := os.Getwd() 96 | if err != nil { 97 | logrus.Fatal("Error when getting the working dir : ", err) 98 | } 99 | viper.SetDefault("working-dir", currentDir) 100 | 101 | // If a config file is found, read it in. 102 | if err := viper.ReadInConfig(); err == nil { 103 | logrus.Infof("Using config file: %s", viper.ConfigFileUsed()) 104 | 105 | } 106 | 107 | playbookDir = viper.GetString("working-dir") 108 | 109 | } 110 | 111 | func askForConfirmation(message string, reader io.Reader) bool { 112 | 113 | r := bufio.NewReader(reader) 114 | 115 | for { 116 | fmt.Printf("%s [y/n]: ", message) 117 | 118 | response, err := r.ReadString('\n') 119 | if err != nil { 120 | logrus.Fatal(err) 121 | } 122 | 123 | response = strings.ToLower(strings.TrimSpace(response)) 124 | 125 | if response == "y" || response == "yes" { 126 | return true 127 | } else { 128 | return false 129 | } 130 | } 131 | } 132 | 133 | func newKubernetesClient() *kubernetes.Client { 134 | kube, err := kubernetes.NewClient(kubectlConfigPath) 135 | if err != nil { 136 | logrus.Fatal(err.Error()) 137 | } 138 | 139 | return kube 140 | } 141 | 142 | func newFileClient(dir string) *files.Client { 143 | f, err := files.NewClient(dir) 144 | if err != nil { 145 | logrus.Fatal(err.Error()) 146 | } 147 | 148 | return f 149 | 150 | } 151 | 152 | func newAPI(files *files.Client, kube *kubernetes.Client) api.Api { 153 | return api.NewApi( 154 | files.Inventories(), 155 | files.Configs(), 156 | files.Playbooks(), 157 | kube.Namespaces(), 158 | kube.Pods(), 159 | kube.Deployments(), 160 | kube.Statefulsets(), 161 | kube.Services(), 162 | kube.Cluster(), 163 | kube.Jobs(), 164 | ) 165 | } 166 | 167 | func setUpLogs(out io.Writer, level string) error { 168 | 169 | logrus.SetOutput(out) 170 | lvl, err := logrus.ParseLevel(level) 171 | if err != nil { 172 | return err 173 | } 174 | logrus.SetLevel(lvl) 175 | logrus.SetFormatter(&logrus.JSONFormatter{}) 176 | return nil 177 | } 178 | -------------------------------------------------------------------------------- /pkg/playbook/inventory.go: -------------------------------------------------------------------------------- 1 | package playbook 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Inventory represents a set of variable to apply to the templates (see config). 8 | // Namespace is the namespace dedicated files where to apply the variables contains into Values 9 | // Values is map of string that contains whatever the user set in the default inventory from a playbook 10 | type Inventory struct { 11 | Namespace string `json:"namespace"` 12 | Values map[string]interface{} `json:"values"` 13 | } 14 | 15 | // InventoryService define the way inventories are managed. 16 | type InventoryService interface { 17 | Create(namespace string) (Inventory, error) 18 | Update(namespace string, inventory Inventory) error 19 | Get(namespace string) (Inventory, error) 20 | Exists(namespace string) bool 21 | List() ([]Inventory, error) 22 | Delete(namespace string) error 23 | Reset(namespace string) (Inventory, error) 24 | } 25 | 26 | // InventoryRepository define the way inventories are actually managed 27 | type InventoryRepository interface { 28 | Get(namespace string) (Inventory, error) 29 | Exists(namespace string) bool 30 | Create(Inventory) error 31 | Delete(namespace string) error 32 | Update(namespace string, inventory Inventory) error 33 | List() ([]Inventory, error) 34 | } 35 | 36 | type inventoryService struct { 37 | inventories InventoryRepository 38 | playbooks PlaybookService 39 | } 40 | 41 | // NewInventoryService create an InventoryService 42 | func NewInventoryService(inventories InventoryRepository, playbooks PlaybookService) InventoryService { 43 | return &inventoryService{ 44 | inventories, 45 | playbooks, 46 | } 47 | } 48 | 49 | // Create instantiate a new Inventory from the default inventory of a playbook and save it 50 | func (is *inventoryService) Create(namespace string) (Inventory, error) { 51 | 52 | if namespace == "" { 53 | return Inventory{}, fmt.Errorf("A namespace cannot be empty") 54 | } 55 | 56 | def, err := is.playbooks.GetDefault() 57 | if err != nil { 58 | return Inventory{}, err 59 | } 60 | 61 | inv := Inventory{ 62 | Namespace: namespace, 63 | Values: def.Values, 64 | } 65 | 66 | if err := is.inventories.Create(inv); err != nil { 67 | return Inventory{}, err 68 | } 69 | 70 | return inv, nil 71 | } 72 | 73 | // Get returns the Inventory for a given namespace 74 | func (is *inventoryService) Get(namespace string) (Inventory, error) { 75 | if namespace == "" { 76 | return Inventory{}, fmt.Errorf("A namespace cannot be empty") 77 | } 78 | 79 | return is.inventories.Get(namespace) 80 | } 81 | 82 | // Exists return true if an inventory for the given namespace already exists. 83 | // Else, it return false. 84 | func (is *inventoryService) Exists(namespace string) bool { 85 | return is.inventories.Exists(namespace) 86 | } 87 | 88 | // Delete deletes the inventory for the given namespace 89 | func (is *inventoryService) Delete(namespace string) error { 90 | return is.inventories.Delete(namespace) 91 | } 92 | 93 | // List returns the list of available inventories 94 | func (is *inventoryService) List() ([]Inventory, error) { 95 | return is.inventories.List() 96 | } 97 | 98 | // Update replace the inventory associated to the given namespace by the given inventory 99 | func (is *inventoryService) Update(namespace string, inv Inventory) error { 100 | return is.inventories.Update(namespace, inv) 101 | } 102 | 103 | // Reset override the inventory file for the given namespace base on the content of the default inventory. 104 | func (is *inventoryService) Reset(namespace string) (Inventory, error) { 105 | def, err := is.playbooks.GetDefault() 106 | if err != nil { 107 | return Inventory{}, err 108 | } 109 | 110 | var inv Inventory 111 | 112 | inv.Namespace = namespace 113 | inv.Values = def.Values 114 | 115 | if err := is.inventories.Update(namespace, inv); err != nil { 116 | return Inventory{}, err 117 | } 118 | 119 | return inv, nil 120 | } 121 | 122 | // ErrorReadingDefaultsFile represents an error due to unreadable default inventory 123 | type ErrorReadingDefaultsFile struct { 124 | msg string 125 | } 126 | 127 | // Error returns the error message 128 | func (err ErrorReadingDefaultsFile) Error() string { 129 | return err.msg 130 | } 131 | 132 | // NewErrorReadingDefaultsFile creates an ErrorReadingDefaultsFile error 133 | func NewErrorReadingDefaultsFile(err error) ErrorReadingDefaultsFile { 134 | return ErrorReadingDefaultsFile{fmt.Sprintf("Error when reading defaults file : %s", err.Error())} 135 | } 136 | 137 | // ErrorInventoryAlreadyExist represents an error due to an already existing inventory for a given namespace 138 | type ErrorInventoryAlreadyExist struct { 139 | msg string 140 | } 141 | 142 | // Error returns the error message 143 | func (err ErrorInventoryAlreadyExist) Error() string { 144 | return err.msg 145 | } 146 | 147 | // NewErrorInventoryAlreadyExist creates a new ErrorInventoryAlreadyExist error 148 | func NewErrorInventoryAlreadyExist(namespace string) ErrorInventoryAlreadyExist { 149 | return ErrorInventoryAlreadyExist{fmt.Sprintf("An inventory for the namespace %s already exist", namespace)} 150 | } 151 | 152 | // ErrorInventoryNotFound represents an error due to a missing inventory for the given namespace 153 | type ErrorInventoryNotFound struct { 154 | msg string 155 | } 156 | 157 | // Error returns the error message 158 | func (err ErrorInventoryNotFound) Error() string { 159 | return err.msg 160 | } 161 | 162 | // NewErrorInventoryNotFound creates a new ErrorInventoryNotFound error 163 | func NewErrorInventoryNotFound(namespace string) ErrorInventoryNotFound { 164 | return ErrorInventoryNotFound{fmt.Sprintf("The inventory for %s does not exist.", namespace)} 165 | } 166 | -------------------------------------------------------------------------------- /pkg/resource/namespace.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type Namespace struct { 12 | Name string 13 | Phase string 14 | Status int 15 | } 16 | 17 | // NamespaceService defined the way namespace are managed. 18 | type NamespaceService interface { 19 | Create(namespace string) error 20 | ApplyConfig(namespace string, configPath string) error 21 | Delete(namespace string) error 22 | GetStatus(namespace string) (*NamespaceStatus, error) 23 | List() ([]Namespace, error) 24 | Watch(events chan NamespaceEvent) 25 | } 26 | 27 | // NamespaceRepository defined the way namespace area actually managed. 28 | type NamespaceRepository interface { 29 | Create(namespace string) error 30 | Get(namespace string) (*Namespace, error) 31 | ApplyConfig(namespace string, configPath string) error 32 | Delete(namespace string) error 33 | List() ([]Namespace, error) 34 | Watch(events chan<- NamespaceEvent) error 35 | } 36 | 37 | type namespaceService struct { 38 | namespaces NamespaceRepository 39 | pods PodRepository 40 | deployments DeploymentRepository 41 | statefulsets StatefulsetRepository 42 | jobs JobRepository 43 | } 44 | 45 | // NamespaceStatus represent namespace with percentage of pods running and status phase (Active or Terminating) 46 | type NamespaceStatus struct { 47 | Status int `json:"status"` 48 | Phase string `json:"phase"` 49 | } 50 | 51 | type NamespaceEvent struct { 52 | Namespace string 53 | Type string 54 | } 55 | 56 | // NewNamespaceService creates a new NamespaceService 57 | func NewNamespaceService( 58 | namespaces NamespaceRepository, 59 | pods PodRepository, 60 | deployments DeploymentRepository, 61 | statefulsets StatefulsetRepository, 62 | jobs JobRepository, 63 | ) NamespaceService { 64 | 65 | ns := &namespaceService{ 66 | namespaces: namespaces, 67 | pods: pods, 68 | deployments: deployments, 69 | statefulsets: statefulsets, 70 | jobs: jobs, 71 | } 72 | 73 | return ns 74 | } 75 | 76 | // Create creates a kubernetes namespace 77 | func (ns *namespaceService) Create(n string) error { 78 | err := ns.namespaces.Create(n) 79 | 80 | if err != nil { 81 | return ErrorCreateNamespace{err.Error()} 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // ApplyConfig apply kubernetes configurations to the given namespace. 88 | // Warning : For now, this method takes a configPath as parameter. This parameter is the directory containing configs in a playbook 89 | // This may change since the NamespaceService should not be aware that configs are stored in files. 90 | func (ns *namespaceService) ApplyConfig(namespace, configPath string) error { 91 | return ns.namespaces.ApplyConfig(namespace, configPath) 92 | } 93 | 94 | // Delete deletes a kubernetes namespace 95 | func (ns *namespaceService) Delete(namespace string) error { 96 | return ns.namespaces.Delete(namespace) 97 | } 98 | 99 | // List returns a slice of namespace from the kubernetes package and enrich each of the 100 | // returned namespace with their status. 101 | func (ns *namespaceService) List() ([]Namespace, error) { 102 | namespaces, err := ns.namespaces.List() 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | var wg sync.WaitGroup 108 | 109 | for i := range namespaces { 110 | wg.Add(1) 111 | 112 | go func(index int) { 113 | status, err := ns.GetStatus(namespaces[index].Name) 114 | 115 | if err != nil { 116 | namespaces[index].Status = 0 117 | } 118 | 119 | namespaces[index].Status = status.Status 120 | wg.Done() 121 | }(i) 122 | } 123 | 124 | wg.Wait() 125 | 126 | return namespaces, nil 127 | } 128 | 129 | // GetStatus returns the status of an inventory 130 | // The status is an int that represents the percentage of pods in a "running" state inside the given namespace 131 | func (ns *namespaceService) GetStatus(namespace string) (*NamespaceStatus, error) { 132 | 133 | // get namespace state 134 | n, err := ns.namespaces.Get(namespace) 135 | if err != nil { 136 | return nil, fmt.Errorf("namespace get status: %v", err) 137 | } 138 | 139 | if n.Phase == "Terminating" { 140 | return &NamespaceStatus{0, n.Phase}, nil 141 | } 142 | 143 | dps, errDps := ns.deployments.List(namespace) 144 | sfs, errSfs := ns.statefulsets.List(namespace) 145 | jbs, errJbs := ns.jobs.List(namespace) 146 | 147 | if errDps != nil || errSfs != nil || errJbs != nil { 148 | return &NamespaceStatus{0, ""}, fmt.Errorf("namespace get status: list deployments, statefulsets or jobs: %v", err) 149 | } 150 | 151 | totalApps := len(dps) + len(sfs) + len(jbs) 152 | 153 | if totalApps == 0 { 154 | return &NamespaceStatus{0, n.Phase}, nil 155 | } 156 | 157 | var i int 158 | 159 | for _, dp := range dps { 160 | if dp.Status == DeploymentReady { 161 | i++ 162 | } 163 | } 164 | 165 | for _, sf := range sfs { 166 | if sf.Status == StatefulsetReady { 167 | i++ 168 | } 169 | } 170 | 171 | for _, job := range jbs { 172 | if job.Status == JobReady { 173 | i++ 174 | } 175 | } 176 | 177 | status := i * 100 / totalApps 178 | 179 | return &NamespaceStatus{status, n.Phase}, nil 180 | } 181 | 182 | func (ns *namespaceService) Watch(events chan NamespaceEvent) { 183 | ticker := time.NewTicker(5 * time.Second) 184 | defer close(events) 185 | 186 | for range ticker.C { 187 | err := ns.namespaces.Watch(events) 188 | if err != nil { 189 | ticker.Stop() 190 | } 191 | 192 | logrus. 193 | WithFields(logrus.Fields{"component": "watcher"}). 194 | Debug("watch namespace restarted") 195 | } 196 | 197 | logrus. 198 | WithFields(logrus.Fields{"component": "watcher"}). 199 | Error("watch namespace stopped due to error") 200 | } 201 | 202 | // ErrorCreateNamespace represents an error due to a namespace creation failure on kubernetes cluster 203 | type ErrorCreateNamespace struct { 204 | Msg string 205 | } 206 | 207 | // Error returns the error message 208 | func (err ErrorCreateNamespace) Error() string { 209 | return err.Msg 210 | } 211 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | 9 | "github.com/Meetic/blackbeard/pkg/playbook" 10 | "github.com/Meetic/blackbeard/pkg/resource" 11 | "github.com/Meetic/blackbeard/pkg/version" 12 | ) 13 | 14 | // Api represents the blackbeard entrypoint by defining the list of actions 15 | // blackbeard is able to perform. 16 | type Api interface { 17 | Inventories() playbook.InventoryService 18 | Namespaces() resource.NamespaceService 19 | Playbooks() playbook.PlaybookService 20 | Pods() resource.PodService 21 | Create(namespace string) (playbook.Inventory, error) 22 | Delete(namespace string, wait bool) error 23 | ListExposedServices(namespace string) ([]resource.Service, error) 24 | ListNamespaces() ([]Namespace, error) 25 | Reset(namespace string, configPath string) error 26 | Apply(namespace string, configPath string) error 27 | Update(namespace string, inventory playbook.Inventory, configPath string) error 28 | WaitForNamespaceReady(namespace string, timeout time.Duration, bar progress) error 29 | GetVersion() (*Version, error) 30 | DeleteResource(namespace string, resource string) error 31 | WatchNamespaceDeleted() 32 | } 33 | 34 | type api struct { 35 | inventories playbook.InventoryService 36 | configs playbook.ConfigService 37 | playbooks playbook.PlaybookService 38 | namespaces resource.NamespaceService 39 | pods resource.PodService 40 | services resource.ServiceService 41 | cluster resource.ClusterService 42 | job resource.JobService 43 | } 44 | 45 | // NewApi creates a blackbeard api. The blackbeard api is responsible for managing playbooks and namespaces. 46 | // Parameters are struct implementing respectively Inventory, Config, Namespace, Pod and Service interfaces. 47 | func NewApi( 48 | inventories playbook.InventoryRepository, 49 | configs playbook.ConfigRepository, 50 | playbooks playbook.PlaybookRepository, 51 | namespaces resource.NamespaceRepository, 52 | pods resource.PodRepository, 53 | deployments resource.DeploymentRepository, 54 | statefulsets resource.StatefulsetRepository, 55 | services resource.ServiceRepository, 56 | cluster resource.ClusterRepository, 57 | job resource.JobRepository, 58 | ) Api { 59 | api := &api{ 60 | inventories: playbook.NewInventoryService(inventories, playbook.NewPlaybookService(playbooks)), 61 | configs: playbook.NewConfigService(configs, playbook.NewPlaybookService(playbooks)), 62 | playbooks: playbook.NewPlaybookService(playbooks), 63 | namespaces: resource.NewNamespaceService(namespaces, pods, deployments, statefulsets, job), 64 | pods: resource.NewPodService(pods), 65 | services: resource.NewServiceService(services), 66 | cluster: resource.NewClusterService(cluster), 67 | job: resource.NewJobService(job), 68 | } 69 | 70 | return api 71 | } 72 | 73 | // Inventories returns the Inventory Service from the api 74 | func (api *api) Inventories() playbook.InventoryService { 75 | return api.inventories 76 | } 77 | 78 | // Namespaces returns the Namespace Service from the api 79 | func (api *api) Namespaces() resource.NamespaceService { 80 | return api.namespaces 81 | } 82 | 83 | // Playbooks returns the Playbook Service from the api 84 | func (api *api) Playbooks() playbook.PlaybookService { 85 | return api.playbooks 86 | } 87 | 88 | func (api *api) Pods() resource.PodService { 89 | return api.pods 90 | } 91 | 92 | // Create is responsible for creating an inventory, a set of kubernetes configs and a kubernetes namespace 93 | // for a given namespace. 94 | // If an inventory already exist, Create will log the error and continue the process. Configs will be override. 95 | func (api *api) Create(namespace string) (playbook.Inventory, error) { 96 | if err := api.namespaces.Create(namespace); err != nil { 97 | return playbook.Inventory{}, err 98 | } 99 | 100 | inv, err := api.inventories.Create(namespace) 101 | if err != nil { 102 | switch e := err.(type) { 103 | default: 104 | return playbook.Inventory{}, e 105 | case *playbook.ErrorInventoryAlreadyExist: 106 | logrus.Warn(e.Error()) 107 | logrus.Info("Process continue") 108 | } 109 | } 110 | 111 | if err := api.configs.Generate(inv); err != nil { 112 | return playbook.Inventory{}, err 113 | } 114 | 115 | return inv, nil 116 | } 117 | 118 | // Delete deletes the inventory, configs and kubernetes namespace for the given namespace. 119 | func (api *api) Delete(namespace string, wait bool) error { 120 | // delete namespace 121 | if err := api.namespaces.Delete(namespace); err != nil { 122 | return err 123 | } 124 | 125 | if !wait { 126 | api.deletePlaybook(namespace) 127 | } 128 | 129 | return nil 130 | } 131 | 132 | // ListExposedServices returns a list of services exposed somehow outside of the kubernetes cluster. 133 | // Exposed services could be : 134 | // * NodePort type services 135 | // * LoadBalancer type services 136 | // * Http services exposed throw Ingress 137 | func (api *api) ListExposedServices(namespace string) ([]resource.Service, error) { 138 | return api.services.ListExposed(namespace) 139 | } 140 | 141 | // Reset resets an inventory, the associated configs and the kubernetes namespaces to default values. 142 | // Defaults values are defines by the InventoryService GetDefault() method. 143 | func (api *api) Reset(namespace string, configPath string) error { 144 | //Reset inventory file 145 | inv, err := api.inventories.Reset(namespace) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | //Apply inventory to configuration 151 | if err := api.configs.Generate(inv); err != nil { 152 | return err 153 | } 154 | 155 | //Apply changes to Kubernetes 156 | if err = api.namespaces.ApplyConfig(namespace, configPath); err != nil { 157 | return err 158 | } 159 | 160 | return nil 161 | } 162 | 163 | // Apply override configs with new generated configs and apply the new configs to the kubernetes namespace. 164 | // Warning : For now, Apply require a configPath as parameter. 165 | // configPath is the location of configs for each namespace. This will change in the future since high level 166 | // api should not be aware that configs are stored in files. 167 | func (api *api) Apply(namespace string, configPath string) error { 168 | inv, err := api.inventories.Get(namespace) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | if err := api.configs.Generate(inv); err != nil { 174 | return err 175 | } 176 | 177 | if err := api.namespaces.ApplyConfig(inv.Namespace, configPath); err != nil { 178 | return err 179 | } 180 | 181 | return nil 182 | } 183 | 184 | // Update replace the inventory associated to the given namespace by the one set in parameters 185 | // and apply the changes to configs and kubernetes namespace (using the Apply method) 186 | func (api *api) Update(namespace string, inventory playbook.Inventory, configPath string) error { 187 | if err := api.inventories.Update(namespace, inventory); err != nil { 188 | return err 189 | } 190 | 191 | if err := api.Apply(namespace, configPath); err != nil { 192 | return err 193 | } 194 | 195 | return nil 196 | } 197 | 198 | // DeleteResource delete a resource from a namespace 199 | // Deletion of a Job only for now 200 | func (api *api) DeleteResource(namespace, resource string) error { 201 | if err := api.job.Delete(namespace, resource); err != nil { 202 | return err 203 | } 204 | 205 | return nil 206 | } 207 | 208 | func (api *api) WatchNamespaceDeleted() { 209 | events := make(chan resource.NamespaceEvent, 0) 210 | 211 | go api.namespaces.Watch(events) 212 | 213 | // handle delete of inventories and configs files 214 | for event := range events { 215 | if event.Type == "DELETED" { 216 | api.deletePlaybook(event.Namespace) 217 | 218 | logrus. 219 | WithFields(logrus.Fields{"component": "watcher", "event": "delete", "namespace": event.Namespace}). 220 | Debug("Playbook deleted") 221 | } 222 | } 223 | } 224 | 225 | func (api *api) deletePlaybook(namespace string) { 226 | if inv, _ := api.inventories.Get(namespace); inv.Namespace == namespace { 227 | api.inventories.Delete(namespace) 228 | api.configs.Delete(namespace) 229 | } 230 | } 231 | 232 | type Version struct { 233 | Blackbeard string `json:"blackbeard"` 234 | Kubernetes string `json:"kubernetes"` 235 | Kubectl string `json:"kubectl"` 236 | } 237 | 238 | func (api *api) GetVersion() (*Version, error) { 239 | v, err := api.cluster.GetVersion() 240 | 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | return &Version{ 246 | Blackbeard: version.GetVersion(), 247 | Kubectl: strings.Join([]string{v.ClientVersion.Major, v.ClientVersion.Minor}, "."), 248 | Kubernetes: strings.Join([]string{v.ServerVersion.Major, v.ServerVersion.Minor}, "."), 249 | }, nil 250 | } 251 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2018-07-23T16:19:48Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 118 | } 119 | echoerr() { 120 | echo "$@" 1>&2 121 | } 122 | log_prefix() { 123 | echo "$0" 124 | } 125 | _logp=6 126 | log_set_priority() { 127 | _logp="$1" 128 | } 129 | log_priority() { 130 | if test -z "$1"; then 131 | echo "$_logp" 132 | return 133 | fi 134 | [ "$1" -le "$_logp" ] 135 | } 136 | log_tag() { 137 | case $1 in 138 | 0) echo "emerg" ;; 139 | 1) echo "alert" ;; 140 | 2) echo "crit" ;; 141 | 3) echo "err" ;; 142 | 4) echo "warning" ;; 143 | 5) echo "notice" ;; 144 | 6) echo "info" ;; 145 | 7) echo "debug" ;; 146 | *) echo "$1" ;; 147 | esac 148 | } 149 | log_debug() { 150 | log_priority 7 || return 0 151 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 152 | } 153 | log_info() { 154 | log_priority 6 || return 0 155 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 156 | } 157 | log_err() { 158 | log_priority 3 || return 0 159 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 160 | } 161 | log_crit() { 162 | log_priority 2 || return 0 163 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 164 | } 165 | uname_os() { 166 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 167 | case "$os" in 168 | msys_nt) os="windows" ;; 169 | esac 170 | echo "$os" 171 | } 172 | uname_arch() { 173 | arch=$(uname -m) 174 | case $arch in 175 | x86_64) arch="amd64" ;; 176 | x86) arch="386" ;; 177 | i686) arch="386" ;; 178 | i386) arch="386" ;; 179 | aarch64) arch="arm64" ;; 180 | armv5*) arch="armv5" ;; 181 | armv6*) arch="armv6" ;; 182 | armv7*) arch="armv7" ;; 183 | esac 184 | echo ${arch} 185 | } 186 | uname_os_check() { 187 | os=$(uname_os) 188 | case "$os" in 189 | darwin) return 0 ;; 190 | dragonfly) return 0 ;; 191 | freebsd) return 0 ;; 192 | linux) return 0 ;; 193 | android) return 0 ;; 194 | nacl) return 0 ;; 195 | netbsd) return 0 ;; 196 | openbsd) return 0 ;; 197 | plan9) return 0 ;; 198 | solaris) return 0 ;; 199 | windows) return 0 ;; 200 | esac 201 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 202 | return 1 203 | } 204 | uname_arch_check() { 205 | arch=$(uname_arch) 206 | case "$arch" in 207 | 386) return 0 ;; 208 | amd64) return 0 ;; 209 | arm64) return 0 ;; 210 | armv5) return 0 ;; 211 | armv6) return 0 ;; 212 | armv7) return 0 ;; 213 | ppc64) return 0 ;; 214 | ppc64le) return 0 ;; 215 | mips) return 0 ;; 216 | mipsle) return 0 ;; 217 | mips64) return 0 ;; 218 | mips64le) return 0 ;; 219 | s390x) return 0 ;; 220 | amd64p32) return 0 ;; 221 | esac 222 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 223 | return 1 224 | } 225 | untar() { 226 | tarball=$1 227 | case "${tarball}" in 228 | *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; 229 | *.tar) tar -xf "${tarball}" ;; 230 | *.zip) unzip "${tarball}" ;; 231 | *) 232 | log_err "untar unknown archive format for ${tarball}" 233 | return 1 234 | ;; 235 | esac 236 | } 237 | mktmpdir() { 238 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 239 | mkdir -p "${TMPDIR}" 240 | echo "${TMPDIR}" 241 | } 242 | http_download_curl() { 243 | local_file=$1 244 | source_url=$2 245 | header=$3 246 | if [ -z "$header" ]; then 247 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 248 | else 249 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 250 | fi 251 | if [ "$code" != "200" ]; then 252 | log_debug "http_download_curl received HTTP status $code" 253 | return 1 254 | fi 255 | return 0 256 | } 257 | http_download_wget() { 258 | local_file=$1 259 | source_url=$2 260 | header=$3 261 | if [ -z "$header" ]; then 262 | wget -q -O "$local_file" "$source_url" 263 | else 264 | wget -q --header "$header" -O "$local_file" "$source_url" 265 | fi 266 | } 267 | http_download() { 268 | log_debug "http_download $2" 269 | if is_command curl; then 270 | http_download_curl "$@" 271 | return 272 | elif is_command wget; then 273 | http_download_wget "$@" 274 | return 275 | fi 276 | log_crit "http_download unable to find wget or curl" 277 | return 1 278 | } 279 | http_copy() { 280 | tmp=$(mktemp) 281 | http_download "${tmp}" "$1" "$2" || return 1 282 | body=$(cat "$tmp") 283 | rm -f "${tmp}" 284 | echo "$body" 285 | } 286 | github_release() { 287 | owner_repo=$1 288 | version=$2 289 | test -z "$version" && version="latest" 290 | giturl="https://github.com/${owner_repo}/releases/${version}" 291 | json=$(http_copy "$giturl" "Accept:application/json") 292 | test -z "$json" && return 1 293 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 294 | test -z "$version" && return 1 295 | echo "$version" 296 | } 297 | hash_sha256() { 298 | TARGET=${1:-/dev/stdin} 299 | if is_command gsha256sum; then 300 | hash=$(gsha256sum "$TARGET") || return 1 301 | echo "$hash" | cut -d ' ' -f 1 302 | elif is_command sha256sum; then 303 | hash=$(sha256sum "$TARGET") || return 1 304 | echo "$hash" | cut -d ' ' -f 1 305 | elif is_command shasum; then 306 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 307 | echo "$hash" | cut -d ' ' -f 1 308 | elif is_command openssl; then 309 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 310 | echo "$hash" | cut -d ' ' -f a 311 | else 312 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 313 | return 1 314 | fi 315 | } 316 | hash_sha256_verify() { 317 | TARGET=$1 318 | checksums=$2 319 | if [ -z "$checksums" ]; then 320 | log_err "hash_sha256_verify checksum file not specified in arg2" 321 | return 1 322 | fi 323 | BASENAME=${TARGET##*/} 324 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 325 | if [ -z "$want" ]; then 326 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 327 | return 1 328 | fi 329 | got=$(hash_sha256 "$TARGET") 330 | if [ "$want" != "$got" ]; then 331 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 332 | return 1 333 | fi 334 | } 335 | cat /dev/null <