diff --git a/pkg/sparrow/run.go b/pkg/sparrow/run.go index 41d888d9..92199f62 100644 --- a/pkg/sparrow/run.go +++ b/pkg/sparrow/run.go @@ -63,7 +63,7 @@ func New(cfg *config.Config) *Sparrow { cCfgChecks: make(chan map[string]any, 1), routingTree: api.NewRoutingTree(), router: chi.NewRouter(), - targets: targets.NewGitlabManager(targets.NewGitlabClient("targetsRepo", "gitlabToken"), 5*time.Minute, 15*time.Minute), + targets: targets.NewGitlabManager(targets.NewGitlab("targetsRepo", "gitlabToken"), 5*time.Minute, 15*time.Minute), } sparrow.loader = config.NewLoader(cfg, sparrow.cCfgChecks) diff --git a/pkg/sparrow/targets/gitlab.go b/pkg/sparrow/targets/gitlab.go index 47347a41..3e660da3 100644 --- a/pkg/sparrow/targets/gitlab.go +++ b/pkg/sparrow/targets/gitlab.go @@ -1,8 +1,12 @@ package targets import ( + "bytes" "context" + "encoding/json" + "fmt" "net/http" + "net/url" "time" "github.com/caas-team/sparrow/internal/logger" @@ -16,19 +20,37 @@ var ( // Gitlab handles interaction with a gitlab repository containing // the global targets for the Sparrow instance type Gitlab interface { - ReadGlobalTargets(ctx context.Context) ([]globalTarget, error) - RegisterSelf(ctx context.Context) error + FetchFiles(ctx context.Context) ([]globalTarget, error) + PutFile(ctx context.Context, file GitlabFile) error + PostFile(ctx context.Context, file GitlabFile) error +} + +// gitlab implements Gitlab +type gitlab struct { + // the base URL of the gitlab instance + baseUrl string + // the ID of the project containing the global targets + projectID int + // the token used to authenticate with the gitlab instance + token string + client *http.Client } // gitlabTargetManager implements TargetManager type gitlabTargetManager struct { targets []globalTarget gitlab Gitlab + // the DNS name used for self-registration + name string // the interval for the target reconciliation process checkInterval time.Duration // the amount of time a target can be // unhealthy before it is removed from the global target list unhealthyThreshold time.Duration + // how often the instance should register itself as a global target + registrationInterval time.Duration + // whether the instance has already registered itself as a global target + registered bool } // NewGitlabManager creates a new gitlabTargetManager @@ -41,58 +63,84 @@ func NewGitlabManager(g Gitlab, checkInterval, unhealthyThreshold time.Duration) } } -// gitlab implements Gitlab -type gitlab struct { - url string - token string - client *http.Client +// file represents a file in a gitlab repository +type file struct { + Name string `json:"name"` } -func NewGitlabClient(url, token string) Gitlab { +func NewGitlab(url, token string) Gitlab { return &gitlab{ - url: url, - token: token, - client: &http.Client{}, + baseUrl: url, + token: token, + client: &http.Client{}, } } -func (t *gitlabTargetManager) Register(ctx context.Context) { - log := logger.FromContext(ctx).With("name", "RegisterGlobalTargets") - log.Debug("Registering global gitlabTargetManager") +// updateRegistration registers the current instance as a global target +func (t *gitlabTargetManager) updateRegistration(ctx context.Context) error { + log := logger.FromContext(ctx).With("name", t.name, "registered", t.registered) + log.Debug("Updating registration") + + f := GitlabFile{ + Branch: "main", + AuthorEmail: fmt.Sprintf("%s@sparrow", t.name), + AuthorName: t.name, + Content: globalTarget{Url: fmt.Sprintf("https://%s", t.name), LastSeen: time.Now().UTC()}, + } + + if t.registered { + f.CommitMessage = "Updated registration" + err := t.gitlab.PutFile(ctx, f) + if err != nil { + log.Error("Failed to update registration", "error", err) + return err + } + log.Debug("Successfully updated registration") + return nil + } - err := t.gitlab.RegisterSelf(ctx) + f.CommitMessage = "Initial registration" + err := t.gitlab.PostFile(ctx, f) if err != nil { log.Error("Failed to register global gitlabTargetManager", "error", err) } + + log.Debug("Successfully registered") + t.registered = true + return nil } // Reconcile reconciles the targets of the gitlabTargetManager. -// The global gitlabTargetManager are parsed from a remote endpoint. +// The global targets are parsed from a remote endpoint. // -// The global gitlabTargetManager are evaluated for healthiness and +// The global targets are evaluated for healthiness and // unhealthy gitlabTargetManager are removed. func (t *gitlabTargetManager) Reconcile(ctx context.Context) { log := logger.FromContext(ctx).With("name", "ReconcileGlobalTargets") log.Debug("Starting global gitlabTargetManager reconciler") for { - // start a timer - timer := time.NewTimer(t.checkInterval) - defer timer.Stop() - select { case <-ctx.Done(): if err := ctx.Err(); err != nil { log.Error("Context canceled", "error", err) return } - case <-timer.C: + // check if this blocks when context is canceled + case <-time.After(t.checkInterval): log.Debug("Getting global gitlabTargetManager") - err := t.updateTargets(ctx) + err := t.refreshTargets(ctx) if err != nil { log.Error("Failed to get global gitlabTargetManager", "error", err) continue } + case <-time.After(t.registrationInterval): + log.Debug("Registering global gitlabTargetManager") + err := t.updateRegistration(ctx) + if err != nil { + log.Error("Failed to register global gitlabTargetManager", "error", err) + continue + } } } } @@ -102,19 +150,21 @@ func (t *gitlabTargetManager) GetTargets() []globalTarget { return t.targets } -// updateTargets sets the global gitlabTargetManager -func (t *gitlabTargetManager) updateTargets(ctx context.Context) error { +// refreshTargets updates the targets of the gitlabTargetManager +// with the latest available healthy targets +func (t *gitlabTargetManager) refreshTargets(ctx context.Context) error { log := logger.FromContext(ctx).With("name", "updateGlobalTargets") var healthyTargets []globalTarget - targets, err := t.gitlab.ReadGlobalTargets(ctx) + targets, err := t.gitlab.FetchFiles(ctx) if err != nil { log.Error("Failed to update global targets", "error", err) return err } + // filter unhealthy targets - this may be removed in the future for _, target := range targets { - if time.Now().Add(-t.unhealthyThreshold).After(target.lastSeen) { + if time.Now().Add(-t.unhealthyThreshold).After(target.LastSeen) { continue } healthyTargets = append(healthyTargets, target) @@ -125,23 +175,207 @@ func (t *gitlabTargetManager) updateTargets(ctx context.Context) error { return nil } -// ReadGlobalTargets fetches the global gitlabTargetManager from the configured gitlab repository -func (g *gitlab) ReadGlobalTargets(ctx context.Context) ([]globalTarget, error) { - log := logger.FromContext(ctx).With("name", "ReadGlobalTargets") - log.Debug("Fetching global gitlabTargetManager") +// FetchFiles fetches the files from the global targets repository from the configured gitlab repository +func (g *gitlab) FetchFiles(ctx context.Context) ([]globalTarget, error) { + log := logger.FromContext(ctx).With("name", "FetchFiles") + fl, err := g.fetchFileList(ctx) + if err != nil { + log.Error("Failed to fetch files", "error", err) + return nil, err + } + + result, err := g.fetchFiles(ctx, fl) + if err != nil { + log.Error("Failed to fetch files", "error", err) + return nil, err + } + log.Info("Successfully fetched all target files", "files", len(result)) + return result, nil +} + +// fetchFiles fetches the files from the global targets repository from the configured gitlab repository +func (g *gitlab) fetchFiles(ctx context.Context, fl []string) ([]globalTarget, error) { + var result []globalTarget + log := logger.FromContext(ctx).With("name", "fetchFiles") + log.Debug("Fetching global files") + for _, f := range fl { + // URL encode the name + n := url.PathEscape(f) + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, + fmt.Sprintf("%s/api/v4/projects/%d/repository/files/%s/raw?ref=main", g.baseUrl, g.projectID, n), + http.NoBody, + ) + if err != nil { + log.Error("Failed to create request", "error", err) + return nil, err + } + req.Header.Add("PRIVATE-TOKEN", g.token) + req.Header.Add("Content-Type", "application/json") + + res, err := g.client.Do(req) + if err != nil { + log.Error("Failed to fetch file", "file", f, "error", err) + return nil, err + } + if res.StatusCode != http.StatusOK { + log.Error("Failed to fetch file", "status", res.Status) + return nil, fmt.Errorf("request failed, status is %s", res.Status) + } + + defer res.Body.Close() + var gt globalTarget + err = json.NewDecoder(res.Body).Decode(>) + if err != nil { + log.Error("Failed to decode file after fetching", "file", f, "error", err) + return nil, err + } + + log.Debug("Successfully fetched file", "file", f) + result = append(result, gt) + } + return result, nil +} + +// fetchFileList fetches the files from the global targets repository from the configured gitlab repository +func (g *gitlab) fetchFileList(ctx context.Context) ([]string, error) { + log := logger.FromContext(ctx).With("name", "fetchFileList") + log.Debug("Fetching global files") + type file struct { + Name string `json:"name"` + } + + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, + fmt.Sprintf("%s/api/v4/projects/%d/repository/tree?ref=main", g.baseUrl, g.projectID), + http.NoBody, + ) + if err != nil { + log.Error("Failed to create request", "error", err) + return nil, err + } + + req.Header.Add("PRIVATE-TOKEN", g.token) + req.Header.Add("Content-Type", "application/json") + + res, err := g.client.Do(req) + if err != nil { + log.Error("Failed to fetch file list", "error", err) + return nil, err + } + if res.StatusCode != http.StatusOK { + log.Error("Failed to fetch file list", "status", res.Status) + return nil, fmt.Errorf("request failed, status is %s", res.Status) + } + + defer res.Body.Close() + var fl []file + err = json.NewDecoder(res.Body).Decode(&fl) + if err != nil { + log.Error("Failed to decode file list", "error", err) + return nil, err + } + + var result []string + for _, f := range fl { + result = append(result, f.Name) + } + + log.Debug("Successfully fetched file list", "files", len(result)) + return result, nil +} + +type GitlabFile struct { + Branch string `json:"branch"` + AuthorEmail string `json:"author_email"` + AuthorName string `json:"author_name"` + Content globalTarget `json:"content"` + CommitMessage string `json:"commit_message"` + fileName string +} + +// Bytes returns the bytes of the GitlabFile +func (g GitlabFile) Bytes() ([]byte, error) { + b, err := json.Marshal(g) + return b, err +} + +// PutFile commits the current instance to the configured gitlab repository +// as a global target for other sparrow instances to discover +func (g *gitlab) PutFile(ctx context.Context, body GitlabFile) error { + log := logger.FromContext(ctx).With("name", "AddRegistration") + log.Debug("Registering sparrow instance to gitlab") + + // chose method based on whether the registration has already happened + n := url.PathEscape(body.Content.Url) + b, err := body.Bytes() + if err != nil { + log.Error("Failed to create request", "error", err) + return err + } + req, err := http.NewRequestWithContext(ctx, + http.MethodGet, + fmt.Sprintf("%s/api/v4/projects/%d/repository/files/%s", g.baseUrl, g.projectID, n), + bytes.NewBuffer(b), + ) + if err != nil { + log.Error("Failed to create request", "error", err) + return err + } + + req.Header.Add("PRIVATE-TOKEN", g.token) + req.Header.Add("Content-Type", "application/json") - // TODO: pull file list from repo and marshal into []globalTarget + resp, err := g.client.Do(req) + if err != nil { + log.Error("Failed to push registration file", "error", err) + return err + } - return nil, nil + if resp.StatusCode != http.StatusAccepted { + log.Error("Failed to push registration file", "status", resp.Status) + return fmt.Errorf("request failed, status is %s", resp.Status) + } + + return nil } -// RegisterSelf commits the current instance to the configured gitlab repository +// PostFile commits the current instance to the configured gitlab repository // as a global target for other sparrow instances to discover -func (g *gitlab) RegisterSelf(ctx context.Context) error { - log := logger.FromContext(ctx).With("name", "RegisterSelf") +func (g *gitlab) PostFile(ctx context.Context, body GitlabFile) error { + log := logger.FromContext(ctx).With("name", "AddRegistration") log.Debug("Registering sparrow instance to gitlab") - // TODO: update & commit self as target to gitlab + // chose method based on whether the registration has already happened + n := url.PathEscape(body.Content.Url) + b, err := body.Bytes() + if err != nil { + log.Error("Failed to create request", "error", err) + return err + } + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, + fmt.Sprintf("%s/api/v4/projects/%d/repository/files/%s", g.baseUrl, g.projectID, n), + bytes.NewBuffer(b), + ) + if err != nil { + log.Error("Failed to create request", "error", err) + return err + } + + req.Header.Add("PRIVATE-TOKEN", g.token) + req.Header.Add("Content-Type", "application/json") + + resp, err := g.client.Do(req) + if err != nil { + log.Error("Failed to push registration file", "error", err) + return err + } + + if resp.StatusCode != http.StatusCreated { + log.Error("Failed to push registration file", "status", resp.Status) + return fmt.Errorf("request failed, status is %s", resp.Status) + } return nil } diff --git a/pkg/sparrow/targets/gitlab_test.go b/pkg/sparrow/targets/gitlab_test.go new file mode 100644 index 00000000..eebe7313 --- /dev/null +++ b/pkg/sparrow/targets/gitlab_test.go @@ -0,0 +1,367 @@ +package targets + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/jarcoal/httpmock" +) + +func Test_gitlab_fetchFileList(t *testing.T) { + type file struct { + Name string `json:"name"` + } + tests := []struct { + name string + want []string + wantErr bool + mockBody []file + mockCode int + }{ + { + name: "success - 0 targets", + want: nil, + wantErr: false, + mockCode: http.StatusOK, + mockBody: []file{}, + }, + { + name: "success - 1 target", + want: []string{ + "test", + }, + wantErr: false, + mockCode: http.StatusOK, + mockBody: []file{ + { + Name: "test", + }, + }, + }, + { + name: "success - 2 targets", + want: []string{ + "test", + "test2", + }, + wantErr: false, + mockCode: http.StatusOK, + mockBody: []file{ + { + Name: "test", + }, + { + Name: "test2", + }, + }, + }, + { + name: "failure - API error", + want: nil, + wantErr: true, + mockCode: http.StatusInternalServerError, + }, + } + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := httpmock.NewJsonResponder(tt.mockCode, tt.mockBody) + if err != nil { + t.Fatalf("error creating mock response: %v", err) + } + httpmock.RegisterResponder("GET", "http://test/api/v4/projects/1/repository/tree?ref=main", resp) + + g := &gitlab{ + baseUrl: "http://test", + projectID: 1, + token: "test", + client: http.DefaultClient, + } + got, err := g.fetchFileList(context.Background()) + if (err != nil) != tt.wantErr { + t.Errorf("FetchFiles() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FetchFiles() got = %v, want %v", got, tt.want) + } + }) + } +} + +// The filelist and url are the same, so we HTTP responders can +// be created without much hassle +func Test_gitlab_fetchFiles(t *testing.T) { + tests := []struct { + name string + want []globalTarget + fileList []string + wantErr bool + mockCode int + }{ + { + name: "success - 0 targets", + want: nil, + wantErr: false, + mockCode: http.StatusOK, + }, + { + name: "success - 1 target", + want: []globalTarget{ + { + Url: "test", + LastSeen: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + fileList: []string{ + "test", + }, + wantErr: false, + mockCode: http.StatusOK, + }, + { + name: "success - 2 targets", + want: []globalTarget{ + { + Url: "test", + LastSeen: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + Url: "test2", + LastSeen: time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC), + }, + }, + fileList: []string{ + "test", + "test2", + }, + wantErr: false, + mockCode: http.StatusOK, + }, + } + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + g := &gitlab{ + baseUrl: "http://test", + projectID: 1, + token: "test", + client: http.DefaultClient, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup mock responses + for i, target := range tt.want { + resp, err := httpmock.NewJsonResponder(tt.mockCode, target) + if err != nil { + t.Fatalf("error creating mock response: %v", err) + } + httpmock.RegisterResponder("GET", fmt.Sprintf("http://test/api/v4/projects/1/repository/files/%s/raw?ref=main", tt.fileList[i]), resp) + } + + got, err := g.fetchFiles(context.Background(), tt.fileList) + if (err != nil) != tt.wantErr { + t.Fatalf("FetchFiles() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("FetchFiles() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_gitlab_fetchFiles_error_cases(t *testing.T) { + type mockResponses struct { + response globalTarget + err bool + } + + tests := []struct { + name string + mockResponses []mockResponses + fileList []string + }{ + { + name: "failure - direct API error", + mockResponses: []mockResponses{ + { + err: true, + }, + }, + fileList: []string{ + "test", + }, + }, + { + name: "failure - API error after one successful request", + mockResponses: []mockResponses{ + { + response: globalTarget{ + Url: "test", + LastSeen: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + err: false, + }, + { + response: globalTarget{}, + err: true, + }, + }, + fileList: []string{ + "test", + "test2-will-fail", + }, + }, + } + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + g := &gitlab{ + baseUrl: "http://test", + projectID: 1, + token: "test", + client: http.DefaultClient, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i, target := range tt.mockResponses { + if target.err { + errResp := httpmock.NewStringResponder(http.StatusInternalServerError, "") + httpmock.RegisterResponder("GET", fmt.Sprintf("http://test/api/v4/projects/1/repository/files/%s/raw?ref=main", tt.fileList[i]), errResp) + continue + } + resp, err := httpmock.NewJsonResponder(http.StatusOK, target) + if err != nil { + t.Fatalf("error creating mock response: %v", err) + } + httpmock.RegisterResponder("GET", fmt.Sprintf("http://test/api/v4/projects/1/repository/files/%s/raw?ref=main", tt.fileList[i]), resp) + } + + _, err := g.fetchFiles(context.Background(), tt.fileList) + if err == nil { + t.Fatalf("Expected error but got none.") + } + }) + } +} + +func Test_gitlabTargetManager_refreshTargets(t *testing.T) { + now := time.Now() + tooOld := now.Add(-time.Hour * 2) + + tests := []struct { + name string + mockTargets []globalTarget + expectedHealthy []globalTarget + wantErr bool + }{ + { + name: "success with 0 targets", + mockTargets: []globalTarget{}, + expectedHealthy: []globalTarget{}, + }, + { + name: "success with 1 healthy target", + mockTargets: []globalTarget{ + { + Url: "test", + LastSeen: now, + }, + }, + expectedHealthy: []globalTarget{ + { + Url: "test", + LastSeen: now, + }, + }, + }, + { + name: "success with 1 unhealthy target", + mockTargets: []globalTarget{ + { + Url: "test", + LastSeen: tooOld, + }, + }, + }, + { + name: "success with 1 healthy and 1 unhealthy targets", + mockTargets: []globalTarget{ + { + Url: "test", + LastSeen: now, + }, + { + Url: "test2", + LastSeen: tooOld, + }, + }, + expectedHealthy: []globalTarget{ + { + Url: "test", + LastSeen: now, + }, + }, + }, + { + name: "failure getting targets", + mockTargets: nil, + expectedHealthy: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gtm := &gitlabTargetManager{ + targets: nil, + gitlab: newMockGitlab(tt.mockTargets, tt.wantErr), + name: "test", + unhealthyThreshold: time.Hour, + } + if err := gtm.refreshTargets(context.Background()); (err != nil) != tt.wantErr { + t.Fatalf("refreshTargets() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +type mockGitlab struct { + targets []globalTarget + err error +} + +func (m mockGitlab) PutFile(ctx context.Context, file GitlabFile) error { + panic("implement me") +} + +func (m mockGitlab) PostFile(ctx context.Context, f GitlabFile) error { + panic("implement me") +} + +func (m mockGitlab) FetchFiles(ctx context.Context) ([]globalTarget, error) { + return m.targets, m.err +} + +func (m mockGitlab) FetchFileList(ctx context.Context) ([]string, error) { + panic("implement me") +} + +func newMockGitlab(targets []globalTarget, err bool) Gitlab { + var e error + if err { + e = fmt.Errorf("error") + } + return &mockGitlab{ + targets: targets, + err: e, + } +} diff --git a/pkg/sparrow/targets/targets.go b/pkg/sparrow/targets/targets.go index 23e2d8f5..5f1eb2ad 100644 --- a/pkg/sparrow/targets/targets.go +++ b/pkg/sparrow/targets/targets.go @@ -7,14 +7,13 @@ import ( // globalTarget represents a globalTarget that can be checked type globalTarget struct { - url string - lastSeen time.Time + Url string `json:"url"` + LastSeen time.Time `json:"lastSeen"` } // TargetManager handles the management of globalTargets for // a Sparrow instance type TargetManager interface { Reconcile(ctx context.Context) - Register(ctx context.Context) GetTargets() []globalTarget }