diff --git a/pkg/sparrow/gitlab/test/mockclient.go b/pkg/sparrow/gitlab/test/mockclient.go index 12d83548..98790898 100644 --- a/pkg/sparrow/gitlab/test/mockclient.go +++ b/pkg/sparrow/gitlab/test/mockclient.go @@ -16,20 +16,20 @@ type MockClient struct { } func (m *MockClient) PutFile(ctx context.Context, _ gitlab.File) error { //nolint: gocritic // irrelevant - log := logger.FromContext(ctx).With("name", "MockPutFile") - log.Debug("MockPutFile called", "err", m.putFileErr) + log := logger.FromContext(ctx) + log.Info("MockPutFile called", "err", m.putFileErr) return m.putFileErr } func (m *MockClient) PostFile(ctx context.Context, _ gitlab.File) error { //nolint: gocritic // irrelevant - log := logger.FromContext(ctx).With("name", "MockPostFile") - log.Debug("MockPostFile called", "err", m.postFileErr) + log := logger.FromContext(ctx) + log.Info("MockPostFile called", "err", m.postFileErr) return m.postFileErr } func (m *MockClient) FetchFiles(ctx context.Context) ([]checks.GlobalTarget, error) { - log := logger.FromContext(ctx).With("name", "MockFetchFiles") - log.Debug("MockFetchFiles called", "targets", len(m.targets), "err", m.fetchFilesErr) + log := logger.FromContext(ctx) + log.Info("MockFetchFiles called", "targets", len(m.targets), "err", m.fetchFilesErr) return m.targets, m.fetchFilesErr } diff --git a/pkg/sparrow/run.go b/pkg/sparrow/run.go index 8805828a..0303bd7c 100644 --- a/pkg/sparrow/run.go +++ b/pkg/sparrow/run.go @@ -39,6 +39,7 @@ const ( gitlabRegistrationProjectID = 1 globalTargetsCheckInterval = 5 * time.Minute registrationUnhealthyThreshold = 15 * time.Minute + registrationInterval = 5 * time.Minute ) type Sparrow struct { @@ -71,7 +72,13 @@ func New(cfg *config.Config) *Sparrow { cCfgChecks: make(chan map[string]any, 1), routingTree: api.NewRoutingTree(), router: chi.NewRouter(), - targets: targets.NewGitlabManager(gitlab.New("targetsRepo", "gitlabToken", gitlabRegistrationProjectID), globalTargetsCheckInterval, registrationUnhealthyThreshold), + targets: targets.NewGitlabManager( + gitlab.New("targetsRepo", "gitlabToken", gitlabRegistrationProjectID), + "DNS-Name", + globalTargetsCheckInterval, + registrationUnhealthyThreshold, + registrationInterval, + ), } sparrow.loader = config.NewLoader(cfg, sparrow.cCfgChecks) diff --git a/pkg/sparrow/targets/gitlab.go b/pkg/sparrow/targets/gitlab.go index e289a71f..6804a45e 100644 --- a/pkg/sparrow/targets/gitlab.go +++ b/pkg/sparrow/targets/gitlab.go @@ -3,6 +3,7 @@ package targets import ( "context" "fmt" + "sync" "time" "github.com/caas-team/sparrow/pkg/checks" @@ -16,6 +17,8 @@ var _ TargetManager = &gitlabTargetManager{} // gitlabTargetManager implements TargetManager type gitlabTargetManager struct { targets []checks.GlobalTarget + mu sync.RWMutex + done chan struct{} gitlab gitlab.Gitlab // the DNS name used for self-registration name string @@ -31,49 +34,18 @@ type gitlabTargetManager struct { } // NewGitlabManager creates a new gitlabTargetManager -func NewGitlabManager(g gitlab.Gitlab, checkInterval, unhealthyThreshold time.Duration) TargetManager { +func NewGitlabManager(g gitlab.Gitlab, name string, checkInterval, unhealthyThreshold, regInterval time.Duration) *gitlabTargetManager { return &gitlabTargetManager{ - gitlab: g, - checkInterval: checkInterval, - unhealthyThreshold: unhealthyThreshold, + gitlab: g, + name: name, + checkInterval: checkInterval, + registrationInterval: regInterval, + unhealthyThreshold: unhealthyThreshold, + mu: sync.RWMutex{}, + done: make(chan struct{}, 1), } } -// 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 := gitlab.File{ - Branch: "main", - AuthorEmail: fmt.Sprintf("%s@sparrow", t.name), - AuthorName: t.name, - Content: checks.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 - } - - f.CommitMessage = "Initial registration" - err := t.gitlab.PostFile(ctx, f) - if err != nil { - log.Error("Failed to register global gitlabTargetManager", "error", err) - return err - } - - log.Debug("Successfully registered") - t.registered = true - return nil -} - // Reconcile reconciles the targets of the gitlabTargetManager. // The global targets are parsed from a gitlab repository. // @@ -83,43 +55,109 @@ func (t *gitlabTargetManager) Reconcile(ctx context.Context) { log := logger.FromContext(ctx).With("name", "ReconcileGlobalTargets") log.Debug("Starting global gitlabTargetManager reconciler") + checkTimer := time.NewTimer(t.checkInterval) + registrationTimer := time.NewTimer(t.registrationInterval) + + defer checkTimer.Stop() + defer registrationTimer.Stop() + for { select { case <-ctx.Done(): if err := ctx.Err(); err != nil { log.Error("Context canceled", "error", err) - return + err = t.Shutdown(ctx) + if err != nil { + log.Error("Failed to shutdown gracefully", "error", err) + return + } } - // check if this blocks when context is canceled - case <-time.After(t.checkInterval): - log.Debug("Getting global gitlabTargetManager") + case <-t.done: + log.Info("Ending Reconcile routine.") + return + case <-checkTimer.C: 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") + checkTimer.Reset(t.checkInterval) + case <-registrationTimer.C: err := t.updateRegistration(ctx) if err != nil { log.Error("Failed to register global gitlabTargetManager", "error", err) continue } + registrationTimer.Reset(t.registrationInterval) } } } // GetTargets returns the current targets of the gitlabTargetManager func (t *gitlabTargetManager) GetTargets() []checks.GlobalTarget { + t.mu.RLock() + defer t.mu.RUnlock() return t.targets } +// Shutdown shuts down the gitlabTargetManager and deletes the file containing +// the sparrow's registration from Gitlab +func (t *gitlabTargetManager) Shutdown(ctx context.Context) error { + log := logger.FromContext(ctx).With("name", "Shutdown") + log.Debug("Shutting down global gitlabTargetManager") + t.mu.Lock() + defer t.mu.Unlock() + t.registered = false + t.done <- struct{}{} + return nil +} + +// 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 := gitlab.File{ + Branch: "main", + AuthorEmail: fmt.Sprintf("%s@sparrow", t.name), + AuthorName: t.name, + Content: checks.GlobalTarget{Url: fmt.Sprintf("https://%s", t.name), LastSeen: time.Now().UTC()}, + } + + if t.Registered() { + t.mu.Lock() + defer t.mu.Unlock() + 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 + } + + t.mu.Lock() + defer t.mu.Unlock() + f.CommitMessage = "Initial registration" + err := t.gitlab.PostFile(ctx, f) + if err != nil { + log.Error("Failed to register global gitlabTargetManager", "error", err) + return err + } + + log.Debug("Successfully registered") + t.registered = true + return nil +} + // 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") + t.mu.Lock() var healthyTargets []checks.GlobalTarget - + defer t.mu.Unlock() targets, err := t.gitlab.FetchFiles(ctx) if err != nil { log.Error("Failed to update global targets", "error", err) @@ -129,6 +167,7 @@ func (t *gitlabTargetManager) refreshTargets(ctx context.Context) error { // filter unhealthy targets - this may be removed in the future for _, target := range targets { if time.Now().Add(-t.unhealthyThreshold).After(target.LastSeen) { + log.Debug("Skipping unhealthy target", "target", target) continue } healthyTargets = append(healthyTargets, target) @@ -138,3 +177,9 @@ func (t *gitlabTargetManager) refreshTargets(ctx context.Context) error { log.Debug("Updated global targets", "targets", len(t.targets)) return nil } + +func (t *gitlabTargetManager) Registered() bool { + t.mu.RLock() + defer t.mu.RUnlock() + return t.registered +} diff --git a/pkg/sparrow/targets/gitlab_test.go b/pkg/sparrow/targets/gitlab_test.go index 7db745d8..7ab7bd55 100644 --- a/pkg/sparrow/targets/gitlab_test.go +++ b/pkg/sparrow/targets/gitlab_test.go @@ -2,6 +2,7 @@ package targets import ( "context" + "errors" "fmt" "testing" "time" @@ -212,3 +213,160 @@ func Test_gitlabTargetManager_updateRegistration(t *testing.T) { }) } } + +// Test_gitlabTargetManager_Reconcile_success tests that the Reconcile method +// will register the target if it is not registered yet and update the +// registration if it is already registered +func Test_gitlabTargetManager_Reconcile_success(t *testing.T) { + tests := []struct { + name string + registered bool + wantPostError bool + wantPutError bool + }{ + { + name: "success - first registration", + }, + { + name: "success - update registration", + registered: true, + }, + } + + glmock := gitlabmock.New( + []checks.GlobalTarget{ + { + Url: "test", + LastSeen: time.Now(), + }, + }, + ) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gtm := NewGitlabManager( + glmock, + "test", + time.Millisecond*100, + time.Hour*1, + time.Millisecond*150, + ) + ctx := context.Background() + go func() { + gtm.Reconcile(ctx) + }() + + time.Sleep(time.Millisecond * 300) + if gtm.GetTargets()[0].Url != "test" { + t.Fatalf("Reconcile() did not receive the correct target") + } + if !gtm.Registered() { + t.Fatalf("Reconcile() did not register") + } + + err := gtm.Shutdown(ctx) + if err != nil { + t.Fatalf("Reconcile() failed to shutdown") + } + }) + } +} + +// Test_gitlabTargetManager_Reconcile_failure tests that the Reconcile method +// will handle API failures gracefully +func Test_gitlabTargetManager_Reconcile_failure(t *testing.T) { + tests := []struct { + name string + registered bool + postErr error + putError error + }{ + { + name: "failure - failed to register", + postErr: errors.New("failed to register"), + }, + { + name: "failure - failed to update registration", + registered: true, + putError: errors.New("failed to update registration"), + }, + } + + glmock := gitlabmock.New( + []checks.GlobalTarget{ + { + Url: "test", + LastSeen: time.Now(), + }, + }, + ) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gtm := NewGitlabManager( + glmock, + "test", + time.Millisecond*100, + time.Hour*1, + time.Millisecond*150, + ) + glmock.SetPostFileErr(tt.postErr) + glmock.SetPutFileErr(tt.putError) + + ctx := context.Background() + go func() { + gtm.Reconcile(ctx) + }() + + time.Sleep(time.Millisecond * 300) + + if tt.postErr != nil && gtm.Registered() { + t.Fatalf("Reconcile() should not have registered") + } + + if tt.putError != nil && !gtm.Registered() { + t.Fatalf("Reconcile() should still be registered") + } + + err := gtm.Shutdown(ctx) + if err != nil { + t.Fatalf("Reconcile() failed to shutdown") + } + }) + } +} + +// Test_gitlabTargetManager_Reconcile_Context_Canceled tests that the Reconcile +// method will shutdown gracefully when the context is canceled. +func Test_gitlabTargetManager_Reconcile_Context_Canceled(t *testing.T) { + glmock := gitlabmock.New( + []checks.GlobalTarget{ + { + Url: "test", + LastSeen: time.Now(), + }, + }, + ) + + gtm := NewGitlabManager( + glmock, + "test", + time.Millisecond*100, + time.Hour*1, + time.Millisecond*150, + ) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + gtm.Reconcile(ctx) + }() + + time.Sleep(time.Millisecond * 250) + cancel() + time.Sleep(time.Millisecond * 100) + + // instance shouldn't be registered anymore + if gtm.Registered() { + t.Fatalf("Reconcile() should not be registered") + } +} diff --git a/pkg/sparrow/targets/targetmanager.go b/pkg/sparrow/targets/targetmanager.go index d7508b63..77f37ec4 100644 --- a/pkg/sparrow/targets/targetmanager.go +++ b/pkg/sparrow/targets/targetmanager.go @@ -14,4 +14,7 @@ type TargetManager interface { Reconcile(ctx context.Context) // GetTargets returns the current global targets GetTargets() []checks.GlobalTarget + // Shutdown shuts down the target manager + // and unregisters the instance as a global target + Shutdown(ctx context.Context) error }