Skip to content

Commit 811ee5d

Browse files
committed
refactor(jobs): extract container resolution logic for deployment commands
Extract common container selection logic into resolveCommandContainer() method that handles both single and multi-container app scenarios. This consolidates duplicated code from run_pre_deployment_command() and run_post_deployment_command() while improving error messaging and test coverage.
1 parent b8b49b9 commit 811ee5d

File tree

2 files changed

+216
-51
lines changed

2 files changed

+216
-51
lines changed

app/Jobs/ApplicationDeploymentJob.php

Lines changed: 100 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3989,43 +3989,88 @@ private function validateContainerName(string $value): string
39893989
return $value;
39903990
}
39913991

3992+
/**
3993+
* Resolve which container to execute a deployment command in.
3994+
*
3995+
* For single-container apps, returns the sole container.
3996+
* For multi-container apps, matches by the user-specified container name.
3997+
* If no container name is specified for multi-container apps, logs available containers and returns null.
3998+
*/
3999+
private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array
4000+
{
4001+
if ($containers->count() === 0) {
4002+
return null;
4003+
}
4004+
4005+
if ($containers->count() === 1) {
4006+
return $containers->first();
4007+
}
4008+
4009+
// Multi-container: require a container name to be specified
4010+
if (empty($specifiedContainerName)) {
4011+
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
4012+
$this->application_deployment_queue->addLogEntry(
4013+
"{$commandType} command: Multiple containers found but no container name specified. Available: {$available}"
4014+
);
4015+
4016+
return null;
4017+
}
4018+
4019+
// Multi-container: match by specified name prefix
4020+
$prefix = $specifiedContainerName.'-'.$this->application->uuid;
4021+
foreach ($containers as $container) {
4022+
$containerName = data_get($container, 'Names');
4023+
if (str_starts_with($containerName, $prefix)) {
4024+
return $container;
4025+
}
4026+
}
4027+
4028+
// No match found — log available containers to help the user debug
4029+
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
4030+
$this->application_deployment_queue->addLogEntry(
4031+
"{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}"
4032+
);
4033+
4034+
return null;
4035+
}
4036+
39924037
private function run_pre_deployment_command()
39934038
{
39944039
if (empty($this->application->pre_deployment_command)) {
39954040
return;
39964041
}
39974042
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
39984043
if ($containers->count() == 0) {
4044+
$this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.');
4045+
39994046
return;
40004047
}
40014048
$this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).');
40024049

4003-
foreach ($containers as $container) {
4004-
$containerName = data_get($container, 'Names');
4005-
if ($containerName) {
4006-
$this->validateContainerName($containerName);
4007-
}
4008-
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
4009-
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
4010-
// Users (team members with deployment access) need full shell flexibility to run commands
4011-
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
4012-
// The trust boundary is at the application/team ownership level — only authenticated team
4013-
// members can set these commands, and execution is scoped to the application's own container.
4014-
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
4015-
// restrict the command itself. Container names are validated separately via validateContainerName().
4016-
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
4017-
$exec = "docker exec {$containerName} {$cmd}";
4018-
$this->execute_remote_command(
4019-
[
4020-
'command' => $exec,
4021-
'hidden' => true,
4022-
],
4023-
);
4050+
$container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment');
4051+
if ($container === null) {
4052+
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
4053+
}
40244054

4025-
return;
4026-
}
4055+
$containerName = data_get($container, 'Names');
4056+
if ($containerName) {
4057+
$this->validateContainerName($containerName);
40274058
}
4028-
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
4059+
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
4060+
// Users (team members with deployment access) need full shell flexibility to run commands
4061+
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
4062+
// The trust boundary is at the application/team ownership level — only authenticated team
4063+
// members can set these commands, and execution is scoped to the application's own container.
4064+
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
4065+
// restrict the command itself. Container names are validated separately via validateContainerName().
4066+
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
4067+
$exec = "docker exec {$containerName} {$cmd}";
4068+
$this->execute_remote_command(
4069+
[
4070+
'command' => $exec,
4071+
'hidden' => true,
4072+
],
4073+
);
40294074
}
40304075

40314076
private function run_post_deployment_command()
@@ -4037,36 +4082,40 @@ private function run_post_deployment_command()
40374082
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).');
40384083

40394084
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
4040-
foreach ($containers as $container) {
4041-
$containerName = data_get($container, 'Names');
4042-
if ($containerName) {
4043-
$this->validateContainerName($containerName);
4044-
}
4045-
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
4046-
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
4047-
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
4048-
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
4049-
$exec = "docker exec {$containerName} {$cmd}";
4050-
try {
4051-
$this->execute_remote_command(
4052-
[
4053-
'command' => $exec,
4054-
'hidden' => true,
4055-
'save' => 'post-deployment-command-output',
4056-
],
4057-
);
4058-
} catch (Exception $e) {
4059-
$post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
4060-
if ($post_deployment_command_output) {
4061-
$this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
4062-
$this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
4063-
}
4064-
}
4085+
if ($containers->count() == 0) {
4086+
$this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
40654087

4066-
return;
4088+
return;
4089+
}
4090+
4091+
$container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment');
4092+
if ($container === null) {
4093+
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
4094+
}
4095+
4096+
$containerName = data_get($container, 'Names');
4097+
if ($containerName) {
4098+
$this->validateContainerName($containerName);
4099+
}
4100+
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
4101+
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
4102+
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
4103+
$exec = "docker exec {$containerName} {$cmd}";
4104+
try {
4105+
$this->execute_remote_command(
4106+
[
4107+
'command' => $exec,
4108+
'hidden' => true,
4109+
'save' => 'post-deployment-command-output',
4110+
],
4111+
);
4112+
} catch (Exception $e) {
4113+
$post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
4114+
if ($post_deployment_command_output) {
4115+
$this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
4116+
$this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
40674117
}
40684118
}
4069-
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
40704119
}
40714120

