. */ 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); } }