Skip to content

Commit a75112e

Browse files
authored
feat: support individual memory deployment without agents (#483)
* feat: support individual memory deployment without agents * fix: address PR review comments on deploy output handling * address comments:
1 parent b852179 commit a75112e

File tree

13 files changed

+344
-99
lines changed

13 files changed

+344
-99
lines changed

src/cli/cloudformation/__tests__/outputs-extended.test.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ describe('buildDeployedState', () => {
157157
},
158158
};
159159

160-
const state = buildDeployedState('default', 'MyStack', agents, {});
160+
const state = buildDeployedState({ targetName: 'default', stackName: 'MyStack', agents, gateways: {} });
161161
expect(state.targets.default).toBeDefined();
162162
expect(state.targets.default!.resources?.agents).toEqual(agents);
163163
expect(state.targets.default!.resources?.stackName).toBe('MyStack');
@@ -181,7 +181,13 @@ describe('buildDeployedState', () => {
181181
DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' },
182182
};
183183

184-
const state = buildDeployedState('dev', 'DevStack', devAgents, {}, existing);
184+
const state = buildDeployedState({
185+
targetName: 'dev',
186+
stackName: 'DevStack',
187+
agents: devAgents,
188+
gateways: {},
189+
existingState: existing,
190+
});
185191
expect(state.targets.prod).toBeDefined();
186192
expect(state.targets.dev).toBeDefined();
187193
expect(state.targets.prod!.resources?.stackName).toBe('ProdStack');
@@ -197,22 +203,34 @@ describe('buildDeployedState', () => {
197203
},
198204
};
199205

200-
const state = buildDeployedState('default', 'NewStack', {}, {}, existing);
206+
const state = buildDeployedState({
207+
targetName: 'default',
208+
stackName: 'NewStack',
209+
agents: {},
210+
gateways: {},
211+
existingState: existing,
212+
});
201213
expect(state.targets.default!.resources?.stackName).toBe('NewStack');
202214
});
203215

204216
it('includes identityKmsKeyArn when provided', () => {
205-
const state = buildDeployedState('default', 'Stack', {}, {}, undefined, 'arn:aws:kms:key');
217+
const state = buildDeployedState({
218+
targetName: 'default',
219+
stackName: 'Stack',
220+
agents: {},
221+
gateways: {},
222+
identityKmsKeyArn: 'arn:aws:kms:key',
223+
});
206224
expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key');
207225
});
208226

209227
it('omits identityKmsKeyArn when undefined', () => {
210-
const state = buildDeployedState('default', 'Stack', {}, {});
228+
const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} });
211229
expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined();
212230
});
213231

214232
it('handles empty agents record', () => {
215-
const state = buildDeployedState('default', 'Stack', {}, {});
216-
expect(state.targets.default!.resources?.agents).toEqual({});
233+
const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} });
234+
expect(state.targets.default!.resources?.agents).toBeUndefined();
217235
});
218236
});

src/cli/cloudformation/__tests__/outputs.test.ts

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildDeployedState, parseGatewayOutputs } from '../outputs';
1+
import { buildDeployedState, parseGatewayOutputs, parseMemoryOutputs } from '../outputs';
22
import { describe, expect, it } from 'vitest';
33

44
describe('buildDeployedState', () => {
@@ -11,14 +11,13 @@ describe('buildDeployedState', () => {
1111
},
1212
};
1313

14-
const result = buildDeployedState(
15-
'default',
16-
'TestStack',
14+
const result = buildDeployedState({
15+
targetName: 'default',
16+
stackName: 'TestStack',
1717
agents,
18-
{},
19-
undefined,
20-
'arn:aws:kms:us-east-1:123456789012:key/abc-123'
21-
);
18+
gateways: {},
19+
identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/abc-123',
20+
});
2221

2322
expect(result.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/abc-123');
2423
});
@@ -32,7 +31,7 @@ describe('buildDeployedState', () => {
3231
},
3332
};
3433

35-
const result = buildDeployedState('default', 'TestStack', agents, {});
34+
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} });
3635

3736
expect(result.targets.default!.resources?.identityKmsKeyArn).toBeUndefined();
3837
});
@@ -49,14 +48,14 @@ describe('buildDeployedState', () => {
4948
},
5049
};
5150

