<?php
class ConfigTgz
{
private $configId = 0;
private $configTitle = '';
private $file = '';
private $modules = array();
private function __construct()
{
}
public function id(): int
{
return $this->configId;
}
public function title(): string
{
return $this->configTitle;
}
public function areAllModulesUpToDate(): bool
{
if (!$this->configId > 0)
ErrorHandler::traceError('ConfigTgz::areAllModulesUpToDate called on un-inserted config.tgz!');
foreach ($this->modules as $module) {
if (!empty($module['filepath']) && file_exists($module['filepath'])) {
if ($module['status'] !== 'OK')
return false;
} else {
return false;
}
}
return true;
}
public function isActive(): bool
{
return readlink(CONFIG_HTTP_DIR . '/default/config.tgz') === $this->file;
}
/**
* @return int[]
*/
public function getModuleIds(): array
{
$ret = [];
foreach ($this->modules as $module) {
$ret[] = (int)$module['moduleid'];
}
return $ret;
}
/**
* @param string $title New title for module
* @param int[] $moduleIds List of modules to include in this config
*/
public function update(string $title, array $moduleIds): void
{
if (!empty($title)) {
$this->configTitle = $title;
}
$this->modules = array();
// Get all modules to put in config
$idstr = '0'; // Passed directly in query. Make sure no SQL injection is possible
foreach ($moduleIds as $module) {
$idstr .= ',' . (int)$module; // Casting to int should make it safe
}
$res = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_module WHERE moduleid IN ($idstr)");
// Delete old connections
Database::exec("DELETE FROM configtgz_x_module WHERE configid = :configid", array('configid' => $this->configId));
// Make connection
foreach ($res as $row) {
Database::exec("INSERT INTO configtgz_x_module (configid, moduleid) VALUES (:configid, :moduleid)", array(
'configid' => $this->configId,
'moduleid' => $row['moduleid']
));
$this->modules[] = $row;
}
// Update name
Database::exec("UPDATE configtgz SET title = :title, status = :status, dateline = :now WHERE configid = :configid LIMIT 1", array(
'configid' => $this->configId,
'title' => $this->configTitle,
'status' => 'OUTDATED',
'now' => time(),
));
}
/**
*
* @param bool $deleteOnError Delete this config in case of error?
* @param int $timeoutMs max time to wait for completion
* @param string|null $parentTask parent task to order this (re)build after
* @return string|bool true=success, false=error, string=taskid, still running
*/
public function generate(bool $deleteOnError = false, int $timeoutMs = 0, ?string $parentTask = null)
{
if (!($this->configId > 0) || empty($this->file))
ErrorHandler::traceError('configId <= 0 or no file in ConfigTgz::rebuild()');
$files = array();
// Get all config modules for system config
foreach ($this->modules as $module) {
if (!empty($module['filepath']) && file_exists($module['filepath'])) {
// Dupcheck only for custom modules for now
$files[$module['filepath']] = ($module['moduletype'] === 'CustomModule');
}
}
$task = self::recompress($files, $this->file, $parentTask);
// Wait for completion
if ($timeoutMs > 0 && !Taskmanager::isFailed($task) && !Taskmanager::isFinished($task)) {
$task = Taskmanager::waitComplete($task, $timeoutMs);
}
if (Taskmanager::isFinished($task)) {
// Success!
$this->markUpdated($task);
return true;
}
if (!is_array($task) || !isset($task['id']) || Taskmanager::isFailed($task)) {
// Failed...
Taskmanager::addErrorMessage($task);
if (!$deleteOnError) {
$this->markFailed();
} else {
$this->delete();
}
return false;
}
// Still running, add callback
TaskmanagerCallback::addCallback($task, 'cbConfTgzCreated', array(
'configid' => $this->configId,
'deleteOnError' => $deleteOnError
));
return $task['id'];
}
public function delete(): bool
{
if ($this->configId === 0)
ErrorHandler::traceError('ConfigTgz::delete called with invalid config id!');
$ret = Database::exec("DELETE FROM configtgz WHERE configid = :configid LIMIT 1",
['configid' => $this->configId], true);
if ($ret !== false) {
if (!empty($this->file)) {
Taskmanager::submit('DeleteFile', array('file' => $this->file), true);
}
$this->configId = 0;
$this->modules = [];
$this->file = '';
}
return $ret !== false;
}
public function markOutdated(): void
{
if ($this->configId === 0)
ErrorHandler::traceError('ConfigTgz::markOutdated called with invalid config id!');
$this->mark('OUTDATED');
}
private function markUpdated(array $task): void
{
if ($this->configId === 0)
ErrorHandler::traceError('ConfigTgz::markUpdated called with invalid config id!');
if ($this->areAllModulesUpToDate()) {
if (empty($task['data']['warnings'])) {
$warnings = '';
} else {
// There have been warnings while generating the combined archive
// Most likely duplicate file entries.
// Get mapping of moduleid to module name for prettier log display
$res = Database::simpleQuery('SELECT moduleid, title FROM configtgz_module');
$mods = [];
foreach ($res as $row) {
$mods[$row['moduleid']] = $row['title'];
}
// Now extract module id from filename and if applicable, replace filename by module name
$warnings = preg_replace_callback('#/opt/openslx/configs/modules/(\w+)_id-(\d+)__.*$#m', function ($m) use ($mods) {
if (!isset($mods[$m[2]]))
return $m[0];
return $mods[$m[2]] . ' (' . $m[1] . '#' . $m[2] . ')';
}, $task['data']['warnings']);
}
Database::exec("UPDATE configtgz SET status = :status, warnings = :warnings
WHERE configid = :configid LIMIT 1", [
'configid' => $this->configId,
'status' => 'OK',
'warnings' => $warnings,
]);
return;
}
$this->mark('OUTDATED');
}
private function markFailed(): void
{
if ($this->configId === 0)
ErrorHandler::traceError('ConfigTgz::markFailed called with invalid config id!');
if (empty($this->file) || !file_exists($this->file)) {
$this->mark('MISSING');
} else {
$this->mark('OUTDATED');
}
}
private function mark($status): void
{
Database::exec("UPDATE configtgz SET status = :status WHERE configid = :configid LIMIT 1", [
'configid' => $this->configId,
'status' => $status,
]);
}
/*
* Static part
*/
/**
* @param bool[] $files source files to include key = file, value = dupCheck
* @param string $destFile where to store final result
* @return false|array taskmanager task
*/
private static function recompress(array $files, string $destFile, $parentTask = null)
{
// Get stuff other modules want to inject
$handler = function($hook) {
include $hook->file;
return $file ?? false;
};
foreach (Hook::load('config-tgz') as $hook) {
$file = $handler($hook);
if ($file !== false) {
$files[$file] = true;
}
}
// Hand over to tm
return Taskmanager::submit('RecompressArchive', array(
'inputFiles' => $files,
'outputFile' =>$destFile,
'parentTask' => $parentTask,
'failOnParentFail' => false,
));
}
/**
* Marks all modules as outdated and triggers generate()
* on each one. This mostly makes sense to call if a global module
* that is injected via a hook has changed.
*/
public static function rebuildAllConfigs(): void
{
Database::exec("UPDATE configtgz SET status = :status", array(
'status' => 'OUTDATED'
));
$res = Database::simpleQuery("SELECT configid FROM configtgz");
foreach ($res as $row) {
$configTgz = self::get($row['configid']);
if ($configTgz !== null) {
$configTgz->generate();
}
}
}
/**
* @param string $title Title of config
* @param int[] $moduleIds Modules to include in config
* @return ConfigTgz The module instance
*/
public static function insert(string $title, array $moduleIds): ConfigTgz
{
$instance = new ConfigTgz;
$instance->configTitle = $title;
// Create output file name (config.tgz)
do {
$instance->file = CONFIG_TGZ_LIST_DIR . '/config-' . Util::sanitizeFilename($instance->configTitle) . '-' . mt_rand() . '-' . time() . '.tgz';
} while (file_exists($instance->file));
Database::exec("INSERT INTO configtgz (title, filepath, status, dateline) VALUES (:title, :filepath, :status, :now)", array(
'title' => $instance->configTitle,
'filepath' => $instance->file,
'status' => 'MISSING',
'now' => time(),
));
$instance->configId = Database::lastInsertId();
$instance->modules = array();
// Get all modules to put in config
$idstr = '0'; // Passed directly in query. Make sure no SQL injection is possible
foreach ($moduleIds as $module) {
$idstr .= ',' . (int)$module; // Casting to int should make it safe
}
$res = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_module WHERE moduleid IN ($idstr)");
// Make connection
foreach ($res as $row) {
Database::exec("INSERT INTO configtgz_x_module (configid, moduleid) VALUES (:configid, :moduleid)", array(
'configid' => $instance->configId,
'moduleid' => $row['moduleid']
));
$instance->modules[] = $row;
}
return $instance;
}
/**
* @param array{configid: int, title: string, filepath: string} $row Input data, fields mandatory
*/
private static function instanceFromRow(array $row): ConfigTgz
{
$instance = new ConfigTgz;
$instance->configId = $row['configid'];
$instance->configTitle = $row['title'];
$instance->file = $row['filepath'];
$innerRes = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_x_module "
. " INNER JOIN configtgz_module USING (moduleid) "
. " WHERE configid = :configid", array('configid' => $instance->configId));
$instance->modules = array();
foreach ($innerRes as $innerRow) {
$instance->modules[] = $innerRow;
}
return $instance;
}
public static function get(int $configId): ?ConfigTgz
{
$ret = Database::queryFirst("SELECT configid, title, filepath FROM configtgz WHERE configid = :configid",
['configid' => $configId]);
if ($ret === false)
return null;
return self::instanceFromRow($ret);
}
/**
* @param int $moduleId ID of config module
* @return ConfigTgz[]
*/
public static function getAllForModule(int $moduleId): array
{
$res = Database::simpleQuery("SELECT configid, title, filepath FROM configtgz_x_module "
. " INNER JOIN configtgz USING (configid) "
. " WHERE moduleid = :moduleid", array(
'moduleid' => $moduleId
));
if ($res === false) {
EventLog::warning('ConfigTgz::getAllForModule failed: ' . Database::lastError());
return [];
}
$list = array();
foreach ($res as $row) {
$instance = self::instanceFromRow($row);
$list[] = $instance;
}
return $list;
}
/**
* Called when (re)generating a config tgz failed, so we can
* update the status in the DB and add a server log entry.
*
* @param array $args contains 'configid' and optionally 'deleteOnError'
*/
public static function generateFailed(array $task, array $args): void
{
if (!isset($args['configid']) || !is_numeric($args['configid'])) {
EventLog::warning('Ignoring generateFailed event as it has no configid assigned.');
return;
}
$config = self::get($args['configid']);
if ($config === null) {
EventLog::warning('generateFailed callback for config id ' . $args['configid'] . ', but no instance could be generated.');
return;
}
if (isset($task['data']['error'])) {
$error = $task['data']['error'];
} elseif (isset($task['data']['messages'])) {
$error = $task['data']['messages'];
} else {
$error = '';
}
EventLog::failure("Generating config.tgz '" . $config->configTitle . "' failed.", $error);
if ($args['deleteOnError']) {
$config->delete();
} else {
$config->markFailed();
}
}
/**
* (Re)generating a config tgz succeeded. Update db entry.
*
* @param array $task the task object
* @param array $args contains 'configid' and optionally 'deleteOnError'
*/
public static function generateSucceeded(array $task, array $args): void
{
if (!isset($args['configid']) || !is_numeric($args['configid'])) {
EventLog::warning('Ignoring generateSucceeded event as it has no configid assigned.');
return;
}
$config = self::get($args['configid']);
if ($config === null) {
EventLog::warning('generateSucceeded callback for config id ' . $args['configid'] . ', but no instance could be generated.');
return;
}
$config->markUpdated($task);
}
}