Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit b692592

Browse files
feat: add cached Docker images, workspace isolation, and cleanup for project containers
Three related improvements to the per-project container lifecycle: 1. Cached Docker images (startup.sh baked in via docker build) Instead of running startup.sh on every container start, a derived image is built once and tagged as pb-cache:{content-hash}. Subsequent runs skip the expensive system-level setup (apt-get, mise install, etc.) entirely. The cache invalidates automatically when dockerimage or startup.sh content changes. 2. Workspace isolation for concurrent agents (PB_WORKSPACE_ID) workspace-init.sh and workspace-cleanup.sh now receive PB_WORKSPACE_ID as an environment variable. Projects use this to namespace their Docker Compose resources (container names, volumes, networks), preventing collisions when multiple agents work on the same project concurrently. 3. Workspace cleanup convention (workspace-cleanup.sh) A new optional script runs inside the project container before it is removed. This tears down any resources the init script created (e.g., docker compose down -v), preventing orphaned containers and volumes. Cleanup failures are non-fatal (logged as warnings). Files changed: - ProjectContainerManager: cached image build, PB_WORKSPACE_ID env var, cleanup script support, updated stopProjectContainer() signature - GitCloneService: passes workspacePath to stopProjectContainer() - multilevel-docker-architecture.md: documents caching, isolation, cleanup - ProjectContainerManagerTest: tests for cache tags and script detection
1 parent 2d43397 commit b692592

File tree

4 files changed

+441
-40
lines changed

4 files changed

+441
-40
lines changed

src/WorkspaceManagement/Infrastructure/Service/GitCloneService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public function cloneRepositoryOnBranch(string $githubUrl, string $githubToken,
137137
public function removeWorkspace(string $workspacePath, ?string $containerName): void
138138
{
139139
if ($containerName !== null) {
140-
$this->containerManager->stopProjectContainer($containerName);
140+
$this->containerManager->stopProjectContainer($containerName, $workspacePath);
141141
}
142142

143143
$workspaceBaseDir = $this->resolveWorkspaceBaseDir();

src/WorkspaceManagement/Infrastructure/Service/ProjectContainerManager.php

Lines changed: 189 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010

1111
class 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

Comments
 (0)