52-
const result = buildDeployedState(
53-
'dev',
54-
'DevStack',
55-
{},
56-
{},
51+
const result = buildDeployedState({
52+
targetName: 'dev',
53+
stackName: 'DevStack',
54+
agents: {},
55+
gateways: {},
5756
existingState,
58-
'arn:aws:kms:us-east-1:123456789012:key/dev-key'
59-
);
57+
identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/dev-key',
58+
});
6059

6160
expect(result.targets.prod!.resources?.stackName).toBe('ProdStack');
6261
expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key');
@@ -77,7 +76,13 @@ describe('buildDeployedState', () => {
7776
},
7877
};
7978

80-
const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials);
79+
const result = buildDeployedState({
80+
targetName: 'default',
81+
stackName: 'TestStack',
82+
agents,
83+
gateways: {},
84+
credentials,
85+
});
8186

8287
expect(result.targets.default!.resources?.credentials).toEqual(credentials);
8388
});
@@ -91,7 +96,7 @@ describe('buildDeployedState', () => {
9196
},
9297
};
9398

94-
const result = buildDeployedState('default', 'TestStack', agents, {});
99+
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} });
95100

96101
expect(result.targets.default!.resources?.credentials).toBeUndefined();
97102
});
@@ -105,10 +110,53 @@ describe('buildDeployedState', () => {
105110
},
106111
};
107112

108-
const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {});
113+
const result = buildDeployedState({
114+
targetName: 'default',
115+
stackName: 'TestStack',
116+
agents,
117+
gateways: {},
118+
credentials: {},
119+
});
109120

110121
expect(result.targets.default!.resources?.credentials).toBeUndefined();
111122
});
123+
124+
it('includes memories in deployed state when provided', () => {
125+
const memories = {
126+
'my-memory': {
127+
memoryId: 'mem-123',
128+
memoryArn: 'arn:aws:bedrock:us-east-1:123456789012:memory/mem-123',
129+
},
130+
};
131+
132+
const result = buildDeployedState({
133+
targetName: 'default',
134+
stackName: 'TestStack',
135+
agents: {},
136+
gateways: {},
137+
memories,
138+
});
139+
140+
expect(result.targets.default!.resources?.memories).toEqual(memories);
141+
});
142+
143+
it('omits memories field when memories is empty object', () => {
144+
const result = buildDeployedState({
145+
targetName: 'default',
146+
stackName: 'TestStack',
147+
agents: {},
148+
gateways: {},
149+
memories: {},
150+
});
151+
152+
expect(result.targets.default!.resources?.memories).toBeUndefined();
153+
});
154+
155+
it('omits agents field when agents is empty object', () => {
156+
const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents: {}, gateways: {} });
157+
158+
expect(result.targets.default!.resources?.agents).toBeUndefined();
159+
});
112160
});
113161

114162
describe('parseGatewayOutputs', () => {
@@ -183,3 +231,57 @@ describe('parseGatewayOutputs', () => {
183231
expect(result['third-gateway']?.gatewayUrl).toBe('https://third.url');
184232
});
185233
});
234+
235+
describe('parseMemoryOutputs', () => {
236+
it('extracts memory outputs matching pattern', () => {
237+
const outputs = {
238+
ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123',
239+
ApplicationMemoryMyMemoryArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123:memory/mem-123',
240+
UnrelatedOutput: 'some-value',
241+
};
242+
243+
const result = parseMemoryOutputs(outputs, ['my-memory']);
244+
245+
expect(result).toEqual({
246+
'my-memory': {
247+
memoryId: 'mem-123',
248+
memoryArn: 'arn:aws:bedrock:us-east-1:123:memory/mem-123',
249+
},
250+
});
251+
});
252+
253+
it('handles multiple memories', () => {
254+
const outputs = {
255+
ApplicationMemoryFirstMemoryIdOutput123: 'mem-1',
256+
ApplicationMemoryFirstMemoryArnOutput123: 'arn:mem-1',
257+
ApplicationMemorySecondMemoryIdOutput456: 'mem-2',
258+
ApplicationMemorySecondMemoryArnOutput456: 'arn:mem-2',
259+
};
260+
261+
const result = parseMemoryOutputs(outputs, ['first-memory', 'second-memory']);
262+
263+
expect(Object.keys(result)).toHaveLength(2);
264+
expect(result['first-memory']?.memoryId).toBe('mem-1');
265+
expect(result['second-memory']?.memoryId).toBe('mem-2');
266+
});
267+
268+
it('returns empty record when no memory outputs found', () => {
269+
const outputs = {
270+
UnrelatedOutput: 'some-value',
271+
};
272+
273+
const result = parseMemoryOutputs(outputs, ['my-memory']);
274+
275+
expect(result).toEqual({});
276+
});
277+
278+
it('skips incomplete memory outputs (missing ARN)', () => {
279+
const outputs = {
280+
ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123',
281+
};
282+
283+
const result = parseMemoryOutputs(outputs, ['my-memory']);
284+
285+
expect(result).toEqual({});
286+
});
287+
});

