* Fixed a bug where the lockfile would be released on exit even if it wasn't locked by the process * Disabled the phpx build as it is broken
		
			
				
	
	
		
			490 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			490 lines
		
	
	
		
			18 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 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;
 | 
						|
 | 
						|
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" ],
 | 
						|
            '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);
 | 
						|
 | 
						|
        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();
 | 
						|
                $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);
 | 
						|
 | 
						|
 | 
						|
            $status = $client->getImageStatus($ref->getImage(), $ref->getTag());
 | 
						|
            $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()
 | 
						|
    {
 | 
						|
        $this->log->append("Pulling updated images...");
 | 
						|
        $this->exec("docker-compose pull --quiet");
 | 
						|
        if (!$this->options['pull']) {
 | 
						|
            $this->log->append("Refreshing updated containers...");
 | 
						|
            $this->exec("docker-compose up -d");
 | 
						|
            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);
 | 
						|
    }
 | 
						|
 | 
						|
}
 | 
						|
 |