diff --git a/.gitignore b/.gitignore index 2be2e62..b8e38c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor /composer.lock /*.phar +/src/version.php +/dist diff --git a/README.md b/README.md index 6e069e3..14007c6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Fresh: Keeping your docker stacks up to date +Fresh was written to scratch an itch. It works by querying the respective repositories +returning + ## Building Build using NoccyLabs Pharlite. @@ -7,30 +10,38 @@ Build using NoccyLabs Pharlite. ## Usage -Options: - -``` - -d,--dir Chdir to path on startup - -c,--config Read file as docker-compose.yml - -i,--image Only check if a newer image exists, set exitcode - -p,--pull Only pull, don't restart anything - --credentials Credentials loader type (auto, basic) - --check Only check (set exitcode) - --slack Notify a Slack webhook on update - -q,--quiet Don't show any extra output (except dockers) -``` - -Update everything and up any new containers in current dir: +To check for updates, pull updated images and recreate any containers defined in the +`docker-compose.yml` in the current directory: ``` $ fresh.phar ``` -Manually pulling when changed: +For all available options, use the `--help` flag. + +## Known Issues + +- There is currently no way to update the state when manually updating using the + `--check` flag. +- Only checks authenticated registries for new versions. But if you are using + this you probably aren't using DockerHub anyway. +- ~Using the `-q` or `--quiet` flags doesn't mute the output from `docker-compose`.~ +- ~Using `--check` still updates the state, need a `--no-state` flag or so to ~ + ~prevent updating the state.~ + +## FAQ + +- **How does Fresh remember the last seen hash?** The container hashes are stored + in the same directory as the `docker-compose.yml` file in a file named `fresh.yml`. + Remove this file to force trigger an update. + +## Changes + +**0.1.1** + +- Moved the logic from the entrypoint script to its own class. +- Added locking (though `fresh.lock` lockfile) to prevent multiple instances. +- Added `--after` hook to invoke script after update. +- Disabled automatic flushing of the state to disk; --check will no longer update + the state file, but --pull and default update will. -``` -if ! fresh.phar --check; then - docker-compose pull - docker-compose up -d -fi -``` diff --git a/bin/freshdocker b/bin/freshdocker index ba647a8..1eeac9e 100755 --- a/bin/freshdocker +++ b/bin/freshdocker @@ -1,154 +1,13 @@ #!/usr/bin/env php "Show this help", - '-q,--quiet' => "Don't show any output (except dockers)", - '-d,--dir DIR' => "Change working directory to DIR", - '-i,--image REF' => "Check a specific image instead of using config/docker-compose", - '--pull' => "Only pull if new, don't up", - '--check' => "Only check, set exit code 1 if not fresh", - '--prune' => "Prune dangling images after pull and up", - ] as $a=>$b) printf(" %-20s %s\n", $a, $b); - - printf(" Webhooks:\n"); - foreach([ - '--slack URL' => "Notify a Slack webhook when updating", - ] as $a=>$b) printf(" %-20s %s\n", $a, $b); - - printf(" Configuration:\n"); - foreach([ - '-c,--config FILE' => "Use a custom configuration (not implemented)", - '--credentials TYPE' => "Select credentials loader (auto or basic)", - ] as $a=>$b) printf(" %-20s %s\n", $a, $b); - - - exit(0); - +if (file_exists(__DIR__."/../src/version.php")) { + require_once __DIR__."/../src/version.php"; +} else { + define("APP_VERSION", "DEV"); } -$credentialsLoaderType = $opts['credentials'] ?? 'auto'; -$path = realpath($opts['d'] ?? ($opts['dir'] ?? getcwd())); -$onlyPull = array_key_exists('p', $opts) || array_key_exists('pull', $opts); -$onlyCheck = array_key_exists('check', $opts); -$quiet = array_key_exists('q', $opts) || array_key_exists('quiet', $opts); -$verbose = array_key_exists('v', $opts); -$prune = array_key_exists('prune', $opts); - -$slackUrl = array_key_exists('slack', $opts) ? $opts['slack'] : null; -$slackHook = null; -if ($slackUrl) { - $slackHook = new SlackHook([ - 'url' => $slackUrl - ]); -} - -if (!is_dir($path)) { - fwrite(STDERR, "error: invalid path for --dir/-d\n"); - exit(2); -} -chdir($path); - -$path = getcwd(); -if (!file_exists($path."/docker-compose.yml")) { - fwrite(STDERR, "error: no docker-compose.yml in the current directory\n"); - exit(2); -} - -switch ($credentialsLoaderType) { - case 'auto': - case 'basic': - $credentialsLoader = new BasicCredentialsLoader(); - break; - default: - fwrite(STDERR, "error: invalid credentials loader {$credentialsLoaderType}\n"); - exit(2); -} - -//$configuration = new LocalConfiguration(getcwd()."/freshdocker.conf"); -$configuration = new DockerComposeConfiguration("{$path}/docker-compose.yml"); -$state = new PersistentState("{$path}/fresh.yml"); -$checks = $configuration->getChecks(); -if (count($checks) === 0) { - fwrite(STDERR, "error: couldn't find any images to check\n"); - exit(2); -} -$update = false; - -$log = []; -foreach ($checks as $check) { - - $ref = new ImageReference($check); - $reg = $ref->getRegistry(); - - $credentials = $credentialsLoader->getCredentials($reg); - if (!$credentials) { - if ($verbose) printf("%s: missing credentials for %s\n", $ref->getImage(), $ref->getRegistry()); - continue; - } - $client = new RegistryV2Client($reg, $credentials); - - - $status = $client->getImageStatus($ref->getImage(), $ref->getTag()); - $image = $status['image'].":".$status['tag']; - $oldHash = $state->get($image); - $newHash = $status['hash']; - $state->set($image, $newHash); - - if ($oldHash != $newHash) { - if (!$quiet) printf("%s: %s → %s\n", $image, truncate($oldHash), truncate($newHash)); - $log[] = sprintf("%s: %s → %s\n", $image, truncate($oldHash), truncate($newHash)); - $update = true; - } else { - if (!$quiet) printf("%s: %s\n", $image, truncate($newHash)); - } -} - - -if ($onlyCheck) { - exit($update?1:0); -} - -if ($update) { - if ($slackHook) { - $msg = "Deploying new container versions:\n* ".join("\n* ", $log); - $slackHook->sendMessage($msg, []); - } - passthru("docker-compose pull"); - if (!$onlyPull) { - passthru("docker-compose up -d"); - if ($prune) { - passthru("docker image prune -f"); - } - } -} - -exit($update?1:0); - -function truncate(?string $hash): string -{ - if ($hash === null) return '*'; - return substr($hash, 0, 4) . ".." . substr($hash, -4, 4); -} - - +$r = new NoccyLabs\FreshDocker\Refresher(); +$r->run(); diff --git a/src/Configuration/DockerComposeConfiguration.php b/src/Configuration/ComposeConfiguration.php similarity index 95% rename from src/Configuration/DockerComposeConfiguration.php rename to src/Configuration/ComposeConfiguration.php index ac22676..d16d233 100644 --- a/src/Configuration/DockerComposeConfiguration.php +++ b/src/Configuration/ComposeConfiguration.php @@ -4,7 +4,7 @@ namespace NoccyLabs\FreshDocker\Configuration; use Symfony\Component\Yaml\Yaml; -class DockerComposeConfiguration +class ComposeConfiguration { private array $checks = []; diff --git a/src/Credentials/BasicCredentialsLoader.php b/src/Credentials/BasicCredentialsLoader.php index 3f77495..3047b58 100644 --- a/src/Credentials/BasicCredentialsLoader.php +++ b/src/Credentials/BasicCredentialsLoader.php @@ -2,7 +2,7 @@ namespace NoccyLabs\FreshDocker\Credentials; -class BasicCredentialsLoader +class BasicCredentialsLoader implements CredentialsLoaderInterface { private array $auth = []; diff --git a/src/Credentials/CredentialsLoaderInterface.php b/src/Credentials/CredentialsLoaderInterface.php new file mode 100644 index 0000000..2d0d413 --- /dev/null +++ b/src/Credentials/CredentialsLoaderInterface.php @@ -0,0 +1,10 @@ + [ + '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" ], + ], + 'Hooks' => [ + 'slack' => [ null, 'slack:', "Notify a slack webhook when updating", "FRESH_SLACK" ], + 'script' => [ null, 'after:', "Invoke script after updating", "FRESH_AFTER" ], + ], + 'Config' => [ + 'config' => [ 'c:', 'config:', "Use custom configuration file", "FRESH_CONFIG" ], + 'type' => [ 'C:', 'config-type:', "Configuration type (auto, fresh, compose)", "FRESH_CONFIG_TYPE", "auto" ], + 'credentials' => [ null, 'credentials:', "Set credentials loader type (auto or basic)", "FRESH_CREDENTIALS", "auto" ], + ] + ]; + + 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[0],':'); + } + if ($h !== null) { + $parsed[$o] = $h ? $v : true; + } + } + } + + $this->options = $parsed; + + $this->log->setVerbose($parsed['verbose']); + + } + + public function printUsage() + { + + printf("fresh.phar v%s - (c) 2022, NoccyLabs / GPL v3 or later\n", APP_VERSION); + 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"; + printf(" %-25s %s", $optkey, $opt[2]); + if ($d) printf(" (default: %s)", $d); + if ($e) printf(" [\$%s]", $e); + echo "\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(); + + $updated = $this->checkUpdates(); + + // If called with --check, only return exit status + if ($this->options['check']) { + exit(($updated === null) ? 0 : 1); + } + + if ($updated !== null) { + $this->callHooks($updated); + $this->doUpdate($updated); + $this->callScript(); + } + + 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"); + } + 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); + + $this->state = new PersistentState($this->path . "/fresh.yml"); + $this->lockfile = new Lockfile($this->path . "/fresh.lock"); + } + + /** + * 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 for credentials"); + break; + default: + fwrite(STDERR, "error: Invalid credentials loader type\n"); + exit(2); + } + } + + /** + * Initialize the configuration to use for updating + * + */ + private function setupConfiguration() + { + + if (file_exists($this->path."/docker-compose.yml")) { + $this->config = new ComposeConfiguration($this->path."/docker-compose.yml"); + } else { + 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 + { + + $this->lockfile->lock(); + + $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 -q"); + 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) + { + $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); + + foreach ($this->hooks as $hook) { + $hook->sendMessage($msg); + } + + } + + private function callScript() + { + $script = $this->options['script']; + 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); + } + +} + diff --git a/src/State/Lockfile.php b/src/State/Lockfile.php new file mode 100644 index 0000000..bf325f7 --- /dev/null +++ b/src/State/Lockfile.php @@ -0,0 +1,36 @@ +filename = $filename; + register_shutdown_function([$this,"release"]); + } + + public function lock() + { + if (file_exists($this->filename)) { + if (time() - filemtime($this->filename) < $this-$maxLock) { + throw new \RuntimeException("Lockfile {$this->filename} already exists"); + } + } + touch($this->filename); + } + + public function release() + { + if (file_exists($this->filename)) { + unlink($this->filename); + } + + } + + +} \ No newline at end of file diff --git a/src/State/Log.php b/src/State/Log.php new file mode 100644 index 0000000..4c8f722 --- /dev/null +++ b/src/State/Log.php @@ -0,0 +1,39 @@ +verbose = $verbose; + } + + public function setVerbose(bool $verbose) + { + $this->verbose = $verbose; + } + + public function append(string $message) + { + array_push($this->log, $message); + if ($this->verbose) { + fwrite(STDOUT, $message . "\n"); + } + } + + public function flush() + { + if ($this->verbose) return; + fwrite(STDOUT, join("\n", $this->log)."\n"); + } + + public function asString(): string + { + return join("\n", $this->log); + } +} \ No newline at end of file diff --git a/src/State/PersistentState.php b/src/State/PersistentState.php index 24cdbbf..b18e18f 100644 --- a/src/State/PersistentState.php +++ b/src/State/PersistentState.php @@ -15,15 +15,17 @@ class PersistentState public function __construct(string $filename) { $this->filename = $filename; - register_shutdown_function([$this,"flush"]); + // register_shutdown_function([$this,"flush"]); if (file_exists($filename)) { - $this->state = Yaml::parseFile($filename); + $this->state = Yaml::parseFile($filename) ?? []; } } public function flush() { if (!$this->dirty) return; + $this->dirty = false; + $yaml = Yaml::dump($this->state); file_put_contents($this->filename."~", $yaml); rename($this->filename."~", $this->filename);