fresh/src/Refresher.php

519 lines
19 KiB
PHP

<?php
namespace NoccyLabs\FreshDocker;
/*
Fresh.phar
Copyright (C) 2022 NoccyLabs
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
use GuzzleHttp\Exception\RequestException;
use NoccyLabs\FreshDocker\State\Log;
use NoccyLabs\FreshDocker\Configuration\ComposeConfiguration;
use NoccyLabs\FreshDocker\Configuration\LocalConfiguration;
use NoccyLabs\FreshDocker\Configuration\SingleImageConfiguration;
use NoccyLabs\FreshDocker\Credentials\BasicCredentialsLoader;
use NoccyLabs\FreshDocker\Credentials\CredentialsLoaderInterface;
use NoccyLabs\FreshDocker\Hooks\SlackHook;
use NoccyLabs\FreshDocker\ImageReference;
use NoccyLabs\FreshDocker\Registry\RegistryV2Client;
use NoccyLabs\FreshDocker\State\PersistentState;
use NoccyLabs\FreshDocker\State\Lockfile;
use Phar;
class Refresher
{
/**
* Map for determining options from command line and environment. Each item
* has the format:
* name => [ short, long, description, [envvar, [default]]]
*/
private static array $optionsMap = [
'General' => [
'help' => [ 'h', 'help', "Show this help" ],
'quiet' => [ 'q', 'quiet', "Don't show any output" ],
'verbose' => [ 'v', null, "Show debug output", null, false ],
'path' => [ 'd:', 'dir:', "Change working directory", "FRESH_DIR" ],
'image' => [ 'i:', 'image:', "Check a specific image instead of images from config", "FRESH_IMAGE" ],
'pull' => [ null, 'pull', "Only pull if updated, don't up" ],
'only' => [ null, 'only:', "Comma-separated list of services to docker-compose pull" ],
'updated' => [ null, 'updated', "Only pass updated services to docker-compose pull" ],
'check' => [ null, 'check', "Only check for updates, set exit code" ],
'prune' => [ null, 'prune', "Prune dangling images after pull and up" ],
'write-state' => [ 'w', 'write-state', "Always write updated state (only useful with --check)", null, false ],
],
'Hooks' => [
'slack' => [ null, 'slack:', "Notify a slack webhook when updating", "FRESH_SLACK" ],
'script-after' => [ null, 'after:', "Invoke script after updating", "FRESH_AFTER" ],
'script-before' => [ null, 'before:', "Invoke script before updating", "FRESH_BEFORE" ],
],
'Config' => [
'config' => [ 'c:', 'config:', "Use custom configuration file", "FRESH_CONFIG" ],
'config-type' => [ 'C:', 'config-type:', "Configuration type (auto, fresh, compose)", "FRESH_CONFIG_TYPE", "auto" ],
'state' => [ 's:', 'state:', "Override the state file name", "FRESH_STATE", ".fresh.yml" ],
'lockfile' => [ 'l:', 'lockfile:', "Override the lockfile file name", "FRESH_LOCKFILE", ".fresh.lock" ],
'credentials' => [ null, 'credentials:', "Set credentials loader type (auto or basic)", "FRESH_CREDENTIALS", "auto" ],
]
];
private static $StateFileName = ".fresh.yml";
private static $LockFileName = ".fresh.lock";
/** @var array The parsed options */
private array $options = [];
/** @var HookInterface[] The hooks to invoke */
private array $hooks = [];
private ?string $path = null;
private Log $log;
private $config;
private CredentialsLoaderInterface $credentialsLoader;
private PersistentState $state;
private Lockfile $lockfile;
public function __construct()
{
$this->log = new Log();
}
/**
* Parse the command line and set values from getopt output or environment
* variables.
*
*/
protected function parseCommandLine()
{
$opts = [];
$long = [];
$short = null;
$envs = [];
$parsed = [];
foreach (self::$optionsMap as $group=>$options) {
$opts = array_merge($opts, $options);
foreach ($options as $optname=>$opt) {
if ($opt[0]) $short .= $opt[0];
if ($opt[1]) $long[] = $opt[1];
if ($opt[3]??null) $envs[$opt[3]] = $optname;
$parsed[$optname] = $opt[4] ?? null;
}
}
foreach ($envs as $env=>$dest) {
$val = getenv($env) ?? $_SERVER[$env];
if ($val) {
$parsed[$dest] = $val;
}
}
$geto = getopt($short, $long);
foreach ($geto as $k=>$v) {
foreach ($opts as $o=>$oo) {
$h = null;
if ($k == rtrim($oo[0],':')) {
$h = str_ends_with($oo[0],':');
} elseif ($k == rtrim($oo[1],':')) {
$h = str_ends_with($oo[1],':');
}
if ($h !== null) {
$parsed[$o] = $h ? $v : true;
}
}
}
$this->options = $parsed;
$this->log->setVerbose($parsed['verbose']);
}
/**
* Print the usage info (from --help or -h)
*
*
*/
public function printUsage()
{
$tty = posix_isatty(STDOUT);
// add the "Self-Updating" options group if we are running in a phar
if (Phar::running()) {
self::$optionsMap['Self-Updating'] = [
'update' => [ 'U', 'self-update', "Update to the latest version" ],
];
}
printf("fresh.phar v%s (%s) - (c) 2022, NoccyLabs / GPL v3 or later\n", APP_VERSION, BUILD_DATE);
printf("Check for updates to docker images or compose stacks.\n\n");
printf("Usage:\n %s [options]\n\n", basename($GLOBALS['argv'][0]));
printf("Options:\n");
foreach(self::$optionsMap as $group=>$options) {
printf(" %s:\n", $group);
foreach ($options as $opt) {
$s = rtrim($opt[0], ':');
$l = rtrim($opt[1], ':');
$h = str_ends_with($opt[0],':') || str_ends_with($opt[1],':');
$d = $opt[4]??null;
$e = $opt[3]??null;
$optkey = null;
if ($s) $optkey .= "-" . $s;
if ($s&&$l) $optkey .= ",";
if ($l) $optkey .= "--" . $l;
if ($h) $optkey .= " VALUE";
$out = sprintf(" %-25s %s", $optkey, $opt[2]);
if ($d) $out.= sprintf(" (default: %s)", $d);
if ($e) $out.= sprintf(" [\$%s]", $e);
echo $out . "\n";
}
}
printf("\nNote:\n");
printf(" When invoked without any flags, fresh will look for a docker-compose.yml file and\n");
printf(" check all images in repositories for which credentials are available. If any of those\n");
printf(" images have been updated, the new images will be pulled and the containers recreated\n");
printf(" and restarted. If you only want to pull, use the --pull flag. If you only want to check\n");
printf(" for updates, use the --check flag and check the exit code.\n");
}
public function run()
{
$this->parseCommandLine();
if ($this->options['help']) {
$this->printUsage();
exit(0);
}
try {
$this->setupDirectory();
$this->setupCredentialsLoader();
$this->setupConfiguration();
$this->setupHooks();
$this->lockfile->lock();
$updated = $this->checkUpdates();
// If called with --check, only return exit status
if ($this->options['check']) {
if (($updated !== null) && $this->options['write-state']) {
// Flush state if called with -w/--write-state
$this->log->append("Flushing state...");
$this->state->flush();
}
exit(($updated === null) ? 0 : 1);
}
if ($updated !== null) {
$this->callHooks($updated, 'before');
$this->callScript('before');
$this->doUpdate($updated);
$this->callHooks($updated, 'after');
$this->callScript('after');
}
$this->lockfile->release();
exit(($updated === null) ? 0 : 1);
} catch (\Throwable $t) {
fprintf(STDERR, "fatal: %s (%s#%d)\n", $t->getMessage(), $t->getFile(), $t->getLine());
if (!$this->options['verbose']) {
fprintf(STDERR, $this->log->asString()."\n");
}
$this->lockfile->release();
exit(2);
}
}
/**
* Makes sure the directory is valid, and prepares the state file and the lockfile
* to be used.
*
*/
private function setupDirectory()
{
$this->path = $this->options['path'] ? realpath($this->options['path']) : getcwd();
if (!is_dir($this->path)) {
fwrite(STDERR, "error: No such path {$this->options['path']}\n");
exit(2);
}
$this->log->append("Working dir: ".$this->path);
chdir($this->path);
$statefile = $this->options['state'];
if (!str_starts_with($statefile,'/')) $statefile = $this->path . "/" . $statefile;
$lockfile = $this->options['lockfile'];
if (!str_starts_with($lockfile,'/')) $lockfile = $this->path . "/" . $lockfile;
$this->log->append("Statefile: {$statefile}");
$this->log->append("Lockfile: {$lockfile}");
$this->state = new PersistentState($statefile); // $this->path . "/" . self::$StateFileName);
$this->lockfile = new Lockfile($lockfile); // $this->path . "/" . self::$LockFileName);
}
/**
* Initialize the credentials loader to retrieve registry credentials
*
*/
private function setupCredentialsLoader()
{
switch ($this->options['credentials']) {
case 'auto':
case 'basic':
$this->credentialsLoader = new BasicCredentialsLoader();
$this->log->append("Using BasicCredentialsLoader");
break;
default:
fwrite(STDERR, "error: Invalid credentials loader type\n");
exit(2);
}
}
/**
* Initialize the configuration to use for updating
*
*/
private function setupConfiguration()
{
if ($this->options['image']) {
$this->config = new SingleImageConfiguration($this->options['image']);
return;
}
$configType = $this->options['config-type'];
$configFile = $this->options['config'] ? realpath($this->options['config']) : null;
switch ($configType) {
case 'auto':
if ($configFile) {
if (basename($configFile) == "docker-compose.yml") {
$this->log->append("Using configuration file ".$configFile);
$this->config = new ComposeConfiguration($configFile);
return;
}
if (basename($configFile) == "freshdocker.conf") {
$this->log->append("Using configuration file ".$this->path."/freshdocker.conf");
$this->config = new LocalConfiguration($this->path."/freshdocker.conf");
return;
}
} else {
if (file_exists($this->path."/docker-compose.yml")) {
$this->log->append("Using configuration file ".$this->path."/docker-compose.yml");
$this->config = new ComposeConfiguration($this->path."/docker-compose.yml");
return;
}
if (file_exists($this->path."/freshdocker.conf")) {
$this->log->append("Using configuration file ".$this->path."/freshdocker.conf");
$this->config = new LocalConfiguration($this->path."/freshdocker.conf");
return;
}
}
break;
case 'fresh':
if ($configFile) {
if (basename($configFile) == "freshdocker.confl") {
$this->log->append("Using configuration file ".$configFile);
$this->config = new LocalConfiguration($configFile);
return;
}
} else {
if (file_exists($this->path."/freshdocker.conf")) {
$this->log->append("Using configuration file ".$this->path."/freshdocker.conf");
$this->config = new LocalConfiguration($this->path."/freshdocker.conf");
return;
}
}
break;
case 'compose':
if ($configFile) {
if (basename($configFile) == "docker-compose.yml") {
$this->log->append("Using configuration file ".$configFile);
$this->config = new ComposeConfiguration($configFile);
return;
}
} else {
if (file_exists($this->path."/docker-compose.yml")) {
$this->log->append("Using configuration file ".$this->path."/docker-compose.yml");
$this->config = new ComposeConfiguration($this->path."/docker-compose.yml");
return;
}
}
break;
}
fwrite(STDERR, "error: Couldn't find a supported configuration file\n");
exit(2);
}
/**
* Check for newer versions of the image(s)
*
* @return array|null An array of information on updated images
*/
private function checkUpdates(): ?array
{
$checks = $this->config->getChecks();
if (count($checks) === 0) {
fwrite(STDERR, "error: couldn't find any images to check\n");
exit(2);
}
$update = [];
$quiet = $this->options['quiet'];
$this->log->append("Checking ".count($checks)." images");
foreach ($checks as $check) {
$ref = new ImageReference($check);
$reg = $ref->getRegistry();
$credentials = $this->credentialsLoader->getCredentials($reg);
if (!$credentials) {
$this->log->append(sprintf(" %s: missing credentials for registry", $reg."/".$ref->getImage(), $ref->getRegistry()));
continue;
}
$client = new RegistryV2Client($reg, $credentials);
try {
$status = $client->getImageStatus($ref->getImage(), $ref->getTag());
} catch (RequestException $e) {
$this->log->append(sprintf(" %s: registry returned error (%s)", $reg."/".$ref->getImage(), $e->getMessage()));
continue;
}
$image = $reg."/".$status['image'].":".$status['tag'];
$oldHash = $this->state->get($image);
$newHash = $status['hash'];
if ($oldHash != $newHash) {
if (!$quiet && !$this->options['verbose']) printf("%s: %s → %s\n", $image, $this->truncateHash($oldHash), $this->truncateHash($newHash));
$this->log->append(sprintf(" %s: %s → %s", $image, $this->truncateHash($oldHash), $this->truncateHash($newHash)));
$this->state->set($image, $newHash);
$update[] = (object)[
'ref' => $ref,
'old' => $oldHash,
'new' => $newHash
];
} else {
$this->log->append(sprintf(" %s: %s", $image, $this->truncateHash($newHash)));
}
}
return empty($update) ? null : $update;
}
public function doUpdate(array $updated)
{
if ($this->options['only']) {
$pullSpec = explode(",", $this->options['only']);
$this->log->append("Refreshing only: " . join(", ", $pullSpec));
} elseif ($this->options['updated'] && ($this->config instanceof ComposeConfiguration)) {
$pullSpec = $this->config->getServicesForImages($updated);
if (count($pullSpec) == 0) {
$this->log->append("Fatal: failed to resolve updated services to docker-compose pull");
return;
}
$this->log->append("Refreshing updated: " . join(", ", $pullSpec));
} else {
$pullSpec = null;
}
$this->log->append("Pulling updated images...");
$this->exec("docker-compose pull --quiet ". ($pullSpec?join(" ",array_map('escapeshellarg', $pullSpec)):'') );
if (!$this->options['pull']) {
$this->log->append("Refreshing updated containers...");
$this->exec("docker-compose up -d". ($pullSpec?join(" ",array_map('escapeshellarg', $pullSpec)):'') );
if ($this->options['prune']) {
$this->log->append("Pruning dangling images...");
$this->exec("docker image prune -f");
}
}
$this->log->append("Flushing state...");
$this->state->flush();
}
private function setupHooks()
{
$slackUrl = $this->options['slack'];
if ($slackUrl) {
$this->hooks[] = new SlackHook([
'url' => $slackUrl
]);
}
}
private function callHooks(array $updated, string $event)
{
switch ($event) {
case 'before':
$images = [];
foreach ($updated as $u) {
$images[] = sprintf("%s/%s:%s", $u->ref->getRegistry(), $u->ref->getImage(), $u->ref->getTag());
}
$msg = "Deploying updated containers:\n* ".join("\n* ", $images);
break;
case 'after':
$msg = "Deploy complete";
break;
default:
return;
}
foreach ($this->hooks as $hook) {
$hook->sendMessage($msg, []);
}
}
private function callScript(string $event)
{
$script = $this->options["script-{$event}"] ?? null;
if ($script) {
$this->exec($script);
}
}
private function truncateHash(?string $hash): string
{
if ($hash === null) return '?';
return substr($hash, 0, 4) . ".." . substr($hash, -4, 4);
}
private function exec(string $cmdl)
{
$this->log->append("\$ {$cmdl}");
$fd = popen($cmdl." 2>&1","r");
while (!feof($fd)) {
$s = rtrim(fgets($fd));
if (trim($s))
$this->log->append($s);
}
fclose($fd);
}
}