Compare commits

..

13 Commits

Author SHA1 Message Date
Vikrant Gupta
cce1d31aaf Merge branch 'main' into keystone-pod/issues/148 2026-10-24 17:13:01 +05:30
Swapnil Nakade
2836e04897 feat: adding client changes for GCP integration agent flow (#13159)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Enabled GCP agent connection method
- Updated GCP agent connection method's create account form
- Added single connection command pointing to Foundy

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
https://github.com/SigNoz/keystone-pod/issues/155

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
<img width="2262" height="1346" alt="image"
src="https://github.com/user-attachments/assets/a8cf9eb0-6eb5-4c51-83a8-eafa0ccebf2e"
/>
<img width="2265" height="1346" alt="image"
src="https://github.com/user-attachments/assets/d1b2bdd9-1399-42b2-8abf-0f2d491dcf2f"
/>




#### Note
Worked with Claude
2026-10-24 17:12:39 +05:30
swapnil-signoz
eedc6f42a6 refactor: renaming agent-id param in foundry command 2026-10-23 02:43:13 +05:30
swapnil-signoz
b6eb17d394 Merge branch 'main' of https://github.com/SigNoz/signoz into keystone-pod/issues/148 2026-10-22 22:51:06 +05:30
swapnil-signoz
674c8730c7 refactor: making changes to the connection artifact response 2026-10-22 21:23:27 +05:30
swapnil-signoz
18d1617d20 chore: generating openapi specs 2026-10-22 21:22:42 +05:30
swapnil-signoz
ecda7da3c0 refactor: renaming gcp integration connection vars 2026-10-15 17:30:00 +05:30
swapnil-signoz
ee377ea9e8 Merge branch 'main' of https://github.com/SigNoz/signoz into keystone-pod/issues/148 2026-10-14 19:24:11 +05:30
swapnil-signoz
f4f9fabe44 refactor: made connectionMethod and projectIDs required 2026-10-14 18:48:20 +05:30
swapnil-signoz
cd3d9e631c feat: gcp manual integration migration and connection cmds 2026-10-14 16:27:24 +05:30
swapnil-signoz
69eca2e8f3 feat: adding stats reporter collection for gcp integration 2026-10-14 11:27:18 +05:30
swapnil-signoz
cf3d2f23ed chore: regenerate OpenAPI specs 2026-10-14 07:49:37 +05:30
swapnil-signoz
8a1ab49939 feat: adding gcp cloud integration agent mode changes 2026-10-14 07:49:36 +05:30
67 changed files with 3944 additions and 877 deletions

View File

@@ -2239,6 +2239,8 @@ components:
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
@@ -2248,13 +2250,79 @@ components:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
properties:
cliCommand:
type: string
required:
- cliCommand
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:
nullable: true
properties:
sinkFilter:
type: string
required:
- sinkFilter
type: object
CloudintegrationtypesGCPMetricsCollectionStrategy:
nullable: true
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:
@@ -2277,6 +2345,16 @@ components:
required:
- enabled
type: object
CloudintegrationtypesGCPTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
required:
- metrics
- logs
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -2413,7 +2491,7 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -2648,7 +2726,6 @@ components:
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId

View File

@@ -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
}

View File

@@ -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

View File

@@ -3792,7 +3792,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export enum CloudintegrationtypesGCPConnectionMethodDTO {
manual = 'manual',
agent = 'agent',
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
@@ -4039,7 +4044,10 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
/**
* @type string
*/
cliCommand: string;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
@@ -4078,8 +4086,62 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
/**
* @type string
*/
sinkFilter: string;
} | null;
/**
* @nullable
*/
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
/**
* @type array
*/
metricNames: string[];
} | null;
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
}
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 {
@@ -4234,7 +4296,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -4377,9 +4439,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
*/
deploymentRegion: string;
/**
* @type array,null
* @type array
*/
projectIds: string[] | null;
projectIds: string[];
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {

View File

@@ -0,0 +1,5 @@
.waitingMessage {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,147 @@
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 AgentSetupCommand from './AgentSetupCommand';
import styles from './AgentSetup.module.scss';
interface AgentSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onCommandGenerated: () => void;
/** Asks to leave the setup; the parent may confirm before closing. */
onCancel: () => void;
onClose: () => void;
}
function AgentSetup({
footerSlot,
onConnectionMethodChange,
onCommandGenerated,
onCancel,
onClose,
}: AgentSetupProps): JSX.Element {
const {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
} = useAgentAccountSetup({ onClose, onCommandGenerated });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
onChange={onConnectionMethodChange}
disabled={isLoading || Boolean(connectionCommand)}
/>
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
{connectionCommand ? (
<>
<AgentSetupCommand
deploymentProjectId={commandProjectId}
command={connectionCommand}
/>
{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}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</>
)}
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to generate the GCP setup command"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onCancel}
>
{!connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(generateCommand)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-generate-command-btn"
>
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="gcp-check-connection-btn"
>
Check Again
</Button>
)}
</SetupDrawerFooter>
</>
);
}
export default AgentSetup;

