["string", null, "identifiant de la tâche"], "serial" => ["int", null, "numéro de série permettant de distinguer deux occurrences de la tâche"], "title" => ["?string", null, "description de la tâche"], "valid" => ["bool", false, "la tâche est-elle valide?"], "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"], "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"], "owner_page" => ["?string", null, "page qui a créé cette tâche"], "owner_params" => ["?array", null, "paramètres à passer à la page"], "cmd" => [null, null, "commande à lancer"], "logfile" => ["?string", null, "sortie de la commande"], ]; const SCHEMA = [ "definition" => [ "?array", null, "définition de la tâche", "schema" => self::DEFINITION_SCHEMA, ], "state" => [ "?array", null, "instance de la tâche", "schema" => [ "definition" => [ "array", null, "copie de la définition de la tâche", "schema" => self::DEFINITION_SCHEMA, ], "started" => ["bool", false, "la tâche a-t-elle été démarrée?"], "date_start" => ["?datetime", null, "date du démarrage de la tâche"], "pid" => ["?int", null, "PID du process contrôleur"], "status" => ["?string", null, "Message de statut indiqué par la tâche"], "stopped" => ["bool", false, "la tâche est-elle terminée?"], "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"], "retcode" => ["?int", null, "code de retour de la commande"], "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"], ], ], "" => [ "auto_properties" => [ "id" => "definition.id", "serial" => "definition.serial", "title" => "definition.title", "valid" => "definition.valid", "owner_login" => "definition.owner_login", "owner_name" => "definition.owner_name", "owner_page" => "definition.owner_page", "owner_params" => "definition.owner_params", "cmd" => "definition.cmd", "logfile" => "definition.logfile", ] ], ]; const _AUTO_PROPERTIES = self::SCHEMA[""]["auto_properties"]; const _SCHEMA = [ "id" => ["string", null, "identifiant de la tâche"], "serial" => ["string", null, "numéro de série permettant de distinguer deux occurrences de la tâche"], "title" => ["?string", null, "description de la tâche"], "valid" => ["bool", false, "la tâche est-elle valide?"], "owner_login" => ["?string", null, "compte de la personne qui a lancé la tâche"], "owner_name" => ["?string", null, "nom de la personne qui a lancé la tâche"], "page" => ["?array", null, "page qui a créé cette tâche et paramètres à passer à la page"], "cmd" => [null, null, "commande à lancer"], "logfile" => ["?string", null, "sortie de commande"], "started" => ["bool", false, "la tâche a-t-elle été démarrée?"], "date_start" => ["?datetime", null, "date du démarrage de la tâche"], "pid" => ["?int", null, "PID du process contrôleur"], "stopped" => ["bool", false, "la tâche est-elle terminée?"], "date_stop" => ["?datetime", null, "date de l'arrêt de la tâche"], "retcode" => ["?int", null, "code de retour de la commande"], "done" => ["bool", false, "la fin de la tâche a-t-elle été prise en compte?"], ]; function __construct(string $id, bool $autoUpdate=false, ?callable $init=null) { # ne pas appeler parent::__construct() if (file_exists($id)) { $file = $id; } else { $authz = authz::get(); $this->data = [ "id" => $id, "serial" => 0, "owner_login" => $authz->getUsername(), "owner_name" => $authz->getDisplayName(), ]; $file = tasks::pf("$id.task"); } $this->init = $init; $this->file = $file; $this->ensureTask($autoUpdate); } /** @var ?callable */ protected $init; /** @var string */ protected $file; private function ensureTask(bool $autoUpdate): void { lock::exlusive(self::LOCK); try { if (is_file($this->file)) { $this->_reload(); } else { $this->_reset(); $this->_save(); } $logfile = $this->getLogfile(); if ($logfile !== null) os::mkdirof($logfile); } finally { lock::release(self::LOCK); } if ($autoUpdate) $this->update(); } private function _init(): bool { $init = $this->init; if ($init !== null) { func::call($init, $this); return true; } return false; } function init(): void { if ($this->init !== null) { lock::exlusive(self::LOCK); try { $this->_init(); $this->_save(); } finally { lock::release(self::LOCK); } } } private function _reset(): void { $authz = authz::get(); $id = $this->data["id"]; $serial = A::get($this->data, "serial", 0); $this->data = $this->ensureData([ "id" => $id, "serial" => $serial + 1, "owner_login" => $authz->getUsername(), "owner_name" => $authz->getDisplayName(), "logfile" => logs::pf("$id/latest.log"), ]); $this->_init(); } function reset(): void { lock::exlusive(self::LOCK); try { $this->_reset(); $this->_save(); } finally { lock::release(self::LOCK); } } private function _reload(): void { $this->data = unserialize(file_get_contents($this->file)); } function reload(): void { lock::exlusive(self::LOCK); try { $this->_reload(); } finally { lock::release(self::LOCK); } } private function _save(): void { os::mkdirof($this->file); $outf = fopen($this->file, "w+"); fwrite($outf, serialize($this->data)); fclose($outf); } function save(): void { lock::exlusive(self::LOCK); try { $this->_save(); } finally { lock::release(self::LOCK); } } /** vérifier que l'objet est bien initialisé */ function validate(): void { if (!$this->isValid()) { if ($this->getCmd() === null) { throw new ValueException("cmd is required"); } $this->setValid(true); $this->save(); } } function _launch(): void { $args = [ __DIR__.'/../../lib/launch_task.php', "--envname", envs::get(), $this->getId(), ]; $logfile = $this->getLogfile(); if ($logfile !== null) A::merge($args, ["--logfile", $logfile]); $cmd = new Cmd($args); $cmd->addRedir("null"); $cmd->passthru(); $this->reload(); } function isLaunchable(): bool { return $this->isStarted(); } function launch(): void { if (!$this->isStartable()) return; if ($this->isDone()) $this->reset(); $this->_launch(); } function isUpdatable(): bool { return $this->isLaunchable() && !$this->isDone(); } function update(): void { if ($this->isUpdatable()) $this->_launch(); else $this->init(); } function kill(): void { if (!$this->isStarted() || $this->isStopped()) return; $args = [ __DIR__.'/../../lib/launch_task.php', "-e", envs::get(), "--kill", $this->getId(), ]; $logfile = $this->getLogfile(); if ($logfile !== null) A::merge($args, ["-L", $logfile]); $cmd = new Cmd($args); $cmd->addRedir("null"); $cmd->passthru(); $this->reload(); } function isStartable(): bool { return !$this->isStarted() || $this->isDone(); } /** * démarrer la commande. doit être lancé depuis launch_task.php */ function ltStart(?string $logfile): void { $pid = pcntl_fork(); if ($pid == -1) { # parent, impossible de forker throw new IllegalAccessException("unable to fork"); } elseif ($pid) { # parent, fork ok $this->setStarted(true); $this->setDateStart(date::datetime()); $this->setPid($pid); $this->save(); } else { ## child, fork ok # Créer un groupe de process, pour pouvoir les tuer toutes en même temps posix_setsid(); msg::push($oldMsg, null, [ "output" => $logfile, ]); $retcode = -776; try { # tout d'abord synchroniser les fichiers le cas échéant $command = $this->get("command"); $append = false; if ($command !== null) { $files = $command["files"]; $forceSync = $this->get("force_sync"); files::sync($files, $forceSync, $logfile, "wb"); $append = true; } # puis lancer la commande $cmd = Cmd::with($this->getCmd()); if ($logfile !== null) $cmd->addRedir("both", $logfile, $append); $cmd->fork_exec($retcode); } catch (Exception $e) { msg::error($e); } finally { $this->reload(); $this->setStopped(true); $this->setDateStop(date::datetime()); $this->setRetcode($retcode); $this->save(); msg::pop($oldMsg); } } } /** arrêter la commande. doit être lancé depuis launch_task.php */ function ltKill(?string $logfile): void { msg::push($oldMsg, null, $logfile); try { $id = $this->getId(); $pid = $this->getPid(); msg::action("$id: $pid"); if (!posix_kill(-$pid, SIGKILL)) { switch (posix_get_last_error()) { case PCNTL_ESRCH: msg::afailure("process inexistant"); break; case PCNTL_EPERM: msg::afailure("process non accessible"); break; case PCNTL_EINVAL: msg::afailure("signal invalide"); break; } return; } $timeout = 10; while ($this->ltIsUndead()) { sleep(1); if (--$timeout == 0) { msg::afailure("tentative d'arrêt de la tâche"); return; } } msg::asuccess("tâche arrêtée"); $this->setStopped(true); $this->setDateStop(date::datetime()); $this->setRetcode(-787); $this->setDone(true); $this->save(); } finally { msg::pop($oldMsg); } } function isReapable(): bool { return $this->isStopped() && !$this->isDone(); } /** * marquer la commande comme terminée. doit être lancé depuis launch_task.php */ function ltReap(): void { pcntl_waitpid($this->getPid(), $status); $this->setDone(true); $this->save(); } /** * vérifier si on est dans le cas où la tâche est censée tourner mais en * réalité ce n'est pas le cas. doit être lancé depuis launch_task.php */ function ltIsUndead(): bool { if (!posix_kill($this->getPid(), 0)) { switch (posix_get_last_error()) { case PCNTL_ESRCH: # process inexistant return true; case PCNTL_EPERM: # process auquel on n'a pas accès: ce doit être un autre process qui a # réutilisé le PID return true; case PCNTL_EINVAL: # ne devrait pas se produire return false; } } # process existant return false; } /** * marquer la tâche comme terminée avec un code d'erreur si elle n'existe * plus. doit être lancé depuis launch_task.php */ function ltCleanUndead(): void { if (!$this->isStopped()) { $this->setStopped(true); $this->setDateStop(date::datetime()); $this->setRetcode(-777); } $this->setDone(true); $this->save(); } function getIdTitle(): string { $idTitle = $this->getId(); $title = $this->getTitle(); if ($title) $idTitle .= " -- $title"; return $idTitle; } function getNameOrLogin(): string { $nameOrLogin = $this->getOwnerName(); if ($nameOrLogin === null) $nameOrLogin = $this->getOwnerLogin(); if ($nameOrLogin === null) $nameOrLogin = "(unknown)"; return $nameOrLogin; } const MAX_LOG_SIZE = 256 * 1024; const CACTION_NONE = "n"; const CACTION_REPLACE = "r"; const CACTION_UPDATE = "u"; function export(?int $serial=null, ?int $cs=null, ?int $ce=null): array { $task = $this->array(); $dateStart = new Datetime($this->getDateStart()); $dateStop = new Datetime($this->getDateStop()); # $ca = action à faire par le client: replace ou update # ls = local start, le = local end (local === server en l'occurrence) # cs = client start, ce = client end (pour CACTION_REPLACE) # $ps = plus start, $pe = plus end (pour CACTION_UPDATE) # $rs = read start, $re = read end if ($serial !== null && $cs !== null && $ce !== null && $this->isStarted()) { lock::exlusive(self::LOCK); $inf = false; try { $logfile = $this->getLogfile(); if (!file_exists($logfile)) { # s'assurer que le fichier existe (il peut avoir été nettoyé entre temps) f::close(f::open($logfile, "cb")); } $inf = f::open($logfile, "rb"); $le = f::seek($inf, 0, SEEK_END); $ls = $le - self::MAX_LOG_SIZE; if ($ls <= 0) { $ls = 0; } else { # trouver le premier saut de ligne $ls = f::find_nl($inf, $ls); } if ($serial != $this->getSerial()) { # nouvelle tâche, on recommence tout $rs = $cs = $ls; $re = $ce = $le; $ca = self::CACTION_REPLACE; } elseif ($ls <= $cs) { # cas courant, on rajoute du contenu, mais pas plus que MAX_LOG_SIZE $ls = $cs; $ps = 0; $pe = $le - $ce; $rs = $ce; $re = $le; $ca = self::CACTION_UPDATE; $cs = $ps; $ce = $pe; } elseif ($ls <= $ce) { # on a dépassé MAX_LOG_SIZE, il faut recalculer # garder une partie des logs précédents $ps = $ls - $cs; $pe = $le - $ce; $rs = $ce; $re = $le; $ca = self::CACTION_UPDATE; $cs = $ps; $ce = $pe; } else { # ne rien garder des logs précédents $rs = $cs = $ls; $re = $ce = $le; $ca = self::CACTION_REPLACE; } $logSize = $re - $rs; if ($logSize > 0) { f::seek($inf, $rs, SEEK_SET); $log = f::read($inf, $logSize); $lf = new BaseF(); #XXX $lf->formatContent($log); } elseif ($ca == self::CACTION_REPLACE) { $log = ""; } else { $log = false; } } finally { if ($inf) f::close($inf); lock::release(self::LOCK); } } else { $cs = $ce = false; $ca = self::CACTION_NONE; $log = false; } $page = $this->getPage(); if ($page !== null) { $dest = A::get($page, 0); $params = A::get($page, 1); $pageUrl = page::bu($dest, $params); } else { $pageUrl = false; } A::merge($task, [ "id_title" => $this->getIdTitle(), "name_or_login" => $this->getNameOrLogin(), "page_url" => $pageUrl, "elapsed_start" => $dateStart->getElapsed()->formatAt(), "elapsed_stop" => $dateStop->getElapsed()->formatSince(), "elapsed_total" => $dateStart->getElapsed($dateStop)->formatDelay(), "launchable" => $this->isLaunchable(), "updatable" => $this->isUpdatable(), "startable" => $this->isStartable(), "reapable" => $this->isReapable(), "working" => $this->isStarted() && !$this->isDone(), "ok" => $this->isDone() && $this->getRetcode() == 0, "ko" => $this->isDone() && $this->getRetcode() != 0, "log" => $log, ]); return [$task, $ca, $cs, $ce]; } ############################################################################# const _AUTOGEN_CONSTS = [ "_AUTO_GETTERS" => [Autogen::class, "auto_getters", self::SCHEMA], "_AUTO_SETTERS" => [Autogen::class, "auto_setters", self::SCHEMA], ]; const _AUTOGEN_METHODS = [ [Autogen::class, "auto_getters_methods", self::SCHEMA], [Autogen::class, "auto_setters_methods", self::SCHEMA], ]; const _AUTO_GETTERS = /*autogen*/[ 'getId' => 'id', 'getSerial' => 'serial', 'getTitle' => 'title', 'isValid' => 'valid', 'getOwnerLogin' => 'owner_login', 'getOwnerName' => 'owner_name', 'getPage' => 'page', 'getCmd' => 'cmd', 'getLogfile' => 'logfile', 'isStarted' => 'started', 'getDateStart' => 'date_start', 'getPid' => 'pid', 'isStopped' => 'stopped', 'getDateStop' => 'date_stop', 'getRetcode' => 'retcode', 'isDone' => 'done', ]; const _AUTO_SETTERS = /*autogen*/[ 'setId' => 'id', 'setSerial' => 'serial', 'setTitle' => 'title', 'setValid' => 'valid', 'setOwnerLogin' => 'owner_login', 'setOwnerName' => 'owner_name', 'setPage' => 'page', 'setCmd' => 'cmd', 'setLogfile' => 'logfile', 'setStarted' => 'started', 'setDateStart' => 'date_start', 'setPid' => 'pid', 'setStopped' => 'stopped', 'setDateStop' => 'date_stop', 'setRetcode' => 'retcode', 'setDone' => 'done', ]; }