serverctl/src/Container/ContainerManager.php

307 lines
8.6 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));
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);
}
}