Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/autocomplete/ResourceStateCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ export class ResourceStateCompletionProvider implements CompletionProvider {
}

log.info(`Retrieving resource details from AWS account with id: ${identifier} and type: ${resource.Type}`);
const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier);
const properties = resourceState?.properties;
if (!properties) {
let properties: string;
try {
const resourceState = await this.resourceStateManager.getResource(resource.Type, identifier);
properties = resourceState.properties;
} catch {
log.info(`No resource found for id: ${identifier} and type: ${resource.Type}`);
return [];
}

const propertiesObj = JSON.parse(properties) as Record<string, string>;
this.applyTransformers(propertiesObj, schema);
this.removeExistingProperties(propertiesObj, resource);
Expand Down
50 changes: 23 additions & 27 deletions src/resourceState/ResourceStateImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class ResourceStateImporter {
completionItem: undefined,
failedImports: {},
successfulImports: {},
failureReasons: {},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be initialized? the type says its optional

};

const generatedLogicalIds = new Set<string>();
Expand All @@ -176,36 +177,31 @@ export class ResourceStateImporter {
for (const resourceIdentifier of resourceSelection.resourceIdentifiers) {
try {
const resourceState = await this.resourceStateManager.getResource(resourceType, resourceIdentifier);
if (resourceState) {
this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier);
const logicalId = this.generateUniqueLogicalId(
resourceType,
resourceIdentifier,
syntaxTree,
generatedLogicalIds,
parentResourceType,
);
generatedLogicalIds.add(logicalId);
fetchedResourceStates.push({
[logicalId]: {
Type: resourceType,
DeletionPolicy:
purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined,
Properties: this.applyTransformations(
resourceState.properties,
schema,
purpose,
logicalId,
),
Metadata: await this.createMetadata(resourceIdentifier, purpose),
},
});
} else {
this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier);
}
this.getOrCreate(importResult.successfulImports, resourceType, []).push(resourceIdentifier);
const logicalId = this.generateUniqueLogicalId(
resourceType,
resourceIdentifier,
syntaxTree,
generatedLogicalIds,
parentResourceType,
);
generatedLogicalIds.add(logicalId);
fetchedResourceStates.push({
[logicalId]: {
Type: resourceType,
DeletionPolicy:
purpose === ResourceStatePurpose.IMPORT ? DeletionPolicyOnImport : undefined,
Properties: this.applyTransformations(resourceState.properties, schema, purpose, logicalId),
Metadata: await this.createMetadata(resourceIdentifier, purpose),
},
});
} catch (error) {
log.error(error, `Error importing resource state for ${resourceType} id: ${resourceIdentifier}`);
this.getOrCreate(importResult.failedImports, resourceType, []).push(resourceIdentifier);
const errorMessage = error instanceof Error ? error.message : String(error);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use extractErrorMessage

importResult.failureReasons ??= {};
importResult.failureReasons[resourceType] ??= {};
importResult.failureReasons[resourceType][resourceIdentifier] = errorMessage;
}
}
}
Expand Down
18 changes: 10 additions & 8 deletions src/resourceState/ResourceStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,14 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable {
this.initializeCounters();
}

