1010
1111class ProjectContainerManager
1212{
13- private const string DOCKERIMAGE_FILE = '.productbuilder/dockerimage ' ;
14- private const string STARTUP_SCRIPT = '.productbuilder/startup.sh ' ;
15- private const string CONTAINER_PREFIX = 'pb-workspace- ' ;
16- private const int CONTAINER_START_TIMEOUT = 30 ;
17- private const int STARTUP_SCRIPT_TIMEOUT = 600 ;
13+ private const string DOCKERIMAGE_FILE = '.productbuilder/dockerimage ' ;
14+ private const string STARTUP_SCRIPT = '.productbuilder/startup.sh ' ;
15+ private const string WORKSPACE_INIT_SCRIPT = '.productbuilder/workspace-init.sh ' ;
16+ private const string WORKSPACE_CLEANUP_SCRIPT = '.productbuilder/workspace-cleanup.sh ' ;
17+ private const string CONTAINER_PREFIX = 'pb-workspace- ' ;
18+ private const string CACHE_IMAGE_PREFIX = 'pb-cache: ' ;
19+ private const int CACHE_HASH_LENGTH = 16 ;
20+ private const int CONTAINER_START_TIMEOUT = 30 ;
21+ private const int IMAGE_BUILD_TIMEOUT = 600 ;
22+ private const int WORKSPACE_INIT_TIMEOUT = 600 ;
23+ private const int WORKSPACE_CLEANUP_TIMEOUT = 120 ;
1824
1925 public function __construct (
2026 private readonly LoggerInterface $ logger ,
@@ -27,23 +33,24 @@ public function hasProjectContainerConfig(string $workspacePath): bool
2733 }
2834
2935 /**
30- * Starts a project container and runs the startup script.
31- * Returns the container name.
36+ * Starts a project container using a cached image (building it if needed)
37+ * and runs the workspace init script. Returns the container name.
3238 */
3339 public function startProjectContainer (string $ workspacePath , string $ workspaceId ): string
3440 {
35- $ dockerImage = $ this ->readDockerImage ($ workspacePath );
36- $ containerName = self ::CONTAINER_PREFIX . $ workspaceId ;
41+ $ baseImage = $ this ->readDockerImage ($ workspacePath );
42+ $ containerName = self ::CONTAINER_PREFIX . $ workspaceId ;
43+ $ effectiveImage = $ this ->resolveEffectiveImage ($ workspacePath , $ baseImage );
3744
3845 $ this ->logger ->info ('[ProjectContainer] Starting project container ' , [
39- 'containerName ' => $ containerName ,
40- 'dockerImage ' => $ dockerImage ,
41- 'workspacePath ' => $ workspacePath ,
46+ 'containerName ' => $ containerName ,
47+ 'baseImage ' => $ baseImage ,
48+ 'effectiveImage ' => $ effectiveImage ,
49+ 'workspacePath ' => $ workspacePath ,
4250 ]);
4351
44- $ this ->pullImage ($ dockerImage );
45- $ this ->runContainer ($ containerName , $ dockerImage , $ workspacePath );
46- $ this ->runStartupScript ($ containerName , $ workspacePath );
52+ $ this ->runContainer ($ containerName , $ effectiveImage , $ workspacePath );
53+ $ this ->runWorkspaceInitScript ($ containerName , $ workspacePath , $ workspaceId );
4754
4855 $ this ->logger ->info ('[ProjectContainer] Project container ready ' , [
4956 'containerName ' => $ containerName ,
@@ -52,8 +59,15 @@ public function startProjectContainer(string $workspacePath, string $workspaceId
5259 return $ containerName ;
5360 }
5461
55- public function stopProjectContainer (string $ containerName ): void
62+ /**
63+ * Runs the workspace cleanup script (if present), then removes the project container.
64+ */
65+ public function stopProjectContainer (string $ containerName , string $ workspacePath ): void
5666 {
67+ $ workspaceId = $ this ->extractWorkspaceId ($ containerName );
68+
69+ $ this ->runWorkspaceCleanupScript ($ containerName , $ workspacePath , $ workspaceId );
70+
5771 $ this ->logger ->info ('[ProjectContainer] Stopping project container ' , [
5872 'containerName ' => $ containerName ,
5973 ]);
@@ -76,6 +90,109 @@ public function stopProjectContainer(string $containerName): void
7690 ]);
7791 }
7892
93+ /**
94+ * @internal exposed for testing only
95+ */
96+ public function computeCacheTag (string $ workspacePath , string $ baseImage ): string
97+ {
98+ $ startupPath = $ workspacePath . '/ ' . self ::STARTUP_SCRIPT ;
99+ $ startupContent = is_file ($ startupPath ) ? (string ) file_get_contents ($ startupPath ) : '' ;
100+ $ cacheKey = hash ('sha256 ' , $ baseImage . "\n" . $ startupContent );
101+
102+ return self ::CACHE_IMAGE_PREFIX . substr ($ cacheKey , 0 , self ::CACHE_HASH_LENGTH );
103+ }
104+
105+ private function extractWorkspaceId (string $ containerName ): string
106+ {
107+ return substr ($ containerName , strlen (self ::CONTAINER_PREFIX ));
108+ }
109+
110+ /**
111+ * Returns the cached image tag if it exists, or builds it from the base
112+ * image + startup.sh. If there is no startup.sh, falls back to pulling
113+ * the base image directly (nothing to cache).
114+ */
115+ private function resolveEffectiveImage (string $ workspacePath , string $ baseImage ): string
116+ {
117+ $ startupPath = $ workspacePath . '/ ' . self ::STARTUP_SCRIPT ;
118+
119+ if (!is_file ($ startupPath )) {
120+ $ this ->logger ->info ('[ProjectContainer] No startup.sh found, using base image directly ' , [
121+ 'baseImage ' => $ baseImage ,
122+ ]);
123+ $ this ->pullImage ($ baseImage );
124+
125+ return $ baseImage ;
126+ }
127+
128+ $ cacheTag = $ this ->computeCacheTag ($ workspacePath , $ baseImage );
129+
130+ if ($ this ->cachedImageExists ($ cacheTag )) {
131+ $ this ->logger ->info ('[ProjectContainer] Cache hit — using existing image ' , [
132+ 'cacheTag ' => $ cacheTag ,
133+ ]);
134+
135+ return $ cacheTag ;
136+ }
137+
138+ $ this ->logger ->info ('[ProjectContainer] Cache miss — building cached image ' , [
139+ 'cacheTag ' => $ cacheTag ,
140+ 'baseImage ' => $ baseImage ,
141+ ]);
142+
143+ $ this ->buildCachedImage ($ workspacePath , $ baseImage , $ cacheTag );
144+
145+ return $ cacheTag ;
146+ }
147+
148+ private function cachedImageExists (string $ cacheTag ): bool
149+ {
150+ $ process = new Process (['docker ' , 'image ' , 'inspect ' , $ cacheTag ]);
151+ $ process ->setTimeout (10 );
152+ $ process ->run ();
153+
154+ return $ process ->isSuccessful ();
155+ }
156+
157+ private function buildCachedImage (string $ workspacePath , string $ baseImage , string $ cacheTag ): void
158+ {
159+ $ dockerfileContent = sprintf (
160+ "FROM %s \nCOPY %s /tmp/pb-startup.sh \nRUN bash /tmp/pb-startup.sh && rm /tmp/pb-startup.sh \n" ,
161+ $ baseImage ,
162+ self ::STARTUP_SCRIPT ,
163+ );
164+
165+ $ dockerfilePath = $ workspacePath . '/.productbuilder-Dockerfile ' ;
166+ file_put_contents ($ dockerfilePath , $ dockerfileContent );
167+
168+ try {
169+ $ process = new Process ([
170+ 'docker ' , 'build ' ,
171+ '-t ' , $ cacheTag ,
172+ '-f ' , $ dockerfilePath ,
173+ $ workspacePath ,
174+ ]);
175+ $ process ->setTimeout (self ::IMAGE_BUILD_TIMEOUT );
176+ $ process ->run ();
177+
178+ if (!$ process ->isSuccessful ()) {
179+ throw new RuntimeException (sprintf (
180+ 'Failed to build cached image "%s": %s ' ,
181+ $ cacheTag ,
182+ $ process ->getErrorOutput (),
183+ ));
184+ }
185+
186+ $ this ->logger ->info ('[ProjectContainer] Cached image built ' , [
187+ 'cacheTag ' => $ cacheTag ,
188+ ]);
189+ } finally {
190+ if (is_file ($ dockerfilePath )) {
191+ unlink ($ dockerfilePath );
192+ }
193+ }
194+ }
195+
79196 private function readDockerImage (string $ workspacePath ): string
80197 {
81198 $ filePath = $ workspacePath . '/ ' . self ::DOCKERIMAGE_FILE ;
@@ -139,42 +256,87 @@ private function runContainer(string $containerName, string $dockerImage, string
139256 }
140257 }
141258
142- private function runStartupScript (string $ containerName , string $ workspacePath ): void
259+ private function runWorkspaceInitScript (string $ containerName , string $ workspacePath, string $ workspaceId ): void
143260 {
144- $ startupScriptPath = $ workspacePath . '/ ' . self ::STARTUP_SCRIPT ;
261+ $ initScriptPath = $ workspacePath . '/ ' . self ::WORKSPACE_INIT_SCRIPT ;
145262
146- if (!is_file ($ startupScriptPath )) {
147- $ this ->logger ->info ('[ProjectContainer] No startup script found, skipping ' , [
148- 'expected ' => $ startupScriptPath ,
263+ if (!is_file ($ initScriptPath )) {
264+ $ this ->logger ->info ('[ProjectContainer] No workspace-init.sh found, skipping ' , [
265+ 'expected ' => $ initScriptPath ,
149266 ]);
150267
151268 return ;
152269 }
153270
154- $ this ->logger ->info ('[ProjectContainer] Running startup script ' , [
271+ $ this ->logger ->info ('[ProjectContainer] Running workspace init script ' , [
155272 'containerName ' => $ containerName ,
156- 'script ' => self ::STARTUP_SCRIPT ,
273+ 'script ' => self ::WORKSPACE_INIT_SCRIPT ,
274+ 'workspaceId ' => $ workspaceId ,
157275 ]);
158276
159277 $ process = new Process ([
160278 'docker ' , 'exec ' ,
279+ '-e ' , 'PB_WORKSPACE_ID= ' . $ workspaceId ,
161280 '-w ' , $ workspacePath ,
162281 $ containerName ,
163- 'bash ' , $ workspacePath . '/ ' . self ::STARTUP_SCRIPT ,
282+ 'bash ' , $ workspacePath . '/ ' . self ::WORKSPACE_INIT_SCRIPT ,
164283 ]);
165- $ process ->setTimeout (self ::STARTUP_SCRIPT_TIMEOUT );
284+ $ process ->setTimeout (self ::WORKSPACE_INIT_TIMEOUT );
166285 $ process ->run ();
167286
168287 if (!$ process ->isSuccessful ()) {
169288 throw new RuntimeException (sprintf (
170- 'Startup script failed in container "%s" (exit %d): %s ' ,
289+ 'Workspace init script failed in container "%s" (exit %d): %s ' ,
171290 $ containerName ,
172291 $ process ->getExitCode (),
173292 $ process ->getErrorOutput (),
174293 ));
175294 }
176295
177- $ this ->logger ->info ('[ProjectContainer] Startup script completed ' , [
296+ $ this ->logger ->info ('[ProjectContainer] Workspace init script completed ' , [
297+ 'containerName ' => $ containerName ,
298+ ]);
299+ }
300+
301+ private function runWorkspaceCleanupScript (string $ containerName , string $ workspacePath , string $ workspaceId ): void
302+ {
303+ $ cleanupScriptPath = $ workspacePath . '/ ' . self ::WORKSPACE_CLEANUP_SCRIPT ;
304+
305+ if (!is_file ($ cleanupScriptPath )) {
306+ $ this ->logger ->info ('[ProjectContainer] No workspace-cleanup.sh found, skipping ' , [
307+ 'expected ' => $ cleanupScriptPath ,
308+ ]);
309+
310+ return ;
311+ }
312+
313+ $ this ->logger ->info ('[ProjectContainer] Running workspace cleanup script ' , [
314+ 'containerName ' => $ containerName ,
315+ 'script ' => self ::WORKSPACE_CLEANUP_SCRIPT ,
316+ 'workspaceId ' => $ workspaceId ,
317+ ]);
318+
319+ $ process = new Process ([
320+ 'docker ' , 'exec ' ,
321+ '-e ' , 'PB_WORKSPACE_ID= ' . $ workspaceId ,
322+ '-w ' , $ workspacePath ,
323+ $ containerName ,
324+ 'bash ' , $ workspacePath . '/ ' . self ::WORKSPACE_CLEANUP_SCRIPT ,
325+ ]);
326+ $ process ->setTimeout (self ::WORKSPACE_CLEANUP_TIMEOUT );
327+ $ process ->run ();
328+
329+ if (!$ process ->isSuccessful ()) {
330+ $ this ->logger ->warning ('[ProjectContainer] Workspace cleanup script failed (proceeding with teardown) ' , [
331+ 'containerName ' => $ containerName ,
332+ 'exitCode ' => $ process ->getExitCode (),
333+ 'stderr ' => $ process ->getErrorOutput (),
334+ ]);
335+
336+ return ;
337+ }
338+
339+ $ this ->logger ->info ('[ProjectContainer] Workspace cleanup script completed ' , [
178340 'containerName ' => $ containerName ,
179341 ]);
180342 }
0 commit comments