11import assert from "node:assert/strict"
22import { createServer } from "node:net"
3- import { parseLoopbackPort , provisionRuntimeServices , RuntimeServiceProvisionError , runtimeServiceEvidenceFromError , runtimeServicePlan , waitForMysqlProtocol , type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts"
4- import { validateWorkspaceRecipeJsonSchema } from "../packages/runtime-core/src/recipe-schema.ts"
3+ import { parseLoopbackPort , provisionRuntimeServices , provisionRuntimeServicesForRecipe , RuntimeServiceProvisionError , runtimeServiceEvidenceFromError , runtimeServicePlan , waitForMysqlProtocol , type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts"
4+ import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.ts"
5+ import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts"
6+ import { validateWorkspaceRecipeJsonSchema , type WorkspaceRecipe } from "../packages/runtime-core/src/index.ts"
57
68const service = { id : "test-db" , kind : "mysql" , outputs : { host : "DB_HOST" , port : "DB_PORT" , password : "DB_PASSWORD" } } as const
79const plan = runtimeServicePlan ( [ service ] )
@@ -13,6 +15,22 @@ const valid = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-
1315assert . equal ( valid . valid , true )
1416const unsafe = validateWorkspaceRecipeJsonSchema ( { schema : "wp-codebox/workspace-recipe/v1" , inputs : { services : [ { ...service , outputs : { port : "bad-name" } } ] } , workflow : { steps : [ { command : "wordpress.run-php" } ] } } )
1517assert . equal ( unsafe . valid , false )
18+ const recipe : WorkspaceRecipe = { schema : "wp-codebox/workspace-recipe/v1" , inputs : { services : [ service ] } , workflow : { steps : [ { command : "wordpress.run-php" , args : [ "code=echo 'ok';" ] } ] } }
19+ assert . deepEqual ( await validateWorkspaceRecipeSemantics ( recipe , "recipe.json" ) , [ ] )
20+ const dryRun = await planWorkspaceRecipe ( recipe , process . cwd ( ) , { recipePath : "recipe.json" } , {
21+ defaultWordPressVersion : "latest" ,
22+ resolveExecutionSpec : async ( step ) => ( { command : step . command , args : step . args ?? [ ] } ) ,
23+ } )
24+ assert . deepEqual ( dryRun . services , plan )
25+ const collisions : WorkspaceRecipe = {
26+ ...recipe ,
27+ distribution : { name : "fixture" , wordpress : { root : "/wordpress" } , env : { DB_HOST : "distribution" } } ,
28+ inputs : { runtimeEnv : { DB_PORT : "3306" } , secretEnv : [ "DB_PASSWORD" ] , services : [ service ] } ,
29+ }
30+ assert . deepEqual (
31+ ( await validateWorkspaceRecipeSemantics ( collisions , "recipe.json" ) ) . map ( ( issue ) => issue . code ) ,
32+ [ "duplicate-runtime-service-env" , "duplicate-runtime-service-env" , "duplicate-runtime-service-env" ] ,
33+ )
1634
1735const server = createServer ( ( socket ) => socket . end ( Buffer . from ( [ 1 , 0 , 0 , 0 , 10 ] ) ) )
1836await new Promise < void > ( ( resolve ) => server . listen ( 0 , "127.0.0.1" , resolve ) )
@@ -44,8 +62,12 @@ assert.equal(provisioned.env.DB_PORT, "41001")
4462assert . equal ( provisioned . env . DB_PASSWORD , Buffer . alloc ( 24 , 7 ) . toString ( "base64url" ) )
4563const runCall = calls . find ( ( call ) => call . args [ 0 ] === "run" )
4664assert . ok ( runCall ?. args . includes ( "MYSQL_PASSWORD" ) )
65+ assert . ok ( runCall ?. args . includes ( "127.0.0.1::3306" ) , "Docker publishes MySQL on a loopback ephemeral port" )
66+ assert . deepEqual ( runCall ?. args . slice ( runCall . args . indexOf ( "--tmpfs" ) , runCall . args . indexOf ( "--tmpfs" ) + 2 ) , [ "--tmpfs" , "/var/lib/mysql" ] )
67+ assert . equal ( runCall ?. args . includes ( "--volume" ) || runCall ?. args . includes ( "--mount" ) , false , "Docker uses no persistent volume" )
4768assert . equal ( runCall ?. args . some ( ( arg ) => arg . includes ( provisioned . env . DB_PASSWORD ) ) , false , "credentials never enter Docker argv" )
4869assert . equal ( JSON . stringify ( provisioned . evidence ) . includes ( provisioned . env . DB_PASSWORD ) , false , "credentials never enter evidence" )
70+ assert . equal ( runCall ?. env ?. DOCKER_HOST , process . env . DOCKER_HOST , "Docker provider context is preserved" )
4971assert . equal ( calls [ 0 ] ?. args [ 0 ] , "image" , "the provider checks the image before starting the service" )
5072await provisioned . release ( )
5173await provisioned . release ( )
@@ -58,6 +80,40 @@ await provisionedBeforeAbort.release()
5880const cleanupCall = calls . filter ( ( call ) => call . args [ 0 ] === "rm" ) . at ( - 1 )
5981assert . equal ( cleanupCall ?. signal , undefined , "teardown has an independent cleanup context after interruption" )
6082
83+ let finishLateProvisioning : ( ( ) => void ) | undefined
84+ let lateRemoval = false
85+ const lateDependencies : RuntimeServiceDependencies = {
86+ ...dependencies ,
87+ async execute ( command , args , options ) {
88+ if ( args [ 0 ] === "run" ) await new Promise < void > ( ( resolve ) => { finishLateProvisioning = resolve } )
89+ if ( args [ 0 ] === "rm" ) lateRemoval = true
90+ return dependencies . execute ( command , args , options )
91+ } ,
92+ }
93+ const guardedProvisioning = provisionRuntimeServicesForRecipe ( [ service ] , async ( ) => {
94+ await new Promise < void > ( ( resolve ) => setTimeout ( resolve , 5 ) )
95+ throw new Error ( "recipe timeout" )
96+ } , { dependencies : lateDependencies } )
97+ while ( ! finishLateProvisioning ) await new Promise < void > ( ( resolve ) => setTimeout ( resolve , 1 ) )
98+ finishLateProvisioning ( )
99+ await assert . rejects ( guardedProvisioning , / r e c i p e t i m e o u t / )
100+ assert . equal ( lateRemoval , true , "a timeout waits for and tears down late provisioning" )
101+
102+ const absentDependencies : RuntimeServiceDependencies = {
103+ ...dependencies ,
104+ async execute ( command , args , options ) {
105+ if ( args [ 0 ] === "rm" ) {
106+ const error = new Error ( "docker rm failed" ) as Error & { stderr : string }
107+ error . stderr = `Error response from daemon: No such container: fixture`
108+ throw error
109+ }
110+ return dependencies . execute ( command , args , options )
111+ } ,
112+ }
113+ const alreadyAbsent = await provisionRuntimeServices ( [ service ] , { dependencies : absentDependencies } )
114+ await alreadyAbsent . release ( )
115+ assert . equal ( alreadyAbsent . evidence [ 0 ] ?. teardown , "completed" , "an already absent container is idempotently released" )
116+
61117let failedCleanup = false
62118const failingDependencies : RuntimeServiceDependencies = {
63119 ...dependencies ,
0 commit comments