diff --git a/internal/install/command.go b/internal/install/command.go index 5202c4f9c..dc02d443a 100644 --- a/internal/install/command.go +++ b/internal/install/command.go @@ -47,6 +47,7 @@ var Command = &cobra.Command{ rf := newServiceRecipeFetcher(&nrClient.NerdGraph) pf := newRegexProcessFilterer(rf) ff := newRecipeFileFetcher() + er := newNerdStorageExecutionStatusReporter(&nrClient.NerdStorage) i := newRecipeInstaller(ic, newPSUtilDiscoverer(pf), @@ -55,6 +56,7 @@ var Command = &cobra.Command{ newGoTaskRecipeExecutor(), newPollingRecipeValidator(&nrClient.Nrdb), ff, + er, ) // Run the install. diff --git a/internal/install/execution_status.go b/internal/install/execution_status.go new file mode 100644 index 000000000..1e214359f --- /dev/null +++ b/internal/install/execution_status.go @@ -0,0 +1,89 @@ +package install + +import ( + "time" + + "github.com/google/uuid" +) + +type executionStatusRollup struct { + Complete bool `json:"complete"` + DocumentID string + EntityGuids []string `json:"entityGuids"` + Statuses []executionStatus `json:"recipes"` + Timestamp int64 `json:"timestamp"` +} + +type executionStatus struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Status executionStatusType `json:"status"` + Errors []executionStatusRecipeError `json:"errors"` +} + +type executionStatusType string + +var executionStatusTypes = struct { + AVAILABLE executionStatusType + FAILED executionStatusType + INSTALLED executionStatusType + SKIPPED executionStatusType +}{ + AVAILABLE: "AVAILABLE", + FAILED: "FAILED", + INSTALLED: "INSTALLED", + SKIPPED: "SKIPPED", +} + +type executionStatusRecipeError struct { + Message string `json:"message"` + Details string `json:"details"` +} + +func newExecutionStatusRollup() executionStatusRollup { + s := executionStatusRollup{ + DocumentID: uuid.New().String(), + Timestamp: getTimestamp(), + } + + return s +} + +func getTimestamp() int64 { + return time.Now().Unix() +} + +func (s *executionStatusRollup) withAvailableRecipes(recipes []recipe) { + for _, r := range recipes { + e := recipeStatusEvent{recipe: r} + s.withRecipeEvent(e, executionStatusTypes.AVAILABLE) + } +} + +func (s *executionStatusRollup) withRecipeEvent(e recipeStatusEvent, rs executionStatusType) { + found := s.getExecutionStatusRecipe(e.recipe) + + if found != nil { + found.Status = rs + } else { + e := &executionStatus{ + Name: e.recipe.Name, + DisplayName: e.recipe.DisplayName, + Status: rs, + } + s.Statuses = append(s.Statuses, *e) + } + + s.Timestamp = getTimestamp() +} + +func (s *executionStatusRollup) getExecutionStatusRecipe(r recipe) *executionStatus { + var found *executionStatus + for i, recipe := range s.Statuses { + if recipe.Name == r.Name { + found = &s.Statuses[i] + } + } + + return found +} diff --git a/internal/install/execution_status_reporter.go b/internal/install/execution_status_reporter.go new file mode 100644 index 000000000..0072029d0 --- /dev/null +++ b/internal/install/execution_status_reporter.go @@ -0,0 +1,13 @@ +package install + +type executionStatusReporter interface { + reportRecipeFailed(event recipeStatusEvent) error + reportRecipeInstalled(event recipeStatusEvent) error + reportRecipesAvailable(recipes []recipe) error +} + +type recipeStatusEvent struct { + recipe recipe + msg string + entityGUID string +} diff --git a/internal/install/execution_status_test.go b/internal/install/execution_status_test.go new file mode 100644 index 000000000..cb5af37ef --- /dev/null +++ b/internal/install/execution_status_test.go @@ -0,0 +1,67 @@ +//+ build unit +package install + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewExecutionStatus(t *testing.T) { + s := newExecutionStatusRollup() + require.NotEmpty(t, s.Timestamp) + require.NotEmpty(t, s.DocumentID) +} + +func TestExecutionStatusWithAvailableRecipes_Basic(t *testing.T) { + s := newExecutionStatusRollup() + r := []recipe{{ + Name: "testRecipe1", + }, { + Name: "testRecipe2", + }} + + s.withAvailableRecipes(r) + + require.NotEmpty(t, s.Statuses) + require.Equal(t, len(r), len(s.Statuses)) + for _, recipeStatus := range s.Statuses { + require.Equal(t, executionStatusTypes.AVAILABLE, recipeStatus.Status) + } +} + +func TestExecutionStatusWithRecipeEvent_Basic(t *testing.T) { + s := newExecutionStatusRollup() + r := recipe{Name: "testRecipe"} + e := recipeStatusEvent{recipe: r} + + s.Timestamp = 0 + s.withRecipeEvent(e, executionStatusTypes.INSTALLED) + + require.NotEmpty(t, s.Statuses) + require.Equal(t, 1, len(s.Statuses)) + require.Equal(t, executionStatusTypes.INSTALLED, s.Statuses[0].Status) + require.NotEmpty(t, s.Timestamp) +} + +func TestExecutionStatusWithRecipeEvent_RecipeExists(t *testing.T) { + s := newExecutionStatusRollup() + r := recipe{Name: "testRecipe"} + e := recipeStatusEvent{recipe: r} + + s.Timestamp = 0 + s.withRecipeEvent(e, executionStatusTypes.AVAILABLE) + + require.NotEmpty(t, s.Statuses) + require.Equal(t, 1, len(s.Statuses)) + require.Equal(t, executionStatusTypes.AVAILABLE, s.Statuses[0].Status) + require.NotEmpty(t, s.Timestamp) + + s.Timestamp = 0 + s.withRecipeEvent(e, executionStatusTypes.INSTALLED) + + require.NotEmpty(t, s.Statuses) + require.Equal(t, 1, len(s.Statuses)) + require.Equal(t, executionStatusTypes.INSTALLED, s.Statuses[0].Status) + require.NotEmpty(t, s.Timestamp) +} diff --git a/internal/install/mock_execution_status_reporter.go b/internal/install/mock_execution_status_reporter.go new file mode 100644 index 000000000..d73c0530c --- /dev/null +++ b/internal/install/mock_execution_status_reporter.go @@ -0,0 +1,29 @@ +package install + +type mockExecutionStatusReporter struct { + reportRecipesAvailableErr error + reportRecipeFailedErr error + reportRecipeInstalledErr error + reportRecipesAvailableCallCount int + reportRecipeFailedCallCount int + reportRecipeInstalledCallCount int +} + +func newMockExecutionStatusReporter() *mockExecutionStatusReporter { + return &mockExecutionStatusReporter{} +} + +func (r *mockExecutionStatusReporter) reportRecipeFailed(event recipeStatusEvent) error { + r.reportRecipeFailedCallCount++ + return r.reportRecipeFailedErr +} + +func (r *mockExecutionStatusReporter) reportRecipeInstalled(event recipeStatusEvent) error { + r.reportRecipeInstalledCallCount++ + return r.reportRecipeInstalledErr +} + +func (r *mockExecutionStatusReporter) reportRecipesAvailable(recipes []recipe) error { + r.reportRecipesAvailableCallCount++ + return r.reportRecipesAvailableErr +} diff --git a/internal/install/mock_nerdstorage_client.go b/internal/install/mock_nerdstorage_client.go new file mode 100644 index 000000000..36cdb243e --- /dev/null +++ b/internal/install/mock_nerdstorage_client.go @@ -0,0 +1,31 @@ +package install + +import "github.com/newrelic/newrelic-client-go/pkg/nerdstorage" + +// nolint:unused,deadcode +type mockNerdstorageClient struct { + respBody interface{} + userScopeError error + entityScopeError error + writeDocumentWithUserScopeCallCount int + writeDocumentWithEntityScopeCallCount int +} + +// nolint:unused,deadcode +func newMockNerdstorageClient() *mockNerdstorageClient { + return &mockNerdstorageClient{ + respBody: struct{}{}, + userScopeError: nil, + entityScopeError: nil, + } +} + +func (c *mockNerdstorageClient) WriteDocumentWithUserScope(nerdstorage.WriteDocumentInput) (interface{}, error) { + c.writeDocumentWithUserScopeCallCount++ + return c.respBody, c.userScopeError +} + +func (c *mockNerdstorageClient) WriteDocumentWithEntityScope(string, nerdstorage.WriteDocumentInput) (interface{}, error) { + c.writeDocumentWithEntityScopeCallCount++ + return c.respBody, c.entityScopeError +} diff --git a/internal/install/mock_recipe_fetcher.go b/internal/install/mock_recipe_fetcher.go index 16ee28dab..48ba8982a 100644 --- a/internal/install/mock_recipe_fetcher.go +++ b/internal/install/mock_recipe_fetcher.go @@ -1,42 +1,58 @@ package install -import "context" +import ( + "context" +) type mockRecipeFetcher struct { - fetchRecipeFunc func(*discoveryManifest, string) (*recipe, error) - fetchRecipesFunc func() ([]recipe, error) - fetchRecommendationsFunc func(*discoveryManifest) ([]recipe, error) + fetchRecipeErr error + fetchRecipesErr error + fetchRecommendationsErr error + fetchRecipeCallCount int + fetchRecipesCallCount int + fetchRecommendationsCallCount int + fetchRecipeVals []recipe + fetchRecipeVal *recipe + fetchRecipesVal []recipe + fetchRecommendationsVal []recipe } func newMockRecipeFetcher() *mockRecipeFetcher { f := mockRecipeFetcher{} - f.fetchRecipeFunc = defaultFetchRecipeFunc - f.fetchRecipesFunc = defaultFetchRecipesFunc - f.fetchRecommendationsFunc = defaultFetchRecommendationsFunc - + f.fetchRecipesVal = []recipe{} + f.fetchRecommendationsVal = []recipe{} return &f } func (f *mockRecipeFetcher) fetchRecipe(ctx context.Context, manifest *discoveryManifest, friendlyName string) (*recipe, error) { - return f.fetchRecipeFunc(manifest, friendlyName) + f.fetchRecipeCallCount++ + + if len(f.fetchRecipeVals) > 0 { + i := minOf(f.fetchRecipeCallCount, len(f.fetchRecipeVals)) - 1 + return &f.fetchRecipeVals[i], f.fetchRecipesErr + } + + return f.fetchRecipeVal, f.fetchRecipeErr } func (f *mockRecipeFetcher) fetchRecipes(ctx context.Context) ([]recipe, error) { - return f.fetchRecipesFunc() + f.fetchRecipesCallCount++ + return f.fetchRecipesVal, f.fetchRecipesErr } func (f *mockRecipeFetcher) fetchRecommendations(ctx context.Context, manifest *discoveryManifest) ([]recipe, error) { - return f.fetchRecommendationsFunc(manifest) + f.fetchRecommendationsCallCount++ + return f.fetchRecommendationsVal, f.fetchRecommendationsErr } -func defaultFetchRecipeFunc(manifest *discoveryManifest, friendlyName string) (*recipe, error) { - return &recipe{}, nil -} +func minOf(vars ...int) int { + min := vars[0] -func defaultFetchRecommendationsFunc(manifest *discoveryManifest) ([]recipe, error) { - return []recipe{}, nil -} + for _, i := range vars { + if min > i { + min = i + } + } -func defaultFetchRecipesFunc() ([]recipe, error) { - return []recipe{}, nil + return min } diff --git a/internal/install/mock_recipe_validator.go b/internal/install/mock_recipe_validator.go index 481b80e70..49360b2aa 100644 --- a/internal/install/mock_recipe_validator.go +++ b/internal/install/mock_recipe_validator.go @@ -3,15 +3,17 @@ package install import "context" type mockRecipeValidator struct { - result func(discoveryManifest, recipe) (bool, error) + validateErr error + validateCallCount int + validateVal bool + validateEntityGUIDVal string } func newMockRecipeValidator() *mockRecipeValidator { - return &mockRecipeValidator{ - result: func(discoveryManifest, recipe) (bool, error) { return false, nil }, - } + return &mockRecipeValidator{} } -func (m *mockRecipeValidator) validate(ctx context.Context, dm discoveryManifest, r recipe) (bool, error) { - return m.result(dm, r) +func (m *mockRecipeValidator) validate(ctx context.Context, dm discoveryManifest, r recipe) (bool, string, error) { + m.validateCallCount++ + return m.validateVal, m.validateEntityGUIDVal, m.validateErr } diff --git a/internal/install/nerdstorage_client.go b/internal/install/nerdstorage_client.go new file mode 100644 index 000000000..1f3f98c78 --- /dev/null +++ b/internal/install/nerdstorage_client.go @@ -0,0 +1,10 @@ +package install + +import ( + "github.com/newrelic/newrelic-client-go/pkg/nerdstorage" +) + +type nerdstorageClient interface { + WriteDocumentWithUserScope(nerdstorage.WriteDocumentInput) (interface{}, error) + WriteDocumentWithEntityScope(string, nerdstorage.WriteDocumentInput) (interface{}, error) +} diff --git a/internal/install/nerdstorage_execution_status_reporter.go b/internal/install/nerdstorage_execution_status_reporter.go new file mode 100644 index 000000000..e38082ac5 --- /dev/null +++ b/internal/install/nerdstorage_execution_status_reporter.go @@ -0,0 +1,80 @@ +package install + +import ( + log "github.com/sirupsen/logrus" + + "github.com/newrelic/newrelic-client-go/pkg/nerdstorage" +) + +const ( + packageID = "badfa35a-827d-428d-8f5b-33b836b0e2dd" + collectionID = "openInstallLibrary" +) + +type nerdstorageExecutionStatusReporter struct { + client nerdstorageClient + executionStatus executionStatusRollup +} + +func newNerdStorageExecutionStatusReporter(client nerdstorageClient) *nerdstorageExecutionStatusReporter { + r := nerdstorageExecutionStatusReporter{ + client: client, + executionStatus: newExecutionStatusRollup(), + } + + return &r +} + +func (r nerdstorageExecutionStatusReporter) reportRecipesAvailable(recipes []recipe) error { + r.executionStatus.withAvailableRecipes(recipes) + if err := r.writeStatus(""); err != nil { + return err + } + + return nil +} + +func (r nerdstorageExecutionStatusReporter) writeStatus(entityGUID string) error { + i := r.buildExecutionStatusDocument() + _, err := r.client.WriteDocumentWithUserScope(i) + if err != nil { + return err + } + + if entityGUID != "" { + log.Debug("No entity GUID available, skipping entity-scoped status update.") + _, err := r.client.WriteDocumentWithEntityScope(entityGUID, i) + if err != nil { + return err + } + } + + return nil +} + +func (r nerdstorageExecutionStatusReporter) reportRecipeFailed(e recipeStatusEvent) error { + r.executionStatus.withRecipeEvent(e, executionStatusTypes.FAILED) + if err := r.writeStatus(e.entityGUID); err != nil { + return err + } + + return nil +} + +func (r nerdstorageExecutionStatusReporter) reportRecipeInstalled(e recipeStatusEvent) error { + r.executionStatus.withRecipeEvent(e, executionStatusTypes.INSTALLED) + if err := r.writeStatus(e.entityGUID); err != nil { + return err + } + + return nil +} + +func (r nerdstorageExecutionStatusReporter) buildExecutionStatusDocument() nerdstorage.WriteDocumentInput { + return nerdstorage.WriteDocumentInput{ + PackageID: packageID, + Collection: collectionID, + DocumentID: r.executionStatus.DocumentID, + Document: r.executionStatus, + } +} diff --git a/internal/install/nerdstorage_execution_status_reporter_integration_test.go b/internal/install/nerdstorage_execution_status_reporter_integration_test.go new file mode 100644 index 000000000..d2ab42e53 --- /dev/null +++ b/internal/install/nerdstorage_execution_status_reporter_integration_test.go @@ -0,0 +1,161 @@ +// +build integration + +package install + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/newrelic/newrelic-client-go/newrelic" + "github.com/newrelic/newrelic-client-go/pkg/config" + "github.com/newrelic/newrelic-client-go/pkg/nerdstorage" + "github.com/newrelic/newrelic-client-go/pkg/workloads" +) + +func TestReportRecipeSucceeded_Basic(t *testing.T) { + apiKey := os.Getenv("NEW_RELIC_API_KEY") + accountID := os.Getenv("NEW_RELIC_ACCOUNT_ID") + if apiKey == "" || accountID == "" { + t.Skipf("NEW_RELIC_API_KEY and NEW_RELIC_ACCOUNT_ID are required to run this test") + } + + cfg := config.Config{ + PersonalAPIKey: apiKey, + } + c, err := newrelic.New(newrelic.ConfigPersonalAPIKey(cfg.PersonalAPIKey)) + if err != nil { + t.Fatalf("error creating integration test client") + } + + a, err := strconv.Atoi(accountID) + if err != nil { + t.Fatalf("error parsing account ID") + } + + entityGUID := createEntity(t, a, c) + + r := newNerdStorageExecutionStatusReporter(&c.NerdStorage) + + defer deleteUserStatusCollection(t, c.NerdStorage) + defer deleteEntityStatusCollection(t, entityGUID, c.NerdStorage) + defer deleteEntity(t, entityGUID, c) + + rec := recipe{Name: "testName"} + evt := recipeStatusEvent{ + recipe: rec, + entityGUID: entityGUID, + } + + err = r.reportRecipeInstalled(evt) + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + s, err := getUserStatusCollection(t, c.NerdStorage) + require.NoError(t, err) + require.NotEmpty(t, s) + + s, err = getEntityStatusCollection(t, entityGUID, c.NerdStorage) + require.NoError(t, err) + require.NotEmpty(t, s) +} +func TestReportRecipeSucceeded_UserScopeOnly(t *testing.T) { + apiKey := os.Getenv("NEW_RELIC_API_KEY") + accountID := os.Getenv("NEW_RELIC_ACCOUNT_ID") + if apiKey == "" || accountID == "" { + t.Skipf("NEW_RELIC_API_KEY and NEW_RELIC_ACCOUNT_ID are required to run this test") + } + + cfg := config.Config{ + PersonalAPIKey: apiKey, + } + c, err := newrelic.New(newrelic.ConfigPersonalAPIKey(cfg.PersonalAPIKey)) + if err != nil { + t.Fatalf("error creating integration test client") + } + + a, err := strconv.Atoi(accountID) + if err != nil { + t.Fatalf("error parsing account ID") + } + + entityGUID := createEntity(t, a, c) + + r := newNerdStorageExecutionStatusReporter(&c.NerdStorage) + + defer deleteUserStatusCollection(t, c.NerdStorage) + defer deleteEntityStatusCollection(t, entityGUID, c.NerdStorage) + defer deleteEntity(t, entityGUID, c) + + rec := recipe{Name: "testName"} + evt := recipeStatusEvent{ + recipe: rec, + } + + err = r.reportRecipeInstalled(evt) + require.NoError(t, err) + + s, err := getUserStatusCollection(t, c.NerdStorage) + require.NoError(t, err) + require.NotEmpty(t, s) + + s, err = getEntityStatusCollection(t, entityGUID, c.NerdStorage) + require.NoError(t, err) + require.Empty(t, s) +} + +func getUserStatusCollection(t *testing.T, c nerdstorage.NerdStorage) ([]interface{}, error) { + getCollectionInput := nerdstorage.GetCollectionInput{ + PackageID: packageID, + Collection: collectionID, + } + + return c.GetCollectionWithUserScope(getCollectionInput) +} + +func getEntityStatusCollection(t *testing.T, guid string, c nerdstorage.NerdStorage) ([]interface{}, error) { + getCollectionInput := nerdstorage.GetCollectionInput{ + PackageID: packageID, + Collection: collectionID, + } + + return c.GetCollectionWithEntityScope(guid, getCollectionInput) +} + +func deleteUserStatusCollection(t *testing.T, c nerdstorage.NerdStorage) { + di := nerdstorage.DeleteCollectionInput{ + Collection: collectionID, + PackageID: packageID, + } + ok, err := c.DeleteCollectionWithUserScope(di) + require.NoError(t, err) + require.True(t, ok) +} + +func deleteEntityStatusCollection(t *testing.T, guid string, c nerdstorage.NerdStorage) { + di := nerdstorage.DeleteCollectionInput{ + Collection: collectionID, + PackageID: packageID, + } + _, err := c.DeleteCollectionWithEntityScope(guid, di) + require.NoError(t, err) +} + +func createEntity(t *testing.T, accountID int, c *newrelic.NewRelic) string { + i := workloads.CreateInput{ + Name: "testEntity", + } + e, err := c.Workloads.CreateWorkload(accountID, i) + require.NoError(t, err) + + return e.GUID +} + +func deleteEntity(t *testing.T, guid string, c *newrelic.NewRelic) { + _, err := c.Workloads.DeleteWorkload(guid) + require.NoError(t, err) +} diff --git a/internal/install/nerdstorage_execution_status_reporter_unit_test.go b/internal/install/nerdstorage_execution_status_reporter_unit_test.go new file mode 100644 index 000000000..3265fe477 --- /dev/null +++ b/internal/install/nerdstorage_execution_status_reporter_unit_test.go @@ -0,0 +1,140 @@ +// +build unit + +package install + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReportRecipesAvailable_Basic(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + recipes := []recipe{{}} + + err := r.reportRecipesAvailable(recipes) + require.NoError(t, err) +} + +func TestReportRecipesAvailable_UserScopeError(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + c.userScopeError = errors.New("error") + + recipes := []recipe{{}} + + err := r.reportRecipesAvailable(recipes) + require.Error(t, err) +} + +func TestReportRecipeInstalled_Basic(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeInstalled(e) + require.NoError(t, err) + require.Equal(t, 1, c.writeDocumentWithUserScopeCallCount) + require.Equal(t, 1, c.writeDocumentWithEntityScopeCallCount) +} + +func TestReportRecipeInstalled_UserScopeOnly(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + e := recipeStatusEvent{} + + err := r.reportRecipeInstalled(e) + require.NoError(t, err) + require.Equal(t, 1, c.writeDocumentWithUserScopeCallCount) + require.Equal(t, 0, c.writeDocumentWithEntityScopeCallCount) +} + +func TestReportRecipeInstalled_UserScopeError(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + c.userScopeError = errors.New("error") + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeInstalled(e) + require.Error(t, err) +} + +func TestReportRecipeInstalled_EntityScopeError(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + c.entityScopeError = errors.New("error") + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeInstalled(e) + require.Error(t, err) +} + +func TestReportRecipeFailed_Basic(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeFailed(e) + require.NoError(t, err) + require.Equal(t, 1, c.writeDocumentWithUserScopeCallCount) + require.Equal(t, 1, c.writeDocumentWithEntityScopeCallCount) +} + +func TestReportRecipeFailed_UserScopeOnly(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + e := recipeStatusEvent{} + + err := r.reportRecipeFailed(e) + require.NoError(t, err) + require.Equal(t, 1, c.writeDocumentWithUserScopeCallCount) + require.Equal(t, 0, c.writeDocumentWithEntityScopeCallCount) +} + +func TestReportRecipeFailed_UserScopeError(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + c.userScopeError = errors.New("error") + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeFailed(e) + require.Error(t, err) +} + +func TestReportRecipeFailed_EntityScopeError(t *testing.T) { + c := newMockNerdstorageClient() + r := newNerdStorageExecutionStatusReporter(c) + + c.entityScopeError = errors.New("error") + + e := recipeStatusEvent{ + entityGUID: "testGuid", + } + + err := r.reportRecipeFailed(e) + require.Error(t, err) +} diff --git a/internal/install/polling_recipe_validator.go b/internal/install/polling_recipe_validator.go index e7446de71..7a2310aef 100644 --- a/internal/install/polling_recipe_validator.go +++ b/internal/install/polling_recipe_validator.go @@ -38,7 +38,11 @@ func newPollingRecipeValidator(c nrdbClient) *pollingRecipeValidator { return &v } -func (m *pollingRecipeValidator) validate(ctx context.Context, dm discoveryManifest, r recipe) (bool, error) { +func (m *pollingRecipeValidator) validate(ctx context.Context, dm discoveryManifest, r recipe) (bool, string, error) { + return m.waitForData(ctx, dm, r) +} + +func (m *pollingRecipeValidator) waitForData(ctx context.Context, dm discoveryManifest, r recipe) (bool, string, error) { count := 0 ticker := time.NewTicker(m.interval) defer ticker.Stop() @@ -51,19 +55,19 @@ func (m *pollingRecipeValidator) validate(ctx context.Context, dm discoveryManif for { if count == m.maxAttempts { - return false, nil + return false, "", nil } log.Debugf("Validation attempt #%d...", count+1) - ok, err := m.tryValidate(ctx, dm, r) + ok, entityGUID, err := m.tryValidate(ctx, dm, r) if err != nil { - return false, err + return false, "", err } count++ if ok { - return true, nil + return true, entityGUID, nil } select { @@ -71,34 +75,40 @@ func (m *pollingRecipeValidator) validate(ctx context.Context, dm discoveryManif continue case <-ctx.Done(): - return false, nil + return false, "", nil } } } -func (m *pollingRecipeValidator) tryValidate(ctx context.Context, dm discoveryManifest, r recipe) (bool, error) { +func (m *pollingRecipeValidator) tryValidate(ctx context.Context, dm discoveryManifest, r recipe) (bool, string, error) { query, err := substituteHostname(dm, r) if err != nil { - return false, err + return false, "", err } results, err := m.executeQuery(ctx, query) if err != nil { - return false, err + return false, "", err } if len(results) == 0 { - return false, nil + return false, "", nil } // The query is assumed to use a count aggregate function count := results[0]["count"].(float64) if count > 0 { - return true, nil + // Try and parse an entity GUID from the results + // The query is assumed to optionally use a facet over entityGuid + if entityGUID, ok := results[0]["entityGuid"]; ok { + return true, entityGUID.(string), nil + } + + return true, "", nil } - return false, nil + return false, "", nil } func substituteHostname(dm discoveryManifest, r recipe) (string, error) { diff --git a/internal/install/polling_recipe_validator_test.go b/internal/install/polling_recipe_validator_test.go index 801021bc6..47e5783db 100644 --- a/internal/install/polling_recipe_validator_test.go +++ b/internal/install/polling_recipe_validator_test.go @@ -37,7 +37,7 @@ func TestValidate(t *testing.T) { r := recipe{} m := discoveryManifest{} - ok, err := v.validate(getTestContext(), m, r) + ok, _, err := v.validate(getTestContext(), m, r) require.NoError(t, err) require.True(t, ok) @@ -55,7 +55,7 @@ func TestValidate_PassAfterNAttempts(t *testing.T) { r := recipe{} m := discoveryManifest{} - ok, err := v.validate(getTestContext(), m, r) + ok, _, err := v.validate(getTestContext(), m, r) require.NoError(t, err) require.True(t, ok) @@ -72,7 +72,7 @@ func TestValidate_FailAfterNAttempts(t *testing.T) { r := recipe{} m := discoveryManifest{} - ok, err := v.validate(getTestContext(), m, r) + ok, _, err := v.validate(getTestContext(), m, r) require.NoError(t, err) require.False(t, ok) @@ -92,7 +92,7 @@ func TestValidate_FailAfterMaxAttempts(t *testing.T) { r := recipe{} m := discoveryManifest{} - ok, err := v.validate(getTestContext(), m, r) + ok, _, err := v.validate(getTestContext(), m, r) require.NoError(t, err) require.False(t, ok) @@ -113,7 +113,7 @@ func TestValidate_FailIfContextDone(t *testing.T) { ctx, cancel := context.WithCancel(getTestContext()) cancel() - ok, err := v.validate(ctx, m, r) + ok, _, err := v.validate(ctx, m, r) require.NoError(t, err) require.False(t, ok) @@ -130,7 +130,7 @@ func TestValidate_QueryError(t *testing.T) { r := recipe{} m := discoveryManifest{} - ok, err := v.validate(getTestContext(), m, r) + ok, _, err := v.validate(getTestContext(), m, r) require.False(t, ok) require.EqualError(t, err, "test error") diff --git a/internal/install/psutil_discoverer_integration_test.go b/internal/install/psutil_discoverer_integration_test.go index 194701abb..6f29b2983 100644 --- a/internal/install/psutil_discoverer_integration_test.go +++ b/internal/install/psutil_discoverer_integration_test.go @@ -18,14 +18,12 @@ func TestDiscovery(t *testing.T) { } mockRecipeFetcher := newMockRecipeFetcher() - mockRecipeFetcher.fetchRecipesFunc = func() ([]recipe, error) { - return []recipe{ - { - ID: "test", - Name: "java", - ProcessMatch: []string{"java"}, - }, - }, nil + mockRecipeFetcher.fetchRecipesVal = []recipe{ + { + ID: "test", + Name: "java", + ProcessMatch: []string{"java"}, + }, } pf := newRegexProcessFilterer(mockRecipeFetcher) diff --git a/internal/install/recipe.go b/internal/install/recipe.go index efe661549..b42be1975 100644 --- a/internal/install/recipe.go +++ b/internal/install/recipe.go @@ -5,9 +5,12 @@ import ( ) type recipe struct { - ID string `json:"id"` - File string `json:"file"` - Name string `json:"name"` + ID string `json:"id"` + File string `json:"file"` + Name string `json:"name"` + // TODO: sort out DisplayName vs Name + // nolint:govet + DisplayName string `json:"name"` Description string `json:"description"` Repository string `json:"repository"` Keywords []string `json:"keywords"` diff --git a/internal/install/recipe_installer.go b/internal/install/recipe_installer.go index 979b7c94d..b5e6f2cc5 100644 --- a/internal/install/recipe_installer.go +++ b/internal/install/recipe_installer.go @@ -1,6 +1,7 @@ package install import ( + "errors" "fmt" "net/url" @@ -18,6 +19,7 @@ type recipeInstaller struct { recipeExecutor recipeExecutor recipeValidator recipeValidator recipeFileFetcher recipeFileFetcher + statusReporter executionStatusReporter } func newRecipeInstaller( @@ -28,6 +30,7 @@ func newRecipeInstaller( e recipeExecutor, v recipeValidator, ff recipeFileFetcher, + er executionStatusReporter, ) *recipeInstaller { i := recipeInstaller{ discoverer: d, @@ -36,6 +39,7 @@ func newRecipeInstaller( recipeExecutor: e, recipeValidator: v, recipeFileFetcher: ff, + statusReporter: er, } i.recipePaths = ic.recipePaths @@ -63,12 +67,6 @@ func (i *recipeInstaller) install() { m = i.discoverFatal() } - // Install the Infrastructure Agent if necessary, exiting on failure. - if i.ShouldInstallInfraAgent() { - i.installInfraAgentFatal(m) - } - - // Install integrations if necessary, continuing on failure with warnings. var recipes []recipe if i.RecipePathsProvided() { // Load the recipes from the provided file names. @@ -84,6 +82,12 @@ func (i *recipeInstaller) install() { } else { // Ask the recipe service for recommendations. recipes = i.fetchRecommendationsFatal(m) + i.reportRecipesAvailable(recipes) + } + + // Install the Infrastructure Agent if requested, exiting on failure. + if i.ShouldInstallInfraAgent() { + i.installInfraAgentFatal(m) } // Run the logging recipe if requested, exiting on failure. @@ -91,7 +95,7 @@ func (i *recipeInstaller) install() { i.installLoggingFatal(m, recipes) } - // Execute and validate each of the remaining recipes. + // Install integrations if necessary, continuing on failure with warnings. if i.ShouldInstallIntegrations() { for _, r := range recipes { i.executeAndValidateWarn(m, &r) @@ -210,29 +214,56 @@ func (i *recipeInstaller) executeAndValidate(m *discoveryManifest, r *recipe) (b // Execute the recipe steps. log.Infof("Installing %s...\n", r.Name) if err := i.recipeExecutor.execute(utils.SignalCtx, *m, *r); err != nil { - return false, fmt.Errorf("encountered an error while executing %s: %s", r.Name, err) + msg := fmt.Sprintf("encountered an error while executing %s: %s", r.Name, err) + i.reportRecipeFailed(recipeStatusEvent{*r, msg, ""}) + return false, errors.New(msg) } log.Infof("Installing %s...success\n", r.Name) if r.ValidationNRQL != "" { log.Info("Listening for data...") - ok, err := i.recipeValidator.validate(utils.SignalCtx, *m, *r) + ok, entityGUID, err := i.recipeValidator.validate(utils.SignalCtx, *m, *r) if err != nil { - return false, fmt.Errorf("encountered an error while validating receipt of data for %s: %s", r.Name, err) + msg := fmt.Sprintf("encountered an error while validating receipt of data for %s: %s", r.Name, err) + i.reportRecipeFailed(recipeStatusEvent{*r, msg, ""}) + return false, errors.New(msg) } if !ok { log.Infoln("failed.") + msg := "could not validate recipe data" + i.reportRecipeFailed(recipeStatusEvent{*r, msg, entityGUID}) return false, nil } + + i.reportRecipeInstalled(recipeStatusEvent{*r, "", entityGUID}) } else { - log.Warnf("unable to validate using empty recipe ValidationNRQL") + log.Debugf("Skipping validation due to missing validation query.") } log.Infoln("success.") + return true, nil } +func (i *recipeInstaller) reportRecipesAvailable(recipes []recipe) { + if err := i.statusReporter.reportRecipesAvailable(recipes); err != nil { + log.Errorf("Could not report recipe execution status: %s", err) + } +} + +func (i *recipeInstaller) reportRecipeInstalled(e recipeStatusEvent) { + if err := i.statusReporter.reportRecipeInstalled(e); err != nil { + log.Errorf("Error writing recipe status for recipe %s: %s", e.recipe.Name, err) + } +} + +func (i *recipeInstaller) reportRecipeFailed(e recipeStatusEvent) { + if err := i.statusReporter.reportRecipeFailed(e); err != nil { + log.Errorf("Error writing recipe status for recipe %s: %s", e.recipe.Name, err) + } +} + func (i *recipeInstaller) executeAndValidateFatal(m *discoveryManifest, r *recipe) { ok, err := i.executeAndValidate(m, r) if err != nil { diff --git a/internal/install/recipe_installer_test.go b/internal/install/recipe_installer_test.go index 6478a2465..5370c0594 100644 --- a/internal/install/recipe_installer_test.go +++ b/internal/install/recipe_installer_test.go @@ -16,6 +16,14 @@ var ( testRecipeFile = &recipeFile{ Name: testRecipeName, } + + d = newMockDiscoverer() + l = newMockFileFilterer() + f = newMockRecipeFetcher() + e = newMockRecipeExecutor() + v = newMockRecipeValidator() + ff = newMockRecipeFileFetcher() + sr = newMockExecutionStatusReporter() ) func TestInstall(t *testing.T) { @@ -32,23 +40,16 @@ func TestNewRecipeInstaller_InstallContextFields(t *testing.T) { skipLoggingInstall: true, } - d := newMockDiscoverer() - l := newMockFileFilterer() - f := newMockRecipeFetcher() - e := newMockRecipeExecutor() - v := newMockRecipeValidator() - ff := newMockRecipeFileFetcher() - - i := newRecipeInstaller(ic, d, l, f, e, v, ff) + i := newRecipeInstaller(ic, d, l, f, e, v, ff, sr) require.True(t, reflect.DeepEqual(ic, i.installContext)) } func TestShouldGetRecipeFromURL(t *testing.T) { ic := installContext{} - ff := newMockRecipeFileFetcher() + ff = newMockRecipeFileFetcher() ff.fetchRecipeFileFunc = fetchRecipeFileFunc - i := newRecipeInstaller(ic, nil, nil, nil, nil, nil, ff) + i := newRecipeInstaller(ic, nil, nil, nil, nil, nil, ff, nil) recipe := i.recipeFromPathFatal("http://recipe/URL") require.NotNil(t, recipe) @@ -57,15 +58,78 @@ func TestShouldGetRecipeFromURL(t *testing.T) { func TestShouldGetRecipeFromFile(t *testing.T) { ic := installContext{} - ff := newMockRecipeFileFetcher() + ff = newMockRecipeFileFetcher() ff.loadRecipeFileFunc = loadRecipeFileFunc - i := newRecipeInstaller(ic, nil, nil, nil, nil, nil, ff) + i := newRecipeInstaller(ic, nil, nil, nil, nil, nil, ff, nil) recipe := i.recipeFromPathFatal("file.txt") require.NotNil(t, recipe) require.Equal(t, recipe.Name, testRecipeName) } +func TestInstall_Basic(t *testing.T) { + ic := installContext{} + f = newMockRecipeFetcher() + f.fetchRecipeVals = []recipe{ + {Name: infraAgentRecipeName}, + {Name: loggingRecipeName}, + } + i := newRecipeInstaller(ic, d, l, f, e, v, ff, sr) + i.install() +} + +func TestInstall_ReportRecipesAvailable(t *testing.T) { + ic := installContext{} + sr = newMockExecutionStatusReporter() + i := newRecipeInstaller(ic, d, l, f, e, v, ff, sr) + i.install() + require.Equal(t, 1, sr.reportRecipesAvailableCallCount) +} + +func TestInstall_ReportRecipeInstalled(t *testing.T) { + ic := installContext{} + sr = newMockExecutionStatusReporter() + f = newMockRecipeFetcher() + f.fetchRecommendationsVal = []recipe{{ + ValidationNRQL: "testNrql", + }} + f.fetchRecipeVals = []recipe{ + { + Name: infraAgentRecipeName, + ValidationNRQL: "testNrql", + }, + { + Name: loggingRecipeName, + ValidationNRQL: "testNrql", + }, + } + v = newMockRecipeValidator() + v.validateVal = true + + i := newRecipeInstaller(ic, d, l, f, e, v, ff, sr) + i.install() + require.Equal(t, 3, sr.reportRecipeInstalledCallCount) +} + +func TestInstall_ReportRecipeFailed(t *testing.T) { + ic := installContext{ + skipInfraInstall: true, + skipLoggingInstall: true, + } + sr = newMockExecutionStatusReporter() + f = newMockRecipeFetcher() + f.fetchRecommendationsVal = []recipe{{ + ValidationNRQL: "testNrql", + }} + + v = newMockRecipeValidator() + v.validateVal = false + + i := newRecipeInstaller(ic, d, l, f, e, v, ff, sr) + i.install() + require.Equal(t, 1, sr.reportRecipeFailedCallCount) +} + func fetchRecipeFileFunc(recipeURL *url.URL) (*recipeFile, error) { return testRecipeFile, nil } diff --git a/internal/install/recipe_validator.go b/internal/install/recipe_validator.go index 3365fa5b7..5f1754aec 100644 --- a/internal/install/recipe_validator.go +++ b/internal/install/recipe_validator.go @@ -3,5 +3,5 @@ package install import "context" type recipeValidator interface { - validate(context.Context, discoveryManifest, recipe) (bool, error) + validate(context.Context, discoveryManifest, recipe) (ok bool, entityGUID string, err error) } diff --git a/internal/install/regex_process_filterer_test.go b/internal/install/regex_process_filterer_test.go index 7d5a0e881..31f6c7cac 100644 --- a/internal/install/regex_process_filterer_test.go +++ b/internal/install/regex_process_filterer_test.go @@ -19,9 +19,7 @@ func TestFilter(t *testing.T) { } mockRecipeFetcher := newMockRecipeFetcher() - mockRecipeFetcher.fetchRecipesFunc = func() ([]recipe, error) { - return recipes, nil - } + mockRecipeFetcher.fetchRecipesVal = recipes processes := []genericProcess{ mockProcess{