@Measure({ name: 'getResource', captureErrorType: true })
public async getResource(typeName: ResourceType, identifier: ResourceId): Promise<ResourceState | undefined> {
@Measure({ name: 'getResource' })
public async getResource(typeName: ResourceType, identifier: ResourceId): Promise<ResourceState> {
const cachedResources = this.getResourceState(typeName, identifier);
if (cachedResources) {
this.telemetry.count('state.hit', 1);
return cachedResources;
}

this.telemetry.count('state.miss', 1);

let output: GetResourceCommandOutput | undefined = undefined;
Expand All @@ -75,20 +76,20 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable {
} catch (error) {
if (error instanceof ResourceNotFoundException) {
log.info(`No resource found for type ${typeName} and identifier "${identifier}"`);
return;
} else {
log.error(error, `CCAPI GetResource failed for type ${typeName} and identifier "${identifier}"`);
}

if (isClientError(error)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this line doing anything anymore? Since there is no more return - it just logs

log.info(`Client error for type ${typeName} and identifier "${identifier}"`);
return;
}
throw error;
}

if (!output?.TypeName || !output?.ResourceDescription?.Identifier || !output?.ResourceDescription?.Properties) {
log.error(
throw new Error(
`GetResource output is missing required fields for type ${typeName} with identifier "${identifier}"`,
);
return;
}

const value: ResourceState = {
Expand Down Expand Up @@ -136,8 +137,9 @@ export class ResourceStateManager implements SettingsConfigurable, Closeable {
typeName: string,
identifier: string,
): Promise<{ found: boolean; resourceList?: ResourceList }> {
const resource = await this.getResource(typeName, identifier);
if (!resource) {
try {
await this.getResource(typeName, identifier);
} catch {
return { found: false };
}

Expand Down
1 change: 1 addition & 0 deletions src/resourceState/ResourceStateTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ResourceStateResult {
completionItem?: CompletionItem;
successfulImports: Record<ResourceType, ResourceIdentifier[]>;
failedImports: Record<ResourceType, ResourceIdentifier[]>;
failureReasons?: Record<ResourceType, Record<ResourceIdentifier, string>>;
warning?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('ResourceStateCompletionProvider', () => {
});

mockComponents.schemaRetriever.getDefault.returns(s3Schemas);
mockComponents.resourceStateManager.getResource.resolves(undefined);
mockComponents.resourceStateManager.getResource.resolves();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this being mocked correctly? Isn't the return type of getResource an actual object?


const result = await provider.getCompletions(context, mockYamlParams);

Expand All @@ -132,12 +132,7 @@ describe('ResourceStateCompletionProvider', () => {
});

mockComponents.schemaRetriever.getDefault.returns(s3Schemas);
mockComponents.resourceStateManager.getResource.resolves({
typeName: 'AWS::S3::Bucket',
identifier: 'test',
properties: '',
createdTimestamp: new Date() as any,
});
mockComponents.resourceStateManager.getResource.rejects(new Error('Invalid resource state'));

const result = await provider.getCompletions(context, mockYamlParams);

Expand Down
8 changes: 7 additions & 1 deletion tst/unit/resourceState/ResourceStateImporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ describe('ResourceStateImporter', () => {

createAndRegisterDocument(uri, scenario.initialContent, scenario.documentType);

// Mock getResource to throw an error
mockResourceStateManager.getResource.mockRejectedValue(new Error('Resource not found'));

const params: ResourceStateParams = {
resourceSelections: [{ resourceType: 'AWS::S3::Bucket', resourceIdentifiers: ['test-bucket'] }],
textDocument: { uri } as any,
Expand Down Expand Up @@ -383,7 +386,10 @@ describe('ResourceStateImporter', () => {
},
];

mockResourceStateManager.getResource.mockResolvedValue(mockResource1);
mockResourceStateManager.getResource
.mockResolvedValueOnce(mockResource1)
.mockResolvedValueOnce(mockResource2)
.mockResolvedValueOnce(mockResource3);

const params: ResourceStateParams = {
resourceSelections,
Expand Down
27 changes: 13 additions & 14 deletions tst/unit/resourceState/ResourceStateManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,35 +73,34 @@ describe('ResourceStateManager', () => {
});
});

it('should handle ResourceNotFoundException', async () => {
it('should throw ResourceNotFoundError for ResourceNotFoundException', async () => {
const error = new ResourceNotFoundException({
message: 'Resource not found',
$metadata: { httpStatusCode: 404 },
});
vi.mocked(mockCcapiService.getResource).mockRejectedValue(error);

const result = await manager.getResource('AWS::S3::Bucket', 'nonexistent');

expect(result).toBeUndefined();
await expect(manager.getResource('AWS::S3::Bucket', 'nonexistent')).rejects.toThrow('Resource not found');
});

it('should throw on server errors', async () => {
it('should rethrow other errors', async () => {
const error = new Error('Service error');
vi.mocked(mockCcapiService.getResource).mockRejectedValue(error);

await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow('Service error');
});

it('should return undefined for client errors', async () => {
const error = { name: 'AccessDeniedException', $metadata: { httpStatusCode: 403 }, message: 'Denied' };
it('should rethrow AccessDeniedException', async () => {
const error = new Error(
'User: arn:aws:sts::123456789012:assumed-role/Limited/user is not authorized to perform: cloudformation:GetResource on resource: arn:aws:cloudformation:us-east-1:123456789012:resource/*',
);
error.name = 'AccessDeniedException';
vi.mocked(mockCcapiService.getResource).mockRejectedValue(error);

const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket');

expect(result).toBeUndefined();
await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow(error.message);
});

it('should handle missing required fields in output', async () => {
it('should throw InvalidResourceOutputError for missing required fields', async () => {
const mockOutput: GetResourceCommandOutput = {
TypeName: 'AWS::S3::Bucket',
ResourceDescription: {
Expand All @@ -112,9 +111,9 @@ describe('ResourceStateManager', () => {
};
vi.mocked(mockCcapiService.getResource).mockResolvedValue(mockOutput);

const result = await manager.getResource('AWS::S3::Bucket', 'my-bucket');

expect(result).toBeUndefined();
await expect(manager.getResource('AWS::S3::Bucket', 'my-bucket')).rejects.toThrow(
'GetResource output is missing required fields',
);
});
});

Expand Down
Loading