From 4fb08a1b31b831d7be25f5173a49604271d4a09c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Dec 2025 23:17:31 +0100 Subject: [PATCH] techdocs: add support for integrations.awsS3 Signed-off-by: Vincenzo Scamporlino --- .../src/stages/publish/awsS3.test.ts | 164 +++++++++++++++++- .../techdocs-node/src/stages/publish/awsS3.ts | 98 +++++++++-- 2 files changed, 241 insertions(+), 21 deletions(-) diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts index 6fb855d199..f1133a1f60 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.test.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.test.ts @@ -270,25 +270,177 @@ describe('AwsS3Publish', () => { expect(getCredProviderMock).toHaveBeenCalledTimes(1); }); - it('should fall back to deprecated method of retrieving credentials', async () => { + it('should use awsS3.credentials if they are provided', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('@aws-sdk/client-s3', () => ({ + ...jest.requireActual('@aws-sdk/client-s3'), + S3Client: jest.fn(), + })); + + const { S3Client: MockS3Client } = require('@aws-sdk/client-s3'); + const { AwsS3Publish: IsolatedAwsS3Publish } = require('./awsS3'); + const mockConfig = new ConfigReader({ + techdocs: { + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }, + bucketName: 'bucketName', + bucketRootPath: '/', + }, + }, + }, + integrations: { + awsS3: [ + { + accessKeyId: 'access-key-from-integrations', + secretAccessKey: 'secret-access-key-from-integrations', + }, + ], + }, + }); + + await IsolatedAwsS3Publish.fromConfig(mockConfig, logger); + expect(getCredProviderMock).toHaveBeenCalledTimes(0); + expect(MockS3Client).toHaveBeenCalledTimes(1); + await expect( + MockS3Client.mock.calls[0][0]!.credentialDefaultProvider!( + undefined!, + )(), + ).resolves.toEqual({ + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }); + }); + }); + + it('should use credentials from integrations if awsS3.credentials is not provided', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('@aws-sdk/client-s3', () => ({ + ...jest.requireActual('@aws-sdk/client-s3'), + S3Client: jest.fn(), + })); + + const { S3Client: MockS3Client } = require('@aws-sdk/client-s3'); + const { AwsS3Publish: IsolatedAwsS3Publish } = require('./awsS3'); + + const mockConfig = new ConfigReader({ + techdocs: { + publisher: { + type: 'awsS3', + awsS3: { + credentials: {}, + bucketName: 'bucketName', + bucketRootPath: '/', + }, + }, + }, + integrations: { + awsS3: [ + { + accessKeyId: 'access-key-from-integrations', + secretAccessKey: 'secret-access-key-from-integrations', + }, + ], + }, + }); + + await IsolatedAwsS3Publish.fromConfig(mockConfig, logger); + expect(getCredProviderMock).toHaveBeenCalledTimes(0); + expect(MockS3Client).toHaveBeenCalledTimes(1); + await expect( + MockS3Client.mock.calls[0][0]!.credentialDefaultProvider!( + undefined!, + )(), + ).resolves.toEqual({ + accessKeyId: 'access-key-from-integrations', + secretAccessKey: 'secret-access-key-from-integrations', + }); + }); + }); + + it('should retrieve default credentials if multiple integrations are present', async () => { const mockConfig = new ConfigReader({ techdocs: { publisher: { type: 'awsS3', awsS3: { - credentials: { - accessKeyId: 'accessKeyId', - secretAccessKey: 'secretAccessKey', - }, + credentials: {}, bucketName: 'bucketName', bucketRootPath: '/', }, }, }, + integrations: { + awsS3: [ + { + accessKeyId: 'access-key-from-integrations', + secretAccessKey: 'secret-access-key-from-integrations', + }, + { + accessKeyId: 'access-key-from-integrations-2', + secretAccessKey: 'secret-access-key-from-integrations-2', + }, + ], + }, }); await AwsS3Publish.fromConfig(mockConfig, logger); - expect(getCredProviderMock).toHaveBeenCalledTimes(0); + expect(getCredProviderMock).toHaveBeenCalledTimes(1); + }); + + it('should retrieve the target integration if multiple integrations are provided and credentials are not provided', async () => { + await jest.isolateModulesAsync(async () => { + jest.doMock('@aws-sdk/client-s3', () => ({ + ...jest.requireActual('@aws-sdk/client-s3'), + S3Client: jest.fn(), + })); + + const { S3Client: MockS3Client } = require('@aws-sdk/client-s3'); + const { AwsS3Publish: IsolatedAwsS3Publish } = require('./awsS3'); + + const mockConfig = new ConfigReader({ + techdocs: { + publisher: { + type: 'awsS3', + awsS3: { + credentials: { + accessKeyId: 'access-key-from-integrations-2', + }, + bucketName: 'bucketName', + bucketRootPath: '/', + }, + }, + }, + integrations: { + awsS3: [ + { + accessKeyId: 'access-key-from-integrations', + secretAccessKey: 'secret-access-key-from-integrations', + }, + { + accessKeyId: 'access-key-from-integrations-2', + secretAccessKey: 'secret-access-key-from-integrations-2', + }, + ], + }, + }); + + await IsolatedAwsS3Publish.fromConfig(mockConfig, logger); + expect(getCredProviderMock).toHaveBeenCalledTimes(0); + expect(MockS3Client).toHaveBeenCalledTimes(1); + await expect( + MockS3Client.mock.calls[0][0]!.credentialDefaultProvider!( + undefined!, + )(), + ).resolves.toEqual({ + accessKeyId: 'access-key-from-integrations-2', + secretAccessKey: 'secret-access-key-from-integrations-2', + }); + }); }); }); diff --git a/plugins/techdocs-node/src/stages/publish/awsS3.ts b/plugins/techdocs-node/src/stages/publish/awsS3.ts index bd8b96ee14..97aab3d2d9 100644 --- a/plugins/techdocs-node/src/stages/publish/awsS3.ts +++ b/plugins/techdocs-node/src/stages/publish/awsS3.ts @@ -139,12 +139,19 @@ export class AwsS3Publish implements PublisherBase { const credentialsConfig = config.getOptionalConfig( 'techdocs.publisher.awsS3.credentials', ); + const credsManager = DefaultAwsCredentialsManager.fromConfig(config); + + const awsS3IntegrationConfig = + config.getOptionalConfigArray('integrations.awsS3'); + const sdkCredentialProvider = await AwsS3Publish.buildCredentials( credsManager, accountId, credentialsConfig, region, + awsS3IntegrationConfig, + logger, ); // AWS endpoint is an optional config. If missing, the default endpoint is built from @@ -213,8 +220,10 @@ export class AwsS3Publish implements PublisherBase { private static async buildCredentials( credsManager: AwsCredentialsManager, accountId?: string, - config?: Config, + credentialsConfig?: Config, region?: string, + awsS3IntegrationConfig?: Config[], + logger: LoggerService, ): Promise { // Pull credentials for the specified account ID from the 'aws' config section if (accountId) { @@ -222,21 +231,14 @@ export class AwsS3Publish implements PublisherBase { .sdkCredentialProvider; } - // Fall back to the default credential chain if neither account ID - // nor explicit credentials are provided - if (!config) { - return (await credsManager.getCredentialProvider()).sdkCredentialProvider; - } + const explicitCredentials = await AwsS3Publish.getExplicitCredentials({ + credsManager, + credentialsConfig, + awsS3IntegrationConfig, + logger, + }); - // Pull credentials from the techdocs config section (deprecated) - const accessKeyId = config.getOptionalString('accessKeyId'); - const secretAccessKey = config.getOptionalString('secretAccessKey'); - const explicitCredentials: AwsCredentialIdentityProvider = - accessKeyId && secretAccessKey - ? AwsS3Publish.buildStaticCredentials(accessKeyId, secretAccessKey) - : (await credsManager.getCredentialProvider()).sdkCredentialProvider; - - const roleArn = config.getOptionalString('roleArn'); + const roleArn = credentialsConfig?.getOptionalString('roleArn'); if (roleArn) { return fromTemporaryCredentials({ masterCredentials: explicitCredentials, @@ -251,6 +253,72 @@ export class AwsS3Publish implements PublisherBase { return explicitCredentials; } + private static async getExplicitCredentials({ + credentialsConfig, + awsS3IntegrationConfig, + credsManager, + logger, + }: { + credentialsConfig?: Config; + awsS3IntegrationConfig?: Config[]; + credsManager: AwsCredentialsManager; + logger: LoggerService; + }): Promise { + const accessKeyId = credentialsConfig?.getOptionalString('accessKeyId'); + const secretAccessKey = + credentialsConfig?.getOptionalString('secretAccessKey'); + + if (accessKeyId && secretAccessKey) { + return AwsS3Publish.buildStaticCredentials(accessKeyId, secretAccessKey); + } + if (awsS3IntegrationConfig && awsS3IntegrationConfig.length > 0) { + if (awsS3IntegrationConfig.length === 1) { + const singleAwsS3IntegrationConfig = awsS3IntegrationConfig[0]; + + const singleAwsS3IntegrationAccessKeyId = + singleAwsS3IntegrationConfig.getOptionalString('accessKeyId'); + const singleAwsS3IntegrationSecretAccessKey = + singleAwsS3IntegrationConfig.getOptionalString('secretAccessKey'); + + if ( + singleAwsS3IntegrationAccessKeyId && + singleAwsS3IntegrationSecretAccessKey + ) { + return AwsS3Publish.buildStaticCredentials( + singleAwsS3IntegrationAccessKeyId, + singleAwsS3IntegrationSecretAccessKey, + ); + } + } else { + if (accessKeyId) { + const targetAwsS3IntegrationConfig = awsS3IntegrationConfig.find( + c => c.getOptionalString('accessKeyId') === accessKeyId, + ); + + if (!targetAwsS3IntegrationConfig) { + logger.warn( + `No AWS S3 integration config under integrations.awsS3 found for access key id ${accessKeyId}.`, + ); + } + const targetAwsS3IntegrationAccessKeyId = + targetAwsS3IntegrationConfig?.getOptionalString('accessKeyId'); + const targetAwsS3IntegrationSecretAccessKey = + targetAwsS3IntegrationConfig?.getOptionalString('secretAccessKey'); + if ( + targetAwsS3IntegrationAccessKeyId && + targetAwsS3IntegrationSecretAccessKey + ) { + return AwsS3Publish.buildStaticCredentials( + targetAwsS3IntegrationAccessKeyId, + targetAwsS3IntegrationSecretAccessKey, + ); + } + } + } + } + return (await credsManager.getCredentialProvider()).sdkCredentialProvider; + } + /** * Check if the defined bucket exists. Being able to connect means the configuration is good * and the storage client will work.