View File

@@ -0,0 +1,31 @@
.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';
}
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
// the code theme sets the block's margin and padding inline, hence !important.
.command {
> div:first-child {
top: var(--spacing-6);
right: var(--spacing-6);
}
> div:last-child {
// 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;
}
}

View File

@@ -0,0 +1,32 @@
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
deploymentProjectId: string;
command: string;
}
function AgentSetupCommand({
deploymentProjectId,
command,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid="gcp-agent-command">
<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 this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
contains your SigNoz keys, so do not share it.
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />
</div>
);
}
export default AgentSetupCommand;

View File

@@ -0,0 +1,183 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
DEPLOYMENT_PROJECT_ID,
fillAgentForm,
onClose,
onCommandGenerated,
renderAgentSetup,
mockAgentSetupApi,
AgentSetupApiMock,
} from './agentSetupTestHelpers';
import {
connectionCredentials,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
gcpConnectionCommand,
} from '../../../__tests__/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(
'SigNoz integration sets up everything needed',
);
});
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-command-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-generate-command-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 its setup command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-agent-command')).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-command')).toHaveTextContent(
gcpConnectionCommand.cliCommand,
);
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
DEPLOYMENT_PROJECT_ID,
);
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-deployment-project-id-input'),
).not.toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(onCommandGenerated).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-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
'projectIds must contain only deploymentProjectId for GCP agent setup',
);
});
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
});
it('locks the connection method and keeps the submitted project while the command is 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-command-btn'));
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'-edited',
);
const commands = await screen.findByTestId('gcp-agent-command');
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
});
});

View File

@@ -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 '../../../__tests__/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 command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-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-command-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-command-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-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -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 '../AgentSetup';
import SetupDrawerLayout from '../../SetupDrawerLayout';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/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 onCommandGenerated = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCommandGenerated']
>;
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}
onCommandGenerated={onCommandGenerated}
onCancel={onCancel}
onClose={onClose}
/>
)}
</SetupDrawerLayout>,
);
};
export const fillAgentForm = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-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;
},
};
};

View File

@@ -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);
}
}
}

View File

@@ -1,288 +1,88 @@
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 the agent command exists; closing after that loses the pending setup.
const [hasGeneratedCommand, setHasGeneratedCommand] = 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 (hasGeneratedCommand) {
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
size="md"
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || 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}
onCommandGenerated={(): void => setHasGeneratedCommand(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
size="md"
variant="solid"
color="highlight-danger"
onClick={onClose}
testId="gcp-close-setup-btn"
>
Close Setup
</Button>
<Button
size="md"
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 a new setup command.
</Typography>
</DialogWrapper>
</>
);
}

View File

@@ -10,12 +10,6 @@
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
.methodRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,89 @@
import { RadioGroup } 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)}>
<Typography.Text
weight="bold"
size="base"
className={styles.drawerSurfaceHead}
>
Connection method
</Typography.Text>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void =>
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
}
disabled={disabled}
disabledTooltip="The connection method can't be changed once setup has started"
items={[
{
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
testId: 'gcp-connection-method-manual',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect Manually
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
testId: 'gcp-connection-method-agent',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect via Agent
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
ConnectionMethodSelector.defaultProps = {
disabled: false,
};
export default ConnectionMethodSelector;

View File

@@ -21,10 +21,6 @@
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}

View File

@@ -9,6 +9,7 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import FormField from './FormField';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
@@ -141,43 +142,32 @@ function ConnectionSecretsFields({
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
key={field.name}
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<FormField
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
error={fieldState.error}
>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
</FormField>
)}
/>
);
})}
</div>

View File

@@ -1,82 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup } 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
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
items={[
{
value: 'manual',
testId: 'gcp-flow-manual',
label: (
<>
<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>
</>
),
},
{
value: 'agent',
testId: 'gcp-flow-agent',
disabled: true,
disabledTooltip: 'Soon',
label: (
<>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="primary" variant="solid">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,7 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { FieldError } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import styles from './FormField.module.scss';
interface FormFieldProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
error?: FieldError;
children: ReactNode;
}
/** A labelled setup drawer field with its validation message below the input. */
function FormField({
htmlFor,
label,
tooltip,
required,
error,
children,
}: FormFieldProps): JSX.Element {
return (
<div className={styles.drawerSection}>
<FieldLabel
htmlFor={htmlFor}
label={label}
tooltip={tooltip}
required={required}
/>
{children}
{error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{error.message}
</Typography.Text>
)}
</div>
);
}
FormField.defaultProps = {
required: false,
error: undefined,
};
export default FormField;

View File

@@ -0,0 +1,139 @@
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
import { Controller, useForm } from 'react-hook-form';
import ProjectIdsInput from '../../ProjectIdsInput';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import FormField from '../FormField';
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,
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} />
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
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
error={fieldState.error}
>
<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"
/>
</FormField>
)}
/>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<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 => (
<FormField
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
error={fieldState.error}
>
<ProjectIdsInput
id="gcp-project-ids-select"
testId="gcp-project-ids-select"
value={field.value}
onChange={field.onChange}
/>
</FormField>
)}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to connect GCP account"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onClose}
>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</SetupDrawerFooter>
</>
);
}
export default ManualSetup;

