311 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			311 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
namespace NoccyLabs\Serverctl\Container;
 | 
						|
 | 
						|
use RuntimeException;
 | 
						|
 | 
						|
class ContainerManager
 | 
						|
{
 | 
						|
 | 
						|
    private string $dataPath;
 | 
						|
 | 
						|
    private string $stateFile;
 | 
						|
 | 
						|
    private array $autoEnv = [];
 | 
						|
 | 
						|
    private array $serverEnvs = [];
 | 
						|
 | 
						|
    public function __construct(?string $dataPath=null)
 | 
						|
    {
 | 
						|
        $this->dataPath = $dataPath ?? (getenv("HOME")."/.var/serverctl");
 | 
						|
        $this->stateFile = $this->dataPath . "/state.json";
 | 
						|
        $this->setupAutoEnv();
 | 
						|
        $this->setupServerEnvs();
 | 
						|
    }
 | 
						|
 | 
						|
    private function setupServerEnvs()
 | 
						|
    {
 | 
						|
        $file = getenv("HOME")."/.serverenvs";
 | 
						|
 | 
						|
        if (!file_exists($file)) {
 | 
						|
            $this->serverEnvs = [];
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        $parsed = parse_ini_file($file, true);
 | 
						|
 | 
						|
        $this->serverEnvs = $parsed;
 | 
						|
    }
 | 
						|
 | 
						|
    private function setupAutoEnv()
 | 
						|
    {
 | 
						|
        $ifs = net_get_interfaces();
 | 
						|
        if ($ifs) {
 | 
						|
            $dockerHostIp = null;
 | 
						|
            if (array_key_exists('docker0', $ifs)) {
 | 
						|
                foreach ($ifs['docker0']['unicast'] as $uc) {
 | 
						|
                    if ($uc['family'] == 2) {
 | 
						|
                        $dockerHostIp = $uc['address'];
 | 
						|
                        break;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            } else {
 | 
						|
                // Find a usable interface
 | 
						|
                foreach ($ifs as $if=>$conf) {
 | 
						|
                    if (!array_key_exists('unicast', $conf)) continue;
 | 
						|
                    foreach ($conf['unicast'] as $uc) {
 | 
						|
                        if ($uc['family'] == 2) {
 | 
						|
                            $dockerHostIp = $uc['address'];
 | 
						|
                            break;
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
            $this->autoEnv['DOCKER_HOST'] = $dockerHostIp;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private function updateStateFile(string $instanceId, ?array $options)
 | 
						|
    {
 | 
						|
        if (!file_exists($this->stateFile)) {
 | 
						|
            touch($this->stateFile);
 | 
						|
        }
 | 
						|
        $fd = fopen($this->stateFile, 'r+');
 | 
						|
        if (!flock($fd, LOCK_EX)) {
 | 
						|
            throw new \RuntimeException("flock fail");
 | 
						|
        }
 | 
						|
        
 | 
						|
        // Read the state from the file, seek to end first to get size
 | 
						|
        fseek($fd, 0, SEEK_END);
 | 
						|
        $flen = ftell($fd);
 | 
						|
        fseek($fd, 0, SEEK_SET);
 | 
						|
        if ($flen > 0) {
 | 
						|
            $body = fread($fd, $flen);
 | 
						|
            // Parse the state
 | 
						|
            $state = (array)((@json_decode($body, true))??[]);
 | 
						|
        } else {
 | 
						|
            $state = [];
 | 
						|
        }
 | 
						|
 | 
						|
        // Update node in state
 | 
						|
        if ($options === null) {
 | 
						|
            unset($state[$instanceId]);
 | 
						|
        } else {
 | 
						|
            $state[$instanceId] = $options;
 | 
						|
        }
 | 
						|
 | 
						|
        // Reencode the state, truncate the file and write it
 | 
						|
        $body = json_encode($state, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 | 
						|
        fseek($fd, 0, SEEK_SET);
 | 
						|
        ftruncate($fd, 0);
 | 
						|
        fwrite($fd, $body);
 | 
						|
        
 | 
						|
        // TODO: release lock
 | 
						|
        flock($fd, LOCK_UN);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Start a service
 | 
						|
     * 
 | 
						|
     * Instance options:
 | 
						|
     *   name: Instance name (default)
 | 
						|
     *   portoffset: Port number offset (0)
 | 
						|
     * 
 | 
						|
     * @param array $service The service definition from the registry  
 | 
						|
     * @param array $options Instance options
 | 
						|
     */
 | 
						|
    public function startService(array $service, array $options)
 | 
						|
    {
 | 
						|
        $args = [];
 | 
						|
 | 
						|
        $serviceName = $service['name'];
 | 
						|
        $instanceName = $options['name']??'default';
 | 
						|
        $portOffset = intval($options['portoffset']??0);
 | 
						|
 | 
						|
        $temporary = $option['temporary']??false;
 | 
						|
        if ($temporary) {
 | 
						|
            $instanceName = sprintf("%04x%04x", rand(0,0xFFFF), rand(0,0xFFFF));
 | 
						|
        }
 | 
						|
 | 
						|
        $containerName = "sm_".$serviceName."_".$instanceName;
 | 
						|
 | 
						|
        $args[] = 'run';
 | 
						|
        $args[] = '--rm'; // remove container after run
 | 
						|
        $args[] = '-d';
 | 
						|
        $args[] = '--name';
 | 
						|
        $args[] = $containerName;
 | 
						|
 | 
						|
        // Map the ports
 | 
						|
        $ports = (array)($service['ports']??[]);
 | 
						|
        $mappedPorts = [];
 | 
						|
        foreach ($ports as $port) {
 | 
						|
            $portNumber = intval($port['port']) + $portOffset;
 | 
						|
            $target = array_key_exists('target',$port)?intval($port['target']):intval($port['port']);
 | 
						|
            $proto = $port['proto']??"tcp";
 | 
						|
            $args[] = '-p';
 | 
						|
            $args[] = $portNumber.":".$target."/".$proto;
 | 
						|
            $mappedPorts[$port['info']] = $portNumber."/".$proto;
 | 
						|
        }
 | 
						|
 | 
						|
        // Get the paths to persist
 | 
						|
        $volumes = (array)($service['persistence']??[]);
 | 
						|
        $volumePath = $this->getServiceDataPath($service)."/".$instanceName;
 | 
						|
        foreach ($volumes as $volume) {
 | 
						|
            // volume { path, hint }
 | 
						|
            $path = $volume['path'];
 | 
						|
            $hint = $volume['hint'] ?? crc32($path);
 | 
						|
            $args[] = '-v'; // add volume
 | 
						|
            $args[] = $volumePath."/".$hint.":".$path;
 | 
						|
        }
 | 
						|
 | 
						|
        $parsedEnv = [];
 | 
						|
 | 
						|
        // Prepared environment from serviceenvs
 | 
						|
        $key = $serviceName . ":" . $instanceName;
 | 
						|
        if (array_key_exists($key, $this->serverEnvs)) {
 | 
						|
            foreach ($this->serverEnvs[$key] as $env=>$value) {
 | 
						|
                $value = $this->expandString($value);
 | 
						|
                array_push($args, "-e", sprintf("%s=%s", $env, $value));
 | 
						|
                $parsedEnv[$env] = $value;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        // Get environment
 | 
						|
        $envs = (array)($service['environment']??[]);
 | 
						|
        foreach ($envs as $env=>$value) {
 | 
						|
            if (array_key_exists($env, $parsedEnv)) continue;
 | 
						|
            $args[] = '-e';
 | 
						|
            $envval = getenv($env, true);
 | 
						|
            if ($envval) $value = $envval;
 | 
						|
            $value = $this->expandString($value);
 | 
						|
            $args[] = sprintf("%s=%s", $env, $value);
 | 
						|
            $parsedEnv[$env] = $value;
 | 
						|
        }
 | 
						|
 | 
						|
        $args[] = $service['image'];
 | 
						|
 | 
						|
        $cmdl = 'docker '.join(' ',array_map('escapeshellarg', $args));
 | 
						|
        
 | 
						|
        //echo "$ {$cmdl}\n";
 | 
						|
 | 
						|
        exec($cmdl, $out, $ret);
 | 
						|
        if ($ret != 0) {
 | 
						|
            echo join("\n",$out)."\n";
 | 
						|
            throw new RuntimeException("Docker returned non-zero exit code");
 | 
						|
        }
 | 
						|
 | 
						|
        $this->updateStateFile($containerName, [
 | 
						|
            'command' => $cmdl,
 | 
						|
            'instance' => $instanceName,
 | 
						|
            'environment' => $parsedEnv,
 | 
						|
            'service' => $service,
 | 
						|
            'ports' => $mappedPorts,
 | 
						|
            'temporary' => $temporary,
 | 
						|
        ]);
 | 
						|
 | 
						|
        return [
 | 
						|
            'ports' => $mappedPorts
 | 
						|
        ];
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Stop a service
 | 
						|
     */
 | 
						|
    public function stopService(array $service, string $instanceName)
 | 
						|
    {
 | 
						|
        $args = [];
 | 
						|
 | 
						|
        $serviceName = $service['name'];
 | 
						|
        $containerName = "sm_".$serviceName."_".$instanceName;
 | 
						|
 | 
						|
        $args[] = 'stop';
 | 
						|
        $args[] = $containerName;
 | 
						|
 | 
						|
        $cmdl = 'docker '.join(' ',array_map('escapeshellarg', $args));
 | 
						|
 | 
						|
        //echo "$ {$cmdl}\n";
 | 
						|
        exec($cmdl, $out, $ret);
 | 
						|
        if ($ret != 0) {
 | 
						|
            echo join("\n",$out)."\n";
 | 
						|
            throw new RuntimeException("Docker returned non-zero exitcode");
 | 
						|
        }
 | 
						|
 | 
						|
        $this->updateStateFile($containerName, null);
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
    public function execute(array $service, string $instanceName, array $command)
 | 
						|
    {
 | 
						|
        $args = [];
 | 
						|
 | 
						|
        $serviceName = $service['name'];
 | 
						|
        $instanceName = $options['name']??'default';
 | 
						|
        $containerName = "sm_".$serviceName."_".$instanceName;
 | 
						|
 | 
						|
        $args[] = 'exec';
 | 
						|
        $args[] = '-it';
 | 
						|
        $args[] = $containerName;
 | 
						|
 | 
						|
        array_push($args, ...$command);
 | 
						|
 | 
						|
        $cmdl = 'docker '.join(' ',array_map('escapeshellarg', $args));
 | 
						|
 | 
						|
        $cmdl = preg_replace_callback('<\$\{(.+?)\}>', function ($v) use ($service) {
 | 
						|
            return $service['environment'][$v[1]]??null;
 | 
						|
        }, $cmdl);
 | 
						|
 | 
						|
        echo "$ {$cmdl}\n";
 | 
						|
        passthru($cmdl);
 | 
						|
        
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get running services
 | 
						|
     */
 | 
						|
    public function getRunningServices(): array
 | 
						|
    {
 | 
						|
        if (!file_exists($this->stateFile)) {
 | 
						|
            return [];
 | 
						|
        }
 | 
						|
 | 
						|
        $fd = fopen($this->stateFile, 'r+');
 | 
						|
        if (!flock($fd, LOCK_SH)) {
 | 
						|
            throw new \RuntimeException("flock fail");
 | 
						|
        }
 | 
						|
        
 | 
						|
        // Read the state from the file, seek to end first to get size
 | 
						|
        fseek($fd, 0, SEEK_END);
 | 
						|
        $flen = ftell($fd);
 | 
						|
        fseek($fd, 0, SEEK_SET);
 | 
						|
 | 
						|
        $ret = [];
 | 
						|
        if ($flen > 0) {
 | 
						|
            $body = fread($fd, $flen);
 | 
						|
            // Parse the state
 | 
						|
            $ret = (array)((@json_decode($body, true))??[]);
 | 
						|
        } else {
 | 
						|
            return [];
 | 
						|
        }
 | 
						|
 | 
						|
        return $ret;
 | 
						|
    }
 | 
						|
 | 
						|
    public function getServiceDataPath(array $service)
 | 
						|
    {
 | 
						|
        return $this->dataPath."/".$service['name'];
 | 
						|
    }
 | 
						|
 | 
						|
    private function expandString(string $input): string
 | 
						|
    {
 | 
						|
        return preg_replace_callback('<\$\{(.+?)\}>i', function ($m) {
 | 
						|
            $k = $m[1];
 | 
						|
            if (array_key_exists($k, $this->autoEnv)) {
 | 
						|
                return $this->autoEnv[$k];
 | 
						|
            }
 | 
						|
            return getenv($k);
 | 
						|
        }, $input);
 | 
						|
    }
 | 
						|
 | 
						|
}
 |