src/cli/cloudformation/outputs.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AgentCoreDeployedState, DeployedState, TargetDeployedState } from '../../schema';
1+
import type { AgentCoreDeployedState, DeployedState, MemoryDeployedState, TargetDeployedState } from '../../schema';
22
import { getCredentialProvider } from '../aws';
33
import { toPascalId } from './logical-ids';
44
import { getStackName } from './stack-discovery';
@@ -172,21 +172,56 @@ export function parseAgentOutputs(
172172
return agents;
173173
}
174174

175+
/**
176+
* Parse stack outputs into deployed state for memories.
177+
*
178+
* Looks up outputs by constructing the expected key prefix from known memory names
179+
*
180+
* Output key pattern: ApplicationMemory{PascalName}(Id|Arn)Output{Hash}
181+
*/
182+
export function parseMemoryOutputs(outputs: StackOutputs, memoryNames: string[]): Record<string, MemoryDeployedState> {
183+
const memories: Record<string, MemoryDeployedState> = {};
184+
const outputKeys = Object.keys(outputs);
185+
186+
for (const memoryName of memoryNames) {
187+
const pascal = toPascalId(memoryName);
188+
const idPrefix = `ApplicationMemory${pascal}IdOutput`;
189+
const arnPrefix = `ApplicationMemory${pascal}ArnOutput`;
190+
191+
const idKey = outputKeys.find(k => k.startsWith(idPrefix));
192+
const arnKey = outputKeys.find(k => k.startsWith(arnPrefix));
193+
194+
if (idKey && arnKey) {
195+
memories[memoryName] = {
196+
memoryId: outputs[idKey]!,
197+
memoryArn: outputs[arnKey]!,
198+
};
199+
}
200+
}
201+
202+
return memories;
203+
}
204+
205+
export interface BuildDeployedStateOptions {
206+
targetName: string;
207+
stackName: string;
208+
agents: Record<string, AgentCoreDeployedState>;
209+
gateways: Record<string, { gatewayId: string; gatewayArn: string; gatewayUrl?: string }>;
210+
existingState?: DeployedState;
211+
identityKmsKeyArn?: string;
212+
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>;
213+
memories?: Record<string, MemoryDeployedState>;
214+
}
215+
175216
/**
176217
* Build deployed state from stack outputs.
177218
*/
178-
export function buildDeployedState(
179-
targetName: string,
180-
stackName: string,
181-
agents: Record<string, AgentCoreDeployedState>,
182-
gateways: Record<string, { gatewayId: string; gatewayArn: string; gatewayUrl?: string }>,
183-
existingState?: DeployedState,
184-
identityKmsKeyArn?: string,
185-
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>
186-
): DeployedState {
219+
export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedState {
220+
const { targetName, stackName, agents, gateways, existingState, identityKmsKeyArn, credentials, memories } = opts;
187221
const targetState: TargetDeployedState = {
188222
resources: {
189-
agents,
223+
agents: Object.keys(agents).length > 0 ? agents : undefined,
224+
memories: memories && Object.keys(memories).length > 0 ? memories : undefined,
190225
stackName,
191226
identityKmsKeyArn,
192227
},

src/cli/commands/deploy/__tests__/deploy.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@ describe('deploy without agents', () => {
5252
await rm(noAgentTestDir, { recursive: true, force: true });
5353
});
5454

55-
it('rejects deploy when no agents are defined', async () => {
55+
it('rejects deploy when no resources are defined', async () => {
5656
const result = await runCLI(['deploy', '--json'], noAgentProjectDir);
5757
expect(result.exitCode).toBe(1);
5858
const json = JSON.parse(result.stdout);
5959
expect(json.success).toBe(false);
6060
expect(json.error).toBeDefined();
61-
expect(json.error.toLowerCase()).toContain('no agents');
61+
expect(json.error.toLowerCase()).toContain('no resources defined');
6262
});
6363
});

0 commit comments

Comments
 (0)