diff --git a/.gitignore b/.gitignore index 75f0793..f78162c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,6 @@ _dist/ dist/ bin/ vendor/ -*.swp cmd/albatross/__debug_bin +pkg/helmcli/repository/testdata/config.lock coverage.txt diff --git a/api/repository/add.go b/api/repository/add.go new file mode 100644 index 0000000..98cb1c9 --- /dev/null +++ b/api/repository/add.go @@ -0,0 +1,133 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/gojekfarm/albatross/pkg/logger" + + "github.com/gorilla/mux" +) + +// AddRequest is the body for PUT request to repository +// swagger:model addRepoRequestBody +type AddRequest struct { + Name string `json:"-"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + + // example: false + ForceUpdate bool `json:"force_update"` + + // example: false + InsecureSkipTLSverify bool `json:"skip_tls_verify"` +} + +type addService interface { + Add(context.Context, AddRequest) (Entry, error) +} + +// AddErrorResponse body of non 2xx response +// swagger:model addRepoErrorResponseBody +type AddErrorResponse struct { + Error string `json:"error"` +} + +// Entry contains metadata about a helm repository entry object +// swagger:model addRepoEntry +type Entry struct { + Name string `json:"name"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` +} + +const URLNamePlaceholder string = "repository_name" + +// AddHandler handles a repo add/update request +// swagger:operation PUT /repositories/{repository_name} repository addOperation +// +// Add/Update a chart repository to the server. +// The endpoint is idempotent and a repository can be updated by using the force_update parameter to true +// --- +// produces: +// - application/json +// parameters: +// - name: repository_name +// in: path +// required: true +// type: string +// format: string +// - name: Body +// in: body +// required: true +// schema: +// "$ref": "#/definitions/addRepoRequestBody" +// schemes: +// - http +// responses: +// '200': +// description: "The repository was added successfully" +// schema: +// $ref: "#/definitions/addRepoEntry" +// '400': +// description: "Invalid Request" +// schema: +// $ref: "#/definitions/addRepoErrorResponseBody" +// '500': +// description: "Something went with the server" +// schema: +// $ref: "#/definitions/addRepoErrorResponseBody" +func AddHandler(s addService) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + vars := mux.Vars(r) + var req AddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logger.Errorf("[RepoAdd] error decoding request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := req.isValid(); err != nil { + logger.Errorf("[RepoAdd] error validating request %v", err) + respondAddError(w, "error adding repo", err, http.StatusBadRequest) + return + } + + req.Name = vars[URLNamePlaceholder] + + resp, err := s.Add(r.Context(), req) + + if err != nil { + logger.Errorf("[RepoAdd] error adding repo: %v", err) + respondAddError(w, "error adding repo", err, http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + respondAddError(w, "error writing response: %v", err, http.StatusInternalServerError) + return + } + }) +} + +func respondAddError(w http.ResponseWriter, logprefix string, err error, errorCode int) { + response := AddErrorResponse{Error: err.Error()} + w.WriteHeader(errorCode) + if err := json.NewEncoder(w).Encode(&response); err != nil { + logger.Errorf("[AddRepo] %s %v", logprefix, err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func (req AddRequest) isValid() error { + if req.URL == "" { + return errors.New("url cannot be empty") + } + return nil +} diff --git a/api/repository/add_test.go b/api/repository/add_test.go new file mode 100644 index 0000000..8a26be0 --- /dev/null +++ b/api/repository/add_test.go @@ -0,0 +1,133 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gojekfarm/albatross/pkg/logger" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "gotest.tools/assert" +) + +type mockService struct { + mock.Mock +} + +func (m *mockService) Add(ctx context.Context, req AddRequest) (Entry, error) { + args := m.Called(ctx, req) + return args.Get(0).(Entry), args.Error(1) +} + +type RepoAddTestSuite struct { + suite.Suite + recorder *httptest.ResponseRecorder + server *httptest.Server + mockService *mockService +} + +func (s *RepoAddTestSuite) SetupSuite() { + logger.Setup("default") +} + +func (s *RepoAddTestSuite) SetupTest() { + s.recorder = httptest.NewRecorder() + s.mockService = new(mockService) + router := mux.NewRouter() + path := fmt.Sprintf("/repositories/{%s}", URLNamePlaceholder) + router.Handle(path, AddHandler(s.mockService)).Methods(http.MethodPut) + s.server = httptest.NewServer(router) +} + +func (s *RepoAddTestSuite) TestRepoAddSuccessFul() { + repoName := "gojek-incubator" + urlName := "https://gojek.github.io/charts/incubator/" + body := fmt.Sprintf(`{"url":"%s", "username":"admin", "password":"123", + "allow_deprecated_repos":true, "force_update": true, "skip_tls_verify": true}`, urlName) + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/repositories/%s", s.server.URL, repoName), strings.NewReader(body)) + request := AddRequest{ + Name: repoName, + URL: urlName, + Username: "admin", + Password: "123", + ForceUpdate: true, + InsecureSkipTLSverify: true, + } + + mockAddResponse := Entry{ + Name: request.Name, + URL: request.URL, + Username: request.Username, + Password: request.Password, + } + s.mockService.On("Add", mock.Anything, request).Return(mockAddResponse, nil) + + resp, err := http.DefaultClient.Do(req) + assert.Equal(s.T(), http.StatusOK, resp.StatusCode) + + respBody, _ := ioutil.ReadAll(resp.Body) + require.NoError(s.T(), err) + + parsedResponse := &Entry{} + err = json.Unmarshal(respBody, parsedResponse) + require.NoError(s.T(), err) + assert.Equal(s.T(), mockAddResponse.Name, parsedResponse.Name) + assert.Equal(s.T(), mockAddResponse.URL, parsedResponse.URL) + assert.Equal(s.T(), mockAddResponse.Username, parsedResponse.Username) + assert.Equal(s.T(), mockAddResponse.Password, parsedResponse.Password) + s.mockService.AssertExpectations(s.T()) +} + +func (s *RepoAddTestSuite) TestRepoAddInvalidRequest() { + repoName := "gojek-incubator" + body := `{"username":"admin", "password":"123"}` + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/repositories/%s", s.server.URL, repoName), strings.NewReader(body)) + + resp, err := http.DefaultClient.Do(req) + assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode) + expectedResponse := `{"error":"url cannot be empty"}` + "\n" + respBody, _ := ioutil.ReadAll(resp.Body) + assert.Equal(s.T(), expectedResponse, string(respBody)) + require.NoError(s.T(), err) + s.mockService.AssertExpectations(s.T()) +} + +func (s *RepoAddTestSuite) TestRepoAddFailure() { + repoName := "gojek-incubator" + urlName := "https://gojek.github.io/charts/incubator/" + body := fmt.Sprintf(`{"url":"%s", "username":"admin", "password":"123"}`, urlName) + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/repositories/%s", s.server.URL, repoName), strings.NewReader(body)) + request := AddRequest{ + Name: repoName, + URL: urlName, + Username: "admin", + Password: "123", + } + + s.mockService.On("Add", mock.Anything, request).Return(Entry{}, errors.New("error adding repository")) + + resp, err := http.DefaultClient.Do(req) + assert.Equal(s.T(), http.StatusInternalServerError, resp.StatusCode) + expectedResponse := `{"error":"error adding repository"}` + "\n" + respBody, _ := ioutil.ReadAll(resp.Body) + assert.Equal(s.T(), expectedResponse, string(respBody)) + require.NoError(s.T(), err) + s.mockService.AssertExpectations(s.T()) +} + +func TestRepoAddAPI(t *testing.T) { + suite.Run(t, new(RepoAddTestSuite)) +} diff --git a/api/repository/service.go b/api/repository/service.go new file mode 100644 index 0000000..06ae1de --- /dev/null +++ b/api/repository/service.go @@ -0,0 +1,53 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + "github.com/gojekfarm/albatross/pkg/helmcli/repository" + "github.com/gojekfarm/albatross/pkg/logger" + + "helm.sh/helm/v3/pkg/repo" +) + +type Service struct { + cli repository.Client +} + +func (s Service) Add(ctx context.Context, req AddRequest) (Entry, error) { + addFlags := flags.AddFlags{ + Name: req.Name, + URL: req.URL, + ForceUpdate: req.ForceUpdate, + } + + adder, err := s.cli.NewAdder(addFlags) + if err != nil { + return Entry{}, err + } + + entry, err := adder.Add(ctx) + if err != nil { + return Entry{}, err + } + return getEntry(entry) +} + +func NewService(cli repository.Client) Service { + return Service{cli} +} + +func getEntry(entry *repo.Entry) (Entry, error) { + if entry != nil { + logger.Infof("Repository %s with URL: %s has been added", entry.Name, entry.URL) + return Entry{ + Name: entry.Name, + URL: entry.URL, + Username: entry.Username, + Password: entry.Password, + }, nil + } + + return Entry{}, fmt.Errorf("couldn't get repository from user") +} diff --git a/api/repository/service_test.go b/api/repository/service_test.go new file mode 100644 index 0000000..73070ff --- /dev/null +++ b/api/repository/service_test.go @@ -0,0 +1,108 @@ +package repository + +import ( + "context" + "errors" + "testing" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + "github.com/gojekfarm/albatross/pkg/helmcli/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/repo" +) + +type mockRepositoryClient struct{ mock.Mock } + +func (m *mockRepositoryClient) NewAdder(addFlags flags.AddFlags) (repository.Adder, error) { + args := m.Called(addFlags) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(repository.Adder), args.Error(1) +} + +type mockAdder struct{ mock.Mock } + +func (m *mockAdder) Add(ctx context.Context) (*repo.Entry, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*repo.Entry), args.Error(1) +} + +func TestServiceAddSuccessful(t *testing.T) { + mockCli := new(mockRepositoryClient) + adder := new(mockAdder) + s := NewService(mockCli) + req := AddRequest{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + } + addFlags := flags.AddFlags{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + } + mockAdd := &repo.Entry{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + } + mockCli.On("NewAdder", addFlags).Return(adder, nil).Once() + adder.On("Add", mock.Anything).Return(mockAdd, nil).Once() + + resp, err := s.Add(context.Background(), req) + + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, req.Name, resp.Name) + assert.Equal(t, req.URL, resp.URL) +} + +func TestServiceNewAdderError(t *testing.T) { + mockCli := new(mockRepositoryClient) + s := NewService(mockCli) + req := AddRequest{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + } + addFlags := flags.AddFlags{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + // Username: "user", + // Password: "password", + } + adderError := errors.New("failed creating adder") + mockCli.On("NewAdder", addFlags).Return(nil, adderError) + + resp, err := s.Add(context.Background(), req) + + require.Error(t, adderError, err) + assert.Equal(t, Entry{}, resp) +} + +func TestServiceAddError(t *testing.T) { + mockCli := new(mockRepositoryClient) + adder := new(mockAdder) + s := NewService(mockCli) + req := AddRequest{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + } + addFlags := flags.AddFlags{ + Name: "repoName", + URL: "https://gojek.github.io/charts/incubator/", + // Username: "user", + // Password: "password", + } + addError := errors.New("error while adding repo") + mockCli.On("NewAdder", addFlags).Return(adder, nil).Once() + adder.On("Add", mock.Anything).Return(nil, addError).Once() + + resp, err := s.Add(context.Background(), req) + + require.Error(t, addError, err) + assert.Equal(t, Entry{}, resp) +} diff --git a/cmd/albatross/albatross.go b/cmd/albatross/albatross.go index 9d38ed1..634bc92 100644 --- a/cmd/albatross/albatross.go +++ b/cmd/albatross/albatross.go @@ -11,10 +11,12 @@ import ( "github.com/gojekfarm/albatross/api" "github.com/gojekfarm/albatross/api/install" "github.com/gojekfarm/albatross/api/list" + "github.com/gojekfarm/albatross/api/repository" "github.com/gojekfarm/albatross/api/status" "github.com/gojekfarm/albatross/api/uninstall" "github.com/gojekfarm/albatross/api/upgrade" "github.com/gojekfarm/albatross/pkg/helmcli" + helmRepository "github.com/gojekfarm/albatross/pkg/helmcli/repository" "github.com/gojekfarm/albatross/pkg/logger" _ "github.com/gojekfarm/albatross/swagger" @@ -51,6 +53,9 @@ func startServer() { router.Handle("/clusters/{cluster}/namespaces/{namespace}/releases", ContentTypeMiddle(listHandler)).Methods(http.MethodGet) router.Handle("/clusters/{cluster}/namespaces/{namespace}/releases/{release_name}", ContentTypeMiddle(statusHandler)).Methods(http.MethodGet) + repositorySubrouter := router.PathPrefix("/repositories").Subrouter() + handleRepositoryRoutes(repositorySubrouter) + serveDocumentation(router) err := http.ListenAndServe(fmt.Sprintf(":%d", 8080), router) if err != nil { @@ -66,3 +71,9 @@ func serveDocumentation(r *mux.Router) { r.PathPrefix("/docs/").Handler(http.StripPrefix("/docs/", fs)) } } + +func handleRepositoryRoutes(router *mux.Router) { + repoClient := helmRepository.NewClient() + repoService := repository.NewService(repoClient) + router.Handle(fmt.Sprintf("/{%s}", repository.URLNamePlaceholder), ContentTypeMiddle(repository.AddHandler(repoService))).Methods(http.MethodPut) +} diff --git a/docs/swagger.json b/docs/swagger.json index eb9578b..1df1a35 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12,7 +12,7 @@ "info": { "description": "Albatross is a helm cli wrapper and enables using helm via http calls", "title": "Albatross API.", - "version": "v1.0.3" + "version": "v1.1.0" }, "paths": { "/clusters/{cluster}/namespaces/{namespace}/releases": { @@ -441,6 +441,59 @@ } } } + }, + "/repositories/{repository_name}": { + "put": { + "description": "The endpoint is idempotent and a repository can be updated by using the force_update parameter to true", + "produces": [ + "application/json" + ], + "schemes": [ + "http" + ], + "tags": [ + "repository" + ], + "summary": "Add/Update a chart repository to the server.", + "operationId": "addOperation", + "parameters": [ + { + "type": "string", + "format": "string", + "name": "repository_name", + "in": "path", + "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/addRepoRequestBody" + } + } + ], + "responses": { + "200": { + "description": "The repository was added successfully", + "schema": { + "$ref": "#/definitions/addRepoEntry" + } + }, + "400": { + "description": "Invalid Request", + "schema": { + "$ref": "#/definitions/addRepoErrorResponseBody" + } + }, + "500": { + "description": "Something went with the server", + "schema": { + "$ref": "#/definitions/addRepoErrorResponseBody" + } + } + } + } } }, "definitions": { @@ -497,6 +550,72 @@ }, "x-go-package": "github.com/gojekfarm/albatross/api/upgrade" }, + "addRepoEntry": { + "description": "Entry contains metadata about a helm repository entry object", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-go-name": "Name" + }, + "password": { + "type": "string", + "x-go-name": "Password" + }, + "url": { + "type": "string", + "x-go-name": "URL" + }, + "username": { + "type": "string", + "x-go-name": "Username" + } + }, + "x-go-name": "Entry", + "x-go-package": "github.com/gojekfarm/albatross/api/repository" + }, + "addRepoErrorResponseBody": { + "description": "AddErrorResponse body of non 2xx response", + "type": "object", + "properties": { + "error": { + "type": "string", + "x-go-name": "Error" + } + }, + "x-go-name": "AddErrorResponse", + "x-go-package": "github.com/gojekfarm/albatross/api/repository" + }, + "addRepoRequestBody": { + "description": "AddRequest is the body for PUT request to repository", + "type": "object", + "properties": { + "force_update": { + "type": "boolean", + "x-go-name": "ForceUpdate", + "example": false + }, + "password": { + "type": "string", + "x-go-name": "Password" + }, + "skip_tls_verify": { + "type": "boolean", + "x-go-name": "InsecureSkipTLSverify", + "example": false + }, + "url": { + "type": "string", + "x-go-name": "URL" + }, + "username": { + "type": "string", + "x-go-name": "Username" + } + }, + "x-go-name": "AddRequest", + "x-go-package": "github.com/gojekfarm/albatross/api/repository" + }, "globalFlags": { "description": "GlobalFlags flags which give context about kubernetes cluster to connect to", "type": "object", diff --git a/go.mod b/go.mod index 63a7c60..a05ebdb 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/gojekfarm/albatross go 1.13 require ( - github.com/gorilla/mux v1.8.0 + github.com/gofrs/flock v0.7.1 + github.com/gorilla/mux v1.7.2 github.com/gorilla/schema v1.2.0 github.com/stretchr/testify v1.5.1 go.uber.org/zap v1.10.0 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + gopkg.in/yaml.v2 v2.2.8 gotest.tools v2.2.0+incompatible helm.sh/helm/v3 v3.2.4 k8s.io/cli-runtime v0.18.0 diff --git a/go.sum b/go.sum index 74ae17e..480fdf4 100644 --- a/go.sum +++ b/go.sum @@ -235,6 +235,7 @@ github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kE github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= @@ -281,8 +282,6 @@ github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33 h1:893HsJqtxp9z1S github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -628,6 +627,8 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7 h1:HmbHVPwrPEKPGLAcHSrMe6+hqSUlvZU0rab6x5EXfGU= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/helmcli/flags/flags.go b/pkg/helmcli/flags/flags.go index 5a9ecea..8f5203f 100644 --- a/pkg/helmcli/flags/flags.go +++ b/pkg/helmcli/flags/flags.go @@ -52,3 +52,19 @@ type StatusFlags struct { Version int GlobalFlags } + +type AddFlags struct { + Name string + URL string + Username string + Password string + ForceUpdate bool + + CertFile string + KeyFile string + CaFile string + InsecureSkipTLSverify bool + + RepoFile string + RepoCache string +} diff --git a/pkg/helmcli/repository/adder.go b/pkg/helmcli/repository/adder.go new file mode 100644 index 0000000..6cd4882 --- /dev/null +++ b/pkg/helmcli/repository/adder.go @@ -0,0 +1,148 @@ +package repository + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + "github.com/gojekfarm/albatross/pkg/logger" + + "github.com/gofrs/flock" + "gopkg.in/yaml.v2" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" +) + +const timeout time.Duration = 30 * time.Second + +type adder struct { + flags.AddFlags + settings *cli.EnvSettings +} + +func (o *adder) Add(ctx context.Context) (*repo.Entry, error) { + err := o.checkPrerequisite() + if err != nil { + return nil, err + } + + // Acquire a file lock for process synchronization + fileLock := flock.New(strings.Replace(o.RepoFile, filepath.Ext(o.RepoFile), ".lock", 1)) + lockCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + locked, err := fileLock.TryLockContext(lockCtx, time.Second) + if err == nil && locked { + defer checkFileUnlock(fileLock.Unlock) + } + if err != nil { + return nil, err + } + + c := repo.Entry{ + Name: o.Name, + URL: o.URL, + Username: o.Username, + Password: o.Password, + CertFile: o.CertFile, + KeyFile: o.KeyFile, + CAFile: o.CaFile, + InsecureSkipTLSverify: o.InsecureSkipTLSverify, + } + + f, err := o.updateRepoEntryInFile(c) + if err != nil { + return nil, err + } + + if err := f.WriteFile(o.RepoFile, 0644); err != nil { + return nil, err + } + + return &c, nil +} + +func (o *adder) checkPrerequisite() error { + // Ensure the file directory exists as it is required for file locking + err := os.MkdirAll(filepath.Dir(o.RepoFile), os.ModePerm) + if err != nil && !os.IsExist(err) { + return err + } + return nil +} + +func (o *adder) updateRepoEntryInFile(c repo.Entry) (*repo.File, error) { + f, err := o.initialiseRepoFile() + if err != nil { + return nil, err + } + + err = o.validateRepoFile(f, c) + if err != nil { + return nil, err + } + + err = o.initialiseChartsFromRepository(c) + if err != nil { + return nil, err + } + + f.Update(&c) + return f, nil +} + +func (o *adder) initialiseRepoFile() (*repo.File, error) { + b, err := ioutil.ReadFile(o.RepoFile) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + var f repo.File + if err := yaml.Unmarshal(b, &f); err != nil { + return nil, err + } + + return &f, nil +} + +func (o *adder) initialiseChartsFromRepository(c repo.Entry) error { + r, err := repo.NewChartRepository(&c, getter.All(o.settings)) + if err != nil { + return err + } + + if o.RepoCache != "" { + r.CachePath = o.RepoCache + } + if _, err := r.DownloadIndexFile(); err != nil { + return fmt.Errorf("%w looks like %v is not a valid chart repository or cannot be reached", err, o.URL) + } + return nil +} + +func (o *adder) validateRepoFile(f *repo.File, c repo.Entry) error { + // If the repo exists do one of two things: + // 1. If the configuration for the name is the same continue without error + // 2. When the config is different require --force-update + if !o.ForceUpdate && f.Has(o.Name) { + existing := f.Get(o.Name) + if c != *existing { + // The input coming in for the name is different from what is already + // configured. Return an error. + return fmt.Errorf("repository name (%s) already exists, please use force_update to update or a different name to make a new entry", o.Name) + } + } + + return nil +} + +func checkFileUnlock(f func() error) { + if err := f(); err != nil { + logger.Errorf("Error while %v", err) + } +} diff --git a/pkg/helmcli/repository/adder_test.go b/pkg/helmcli/repository/adder_test.go new file mode 100644 index 0000000..ab91b28 --- /dev/null +++ b/pkg/helmcli/repository/adder_test.go @@ -0,0 +1,213 @@ +package repository + +import ( + "context" + "io" + "io/ioutil" + "os" + "testing" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "gopkg.in/yaml.v2" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/repo" +) + +type AdderTestSuite struct { + suite.Suite +} + +const ( + testConfigPath string = "./testdata/config.yaml" + testCachePath string = "./testdata/cache" + testRegistryConfig string = "./testdata/registry.json" + testSampleConfigPath1 string = "./testdata/config_sample.yaml" + testSampleConfigPath2 string = "./testdata/config_sample2.yaml" +) + +func (s *AdderTestSuite) SetupTest() { + os.Setenv("HELM_REPOSITORY_CONFIG", testConfigPath) + os.Setenv("HELM_REGISTRY_CONFIG", testRegistryConfig) + os.Setenv("HELM_REPOSITORY_CACHE", testCachePath) +} + +func initialiseAdder() *adder { + settings := cli.New() + adder := &adder{ + AddFlags: flags.AddFlags{ + Name: "influxdata", + URL: "https://helm.influxdata.com/", + RepoFile: settings.RepositoryConfig, + RepoCache: settings.RepositoryCache, + }, + settings: settings, + } + return adder +} + +func (s *AdderTestSuite) TestAddRepo() { + newAdder := initialiseAdder() + entry, err := newAdder.Add(context.Background()) + + suiteAssertion := s.Assert() + suiteAssertion.NoError(err) + b, err := ioutil.ReadFile(newAdder.RepoFile) + suiteAssertion.NoError(err) + var f repo.File + expectedRepo := &repo.Entry{ + Name: "influxdata", + URL: "https://helm.influxdata.com/", + } + suiteAssertion.NoError(yaml.Unmarshal(b, &f)) + repoFoundCount := 0 + suiteAssertion.Equal(expectedRepo.Name, entry.Name) + suiteAssertion.Equal(expectedRepo.URL, entry.URL) + for _, repo := range f.Repositories { + if repo.Name == expectedRepo.Name { + suiteAssertion.Equal(expectedRepo, repo) + repoFoundCount++ + } + } + if repoFoundCount == 0 { + suiteAssertion.FailNow("Did not find added repo in configuration") + } else if repoFoundCount > 1 { + suiteAssertion.FailNow("Duplicate repo found") + } +} + +func (s *AdderTestSuite) TestAddDuplicateRepo() { + err := copyUtil(testSampleConfigPath1, testConfigPath) + require.NoError(s.T(), err) + newAdder := initialiseAdder() + newAdder.Username = "abcd" + newAdder.Password = "1234" + entry, err := newAdder.Add(context.Background()) + assert.Error(s.T(), err) + assert.Equal(s.T(), "repository name (influxdata) already exists, please use force_update to update or a different name to make a new entry", err.Error()) + assert.Nil(s.T(), entry) +} + +func (s *AdderTestSuite) TestAddDuplicateRepoForceUpdate() { + err := copyUtil(testSampleConfigPath1, testConfigPath) + require.NoError(s.T(), err) + newAdder := initialiseAdder() + newAdder.Username = "abcd" + newAdder.Password = "1234" + newAdder.ForceUpdate = true + + entry, err := newAdder.Add(context.Background()) + + suiteAssertion := s.Assert() + suiteAssertion.NoError(err) + b, err := ioutil.ReadFile(newAdder.RepoFile) + suiteAssertion.NoError(err) + var f repo.File + expectedRepo := &repo.Entry{ + Name: "influxdata", + URL: "https://helm.influxdata.com/", + Username: "abcd", + Password: "1234", + } + suiteAssertion.NoError(yaml.Unmarshal(b, &f)) + suiteAssertion.Equal(expectedRepo.Name, entry.Name) + suiteAssertion.Equal(expectedRepo.URL, entry.URL) + suiteAssertion.Equal(expectedRepo.Username, entry.Username) + suiteAssertion.Equal(expectedRepo.Password, entry.Password) + repoFoundCount := 0 + for _, repo := range f.Repositories { + if repo.Name == expectedRepo.Name { + suiteAssertion.Equal(expectedRepo, repo) + repoFoundCount++ + } + } + if repoFoundCount == 0 { + suiteAssertion.FailNow("Did not find added repo in configuration") + } else if repoFoundCount > 1 { + suiteAssertion.FailNow("Duplicate repo found") + } +} + +// func (s *AdderTestSuite) TestAddRepoToExistingFile() { + +// } + +func (s *AdderTestSuite) TestAddingToExistingConfig() { + err := copyUtil(testSampleConfigPath2, testConfigPath) + require.NoError(s.T(), err) + newAdder := initialiseAdder() + entry, err := newAdder.Add(context.Background()) + require.NoError(s.T(), err) + b, err := ioutil.ReadFile(newAdder.RepoFile) + assert.NoError(s.T(), err) + var f repo.File + expectedRepo := &repo.Entry{ + Name: "influxdata", + URL: "https://helm.influxdata.com/", + } + assert.NoError(s.T(), yaml.Unmarshal(b, &f)) + assert.Equal(s.T(), expectedRepo.Name, entry.Name) + assert.Equal(s.T(), expectedRepo.URL, entry.URL) + repoFoundCount := 0 + for _, repo := range f.Repositories { + if repo.Name == expectedRepo.Name { + assert.Equal(s.T(), expectedRepo, repo) + repoFoundCount++ + } + } + assert.NotEqual(s.T(), 1, len(f.Repositories)) + if repoFoundCount == 0 { + assert.FailNow(s.T(), "Did not find added repo in configuration") + } else if repoFoundCount > 1 { + assert.FailNow(s.T(), "Duplicate repo found") + } +} + +func (s *AdderTestSuite) TearDownTest() { + os.Unsetenv("HELM_REPOSITORY_CONFIG") + os.Unsetenv("HELM_REPOSITORY_CACHE") + os.Unsetenv("HELM_REGISTRY_CONFIG") + err := os.Remove(testConfigPath) + if err != nil { + s.FailNow("Failed to delete config file", err) + } + err = os.RemoveAll(testCachePath) + if err != nil { + s.FailNow("Failed to delete cache folder", err) + } +} + +func TestAdderSuite(t *testing.T) { + suite.Run(t, new(AdderTestSuite)) +} + +func copyUtil(sourceFileName, destinationFileName string) error { + srcFile, err := os.Open(sourceFileName) + if err != nil { + return err + } + defer srcFile.Close() + if err != nil { + return err + } + + destFile, err := os.Create(destinationFileName) + if err != nil { + return err + } + defer destFile.Close() + if err != nil { + return err + } + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + + err = destFile.Sync() + return err +} diff --git a/pkg/helmcli/repository/client.go b/pkg/helmcli/repository/client.go new file mode 100644 index 0000000..537cd3b --- /dev/null +++ b/pkg/helmcli/repository/client.go @@ -0,0 +1,32 @@ +package repository + +import ( + "context" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/repo" +) + +type Client interface { + NewAdder(flags.AddFlags) (Adder, error) +} + +type Adder interface { + Add(ctx context.Context) (*repo.Entry, error) +} + +type repoClient struct{} + +func (c repoClient) NewAdder(addFlags flags.AddFlags) (Adder, error) { + settings := cli.New() + addFlags.RepoCache = settings.RepositoryCache + addFlags.RepoFile = settings.RepositoryConfig + newAdder := adder{AddFlags: addFlags, settings: settings} + return &newAdder, nil +} + +func NewClient() Client { + return repoClient{} +} diff --git a/pkg/helmcli/repository/client_test.go b/pkg/helmcli/repository/client_test.go new file mode 100644 index 0000000..6a27f12 --- /dev/null +++ b/pkg/helmcli/repository/client_test.go @@ -0,0 +1,57 @@ +package repository + +import ( + "os" + "testing" + + "github.com/gojekfarm/albatross/pkg/helmcli/flags" + + "github.com/stretchr/testify/suite" +) + +type RepositoryClientTestSuite struct { + suite.Suite + c Client +} + +const ( + configPath string = "./testdata/repositories.yaml" + cachePath string = "./testdata/repositories" +) + +func (s *RepositoryClientTestSuite) SetupTest() { + s.c = NewClient() + os.Setenv("HELM_REPOSITORY_CONFIG", configPath) + os.Setenv("HELM_REPOSITORY_CACHE", cachePath) +} + +func (s *RepositoryClientTestSuite) TestNewAdder() { + addFlags := flags.AddFlags{ + Name: "repo-name", + URL: "http://helm-repository.com", + Username: "abcd", + Password: "1234", + } + newAdder, err := s.c.NewAdder(addFlags) + + assertion := s.Assert() + assertion.NoError(err) + adderStruct, ok := newAdder.(*adder) + assertion.True(ok) + assertion.Equal(addFlags.Name, adderStruct.Name) + assertion.Equal(addFlags.URL, adderStruct.URL) + assertion.Equal(addFlags.Username, adderStruct.Username) + assertion.Equal(addFlags.Password, adderStruct.Password) + assertion.Equal(cachePath, adderStruct.RepoCache) + assertion.Equal(configPath, adderStruct.RepoFile) + assertion.NotNil(adderStruct.settings) +} + +func (s *RepositoryClientTestSuite) TearDownTest() { + os.Unsetenv("HELM_REPOSITORY_CONFIG") + os.Unsetenv("HELM_REPOSITORY_CACHE") +} + +func TestRepoClient(t *testing.T) { + suite.Run(t, new(RepositoryClientTestSuite)) +} diff --git a/pkg/helmcli/repository/testdata/config_sample.yaml b/pkg/helmcli/repository/testdata/config_sample.yaml new file mode 100644 index 0000000..a0645fc --- /dev/null +++ b/pkg/helmcli/repository/testdata/config_sample.yaml @@ -0,0 +1,11 @@ +apiVersion: "" +generated: "0001-01-01T00:00:00Z" +repositories: +- caFile: "" + certFile: "" + insecure_skip_tls_verify: false + keyFile: "" + name: influxdata + password: "" + url: https://helm.influxdata.com/ + username: "" diff --git a/pkg/helmcli/repository/testdata/config_sample2.yaml b/pkg/helmcli/repository/testdata/config_sample2.yaml new file mode 100644 index 0000000..029139f --- /dev/null +++ b/pkg/helmcli/repository/testdata/config_sample2.yaml @@ -0,0 +1,19 @@ +apiVersion: "" +generated: "0001-01-01T00:00:00Z" +repositories: +- caFile: "" + certFile: "" + insecure_skip_tls_verify: false + keyFile: "" + name: stable + password: "" + url: https://charts.helm.sh/stable + username: "" +- caFile: "" + certFile: "" + insecure_skip_tls_verify: false + keyFile: "" + name: gojektech-incubator + password: "" + url: https://gojek.github.io/charts/incubator/ + username: "" diff --git a/swagger/docs.go b/swagger/docs.go index cd138ec..d489007 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -3,7 +3,7 @@ // Albatross is a helm cli wrapper and enables using helm via http calls // // Schemes: http -// Version: v1.0.3 +// Version: v1.1.0 // // Consumes: // - application/json