From bab8c8274c87481aa22d882ece58e8e9557f2808 Mon Sep 17 00:00:00 2001 From: Raj Kamal Singh <1133322+raj-k-singh@users.noreply.github.com> Date: Thu, 16 Jan 2025 17:36:09 +0530 Subject: [PATCH] feat: aws integration UI facing api: services (#6803) * feat: cloud service integrations: get model and repo interface started * feat: cloud service integrations: flesh out more of cloud services model * feat: cloud integrations: reorganize things a little * feat: cloud integrations: get svc controller started * feat: cloud integrations: add stubs for EC2 and RDS postgres services * feat: cloud integrations: add validation for listing and getting available svcs and some cleanup * feat: cloud integrations: refactor helpers in existing integrations code for reuse * feat: cloud integrations: parsing of cloud service definitions * feat: cloud integrations: impl for getCloudProviderService * feat: cloud integrations: some reorganization * feat: cloud integrations: some more cleanup * feat: cloud integrations: add validation for listing available cloud provider services * feat: cloud integrations: API endpoint for listing available cloud provider services * feat: cloud integrations: add validation for getting details of a particular service * feat: cloud integrations: API endpoint for getting details of a service * feat: cloud integrations: add controller validation for configuring cloud services * feat: cloud integrations: get serviceConfigRepo started * feat: cloud integrations: service config in service list summaries when queried for cloud account id * feat: cloud integrations: only a supported service for a connected cloud account can be configured * feat: cloud integrations: add validation for configuring services via the API * feat: cloud integrations: API for configuring services * feat: cloud integrations: some cleanup * feat: cloud integrations: fix broken test --------- Co-authored-by: Srikanth Chekuri --- .../{repo.go => accountsRepo.go} | 8 +- .../cloudintegrations/availableServices.go | 217 ++++++++++++++++++ .../availableServices_test.go | 34 +++ .../app/cloudintegrations/controller.go | 155 ++++++++++++- .../app/cloudintegrations/controller_test.go | 125 +++++++++- .../app/cloudintegrations/model.go | 101 ++++++++ .../cloudintegrations/serviceConfigRepo.go | 198 ++++++++++++++++ .../serviceDefinitions/aws/ec2/icon.svg | 11 + .../aws/ec2/integration.json | 30 +++ .../serviceDefinitions/aws/ec2/overview.md | 3 + .../aws/rdsPostgres/icon.svg | 21 ++ .../aws/rdsPostgres/integration.json | 30 +++ .../aws/rdsPostgres/overview.md | 3 + pkg/query-service/app/http_handler.go | 83 +++++++ pkg/query-service/app/integrations/builtin.go | 14 +- .../signoz_cloud_integrations_test.go | 119 +++++++++- 16 files changed, 1124 insertions(+), 28 deletions(-) rename pkg/query-service/app/cloudintegrations/{repo.go => accountsRepo.go} (96%) create mode 100644 pkg/query-service/app/cloudintegrations/availableServices.go create mode 100644 pkg/query-service/app/cloudintegrations/availableServices_test.go create mode 100644 pkg/query-service/app/cloudintegrations/serviceConfigRepo.go create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/icon.svg create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/integration.json create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/overview.md create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/icon.svg create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/integration.json create mode 100644 pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/overview.md diff --git a/pkg/query-service/app/cloudintegrations/repo.go b/pkg/query-service/app/cloudintegrations/accountsRepo.go similarity index 96% rename from pkg/query-service/app/cloudintegrations/repo.go rename to pkg/query-service/app/cloudintegrations/accountsRepo.go index e5c4e4cc94..edb72209f7 100644 --- a/pkg/query-service/app/cloudintegrations/repo.go +++ b/pkg/query-service/app/cloudintegrations/accountsRepo.go @@ -37,8 +37,8 @@ type cloudProviderAccountsRepository interface { func newCloudProviderAccountsRepository(db *sqlx.DB) ( *cloudProviderAccountsSQLRepository, error, ) { - if err := InitSqliteDBIfNeeded(db); err != nil { - return nil, fmt.Errorf("could not init sqlite DB for cloudintegrations: %w", err) + if err := initAccountsSqliteDBIfNeeded(db); err != nil { + return nil, fmt.Errorf("could not init sqlite DB for cloudintegrations accounts: %w", err) } return &cloudProviderAccountsSQLRepository{ @@ -46,7 +46,7 @@ func newCloudProviderAccountsRepository(db *sqlx.DB) ( }, nil } -func InitSqliteDBIfNeeded(db *sqlx.DB) error { +func initAccountsSqliteDBIfNeeded(db *sqlx.DB) error { if db == nil { return fmt.Errorf("db is required") } @@ -66,7 +66,7 @@ func InitSqliteDBIfNeeded(db *sqlx.DB) error { _, err := db.Exec(createTablesStatements) if err != nil { return fmt.Errorf( - "could not ensure cloud provider integrations schema in sqlite DB: %w", err, + "could not ensure cloud provider accounts schema in sqlite DB: %w", err, ) } diff --git a/pkg/query-service/app/cloudintegrations/availableServices.go b/pkg/query-service/app/cloudintegrations/availableServices.go new file mode 100644 index 0000000000..474077e7e4 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/availableServices.go @@ -0,0 +1,217 @@ +package cloudintegrations + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io/fs" + "path" + "sort" + + koanfJson "github.com/knadh/koanf/parsers/json" + "go.signoz.io/signoz/pkg/query-service/app/integrations" + "go.signoz.io/signoz/pkg/query-service/model" + "golang.org/x/exp/maps" +) + +func listCloudProviderServices( + cloudProvider string, +) ([]CloudServiceDetails, *model.ApiError) { + cloudServices := availableServices[cloudProvider] + if cloudServices == nil { + return nil, model.NotFoundError(fmt.Errorf( + "unsupported cloud provider: %s", cloudProvider, + )) + } + + services := maps.Values(cloudServices) + sort.Slice(services, func(i, j int) bool { + return services[i].Id < services[j].Id + }) + + return services, nil +} + +func getCloudProviderService( + cloudProvider string, serviceId string, +) (*CloudServiceDetails, *model.ApiError) { + cloudServices := availableServices[cloudProvider] + if cloudServices == nil { + return nil, model.NotFoundError(fmt.Errorf( + "unsupported cloud provider: %s", cloudProvider, + )) + } + + svc, exists := cloudServices[serviceId] + if !exists { + return nil, model.NotFoundError(fmt.Errorf( + "%s service not found: %s", cloudProvider, serviceId, + )) + } + + return &svc, nil +} + +// End of API. Logic for reading service definition files follows + +// Service details read from ./serviceDefinitions +// { "providerName": { "service_id": {...}} } +var availableServices map[string]map[string]CloudServiceDetails + +func init() { + err := readAllServiceDefinitions() + if err != nil { + panic(fmt.Errorf( + "couldn't read cloud service definitions: %w", err, + )) + } +} + +//go:embed serviceDefinitions/* +var serviceDefinitionFiles embed.FS + +func readAllServiceDefinitions() error { + availableServices = map[string]map[string]CloudServiceDetails{} + + rootDirName := "serviceDefinitions" + + cloudProviderDirs, err := fs.ReadDir(serviceDefinitionFiles, rootDirName) + if err != nil { + return fmt.Errorf("couldn't read dirs in %s: %w", rootDirName, err) + } + + for _, d := range cloudProviderDirs { + if !d.IsDir() { + continue + } + + cloudProviderDirPath := path.Join(rootDirName, d.Name()) + cloudServices, err := readServiceDefinitionsFromDir(cloudProviderDirPath) + if err != nil { + return fmt.Errorf("couldn't read %s service definitions", d.Name()) + } + + if len(cloudServices) < 1 { + return fmt.Errorf("no %s services could be read", d.Name()) + } + + availableServices[d.Name()] = cloudServices + } + + return nil +} + +func readServiceDefinitionsFromDir(cloudProviderDirPath string) ( + map[string]CloudServiceDetails, error, +) { + svcDefDirs, err := fs.ReadDir(serviceDefinitionFiles, cloudProviderDirPath) + if err != nil { + return nil, fmt.Errorf("couldn't list integrations dirs: %w", err) + } + + svcDefs := map[string]CloudServiceDetails{} + + for _, d := range svcDefDirs { + if !d.IsDir() { + continue + } + + svcDirPath := path.Join(cloudProviderDirPath, d.Name()) + s, err := readServiceDefinition(svcDirPath) + if err != nil { + return nil, fmt.Errorf("couldn't read svc definition for %s: %w", d.Name(), err) + } + + _, exists := svcDefs[s.Id] + if exists { + return nil, fmt.Errorf( + "duplicate service definition for id %s at %s", s.Id, d.Name(), + ) + } + svcDefs[s.Id] = *s + } + + return svcDefs, nil +} + +func readServiceDefinition(dirpath string) (*CloudServiceDetails, error) { + integrationJsonPath := path.Join(dirpath, "integration.json") + + serializedSpec, err := serviceDefinitionFiles.ReadFile(integrationJsonPath) + if err != nil { + return nil, fmt.Errorf( + "couldn't find integration.json in %s: %w", + dirpath, err, + ) + } + + integrationSpec, err := koanfJson.Parser().Unmarshal(serializedSpec) + if err != nil { + return nil, fmt.Errorf( + "couldn't parse integration.json from %s: %w", + integrationJsonPath, err, + ) + } + + hydrated, err := integrations.HydrateFileUris( + integrationSpec, serviceDefinitionFiles, dirpath, + ) + if err != nil { + return nil, fmt.Errorf( + "couldn't hydrate files referenced in service definition %s: %w", + integrationJsonPath, err, + ) + } + + hydratedSpec := hydrated.(map[string]interface{}) + hydratedSpecJson, err := koanfJson.Parser().Marshal(hydratedSpec) + if err != nil { + return nil, fmt.Errorf( + "couldn't serialize hydrated integration spec back to JSON %s: %w", + integrationJsonPath, err, + ) + } + + var serviceDef CloudServiceDetails + decoder := json.NewDecoder(bytes.NewReader(hydratedSpecJson)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&serviceDef) + if err != nil { + return nil, fmt.Errorf( + "couldn't parse hydrated JSON spec read from %s: %w", + integrationJsonPath, err, + ) + } + + err = validateServiceDefinition(serviceDef) + if err != nil { + return nil, fmt.Errorf("invalid service definition %s: %w", serviceDef.Id, err) + } + + return &serviceDef, nil + +} + +func validateServiceDefinition(s CloudServiceDetails) error { + // Validate dashboard data + seenDashboardIds := map[string]interface{}{} + for _, dd := range s.Assets.Dashboards { + did, exists := dd["id"] + if !exists { + return fmt.Errorf("id is required. not specified in dashboard titled %v", dd["title"]) + } + dashboardId, ok := did.(string) + if !ok { + return fmt.Errorf("id must be string in dashboard titled %v", dd["title"]) + } + if _, seen := seenDashboardIds[dashboardId]; seen { + return fmt.Errorf("multiple dashboards found with id %s", dashboardId) + } + seenDashboardIds[dashboardId] = nil + } + + // potentially more to follow + + return nil +} diff --git a/pkg/query-service/app/cloudintegrations/availableServices_test.go b/pkg/query-service/app/cloudintegrations/availableServices_test.go new file mode 100644 index 0000000000..cdc222463a --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/availableServices_test.go @@ -0,0 +1,34 @@ +package cloudintegrations + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.signoz.io/signoz/pkg/query-service/model" +) + +func TestAvailableServices(t *testing.T) { + require := require.New(t) + + // should be able to list available services. + _, apiErr := listCloudProviderServices("bad-cloud-provider") + require.NotNil(apiErr) + require.Equal(model.ErrorNotFound, apiErr.Type()) + + awsSvcs, apiErr := listCloudProviderServices("aws") + require.Nil(apiErr) + require.Greater(len(awsSvcs), 0) + + // should be able to get details of a service + _, apiErr = getCloudProviderService( + "aws", "bad-service-id", + ) + require.NotNil(apiErr) + require.Equal(model.ErrorNotFound, apiErr.Type()) + + svc, apiErr := getCloudProviderService( + "aws", awsSvcs[0].Id, + ) + require.Nil(apiErr) + require.Equal(*svc, awsSvcs[0]) +} diff --git a/pkg/query-service/app/cloudintegrations/controller.go b/pkg/query-service/app/cloudintegrations/controller.go index 9cfe9fb3e1..23b8c9ac74 100644 --- a/pkg/query-service/app/cloudintegrations/controller.go +++ b/pkg/query-service/app/cloudintegrations/controller.go @@ -22,19 +22,26 @@ func validateCloudProviderName(name string) *model.ApiError { } type Controller struct { - repo cloudProviderAccountsRepository + accountsRepo cloudProviderAccountsRepository + serviceConfigRepo serviceConfigRepository } func NewController(db *sqlx.DB) ( *Controller, error, ) { - repo, err := newCloudProviderAccountsRepository(db) + accountsRepo, err := newCloudProviderAccountsRepository(db) if err != nil { return nil, fmt.Errorf("couldn't create cloud provider accounts repo: %w", err) } + serviceConfigRepo, err := newServiceConfigRepository(db) + if err != nil { + return nil, fmt.Errorf("couldn't create cloud provider service config repo: %w", err) + } + return &Controller{ - repo: repo, + accountsRepo: accountsRepo, + serviceConfigRepo: serviceConfigRepo, }, nil } @@ -58,7 +65,7 @@ func (c *Controller) ListConnectedAccounts( return nil, apiErr } - accountRecords, apiErr := c.repo.listConnected(ctx, cloudProvider) + accountRecords, apiErr := c.accountsRepo.listConnected(ctx, cloudProvider) if apiErr != nil { return nil, model.WrapApiError(apiErr, "couldn't list cloud accounts") } @@ -100,7 +107,7 @@ func (c *Controller) GenerateConnectionUrl( return nil, model.BadRequest(fmt.Errorf("unsupported cloud provider: %s", cloudProvider)) } - account, apiErr := c.repo.upsert( + account, apiErr := c.accountsRepo.upsert( ctx, cloudProvider, req.AccountId, &req.AccountConfig, nil, nil, nil, ) if apiErr != nil { @@ -133,7 +140,7 @@ func (c *Controller) GetAccountStatus( return nil, apiErr } - account, apiErr := c.repo.get(ctx, cloudProvider, accountId) + account, apiErr := c.accountsRepo.get(ctx, cloudProvider, accountId) if apiErr != nil { return nil, apiErr } @@ -164,7 +171,7 @@ func (c *Controller) CheckInAsAgent( return nil, apiErr } - existingAccount, apiErr := c.repo.get(ctx, cloudProvider, req.AccountId) + existingAccount, apiErr := c.accountsRepo.get(ctx, cloudProvider, req.AccountId) if existingAccount != nil && existingAccount.CloudAccountId != nil && *existingAccount.CloudAccountId != req.CloudAccountId { return nil, model.BadRequest(fmt.Errorf( "can't check in with new %s account id %s for account %s with existing %s id %s", @@ -172,7 +179,7 @@ func (c *Controller) CheckInAsAgent( )) } - existingAccount, apiErr = c.repo.getConnectedCloudAccount(ctx, cloudProvider, req.CloudAccountId) + existingAccount, apiErr = c.accountsRepo.getConnectedCloudAccount(ctx, cloudProvider, req.CloudAccountId) if existingAccount != nil && existingAccount.Id != req.AccountId { return nil, model.BadRequest(fmt.Errorf( "can't check in to %s account %s with id %s. already connected with id %s", @@ -185,7 +192,7 @@ func (c *Controller) CheckInAsAgent( Data: req.Data, } - account, apiErr := c.repo.upsert( + account, apiErr := c.accountsRepo.upsert( ctx, cloudProvider, &req.AccountId, nil, &req.CloudAccountId, &agentReport, nil, ) if apiErr != nil { @@ -211,7 +218,7 @@ func (c *Controller) UpdateAccountConfig( return nil, apiErr } - accountRecord, apiErr := c.repo.upsert( + accountRecord, apiErr := c.accountsRepo.upsert( ctx, cloudProvider, &accountId, &req.Config, nil, nil, nil, ) if apiErr != nil { @@ -230,13 +237,13 @@ func (c *Controller) DisconnectAccount( return nil, apiErr } - account, apiErr := c.repo.get(ctx, cloudProvider, accountId) + account, apiErr := c.accountsRepo.get(ctx, cloudProvider, accountId) if apiErr != nil { return nil, model.WrapApiError(apiErr, "couldn't disconnect account") } tsNow := time.Now() - account, apiErr = c.repo.upsert( + account, apiErr = c.accountsRepo.upsert( ctx, cloudProvider, &accountId, nil, nil, nil, &tsNow, ) if apiErr != nil { @@ -245,3 +252,127 @@ func (c *Controller) DisconnectAccount( return account, nil } + +type ListServicesResponse struct { + Services []CloudServiceSummary `json:"services"` +} + +func (c *Controller) ListServices( + ctx context.Context, + cloudProvider string, + cloudAccountId *string, +) (*ListServicesResponse, *model.ApiError) { + + if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil { + return nil, apiErr + } + + services, apiErr := listCloudProviderServices(cloudProvider) + if apiErr != nil { + return nil, model.WrapApiError(apiErr, "couldn't list cloud services") + } + + svcConfigs := map[string]*CloudServiceConfig{} + if cloudAccountId != nil { + svcConfigs, apiErr = c.serviceConfigRepo.getAllForAccount( + ctx, cloudProvider, *cloudAccountId, + ) + if apiErr != nil { + return nil, model.WrapApiError( + apiErr, "couldn't get service configs for cloud account", + ) + } + } + + summaries := []CloudServiceSummary{} + for _, s := range services { + summary := s.CloudServiceSummary + summary.Config = svcConfigs[summary.Id] + + summaries = append(summaries, summary) + } + + return &ListServicesResponse{ + Services: summaries, + }, nil +} + +func (c *Controller) GetServiceDetails( + ctx context.Context, + cloudProvider string, + serviceId string, + cloudAccountId *string, +) (*CloudServiceDetails, *model.ApiError) { + + if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil { + return nil, apiErr + } + + service, apiErr := getCloudProviderService(cloudProvider, serviceId) + if apiErr != nil { + return nil, apiErr + } + + if cloudAccountId != nil { + config, apiErr := c.serviceConfigRepo.get( + ctx, cloudProvider, *cloudAccountId, serviceId, + ) + if apiErr != nil && apiErr.Type() != model.ErrorNotFound { + return nil, model.WrapApiError(apiErr, "couldn't fetch service config") + } + + if config != nil { + service.Config = config + } + } + + return service, nil +} + +type UpdateServiceConfigRequest struct { + CloudAccountId string `json:"cloud_account_id"` + Config CloudServiceConfig `json:"config"` +} + +type UpdateServiceConfigResponse struct { + Id string `json:"id"` + Config CloudServiceConfig `json:"config"` +} + +func (c *Controller) UpdateServiceConfig( + ctx context.Context, + cloudProvider string, + serviceId string, + req UpdateServiceConfigRequest, +) (*UpdateServiceConfigResponse, *model.ApiError) { + + if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil { + return nil, apiErr + } + + // can only update config for a connected cloud account id + _, apiErr := c.accountsRepo.getConnectedCloudAccount( + ctx, cloudProvider, req.CloudAccountId, + ) + if apiErr != nil { + return nil, model.WrapApiError(apiErr, "couldn't find connected cloud account") + } + + // can only update config for a valid service. + _, apiErr = getCloudProviderService(cloudProvider, serviceId) + if apiErr != nil { + return nil, model.WrapApiError(apiErr, "unsupported service") + } + + updatedConfig, apiErr := c.serviceConfigRepo.upsert( + ctx, cloudProvider, req.CloudAccountId, serviceId, req.Config, + ) + if apiErr != nil { + return nil, model.WrapApiError(apiErr, "couldn't update service config") + } + + return &UpdateServiceConfigResponse{ + Id: serviceId, + Config: *updatedConfig, + }, nil +} diff --git a/pkg/query-service/app/cloudintegrations/controller_test.go b/pkg/query-service/app/cloudintegrations/controller_test.go index 2d85cc1b95..7b361615d0 100644 --- a/pkg/query-service/app/cloudintegrations/controller_test.go +++ b/pkg/query-service/app/cloudintegrations/controller_test.go @@ -30,7 +30,7 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) { require.NotEmpty(resp1.AccountId) testAccountId := resp1.AccountId - account, apiErr := controller.repo.get( + account, apiErr := controller.accountsRepo.get( context.TODO(), "aws", testAccountId, ) require.Nil(apiErr) @@ -47,7 +47,7 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) { require.Nil(apiErr) require.Equal(testAccountId, resp2.AccountId) - account, apiErr = controller.repo.get( + account, apiErr = controller.accountsRepo.get( context.TODO(), "aws", testAccountId, ) require.Nil(apiErr) @@ -89,7 +89,7 @@ func TestAgentCheckIns(t *testing.T) { // if another connected AccountRecord exists for same cloud account // i.e. there can't be 2 connected account records for the same cloud account id // at any point in time. - existingConnected, apiErr := controller.repo.getConnectedCloudAccount( + existingConnected, apiErr := controller.accountsRepo.getConnectedCloudAccount( context.TODO(), "aws", testCloudAccountId1, ) require.Nil(apiErr) @@ -112,7 +112,7 @@ func TestAgentCheckIns(t *testing.T) { context.TODO(), "aws", testAccountId1, ) - existingConnected, apiErr = controller.repo.getConnectedCloudAccount( + existingConnected, apiErr = controller.accountsRepo.getConnectedCloudAccount( context.TODO(), "aws", testCloudAccountId1, ) require.Nil(existingConnected) @@ -151,3 +151,120 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) { require.Equal(model.ErrorNotFound, apiErr.Type()) require.Nil(account) } + +func TestConfigureService(t *testing.T) { + require := require.New(t) + testDB, _ := utils.NewTestSqliteDB(t) + controller, err := NewController(testDB) + require.NoError(err) + + testCloudAccountId := "546311234" + + // should start out without any service config + svcListResp, apiErr := controller.ListServices( + context.TODO(), "aws", &testCloudAccountId, + ) + require.Nil(apiErr) + + testSvcId := svcListResp.Services[0].Id + require.Nil(svcListResp.Services[0].Config) + + svcDetails, apiErr := controller.GetServiceDetails( + context.TODO(), "aws", testSvcId, &testCloudAccountId, + ) + require.Nil(apiErr) + require.Equal(testSvcId, svcDetails.Id) + require.Nil(svcDetails.Config) + + // should be able to configure a service for a connected account + testConnectedAccount := makeTestConnectedAccount(t, controller, testCloudAccountId) + require.Nil(testConnectedAccount.RemovedAt) + require.NotNil(testConnectedAccount.CloudAccountId) + require.Equal(testCloudAccountId, *testConnectedAccount.CloudAccountId) + + testSvcConfig := CloudServiceConfig{ + Metrics: &CloudServiceMetricsConfig{ + Enabled: true, + }, + } + updateSvcConfigResp, apiErr := controller.UpdateServiceConfig( + context.TODO(), "aws", testSvcId, UpdateServiceConfigRequest{ + CloudAccountId: testCloudAccountId, + Config: testSvcConfig, + }, + ) + require.Nil(apiErr) + require.Equal(testSvcId, updateSvcConfigResp.Id) + require.Equal(testSvcConfig, updateSvcConfigResp.Config) + + svcDetails, apiErr = controller.GetServiceDetails( + context.TODO(), "aws", testSvcId, &testCloudAccountId, + ) + require.Nil(apiErr) + require.Equal(testSvcId, svcDetails.Id) + require.Equal(testSvcConfig, *svcDetails.Config) + + svcListResp, apiErr = controller.ListServices( + context.TODO(), "aws", &testCloudAccountId, + ) + require.Nil(apiErr) + for _, svc := range svcListResp.Services { + if svc.Id == testSvcId { + require.Equal(testSvcConfig, *svc.Config) + } + } + + // should not be able to configure service after cloud account has been disconnected + _, apiErr = controller.DisconnectAccount( + context.TODO(), "aws", testConnectedAccount.Id, + ) + require.Nil(apiErr) + + _, apiErr = controller.UpdateServiceConfig( + context.TODO(), "aws", testSvcId, + UpdateServiceConfigRequest{ + CloudAccountId: testCloudAccountId, + Config: testSvcConfig, + }, + ) + require.NotNil(apiErr) + + // should not be able to configure a service for a cloud account id that is not connected yet + _, apiErr = controller.UpdateServiceConfig( + context.TODO(), "aws", testSvcId, + UpdateServiceConfigRequest{ + CloudAccountId: "9999999999", + Config: testSvcConfig, + }, + ) + require.NotNil(apiErr) + + // should not be able to set config for an unsupported service + _, apiErr = controller.UpdateServiceConfig( + context.TODO(), "aws", "bad-service", UpdateServiceConfigRequest{ + CloudAccountId: testCloudAccountId, + Config: testSvcConfig, + }, + ) + require.NotNil(apiErr) + +} + +func makeTestConnectedAccount(t *testing.T, controller *Controller, cloudAccountId string) *AccountRecord { + require := require.New(t) + + // a check in from SigNoz agent creates or updates a connected account. + testAccountId := uuid.NewString() + resp, apiErr := controller.CheckInAsAgent( + context.TODO(), "aws", AgentCheckInRequest{ + AccountId: testAccountId, + CloudAccountId: cloudAccountId, + }, + ) + require.Nil(apiErr) + require.Equal(testAccountId, resp.Account.Id) + require.Equal(cloudAccountId, *resp.Account.CloudAccountId) + + return &resp.Account + +} diff --git a/pkg/query-service/app/cloudintegrations/model.go b/pkg/query-service/app/cloudintegrations/model.go index 53ad4c853e..5f35336b9f 100644 --- a/pkg/query-service/app/cloudintegrations/model.go +++ b/pkg/query-service/app/cloudintegrations/model.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "go.signoz.io/signoz/pkg/query-service/app/dashboards" ) // Represents a cloud provider account for cloud integrations @@ -115,3 +117,102 @@ func (a *AccountRecord) account() Account { return ca } + +type CloudServiceSummary struct { + Id string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon"` + + // Present only if the service has been configured in the + // context of a cloud provider account. + Config *CloudServiceConfig `json:"config,omitempty"` +} + +type CloudServiceDetails struct { + CloudServiceSummary + + Overview string `json:"overview"` // markdown + + Assets CloudServiceAssets `json:"assets"` + + SupportedSignals SupportedSignals `json:"supported_signals"` + + DataCollected DataCollectedForService `json:"data_collected"` + + ConnectionStatus *CloudServiceConnectionStatus `json:"status,omitempty"` +} + +type CloudServiceConfig struct { + Logs *CloudServiceLogsConfig `json:"logs,omitempty"` + Metrics *CloudServiceMetricsConfig `json:"metrics,omitempty"` +} + +// For serializing from db +func (c *CloudServiceConfig) Scan(src any) error { + data, ok := src.([]byte) + if !ok { + return fmt.Errorf("tried to scan from %T instead of bytes", src) + } + + return json.Unmarshal(data, &c) +} + +// For serializing to db +func (c *CloudServiceConfig) Value() (driver.Value, error) { + if c == nil { + return nil, nil + } + + serialized, err := json.Marshal(c) + if err != nil { + return nil, fmt.Errorf( + "couldn't serialize cloud service config to JSON: %w", err, + ) + } + return serialized, nil +} + +type CloudServiceLogsConfig struct { + Enabled bool `json:"enabled"` +} + +type CloudServiceMetricsConfig struct { + Enabled bool `json:"enabled"` +} + +type CloudServiceAssets struct { + Dashboards []dashboards.Data `json:"dashboards"` +} + +type SupportedSignals struct { + Logs bool `json:"logs"` + Metrics bool `json:"metrics"` +} + +type DataCollectedForService struct { + Logs []CollectedLogAttribute `json:"logs"` + Metrics []CollectedMetric `json:"metrics"` +} + +type CollectedLogAttribute struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` +} + +type CollectedMetric struct { + Name string `json:"name"` + Type string `json:"type"` + Unit string `json:"unit"` + Description string `json:"description"` +} + +type CloudServiceConnectionStatus struct { + Logs *SignalConnectionStatus `json:"logs"` + Metrics *SignalConnectionStatus `json:"metrics"` +} + +type SignalConnectionStatus struct { + LastReceivedTsMillis int64 `json:"last_received_ts_ms"` // epoch milliseconds + LastReceivedFrom string `json:"last_received_from"` // resource identifier +} diff --git a/pkg/query-service/app/cloudintegrations/serviceConfigRepo.go b/pkg/query-service/app/cloudintegrations/serviceConfigRepo.go new file mode 100644 index 0000000000..17994e7565 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceConfigRepo.go @@ -0,0 +1,198 @@ +package cloudintegrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "go.signoz.io/signoz/pkg/query-service/model" +) + +type serviceConfigRepository interface { + get( + ctx context.Context, + cloudProvider string, + cloudAccountId string, + serviceId string, + ) (*CloudServiceConfig, *model.ApiError) + + upsert( + ctx context.Context, + cloudProvider string, + cloudAccountId string, + serviceId string, + config CloudServiceConfig, + ) (*CloudServiceConfig, *model.ApiError) + + getAllForAccount( + ctx context.Context, + cloudProvider string, + cloudAccountId string, + ) ( + configsBySvcId map[string]*CloudServiceConfig, + apiErr *model.ApiError, + ) +} + +func newServiceConfigRepository(db *sqlx.DB) ( + *serviceConfigSQLRepository, error, +) { + + if err := initServiceConfigSqliteDBIfNeeded(db); err != nil { + return nil, fmt.Errorf( + "could not init sqlite DB for cloudintegrations service configs: %w", err, + ) + } + + return &serviceConfigSQLRepository{ + db: db, + }, nil +} + +func initServiceConfigSqliteDBIfNeeded(db *sqlx.DB) error { + + if db == nil { + return fmt.Errorf("db is required") + } + + createTableStatement := ` + CREATE TABLE IF NOT EXISTS cloud_integrations_service_configs( + cloud_provider TEXT NOT NULL, + cloud_account_id TEXT NOT NULL, + service_id TEXT NOT NULL, + config_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE(cloud_provider, cloud_account_id, service_id) + ) + ` + _, err := db.Exec(createTableStatement) + if err != nil { + return fmt.Errorf( + "could not ensure cloud provider service configs schema in sqlite DB: %w", err, + ) + } + + return nil +} + +type serviceConfigSQLRepository struct { + db *sqlx.DB +} + +func (r *serviceConfigSQLRepository) get( + ctx context.Context, + cloudProvider string, + cloudAccountId string, + serviceId string, +) (*CloudServiceConfig, *model.ApiError) { + + var result CloudServiceConfig + + err := r.db.GetContext( + ctx, &result, ` + select + config_json + from cloud_integrations_service_configs + where + cloud_provider=$1 + and cloud_account_id=$2 + and service_id=$3 + `, + cloudProvider, cloudAccountId, serviceId, + ) + + if err == sql.ErrNoRows { + return nil, model.NotFoundError(fmt.Errorf( + "couldn't find %s %s config for %s", + cloudProvider, serviceId, cloudAccountId, + )) + + } else if err != nil { + return nil, model.InternalError(fmt.Errorf( + "couldn't query cloud service config: %w", err, + )) + } + + return &result, nil + +} + +func (r *serviceConfigSQLRepository) upsert( + ctx context.Context, + cloudProvider string, + cloudAccountId string, + serviceId string, + config CloudServiceConfig, +) (*CloudServiceConfig, *model.ApiError) { + + query := ` + INSERT INTO cloud_integrations_service_configs ( + cloud_provider, + cloud_account_id, + service_id, + config_json + ) values ($1, $2, $3, $4) + on conflict(cloud_provider, cloud_account_id, service_id) + do update set config_json=excluded.config_json + ` + _, dbErr := r.db.ExecContext( + ctx, query, + cloudProvider, cloudAccountId, serviceId, &config, + ) + if dbErr != nil { + return nil, model.InternalError(fmt.Errorf( + "could not upsert cloud service config: %w", dbErr, + )) + } + + upsertedConfig, apiErr := r.get(ctx, cloudProvider, cloudAccountId, serviceId) + if apiErr != nil { + return nil, model.InternalError(fmt.Errorf( + "couldn't fetch upserted service config: %w", apiErr.ToError(), + )) + } + + return upsertedConfig, nil + +} + +func (r *serviceConfigSQLRepository) getAllForAccount( + ctx context.Context, + cloudProvider string, + cloudAccountId string, +) (map[string]*CloudServiceConfig, *model.ApiError) { + + type ScannedServiceConfigRecord struct { + ServiceId string `db:"service_id"` + Config CloudServiceConfig `db:"config_json"` + } + + records := []ScannedServiceConfigRecord{} + + err := r.db.SelectContext( + ctx, &records, ` + select + service_id, + config_json + from cloud_integrations_service_configs + where + cloud_provider=$1 + and cloud_account_id=$2 + `, + cloudProvider, cloudAccountId, + ) + if err != nil { + return nil, model.InternalError(fmt.Errorf( + "could not query service configs from db: %w", err, + )) + } + + result := map[string]*CloudServiceConfig{} + + for _, r := range records { + result[r.ServiceId] = &r.Config + } + + return result, nil +} diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/icon.svg b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/icon.svg new file mode 100644 index 0000000000..6c85e7c8d2 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/integration.json b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/integration.json new file mode 100644 index 0000000000..55f85ca5bd --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/integration.json @@ -0,0 +1,30 @@ +{ + "id": "ec2", + "title": "EC2", + "icon": "file://icon.svg", + "overview": "file://overview.md", + "assets": { + "dashboards": [] + }, + "supported_signals": { + "metrics": true, + "logs": false + }, + "data_collected": { + "metrics": [ + { + "name": "ec2_cpuutilization_average", + "type": "Gauge", + "unit": "number", + "description": "CloudWatch metric CPUUtilization" + }, + { + "name": "ec2_cpuutilization_maximum", + "type": "Gauge", + "unit": "number", + "description": "CloudWatch metric CPUUtilization" + } + ], + "logs": [] + } +} \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/overview.md b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/overview.md new file mode 100644 index 0000000000..3a1642c016 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/ec2/overview.md @@ -0,0 +1,3 @@ +### Monitor EC2 with SigNoz + +Collect key EC2 metrics and view them with an out of the box dashboard. diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/icon.svg b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/icon.svg new file mode 100644 index 0000000000..0eab74ea79 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/icon.svg @@ -0,0 +1,21 @@ + + + Icon-Architecture/64/Arch_Amazon-RDS_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/integration.json b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/integration.json new file mode 100644 index 0000000000..969cbf56b4 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/integration.json @@ -0,0 +1,30 @@ +{ + "id": "rds-postgres", + "title": "RDS Postgres", + "icon": "file://icon.svg", + "overview": "file://overview.md", + "assets": { + "dashboards": [] + }, + "supported_signals": { + "metrics": true, + "logs": true + }, + "data_collected": { + "metrics": [ + { + "name": "rds_postgres_cpuutilization_average", + "type": "Gauge", + "unit": "number", + "description": "CloudWatch metric CPUUtilization" + }, + { + "name": "rds_postgres_cpuutilization_maximum", + "type": "Gauge", + "unit": "number", + "description": "CloudWatch metric CPUUtilization" + } + ], + "logs": [] + } +} \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/overview.md b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/overview.md new file mode 100644 index 0000000000..193353be1f --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/serviceDefinitions/aws/rdsPostgres/overview.md @@ -0,0 +1,3 @@ +### Monitor RDS Postgres with SigNoz + +Collect key RDS Postgres metrics and view them with an out of the box dashboard. diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 5da9200c0e..fff4fded57 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -3902,6 +3902,18 @@ func (aH *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *Au "/{cloudProvider}/agent-check-in", am.EditAccess(aH.CloudIntegrationsAgentCheckIn), ).Methods(http.MethodPost) + subRouter.HandleFunc( + "/{cloudProvider}/services", am.ViewAccess(aH.CloudIntegrationsListServices), + ).Methods(http.MethodGet) + + subRouter.HandleFunc( + "/{cloudProvider}/services/{serviceId}", am.ViewAccess(aH.CloudIntegrationsGetServiceDetails), + ).Methods(http.MethodGet) + + subRouter.HandleFunc( + "/{cloudProvider}/services/{serviceId}/config", am.EditAccess(aH.CloudIntegrationsUpdateServiceConfig), + ).Methods(http.MethodPost) + } func (aH *APIHandler) CloudIntegrationsListConnectedAccounts( @@ -4025,6 +4037,77 @@ func (aH *APIHandler) CloudIntegrationsDisconnectAccount( aH.Respond(w, result) } +func (aH *APIHandler) CloudIntegrationsListServices( + w http.ResponseWriter, r *http.Request, +) { + cloudProvider := mux.Vars(r)["cloudProvider"] + + var cloudAccountId *string + + cloudAccountIdQP := r.URL.Query().Get("cloud_account_id") + if len(cloudAccountIdQP) > 0 { + cloudAccountId = &cloudAccountIdQP + } + + resp, apiErr := aH.CloudIntegrationsController.ListServices( + r.Context(), cloudProvider, cloudAccountId, + ) + + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + aH.Respond(w, resp) +} + +func (aH *APIHandler) CloudIntegrationsGetServiceDetails( + w http.ResponseWriter, r *http.Request, +) { + cloudProvider := mux.Vars(r)["cloudProvider"] + serviceId := mux.Vars(r)["serviceId"] + + var cloudAccountId *string + + cloudAccountIdQP := r.URL.Query().Get("cloud_account_id") + if len(cloudAccountIdQP) > 0 { + cloudAccountId = &cloudAccountIdQP + } + + resp, apiErr := aH.CloudIntegrationsController.GetServiceDetails( + r.Context(), cloudProvider, serviceId, cloudAccountId, + ) + + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + aH.Respond(w, resp) +} + +func (aH *APIHandler) CloudIntegrationsUpdateServiceConfig( + w http.ResponseWriter, r *http.Request, +) { + cloudProvider := mux.Vars(r)["cloudProvider"] + serviceId := mux.Vars(r)["serviceId"] + + req := cloudintegrations.UpdateServiceConfigRequest{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, model.BadRequest(err), nil) + return + } + + result, apiErr := aH.CloudIntegrationsController.UpdateServiceConfig( + r.Context(), cloudProvider, serviceId, req, + ) + + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + aH.Respond(w, result) +} + // logs func (aH *APIHandler) RegisterLogsRoutes(router *mux.Router, am *AuthMiddleware) { subRouter := router.PathPrefix("/api/v1/logs").Subrouter() diff --git a/pkg/query-service/app/integrations/builtin.go b/pkg/query-service/app/integrations/builtin.go index d8099633ab..77e31df001 100644 --- a/pkg/query-service/app/integrations/builtin.go +++ b/pkg/query-service/app/integrations/builtin.go @@ -105,7 +105,7 @@ func readBuiltInIntegration(dirpath string) ( ) } - hydrated, err := hydrateFileUris(integrationSpec, dirpath) + hydrated, err := HydrateFileUris(integrationSpec, integrationFiles, dirpath) if err != nil { return nil, fmt.Errorf( "couldn't hydrate files referenced in integration %s: %w", integrationJsonPath, err, @@ -172,11 +172,11 @@ func validateIntegration(i IntegrationDetails) error { return nil } -func hydrateFileUris(spec interface{}, basedir string) (interface{}, error) { +func HydrateFileUris(spec interface{}, fs embed.FS, basedir string) (interface{}, error) { if specMap, ok := spec.(map[string]interface{}); ok { result := map[string]interface{}{} for k, v := range specMap { - hydrated, err := hydrateFileUris(v, basedir) + hydrated, err := HydrateFileUris(v, fs, basedir) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func hydrateFileUris(spec interface{}, basedir string) (interface{}, error) { } else if specSlice, ok := spec.([]interface{}); ok { result := []interface{}{} for _, v := range specSlice { - hydrated, err := hydrateFileUris(v, basedir) + hydrated, err := HydrateFileUris(v, fs, basedir) if err != nil { return nil, err } @@ -196,14 +196,14 @@ func hydrateFileUris(spec interface{}, basedir string) (interface{}, error) { return result, nil } else if maybeFileUri, ok := spec.(string); ok { - return readFileIfUri(maybeFileUri, basedir) + return readFileIfUri(fs, maybeFileUri, basedir) } return spec, nil } -func readFileIfUri(maybeFileUri string, basedir string) (interface{}, error) { +func readFileIfUri(fs embed.FS, maybeFileUri string, basedir string) (interface{}, error) { fileUriPrefix := "file://" if !strings.HasPrefix(maybeFileUri, fileUriPrefix) { return maybeFileUri, nil @@ -212,7 +212,7 @@ func readFileIfUri(maybeFileUri string, basedir string) (interface{}, error) { relativePath := maybeFileUri[len(fileUriPrefix):] fullPath := path.Join(basedir, relativePath) - fileContents, err := integrationFiles.ReadFile(fullPath) + fileContents, err := fs.ReadFile(fullPath) if err != nil { return nil, fmt.Errorf("couldn't read referenced file: %w", err) } diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index 9f199b5b06..55f337742d 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/jmoiron/sqlx" mockhouse "github.com/srikanthccv/ClickHouse-go-mock" "github.com/stretchr/testify/require" @@ -19,7 +20,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/utils" ) -func TestAWSIntegrationLifecycle(t *testing.T) { +func TestAWSIntegrationAccountLifecycle(t *testing.T) { // Test for happy path of connecting and managing AWS integration accounts t0 := time.Now() @@ -126,6 +127,70 @@ func TestAWSIntegrationLifecycle(t *testing.T) { require.LessOrEqual(tsBeforeDisconnect, *agentCheckInResp2.Account.RemovedAt) } +func TestAWSIntegrationServices(t *testing.T) { + require := require.New(t) + + testbed := NewCloudIntegrationsTestBed(t, nil) + + // should be able to list available cloud services. + svcListResp := testbed.GetServicesFromQS("aws", nil) + require.Greater(len(svcListResp.Services), 0) + for _, svc := range svcListResp.Services { + require.NotEmpty(svc.Id) + require.Nil(svc.Config) + } + + // should be able to get details of a particular service. + svcId := svcListResp.Services[0].Id + svcDetailResp := testbed.GetServiceDetailFromQS("aws", svcId, nil) + require.Equal(svcId, svcDetailResp.Id) + require.NotEmpty(svcDetailResp.Overview) + require.Nil(svcDetailResp.Config) + require.Nil(svcDetailResp.ConnectionStatus) + + // should be able to configure a service in the ctx of a connected account + + // create a connected account + testAccountId := uuid.NewString() + testAWSAccountId := "389389489489" + testbed.CheckInAsAgentWithQS( + "aws", cloudintegrations.AgentCheckInRequest{ + AccountId: testAccountId, + CloudAccountId: testAWSAccountId, + }, + ) + + testSvcConfig := cloudintegrations.CloudServiceConfig{ + Metrics: &cloudintegrations.CloudServiceMetricsConfig{ + Enabled: true, + }, + } + updateSvcConfigResp := testbed.UpdateServiceConfigWithQS("aws", svcId, cloudintegrations.UpdateServiceConfigRequest{ + CloudAccountId: testAWSAccountId, + Config: testSvcConfig, + }) + require.Equal(svcId, updateSvcConfigResp.Id) + require.Equal(testSvcConfig, updateSvcConfigResp.Config) + + // service list should include config when queried in the ctx of an account + svcListResp = testbed.GetServicesFromQS("aws", &testAWSAccountId) + require.Greater(len(svcListResp.Services), 0) + for _, svc := range svcListResp.Services { + if svc.Id == svcId { + require.NotNil(svc.Config) + require.Equal(testSvcConfig, *svc.Config) + } + } + + // service detail should include config and status info when + // queried in the ctx of an account + svcDetailResp = testbed.GetServiceDetailFromQS("aws", svcId, &testAWSAccountId) + require.Equal(svcId, svcDetailResp.Id) + require.NotNil(svcDetailResp.Config) + require.Equal(testSvcConfig, *svcDetailResp.Config) + +} + type CloudIntegrationsTestBed struct { t *testing.T testUser *model.User @@ -275,6 +340,41 @@ func (tb *CloudIntegrationsTestBed) DisconnectAccountWithQS( return &resp } +func (tb *CloudIntegrationsTestBed) GetServicesFromQS( + cloudProvider string, cloudAccountId *string, +) *cloudintegrations.ListServicesResponse { + path := fmt.Sprintf("/api/v1/cloud-integrations/%s/services", cloudProvider) + if cloudAccountId != nil { + path = fmt.Sprintf("%s?cloud_account_id=%s", path, *cloudAccountId) + } + + return RequestQSAndParseResp[cloudintegrations.ListServicesResponse]( + tb, path, nil, + ) +} + +func (tb *CloudIntegrationsTestBed) GetServiceDetailFromQS( + cloudProvider string, serviceId string, cloudAccountId *string, +) *cloudintegrations.CloudServiceDetails { + path := fmt.Sprintf("/api/v1/cloud-integrations/%s/services/%s", cloudProvider, serviceId) + if cloudAccountId != nil { + path = fmt.Sprintf("%s?cloud_account_id=%s", path, *cloudAccountId) + } + + return RequestQSAndParseResp[cloudintegrations.CloudServiceDetails]( + tb, path, nil, + ) +} +func (tb *CloudIntegrationsTestBed) UpdateServiceConfigWithQS( + cloudProvider string, serviceId string, req any, +) *cloudintegrations.UpdateServiceConfigResponse { + path := fmt.Sprintf("/api/v1/cloud-integrations/%s/services/%s/config", cloudProvider, serviceId) + + return RequestQSAndParseResp[cloudintegrations.UpdateServiceConfigResponse]( + tb, path, req, + ) +} + func (tb *CloudIntegrationsTestBed) RequestQS( path string, postData interface{}, @@ -297,3 +397,20 @@ func (tb *CloudIntegrationsTestBed) RequestQS( } return dataJson } + +func RequestQSAndParseResp[ResponseType any]( + tb *CloudIntegrationsTestBed, + path string, + postData interface{}, +) *ResponseType { + respDataJson := tb.RequestQS(path, postData) + + var resp ResponseType + + err := json.Unmarshal(respDataJson, &resp) + if err != nil { + tb.t.Fatalf("could not unmarshal apiResponse.Data json into %T", resp) + } + + return &resp +}