mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-18 16:11:05 +01:00
Compare commits
10 Commits
main
...
keystone-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e53cb3d2 | ||
|
|
9b25a5ea1d | ||
|
|
123d8a66eb | ||
|
|
ecda7da3c0 | ||
|
|
ee377ea9e8 | ||
|
|
f4f9fabe44 | ||
|
|
cd3d9e631c | ||
|
|
69eca2e8f3 | ||
|
|
cf3d2f23ed | ||
|
|
8a1ab49939 |
@@ -2159,6 +2159,8 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
@@ -2168,13 +2170,83 @@ components:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
createSecretsCommand:
|
||||
type: string
|
||||
createServiceAccountCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- createSecretsCommand
|
||||
- createServiceAccountCommand
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionMethod:
|
||||
enum:
|
||||
- manual
|
||||
- agent
|
||||
type: string
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
- telemetryCollectionStrategy
|
||||
type: object
|
||||
CloudintegrationtypesGCPLogsCollectionStrategy:
|
||||
properties:
|
||||
sinkFilter:
|
||||
type: string
|
||||
required:
|
||||
- sinkFilter
|
||||
type: object
|
||||
CloudintegrationtypesGCPMetricsCollectionStrategy:
|
||||
properties:
|
||||
metricNames:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- metricNames
|
||||
type: object
|
||||
CloudintegrationtypesGCPPostableAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
@@ -2197,6 +2269,13 @@ components:
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPTelemetryCollectionStrategy:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -2333,7 +2412,7 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -2568,7 +2647,6 @@ components:
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
|
||||
@@ -7,24 +7,66 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
var metricNames []string
|
||||
var sinkFilters []string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if svcDef.TelemetryCollectionStrategy.GCP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.GCP
|
||||
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
|
||||
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
|
||||
}
|
||||
|
||||
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
|
||||
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
|
||||
}
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
GCP: connectionArtifact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
@@ -34,3 +76,7 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -171,18 +170,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -195,41 +182,36 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
if account.RemovedAt != nil {
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
|
||||
}
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
return nil, errors.Newf(
|
||||
errors.TypeAlreadyExists,
|
||||
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
|
||||
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
|
||||
)
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -498,11 +480,45 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
|
||||
}
|
||||
|
||||
// NOTE: not adding stats for services for now.
|
||||
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
if err == nil {
|
||||
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
|
||||
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
response := cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
providerAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
)
|
||||
|
||||
if account.AgentReport == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if syncState == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
account.UpdateSyncState(syncState)
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
|
||||
@@ -3722,7 +3722,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesGCPConnectionMethodDTO {
|
||||
manual = 'manual',
|
||||
agent = 'agent',
|
||||
}
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3969,7 +3974,18 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createSecretsCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createServiceAccountCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
@@ -4008,8 +4024,56 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPLogsCollectionStrategyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
sinkFilter: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPMetricsCollectionStrategyDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
metricNames: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
|
||||
logs?: CloudintegrationtypesGCPLogsCollectionStrategyDTO;
|
||||
metrics?: CloudintegrationtypesGCPMetricsCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
[key: string]: unknown;
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
@@ -4164,7 +4228,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -4308,9 +4372,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[] | null;
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.waitingMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
import AgentSetupCommands from './AgentSetupCommands';
|
||||
|
||||
import styles from './AgentSetup.module.scss';
|
||||
|
||||
interface AgentSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onCommandsGenerated: () => void;
|
||||
/** Asks to leave the setup; the parent may confirm before closing. */
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AgentSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onCommandsGenerated,
|
||||
onCancel,
|
||||
onClose,
|
||||
}: AgentSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
generateCommands,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommands,
|
||||
commandsProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
} = useAgentAccountSetup({ onClose, onCommandsGenerated });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading || Boolean(connectionCommands)}
|
||||
/>
|
||||
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
|
||||
{connectionCommands ? (
|
||||
<>
|
||||
<AgentSetupCommands
|
||||
deploymentProjectId={commandsProjectId}
|
||||
commands={connectionCommands}
|
||||
/>
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon={false}
|
||||
title={
|
||||
<div className={styles.waitingMessage}>
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
Waiting for GCP account connection, retrying in 10 secs...
|
||||
</div>
|
||||
}
|
||||
testId="gcp-agent-waiting"
|
||||
/>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
title="We couldn't establish a connection to your GCP account. Please try again"
|
||||
testId="gcp-agent-connection-error"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
tooltip="The GCP project where SigNoz deploys the agent. The agent monitors this project."
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
tooltip="The GCP region where the agent resources are deployed"
|
||||
/>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to generate GCP setup commands"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{!connectionCommands && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(generateCommands)}
|
||||
loading={isLoading}
|
||||
disabled={isConnectionParamsLoading}
|
||||
testId="gcp-generate-commands-btn"
|
||||
>
|
||||
Generate Commands
|
||||
</Button>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={checkConnectionAgain}
|
||||
testId="gcp-check-connection-btn"
|
||||
>
|
||||
Check Again
|
||||
</Button>
|
||||
)}
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetup;
|
||||
@@ -0,0 +1,15 @@
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.commandsHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionArtifactDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import AgentSetupStep from './AgentSetupStep';
|
||||
import { AGENT_SETUP_STEPS } from './constants';
|
||||
|
||||
import styles from './AgentSetupCommands.module.scss';
|
||||
|
||||
interface AgentSetupCommandsProps {
|
||||
deploymentProjectId: string;
|
||||
commands: CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
function AgentSetupCommands({
|
||||
deploymentProjectId,
|
||||
commands,
|
||||
}: AgentSetupCommandsProps): JSX.Element {
|
||||
// Only the first step starts open; Next walks the user through the rest in order.
|
||||
const [expandedSteps, setExpandedSteps] = useState<boolean[]>(() =>
|
||||
AGENT_SETUP_STEPS.map((_, index) => index === 0),
|
||||
);
|
||||
|
||||
const toggleStep = (index: number): void => {
|
||||
setExpandedSteps((steps) =>
|
||||
steps.map((isExpanded, i) => (i === index ? !isExpanded : isExpanded)),
|
||||
);
|
||||
};
|
||||
|
||||
const openNextStep = (index: number): void => {
|
||||
setExpandedSteps((steps) =>
|
||||
steps.map((isExpanded, i) => {
|
||||
if (i === index) {
|
||||
return false;
|
||||
}
|
||||
if (i === index + 1) {
|
||||
return true;
|
||||
}
|
||||
return isExpanded;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.commands} data-testid="gcp-agent-commands">
|
||||
<div className={styles.commandsHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Deploy the SigNoz agent
|
||||
</Typography.Text>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Run these commands in order in Google Cloud Shell, or in a terminal where
|
||||
gcloud is signed in to{' '}
|
||||
<span className={styles.mono}>{deploymentProjectId}</span>. Use the same
|
||||
shell session for all three.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
{AGENT_SETUP_STEPS.map((step, index) => {
|
||||
const isLastStep = index === AGENT_SETUP_STEPS.length - 1;
|
||||
return (
|
||||
<AgentSetupStep
|
||||
key={step.command}
|
||||
stepNumber={index + 1}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
command={commands[step.command]}
|
||||
isExpanded={expandedSteps[index]}
|
||||
onToggle={(): void => toggleStep(index)}
|
||||
onNext={isLastStep ? undefined : (): void => openNextStep(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetupCommands;
|
||||
@@ -0,0 +1,95 @@
|
||||
.step {
|
||||
display: flex;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--accent-primary);
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// Runs from this step's circle down to the next one; the last step has none.
|
||||
.connector {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: var(--spacing-6);
|
||||
margin-top: var(--spacing-2);
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.lastStep .content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
color: var(--l1-foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
|
||||
// CodeBlock positions its copy button and the code theme sets the block's margin
|
||||
// and padding inline, hence !important.
|
||||
.command {
|
||||
> div {
|
||||
// The theme's top margin pushes the bordered block under the copy button.
|
||||
margin: 0 !important;
|
||||
// Keeps long lines from running underneath the copy button.
|
||||
padding-right: var(--spacing-24) !important;
|
||||
}
|
||||
|
||||
> button {
|
||||
top: var(--spacing-6) !important;
|
||||
right: var(--spacing-6) !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import CodeBlock from 'components/CodeBlock/CodeBlock';
|
||||
|
||||
import styles from './AgentSetupStep.module.scss';
|
||||
|
||||
interface AgentSetupStepProps {
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
description: string;
|
||||
command: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
/** Omitted on the last step, which has no next step to open. */
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
function AgentSetupStep({
|
||||
stepNumber,
|
||||
title,
|
||||
description,
|
||||
command,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onNext,
|
||||
}: AgentSetupStepProps): JSX.Element {
|
||||
const testId = `gcp-agent-step-${stepNumber}`;
|
||||
const bodyId = `${testId}-body`;
|
||||
const isLastStep = !onNext;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.step, { [styles.lastStep]: isLastStep })}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={styles.indicator}>
|
||||
<span className={styles.stepNumber}>{stepNumber}</span>
|
||||
{!isLastStep && <span className={styles.connector} />}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={bodyId}
|
||||
data-testid={`${testId}-toggle`}
|
||||
>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
{title}
|
||||
</Typography.Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} className={styles.chevron} />
|
||||
) : (
|
||||
<ChevronRight size={14} className={styles.chevron} />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div id={bodyId} className={styles.body}>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
<CodeBlock language="bash" code={command} className={styles.command} />
|
||||
{onNext && (
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
testId={`${testId}-next`}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AgentSetupStep.defaultProps = {
|
||||
onNext: undefined,
|
||||
};
|
||||
|
||||
export default AgentSetupStep;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CloudintegrationtypesGCPConnectionArtifactDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface AgentSetupStep {
|
||||
command: keyof CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Order matters: step 2 grants access to the secrets created in step 1, and step 3
|
||||
// reads the secret versions that step 1 exports into the shell.
|
||||
export const AGENT_SETUP_STEPS: AgentSetupStep[] = [
|
||||
{
|
||||
command: 'createSecretsCommand',
|
||||
title: 'Store SigNoz credentials in Secret Manager',
|
||||
description:
|
||||
'Enables the required Google Cloud APIs and saves your SigNoz API key and ingestion key as secrets. This command contains your keys, so do not share it.',
|
||||
},
|
||||
{
|
||||
command: 'createServiceAccountCommand',
|
||||
title: 'Create the bootstrap service account',
|
||||
description:
|
||||
'Creates the service account that Infrastructure Manager uses to deploy the agent, and grants it the roles it needs.',
|
||||
},
|
||||
{
|
||||
command: 'cliCommand',
|
||||
title: 'Deploy the agent',
|
||||
description:
|
||||
'Deploys the SigNoz agent with Infrastructure Manager. This can take a few minutes.',
|
||||
},
|
||||
];
|
||||
@@ -1,142 +0,0 @@
|
||||
.setupDrawer {
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
// Input and ComboboxSimple default to --border borders, inherited text and
|
||||
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
|
||||
// brighter values and duller placeholders. Backgrounds are left alone — both
|
||||
// components default to transparent and every field sits on an --l2-background
|
||||
// surface already. Focus borders stay at their per-component defaults.
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l2-border);
|
||||
--input-foreground: var(--l1-foreground);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
--combobox-trigger-border-color: var(--l2-border);
|
||||
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.projectIdsSelect {
|
||||
:global(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector),
|
||||
&:global(.ant-select-focused) :global(.ant-select-selector) {
|
||||
border-color: var(--l2-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
|
||||
:global(.ant-select-selection-placeholder),
|
||||
:global(.ant-select-selection-search-input),
|
||||
:global(.ant-select-selection-item) {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-search-input) {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item-remove) {
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
|
||||
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ConnectionSecretsFields from './ConnectionSecretsFields';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FlowSelector from './FlowSelector';
|
||||
import SetupGuideCallout from './SetupGuideCallout';
|
||||
import { GcpSetupFormValues, SetupFlow } from './types';
|
||||
|
||||
import styles from './CloudAccountSetupDrawer.module.scss';
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
const DEFAULT_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
import AgentSetup from './AgentSetup/AgentSetup';
|
||||
import ManualSetup from './ManualSetup/ManualSetup';
|
||||
import SetupDrawerLayout from './SetupDrawerLayout';
|
||||
|
||||
function CloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useCloudAccountSetupDrawer({ onClose });
|
||||
const [connectionMethod, setConnectionMethod] =
|
||||
useState<CloudintegrationtypesGCPConnectionMethodDTO>(
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
);
|
||||
// Set once agent commands exist; closing after that loses the pending setup.
|
||||
const [hasGeneratedCommands, setHasGeneratedCommands] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
});
|
||||
|
||||
const [flow, setFlow] = useState<SetupFlow>('manual');
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
const requestClose = (): void => {
|
||||
if (hasGeneratedCommands) {
|
||||
setIsCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footerContainer}>
|
||||
{submitError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={clearSubmitError}
|
||||
title="Failed to connect GCP account"
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{submitError}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading}
|
||||
disabled={isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
<>
|
||||
<SetupDrawerLayout onClose={requestClose}>
|
||||
{(footerSlot): JSX.Element =>
|
||||
connectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent ? (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onCommandsGenerated={(): void => setHasGeneratedCommands(true)}
|
||||
onCancel={requestClose}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<ManualSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="base"
|
||||
footer={footer}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<FlowSelector value={flow} onChange={setFlow} />
|
||||
<SetupGuideCallout />
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip="The GCP region where your OTel Collector will be deployed"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Select
|
||||
id="gcp-project-ids-select"
|
||||
className={cx(styles.fullWidth, styles.projectIdsSelect)}
|
||||
mode="tags"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value)}
|
||||
placeholder="Add project IDs…"
|
||||
tokenSeparators={[',', ' ']}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="gcp-project-ids-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
</SetupDrawerLayout>
|
||||
<DialogWrapper
|
||||
open={isCloseConfirmOpen}
|
||||
onOpenChange={setIsCloseConfirmOpen}
|
||||
title="Close GCP agent setup?"
|
||||
testId="gcp-close-setup-confirm"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
onClick={onClose}
|
||||
testId="gcp-close-setup-btn"
|
||||
>
|
||||
Close Setup
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsCloseConfirmOpen(false)}
|
||||
testId="gcp-continue-setup-btn"
|
||||
>
|
||||
Continue Setup
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography>
|
||||
The agent has not connected yet. If you close now, you will have to start
|
||||
the installation process again with new setup commands.
|
||||
</Typography>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
composes: drawerSurfaceHead from './shared.module.scss';
|
||||
}
|
||||
|
||||
.flowRadioGroup {
|
||||
.methodRadioGroup {
|
||||
--radio-group-item-border-color: var(--l2-border);
|
||||
|
||||
display: flex;
|
||||
@@ -18,7 +18,7 @@
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
|
||||
.flowRadio {
|
||||
.methodRadio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
@@ -53,7 +53,7 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.flowRadioManual:has(button[data-state='checked']) {
|
||||
&:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
}
|
||||
@@ -73,12 +73,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.flowRadioTitle {
|
||||
.methodRadioTitle {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flowRadioDesc {
|
||||
.methodRadioDesc {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ConnectionMethodSelector.module.scss';
|
||||
|
||||
interface ConnectionMethodSelectorProps {
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
onChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ConnectionMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ConnectionMethodSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onChange={(next): void =>
|
||||
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
|
||||
}
|
||||
disabled={disabled}
|
||||
className={styles.methodRadioGroup}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
containerClassName={styles.methodRadio}
|
||||
testId="gcp-connection-method-manual"
|
||||
>
|
||||
<div className={styles.methodRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
|
||||
<RadioGroupItem
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
containerClassName={styles.methodRadio}
|
||||
testId="gcp-connection-method-agent"
|
||||
>
|
||||
<div className={styles.methodRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionMethodSelector.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ConnectionMethodSelector;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { SetupFlow } from './types';
|
||||
import styles from './FlowSelector.module.scss';
|
||||
|
||||
interface FlowSelectorProps {
|
||||
value: SetupFlow;
|
||||
onChange: (flow: SetupFlow) => void;
|
||||
}
|
||||
|
||||
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as SetupFlow)}
|
||||
className={styles.flowRadioGroup}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
|
||||
testId="gcp-flow-manual"
|
||||
>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
|
||||
<RadioGroupItem
|
||||
value="agent"
|
||||
containerClassName={styles.flowRadio}
|
||||
testId="gcp-flow-agent"
|
||||
disabled
|
||||
>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Badge color="robin" variant="default">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowSelector;
|
||||
@@ -0,0 +1,55 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.projectIdsSelect {
|
||||
:global(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector),
|
||||
&:global(.ant-select-focused) :global(.ant-select-selector) {
|
||||
border-color: var(--l2-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
|
||||
:global(.ant-select-selection-placeholder),
|
||||
:global(.ant-select-selection-search-input),
|
||||
:global(.ant-select-selection-item) {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-search-input) {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item-remove) {
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Select } from 'antd';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import FieldLabel from '../FieldLabel';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
|
||||
import styles from './ManualSetup.module.scss';
|
||||
|
||||
interface ManualSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ManualSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onClose,
|
||||
}: ManualSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useManualAccountSetup({ onClose });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
tooltip="The GCP region where your OTel Collector will be deployed"
|
||||
/>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Select
|
||||
id="gcp-project-ids-select"
|
||||
className={cx(styles.fullWidth, styles.projectIdsSelect)}
|
||||
mode="tags"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value)}
|
||||
placeholder="Add project IDs…"
|
||||
tokenSeparators={[',', ' ']}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="gcp-project-ids-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to connect GCP account"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading}
|
||||
disabled={isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManualSetup;
|
||||
@@ -0,0 +1,14 @@
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
import styles from './SetupDrawerFooter.module.scss';
|
||||
|
||||
interface SetupDrawerFooterProps {
|
||||
slot: HTMLDivElement | null;
|
||||
errorTitle: string;
|
||||
error: string | null;
|
||||
onDismissError: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function SetupDrawerFooter({
|
||||
slot,
|
||||
errorTitle,
|
||||
error,
|
||||
onDismissError,
|
||||
onCancel,
|
||||
children,
|
||||
}: SetupDrawerFooterProps): JSX.Element | null {
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.footerContainer}>
|
||||
{error && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={onDismissError}
|
||||
title={errorTitle}
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
slot,
|
||||
);
|
||||
}
|
||||
|
||||
SetupDrawerFooter.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default SetupDrawerFooter;
|
||||
@@ -0,0 +1,61 @@
|
||||
.setupDrawer {
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
// Input and ComboboxSimple default to --border borders, inherited text and
|
||||
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
|
||||
// brighter values and duller placeholders. Backgrounds are left alone — both
|
||||
// components default to transparent and every field sits on an --l2-background
|
||||
// surface already. Focus borders stay at their per-component defaults.
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l2-border);
|
||||
--input-foreground: var(--l1-foreground);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
--combobox-trigger-border-color: var(--l2-border);
|
||||
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.footerSlot {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import styles from './SetupDrawerLayout.module.scss';
|
||||
|
||||
interface SetupDrawerLayoutProps {
|
||||
onClose: () => void;
|
||||
/** Receives the footer slot element that SetupDrawerFooter portals into. */
|
||||
children: (footerSlot: HTMLDivElement | null) => ReactNode;
|
||||
}
|
||||
|
||||
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
|
||||
function SetupDrawerLayout({
|
||||
onClose,
|
||||
children,
|
||||
}: SetupDrawerLayoutProps): JSX.Element {
|
||||
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="extra-wide"
|
||||
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
testId="gcp-setup-drawer"
|
||||
>
|
||||
{children(footerSlot)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupDrawerLayout;
|
||||
@@ -4,24 +4,30 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './SetupGuideCallout.module.scss';
|
||||
|
||||
const GCP_INTEGRATION_DOCS_URL =
|
||||
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
|
||||
interface SetupGuideCalloutProps {
|
||||
description: string;
|
||||
docsUrl: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
|
||||
function SetupGuideCallout(): JSX.Element {
|
||||
function SetupGuideCallout({
|
||||
description,
|
||||
docsUrl,
|
||||
linkLabel,
|
||||
}: SetupGuideCalloutProps): JSX.Element {
|
||||
return (
|
||||
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
|
||||
<Typography.Text as="span" size="base">
|
||||
Please go through our GCP integration guide, which covers all prerequisites
|
||||
— service account, IAM roles, and resource setup.
|
||||
{description}
|
||||
</Typography.Text>
|
||||
<a
|
||||
className={styles.guideLink}
|
||||
href={GCP_INTEGRATION_DOCS_URL}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="gcp-setup-guide-link"
|
||||
>
|
||||
GCP integration guide
|
||||
{linkLabel}
|
||||
<ArrowUpRight size={12} />
|
||||
</a>
|
||||
</Callout>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
export const MANUAL_SETUP_GUIDE = {
|
||||
description:
|
||||
'Please go through our GCP integration guide, which covers all prerequisites — service account, IAM roles, and resource setup.',
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'GCP integration guide',
|
||||
};
|
||||
|
||||
export const AGENT_SETUP_GUIDE = {
|
||||
description:
|
||||
'Check our docs to learn how the SigNoz agent integration works, what it deploys in your project, and the permissions needed to run the setup commands.',
|
||||
// TODO: point to the agent setup docs once they are published.
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'GCP agent integration docs',
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from '../FieldLabel';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
interface DeploymentProjectIdFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function DeploymentProjectIdField({
|
||||
control,
|
||||
tooltip,
|
||||
}: DeploymentProjectIdFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip={tooltip}
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentProjectIdField;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from '../FieldLabel';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
interface DeploymentRegionFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function DeploymentRegionField({
|
||||
control,
|
||||
tooltip,
|
||||
}: DeploymentRegionFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip={tooltip}
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, styles.regionSelect, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentRegionField;
|
||||
@@ -0,0 +1,27 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.regionSelect {
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
&.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export type SetupFlow = 'manual' | 'agent';
|
||||
|
||||
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
|
||||
export interface GcpSetupFormValues {
|
||||
accountName: string;
|
||||
deploymentProjectId: string;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UseFormSetValue } from 'react-hook-form';
|
||||
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
export function usePrefillCredentials(
|
||||
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
|
||||
setValue: UseFormSetValue<GcpSetupFormValues>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Form, Select } from 'antd';
|
||||
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
|
||||
@@ -40,6 +41,10 @@ function AccountSettingsDrawer({
|
||||
() => ('project_ids' in account.config ? account.config : null),
|
||||
[account.config],
|
||||
);
|
||||
// The backend rejects updates to agent accounts, so their settings are read-only.
|
||||
const isAgentAccount =
|
||||
gcpConfig?.connection_method ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent;
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
@@ -65,18 +70,21 @@ function AccountSettingsDrawer({
|
||||
handleClose();
|
||||
}}
|
||||
cloudProvider={INTEGRATION_TYPES.GCP}
|
||||
gcpConnectionMethod={gcpConfig?.connection_method}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
data-testid="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
{!isAgentAccount && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
data-testid="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -118,35 +126,47 @@ function AccountSettingsDrawer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.regionSelector}>
|
||||
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
{isAgentAccount ? (
|
||||
<div className={styles.regionSelector}>
|
||||
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
|
||||
<div
|
||||
className={styles.regionSelectorDescription}
|
||||
data-testid="gcp-agent-monitored-projects"
|
||||
>
|
||||
{gcpConfig?.project_ids.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.regionSelector}>
|
||||
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={projectIds}
|
||||
tokenSeparators={[',']}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
data-testid="gcp-edit-project-ids-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={projectIds}
|
||||
tokenSeparators={[',']}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
data-testid="gcp-edit-project-ids-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</DrawerWrapper>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNT_URL,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
@@ -32,20 +29,22 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const onClose = jest.fn();
|
||||
const setActiveAccount = jest.fn();
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<TooltipProvider>
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</MockQueryClientProvider>,
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,8 +134,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).toHaveBeenCalledWith({
|
||||
...gcpAccount,
|
||||
config: {
|
||||
deployment_region: gcpAccountConfig.deployment_region,
|
||||
deployment_project_id: gcpAccountConfig.deployment_project_id,
|
||||
...gcpAccountConfig,
|
||||
project_ids: ['project-a', 'project-b', 'project-c'],
|
||||
},
|
||||
});
|
||||
@@ -200,31 +198,4 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
gcpAgentAccount,
|
||||
listAccountsResponse,
|
||||
} from './mockData';
|
||||
|
||||
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
|
||||
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
|
||||
render(
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={account}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('GCP AccountSettingsDrawer by connection method', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listAccountsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
|
||||
'my-agent-project',
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('gcp-edit-project-ids-select'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-update-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
onCommandsGenerated,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
AgentSetupApiMock,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectionCredentials,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpConnectionCommands,
|
||||
} from './mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup form', () => {
|
||||
let api: AgentSetupApiMock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api = mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('asks only for the deployment project, region and credentials', async () => {
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
|
||||
connectionCredentials.sigNozApiUrl,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-region-select'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
|
||||
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
|
||||
'how the SigNoz agent integration works',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks submission and surfaces validation errors when the form is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-commands-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Please enter the deployment project ID'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Please select a region')).toBeInTheDocument();
|
||||
expect(api.createAccountPayload).toBeNull();
|
||||
});
|
||||
|
||||
it('creates an agent account and shows the setup commands in order', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-agent-commands')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(api.createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
|
||||
projectIds: [DEPLOYMENT_PROJECT_ID],
|
||||
},
|
||||
},
|
||||
credentials: connectionCredentials,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-step-1')).toHaveTextContent(
|
||||
gcpConnectionCommands.createSecretsCommand,
|
||||
);
|
||||
expect(screen.getByTestId('gcp-agent-commands')).toHaveTextContent(
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-commands-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-deployment-project-id-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-connection-method-manual')).toBeDisabled();
|
||||
expect(onCommandsGenerated).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(api.checkInCalled).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the backend error inline and keeps the form when creation fails', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
message:
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('gcp-agent-commands')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-generate-commands-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks the connection method and keeps the submitted project while commands are generating', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-connection-method-manual')).toBeDisabled();
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'-edited',
|
||||
);
|
||||
|
||||
const commands = await screen.findByTestId('gcp-agent-commands');
|
||||
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
|
||||
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup connection polling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('waits for the agent after showing the commands', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-waiting');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-check-connection-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the agent checks in', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a timeout and lets the user check again', async () => {
|
||||
const realNow = Date.now.bind(Date);
|
||||
let clockOffset = 0;
|
||||
const nowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => realNow() + clockOffset);
|
||||
|
||||
let pollCount = 0;
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
// Polling started before this response, so jumping the clock
|
||||
// past the 10 minute window makes this first poll time out.
|
||||
clockOffset += 11 * 60 * 1000;
|
||||
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-check-connection-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a retryable error when the connection status cannot be fetched', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
fillAgentForm,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
} from './agentSetupTestHelpers';
|
||||
import { gcpConnectionCommands } from './mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup command steps', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('opens only the first step and walks through the rest with Next', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
await screen.findByTestId('gcp-agent-commands');
|
||||
|
||||
const stepToggle = (step: number): HTMLElement =>
|
||||
screen.getByTestId(`gcp-agent-step-${step}-toggle`);
|
||||
|
||||
expect(stepToggle(1)).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(stepToggle(2)).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(stepToggle(3)).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByTestId('gcp-agent-step-2')).not.toHaveTextContent(
|
||||
gcpConnectionCommands.createServiceAccountCommand,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('gcp-agent-step-1-next'));
|
||||
|
||||
expect(stepToggle(1)).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByTestId('gcp-agent-step-2')).toHaveTextContent(
|
||||
gcpConnectionCommands.createServiceAccountCommand,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('gcp-agent-step-2-next'));
|
||||
|
||||
expect(stepToggle(2)).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getByTestId('gcp-agent-step-3')).toHaveTextContent(
|
||||
gcpConnectionCommands.cliCommand,
|
||||
);
|
||||
expect(screen.queryByTestId('gcp-agent-step-3-next')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets any step be expanded or collapsed directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
await screen.findByTestId('gcp-agent-commands');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-agent-step-3-toggle'));
|
||||
expect(screen.getByTestId('gcp-agent-step-3')).toHaveTextContent(
|
||||
gcpConnectionCommands.cliCommand,
|
||||
);
|
||||
expect(screen.getByTestId('gcp-agent-step-1')).toHaveTextContent(
|
||||
gcpConnectionCommands.createSecretsCommand,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('gcp-agent-step-1-toggle'));
|
||||
expect(screen.getByTestId('gcp-agent-step-1')).not.toHaveTextContent(
|
||||
gcpConnectionCommands.createSecretsCommand,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
const generateAgentCommands = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-commands-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
await screen.findByTestId('gcp-agent-commands');
|
||||
};
|
||||
|
||||
describe('GCP setup drawer close confirmation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes right away when no setup commands have been generated', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks before cancelling a pending agent setup and can continue it', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommands(user);
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
await screen.findByText(/start the installation process again/i);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('gcp-agent-commands')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the user confirms', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommands(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes without asking once the agent connects', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommands(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
@@ -17,24 +15,20 @@ import {
|
||||
GCP_CREDENTIALS_URL,
|
||||
} from './mockData';
|
||||
|
||||
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
|
||||
// `useManualAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept — so mock the resolved module directly.
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn();
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<TooltipProvider>
|
||||
<CloudAccountSetupDrawer onClose={onClose} />
|
||||
</TooltipProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
describe('GCP CloudAccountSetupDrawer', () => {
|
||||
@@ -49,7 +43,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
// check_in is registered first — it is a more specific path than
|
||||
// check_in is registered first, it is a more specific path than
|
||||
// /accounts and msw matches handlers in registration order.
|
||||
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
|
||||
checkInPayload = await req.json();
|
||||
@@ -109,6 +103,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
});
|
||||
|
||||
it('creates the account, checks the agent in, and closes the drawer', async () => {
|
||||
// A slow check-in keeps the connection in flight long enough to observe it.
|
||||
server.use(
|
||||
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
|
||||
checkInPayload = await req.json();
|
||||
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
@@ -135,6 +136,11 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
|
||||
// Switching away mid-request would let its success close the other form.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-connection-method-agent')).toBeDisabled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createAccountPayload).not.toBeNull();
|
||||
});
|
||||
@@ -142,6 +148,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
expect(createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'manual',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
projectIds: ['project-a', 'project-b'],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
describe('GCP setup drawer connection method switch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('switches between the manual and agent connection methods inside the same drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
|
||||
await screen.findByTestId('gcp-generate-commands-btn');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-connect-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-manual'));
|
||||
|
||||
await screen.findByTestId('gcp-account-name-input');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-commands-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The drawer itself stays mounted, so it does not replay its open animation.
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
});
|
||||
|
||||
it('keeps the drawer width the same across connection methods and setup steps', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-commands-btn')).toBeEnabled();
|
||||
});
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-commands-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-commands');
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
|
||||
import AgentSetup from '../AddNewAccount/AgentSetup/AgentSetup';
|
||||
import SetupDrawerLayout from '../AddNewAccount/SetupDrawerLayout';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CHECK_IN_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
|
||||
|
||||
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
|
||||
|
||||
export const onClose = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onClose']
|
||||
>;
|
||||
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onConnectionMethodChange']
|
||||
>;
|
||||
export const onCommandsGenerated = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCommandsGenerated']
|
||||
>;
|
||||
export const onCancel = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCancel']
|
||||
>;
|
||||
|
||||
export const renderAgentSetup = (): void => {
|
||||
render(
|
||||
<SetupDrawerLayout onClose={onClose}>
|
||||
{(footerSlot): JSX.Element => (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={onConnectionMethodChange}
|
||||
onCommandsGenerated={onCommandsGenerated}
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</SetupDrawerLayout>,
|
||||
);
|
||||
};
|
||||
|
||||
export const fillAgentForm = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-commands-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
};
|
||||
|
||||
export interface AgentSetupApiMock {
|
||||
readonly createAccountPayload: Record<string, unknown> | null;
|
||||
readonly checkInCalled: boolean;
|
||||
}
|
||||
|
||||
/** Registers the agent setup endpoints; the polled account stays pending. */
|
||||
export const mockAgentSetupApi = (): AgentSetupApiMock => {
|
||||
let createAccountPayload: Record<string, unknown> | null = null;
|
||||
let checkInCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
// check_in is registered first, it is a more specific path than
|
||||
// /accounts and msw matches handlers in registration order.
|
||||
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
|
||||
checkInCalled = true;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
|
||||
createAccountPayload = await req.json();
|
||||
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
|
||||
}),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
get createAccountPayload(): Record<string, unknown> | null {
|
||||
return createAccountPayload;
|
||||
},
|
||||
get checkInCalled(): boolean {
|
||||
return checkInCalled;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CloudAccount,
|
||||
GCPCloudAccountConfig,
|
||||
@@ -17,6 +20,7 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
|
||||
|
||||
export const gcpAccountConfig: GCPCloudAccountConfig = {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deployment_region: 'asia-south1',
|
||||
deployment_project_id: 'my-deployment-project-123',
|
||||
project_ids: ['project-a', 'project-b'],
|
||||
@@ -30,6 +34,19 @@ export const gcpAccount: CloudAccount = {
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const gcpAgentAccount: CloudAccount = {
|
||||
id: 'acc-gcp-agent-1',
|
||||
cloud_account_id: 'acc-gcp-agent-1',
|
||||
providerAccountId: 'my-agent-project',
|
||||
config: {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-agent-project',
|
||||
project_ids: ['my-agent-project'],
|
||||
},
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const listAccountsResponse = {
|
||||
status: 'success',
|
||||
data: { accounts: [] },
|
||||
@@ -63,3 +80,56 @@ export const checkInResponse = {
|
||||
status: 'success',
|
||||
data: {},
|
||||
};
|
||||
|
||||
export const gcpConnectionCommands = {
|
||||
createSecretsCommand:
|
||||
"gcloud secrets create 'signoz-integration-api-credentials'",
|
||||
createServiceAccountCommand:
|
||||
"gcloud iam service-accounts create 'signoz-integration-bootstrap'",
|
||||
cliCommand: 'gcloud infra-manager deployments apply',
|
||||
};
|
||||
|
||||
export const createAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
connectionArtifact: { gcp: gcpConnectionCommands },
|
||||
},
|
||||
};
|
||||
|
||||
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
|
||||
|
||||
const pendingAgentAccount = {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'asia-south1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const pendingAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: pendingAgentAccount,
|
||||
};
|
||||
|
||||
export const connectedAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
...pendingAgentAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport: {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Modal } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import {
|
||||
INTEGRATION_TELEMETRY_EVENTS,
|
||||
@@ -17,10 +18,12 @@ function RemoveIntegrationAccount({
|
||||
cloudProvider,
|
||||
accountId,
|
||||
onRemoveIntegrationAccountSuccess,
|
||||
gcpConnectionMethod,
|
||||
}: {
|
||||
cloudProvider: string;
|
||||
accountId: string;
|
||||
onRemoveIntegrationAccountSuccess: () => void;
|
||||
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -44,7 +47,7 @@ function RemoveIntegrationAccount({
|
||||
},
|
||||
});
|
||||
const handleOk = (): void => {
|
||||
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
accountId,
|
||||
integration: cloudProvider,
|
||||
});
|
||||
@@ -73,6 +76,20 @@ function RemoveIntegrationAccount({
|
||||
manually when connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (
|
||||
cloudProvider === INTEGRATION_TYPES.GCP &&
|
||||
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
|
||||
) {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will stop SigNoz from monitoring it, and the agent
|
||||
will disable the telemetry resources it created in your GCP project. <br />
|
||||
<br />
|
||||
After that, you can delete the signoz-integration-bootstrap Infrastructure
|
||||
Manager deployment, the signoz-integration-bootstrap service account and the
|
||||
signoz-integration secrets that were created while connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
|
||||
modalDescription = (
|
||||
<>
|
||||
@@ -128,4 +145,8 @@ function RemoveIntegrationAccount({
|
||||
);
|
||||
}
|
||||
|
||||
RemoveIntegrationAccount.defaultProps = {
|
||||
gcpConnectionMethod: undefined,
|
||||
};
|
||||
|
||||
export default RemoveIntegrationAccount;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
|
||||
|
||||
const gcpAccountDto = (
|
||||
connectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CloudintegrationtypesAccountDTO => ({
|
||||
id: 'acc-gcp-1',
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: 'my-project',
|
||||
removedAt: null,
|
||||
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod:
|
||||
connectionMethod as CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
deploymentProjectId: 'my-project',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-project'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('mapAccountDtoToGcpCloudAccount', () => {
|
||||
it('keeps the connection method of the account', () => {
|
||||
const account = mapAccountDtoToGcpCloudAccount(
|
||||
gcpAccountDto(CloudintegrationtypesGCPConnectionMethodDTO.agent),
|
||||
);
|
||||
|
||||
expect(account?.config).toStrictEqual({
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-project',
|
||||
project_ids: ['my-project'],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an account without a connection method as manual', () => {
|
||||
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto());
|
||||
|
||||
expect(account?.config).toHaveProperty(
|
||||
'connection_method',
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { CloudAccount as IntegrationCloudAccount } from 'container/Integrations/types';
|
||||
|
||||
import { CloudAccount as AwsCloudAccount } from './AmazonWebServices/types';
|
||||
@@ -59,6 +62,9 @@ export function mapAccountDtoToGcpCloudAccount(
|
||||
id: account.id,
|
||||
cloud_account_id: account.id,
|
||||
config: {
|
||||
connection_method:
|
||||
account.config?.gcp?.connectionMethod ??
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
|
||||
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
|
||||
project_ids: account.config?.gcp?.projectIds ?? [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AWSCloudAccountConfig,
|
||||
AWSServiceConfig,
|
||||
@@ -102,6 +103,7 @@ export interface AzureCloudAccountConfig {
|
||||
}
|
||||
|
||||
export interface GCPCloudAccountConfig {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
deployment_region: string;
|
||||
deployment_project_id: string;
|
||||
project_ids: string[];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
|
||||
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
|
||||
|
||||
export const onConnected = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onConnected']
|
||||
>;
|
||||
export const onTimeout = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onTimeout']
|
||||
>;
|
||||
export const onError = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onError']
|
||||
>;
|
||||
|
||||
export const callbacks = { onConnected, onTimeout, onError };
|
||||
|
||||
export const ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
|
||||
|
||||
const agentReport = {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
};
|
||||
|
||||
export const pendingAccount: CloudintegrationtypesAccountDTO = {
|
||||
id: ACCOUNT_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const connectedAccount: CloudintegrationtypesAccountDTO = {
|
||||
...pendingAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport,
|
||||
};
|
||||
|
||||
export const mockAccountResponses = (
|
||||
...accounts: CloudintegrationtypesAccountDTO[]
|
||||
): { requestCount: () => number } => {
|
||||
let count = 0;
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
|
||||
const account = accounts[Math.min(count, accounts.length - 1)];
|
||||
count += 1;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
|
||||
}),
|
||||
);
|
||||
return { requestCount: (): number => count };
|
||||
};
|
||||
|
||||
export const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
ACCOUNT_URL,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onError,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
describe('useAgentConnectionPolling outcomes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps polling until the agent checks in, then reports the connection', async () => {
|
||||
const { requestCount } = mockAccountResponses(
|
||||
pendingAccount,
|
||||
pendingAccount,
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
refetchInterval: 20,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalled());
|
||||
expect(requestCount()).toBeGreaterThanOrEqual(3);
|
||||
expect(onConnected).toHaveBeenCalledWith(connectedAccount);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat a removed account as connected', async () => {
|
||||
mockAccountResponses({
|
||||
...connectedAccount,
|
||||
removedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
timeoutMs: 0,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error when the account request fails', async () => {
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
sleep,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
describe('useAgentConnectionPolling polling window', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not poll while disabled', async () => {
|
||||
const { requestCount } = mockAccountResponses(connectedAccount);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: false,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await sleep(50);
|
||||
expect(requestCount()).toBe(0);
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not poll without an account id', async () => {
|
||||
const { requestCount } = mockAccountResponses(connectedAccount);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: null,
|
||||
enabled: true,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await sleep(50);
|
||||
expect(requestCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a timeout once the deadline passes without a check-in', async () => {
|
||||
mockAccountResponses(pendingAccount);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
timeoutMs: 0,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalledTimes(1));
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the timeout clock when polling is enabled, not on mount', async () => {
|
||||
const { requestCount } = mockAccountResponses(pendingAccount);
|
||||
const timeoutMs = 200;
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled,
|
||||
timeoutMs,
|
||||
refetchInterval: 60_000,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders, initialProps: { enabled: false } },
|
||||
);
|
||||
|
||||
await sleep(timeoutMs + 100);
|
||||
rerender({ enabled: true });
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
await sleep(20);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -87,8 +87,7 @@ export function useAccountSettingsDrawer({
|
||||
{
|
||||
onSuccess: () => {
|
||||
const nextConfig = {
|
||||
deployment_region: accountConfig.deployment_region,
|
||||
deployment_project_id: accountConfig.deployment_project_id,
|
||||
...accountConfig,
|
||||
project_ids: values.projectIds || [],
|
||||
};
|
||||
|
||||
|
||||
198
frontend/src/hooks/integration/gcp/useAgentAccountSetup.ts
Normal file
198
frontend/src/hooks/integration/gcp/useAgentAccountSetup.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
GetConnectionCredentialsQueryResult,
|
||||
invalidateListAccounts,
|
||||
useCreateAccount,
|
||||
useGetConnectionCredentials,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionArtifactDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
|
||||
|
||||
const GENERATE_COMMANDS_ERROR = 'Failed to generate GCP setup commands';
|
||||
|
||||
interface UseAgentAccountSetupProps {
|
||||
onClose: () => void;
|
||||
onCommandsGenerated: () => void;
|
||||
}
|
||||
|
||||
interface UseAgentAccountSetup {
|
||||
isLoading: boolean;
|
||||
generateCommands: (values: GcpSetupFormValues) => Promise<void>;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
submitError: string | null;
|
||||
clearSubmitError: () => void;
|
||||
connectionCommands: CloudintegrationtypesGCPConnectionArtifactDTO | null;
|
||||
/** The project the generated commands target, as submitted. */
|
||||
commandsProjectId: string;
|
||||
modalState: ModalStateEnum;
|
||||
checkConnectionAgain: () => void;
|
||||
}
|
||||
|
||||
export function useAgentAccountSetup({
|
||||
onClose,
|
||||
onCommandsGenerated,
|
||||
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [connectionCommands, setConnectionCommands] =
|
||||
useState<CloudintegrationtypesGCPConnectionArtifactDTO | null>(null);
|
||||
const [commandsProjectId, setCommandsProjectId] = useState('');
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [modalState, setModalState] = useState<ModalStateEnum>(
|
||||
ModalStateEnum.FORM,
|
||||
);
|
||||
|
||||
const clearSubmitError = useCallback((): void => {
|
||||
setSubmitError(null);
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createAccount } = useCreateAccount();
|
||||
const handleError = useAxiosError();
|
||||
|
||||
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
|
||||
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
|
||||
{
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
onError: handleError,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const generateCommands = useCallback(
|
||||
async (values: GcpSetupFormValues): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const payload: CloudintegrationtypesPostableAccountDTO = {
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
// The agent monitors only the project it is deployed in.
|
||||
projectIds: [values.deploymentProjectId],
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
|
||||
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
|
||||
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
|
||||
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await createAccount({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
const commands = response.data.connectionArtifact.gcp;
|
||||
if (!commands) {
|
||||
setSubmitError(GENERATE_COMMANDS_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountId(response.data.id);
|
||||
setCommandsProjectId(values.deploymentProjectId);
|
||||
setConnectionCommands(commands);
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
onCommandsGenerated();
|
||||
|
||||
void logEvent('GCP Integration: Account connection commands generated', {
|
||||
id: response.data.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
GENERATE_COMMANDS_ERROR,
|
||||
).getErrorMessage();
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[connectionParams, createAccount, onCommandsGenerated],
|
||||
);
|
||||
|
||||
const handleConnected = useCallback(
|
||||
(account: CloudintegrationtypesAccountDTO): void => {
|
||||
void logEvent('GCP Integration: Account connected', {
|
||||
cloudIntegrationId: account.id,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
toast.success('GCP account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, queryClient],
|
||||
);
|
||||
|
||||
const handleTimeout = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('GCP Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
const handlePollingError = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
}, []);
|
||||
|
||||
const checkConnectionAgain = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
accountId,
|
||||
enabled: modalState === ModalStateEnum.WAITING,
|
||||
onConnected: handleConnected,
|
||||
onTimeout: handleTimeout,
|
||||
onError: handlePollingError,
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
generateCommands,
|
||||
connectionParams: connectionParams?.data as
|
||||
| CloudintegrationtypesCredentialsDTO
|
||||
| undefined,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommands,
|
||||
commandsProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useGetAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
|
||||
const POLL_INTERVAL_MS = 10 * 1000;
|
||||
const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
interface UseAgentConnectionPollingProps {
|
||||
accountId: string | null;
|
||||
enabled: boolean;
|
||||
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
|
||||
onTimeout: () => void;
|
||||
onError: () => void;
|
||||
refetchInterval?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a GCP agent account until the agent checks in. The timeout is measured
|
||||
* from when polling gets enabled, so re-enabling it starts a fresh window.
|
||||
*/
|
||||
export function useAgentConnectionPolling({
|
||||
accountId,
|
||||
enabled,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
onError,
|
||||
refetchInterval = POLL_INTERVAL_MS,
|
||||
timeoutMs = CONNECTION_TIMEOUT_MS,
|
||||
}: UseAgentConnectionPollingProps): void {
|
||||
const isPolling = enabled && Boolean(accountId);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
startTimeRef.current = isPolling ? Date.now() : null;
|
||||
}, [isPolling]);
|
||||
|
||||
useGetAccount(
|
||||
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId ?? '' },
|
||||
{
|
||||
query: {
|
||||
enabled: isPolling,
|
||||
refetchInterval,
|
||||
select: (response): CloudintegrationtypesAccountDTO => response.data,
|
||||
onSuccess: (account) => {
|
||||
const isConnected =
|
||||
Boolean(account.providerAccountId) && account.removedAt === null;
|
||||
|
||||
if (isConnected) {
|
||||
onConnected(account);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = startTimeRef.current ?? Date.now();
|
||||
if (Date.now() - startTime >= timeoutMs) {
|
||||
onTimeout();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onError();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -22,11 +23,11 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
|
||||
interface UseCloudAccountSetupDrawerProps {
|
||||
interface UseManualAccountSetupProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseCloudAccountSetupDrawer {
|
||||
interface UseManualAccountSetup {
|
||||
isLoading: boolean;
|
||||
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
@@ -36,9 +37,9 @@ interface UseCloudAccountSetupDrawer {
|
||||
clearSubmitError: () => void;
|
||||
}
|
||||
|
||||
export function useCloudAccountSetupDrawer({
|
||||
export function useManualAccountSetup({
|
||||
onClose,
|
||||
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
|
||||
}: UseManualAccountSetupProps): UseManualAccountSetup {
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
@@ -96,13 +97,14 @@ export function useCloudAccountSetupDrawer({
|
||||
const payload: CloudintegrationtypesPostableAccountDTO = {
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
projectIds: values.projectIds || [],
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
// Cloud users can't edit these — the backend-provided credentials are
|
||||
// Cloud users can't edit these, the backend-provided credentials are
|
||||
// authoritative. Enterprise users have no backend defaults and enter
|
||||
// their own (validated non-empty), so their form values are used.
|
||||
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
|
||||
@@ -3,6 +3,7 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
CloudintegrationtypesAccountConfigDTO,
|
||||
CloudintegrationtypesAccountDTO,
|
||||
@@ -282,6 +283,7 @@ const accountConfig = (
|
||||
|
||||
return {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deploymentRegion: 'us-central1',
|
||||
deploymentProjectId: 'signoz-prod-4471',
|
||||
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
|
||||
@@ -327,6 +329,20 @@ export const accountResponse = (
|
||||
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
|
||||
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
|
||||
|
||||
// A freshly created GCP agent account stays pending, so the setup drawer keeps
|
||||
// showing its commands and waiting state instead of closing on the first poll.
|
||||
if (provider === 'gcp' && id === `${provider}-account-new`) {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
...account(provider, index),
|
||||
id,
|
||||
providerAccountId: null,
|
||||
agentReport: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'success', data: account(provider, index) };
|
||||
};
|
||||
|
||||
@@ -559,6 +575,14 @@ export const createdAccountResponse = (
|
||||
cloudPowerShellCommand:
|
||||
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
|
||||
},
|
||||
gcp: {
|
||||
createSecretsCommand:
|
||||
"gcloud secrets create 'signoz-integration-api-credentials' --project='signoz-prod-4471'",
|
||||
createServiceAccountCommand:
|
||||
"gcloud iam service-accounts create 'signoz-integration-bootstrap' --project='signoz-prod-4471'",
|
||||
cliCommand:
|
||||
"gcloud infra-manager deployments apply 'projects/signoz-prod-4471/locations/us-central1/deployments/signoz-integration-bootstrap'",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,7 +121,33 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"cloudsql.googleapis.com/database/up",
|
||||
"cloudsql.googleapis.com/database/instance_state",
|
||||
"cloudsql.googleapis.com/database/replication/replica_lag",
|
||||
"cloudsql.googleapis.com/database/cpu/utilization",
|
||||
"cloudsql.googleapis.com/database/memory/utilization",
|
||||
"cloudsql.googleapis.com/database/disk/utilization",
|
||||
"cloudsql.googleapis.com/database/network/connections",
|
||||
"cloudsql.googleapis.com/database/mysql/queries",
|
||||
"cloudsql.googleapis.com/database/mysql/dml_operations_count",
|
||||
"cloudsql.googleapis.com/database/mysql/threads",
|
||||
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_reads_count",
|
||||
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_read_requests_count",
|
||||
"cloudsql.googleapis.com/database/mysql/slow_queries_count",
|
||||
"cloudsql.googleapis.com/database/mysql/aborted_connects_count",
|
||||
"cloudsql.googleapis.com/database/mysql/innodb/deadlocks_count",
|
||||
"cloudsql.googleapis.com/database/mysql/innodb/row_lock_waits_count",
|
||||
"cloudsql.googleapis.com/database/disk/read_ops_count",
|
||||
"cloudsql.googleapis.com/database/disk/write_ops_count"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fmysql\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.MysqlAuditEntry\"))"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
@@ -75,6 +75,12 @@
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
|
||||
"unit": "Microseconds",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
|
||||
"unit": "Microseconds",
|
||||
@@ -91,7 +97,29 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"cloudsql.googleapis.com/database/up",
|
||||
"cloudsql.googleapis.com/database/cpu/utilization",
|
||||
"cloudsql.googleapis.com/database/memory/utilization",
|
||||
"cloudsql.googleapis.com/database/memory/usage",
|
||||
"cloudsql.googleapis.com/database/disk/bytes_used",
|
||||
"cloudsql.googleapis.com/database/postgresql/num_backends",
|
||||
"cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
|
||||
"cloudsql.googleapis.com/database/postgresql/transaction_count",
|
||||
"cloudsql.googleapis.com/database/postgresql/deadlock_count",
|
||||
"cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
|
||||
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
|
||||
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
|
||||
"cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
|
||||
"cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fpostgres\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry\"))"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
@@ -55,7 +55,22 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"storage.googleapis.com/storage/v2/total_bytes",
|
||||
"storage.googleapis.com/storage/v2/total_count",
|
||||
"storage.googleapis.com/storage/v2/deleted_bytes",
|
||||
"storage.googleapis.com/api/request_count",
|
||||
"storage.googleapis.com/network/received_bytes_count",
|
||||
"storage.googleapis.com/network/sent_bytes_count",
|
||||
"storage.googleapis.com/authn/authentication_count"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=\"gcs_bucket\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
@@ -109,7 +109,31 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"compute.googleapis.com/instance/uptime_total",
|
||||
"compute.googleapis.com/instance/cpu/utilization",
|
||||
"compute.googleapis.com/guest/memory/bytes_used",
|
||||
"compute.googleapis.com/guest/memory/percent_used",
|
||||
"agent.googleapis.com/memory/percent_used",
|
||||
"compute.googleapis.com/instance/disk/average_io_latency",
|
||||
"compute.googleapis.com/instance/disk/read_bytes_count",
|
||||
"compute.googleapis.com/instance/disk/write_bytes_count",
|
||||
"compute.googleapis.com/instance/disk/read_ops_count",
|
||||
"compute.googleapis.com/instance/disk/write_ops_count",
|
||||
"compute.googleapis.com/instance/disk/performance_status",
|
||||
"compute.googleapis.com/instance/network/received_bytes_count",
|
||||
"compute.googleapis.com/instance/network/sent_bytes_count",
|
||||
"compute.googleapis.com/instance/network/received_packets_count",
|
||||
"compute.googleapis.com/instance/network/sent_packets_count",
|
||||
"compute.googleapis.com/firewall/dropped_packets_count"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=\"gce_instance\" OR protoPayload.serviceName=\"compute.googleapis.com\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
@@ -109,7 +109,31 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"kubernetes.io/container/cpu/limit_utilization",
|
||||
"kubernetes.io/container/cpu/request_utilization",
|
||||
"kubernetes.io/container/memory/limit_utilization",
|
||||
"kubernetes.io/container/memory/request_utilization",
|
||||
"kubernetes.io/container/restart_count",
|
||||
"kubernetes.io/pod/latencies/pod_first_ready",
|
||||
"kubernetes.io/pod/network/received_bytes_count",
|
||||
"kubernetes.io/pod/network/sent_bytes_count",
|
||||
"kubernetes.io/pod/volume/utilization",
|
||||
"kubernetes.io/node/cpu/allocatable_utilization",
|
||||
"kubernetes.io/node/memory/allocatable_utilization",
|
||||
"kubernetes.io/node/ephemeral_storage/used_bytes",
|
||||
"kubernetes.io/node/ephemeral_storage/allocatable_bytes",
|
||||
"kubernetes.io/node/network/received_bytes_count",
|
||||
"kubernetes.io/node/network/sent_bytes_count",
|
||||
"kubernetes.io/node/status_condition"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=(\"k8s_container\" OR \"k8s_pod\" OR \"k8s_node\" OR \"k8s_cluster\" OR \"k8s_control_plane_component\" OR \"gke_cluster\" OR \"gke_nodepool\")"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
@@ -79,7 +79,26 @@
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
"gcp": {
|
||||
"metrics": {
|
||||
"metricNames": [
|
||||
"redis.googleapis.com/server/uptime",
|
||||
"redis.googleapis.com/stats/memory/usage_ratio",
|
||||
"redis.googleapis.com/stats/memory/system_memory_usage_ratio",
|
||||
"redis.googleapis.com/stats/cpu_utilization_main_thread",
|
||||
"redis.googleapis.com/clients/connected",
|
||||
"redis.googleapis.com/stats/cache_hit_ratio",
|
||||
"redis.googleapis.com/keyspace/keys",
|
||||
"redis.googleapis.com/commands/usec_per_call",
|
||||
"redis.googleapis.com/commands/calls",
|
||||
"redis.googleapis.com/clients/blocked",
|
||||
"redis.googleapis.com/stats/reject_connections_count"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"sinkFilter": "resource.type=\"redis_instance\" OR (protoPayload.serviceName=\"redis.googleapis.com\" AND protoPayload.methodName:\".CloudRedis.\") OR (log_id(\"maintenance.googleapis.com/activity_log\") AND labels.activity_type_name:\"CloudRedisRollout\")"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
|
||||
@@ -262,6 +262,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
85
pkg/sqlmigration/136_add_gcp_connection_method.go
Normal file
85
pkg/sqlmigration/136_add_gcp_connection_method.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addGCPConnectionMethod struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type gcpConnectionMethodMigrationRow struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Config string `bun:"config"`
|
||||
}
|
||||
|
||||
func NewAddGCPConnectionMethodFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_gcp_connection_method"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addGCPConnectionMethod{logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var accounts []*gcpConnectionMethodMigrationRow
|
||||
if err := tx.NewSelect().
|
||||
Model(&accounts).
|
||||
Where("provider = ?", "gcp").
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
// Preserve config fields that are not known to this migration.
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(account.Config), &config); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with unreadable config", slog.String("account_id", account.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if config == nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with null config", slog.String("account_id", account.ID))
|
||||
continue
|
||||
}
|
||||
if _, exists := config["connectionMethod"]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
config["connectionMethod"] = json.RawMessage(`"manual"`)
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*gcpConnectionMethodMigrationRow)(nil)).
|
||||
Set("config = ?", string(encoded)).
|
||||
Where("id = ?", account.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
}
|
||||
account.Config.Azure = azureConfig
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := new(GCPAccountConfig)
|
||||
gcpConfig := &GCPAccountConfig{ConnectionMethod: GCPConnectionMethodManual}
|
||||
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,25 +240,16 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
|
||||
}
|
||||
|
||||
if config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
gcpConfig := &GCPAccountConfig{
|
||||
ConnectionMethod: config.GCP.ConnectionMethod,
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
}
|
||||
|
||||
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
|
||||
if err := gcpConfig.ErrIfInvalid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
GCP: &GCPAccountConfig{
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
},
|
||||
}, nil
|
||||
return &AccountConfig{GCP: gcpConfig}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -301,12 +292,12 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(config.Config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
if err := validateGCPProjectIDs(config.Config.GCP.ProjectIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.Config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
if err := validateGCPDeploymentProjectID(config.Config.GCP.DeploymentProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
@@ -355,6 +346,13 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
|
||||
}
|
||||
|
||||
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
|
||||
if provider == CloudProviderTypeGCP {
|
||||
if account.Config.GCP.ConnectionMethod == GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP agent account configuration cannot be updated")
|
||||
}
|
||||
config.GCP.ConnectionMethod = account.Config.GCP.ConnectionMethod
|
||||
}
|
||||
|
||||
// deployment region can not be updated once set for Azure
|
||||
if provider == CloudProviderTypeAzure {
|
||||
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
|
||||
@@ -366,6 +364,18 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) ErrIfInvalidProviderAccountID(providerAccountID string) error {
|
||||
if account.Provider != CloudProviderTypeGCP || account.Config.GCP.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if providerAccountID != account.Config.GCP.DeploymentProjectID {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "providerAccountId must match deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
|
||||
account.ProviderAccountID = providerAccountID
|
||||
account.AgentReport = agentReport
|
||||
|
||||
@@ -1,30 +1,96 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
|
||||
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
|
||||
|
||||
// TODO: Confirm the published bootstrap module location before release.
|
||||
AgentGCPTerraformModuleStorePath = "gs://signoz-cloud-integration/gcp/%s/bootstrap/"
|
||||
AgentGCPBootstrapDeploymentName = "signoz-integration-bootstrap"
|
||||
gcpBootstrapExecutorName = "signoz-integration-bootstrap"
|
||||
gcpAPISecretID = "signoz-integration-api-credentials"
|
||||
gcpIngestionSecretID = "signoz-integration-ingestion-key-credential"
|
||||
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}).Parse(gcpCLITemplateStr()))
|
||||
gcpCreateServiceAccountTemplate = template.Must(gcpCLITemplate.New("gcpCreateServiceAccount").Parse(gcpCreateServiceAccountTemplateStr()))
|
||||
gcpCreateSecretsTemplate = template.Must(gcpCLITemplate.New("gcpCreateSecrets").Parse(gcpCreateSecretsTemplateStr()))
|
||||
)
|
||||
|
||||
type GCPConnectionMethod struct{ valuer.String }
|
||||
|
||||
func (GCPConnectionMethod) Enum() []any {
|
||||
return []any{GCPConnectionMethodManual, GCPConnectionMethodAgent}
|
||||
}
|
||||
|
||||
type GCPAccountConfig struct {
|
||||
// Project ID where central pub/sub for logs exist
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
// DeploymentProjectID is the project hosting the integration resources.
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Project ID where otel collector will be deployed
|
||||
// DeploymentRegion is the region used for the integration resources.
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
// Agent accounts store exactly the deployment project; manual accounts may monitor multiple projects.
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPPostableAccountConfig = GCPAccountConfig
|
||||
|
||||
type UpdatableGCPAccountConfig struct {
|
||||
// Project ID where central pub/sub for logs exist
|
||||
type GCPPostableAccountConfig struct {
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
// DeploymentProjectID is the project hosting the integration resources.
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Compute service region where otel collector will be deployed
|
||||
// DeploymentRegion is the region used for the integration resources.
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true"`
|
||||
// Agent setup requires exactly DeploymentProjectID; manual setup may select multiple projects.
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPConnectionArtifact struct{}
|
||||
// UpdatableGCPAccountConfig contains settings for manual integrations only.
|
||||
type UpdatableGCPAccountConfig struct {
|
||||
// DeploymentProjectID is the project hosting the manually configured integration resources.
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// DeploymentRegion is the region used for the manually configured integration resources.
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// ProjectIDs lists the projects monitored by the manual integration.
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPIntegrationConfig struct{}
|
||||
type GCPConnectionArtifact struct {
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
CreateSecretsCommand string `json:"createSecretsCommand" required:"true"`
|
||||
CreateServiceAccountCommand string `json:"createServiceAccountCommand" required:"true"`
|
||||
}
|
||||
|
||||
type GCPTelemetryCollectionStrategy struct{}
|
||||
type GCPIntegrationConfig struct {
|
||||
GCPAccountConfig
|
||||
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPTelemetryCollectionStrategy struct {
|
||||
Metrics *GCPMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
|
||||
Logs *GCPLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPMetricsCollectionStrategy struct {
|
||||
MetricNames []string `json:"metricNames" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPLogsCollectionStrategy struct {
|
||||
SinkFilter string `json:"sinkFilter" required:"true"`
|
||||
}
|
||||
|
||||
type GCPServiceConfig struct {
|
||||
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`
|
||||
@@ -38,3 +104,224 @@ type GCPServiceLogsConfig struct {
|
||||
type GCPServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
func (config *GCPAccountConfig) ErrIfInvalid() error {
|
||||
if config.ConnectionMethod.IsZero() {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "connectionMethod is required for GCP provider")
|
||||
}
|
||||
if config.ConnectionMethod != GCPConnectionMethodManual && config.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "unsupported GCP connectionMethod: %q", config.ConnectionMethod.StringValue())
|
||||
}
|
||||
if err := validateGCPDeploymentProjectID(config.DeploymentProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPRegion(config.DeploymentRegion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPProjectIDs(config.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.ConnectionMethod == GCPConnectionMethodAgent && (len(config.ProjectIDs) != 1 || config.ProjectIDs[0] != config.DeploymentProjectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain only deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewGCPConnectionArtifact(accountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
|
||||
apiSecretJSON, err := json.Marshal(map[string]string{"api_key": creds.SigNozAPIKey})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executorEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", gcpBootstrapExecutorName, cfg.DeploymentProjectID)
|
||||
data := gcpTemplateData{
|
||||
DeploymentName: fmt.Sprintf("projects/%s/locations/%s/deployments/%s", cfg.DeploymentProjectID, cfg.DeploymentRegion, AgentGCPBootstrapDeploymentName),
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
Region: cfg.DeploymentRegion,
|
||||
ServiceAccount: fmt.Sprintf("projects/%s/serviceAccounts/%s", cfg.DeploymentProjectID, executorEmail),
|
||||
ExecutorName: gcpBootstrapExecutorName,
|
||||
ExecutorEmail: executorEmail,
|
||||
APISecretID: gcpAPISecretID,
|
||||
IngestionSecretID: gcpIngestionSecretID,
|
||||
APISecretJSON: string(apiSecretJSON),
|
||||
IngestionKey: creds.IngestionKey,
|
||||
ModuleURL: fmt.Sprintf(AgentGCPTerraformModuleStorePath, agentVersion),
|
||||
InputValues: fmt.Sprintf(
|
||||
"project_id=%s,deployment_region=%s,integration_account_id=%s,signoz_api_url=%s,ingestion_url=%s,"+
|
||||
"api_secret_id=%s,ingestion_secret_id=%s",
|
||||
cfg.DeploymentProjectID, cfg.DeploymentRegion, accountID.StringValue(), creds.SigNozAPIURL, creds.IngestionURL, gcpAPISecretID, gcpIngestionSecretID,
|
||||
),
|
||||
}
|
||||
|
||||
cliCommand, err := newGCPConnectionCommand(gcpCLITemplate, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createServiceAccountCommand, err := newGCPConnectionCommand(gcpCreateServiceAccountTemplate, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createSecretsCommand, err := newGCPConnectionCommand(gcpCreateSecretsTemplate, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GCPConnectionArtifact{
|
||||
CLICommand: cliCommand,
|
||||
CreateServiceAccountCommand: createServiceAccountCommand,
|
||||
CreateSecretsCommand: createSecretsCommand,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGCPIntegrationConfig sorts and deduplicates the input slices in place and formats sink filters for joining with OR.
|
||||
func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters []string) *GCPIntegrationConfig {
|
||||
// Parentheses preserve each service filter's meaning when combined with OR.
|
||||
for i, filter := range sinkFilters {
|
||||
sinkFilters[i] = "(" + strings.TrimSpace(filter) + ")"
|
||||
}
|
||||
|
||||
// Keep the generated config stable and avoid collecting shared metrics more than once.
|
||||
slices.Sort(metricNames)
|
||||
metricNames = slices.Compact(metricNames)
|
||||
slices.Sort(sinkFilters)
|
||||
sinkFilters = slices.Compact(sinkFilters)
|
||||
|
||||
strategy := new(GCPTelemetryCollectionStrategy)
|
||||
if len(metricNames) > 0 {
|
||||
strategy.Metrics = &GCPMetricsCollectionStrategy{MetricNames: metricNames}
|
||||
}
|
||||
if len(sinkFilters) > 0 {
|
||||
strategy.Logs = &GCPLogsCollectionStrategy{SinkFilter: strings.Join(sinkFilters, " OR ")}
|
||||
}
|
||||
|
||||
return &GCPIntegrationConfig{
|
||||
GCPAccountConfig: *config,
|
||||
TelemetryCollectionStrategy: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
type gcpTemplateData struct {
|
||||
DeploymentName string
|
||||
ProjectID string
|
||||
Region string
|
||||
ServiceAccount string
|
||||
ExecutorName string
|
||||
ExecutorEmail string
|
||||
APISecretID string
|
||||
IngestionSecretID string
|
||||
APISecretJSON string
|
||||
IngestionKey string
|
||||
ModuleURL string
|
||||
InputValues string
|
||||
}
|
||||
|
||||
func newGCPConnectionCommand(commandTemplate *template.Template, data gcpTemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := commandTemplate.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func gcpCLITemplateStr() string {
|
||||
return `gcloud infra-manager deployments apply \
|
||||
{{.DeploymentName | shellQuote}} \
|
||||
--project={{.ProjectID | shellQuote}} \
|
||||
--service-account={{.ServiceAccount | shellQuote}} \
|
||||
--gcs-source={{.ModuleURL | shellQuote}} \
|
||||
--tf-version-constraint='=1.5.7' \
|
||||
--input-values={{.InputValues | shellQuote}},api_secret_version="${api_secret_version:-latest}",ingestion_secret_version="${ingestion_secret_version:-latest}"`
|
||||
}
|
||||
|
||||
func gcpCreateServiceAccountTemplateStr() string {
|
||||
return `project_id={{.ProjectID | shellQuote}}
|
||||
executor_email={{.ExecutorEmail | shellQuote}}
|
||||
api_secret_id={{.APISecretID | shellQuote}}
|
||||
ingestion_secret_id={{.IngestionSecretID | shellQuote}}
|
||||
caller_email=$(gcloud config get-value account)
|
||||
|
||||
gcloud iam service-accounts create {{.ExecutorName | shellQuote}} \
|
||||
--project="$project_id" --display-name="SigNoz bootstrap executor"
|
||||
|
||||
for executor_role in \
|
||||
roles/config.agent \
|
||||
roles/serviceusage.serviceUsageAdmin \
|
||||
roles/resourcemanager.projectIamAdmin \
|
||||
roles/iam.serviceAccountAdmin \
|
||||
roles/iam.serviceAccountUser \
|
||||
roles/run.admin \
|
||||
roles/pubsub.admin \
|
||||
roles/logging.configWriter
|
||||
do
|
||||
gcloud projects add-iam-policy-binding "$project_id" \
|
||||
--member="serviceAccount:${executor_email}" --role="$executor_role" \
|
||||
--condition=None
|
||||
done
|
||||
|
||||
for secret_id in "$api_secret_id" "$ingestion_secret_id"
|
||||
do
|
||||
gcloud secrets add-iam-policy-binding "$secret_id" \
|
||||
--project="$project_id" --member="serviceAccount:${executor_email}" \
|
||||
--role=roles/secretmanager.admin --condition=None
|
||||
done
|
||||
|
||||
gcloud iam service-accounts add-iam-policy-binding "$executor_email" \
|
||||
--project="$project_id" --member="user:${caller_email}" \
|
||||
--role=roles/iam.serviceAccountUser --condition=None`
|
||||
}
|
||||
|
||||
func gcpCreateSecretsTemplateStr() string {
|
||||
return `project_id={{.ProjectID | shellQuote}}
|
||||
region={{.Region | shellQuote}}
|
||||
api_secret_id={{.APISecretID | shellQuote}}
|
||||
ingestion_secret_id={{.IngestionSecretID | shellQuote}}
|
||||
|
||||
gcloud services enable config.googleapis.com cloudbuild.googleapis.com \
|
||||
storage.googleapis.com iam.googleapis.com serviceusage.googleapis.com \
|
||||
cloudresourcemanager.googleapis.com secretmanager.googleapis.com \
|
||||
--project="$project_id" &&
|
||||
sleep 60 &&
|
||||
|
||||
gcloud secrets create "$api_secret_id" \
|
||||
--project="$project_id" --replication-policy=user-managed \
|
||||
--locations="$region" --labels=managed-by=signoz &&
|
||||
|
||||
gcloud secrets create "$ingestion_secret_id" \
|
||||
--project="$project_id" --replication-policy=user-managed \
|
||||
--locations="$region" --labels=managed-by=signoz &&
|
||||
|
||||
api_secret_version=$(printf '%s' {{.APISecretJSON | shellQuote}} | \
|
||||
gcloud secrets versions add "$api_secret_id" \
|
||||
--project="$project_id" --data-file=- --format='value(name.basename())') &&
|
||||
|
||||
ingestion_secret_version=$(printf '%s' {{.IngestionKey | shellQuote}} | \
|
||||
gcloud secrets versions add "$ingestion_secret_id" \
|
||||
--project="$project_id" --data-file=- --format='value(name.basename())')`
|
||||
}
|
||||
|
||||
func validateGCPDeploymentProjectID(projectID string) error {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId is required for GCP provider")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId cannot contain leading or trailing whitespace")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGCPProjectIDs(projectIDs []string) error {
|
||||
if len(projectIDs) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain at least one project ID for GCP provider")
|
||||
}
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain an empty or whitespace-only project ID")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain leading or trailing whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
3
tests/fixtures/cloudintegrations.py
vendored
3
tests/fixtures/cloudintegrations.py
vendored
@@ -49,8 +49,7 @@ class ProviderAccountSpec:
|
||||
class ProviderServiceSpec:
|
||||
provider: str
|
||||
service_id: str
|
||||
# GCP ships every service with supportedSignals.logs false, so a logs block
|
||||
# is neither required on write nor persisted.
|
||||
# Whether writes require a logs block and reads include it.
|
||||
supports_logs: bool
|
||||
account_config: dict
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
|
||||
@@ -6,7 +6,7 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import ProviderAccountSpec
|
||||
from fixtures.cloudintegrations import ProviderAccountSpec, simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -27,12 +27,14 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -91,6 +93,142 @@ def test_create_account(
|
||||
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
|
||||
|
||||
|
||||
def test_create_gcp_manual_account_connection_method(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Manual accounts retain the explicitly selected projects."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
config = {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-test-project", "signoz-test-project-2"],
|
||||
}
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config={"gcp": config})
|
||||
assert account["connectionArtifact"].get("gcp") is None
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == config
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account["id"], "manual-account")
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
assert checkin.json()["data"]["integrationConfig"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_changes,omitted_field,error_field",
|
||||
[
|
||||
pytest.param({}, "connectionMethod", "connectionMethod", id="missing_connection_method"),
|
||||
pytest.param({"connectionMethod": None}, None, "connectionMethod", id="null_connection_method"),
|
||||
pytest.param({"connectionMethod": ""}, None, "connectionMethod", id="empty_connection_method"),
|
||||
pytest.param({"connectionMethod": " "}, None, "connectionMethod", id="blank_connection_method"),
|
||||
pytest.param({"connectionMethod": "unsupported"}, None, "connectionMethod", id="unsupported_connection_method"),
|
||||
*[pytest.param({"connectionMethod": method}, "projectIds", "projectIds", id=f"{method}_missing_project_ids") for method in ("manual", "agent")],
|
||||
*[
|
||||
pytest.param({"connectionMethod": method, "projectIds": project_ids}, None, "projectIds", id=f"{method}_{case}")
|
||||
for method in ("manual", "agent")
|
||||
for case, project_ids in (
|
||||
("null_project_ids", None),
|
||||
("empty_project_ids", []),
|
||||
("empty_project_id", [""]),
|
||||
("blank_project_id", [" "]),
|
||||
("padded_project_id", [" signoz-validation-project "]),
|
||||
)
|
||||
],
|
||||
pytest.param({"connectionMethod": "agent", "projectIds": ["signoz-other-project"]}, None, "projectIds", id="agent_mismatched_project"),
|
||||
pytest.param({"connectionMethod": "agent", "projectIds": ["signoz-validation-project", "signoz-other-project"]}, None, "projectIds", id="agent_multiple_projects"),
|
||||
pytest.param({"deploymentProjectId": " signoz-validation-project "}, None, "deploymentProjectId", id="padded_deployment_project"),
|
||||
],
|
||||
)
|
||||
def test_create_gcp_account_rejects_invalid_config(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
config_changes: dict,
|
||||
omitted_field: str | None,
|
||||
error_field: str,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
config = {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-validation-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-validation-project"],
|
||||
} | config_changes
|
||||
if omitted_field is not None:
|
||||
del config[omitted_field]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {"gcp": config},
|
||||
"credentials": {
|
||||
"sigNozApiUrl": "https://test-deployment.test.signoz.cloud",
|
||||
"sigNozApiKey": "test-api-key-789",
|
||||
"ingestionUrl": "https://ingest.test.signoz.cloud",
|
||||
"ingestionKey": "test-ingestion-key-123456",
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert error_field in response.text
|
||||
|
||||
|
||||
def test_create_gcp_agent_account_connection_command(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
"gcp",
|
||||
config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": "signoz-agent-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-agent-project"]}},
|
||||
)
|
||||
artifact = account["connectionArtifact"]["gcp"]
|
||||
service_account_command = artifact["createServiceAccountCommand"]
|
||||
secrets_command = artifact["createSecretsCommand"]
|
||||
deploy_command = artifact["cliCommand"]
|
||||
|
||||
assert "gcloud iam service-accounts create 'signoz-integration-bootstrap'" in service_account_command
|
||||
assert "executor_email='signoz-integration-bootstrap@signoz-agent-project.iam.gserviceaccount.com'" in service_account_command
|
||||
assert "roles/config.agent" in service_account_command
|
||||
assert "--role=roles/secretmanager.admin" in service_account_command
|
||||
|
||||
for prerequisite_command in (service_account_command, secrets_command):
|
||||
assert "project_id='signoz-agent-project'" in prerequisite_command
|
||||
assert "api_secret_id='signoz-integration-api-credentials'" in prerequisite_command
|
||||
assert "ingestion_secret_id='signoz-integration-ingestion-key-credential'" in prerequisite_command
|
||||
|
||||
assert "region='us-central1'" in secrets_command
|
||||
assert 'gcloud secrets create "$api_secret_id"' in secrets_command
|
||||
assert 'gcloud secrets create "$ingestion_secret_id"' in secrets_command
|
||||
assert "--replication-policy=user-managed" in secrets_command
|
||||
assert secrets_command.count("--labels=managed-by=signoz &&") == 2
|
||||
assert """printf '%s' '{"api_key":"test-api-key-789"}'""" in secrets_command
|
||||
assert "printf '%s' 'test-ingestion-key-123456'" in secrets_command
|
||||
|
||||
assert "gcloud infra-manager deployments apply" in deploy_command
|
||||
assert "projects/signoz-agent-project/locations/us-central1/deployments/signoz-integration-bootstrap" in deploy_command
|
||||
assert "--gcs-source='gs://signoz-cloud-integration/gcp/v0.0.8/bootstrap/'" in deploy_command
|
||||
assert "project_id=signoz-agent-project,deployment_region=us-central1" in deploy_command
|
||||
assert f"integration_account_id={account['id']}" in deploy_command
|
||||
assert "api_secret_id=signoz-integration-api-credentials" in deploy_command
|
||||
assert "ingestion_secret_id=signoz-integration-ingestion-key-credential" in deploy_command
|
||||
assert 'api_secret_version="${api_secret_version:-latest}"' in deploy_command
|
||||
assert 'ingestion_secret_version="${ingestion_secret_version:-latest}"' in deploy_command
|
||||
assert "test-api-key-789" not in deploy_command
|
||||
assert "test-ingestion-key-123456" not in deploy_command
|
||||
|
||||
|
||||
def test_create_account_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -135,6 +273,7 @@ def test_create_gcp_account_without_project_ids(
|
||||
json={
|
||||
"config": {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [],
|
||||
|
||||
@@ -31,12 +31,14 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -51,6 +53,25 @@ provider_spec = pytest.mark.parametrize(
|
||||
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
|
||||
)
|
||||
|
||||
provider_account_reuse = pytest.mark.parametrize(
|
||||
"provider,account_config,provider_account_id",
|
||||
[
|
||||
pytest.param("aws", {"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}}, "123456789012", id="aws"),
|
||||
pytest.param(
|
||||
"gcp",
|
||||
{"gcp": {"connectionMethod": "manual", "deploymentProjectId": "signoz-reuse-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-reuse-project"]}},
|
||||
"reused-manual-account",
|
||||
id="gcp_manual",
|
||||
),
|
||||
pytest.param(
|
||||
"gcp",
|
||||
{"gcp": {"connectionMethod": "agent", "deploymentProjectId": "signoz-reuse-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-reuse-project"]}},
|
||||
"signoz-reuse-project",
|
||||
id="gcp_agent",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
@@ -155,6 +176,201 @@ def test_duplicate_cloud_account_checkins(
|
||||
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_gcp_agent_check_in_returns_collection_strategy(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
project_id = "signoz-agent-strategy-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
services_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services")
|
||||
mysql_sink_filter = '(resource.type="cloudsql_database" AND (logName:"/logs/cloudsql.googleapis.com%2Fmysql" OR (log_id("cloudaudit.googleapis.com/data_access") AND protoPayload.request.@type="type.googleapis.com/google.cloud.sql.audit.v1.MysqlAuditEntry")))'
|
||||
postgres_sink_filter = '(resource.type="cloudsql_database" AND (logName:"/logs/cloudsql.googleapis.com%2Fpostgres" OR (log_id("cloudaudit.googleapis.com/data_access") AND protoPayload.request.@type="type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry")))'
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["integrationConfig"]["gcp"] == {
|
||||
"connectionMethod": "agent",
|
||||
"deploymentProjectId": project_id,
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [project_id],
|
||||
"telemetryCollectionStrategy": {},
|
||||
}
|
||||
|
||||
for service_id, service_config in (
|
||||
("cloudsql_mysql", {"metrics": {"enabled": True}, "logs": {"enabled": False}}),
|
||||
("cloudsql_postgres", {"metrics": {"enabled": True}, "logs": {"enabled": True}}),
|
||||
):
|
||||
response = requests.put(f"{services_url}/{service_id}", headers=headers, json={"config": {"gcp": service_config}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
strategy = response.json()["data"]["integrationConfig"]["gcp"]["telemetryCollectionStrategy"]
|
||||
metric_names = strategy["metrics"]["metricNames"]
|
||||
assert metric_names == sorted(set(metric_names)), "metrics shared by MySQL and Postgres should be listed once, sorted"
|
||||
assert {"cloudsql.googleapis.com/database/up", "cloudsql.googleapis.com/database/mysql/queries", "cloudsql.googleapis.com/database/postgresql/num_backends"} <= set(metric_names)
|
||||
assert strategy["logs"] == {"sinkFilter": postgres_sink_filter}, "only services with logs enabled should contribute a sink filter"
|
||||
|
||||
response = requests.put(f"{services_url}/cloudsql_mysql", headers=headers, json={"config": {"gcp": {"metrics": {"enabled": False}, "logs": {"enabled": True}}}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
strategy = response.json()["data"]["integrationConfig"]["gcp"]["telemetryCollectionStrategy"]
|
||||
assert "cloudsql.googleapis.com/database/up" in strategy["metrics"]["metricNames"], "Postgres should still collect the shared metrics"
|
||||
assert not [name for name in strategy["metrics"]["metricNames"] if "/database/mysql/" in name], "MySQL metrics should stop once its metrics are disabled"
|
||||
assert strategy["logs"] == {"sinkFilter": f"{mysql_sink_filter} OR {postgres_sink_filter}"}
|
||||
|
||||
|
||||
def test_gcp_agent_check_in_rejects_other_project(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
project_id = "signoz-agent-mismatch-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, "signoz-other-project")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["providerAccountId"] is None, "a rejected check-in must not connect the account"
|
||||
assert response.json()["data"]["agentReport"] is None
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
@provider_account_reuse
|
||||
def test_removed_account_checkins_after_provider_account_id_is_reused(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
provider: str,
|
||||
account_config: dict,
|
||||
provider_account_id: str,
|
||||
) -> None:
|
||||
"""Connect old account, remove it, connect replacement, then check in from the old agent."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
accounts_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{provider}/accounts")
|
||||
|
||||
# The original agent connects before its integration is removed.
|
||||
old_account_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id, data={"version": "old-agent"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.delete(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
old_account_before_checkins = response.json()["data"]
|
||||
|
||||
# A new integration claims the same provider account ID.
|
||||
replacement_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id, data={"version": "replacement-agent"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
replacement_before_checkins = response.json()["data"]
|
||||
|
||||
# The old agent keeps retrying. It must receive removal instructions, rather than a 409.
|
||||
for _ in range(3):
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id, data={"version": "old-agent-after-removal"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
cleanup_response = response.json()["data"]
|
||||
assert cleanup_response["cloudIntegrationId"] == old_account_id
|
||||
assert cleanup_response["providerAccountId"] == provider_account_id
|
||||
assert cleanup_response["removedAt"] is not None
|
||||
assert cleanup_response["removedAt"] == old_account_before_checkins["removedAt"]
|
||||
assert cleanup_response["integrationConfig"] == {}
|
||||
|
||||
# Cleanup may update sync state, but must preserve the original heartbeat, data and identity.
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
removed_account = response.json()["data"]
|
||||
assert removed_account["providerAccountId"] == old_account_before_checkins["providerAccountId"]
|
||||
assert removed_account["agentReport"]["timestampMillis"] == old_account_before_checkins["agentReport"]["timestampMillis"]
|
||||
assert removed_account["agentReport"]["data"] == old_account_before_checkins["agentReport"]["data"]
|
||||
assert removed_account["agentReport"]["syncState"] == cleanup_response["syncState"]
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == replacement_before_checkins, "old check-ins must not change the replacement account"
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["removedAt"] is None
|
||||
|
||||
|
||||
@provider_account_reuse
|
||||
def test_removed_account_first_checkin_after_provider_account_id_is_reused(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
provider: str,
|
||||
account_config: dict,
|
||||
provider_account_id: str,
|
||||
) -> None:
|
||||
"""Removal before the first heartbeat must still be reported after another integration claims the provider ID."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
accounts_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{provider}/accounts")
|
||||
|
||||
# Remove the original integration before its agent has checked in even once.
|
||||
old_account_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = requests.delete(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
old_account_before_checkins = response.json()["data"]
|
||||
assert old_account_before_checkins["providerAccountId"] is None
|
||||
assert old_account_before_checkins["agentReport"] is None
|
||||
|
||||
replacement_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
replacement_before_checkins = response.json()["data"]
|
||||
|
||||
# Late first check-ins must report removal without creating a heartbeat or claiming the ID.
|
||||
for _ in range(3):
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
cleanup_response = response.json()["data"]
|
||||
assert cleanup_response["cloudIntegrationId"] == old_account_id
|
||||
assert cleanup_response["providerAccountId"] == provider_account_id
|
||||
assert cleanup_response["removedAt"] == old_account_before_checkins["removedAt"]
|
||||
assert cleanup_response["removedAt"] is not None
|
||||
assert cleanup_response["integrationConfig"] == {}
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == old_account_before_checkins, "late first check-ins must not change a removed account"
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == replacement_before_checkins
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["removedAt"] is None
|
||||
|
||||
|
||||
def test_sync_state_drops_removed_region_after_ack(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -346,12 +562,13 @@ def test_sync_state_region_removed_during_sync(
|
||||
}
|
||||
|
||||
|
||||
def test_sync_state_after_disconnect(
|
||||
def test_sync_state_after_disconnect_when_provider_account_id_is_reused(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""The old AWS agent can finish cleanup while its provider ID belongs to a replacement integration."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
@@ -366,6 +583,10 @@ def test_sync_state_after_disconnect(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
replacement_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
for _ in range(2):
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
@@ -380,3 +601,11 @@ def test_sync_state_after_disconnect(
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=2)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {"version": 2, "inSync": True, "regions": {}}, "acked removal should leave no regions"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["agentReport"]["syncState"] == {"version": 2, "inSync": True, "regions": {}}, "cleanup acknowledgment must be saved"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
@@ -42,12 +44,14 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -73,6 +77,47 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_gcp_account_without_connection_method_remains_manual(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""A stored manual account without the new field can still be read, connected and updated."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
project_id = "signoz-test-project"
|
||||
config = {"deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config={"gcp": config | {"connectionMethod": "manual"}})
|
||||
|
||||
# Reproduce the released manual account format, which had no connection method.
|
||||
with signoz.sqlstore.conn.begin() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE cloud_integration SET config=:config WHERE id=:id"),
|
||||
{"config": json.dumps(config), "id": account["id"]},
|
||||
)
|
||||
|
||||
account_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account['id']}")
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
response = requests.get(
|
||||
account_url,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == config | {"connectionMethod": "manual"}
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account["id"], project_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
assert checkin.json()["data"]["integrationConfig"] == {}
|
||||
|
||||
updated_config = config | {"projectIds": [project_id, "signoz-test-project-2"]}
|
||||
response = requests.put(account_url, headers=headers, json={"config": {"gcp": updated_config}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
stored = conn.execute(sql.text("SELECT config FROM cloud_integration WHERE id=:id"), {"id": account["id"]}).scalar_one()
|
||||
assert json.loads(stored) == updated_config | {"connectionMethod": "manual"}
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_accounts_empty(
|
||||
signoz: types.SigNoz,
|
||||
@@ -293,6 +338,34 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
assert found_after["removedAt"] is None, "removedAt should still be null"
|
||||
|
||||
|
||||
def test_update_gcp_agent_account_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
project_id = "signoz-agent-update-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
account_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}")
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
|
||||
response = requests.put(account_url, headers=headers, json={"config": {"gcp": {"deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id, "signoz-agent-update-project-2"]}}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.get(account_url, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == {
|
||||
"connectionMethod": "agent",
|
||||
"deploymentProjectId": project_id,
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [project_id],
|
||||
}, "a rejected update must not change the agent account config"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_disconnect_account(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
@@ -26,9 +26,10 @@ AWS_SERVICE_SPEC = ProviderServiceSpec(
|
||||
GCP_SERVICE_SPEC = ProviderServiceSpec(
|
||||
provider="gcp",
|
||||
service_id="cloudsql_postgres",
|
||||
supports_logs=False,
|
||||
supports_logs=True,
|
||||
account_config={
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-test-project"],
|
||||
@@ -337,7 +338,7 @@ def test_update_gcp_service_without_metrics_config(
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""GCP services support metrics only, so a config omitting metrics is rejected."""
|
||||
"""GCP services require a metrics config, so a logs-only config is rejected."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)
|
||||
|
||||
Reference in New Issue
Block a user