diff --git a/README.md b/README.md index 19e6015c..4c7f72f9 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ This enables managed platform updates and configures Elastic Beanstalk to automa | `create-s3-bucket-if-not-exists` | Create S3 bucket if it doesn't exist | `true` | | `s3-bucket-name` | Custom S3 bucket name for deployment packages | `elasticbeanstalk-{region}-{accountId}` | | `exclude-patterns` | Comma-separated glob patterns to exclude from auto-created packages | None | +| `verbose-logging` | Opt in to include infrastructure identifiers (region, account ID, environment ID, version label, URL) in logs and outputs. When false, general deployment progress is still visible. | `false` | ## Outputs diff --git a/action.yml b/action.yml index 2b9cb344..357335a9 100644 --- a/action.yml +++ b/action.yml @@ -75,6 +75,10 @@ inputs: option-settings: description: 'Elastic Beanstalk option settings as JSON array. Required when creating a new environment - MUST include IAM settings: [{"Namespace":"aws:autoscaling:launchconfiguration","OptionName":"IamInstanceProfile","Value":"your-instance-profile"},{"Namespace":"aws:elasticbeanstalk:environment","OptionName":"ServiceRole","Value":"your-service-role"}]' required: false + verbose-logging: + description: 'Enable verbose logging of deployment details such as region, account ID, environment ID, version label, and environment URL. When set to false, operational progress is still logged but identifying infrastructure details are omitted from logs and outputs.' + required: false + default: 'false' outputs: environment-url: diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 1314c868..0a068354 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -105,11 +105,15 @@ import { } from '../aws-operations'; import { waitForDeploymentCompletion, waitForHealthRecovery } from '../monitoring'; import { AWSClients } from '../aws-clients'; +import { DeploymentContext } from '../logging'; const mockedCore = core as jest.Mocked; const mockedExec = exec as jest.Mocked; const mockedFs = fs as jest.Mocked; +// Default context for tests +const defaultCtx: DeploymentContext = { verboseLogging: true, maxRetries: 3, retryDelay: 1 }; + describe('Main Functions', () => { let mockClients: AWSClients; @@ -152,6 +156,7 @@ describe('Main Functions', () => { }); mockedCore.getBooleanInput.mockImplementation((name: string) => { if (name === 'create-s3-bucket-if-not-exists') return true; + if (name === 'verbose-logging') return true; return false; }); }); @@ -173,16 +178,16 @@ describe('Main Functions', () => { const result = await createDeploymentPackage(undefined, 'v1.0.0', '*.git*,*.node*'); expect(result.path).toBe('deploy-v1.0.0.zip'); - + const archiver = require('archiver'); expect(archiver).toHaveBeenCalledWith('zip'); - + // Verify the mock archive methods were called const mockArchiveInstance = archiver(); expect(mockArchiveInstance.pipe).toHaveBeenCalled(); - expect(mockArchiveInstance.glob).toHaveBeenCalledWith('**/*', { + expect(mockArchiveInstance.glob).toHaveBeenCalledWith('**/*', { dot: true, - ignore: ['*.git*', '*.node*'] + ignore: ['*.git*', '*.node*'] }); expect(mockArchiveInstance.finalize).toHaveBeenCalled(); }); @@ -249,7 +254,7 @@ describe('Main Functions', () => { describe('retryWithBackoff', () => { it('should succeed on first attempt', async () => { const mockFn = jest.fn().mockResolvedValue('success'); - const result = await retryWithBackoff(mockFn, 3, 1, 'Test'); + const result = await retryWithBackoff(mockFn, 'Test', defaultCtx); expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(1); }); @@ -258,14 +263,15 @@ describe('Main Functions', () => { const mockFn = jest.fn() .mockRejectedValueOnce(new Error('fail')) .mockResolvedValue('success'); - const result = await retryWithBackoff(mockFn, 3, 1, 'Test'); + const result = await retryWithBackoff(mockFn, 'Test', defaultCtx); expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(2); }); it('should fail after max retries', async () => { const mockFn = jest.fn().mockRejectedValue(new Error('fail')); - await expect(retryWithBackoff(mockFn, 2, 1, 'Test')) + const ctx: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 1 }; + await expect(retryWithBackoff(mockFn, 'Test', ctx)) .rejects.toThrow('Test failed after 3 attempts (2 retries): fail'); expect(mockFn).toHaveBeenCalledTimes(3); }); @@ -274,7 +280,7 @@ describe('Main Functions', () => { const errorMessage = "You do not have permission to perform the 'ec2:DescribeImages' action."; const mockFn = jest.fn().mockRejectedValue(new Error(errorMessage)); - await expect(retryWithBackoff(mockFn, 3, 1, 'Create environment')) + await expect(retryWithBackoff(mockFn, 'Create environment', defaultCtx)) .rejects.toThrow(errorMessage); // Ensure we only attempted once (no retries) @@ -285,7 +291,7 @@ describe('Main Functions', () => { describe('getAwsAccountId', () => { it('should return account ID', async () => { mockSend.mockResolvedValue({ Account: '123456789012' }); - const result = await getAwsAccountId(mockClients, 3, 1); + const result = await getAwsAccountId(mockClients, defaultCtx); expect(result).toBe('123456789012'); }); }); @@ -295,7 +301,7 @@ describe('Main Functions', () => { mockSend.mockResolvedValue({ Environments: [{ Status: 'Ready', Health: 'Green' }], }); - const result = await environmentExists(mockClients, 'app', 'env'); + const result = await environmentExists(mockClients, 'app', 'env', defaultCtx); expect(result).toEqual({ exists: true, status: 'Ready', @@ -305,7 +311,7 @@ describe('Main Functions', () => { it('should return false if environment does not exist', async () => { mockSend.mockResolvedValue({ Environments: [] }); - const result = await environmentExists(mockClients, 'app', 'env'); + const result = await environmentExists(mockClients, 'app', 'env', defaultCtx); expect(result).toEqual({ exists: false }); }); @@ -313,13 +319,13 @@ describe('Main Functions', () => { mockSend.mockResolvedValue({ Environments: [{ Status: 'Terminated', Health: 'Grey' }], }); - const result = await environmentExists(mockClients, 'app', 'env'); + const result = await environmentExists(mockClients, 'app', 'env', defaultCtx); expect(result).toEqual({ exists: false, status: 'Terminated', health: 'Grey' }); }); it('should rethrow unexpected API errors', async () => { mockSend.mockRejectedValue(new Error('API Error')); - await expect(environmentExists(mockClients, 'app', 'env')).rejects.toThrow('API Error'); + await expect(environmentExists(mockClients, 'app', 'env', defaultCtx)).rejects.toThrow('API Error'); }); it('should return false on 404 not found', async () => { @@ -327,7 +333,7 @@ describe('Main Functions', () => { name: 'NoSuchEntityException', }); mockSend.mockRejectedValue(notFoundError); - const result = await environmentExists(mockClients, 'app', 'env'); + const result = await environmentExists(mockClients, 'app', 'env', defaultCtx); expect(result).toEqual({ exists: false }); }); }); @@ -336,18 +342,18 @@ describe('Main Functions', () => { describe('updateEnvironment', () => { it('should update environment without options', async () => { mockSend.mockResolvedValue({}); - await updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', '', '64bit Amazon Linux 2', undefined, 3, 1); + await updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', '', '64bit Amazon Linux 2', undefined, defaultCtx); expect(mockSend).toHaveBeenCalled(); }); it('should update environment with options', async () => { mockSend.mockResolvedValue({}); - await updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"test","OptionName":"test","Value":"test"}]', '64bit Amazon Linux 2', undefined, 3, 1); + await updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"test","OptionName":"test","Value":"test"}]', '64bit Amazon Linux 2', undefined, defaultCtx); expect(mockSend).toHaveBeenCalled(); }); it('should handle invalid JSON options', async () => { - await expect(updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', 'invalid-json', '64bit Amazon Linux 2', undefined, 3, 1)) + await expect(updateEnvironment(mockClients, 'app', 'env', 'v1.0.0', 'invalid-json', '64bit Amazon Linux 2', undefined, defaultCtx)) .rejects.toThrow('Failed to parse option-settings'); }); }); @@ -355,20 +361,20 @@ describe('Main Functions', () => { describe('createEnvironment', () => { it('should create environment', async () => { mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"aws:autoscaling:launchconfiguration","OptionName":"IamInstanceProfile","Value":"profile"}]', 'stack', undefined, undefined, 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"aws:autoscaling:launchconfiguration","OptionName":"IamInstanceProfile","Value":"profile"}]', 'stack', undefined, undefined, defaultCtx); expect(mockSend).toHaveBeenCalledTimes(1); // 1 create }); it('should create environment with custom options', async () => { mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"test","OptionName":"test","Value":"test"}]', 'stack', undefined, undefined, 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[{"Namespace":"test","OptionName":"test","Value":"test"}]', 'stack', undefined, undefined, defaultCtx); expect(mockSend).toHaveBeenCalledTimes(1); }); it('should pass cnamePrefix to CreateEnvironmentCommand', async () => { const { CreateEnvironmentCommand } = require('@aws-sdk/client-elastic-beanstalk'); mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack', undefined, 'my-cname', 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack', undefined, 'my-cname', defaultCtx); expect(CreateEnvironmentCommand).toHaveBeenCalledWith( expect.objectContaining({ CNAMEPrefix: 'my-cname' }) ); @@ -377,7 +383,7 @@ describe('Main Functions', () => { it('should not include CNAMEPrefix when cnamePrefix is undefined', async () => { const { CreateEnvironmentCommand } = require('@aws-sdk/client-elastic-beanstalk'); mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack', undefined, undefined, 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack', undefined, undefined, defaultCtx); expect(CreateEnvironmentCommand).toHaveBeenCalledWith( expect.not.objectContaining({ CNAMEPrefix: expect.anything() }) ); @@ -386,7 +392,7 @@ describe('Main Functions', () => { it('should use PlatformArn only when SolutionStackName is not set', async () => { const { CreateEnvironmentCommand } = require('@aws-sdk/client-elastic-beanstalk'); mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', undefined, 'arn:aws:elasticbeanstalk:us-east-1::platform/Node.js/1.0', undefined, 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', undefined, 'arn:aws:elasticbeanstalk:us-east-1::platform/Node.js/1.0', undefined, defaultCtx); expect(CreateEnvironmentCommand).toHaveBeenCalledWith( expect.objectContaining({ PlatformArn: 'arn:aws:elasticbeanstalk:us-east-1::platform/Node.js/1.0' }) ); @@ -398,7 +404,7 @@ describe('Main Functions', () => { it('should prefer SolutionStackName over PlatformArn when both are set', async () => { const { CreateEnvironmentCommand } = require('@aws-sdk/client-elastic-beanstalk'); mockSend.mockResolvedValue({}); - await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack-name', 'arn:platform', undefined, 3, 1); + await createEnvironment(mockClients, 'app', 'env', 'v1.0.0', '[]', 'stack-name', 'arn:platform', undefined, defaultCtx); expect(CreateEnvironmentCommand).toHaveBeenCalledWith( expect.objectContaining({ SolutionStackName: 'stack-name' }) ); @@ -413,7 +419,7 @@ describe('Main Functions', () => { mockSend.mockResolvedValue({ Environments: [{ Status: 'Ready' }], }); - await waitForDeploymentCompletion(mockClients, 'app', 'env', 900); + await waitForDeploymentCompletion(mockClients, 'app', 'env', 900, defaultCtx); expect(mockSend).toHaveBeenCalled(); }); }); @@ -423,7 +429,7 @@ describe('Main Functions', () => { mockSend.mockResolvedValue({ Environments: [{ Health: 'Green', Status: 'Ready' }], }); - await waitForHealthRecovery(mockClients, 'app', 'env', 900); + await waitForHealthRecovery(mockClients, 'app', 'env', 900, defaultCtx); expect(mockSend).toHaveBeenCalled(); }); @@ -431,7 +437,7 @@ describe('Main Functions', () => { mockSend.mockResolvedValue({ Environments: [{ Health: 'Yellow', Status: 'Ready' }], }); - await waitForHealthRecovery(mockClients, 'app', 'env', 900); + await waitForHealthRecovery(mockClients, 'app', 'env', 900, defaultCtx); expect(mockSend).toHaveBeenCalled(); }); @@ -449,7 +455,7 @@ describe('Main Functions', () => { } ] }); - await expect(waitForHealthRecovery(mockClients, 'app', 'env', 1)) + await expect(waitForHealthRecovery(mockClients, 'app', 'env', 1, defaultCtx)) .rejects.toThrow('Environment health recovery failed - health is Red'); }); @@ -463,7 +469,7 @@ describe('Main Functions', () => { Environments: [{ Health: 'Red', Status: 'Updating' }], }); }); - await expect(waitForHealthRecovery(mockClients, 'app', 'env', 1)) + await expect(waitForHealthRecovery(mockClients, 'app', 'env', 1, defaultCtx)) .rejects.toThrow('Environment health recovery timed out after 1s'); }); }); @@ -478,7 +484,7 @@ describe('Main Functions', () => { Health: 'Green', }], }); - const result = await getEnvironmentInfo(mockClients, 'app', 'env'); + const result = await getEnvironmentInfo(mockClients, 'app', 'env', defaultCtx); expect(result).toEqual({ url: 'test.com', id: 'e-123', @@ -489,9 +495,22 @@ describe('Main Functions', () => { it('should throw error if no environment found', async () => { mockSend.mockResolvedValue({ Environments: [] }); - await expect(getEnvironmentInfo(mockClients, 'app', 'env')) + await expect(getEnvironmentInfo(mockClients, 'app', 'env', defaultCtx)) .rejects.toThrow('Environment env not found after deployment'); }); + + it('should suppress environment name in error when verbose-logging is false', async () => { + mockSend.mockResolvedValue({ Environments: [] }); + const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 }; + try { + await getEnvironmentInfo(mockClients, 'app', 'env', quietCtx); + fail('Expected error to be thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).not.toContain('env'); + expect(message).toContain('Environment not found after deployment'); + } + }); }); describe('run', () => { @@ -542,6 +561,7 @@ describe('Main Functions', () => { mockedCore.getBooleanInput.mockImplementation((name: string) => { if (name === 'create-s3-bucket-if-not-exists') return true; if (name === 'create-environment-if-not-exists') return true; + if (name === 'verbose-logging') return true; return false; }); @@ -572,6 +592,7 @@ describe('Main Functions', () => { mockedCore.getBooleanInput.mockImplementation((name: string) => { if (name === 'create-s3-bucket-if-not-exists') return true; if (name === 'use-existing-application-version-if-available') return true; + if (name === 'verbose-logging') return true; return false; }); @@ -614,6 +635,7 @@ describe('Main Functions', () => { mockedCore.getBooleanInput.mockImplementation((name: string) => { if (name === 'create-s3-bucket-if-not-exists') return true; if (name === 'create-environment-if-not-exists') return true; + if (name === 'verbose-logging') return true; return false; }); @@ -664,6 +686,154 @@ describe('Main Functions', () => { 'Deployment failed: Either solution-stack-name or platform-arn must be provided when creating a new environment', ); }); + + it('should suppress sensitive outputs when verbose-logging is false', async () => { + mockedCore.getBooleanInput.mockImplementation((name: string) => { + if (name === 'create-s3-bucket-if-not-exists') return true; + if (name === 'verbose-logging') return false; + return false; + }); + + mockSend.mockImplementation(() => { + const callCount = mockSend.mock.calls.length + 1; + + if (callCount === 1) return Promise.resolve({ Account: '123456789012' }); // GetCallerIdentity + if (callCount === 2) return Promise.resolve({ ApplicationVersions: [] }); // DescribeApplicationVersions + if (callCount === 3) return Promise.resolve({}); // HeadBucket + if (callCount === 4) return Promise.resolve({}); // PutObject + if (callCount === 5) return Promise.resolve({}); // CreateAppVersion + if (callCount === 6) return Promise.resolve({ Environments: [{ Status: 'Ready', Health: 'Green' }] }); // DescribeEnvironment + if (callCount === 7) return Promise.resolve({}); // UpdateEnvironment + if (callCount === 8) return Promise.resolve({ Environments: [{ CNAME: 'test-env.elasticbeanstalk.com', EnvironmentId: 'e-123', Status: 'Ready', Health: 'Green' }] }); // GetEnvironmentInfo + + return Promise.resolve({}); + }); + + await run(); + + // Non-sensitive outputs should always be set + expect(mockedCore.setOutput).toHaveBeenCalledWith('environment-status', 'Ready'); + expect(mockedCore.setOutput).toHaveBeenCalledWith('environment-health', 'Green'); + expect(mockedCore.setOutput).toHaveBeenCalledWith('deployment-action-type', 'update'); + + // Sensitive outputs should NOT be set when verbose-logging is false + expect(mockedCore.setOutput).not.toHaveBeenCalledWith('environment-url', expect.anything()); + expect(mockedCore.setOutput).not.toHaveBeenCalledWith('environment-id', expect.anything()); + expect(mockedCore.setOutput).not.toHaveBeenCalledWith('version-label', expect.anything()); + }); + + it('should suppress sensitive log details when verbose-logging is false', async () => { + mockedCore.getBooleanInput.mockImplementation((name: string) => { + if (name === 'create-s3-bucket-if-not-exists') return true; + if (name === 'verbose-logging') return false; + return false; + }); + + mockSend.mockImplementation(() => { + const callCount = mockSend.mock.calls.length + 1; + + if (callCount === 1) return Promise.resolve({ Account: '123456789012' }); // GetCallerIdentity + if (callCount === 2) return Promise.resolve({ ApplicationVersions: [] }); // DescribeApplicationVersions + if (callCount === 3) return Promise.resolve({}); // HeadBucket + if (callCount === 4) return Promise.resolve({}); // PutObject + if (callCount === 5) return Promise.resolve({}); // CreateAppVersion + if (callCount === 6) return Promise.resolve({ Environments: [{ Status: 'Ready', Health: 'Green' }] }); // DescribeEnvironment + if (callCount === 7) return Promise.resolve({}); // UpdateEnvironment + if (callCount === 8) return Promise.resolve({ Environments: [{ CNAME: 'test-env.elasticbeanstalk.com', EnvironmentId: 'e-123', Status: 'Ready', Health: 'Green' }] }); // GetEnvironmentInfo + + return Promise.resolve({}); + }); + + await run(); + + const infoCalls = mockedCore.info.mock.calls.map(c => c[0]); + + // Application name, environment name, version label, and region should NOT appear + expect(infoCalls).not.toContainEqual('Application: test-app'); + expect(infoCalls).not.toContainEqual('Environment: test-env'); + expect(infoCalls).not.toContainEqual('Version: v1.0.0'); + expect(infoCalls).not.toContainEqual('Region: us-east-1'); + + // Deployment Outputs should NOT contain sensitive details + expect(infoCalls).not.toContainEqual(expect.stringContaining('Environment URL:')); + expect(infoCalls).not.toContainEqual(expect.stringContaining('Environment ID:')); + expect(infoCalls).not.toContainEqual(expect.stringContaining('Application Version Label:')); + + // Environment name should NOT appear in environment status/update messages + expect(infoCalls).not.toContainEqual(expect.stringContaining('test-env')); + + // Deployment package filename should NOT contain version label + expect(infoCalls).not.toContainEqual(expect.stringContaining('deploy-v1.0.0.zip')); + + // startGroup for version creation should NOT contain version label + const startGroupCalls = mockedCore.startGroup.mock.calls.map(c => c[0]); + const versionGroupCall = startGroupCalls.find(c => c.includes('Creating application version')); + expect(versionGroupCall).not.toContain('v1.0.0'); + + // Generic operational messages SHOULD still appear + expect(infoCalls).toContainEqual('📦 Creating deployment package'); + expect(infoCalls).toContainEqual(expect.stringContaining('Environment found - Status:')); + expect(infoCalls).toContainEqual('🔄 Updating environment'); + expect(infoCalls).toContainEqual('✅ Environment update initiated'); + }); + + it('should suppress error details when verbose-logging is false and deployment fails', async () => { + mockedCore.getBooleanInput.mockImplementation((name: string) => { + if (name === 'create-s3-bucket-if-not-exists') return true; + if (name === 'verbose-logging') return false; + return false; + }); + + // Force an error early - STS call fails + mockSend.mockRejectedValue(new Error('AWS Error')); + + await run(); + + // Error details should NOT appear in setFailed + expect(mockedCore.setFailed).toHaveBeenCalledWith('Deployment failed (enable verbose-logging for details)'); + + // Error details should be in debug log instead + expect(mockedCore.debug).toHaveBeenCalledWith(expect.stringContaining('Deployment error detail:')); + + // core.error should NOT contain the error message detail + const errorCalls = mockedCore.error.mock.calls.map(c => c[0]); + expect(errorCalls).not.toContainEqual(expect.stringContaining('AWS Error')); + }); + + it('should suppress env name in error when env not exists and verbose-logging is false', async () => { + mockedCore.getBooleanInput.mockImplementation((name: string) => { + if (name === 'create-s3-bucket-if-not-exists') return true; + if (name === 'verbose-logging') return false; + return false; + }); + + mockSend.mockImplementation(() => { + const callCount = mockSend.mock.calls.length + 1; + + if (callCount === 1) return Promise.resolve({ Account: '123456789012' }); // GetCallerIdentity + if (callCount === 2) return Promise.resolve({ ApplicationVersions: [] }); // DescribeApplicationVersions + if (callCount === 3) return Promise.resolve({}); // HeadBucket + if (callCount === 4) return Promise.resolve({}); // PutObject + if (callCount === 5) return Promise.resolve({}); // CreateAppVersion + if (callCount === 6) return Promise.resolve({ Environments: [] }); // No env found + + return Promise.resolve({}); + }); + + await run(); + + // setFailed should NOT contain the environment name + expect(mockedCore.setFailed).toHaveBeenCalledWith('Deployment failed (enable verbose-logging for details)'); + + // The error detail (with env name) should be in debug only + expect(mockedCore.debug).toHaveBeenCalledWith( + expect.stringContaining('Environment does not exist and create-environment-if-not-exists is false') + ); + // And that debug message should NOT contain the actual env name + const debugCalls = mockedCore.debug.mock.calls.map(c => c[0]); + const envErrorDebug = debugCalls.find(c => typeof c === 'string' && c.includes('does not exist')); + expect(envErrorDebug).not.toContain('test-env'); + }); }); describe('walkFiles', () => { diff --git a/src/__tests__/s3_operations.test.ts b/src/__tests__/s3_operations.test.ts index 9a93d9e5..205e0e9f 100644 --- a/src/__tests__/s3_operations.test.ts +++ b/src/__tests__/s3_operations.test.ts @@ -62,9 +62,12 @@ jest.mock('@aws-sdk/client-sts', () => ({ import * as fs from 'fs'; import { uploadToS3, createS3Bucket } from '../aws-operations'; import { AWSClients } from '../aws-clients'; +import { DeploymentContext } from '../logging'; const mockedFs = fs as jest.Mocked; +const defaultCtx: DeploymentContext = { verboseLogging: true, maxRetries: 3, retryDelay: 1 }; + describe('S3 Operations', () => { let mockClients: AWSClients; @@ -80,7 +83,7 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockResolvedValueOnce({}); // PutObject - const result = await uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', 3, 1, false); + const result = await uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', false, defaultCtx); expect(result).toEqual({ bucket: 'elasticbeanstalk-us-east-1-123456789012', @@ -95,7 +98,7 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockResolvedValueOnce({}); // PutObject - const result = await uploadToS3(mockClients, 'us-west-2', '987654321098', 'app', 'abc123', 'deploy.jar', 3, 1, false); + const result = await uploadToS3(mockClients, 'us-west-2', '987654321098', 'app', 'abc123', 'deploy.jar', false, defaultCtx); expect(result).toEqual({ bucket: 'elasticbeanstalk-us-west-2-987654321098', @@ -109,7 +112,7 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockResolvedValueOnce({}); // PutObject - const result = await uploadToS3(mockClients, 'eu-west-1', '111222333444', 'test-app', 'v2.0.0', 'package.zip', 3, 1, false); + const result = await uploadToS3(mockClients, 'eu-west-1', '111222333444', 'test-app', 'v2.0.0', 'package.zip', false, defaultCtx); expect(result.bucket).toBe('elasticbeanstalk-eu-west-1-111222333444'); }); @@ -119,7 +122,7 @@ describe('S3 Operations', () => { it('should not create bucket if it already exists', async () => { mockSend.mockResolvedValueOnce({}); // HeadBucket with ExpectedBucketOwner succeeds - await createS3Bucket(mockClients, 'us-east-1', 'existing-bucket', '123456789012', 3, 1); + await createS3Bucket(mockClients, 'us-east-1', 'existing-bucket', '123456789012', defaultCtx); expect(mockSend).toHaveBeenCalledTimes(1); // HeadBucket only (ownership verified via ExpectedBucketOwner) }); @@ -129,7 +132,7 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucket throws (bucket doesn't exist) .mockResolvedValueOnce({}); // CreateBucket succeeds - await createS3Bucket(mockClients, 'us-east-1', 'new-bucket', '123456789012', 3, 1); + await createS3Bucket(mockClients, 'us-east-1', 'new-bucket', '123456789012', defaultCtx); expect(mockSend).toHaveBeenCalledTimes(2); // HeadBucket + CreateBucket }); @@ -139,7 +142,7 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucket throws (bucket doesn't exist) .mockResolvedValueOnce({}); // CreateBucket with LocationConstraint succeeds - await createS3Bucket(mockClients, 'eu-central-1', 'euro-bucket', '123456789012', 3, 1); + await createS3Bucket(mockClients, 'eu-central-1', 'euro-bucket', '123456789012', defaultCtx); expect(mockSend).toHaveBeenCalledTimes(2); // HeadBucket + CreateBucket }); @@ -150,7 +153,7 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(new Error('NetworkError')) // CreateBucket attempt 1 fails .mockResolvedValueOnce({}); // CreateBucket attempt 2 succeeds - await createS3Bucket(mockClients, 'us-west-2', 'retry-bucket', '123456789012', 3, 1); + await createS3Bucket(mockClients, 'us-west-2', 'retry-bucket', '123456789012', defaultCtx); expect(mockSend).toHaveBeenCalledTimes(3); // HeadBucket + 2 CreateBucket attempts }); @@ -161,12 +164,33 @@ describe('S3 Operations', () => { mockSend.mockRejectedValueOnce(forbiddenError); // HeadBucket returns 403 - await expect(createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', 3, 1)) + await expect(createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', defaultCtx)) .rejects.toThrow("S3 bucket 'someone-elses-bucket' exists but is not owned by this AWS account"); expect(mockSend).toHaveBeenCalledTimes(1); // HeadBucket only, no create attempt }); + it('should redact bucket name and account ID in 403 error when verbose-logging is false', async () => { + const forbiddenError = new Error('Forbidden'); + (forbiddenError as any).$metadata = { httpStatusCode: 403 }; + + mockSend.mockRejectedValueOnce(forbiddenError); // HeadBucket returns 403 + + const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 }; + try { + await createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', quietCtx); + fail('Expected error to be thrown'); + } catch (error) { + const message = (error as Error).message; + // Should NOT contain bucket name or account ID + expect(message).not.toContain('someone-elses-bucket'); + expect(message).not.toContain('123456789012'); + // Should still contain actionable guidance + expect(message).toContain('S3 bucket exists but is not owned by this AWS account'); + expect(message).toContain('s3-bucket-name'); + } + }); + it('should bubble up AccessDenied permissions error without retrying', async () => { const accessDeniedError = new Error('Access Denied'); accessDeniedError.name = 'AccessDenied'; @@ -175,7 +199,7 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucketCommand throws error (bucket doesn't exist) .mockRejectedValueOnce(accessDeniedError); // First CreateBucketCommand fails with AccessDenied - await expect(createS3Bucket(mockClients, 'us-east-1', 'permission-denied-bucket', '123456789012', 3, 1)) + await expect(createS3Bucket(mockClients, 'us-east-1', 'permission-denied-bucket', '123456789012', defaultCtx)) .rejects.toThrow('Access Denied'); expect(mockSend).toHaveBeenCalledTimes(2); // 1 HeadBucket + 1 CreateBucket (no retries on AccessDenied) @@ -191,7 +215,8 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(bucketExistsError) // CreateBucketCommand attempt 2 fails (retry 1) .mockRejectedValueOnce(bucketExistsError); // CreateBucketCommand attempt 3 fails (retry 2) - await expect(createS3Bucket(mockClients, 'eu-west-1', 'taken-bucket-name', '123456789012', 2, 1)) + const ctx2Retries: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 1 }; + await expect(createS3Bucket(mockClients, 'eu-west-1', 'taken-bucket-name', '123456789012', ctx2Retries)) .rejects.toThrow('Create S3 bucket failed after 3 attempts (2 retries): The requested bucket name is not available'); expect(mockSend).toHaveBeenCalledTimes(4); // 1 HeadBucket + 3 CreateBucket attempts @@ -206,7 +231,8 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(invalidNameError) // CreateBucketCommand attempt 1 fails .mockRejectedValueOnce(invalidNameError); // CreateBucketCommand attempt 2 fails (retry 1) - await expect(createS3Bucket(mockClients, 'us-west-2', 'Invalid_Bucket_Name', '123456789012', 1, 1)) + const ctx1Retry: DeploymentContext = { verboseLogging: true, maxRetries: 1, retryDelay: 1 }; + await expect(createS3Bucket(mockClients, 'us-west-2', 'Invalid_Bucket_Name', '123456789012', ctx1Retry)) .rejects.toThrow('Create S3 bucket failed after 2 attempts (1 retry): The specified bucket is not valid'); expect(mockSend).toHaveBeenCalledTimes(3); // 1 HeadBucket + 2 CreateBucket attempts @@ -223,7 +249,8 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockRejectedValueOnce(uploadError); // PutObject fails with non-retryable AccessDenied - await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', 2, 1, false)) + const ctx2Retries: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 1 }; + await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', false, ctx2Retries)) .rejects.toThrow('Access Denied'); expect(mockSend).toHaveBeenCalledTimes(2); // 1 HeadBucket + 1 PutObject @@ -241,7 +268,7 @@ describe('S3 Operations', () => { .mockRejectedValueOnce(noSuchBucketError) // PutObject attempt 3 fails (retry 2) .mockRejectedValueOnce(noSuchBucketError); // PutObject attempt 4 fails (retry 3) - await expect(uploadToS3(mockClients, 'eu-central-1', '987654321098', 'test-app', 'v2.0.0', 'deploy.jar', 3, 1, false)) + await expect(uploadToS3(mockClients, 'eu-central-1', '987654321098', 'test-app', 'v2.0.0', 'deploy.jar', false, defaultCtx)) .rejects.toThrow('Upload to S3 failed after 4 attempts (3 retries): The specified bucket does not exist'); expect(mockSend).toHaveBeenCalledTimes(5); // 1 HeadBucket + 4 PutObject attempts @@ -253,7 +280,7 @@ describe('S3 Operations', () => { const oversizedPackage = 600 * 1024 * 1024; // 600 MB mockedFs.statSync.mockReturnValue({ size: oversizedPackage } as any); - await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'large-app.zip', 3, 1, false)) + await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'large-app.zip', false, defaultCtx)) .rejects.toThrow('exceeds the maximum allowed size of 500 MB'); // Should fail before any AWS API calls @@ -267,7 +294,7 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockResolvedValueOnce({}); // PutObject - const result = await uploadToS3(mockClients, 'us-west-2', '123456789012', 'my-app', 'v2.0.0', 'valid-app.zip', 3, 1, false); + const result = await uploadToS3(mockClients, 'us-west-2', '123456789012', 'my-app', 'v2.0.0', 'valid-app.zip', false, defaultCtx); expect(result).toEqual({ bucket: 'elasticbeanstalk-us-west-2-123456789012', @@ -283,7 +310,7 @@ describe('S3 Operations', () => { .mockResolvedValueOnce({}) // HeadBucket (ownership check) .mockResolvedValueOnce({}); // PutObject - const result = await uploadToS3(mockClients, 'eu-west-1', '987654321098', 'test-app', 'v1.0.0', 'exact-app.zip', 3, 1, false); + const result = await uploadToS3(mockClients, 'eu-west-1', '987654321098', 'test-app', 'v1.0.0', 'exact-app.zip', false, defaultCtx); expect(result).toEqual({ bucket: 'elasticbeanstalk-eu-west-1-987654321098', @@ -296,7 +323,7 @@ describe('S3 Operations', () => { const justOverLimit = (500 * 1024 * 1024) + 1; // 500 MB + 1 byte mockedFs.statSync.mockReturnValue({ size: justOverLimit } as any); - await expect(uploadToS3(mockClients, 'ap-southeast-1', '111222333444', 'app', 'v3.0.0', 'over-limit.zip', 3, 1, false)) + await expect(uploadToS3(mockClients, 'ap-southeast-1', '111222333444', 'app', 'v3.0.0', 'over-limit.zip', false, defaultCtx)) .rejects.toThrow('exceeds the maximum allowed size of 500 MB'); expect(mockSend).not.toHaveBeenCalled(); diff --git a/src/__tests__/validations.test.ts b/src/__tests__/validations.test.ts index 01afe47d..01d78e10 100644 --- a/src/__tests__/validations.test.ts +++ b/src/__tests__/validations.test.ts @@ -87,6 +87,7 @@ describe('Validation Functions', () => { ]); return ''; }); + mockedCore.getBooleanInput.mockReturnValue(true); const result = validateAllInputs(); @@ -94,6 +95,23 @@ describe('Validation Functions', () => { expect(mockedCore.setFailed).toHaveBeenCalledWith('Invalid AWS region format: invalid-region. Expected format like \'us-east-1\' or \'us-gov-east-1\''); }); + it('should suppress region in error when verbose-logging is false', () => { + mockedCore.getInput.mockImplementation((name: string) => { + if (name === 'aws-region') return 'invalid-region'; + if (name === 'application-name') return 'test-app'; + if (name === 'environment-name') return 'test-env'; + return ''; + }); + mockedCore.getBooleanInput.mockReturnValue(false); + + const result = validateAllInputs(); + + expect(result.valid).toBe(false); + const failedMessage = mockedCore.setFailed.mock.calls[0][0] as string; + expect(failedMessage).not.toContain('invalid-region'); + expect(failedMessage).toContain("Expected format like 'us-east-1' or 'us-gov-east-1'"); + }); + it('should validate successfully for GovCloud regions', () => { mockedCore.getInput.mockImplementation((name: string) => { const inputs: Record = { diff --git a/src/__tests__/version_management.test.ts b/src/__tests__/version_management.test.ts index 2cb0e925..0bd21037 100644 --- a/src/__tests__/version_management.test.ts +++ b/src/__tests__/version_management.test.ts @@ -1,5 +1,6 @@ import { applicationVersionExists, getVersionS3Location, createApplicationVersion } from '../aws-operations'; import { AWSClients } from '../aws-clients'; +import { DeploymentContext } from '../logging'; // Mock dependencies jest.mock('@actions/core', () => ({ @@ -23,6 +24,8 @@ jest.mock('@aws-sdk/client-elastic-beanstalk', () => ({ CreateApplicationCommand: jest.fn(), })); +const defaultCtx: DeploymentContext = { verboseLogging: true, maxRetries: 3, retryDelay: 1 }; + describe('Version Management', () => { let mockClients: AWSClients; @@ -37,7 +40,7 @@ describe('Version Management', () => { ApplicationVersions: [{ VersionLabel: 'v1.0.0' }], }); - const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0'); + const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0', defaultCtx); expect(result).toBe(true); }); @@ -47,7 +50,7 @@ describe('Version Management', () => { ApplicationVersions: [], }); - const result = await applicationVersionExists(mockClients, 'my-app', 'v2.0.0'); + const result = await applicationVersionExists(mockClients, 'my-app', 'v2.0.0', defaultCtx); expect(result).toBe(false); }); @@ -55,7 +58,7 @@ describe('Version Management', () => { it('should return false on error', async () => { mockSend.mockRejectedValue(new Error('API Error')); - const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0'); + const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0', defaultCtx); expect(result).toBe(false); }); @@ -63,10 +66,23 @@ describe('Version Management', () => { it('should handle empty response', async () => { mockSend.mockResolvedValue({}); - const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0'); + const result = await applicationVersionExists(mockClients, 'my-app', 'v1.0.0', defaultCtx); expect(result).toBe(false); }); + + it('should not include version label in debug log when verbose-logging is false', async () => { + const core = require('@actions/core'); + mockSend.mockRejectedValue(new Error('API Error')); + + const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 }; + await applicationVersionExists(mockClients, 'my-app', 'v1.0.0', quietCtx); + + const debugCalls = core.debug.mock.calls.map((c: any[]) => c[0]); + const relevantCall = debugCalls.find((c: string) => c.includes('application version')); + expect(relevantCall).toBeDefined(); + expect(relevantCall).not.toContain('v1.0.0'); + }); }); describe('getVersionS3Location', () => { @@ -81,7 +97,7 @@ describe('Version Management', () => { }], }); - const result = await getVersionS3Location(mockClients, 'my-app', 'v1.0.0'); + const result = await getVersionS3Location(mockClients, 'my-app', 'v1.0.0', defaultCtx); expect(result).toEqual({ bucket: 'my-bucket', @@ -94,7 +110,7 @@ describe('Version Management', () => { ApplicationVersions: [], }); - await expect(getVersionS3Location(mockClients, 'my-app', 'v2.0.0')) + await expect(getVersionS3Location(mockClients, 'my-app', 'v2.0.0', defaultCtx)) .rejects.toThrow('Version v2.0.0 not found'); }); @@ -105,7 +121,7 @@ describe('Version Management', () => { }], }); - await expect(getVersionS3Location(mockClients, 'my-app', 'v1.0.0')) + await expect(getVersionS3Location(mockClients, 'my-app', 'v1.0.0', defaultCtx)) .rejects.toThrow('has incomplete S3 source bundle information'); }); @@ -119,9 +135,25 @@ describe('Version Management', () => { }], }); - await expect(getVersionS3Location(mockClients, 'my-app', 'v1.0.0')) + await expect(getVersionS3Location(mockClients, 'my-app', 'v1.0.0', defaultCtx)) .rejects.toThrow('has incomplete S3 source bundle information'); }); + + it('should not include version label in errors when verbose-logging is false', async () => { + mockSend.mockResolvedValue({ + ApplicationVersions: [], + }); + + const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 }; + try { + await getVersionS3Location(mockClients, 'my-app', 'v1.0.0', quietCtx); + fail('Expected error to be thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).not.toContain('v1.0.0'); + expect(message).toContain('Application version not found'); + } + }); }); describe('createApplicationVersion', () => { @@ -134,9 +166,8 @@ describe('Version Management', () => { 'v1.0.0', 'my-bucket', 'my-app/v1.0.0.zip', - 3, - 1, - false + false, + defaultCtx, ); expect(mockSend).toHaveBeenCalled(); @@ -153,9 +184,8 @@ describe('Version Management', () => { 'v1.0.0', 'my-bucket', 'new-app/v1.0.0.zip', - 3, - 1, - true + true, + defaultCtx, ); expect(mockSend).toHaveBeenCalledTimes(2); @@ -164,20 +194,43 @@ describe('Version Management', () => { it('should handle version creation with different S3 paths', async () => { mockSend.mockResolvedValue({}); + const euroCtx: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 5 }; await createApplicationVersion( mockClients, 'euro-app', 'abc123', 'elasticbeanstalk-eu-west-1-123456', 'euro-app/abc123.jar', - 2, - 5, - false + false, + euroCtx, ); expect(mockSend).toHaveBeenCalled(); }); + it('should not log version label when verboseLogging is false', async () => { + const core = require('@actions/core'); + mockSend.mockResolvedValue({}); + + const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 }; + await createApplicationVersion( + mockClients, + 'my-app', + 'v1.0.0', + 'my-bucket', + 'my-app/v1.0.0.zip', + false, + quietCtx, + ); + + const infoCalls = core.info.mock.calls.map((c: any[]) => c[0]); + // Should log generic messages without version label + expect(infoCalls).toContainEqual('📝 Creating application version'); + expect(infoCalls).toContainEqual('✅ Application version created'); + // Should NOT contain version label in any info call + expect(infoCalls).not.toContainEqual(expect.stringContaining('v1.0.0')); + }); + it('should fail fast when application version already exists', async () => { const existingVersionError = new Error('Application Version v1.0.0 already exists.'); (existingVersionError as any).name = 'InvalidParameterValueException'; @@ -191,9 +244,8 @@ describe('Version Management', () => { 'v1.0.0', 'my-bucket', 'my-app/v1.0.0.zip', - 3, - 1, - false + false, + defaultCtx, ) ).rejects.toThrow('Application Version v1.0.0 already exists.'); diff --git a/src/aws-operations.ts b/src/aws-operations.ts index 28db30a7..b1ca2686 100644 --- a/src/aws-operations.ts +++ b/src/aws-operations.ts @@ -13,6 +13,7 @@ import { GetCallerIdentityCommand } from '@aws-sdk/client-sts'; import * as fs from 'fs'; import * as path from 'path'; import { AWSClients } from './aws-clients'; +import { DeploymentContext, logInfo, logError } from './logging'; import { parseJsonInput } from './validations'; /** @@ -35,12 +36,12 @@ export function validateOptionSettingsForCreate(optionSettingsJson: string | und let hasServiceRole = false; for (const setting of parsedSettings) { - if (setting.Namespace === 'aws:autoscaling:launchconfiguration' && + if (setting.Namespace === 'aws:autoscaling:launchconfiguration' && setting.OptionName === 'IamInstanceProfile') { hasIamInstanceProfile = true; } - if (setting.Namespace === 'aws:elasticbeanstalk:environment' && + if (setting.Namespace === 'aws:elasticbeanstalk:environment' && setting.OptionName === 'ServiceRole') { hasServiceRole = true; } @@ -93,13 +94,12 @@ export type AWSS3Region = typeof AWS_S3_REGIONS[number]; */ export async function retryWithBackoff( fn: () => Promise, - maxRetries: number, - retryDelay: number, - operationName: string + operationName: string, + ctx: DeploymentContext, ): Promise { let lastError: Error | undefined; - const totalAttempts = maxRetries + 1; + const totalAttempts = ctx.maxRetries + 1; for (let attempt = 1; attempt <= totalAttempts; attempt++) { try { @@ -126,16 +126,23 @@ export async function retryWithBackoff( lastError = err; if (attempt < totalAttempts) { - const delay = retryDelay * Math.pow(2, attempt - 1); + const delay = ctx.retryDelay * Math.pow(2, attempt - 1); core.warning(`❌ ${operationName} failed (attempt ${attempt}/${totalAttempts}). Retrying in ${delay}s...`); await new Promise(resolve => setTimeout(resolve, delay * 1000)); } } } - const retryWord = maxRetries === 1 ? 'retry' : 'retries'; - const errorMessage = `${operationName} failed after ${totalAttempts} attempts (${maxRetries} ${retryWord}): ${lastError?.message}`; - core.error(errorMessage); + const retryWord = ctx.maxRetries === 1 ? 'retry' : 'retries'; + const errorMessage = `${operationName} failed after ${totalAttempts} attempts (${ctx.maxRetries} ${retryWord}): ${lastError?.message}`; + logError( + ctx, + errorMessage, + `${operationName} failed after ${totalAttempts} attempts (${ctx.maxRetries} ${retryWord})` + ); + if (!ctx.verboseLogging) { + core.debug(`Retry error detail: ${lastError?.message}`); + } throw new Error(errorMessage); } @@ -144,8 +151,7 @@ export async function retryWithBackoff( */ export async function getAwsAccountId( clients: AWSClients, - maxRetries: number, - retryDelay: number + ctx: DeploymentContext, ): Promise { return retryWithBackoff( async () => { @@ -153,9 +159,8 @@ export async function getAwsAccountId( const response = await clients.getSTSClient().send(command); return response.Account!; }, - maxRetries, - retryDelay, - 'Get AWS Account ID' + 'Get AWS Account ID', + ctx, ); } @@ -165,7 +170,8 @@ export async function getAwsAccountId( export async function applicationVersionExists( clients: AWSClients, applicationName: string, - versionLabel: string + versionLabel: string, + ctx: DeploymentContext, ): Promise { try { const command = new DescribeApplicationVersionsCommand({ @@ -176,7 +182,9 @@ export async function applicationVersionExists( const response = await clients.getElasticBeanstalkClient().send(command); return (response.ApplicationVersions?.length ?? 0) > 0; } catch (error) { - core.debug(`Error checking application version ${versionLabel} existence: ${error}`); + core.debug(ctx.verboseLogging + ? `Error checking application version ${versionLabel} existence: ${error}` + : `Error checking application version existence: ${error}`); return false; } } @@ -187,7 +195,8 @@ export async function applicationVersionExists( export async function getVersionS3Location( clients: AWSClients, applicationName: string, - versionLabel: string + versionLabel: string, + ctx: DeploymentContext, ): Promise<{ bucket: string; key: string }> { try { const command = new DescribeApplicationVersionsCommand({ @@ -198,7 +207,9 @@ export async function getVersionS3Location( const response = await clients.getElasticBeanstalkClient().send(command); if (!response.ApplicationVersions || response.ApplicationVersions.length === 0) { - throw new Error(`Version ${versionLabel} not found`); + throw new Error(ctx.verboseLogging + ? `Version ${versionLabel} not found` + : 'Application version not found'); } const version = response.ApplicationVersions[0]; @@ -206,15 +217,18 @@ export async function getVersionS3Location( const key = version.SourceBundle?.S3Key; if (!bucket || !key) { - throw new Error( - `Application Version ${versionLabel} has incomplete S3 source bundle information. ` + - `Bucket ${bucket ? 'found' : 'missing'}, Key ${key ? 'found' : 'missing'}` - ); + throw new Error(ctx.verboseLogging + ? `Application Version ${versionLabel} has incomplete S3 source bundle information. ` + + `Bucket ${bucket ? 'found' : 'missing'}, Key ${key ? 'found' : 'missing'}` + : `Application version has incomplete S3 source bundle information. ` + + `Bucket ${bucket ? 'found' : 'missing'}, Key ${key ? 'found' : 'missing'}`); } return { bucket, key }; } catch (error) { - throw new Error(`Failed to get S3 location for application version ${versionLabel}: ${error}`); + throw new Error(ctx.verboseLogging + ? `Failed to get S3 location for application version ${versionLabel}: ${error}` + : `Failed to get S3 location for application version: ${error}`); } } @@ -224,7 +238,8 @@ export async function getVersionS3Location( export async function environmentExists( clients: AWSClients, applicationName: string, - environmentName: string + environmentName: string, + ctx: DeploymentContext, ): Promise<{ exists: boolean; status?: string; health?: string }> { try { const command = new DescribeEnvironmentsCommand({ @@ -238,13 +253,21 @@ export async function environmentExists( const env = response.Environments[0]; const status = env.Status; const health = env.Health; - core.info(`Environment ${environmentName} found - Status: ${status}, Health: ${health}`); + logInfo( + ctx, + `Environment ${environmentName} found - Status: ${status}, Health: ${health}`, + `Environment found - Status: ${status}, Health: ${health}` + ); const exists = status !== 'Terminated'; return { exists, status, health }; } - core.info(`No environments found with name ${environmentName}`); + logInfo( + ctx, + `No environments found with name ${environmentName}`, + 'No environments found with the specified name' + ); return { exists: false }; } catch (error) { const err = error as Error & { name?: string; $metadata?: { httpStatusCode?: number } }; @@ -268,10 +291,9 @@ export async function uploadToS3( applicationName: string, versionLabel: string, packagePath: string, - maxRetries: number, - retryDelay: number, createBucketIfNotExists: boolean, - customBucketName?: string + ctx: DeploymentContext, + customBucketName?: string, ): Promise<{ bucket: string; key: string }> { const bucket = customBucketName || `elasticbeanstalk-${region}-${accountId}`; const packageExtension = path.extname(packagePath); @@ -291,7 +313,7 @@ export async function uploadToS3( } if (createBucketIfNotExists) { - await createS3Bucket(clients, region, bucket, accountId, maxRetries, retryDelay); + await createS3Bucket(clients, region, bucket, accountId, ctx); } else { // Verify bucket exists and is owned by this account before uploading. // ExpectedBucketOwner causes a 403 if owned by a different account, @@ -316,9 +338,8 @@ export async function uploadToS3( await clients.getS3Client().send(command); }, - maxRetries, - retryDelay, - 'Upload to S3' + 'Upload to S3', + ctx, ); core.info('✅ Upload complete'); @@ -333,8 +354,7 @@ export async function createS3Bucket( region: string, bucket: string, accountId: string, - maxRetries: number, - retryDelay: number + ctx: DeploymentContext, ): Promise { try { core.info('🪣 Checking if S3 bucket exists'); @@ -351,9 +371,11 @@ export async function createS3Bucket( const err = error as Error & { $metadata?: { httpStatusCode?: number } }; if (err.$metadata?.httpStatusCode === 403) { + const detail = ctx.verboseLogging + ? `S3 bucket '${bucket}' exists but is not owned by this AWS account (${accountId}).` + : 'S3 bucket exists but is not owned by this AWS account.'; throw new Error( - `S3 bucket '${bucket}' exists but is not owned by this AWS account (${accountId}). ` + - 'Specify a different bucket name using the s3-bucket-name input.' + `${detail} Specify a different bucket name using the s3-bucket-name input.` ); } @@ -372,9 +394,8 @@ export async function createS3Bucket( await clients.getS3Client().send(new CreateBucketCommand(createParams)); }, - maxRetries, - retryDelay, - 'Create S3 bucket' + 'Create S3 bucket', + ctx, ); core.info('✅ S3 bucket created'); @@ -390,11 +411,10 @@ export async function createApplicationVersion( versionLabel: string, s3Bucket: string, s3Key: string, - maxRetries: number, - retryDelay: number, - autoCreateApplication: boolean + autoCreateApplication: boolean, + ctx: DeploymentContext, ): Promise { - core.info(`📝 Creating application version: ${versionLabel}`); + logInfo(ctx, `📝 Creating application version: ${versionLabel}`, '📝 Creating application version'); await retryWithBackoff( async () => { @@ -411,12 +431,11 @@ export async function createApplicationVersion( await clients.getElasticBeanstalkClient().send(command); }, - maxRetries, - retryDelay, - 'Create application version' + 'Create application version', + ctx, ); - core.info(`✅ Application version ${versionLabel} created`); + logInfo(ctx, `✅ Application version ${versionLabel} created`, '✅ Application version created'); } /** @@ -430,10 +449,9 @@ export async function updateEnvironment( optionSettings: string | undefined, solutionStackName: string | undefined, platformArn: string | undefined, - maxRetries: number, - retryDelay: number + ctx: DeploymentContext, ): Promise { - core.info(`🔄 Updating environment: ${environmentName}`); + logInfo(ctx, `🔄 Updating environment: ${environmentName}`, '🔄 Updating environment'); let parsedOptionSettings: Array<{ Namespace?: string; @@ -471,12 +489,11 @@ export async function updateEnvironment( await clients.getElasticBeanstalkClient().send(command); }, - maxRetries, - retryDelay, - 'Update environment' + 'Update environment', + ctx, ); - core.info(`✅ Environment update initiated for ${environmentName}`); + logInfo(ctx, `✅ Environment update initiated for ${environmentName}`, '✅ Environment update initiated'); } /** @@ -491,10 +508,9 @@ export async function createEnvironment( solutionStackName: string | undefined, platformArn: string | undefined, cnamePrefix: string | undefined, - maxRetries: number, - retryDelay: number + ctx: DeploymentContext, ): Promise { - core.info(`🆕 Creating new environment: ${environmentName}`); + logInfo(ctx, `🆕 Creating new environment: ${environmentName}`, '🆕 Creating new environment'); const optionSettings = parseJsonInput(optionSettingsJson, 'option-settings'); @@ -517,12 +533,11 @@ export async function createEnvironment( await clients.getElasticBeanstalkClient().send(command); }, - maxRetries, - retryDelay, - 'Create environment' + 'Create environment', + ctx, ); - core.info(`✅ Environment creation initiated for ${environmentName}`); + logInfo(ctx, `✅ Environment creation initiated for ${environmentName}`, '✅ Environment creation initiated'); } /** @@ -531,7 +546,8 @@ export async function createEnvironment( export async function getEnvironmentInfo( clients: AWSClients, applicationName: string, - environmentName: string + environmentName: string, + ctx: DeploymentContext, ): Promise<{ url: string; id: string; status: string; health: string }> { const command = new DescribeEnvironmentsCommand({ ApplicationName: applicationName, @@ -541,7 +557,9 @@ export async function getEnvironmentInfo( const response = await clients.getElasticBeanstalkClient().send(command); if (!response.Environments || response.Environments.length === 0) { - throw new Error(`Environment ${environmentName} not found after deployment`); + throw new Error(ctx.verboseLogging + ? `Environment ${environmentName} not found after deployment` + : 'Environment not found after deployment'); } const env = response.Environments[0]; diff --git a/src/deploymentpackage.ts b/src/deploymentpackage.ts index a3d0249a..40be3475 100644 --- a/src/deploymentpackage.ts +++ b/src/deploymentpackage.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import archiver from 'archiver'; import ignore, { Ignore } from 'ignore'; +import { DeploymentContext, logInfo } from './logging'; /** * Loads ignore patterns from .ebignore or .gitignore (EB CLI behavior). @@ -82,30 +83,45 @@ export async function createDeploymentPackage( packagePath: string | undefined, versionLabel: string, excludePatternsInput: string, - sourceDirectory?: string + sourceDirectory?: string, + ctx?: DeploymentContext, ): Promise<{ path: string }> { if (packagePath) { if (!fs.existsSync(packagePath)) { throw new Error( - `deployment-package-path '${packagePath}' does not exist. ` + - 'Either provide a valid file path or omit deployment-package-path to have the action create a package automatically.' + (ctx?.verboseLogging ?? true) + ? `deployment-package-path '${packagePath}' does not exist. ` + + 'Either provide a valid file path or omit deployment-package-path to have the action create a package automatically.' + : 'deployment-package-path does not exist. ' + + 'Either provide a valid file path or omit deployment-package-path to have the action create a package automatically.' ); } const stats = fs.statSync(packagePath); if (!stats.isFile()) { throw new Error( - `deployment-package-path '${packagePath}' is not a file. ` + - 'It must point to an existing deployment archive file (e.g., .zip, .war).' + (ctx?.verboseLogging ?? true) + ? `deployment-package-path '${packagePath}' is not a file. ` + + 'It must point to an existing deployment archive file (e.g., .zip, .war).' + : 'deployment-package-path is not a file. ' + + 'It must point to an existing deployment archive file (e.g., .zip, .war).' ); } - core.info(`📦 Using existing deployment package: ${packagePath}`); + if (ctx) { + logInfo(ctx, `📦 Using existing deployment package: ${packagePath}`, '📦 Using existing deployment package'); + } else { + core.info(`📦 Using existing deployment package: ${packagePath}`); + } return { path: packagePath }; } const zipFileName = `deploy-${versionLabel}.zip`; - core.info(`📦 Creating deployment package: ${zipFileName}`); + if (ctx) { + logInfo(ctx, `📦 Creating deployment package: ${zipFileName}`, '📦 Creating deployment package'); + } else { + core.info(`📦 Creating deployment package: ${zipFileName}`); + } const excludePatterns = excludePatternsInput .split(',') diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 00000000..12184bfb --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,35 @@ +import * as core from '@actions/core'; + +/** + * Cross-cutting deployment configuration that is threaded through all operations. + * Avoids passing verboseLogging, maxRetries, and retryDelay as individual parameters. + */ +export interface DeploymentContext { + verboseLogging: boolean; + maxRetries: number; + retryDelay: number; +} + +/** + * Log an info message. When verbose is false, uses the redacted alternative. + * If redacted is omitted, the message is suppressed entirely when verbose is off. + */ +export function logInfo(ctx: DeploymentContext, verbose: string, redacted?: string): void { + if (ctx.verboseLogging) { + core.info(verbose); + } else if (redacted !== undefined) { + core.info(redacted); + } +} + +/** + * Log an error message. When verbose is false, uses the redacted alternative. + * If redacted is omitted, the message is suppressed entirely when verbose is off. + */ +export function logError(ctx: DeploymentContext, verbose: string, redacted?: string): void { + if (ctx.verboseLogging) { + core.error(verbose); + } else if (redacted !== undefined) { + core.error(redacted); + } +} diff --git a/src/main.ts b/src/main.ts index 467625bc..04672f7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import * as core from '@actions/core'; import { validateAllInputs, Inputs } from './validations'; import { AWSClients } from './aws-clients'; +import { DeploymentContext, logInfo } from './logging'; import { createDeploymentPackage } from './deploymentpackage'; import { getAwsAccountId, @@ -18,6 +19,9 @@ import { waitForDeploymentCompletion, waitForHealthRecovery } from './monitoring export async function run(): Promise { const startTime = Date.now(); + // Hoist verboseLogging so it is accessible in the catch block for error gating. + // Defaults to false (quiet) if validation fails before inputs are parsed. + let verboseLogging = false; try { core.info('🚀 Starting Elastic Beanstalk deployment...'); @@ -33,21 +37,27 @@ export async function run(): Promise { createEnvironmentIfNotExists, createApplicationIfNotExists, waitForDeployment, waitForEnvironmentRecovery, deploymentTimeout, maxRetries, retryDelay, useExistingApplicationVersionIfAvailable, createS3BucketIfNotExists, s3BucketName, cnamePrefix, excludePatterns, - optionSettings + optionSettings, verboseLogging: verboseLoggingInput } = inputs as Inputs; + verboseLogging = verboseLoggingInput; + + // Build the deployment context once; pass it everywhere instead of individual flags. + const ctx: DeploymentContext = { verboseLogging, maxRetries, retryDelay }; core.startGroup('📋 Validating inputs'); - core.info(`Application: ${applicationName}`); - core.info(`Environment: ${environmentName}`); - core.info(`Version: ${applicationVersionLabel}`); - core.info(`Region: ${awsRegion}`); + if (verboseLogging) { + core.info(`Application: ${applicationName}`); + core.info(`Environment: ${environmentName}`); + core.info(`Version: ${applicationVersionLabel}`); + core.info(`Region: ${awsRegion}`); + } core.endGroup(); // Initialize AWS clients singleton const clients = AWSClients.getInstance(awsRegion); core.startGroup('🔐 Getting AWS account information'); - const accountId = await getAwsAccountId(clients, maxRetries, retryDelay); + const accountId = await getAwsAccountId(clients, ctx); core.info('✅ AWS account verified'); core.endGroup(); @@ -56,14 +66,15 @@ export async function run(): Promise { deploymentPackagePath, applicationVersionLabel, excludePatterns, - sourceDirectory + sourceDirectory, + ctx, ); core.endGroup(); // Check if we should reuse existing application version let bucket: string; let key: string; - const shouldCreateNewApplicationVersion = !useExistingApplicationVersionIfAvailable || !(await applicationVersionExists(clients, applicationName, applicationVersionLabel)); + const shouldCreateNewApplicationVersion = !useExistingApplicationVersionIfAvailable || !(await applicationVersionExists(clients, applicationName, applicationVersionLabel, ctx)); if (shouldCreateNewApplicationVersion) { core.startGroup('☁️ Uploading to S3'); @@ -74,31 +85,33 @@ export async function run(): Promise { applicationName, applicationVersionLabel, packagePath, - maxRetries, - retryDelay, createS3BucketIfNotExists, - s3BucketName + ctx, + s3BucketName, ); bucket = uploadResult.bucket; key = uploadResult.key; core.endGroup(); - core.startGroup(`📝 Creating application version ${applicationVersionLabel}`); + core.startGroup(verboseLogging ? `📝 Creating application version ${applicationVersionLabel}` : '📝 Creating application version'); await createApplicationVersion( clients, applicationName, applicationVersionLabel, bucket, key, - maxRetries, - retryDelay, - createApplicationIfNotExists + createApplicationIfNotExists, + ctx, ); core.endGroup(); } else { core.startGroup('♻️ Reusing existing version'); - core.info(`Version ${applicationVersionLabel} already exists, skipping S3 upload and version creation`); - const s3Location = await getVersionS3Location(clients, applicationName, applicationVersionLabel); + logInfo( + ctx, + `Version ${applicationVersionLabel} already exists, skipping S3 upload and version creation`, + 'Version already exists, skipping S3 upload and version creation' + ); + const s3Location = await getVersionS3Location(clients, applicationName, applicationVersionLabel, ctx); bucket = s3Location.bucket; key = s3Location.key; core.endGroup(); @@ -108,7 +121,8 @@ export async function run(): Promise { const { exists: envExists } = await environmentExists( clients, applicationName, - environmentName + environmentName, + ctx, ); core.endGroup(); @@ -125,14 +139,15 @@ export async function run(): Promise { optionSettings, solutionStackName, platformArn, - maxRetries, - retryDelay + ctx, ); deploymentActionType = 'update'; core.endGroup(); } else { if (!createEnvironmentIfNotExists) { - throw new Error(`Environment ${environmentName} does not exist and create-environment-if-not-exists is false`); + throw new Error(verboseLogging + ? `Environment ${environmentName} does not exist and create-environment-if-not-exists is false` + : 'Environment does not exist and create-environment-if-not-exists is false'); } // Validate option-settings with IAM roles are provided when creating environment @@ -144,7 +159,7 @@ export async function run(): Promise { } core.startGroup('🆕 Creating new environment'); - + await createEnvironment( clients, applicationName, @@ -154,8 +169,7 @@ export async function run(): Promise { solutionStackName, platformArn, cnamePrefix, - maxRetries, - retryDelay + ctx, ); deploymentActionType = 'create'; core.endGroup(); @@ -164,41 +178,55 @@ export async function run(): Promise { let lastSeenEventDate: Date | undefined; if (waitForDeployment) { core.startGroup('⏳ Waiting for deployment'); - lastSeenEventDate = await waitForDeploymentCompletion(clients, applicationName, environmentName, deploymentTimeout, deploymentActionType, deploymentStartTime); + lastSeenEventDate = await waitForDeploymentCompletion(clients, applicationName, environmentName, deploymentTimeout, ctx, deploymentActionType, deploymentStartTime); core.endGroup(); } if (waitForEnvironmentRecovery) { core.startGroup('🏥 Waiting for environment health'); - await waitForHealthRecovery(clients, applicationName, environmentName, deploymentTimeout, deploymentStartTime, lastSeenEventDate); + await waitForHealthRecovery(clients, applicationName, environmentName, deploymentTimeout, ctx, deploymentStartTime, lastSeenEventDate); core.endGroup(); } - const envInfo = await getEnvironmentInfo(clients, applicationName, environmentName); + const envInfo = await getEnvironmentInfo(clients, applicationName, environmentName, ctx); - core.setOutput('environment-url', envInfo.url); - core.setOutput('environment-id', envInfo.id); + // Always set non-sensitive outputs core.setOutput('environment-status', envInfo.status); core.setOutput('environment-health', envInfo.health); core.setOutput('deployment-action-type', deploymentActionType); - core.setOutput('version-label', applicationVersionLabel); + + // Gate sensitive outputs + if (verboseLogging) { + core.setOutput('environment-url', envInfo.url); + core.setOutput('environment-id', envInfo.id); + core.setOutput('version-label', applicationVersionLabel); + } const totalTime = Math.round((Date.now() - startTime) / 1000); - + core.startGroup('📤 Deployment Outputs'); - core.info(`Environment URL: ${envInfo.url}`); - core.info(`Environment ID: ${envInfo.id}`); core.info(`Environment Status: ${envInfo.status}`); core.info(`Environment Health: ${envInfo.health}`); core.info(`Deployment Action: ${deploymentActionType}`); - core.info(`Application Version Label: ${applicationVersionLabel}`); + if (verboseLogging) { + core.info(`Environment URL: ${envInfo.url}`); + core.info(`Environment ID: ${envInfo.id}`); + core.info(`Application Version Label: ${applicationVersionLabel}`); + } core.endGroup(); - + core.info(`✅ Deployment successful! (${deploymentActionType}) - Total time: ${totalTime}s`); } catch (error) { const totalTime = Math.round((Date.now() - startTime) / 1000); - core.error(`❌ Deployment failed after ${totalTime}s: ${(error as Error).message}`); - core.setFailed(`Deployment failed: ${(error as Error).message}`); + const errorMessage = (error as Error).message; + if (verboseLogging) { + core.error(`❌ Deployment failed after ${totalTime}s: ${errorMessage}`); + core.setFailed(`Deployment failed: ${errorMessage}`); + } else { + core.error(`❌ Deployment failed after ${totalTime}s`); + core.setFailed('Deployment failed (enable verbose-logging for details)'); + core.debug(`Deployment error detail: ${errorMessage}`); + } } } diff --git a/src/monitoring.ts b/src/monitoring.ts index 963abfed..588584eb 100644 --- a/src/monitoring.ts +++ b/src/monitoring.ts @@ -4,6 +4,7 @@ import { DescribeEventsCommand, } from '@aws-sdk/client-elastic-beanstalk'; import { AWSClients } from './aws-clients'; +import { DeploymentContext } from './logging'; /** * Fetch recent environment events for debugging and check for fatal/error events @@ -12,8 +13,9 @@ async function describeRecentEvents( clients: AWSClients, applicationName: string, environmentName: string, + ctx: DeploymentContext, lastSeenEventDate?: Date, - deploymentStartTime?: Date + deploymentStartTime?: Date, ): Promise<{ hasError: boolean; errorMessage?: string; lastEventDate?: Date }> { try { const command = new DescribeEventsCommand({ @@ -44,20 +46,20 @@ async function describeRecentEvents( if (newEvents.length > 0) { // Only show header on first call - if (!lastSeenEventDate) { + if (ctx.verboseLogging && !lastSeenEventDate) { core.info('📋 Recent events:'); } - + // Sort events by timestamp in ascending order (oldest first) const sortedEvents = [...newEvents].sort((a, b) => { const dateA = a.EventDate?.getTime() || 0; const dateB = b.EventDate?.getTime() || 0; return dateA - dateB; }); - + const fatalOrErrorEvents: Array<{ message: string }> = []; let mostRecentDate: Date | undefined; - + sortedEvents.forEach((event) => { const eventDate = event.EventDate; if (eventDate) { @@ -66,18 +68,24 @@ async function describeRecentEvents( mostRecentDate = eventDate; } } - - const timestamp = eventDate?.toISOString() || 'Unknown time'; + const severity = event.Severity || 'INFO'; const message = event.Message || 'No message'; if (severity === 'ERROR' || severity === 'FATAL') { - core.error(` [${timestamp}] ${severity}: ${message}`); fatalOrErrorEvents.push({ message }); - } else if (severity === 'WARN') { - core.warning(` [${timestamp}] ${severity}: ${message}`); - } else { - core.info(` [${timestamp}] ${severity}: ${message}`); + } + + // Only log event details when verbose logging is enabled + if (ctx.verboseLogging) { + const timestamp = eventDate?.toISOString() || 'Unknown time'; + if (severity === 'ERROR' || severity === 'FATAL') { + core.error(` [${timestamp}] ${severity}: ${message}`); + } else if (severity === 'WARN') { + core.warning(` [${timestamp}] ${severity}: ${message}`); + } else { + core.info(` [${timestamp}] ${severity}: ${message}`); + } } }); @@ -85,10 +93,10 @@ async function describeRecentEvents( const errorMessage = fatalOrErrorEvents[0].message || 'Unknown error occurred'; return { hasError: true, errorMessage, lastEventDate: mostRecentDate }; } - + return { hasError: false, lastEventDate: mostRecentDate }; } - } + } return { hasError: false, lastEventDate: lastSeenEventDate }; } catch (error) { // If we can't fetch events, just log and continue @@ -106,8 +114,9 @@ export async function waitForDeploymentCompletion( applicationName: string, environmentName: string, timeout: number, + ctx: DeploymentContext, deploymentActionType?: 'create' | 'update', - deploymentStartTime?: Date + deploymentStartTime?: Date, ): Promise { core.info('⏳ Waiting for deployment to complete...'); @@ -115,7 +124,7 @@ export async function waitForDeploymentCompletion( const maxWait = timeout * 1000; let previousStatus: string | undefined; let lastSeenEventDate: Date | undefined; - + // Poll every 20 seconds for create, 10 seconds for update const pollInterval = deploymentActionType === 'create' ? 20000 : 10000; @@ -137,8 +146,9 @@ export async function waitForDeploymentCompletion( clients, applicationName, environmentName, + ctx, lastSeenEventDate, - deploymentStartTime + deploymentStartTime, ); core.info('✅ Deployment complete'); return finalEvents.lastEventDate || lastSeenEventDate; @@ -149,16 +159,15 @@ export async function waitForDeploymentCompletion( clients, applicationName, environmentName, + ctx, lastSeenEventDate, - deploymentStartTime + deploymentStartTime, ); lastSeenEventDate = eventCheck.lastEventDate; if (eventCheck.hasError) { - throw new Error( - `Environment deployment failed - fatal or error event detected: ${eventCheck.errorMessage}` - ); + throw new Error(`Environment deployment failed - fatal or error event detected: ${eventCheck.errorMessage}`); } // Only log when status changes @@ -172,7 +181,7 @@ export async function waitForDeploymentCompletion( } // Timeout occurred - fetch events to help diagnose - await describeRecentEvents(clients, applicationName, environmentName, lastSeenEventDate, deploymentStartTime); + await describeRecentEvents(clients, applicationName, environmentName, ctx, lastSeenEventDate, deploymentStartTime); throw new Error(`Deployment timed out after ${timeout}s`); } @@ -184,8 +193,9 @@ export async function waitForHealthRecovery( applicationName: string, environmentName: string, timeout: number, + ctx: DeploymentContext, deploymentStartTime?: Date, - lastEventDateFromDeployment?: Date + lastEventDateFromDeployment?: Date, ): Promise { core.info('🏥 Waiting for environment health to recover...'); @@ -218,8 +228,9 @@ export async function waitForHealthRecovery( clients, applicationName, environmentName, + ctx, lastSeenEventDate, - deploymentStartTime + deploymentStartTime, ); if (eventCheck.lastEventDate) { @@ -227,9 +238,7 @@ export async function waitForHealthRecovery( } if (eventCheck.hasError) { - throw new Error( - `Environment health recovery failed - fatal or error event detected: ${eventCheck.errorMessage}` - ); + throw new Error(`Environment health recovery failed - fatal or error event detected: ${eventCheck.errorMessage}`); } if (health === 'Red' && status === 'Ready') { @@ -247,6 +256,6 @@ export async function waitForHealthRecovery( } // Timeout occurred - fetch events to help diagnose - await describeRecentEvents(clients, applicationName, environmentName, lastSeenEventDate, deploymentStartTime); + await describeRecentEvents(clients, applicationName, environmentName, ctx, lastSeenEventDate, deploymentStartTime); throw new Error(`Environment health recovery timed out after ${timeout}s`); } diff --git a/src/validations.ts b/src/validations.ts index a643492e..57623b13 100644 --- a/src/validations.ts +++ b/src/validations.ts @@ -23,6 +23,7 @@ export interface Inputs { sourceDirectory?: string; excludePatterns: string; optionSettings?: string; + verboseLogging: boolean; } function validateRequiredInputs() { @@ -41,7 +42,12 @@ function validateRequiredInputs() { // Validate AWS region format (e.g., us-east-1, eu-west-2, us-gov-east-1) const regionPattern = /^(us(-gov)?|af|ap|ca|eu|il|me|sa)-(north|south|east|west|central|northeast|southeast|northwest|southwest)-\d$/; if (!regionPattern.test(awsRegion)) { - core.setFailed(`Invalid AWS region format: ${awsRegion}. Expected format like 'us-east-1' or 'us-gov-east-1'`); + // Read verbose-logging early so the error can omit the region value when quiet + let verbose = false; + try { verbose = core.getBooleanInput('verbose-logging'); } catch { /* default false */ } + core.setFailed(verbose + ? `Invalid AWS region format: ${awsRegion}. Expected format like 'us-east-1' or 'us-gov-east-1'` + : "Invalid AWS region format. Expected format like 'us-east-1' or 'us-gov-east-1'"); return { valid: false }; } @@ -158,6 +164,7 @@ function validateOptionalInputs() { const waitForEnvironmentRecovery = core.getBooleanInput('wait-for-environment-recovery'); const useExistingApplicationVersionIfAvailable = core.getBooleanInput('use-existing-application-version-if-available'); const createS3BucketIfNotExists = core.getBooleanInput('create-s3-bucket-if-not-exists'); + const verboseLogging = core.getBooleanInput('verbose-logging'); return { valid: true, @@ -173,7 +180,8 @@ function validateOptionalInputs() { s3BucketName, cnamePrefix, excludePatterns, - optionSettings + optionSettings, + verboseLogging }; } @@ -264,7 +272,8 @@ export function validateAllInputs(): { valid: boolean } & Partial { createS3BucketIfNotExists: optionalInputs.createS3BucketIfNotExists!, s3BucketName: optionalInputs.s3BucketName, excludePatterns: optionalInputs.excludePatterns!, - optionSettings: optionalInputs.optionSettings + optionSettings: optionalInputs.optionSettings, + verboseLogging: optionalInputs.verboseLogging! }; checkInputConflicts(validatedInputs);