View File

@@ -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;
}

View File

@@ -0,0 +1,65 @@
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
size="md"
variant="outlined"
color="secondary"
onClick={onCancel}
testId="gcp-cancel-btn"
>
Cancel
</Button>
{children}
</div>
</div>,
slot,
);
}
SetupDrawerFooter.defaultProps = {
children: undefined,
};
export default SetupDrawerFooter;

View File

@@ -0,0 +1,36 @@
.setupDrawer {
// 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;
overflow-y: auto;
}
// The region menu renders in place (no portal), so it needs its own surface
// to sit above the fields below it.
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
}
.footerSlot {
width: 100%;
}

View File

@@ -0,0 +1,40 @@
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} />}
testId="gcp-setup-drawer"
>
{children(footerSlot)}
</DrawerWrapper>
);
}
export default SetupDrawerLayout;

View File

@@ -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>

View File

@@ -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 follow our setup guide to create the resources needed to complete the integration.',
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'View setup guide',
};
export const AGENT_SETUP_GUIDE = {
description:
'SigNoz integration sets up everything needed to collect telemetry from your project.',
// TODO: point to the agent setup docs once they are published.
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'Learn more',
};

View File

@@ -0,0 +1,54 @@
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
};
interface DeploymentProjectIdFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentProjectIdField({
control,
connectionMethod,
}: DeploymentProjectIdFieldProps): JSX.Element {
return (
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<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"
/>
</FormField>
)}
/>
);
}
export default DeploymentProjectIdField;

View File

@@ -0,0 +1,65 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP region where your OTel Collector will be deployed',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP region where the agent resources are deployed',
};
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
interface DeploymentRegionFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentRegionField({
control,
connectionMethod,
}: DeploymentRegionFieldProps): JSX.Element {
return (
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<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"
/>
</FormField>
)}
/>
);
}
export default DeploymentRegionField;

View File

@@ -0,0 +1,19 @@
.mono {
composes: mono from '../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.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);
}
}

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -3,13 +3,15 @@ import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { Form } 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';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import ProjectIdsInput from '../ProjectIdsInput';
import styles from './AccountSettingsDrawer.module.scss';
@@ -40,6 +42,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,20 +71,23 @@ function AccountSettingsDrawer({
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
gcpConnectionMethod={gcpConfig?.connection_method}
/>
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
{!isAgentAccount && (
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
)}
</div>
}
>
@@ -122,32 +131,42 @@ function AccountSettingsDrawer({
<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>
{isAgentAccount ? (
<div
className={styles.regionSelectorDescription}
data-testid="gcp-agent-monitored-projects"
>
{gcpConfig?.project_ids.join(', ')}
</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>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<ProjectIdsInput
id="gcp-edit-project-ids-select"
testId="gcp-edit-project-ids-select"
value={projectIds}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
/>
</Form.Item>
</>
)}
</div>
</div>
</Form>

View File

@@ -0,0 +1,5 @@
.projectIds {
// The multi-value trigger is content-box, so full width needs its padding counted in.
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,43 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import cx from 'classnames';
import styles from './ProjectIdsInput.module.scss';
interface ProjectIdsInputProps {
id: string;
testId: string;
value: string[];
onChange: (projectIds: string[]) => void;
className?: string;
}
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
function ProjectIdsInput({
id,
testId,
value,
onChange,
className,
}: ProjectIdsInputProps): JSX.Element {
return (
<ComboboxSimple
id={id}
testId={testId}
className={cx(styles.projectIds, className)}
multiple
allowCreate
value={value}
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
placeholder="Add project IDs…"
inputPlaceholder="Type a project ID and press Enter"
emptyPlaceholder="Type a project ID to add it"
withPortal={false}
/>
);
}
ProjectIdsInput.defaultProps = {
className: undefined,
};
export default ProjectIdsInput;

View File

@@ -1,19 +1,18 @@
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 { 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,
gcpAccountConfig,
listAccountsResponse,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
@@ -31,31 +30,29 @@ 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>
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>
</MockQueryClientProvider>,
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>,
);
};
/** The antd tags Select renders its text input inside the testId wrapper. */
const getProjectIdsInput = (): HTMLElement =>
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
'combobox',
);
/** Each selected tag carries an antd "close" icon that removes it. */
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
/** Each selected project ID renders as a pill with its own remove button. */
const getProjectIdRemoveButtons = (): HTMLElement[] =>
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
'close',
/^Remove /,
);
describe('GCP AccountSettingsDrawer', () => {
@@ -86,8 +83,9 @@ describe('GCP AccountSettingsDrawer', () => {
screen.getByText(gcpAccountConfig.deployment_region),
).toBeInTheDocument();
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
gcpAccountConfig.project_ids.forEach((projectId) => {
expect(screen.getByTitle(projectId)).toBeInTheDocument();
expect(projectIdsInput).toHaveTextContent(projectId);
});
});
@@ -100,7 +98,7 @@ describe('GCP AccountSettingsDrawer', () => {
'true',
);
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
@@ -114,7 +112,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -141,8 +139,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'],
},
});
@@ -159,9 +156,9 @@ describe('GCP AccountSettingsDrawer', () => {
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
while (getProjectIdTagRemoveButtons().length > 0) {
while (getProjectIdRemoveButtons().length > 0) {
// eslint-disable-next-line no-await-in-loop
await user.click(getProjectIdTagRemoveButtons()[0]);
await user.click(getProjectIdRemoveButtons()[0]);
}
await waitFor(() => {
@@ -194,7 +191,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -212,31 +209,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);
});
});