40724121
/**
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
use App\Jobs\ApplicationDeploymentJob;
4+
use App\Models\Application;
5+
use App\Models\ApplicationDeploymentQueue;
6+
7+
function createJobWithProperties(string $uuid): object
8+
{
9+
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
10+
$instance = $ref->newInstanceWithoutConstructor();
11+
12+
$app = Mockery::mock(Application::class)->makePartial();
13+
$app->uuid = $uuid;
14+
15+
$queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
16+
$queue->shouldReceive('addLogEntry')->andReturnNull();
17+
18+
$appProp = $ref->getProperty('application');
19+
$appProp->setAccessible(true);
20+
$appProp->setValue($instance, $app);
21+
22+
$queueProp = $ref->getProperty('application_deployment_queue');
23+
$queueProp->setAccessible(true);
24+
$queueProp->setValue($instance, $queue);
25+
26+
return $instance;
27+
}
28+
29+
function invokeResolve(object $instance, $containers, ?string $specifiedName, string $type): ?array
30+
{
31+
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
32+
$method = $ref->getMethod('resolveCommandContainer');
33+
$method->setAccessible(true);
34+
35+
return $method->invoke($instance, $containers, $specifiedName, $type);
36+
}
37+
38+
describe('resolveCommandContainer', function () {
39+
test('returns null when no containers exist', function () {
40+
$instance = createJobWithProperties('abc123');
41+
$result = invokeResolve($instance, collect([]), 'web', 'Pre-deployment');
42+
43+
expect($result)->toBeNull();
44+
});
45+
46+
test('returns the sole container when only one exists', function () {
47+
$container = ['Names' => 'web-abc123', 'Labels' => ''];
48+
$instance = createJobWithProperties('abc123');
49+
$result = invokeResolve($instance, collect([$container]), null, 'Pre-deployment');
50+
51+
expect($result)->toBe($container);
52+
});
53+
54+
test('returns the sole container regardless of specified name when only one exists', function () {
55+
$container = ['Names' => 'web-abc123', 'Labels' => ''];
56+
$instance = createJobWithProperties('abc123');
57+
$result = invokeResolve($instance, collect([$container]), 'wrong-name', 'Pre-deployment');
58+
59+
expect($result)->toBe($container);
60+
});
61+
62+
test('returns null when no container name specified for multi-container app', function () {
63+
$containers = collect([
64+
['Names' => 'web-abc123', 'Labels' => ''],
65+
['Names' => 'worker-abc123', 'Labels' => ''],
66+
]);
67+
$instance = createJobWithProperties('abc123');
68+
$result = invokeResolve($instance, $containers, null, 'Pre-deployment');
69+
70+
expect($result)->toBeNull();
71+
});
72+
73+
test('returns null when empty string container name for multi-container app', function () {
74+
$containers = collect([
75+
['Names' => 'web-abc123', 'Labels' => ''],
76+
['Names' => 'worker-abc123', 'Labels' => ''],
77+
]);
78+
$instance = createJobWithProperties('abc123');
79+
$result = invokeResolve($instance, $containers, '', 'Pre-deployment');
80+
81+
expect($result)->toBeNull();
82+
});
83+
84+
test('matches correct container by specified name in multi-container app', function () {
85+
$containers = collect([
86+
['Names' => 'web-abc123', 'Labels' => ''],
87+
['Names' => 'worker-abc123', 'Labels' => ''],
88+
]);
89+
$instance = createJobWithProperties('abc123');
90+
$result = invokeResolve($instance, $containers, 'worker', 'Pre-deployment');
91+
92+
expect($result)->toBe(['Names' => 'worker-abc123', 'Labels' => '']);
93+
});
94+
95+
test('returns null when specified container name does not match any container', function () {
96+
$containers = collect([
97+
['Names' => 'web-abc123', 'Labels' => ''],
98+
['Names' => 'worker-abc123', 'Labels' => ''],
99+
]);
100+
$instance = createJobWithProperties('abc123');
101+
$result = invokeResolve($instance, $containers, 'nonexistent', 'Pre-deployment');
102+
103+
expect($result)->toBeNull();
104+
});
105+
106+
test('matches container with PR suffix', function () {
107+
$containers = collect([
108+
['Names' => 'web-abc123-pr-42', 'Labels' => ''],
109+
['Names' => 'worker-abc123-pr-42', 'Labels' => ''],
110+
]);
111+
$instance = createJobWithProperties('abc123');
112+
$result = invokeResolve($instance, $containers, 'web', 'Pre-deployment');
113+
114+
expect($result)->toBe(['Names' => 'web-abc123-pr-42', 'Labels' => '']);
115+
});
116+
});

0 commit comments

Comments
 (0)