@@ -11,6 +11,7 @@ import {
1111 type ProjectEvent ,
1212} from "../../handlers/project/types" ;
1313import { createSilentLogger } from "../../testing" ;
14+ import type { CdkEvent , CdkOperation , CdkRunOptions } from "../../io" ;
1415
1516const originalCwd = process . cwd ( ) ;
1617const tempDirectories : string [ ] = [ ] ;
@@ -254,10 +255,22 @@ describe("FsProjectManager.build", () => {
254255
255256 const events = await drain ( subject . build ( project ) ) ;
256257
258+ const cdkDir = join ( directory , "example" , "agentcore" , "cdk" ) ;
257259 expect ( commands ) . toEqual ( [
258260 {
259- command : [ "npm" , "run" , "cdk" , "--" , "synth" , "--quiet" ] ,
260- cwd : join ( directory , "example" , "agentcore" , "cdk" ) ,
261+ // --output pins the assembly where deploy looks for it, so a project that
262+ // sets cdk.json's `output` cannot send synth somewhere deploy never reads.
263+ command : [
264+ "npm" ,
265+ "run" ,
266+ "cdk" ,
267+ "--" ,
268+ "synth" ,
269+ "--quiet" ,
270+ "--output" ,
271+ join ( cdkDir , "cdk.out" ) ,
272+ ] ,
273+ cwd : cdkDir ,
261274 } ,
262275 ] ) ;
263276 expect ( events ) . toEqual ( [ { message : "Synthesizing CloudFormation templates" } ] ) ;
@@ -301,6 +314,289 @@ describe("FsProjectManager.build", () => {
301314 } ) ;
302315} ) ;
303316
317+ describe ( "FsProjectManager.deploy" , ( ) => {
318+ // Synth is pinned to the directory deploy reads, so both name it the same way.
319+ function synthCommand ( directory : string ) : string [ ] {
320+ return [
321+ "npm" ,
322+ "run" ,
323+ "cdk" ,
324+ "--" ,
325+ "synth" ,
326+ "--quiet" ,
327+ "--output" ,
328+ assemblyDirectory ( directory ) ,
329+ ] ;
330+ }
331+ const REGION = "us-west-2" ;
332+
333+ type RecordedCommand = { command : string [ ] ; cwd : string } ;
334+ type RecordedCdkRun = { operation : CdkOperation ; options : CdkRunOptions } ;
335+
336+ // Like manager(), but also records the CDK operations the manager drives, and lets
337+ // a test supply the events one of them emits (and the failure it ends with).
338+ function deployManager (
339+ onCdk ?: ( operation : CdkOperation , emit : ( event : CdkEvent ) => void ) => void ,
340+ ) : { manager : FsProjectManager ; commands : RecordedCommand [ ] ; runs : RecordedCdkRun [ ] } {
341+ const commands : RecordedCommand [ ] = [ ] ;
342+ const runs : RecordedCdkRun [ ] = [ ] ;
343+ return {
344+ manager : new FsProjectManager ( {
345+ logger : createSilentLogger ( ) ,
346+ runner : async ( command , { cwd } ) => {
347+ commands . push ( { command, cwd } ) ;
348+ } ,
349+ cdk : async function * ( operation , options ) {
350+ runs . push ( { operation, options } ) ;
351+ const events : CdkEvent [ ] = [ ] ;
352+ let failure : unknown ;
353+ try {
354+ onCdk ?.( operation , ( event ) => events . push ( event ) ) ;
355+ } catch ( error ) {
356+ failure = error ;
357+ }
358+ // Emit before throwing, as the real runner does: the output explaining a
359+ // failure is only useful if the consumer sees it.
360+ yield * events ;
361+ if ( failure ) throw failure ;
362+ } ,
363+ checkTool : async ( ) => { } ,
364+ } ) ,
365+ commands,
366+ runs,
367+ } ;
368+ }
369+
370+ // The assembly directory every operation reads, which build synthesized into.
371+ function assemblyDirectory ( directory : string ) : string {
372+ return join ( directory , "example" , "agentcore" , "cdk" , "cdk.out" ) ;
373+ }
374+
375+ // deploy() builds first, so the CDK app's node_modules must exist; create() with
376+ // skipInstall never produces them. Targets overwrite the empty list create()
377+ // scaffolds; null leaves that empty list in place, and a string is written verbatim
378+ // so a test can supply invalid JSON.
379+ async function scaffolded (
380+ subject : FsProjectManager ,
381+ directory : string ,
382+ targets : unknown = [ { name : "default" , account : "111122223333" , region : "us-east-1" } ] ,
383+ ) : Promise < Project > {
384+ const { project } = await runCreate ( subject , {
385+ name : "example" ,
386+ template : PROJECT_TEMPLATES . HELLO_WORLD_PYTHON ,
387+ skipInstall : true ,
388+ skipGit : true ,
389+ } ) ;
390+ await mkdir ( join ( directory , "example" , "agentcore" , "cdk" , "node_modules" ) , {
391+ recursive : true ,
392+ } ) ;
393+ if ( targets !== null ) {
394+ await writeFile (
395+ join ( directory , "example" , "agentcore" , "aws-targets.json" ) ,
396+ typeof targets === "string" ? targets : JSON . stringify ( targets ) ,
397+ ) ;
398+ }
399+ return project ;
400+ }
401+
402+ async function drain ( generator : AsyncGenerator < ProjectEvent , void > ) : Promise < ProjectEvent [ ] > {
403+ const events : ProjectEvent [ ] = [ ] ;
404+ for await ( const event of generator ) events . push ( event ) ;
405+ return events ;
406+ }
407+
408+ test ( "synthesizes, bootstraps the target environment, then deploys every stack" , async ( ) => {
409+ const directory = await inTempDirectory ( ) ;
410+ const { manager : subject , commands, runs } = deployManager ( ) ;
411+ const project = await scaffolded ( subject , directory ) ;
412+ commands . length = 0 ; // discard create()'s commands
413+ const cdkDir = join ( directory , "example" , "agentcore" , "cdk" ) ;
414+ const options = { assemblyDirectory : assemblyDirectory ( directory ) , region : REGION } ;
415+
416+ const events = await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ;
417+
418+ // Only synthesis shells out; everything that reaches AWS goes through the toolkit.
419+ expect ( commands ) . toEqual ( [ { command : synthCommand ( directory ) , cwd : cdkDir } ] ) ;
420+ expect ( runs ) . toEqual ( [
421+ { operation : { kind : "bootstrap" , environments : [ "aws://111122223333/us-east-1" ] } , options } ,
422+ { operation : { kind : "deploy" } , options } ,
423+ ] ) ;
424+ expect ( events ) . toEqual ( [
425+ { message : "Synthesizing CloudFormation templates" } ,
426+ { message : "Bootstrapping aws://111122223333/us-east-1" } ,
427+ { message : "Deploying stacks" } ,
428+ ] ) ;
429+ } ) ;
430+
431+ test ( "synthesizes into the same directory the toolkit deploys from" , async ( ) => {
432+ const directory = await inTempDirectory ( ) ;
433+ const { manager : subject , commands, runs } = deployManager ( ) ;
434+ const project = await scaffolded ( subject , directory ) ;
435+ commands . length = 0 ;
436+
437+ await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : true } ) ) ;
438+
439+ // The invariant behind passing --output at all: whatever synth was told to write
440+ // is exactly what the toolkit is handed. Left to cdk.json's `output`, synth could
441+ // write elsewhere and deploy would ship a stale assembly while reporting success.
442+ const assembly = assemblyDirectory ( directory ) ;
443+ expect ( commands [ 0 ] ?. command . at ( - 1 ) ) . toBe ( assembly ) ;
444+ expect ( runs . map ( ( { options } ) => options . assemblyDirectory ) ) . toEqual ( [ assembly ] ) ;
445+ } ) ;
446+
447+ test ( "bootstraps each distinct environment once, however many targets share it" , async ( ) => {
448+ const directory = await inTempDirectory ( ) ;
449+ const { manager : subject , runs } = deployManager ( ) ;
450+ const project = await scaffolded ( subject , directory , [
451+ { name : "alpha" , account : "111122223333" , region : "us-east-1" } ,
452+ { name : "beta" , account : "111122223333" , region : "us-east-1" } , // same environment
453+ { name : "gamma" , account : "444455556666" , region : "eu-west-1" } ,
454+ ] ) ;
455+
456+ await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ;
457+
458+ expect (
459+ runs . flatMap ( ( { operation } ) =>
460+ operation . kind === "bootstrap" ? operation . environments : [ ] ,
461+ ) ,
462+ ) . toEqual ( [ "aws://111122223333/us-east-1" , "aws://444455556666/eu-west-1" ] ) ;
463+ } ) ;
464+
465+ test ( "skips bootstrapping when asked, and still deploys" , async ( ) => {
466+ const directory = await inTempDirectory ( ) ;
467+ const { manager : subject , commands, runs } = deployManager ( ) ;
468+ const project = await scaffolded ( subject , directory ) ;
469+ commands . length = 0 ;
470+ const cdkDir = join ( directory , "example" , "agentcore" , "cdk" ) ;
471+
472+ const events = await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : true } ) ) ;
473+
474+ expect ( commands ) . toEqual ( [ { command : synthCommand ( directory ) , cwd : cdkDir } ] ) ;
475+ expect ( runs . map ( ( { operation } ) => operation ) ) . toEqual ( [ { kind : "deploy" } ] ) ;
476+ expect ( events ) . not . toContainEqual ( { message : "Bootstrapping aws://111122223333/us-east-1" } ) ;
477+ } ) ;
478+
479+ test ( "names the file to fix when no deployment targets are configured" , async ( ) => {
480+ const directory = await inTempDirectory ( ) ;
481+ const { manager : subject , commands, runs } = deployManager ( ) ;
482+ // create() scaffolds an empty list, which is what a fresh project has.
483+ const project = await scaffolded ( subject , directory , null ) ;
484+ commands . length = 0 ;
485+
486+ await expect (
487+ drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ,
488+ ) . rejects . toThrow ( / a w s - t a r g e t s \. j s o n / ) ;
489+ // Nothing ran at all: a deploy with nowhere to go does not even synthesize.
490+ expect ( commands ) . toEqual ( [ ] ) ;
491+ expect ( runs ) . toEqual ( [ ] ) ;
492+ } ) ;
493+
494+ test ( "reports a malformed aws-targets.json as an actionable error" , async ( ) => {
495+ const directory = await inTempDirectory ( ) ;
496+ const { manager : subject } = deployManager ( ) ;
497+ const project = await scaffolded ( subject , directory , "{ not a target list" ) ;
498+
499+ await expect (
500+ drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ,
501+ ) . rejects . toThrow ( / i s n o t a v a l i d l i s t o f d e p l o y m e n t t a r g e t s / ) ;
502+ } ) ;
503+
504+ test ( "rejects targets that omit a required field" , async ( ) => {
505+ const directory = await inTempDirectory ( ) ;
506+ const { manager : subject } = deployManager ( ) ;
507+ const project = await scaffolded ( subject , directory , [
508+ { name : "default" , region : "us-east-1" } ,
509+ ] ) ;
510+
511+ await expect (
512+ drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ,
513+ ) . rejects . toThrow ( / i s n o t a v a l i d l i s t o f d e p l o y m e n t t a r g e t s / ) ;
514+ } ) ;
515+
516+ test ( "streams the CDK toolkit's messages as they arrive" , async ( ) => {
517+ const directory = await inTempDirectory ( ) ;
518+ const { manager : subject } = deployManager ( ( operation , emit ) => {
519+ if ( operation . kind !== "deploy" ) return ;
520+ emit ( { level : "info" , message : "example-stack: creating CloudFormation changeset..." } ) ;
521+ emit ( { level : "result" , message : "example-stack: deployed" } ) ;
522+ } ) ;
523+ const project = await scaffolded ( subject , directory ) ;
524+
525+ const events = await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : true } ) ) ;
526+
527+ expect ( events . map ( ( event ) => event . output ) . filter ( Boolean ) ) . toEqual ( [
528+ "example-stack: creating CloudFormation changeset..." ,
529+ "example-stack: deployed" ,
530+ ] ) ;
531+ } ) ;
532+
533+ test ( "keeps the toolkit's debug and trace messages off screen" , async ( ) => {
534+ const directory = await inTempDirectory ( ) ;
535+ const { manager : subject } = deployManager ( ( operation , emit ) => {
536+ if ( operation . kind !== "deploy" ) return ;
537+ emit ( { level : "debug" , message : "resolved 3 environments" } ) ;
538+ emit ( { level : "trace" , message : "sdk call: DescribeStacks" } ) ;
539+ emit ( { level : "warn" , message : "example-stack: no changes" } ) ;
540+ } ) ;
541+ const project = await scaffolded ( subject , directory ) ;
542+
543+ const events = await drain ( subject . deploy ( project , { region : REGION , skipBootstrap : true } ) ) ;
544+
545+ // The suppressed ones are still in the debug log; only the warning is surfaced.
546+ expect ( events . map ( ( event ) => event . output ) . filter ( Boolean ) ) . toEqual ( [
547+ "example-stack: no changes" ,
548+ ] ) ;
549+ } ) ;
550+
551+ test ( "yields the output that explains a failure before propagating it" , async ( ) => {
552+ const directory = await inTempDirectory ( ) ;
553+ const { manager : subject } = deployManager ( ( operation , emit ) => {
554+ if ( operation . kind !== "deploy" ) return ;
555+ emit ( { level : "error" , message : "example-stack: CREATE_FAILED" } ) ;
556+ throw new Error ( "cdk deploy exploded" ) ;
557+ } ) ;
558+ const project = await scaffolded ( subject , directory ) ;
559+
560+ const events : ProjectEvent [ ] = [ ] ;
561+ const generator = subject . deploy ( project , { region : REGION , skipBootstrap : true } ) ;
562+ await expect (
563+ ( async ( ) => {
564+ for await ( const event of generator ) events . push ( event ) ;
565+ } ) ( ) ,
566+ ) . rejects . toThrow ( "cdk deploy exploded" ) ;
567+ expect ( events ) . toContainEqual ( { output : "example-stack: CREATE_FAILED" } ) ;
568+ } ) ;
569+
570+ test ( "refuses a project managed by a backend it cannot deploy" , async ( ) => {
571+ const directory = await inTempDirectory ( ) ;
572+ const { manager : subject , commands, runs } = deployManager ( ) ;
573+ const project = await scaffolded ( subject , directory ) ;
574+ commands . length = 0 ;
575+
576+ // CDK is the only backend today; the cast stands in for a future one.
577+ const foreign = { ...project , managedBy : "Terraform" as Project [ "managedBy" ] } ;
578+ await expect (
579+ drain ( subject . deploy ( foreign , { region : REGION , skipBootstrap : false } ) ) ,
580+ ) . rejects . toThrow ( / u n s u p p o r t e d b a c k e n d : T e r r a f o r m / ) ;
581+ expect ( commands ) . toEqual ( [ ] ) ;
582+ expect ( runs ) . toEqual ( [ ] ) ;
583+ } ) ;
584+
585+ test ( "fails before touching AWS when the CDK dependencies are missing" , async ( ) => {
586+ const directory = await inTempDirectory ( ) ;
587+ const { manager : subject , commands, runs } = deployManager ( ) ;
588+ const project = await scaffolded ( subject , directory ) ;
589+ await rm ( join ( directory , "example" , "agentcore" , "cdk" , "node_modules" ) , { recursive : true } ) ;
590+ commands . length = 0 ;
591+
592+ await expect (
593+ drain ( subject . deploy ( project , { region : REGION , skipBootstrap : false } ) ) ,
594+ ) . rejects . toThrow ( / n p m i n s t a l l / ) ;
595+ expect ( commands ) . toEqual ( [ ] ) ;
596+ expect ( runs ) . toEqual ( [ ] ) ;
597+ } ) ;
598+ } ) ;
599+
304600describe ( "FsProjectManager.resolve" , ( ) => {
305601 test ( "round-trips a project it just created" , async ( ) => {
306602 const root = await inTempDirectory ( ) ;
0 commit comments