@@ -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 /**
0 commit comments