View File

@@ -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();
});
});

View File

@@ -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 generateAgentCommand = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-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-command-btn'));
await screen.findByTestId('gcp-agent-command');
};
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 command has 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 generateAgentCommand(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-command')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the user confirms', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(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 generateAgentCommand(user);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 {
@@ -15,8 +14,9 @@ import {
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `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.
jest.mock('../../../../../api/common/logEvent', () => ({
@@ -24,14 +24,12 @@ jest.mock('../../../../../api/common/logEvent', () => ({
default: jest.fn(),
}));
const onClose = jest.fn();
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<CloudAccountSetupDrawer onClose={onClose} />
</MockQueryClientProvider>,
);
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP CloudAccountSetupDrawer', () => {
@@ -109,6 +107,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();
@@ -127,13 +132,21 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,project-b,');
await addProjectIds(user, 'gcp-project-ids-select', [
'project-a',
'project-b',
]);
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.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await waitFor(() => {
expect(createAccountPayload).not.toBeNull();
});
@@ -141,6 +154,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
expect(createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'manual',
deploymentRegion: 'asia-south1',
deploymentProjectId: 'my-deployment-project-123',
projectIds: ['project-a', 'project-b'],
@@ -193,10 +207,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,');
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
await user.click(screen.getByTestId('gcp-connect-account-btn'));

View File

@@ -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-command-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-command-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-command-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-command-btn'));
await screen.findByTestId('gcp-agent-command');
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
});
});

View File

@@ -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,53 @@ export const checkInResponse = {
status: 'success',
data: {},
};
export const gcpConnectionCommand = {
cliCommand:
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
};
export const createAgentAccountResponse = {
status: 'success',
data: {
id: CLOUD_INTEGRATION_ID,
connectionArtifact: { gcp: gcpConnectionCommand },
},
};
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,
},
},
};

View File

@@ -0,0 +1,17 @@
import { screen, userEvent } from 'tests/test-utils';
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
export const addProjectIds = async (
user: ReturnType<typeof userEvent.setup>,
testId: string,
projectIds: string[],
): Promise<void> => {
await user.click(screen.getByTestId(testId));
const input = await screen.findByPlaceholderText(
'Type a project ID and press Enter',
);
for (const projectId of projectIds) {
// eslint-disable-next-line no-await-in-loop
await user.type(input, `${projectId}{Enter}`);
}
};

View File

@@ -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 = (
<>
@@ -129,4 +146,8 @@ function RemoveIntegrationAccount({
);
}
RemoveIntegrationAccount.defaultProps = {
gcpConnectionMethod: undefined,
};
export default RemoveIntegrationAccount;

View File

@@ -0,0 +1,51 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
const gcpAccountDto: 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: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-project',
deploymentRegion: 'us-central1',
projectIds: ['my-project'],
},
},
};
describe('mapAccountDtoToGcpCloudAccount', () => {
it('maps the GCP config, including its connection method', () => {
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
expect(account?.config).toStrictEqual({
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-project',
project_ids: ['my-project'],
});
});
it('skips an account that has no GCP config', () => {
expect(
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
).toBeNull();
});
it('skips an account the agent has not checked in for yet', () => {
expect(
mapAccountDtoToGcpCloudAccount({
...gcpAccountDto,
providerAccountId: null,
}),
).toBeNull();
});
});

View File

@@ -51,7 +51,8 @@ export function mapAccountDtoToAzureCloudAccount(
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
const gcpConfig = account.config.gcp;
if (!account.providerAccountId || !gcpConfig) {
return null;
}
@@ -59,9 +60,10 @@ export function mapAccountDtoToGcpCloudAccount(
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
connection_method: gcpConfig.connectionMethod,
deployment_region: gcpConfig.deploymentRegion,
deployment_project_id: gcpConfig.deploymentProjectId,
project_ids: gcpConfig.projectIds,
},
status: {
integration: {

View File

@@ -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[];

View File

@@ -0,0 +1,67 @@
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 };
};

View File

@@ -0,0 +1,94 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
ACCOUNT_URL,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onError,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,
}),
{ wrapper: AllTheProviders },
);
};
describe('useAgentConnectionPolling outcomes', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps polling until the agent checks in, then reports the connection', async () => {
const { requestCount } = mockAccountResponses(
pendingAccount,
pendingAccount,
connectedAccount,
);
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() => expect(requestCount()).toBe(2));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() =>
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
);
expect(onTimeout).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('does not treat a removed account as connected', async () => {
const { requestCount } = mockAccountResponses({
...connectedAccount,
removedAt: '2026-01-01T00:00:00Z',
});
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
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' })),
),
);
renderPolling();
await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (
initialEnabled: boolean,
): ((enabled: boolean) => void) => {
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled,
...callbacks,
}),
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
);
return (enabled: boolean): void => rerender({ enabled });
};
describe('useAgentConnectionPolling polling window', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('does not poll while disabled', async () => {
const { requestCount } = mockAccountResponses(connectedAccount);
renderPolling(false);
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
});
expect(requestCount()).toBe(0);
expect(onConnected).not.toHaveBeenCalled();
});
it('reports a timeout once the deadline passes without a check-in', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
renderPolling(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('starts the timeout clock when polling is enabled, not on mount', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
const setEnabled = renderPolling(false);
// Longer than the whole window passes before polling is turned on.
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
});
setEnabled(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -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 || [],
};

View File

@@ -0,0 +1,138 @@
import { useCallback, useState } from 'react';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
import { useGcpAccountSetup } from './useGcpAccountSetup';
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
interface UseAgentAccountSetupProps {
onClose: () => void;
onCommandGenerated: () => void;
}
interface UseAgentAccountSetup {
isLoading: boolean;
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
connectionCommand: string | null;
/** The project the generated command targets, as submitted. */
commandProjectId: string;
modalState: ModalStateEnum;
checkConnectionAgain: () => void;
}
export function useAgentAccountSetup({
onClose,
onCommandGenerated,
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const [commandProjectId, setCommandProjectId] = useState('');
const [accountId, setAccountId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const generateCommand = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
// The agent monitors only the project it is deployed in.
projectIds: [values.deploymentProjectId],
},
values,
);
const command = account.connectionArtifact.gcp?.cliCommand;
if (!command) {
setSubmitError(GENERATE_COMMAND_ERROR);
return;
}
setAccountId(account.id);
setCommandProjectId(values.deploymentProjectId);
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
onCommandGenerated();
void logEvent('GCP Integration: Account connection command generated', {
id: account.id,
});
}, GENERATE_COMMAND_ERROR),
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
);
const handleAgentConnected = useCallback(
(account: CloudintegrationtypesAccountDTO): void => {
handleConnected({
cloudIntegrationId: account.id,
providerAccountId: account.providerAccountId,
});
},
[handleConnected],
);
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: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
onConnected: handleAgentConnected,
onTimeout: handleTimeout,
onError: handlePollingError,
});
return {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
};
}

View File

@@ -0,0 +1,62 @@
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';
export const POLL_INTERVAL_MS = 10 * 1000;
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
interface UseAgentConnectionPollingProps {
accountId: string;
/** Polls exactly while true; the caller decides when there is an account to poll. */
enabled: boolean;
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
onTimeout: () => void;
onError: () => void;
}
/**
* 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,
}: UseAgentConnectionPollingProps): void {
const startTimeRef = useRef<number | null>(null);
useEffect(() => {
startTimeRef.current = enabled ? Date.now() : null;
}, [enabled]);
useGetAccount(
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
{
query: {
enabled,
refetchInterval: POLL_INTERVAL_MS,
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 >= CONNECTION_TIMEOUT_MS) {
onTimeout();
}
},
onError: () => {
onError();
},
},
},
);
}

View File

@@ -2,16 +2,15 @@ import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesGCPPostableAccountConfigDTO,
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
@@ -22,23 +21,36 @@ import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
interface UseGcpAccountSetupProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
interface UseGcpAccountSetup {
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
isLoading: boolean;
submitError: string | null;
setSubmitError: (message: string | null) => void;
clearSubmitError: () => void;
createGcpAccount: (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
runSubmit: (
work: () => Promise<void>,
fallbackMessage: string,
) => Promise<void>;
handleConnected: (payload: {
cloudIntegrationId: string;
providerAccountId: string | null;
}) => void;
}
export function useCloudAccountSetupDrawer({
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
export function useGcpAccountSetup({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -48,7 +60,6 @@ export function useCloudAccountSetupDrawer({
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -63,44 +74,15 @@ export function useCloudAccountSetupDrawer({
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
const createGcpAccount = useCallback(
async (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
const response = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
config: { gcp: gcpConfig },
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
@@ -110,56 +92,69 @@ export function useCloudAccountSetupDrawer({
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
},
});
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
return response.data;
},
[connectionParams, createAccount],
);
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
const runSubmit = useCallback(
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
await work();
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
fallbackMessage,
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
[],
);
const handleConnected = useCallback(
({
cloudIntegrationId,
providerAccountId,
}: {
cloudIntegrationId: string;
providerAccountId: string | null;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId,
providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
onClose();
},
[onClose, queryClient],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import logEvent from '../../../api/common/logEvent';
import { useGcpAccountSetup } from './useGcpAccountSetup';
interface UseManualAccountSetupProps {
onClose: () => void;
}
interface UseManualAccountSetup {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useManualAccountSetup({
onClose,
}: UseManualAccountSetupProps): UseManualAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const { mutateAsync: checkIn } = useAgentCheckIn();
const connectAccount = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
// Step 1: create the integration account.
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
values,
);
const cloudIntegrationId = account.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnected({ cloudIntegrationId, providerAccountId });
}, 'Failed to connect GCP account'),
[checkIn, createGcpAccount, handleConnected, runSubmit],
);
return {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -4,7 +4,11 @@
*/
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesGCPConnectionMethodDTO,
type CloudintegrationtypesPostableAccountDTO,
type CloudintegrationtypesUpdatableServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
@@ -18,9 +22,14 @@ import {
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
GCP_CONNECTION_METHODS,
MUTATION_STATES,
type MutationState,
NEW_ACCOUNT_CHECK_INS,
type NewAccountCheckIn,
createdAccountResponse,
newAccountId,
newAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
seedServiceSignals,
@@ -36,6 +45,11 @@ import {
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
const FAILED_REQUEST = {
status: 'error',
error: { code: 'internal', message: 'Request failed' },
};
/**
* Answers a mutation by the control's state: never, with a failure, or with the
* success body the caller builds.
@@ -48,16 +62,33 @@ const mutationResolver =
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Request failed' },
}),
)
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: ok(req, res, ctx, ...rest);
};
/**
* The account the connect flow just created answers by the check-in control;
* every other account is the page's data.
*/
const accountResolver =
(
provider: CloudProvider,
checkIn: NewAccountCheckIn,
existing: MockResolver,
): MockResolver =>
(req, res, ctx, ...rest) => {
if (String(req.params.id) !== newAccountId(provider)) {
return existing(req, res, ctx, ...rest);
}
return checkIn === 'error'
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: res(
ctx.status(200),
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
);
};
const PROVIDER = 'Cloud integration · provider';
const MUTATIONS = 'Cloud integration · mutations';
const SERVICES = 'Cloud integration · services';
@@ -71,6 +102,17 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: CLOUD_PROVIDERS,
value: 'aws',
}),
gcpConnectionMethod:
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
'GCP connection method',
{
group: PROVIDER,
description:
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
options: GCP_CONNECTION_METHODS,
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
},
),
accounts: countControl('Connected accounts', {
group: PROVIDER,
description:
@@ -99,6 +141,13 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: MUTATION_STATES,
value: 'success',
}),
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
group: MUTATIONS,
description:
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
options: NEW_ACCOUNT_CHECK_INS,
value: 'checked-in',
}),
accountUpdate: choiceControl<MutationState>('Account settings update', {
group: MUTATIONS,
description:
@@ -172,9 +221,19 @@ export const cloudIntegrationMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
mutationResolver(values.accountCreate, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
),
mutationResolver(values.accountCreate, async (req, res, ctx) => {
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
return res(
ctx.status(201),
ctx.json(
createdAccountResponse(
values.provider,
body.config?.gcp?.connectionMethod,
),
),
);
}),
),
rest.put(
@@ -182,21 +241,41 @@ export const cloudIntegrationMocks = defineStoryMocks({
mutationResolver(values.accountUpdate, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(accountResponse(values.provider, String(req.params.id))),
ctx.json(
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
accountResolver(
values.provider,
values.newAccountCheckIn,
response.json((req) =>
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => accountsResponse(values.provider, values.accounts)),
response.json(() =>
accountsResponse(
values.provider,
values.accounts,
values.gcpConnectionMethod,
),
),
),
],
config: (values) => ({
@@ -264,6 +343,17 @@ export const connectAzureAccount = async (
);
};
const addGcpProjectId = async (
testId: string,
projectId: string,
): Promise<void> => {
await userEvent.click(screen.getByTestId(testId));
await userEvent.type(
await screen.findByPlaceholderText('Type a project ID and press Enter'),
`${projectId}{Enter}`,
);
};
export const connectGcpAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
@@ -278,14 +368,40 @@ export const connectGcpAccount = async (
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
await userEvent.type(
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
'project-a,',
);
await addGcpProjectId('gcp-project-ids-select', 'project-a');
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
};
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
export const openGcpAgentSetup = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByTestId(
'gcp-connection-method-agent',
undefined,
untilLoaded,
),
);
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
};
export const generateGcpAgentCommand = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openGcpAgentSetup(canvasElement);
await userEvent.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'signoz-prod-4471',
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
};
export const openEditAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/edit account/i,
@@ -308,6 +424,17 @@ export const updateAwsAccount = async (
);
};
export const updateGcpProjects = async (
canvasElement: HTMLElement,
projectId: string,
): Promise<void> => {
await openEditAccount(canvasElement);
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};
export const updateTaggedAccount = async (
canvasElement: HTMLElement,
tag: string,

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import { screen, userEvent, within } from 'storybook/test';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -12,9 +13,13 @@ import {
connectAzureAccount,
connectGcpAccount,
expectToast,
generateGcpAgentCommand,
openConnectFlow,
openEditAccount,
openGcpAgentSetup,
untilLoaded,
updateAwsAccount,
updateGcpProjects,
updateTaggedAccount,
} from './CloudIntegration.stories.mocks';
@@ -251,6 +256,111 @@ export const GcpRegionOpen: Story = {
},
};
/**
* The GCP connect drawer on its agent connection method: only the deployment
* project, region and credentials, since the agent monitors the project it is
* deployed in.
*/
export const GcpAgentSetup: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openGcpAgentSetup(canvasElement);
},
};
/**
* The setup command the agent connection generates, with the drawer waiting for
* the agent to check in. Set New account check-in to
* `checked-in` or `error` to see it connect or fail.
*/
export const GcpAgentCommand: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
},
};
/** The agent checks in on the first poll, so the drawer closes with a toast. */
export const GcpAgentConnectedToast: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
play: async ({ canvasElement, args }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
if (args.newAccountCheckIn === 'checked-in') {
await expectToast(/gcp account connected successfully/i);
}
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const GcpAgentConnectionError: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId(
'gcp-agent-connection-error',
undefined,
untilLoaded,
);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const GcpAgentCloseConfirm: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await userEvent.click(
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
},
};
/**
* The settings of a GCP account connected through the agent: read-only, since
* the backend rejects updates to agent accounts.
*/
export const GcpAgentAccountSettings: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await screen.findByTestId(
'gcp-agent-monitored-projects',
undefined,
untilLoaded,
);
},
};
/** Disconnecting an agent account, which names what is left to clean up. */
export const GcpAgentDisconnect: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
);
await screen.findByText(
/signoz-integration-bootstrap Infrastructure/i,
undefined,
untilLoaded,
);
},
};
/**
* Saving the regions of a connected AWS account. Set Account settings update
* to `error` for the failure toast.
@@ -301,7 +411,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
export const GcpAccountSettingsUpdatedToast: Story = {
args: { provider: 'gcp' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
@@ -312,7 +422,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
export const GcpAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'gcp', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}

View File

@@ -3,6 +3,7 @@
* Do not hand-edit: regenerate instead.
*/
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import type {
CloudintegrationtypesAccountConfigDTO,
CloudintegrationtypesAccountDTO,
@@ -23,6 +24,19 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
export type MutationState = (typeof MUTATION_STATES)[number];
export const GCP_CONNECTION_METHODS = [
CloudintegrationtypesGCPConnectionMethodDTO.manual,
CloudintegrationtypesGCPConnectionMethodDTO.agent,
] as const;
/** How the poll for the account the connect flow just created answers. */
export const NEW_ACCOUNT_CHECK_INS = [
'checked-in',
'pending',
'error',
] as const;
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
interface CloudServiceSeed {
id: string;
title: string;
@@ -266,6 +280,7 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
const accountConfig = (
provider: CloudProvider,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountConfigDTO => {
if (provider === 'aws') {
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
@@ -280,8 +295,23 @@ const accountConfig = (
};
}
// The backend only accepts the deployment project as an agent account's scope.
if (
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471'],
},
};
}
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
@@ -292,12 +322,13 @@ const accountConfig = (
const account = (
provider: CloudProvider,
index: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountDTO => ({
id: `${provider}-account-${index + 1}`,
orgId: 'story-org',
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
config: accountConfig(provider, gcpConnectionMethod),
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
@@ -311,11 +342,12 @@ const account = (
export const accountsResponse = (
provider: CloudProvider,
count: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): ListAccounts200 => ({
status: 'success',
data: {
accounts: Array.from({ length: count }, (_, index) =>
account(provider, index),
account(provider, index, gcpConnectionMethod),
),
},
});
@@ -323,11 +355,52 @@ export const accountsResponse = (
export const accountResponse = (
provider: CloudProvider,
id: string,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): GetAccount200 => {
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
return { status: 'success', data: account(provider, index) };
return {
status: 'success',
data: account(provider, index, gcpConnectionMethod),
};
};
/** The backend's command template, filled in for the story's project and region. */
const GCP_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
--region 'us-central1' \\
--gcp-project 'signoz-prod-4471' \\
--integration-account-id 'gcp-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
export const newAccountId = (provider: CloudProvider): string =>
`${provider}-account-new`;
/**
* The account the connect flow just created, as its connection poll sees it. Only
* GCP's agent connection polls, so a GCP account here is an agent one.
*/
export const newAccountResponse = (
provider: CloudProvider,
isCheckedIn: boolean,
): GetAccount200 => {
const created = {
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
id: newAccountId(provider),
};
return {
status: 'success',
data: isCheckedIn
? created
: { ...created, providerAccountId: null, agentReport: null },
};
};
/**
@@ -541,14 +614,16 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
/**
* The connection URL is what the setup flow opens in a new tab, which the story
* reports through the navigation overlay rather than following.
* reports through the navigation overlay rather than following. GCP returns its
* setup command only for an agent connection; a manual one gets none.
*/
export const createdAccountResponse = (
provider: CloudProvider,
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
): CreateAccount201 => ({
status: 'success',
data: {
id: `${provider}-account-new`,
id: newAccountId(provider),
connectionArtifact: {
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
@@ -559,6 +634,10 @@ export const createdAccountResponse = (
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
...(gcpConnectionMethod ===
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
gcp: GCP_AGENT_COMMAND,
}),
},
},
});

View File

@@ -264,6 +264,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewAddGCPConnectionMethodFactory(),
)
}

View 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
}

View File

@@ -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

View File

@@ -1,32 +1,69 @@
package cloudintegrationtypes
import (
"bytes"
"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")}
foundryScriptURL = "https://signoz.io/foundry.sh"
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
"shellQuote": func(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
},
}).Parse(gcpCLITemplateStr()))
)
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
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Project ID where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// 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
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Compute service region where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true"`
type GCPPostableAccountConfig struct {
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" 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 string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPIntegrationConfig struct{}
type GCPConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
}
type GCPIntegrationConfig struct {
GCPAccountConfig
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
type GCPTelemetryCollectionStrategy struct {
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"true" nullable:"true"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"true" nullable:"true"`
}
type GCPMetricsCollectionStrategy struct {
@@ -50,3 +87,120 @@ 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(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
data := gcpTemplateData{
FoundryScriptURL: foundryScriptURL,
ProjectID: cfg.DeploymentProjectID,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
}
var command bytes.Buffer
if err := gcpCLITemplate.Execute(&command, data); err != nil {
return nil, err
}
return &GCPConnectionArtifact{
CLICommand: command.String(),
}, 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 {
FoundryScriptURL string
ProjectID string
Region string
IntegrationAccountID string
AgentVersion string
Credentials *Credentials
}
func gcpCLITemplateStr() string {
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration gcp terraform \
--region {{.Region | shellQuote}} \
--gcp-project {{.ProjectID | shellQuote}} \
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
--agent-version {{.AgentVersion | shellQuote}}`
}
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
}

View File

@@ -49,6 +49,7 @@ class ProviderAccountSpec:
class ProviderServiceSpec:
provider: str
service_id: str
account_config: dict
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")

View File

@@ -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,121 @@ 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"]
assert set(artifact) == {"cliCommand"}
command = artifact["cliCommand"]
assert command.startswith("curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform")
assert "--region 'us-central1'" in command
assert "--gcp-project 'signoz-agent-project'" in command
assert f"--integration-account-id '{account['id']}'" in command
assert "--signoz-api-url 'https://test-deployment.test.signoz.cloud'" in command
assert "--signoz-api-key 'test-api-key-789'" in command
assert "--ingestion-url 'https://ingest.test.signoz.cloud'" in command
assert "--ingestion-key 'test-ingestion-key-123456'" in command
assert "--agent-version 'v0.0.8'" in command
def test_create_account_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
@@ -135,6 +252,7 @@ def test_create_gcp_account_without_project_ids(
json={
"config": {
"gcp": {
"connectionMethod": "manual",
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": [],

View File

@@ -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": {"metrics": None, "logs": None},
}
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"

View File

@@ -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,

View File

@@ -27,6 +27,7 @@ GCP_SERVICE_SPEC = ProviderServiceSpec(
service_id="cloudsql_postgres",
account_config={
"gcp": {
"connectionMethod": "manual",
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": ["signoz-test-project"],