commit 6cdb155dc530d288069dd9a0df62fb0c402130aa Author: Christopher Vagnetoft Date: Mon Mar 7 22:45:54 2022 +0100 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..24786e9 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Fresh: Keeping your docker stacks up to date + + + + +## 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: + +``` +$ fresh.phar +``` + + +Manually pulling when changed: + +``` +if ! fresh.phar --check; then + docker-compose pull + docker-compose up -d +fi +``` \ No newline at end of file diff --git a/bin/freshdocker b/bin/freshdocker new file mode 100755 index 0000000..ae38347 --- /dev/null +++ b/bin/freshdocker @@ -0,0 +1,133 @@ +#!/usr/bin/env php + "Show this help", + '-d,--dir DIR' => "Change working directory to DIR", + '-c,--config FILE' => "Use a custom configuration (not implemented)", + '-i,--image REF' => "Check a specific image instead of using config/docker-compose", + '--pull' => "Only pull if new, don't up", + '--credentials TYPE' => "Select credentials loader (auto or basic)", + '--check' => "Only check, set exit code 1 if not fresh", + '-q,--quiet' => "Don't show any output (except dockers)", + ] as $a=>$b) + printf(" %-20s %s\n", $a, $b); + exit(0); + +} + +$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); + +$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) { + //printf("notice: missing credentials for %s, skipping image %s\n", $reg, $check); + 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"); +} + +exit($update?1:0); + +function truncate(?string $hash): string +{ + if ($hash === null) return '*'; + return substr($hash, 0, 4) . ".." . substr($hash, -4, 4); +} + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b0a834e --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "noccylabs/fresh-docker", + "description": "Update docker-compose stacks when new versions are pushed", + "type": "application", + "license": "MIT", + "autoload": { + "psr-4": { + "NoccyLabs\\FreshDocker\\": "src/" + } + }, + "require": { + "php": "^8.0", + "ext-json": "*", + "symfony/yaml": "^6.0", + "guzzlehttp/guzzle": "^7.4" + }, + "bin": [ + "bin/freshdocker" + ], + "extra": { + "phar": { + "output": "fresh.phar" + } + } +} diff --git a/src/Configuration/DockerComposeConfiguration.php b/src/Configuration/DockerComposeConfiguration.php new file mode 100644 index 0000000..ac22676 --- /dev/null +++ b/src/Configuration/DockerComposeConfiguration.php @@ -0,0 +1,33 @@ +loadComposeFile($configfile); + } + + private function loadComposeFile($configfile) + { + $config = Yaml::parseFile($configfile); + $services = $config['services']??[]; + foreach ($services as $name=>$service) { + $image = $service['image']??null; + if ($image && !in_array($image,$this->checks)) { + $this->checks[] = $image; + } + } + } + + public function getChecks(): array + { + return $this->checks; + } +} \ No newline at end of file diff --git a/src/Configuration/LocalConfiguration.php b/src/Configuration/LocalConfiguration.php new file mode 100644 index 0000000..7a4f293 --- /dev/null +++ b/src/Configuration/LocalConfiguration.php @@ -0,0 +1,21 @@ +config = Yaml::parseFile($configfile); + } + + public function getChecks(): array + { + return $this->config['check']??[]; + } +} \ No newline at end of file diff --git a/src/Credentials/BasicCredentialsLoader.php b/src/Credentials/BasicCredentialsLoader.php new file mode 100644 index 0000000..3f77495 --- /dev/null +++ b/src/Credentials/BasicCredentialsLoader.php @@ -0,0 +1,31 @@ +load(); + } + + private function load() + { + $config = getenv("HOME")."/.docker/config.json"; + if (!file_exists($config)) return; + + $conf = json_decode(file_get_contents($config)); + $this->auth = isset($conf->auths) ? (array)$conf->auths : []; + } + + public function getCredentials(string $repo): ?array + { + if (!array_key_exists($repo, $this->auth)) + return null; + $auth = $this->auth[$repo]->auth; + return explode(":", base64_decode($auth), 2); + } +} \ No newline at end of file diff --git a/src/Hooks/SlackHook.php b/src/Hooks/SlackHook.php new file mode 100644 index 0000000..7003fa7 --- /dev/null +++ b/src/Hooks/SlackHook.php @@ -0,0 +1,56 @@ +options = $options; + } + + public function sendMessage(string $text, array $options) + { + $mergedOptions = array_merge($this->options, $options); + + $url = $mergedOptions['url'] ?? throw new RuntimeException("Empty hook url"); + + $body = $this->makeBody($text, $mergedOptions); + + $client = new Client(); + $client->post($url, [ + 'json' => $body + ]); + } + + private function makeBody(string $text, array $options) + { + /* + { + "channel": "town-square", + "username": "test-automation", + "icon_url": "https://mattermost.org/wp-content/uploads/2016/04/icon.png", + "text": "#### Test results for July 27th, 2017\n please review failed tests.\n + | Component | Tests Run | Tests Failed | + |:-----------|:-----------:|:-----------------------------------------------| + | Server | 948 | :white_check_mark: 0 | + | Web Client | 123 | :warning: 2 [(see details)](http://linktologs) | + | iOS Client | 78 | :warning: 3 [(see details)](http://linktologs) | + " + } + */ + $body = []; + if (array_key_exists('channel', $options)) $body['channel'] = $options['channel']; + if (array_key_exists('username', $options)) $body['username'] = $options['username']; + if (array_key_exists('icon_url', $options)) $body['icon_url'] = $options['icon_url']; + $body['text'] = $text; + + return $body; + } +} \ No newline at end of file diff --git a/src/ImageReference.php b/src/ImageReference.php new file mode 100644 index 0000000..8e2b14a --- /dev/null +++ b/src/ImageReference.php @@ -0,0 +1,56 @@ +image = $image; + $this->tag = $tag; + $this->registry = $parts['host']; + $this->scheme = $parts['scheme']; + } + + public function getRegistry(): string + { + return $this->registry; + } + + public function getImage(): string + { + return $this->image; + } + + public function getTag(): string + { + return $this->tag; + } +} \ No newline at end of file diff --git a/src/Registry/RegistryV2Client.php b/src/Registry/RegistryV2Client.php new file mode 100644 index 0000000..f35816c --- /dev/null +++ b/src/Registry/RegistryV2Client.php @@ -0,0 +1,50 @@ +createClient($registry, $auth); + } + + private function createClient(string $registry, ?array $auth) + { + $this->client = new Client([ + 'base_uri' => 'https://' . $registry . '/v2/', + 'auth' => $auth, + ]); + } + + public function getManifest(string $image, string $tag) + { + $response = $this->client->get("{$image}/manifests/{$tag}"); + $body = $response->getBody(); + return json_decode($body); + } + + public function getImageStatus(string $image, string $tag) + { + $manifest = $this->getManifest($image, $tag); + + $fslayers = (array)$manifest->fsLayers; + $fshashes = array_map(fn($layer) => $layer->blobSum, $fslayers); + $metahash = hash("sha256", join("|", $fshashes)); + + $history = $manifest->history[0]; + $info = json_decode($history->v1Compatibility); + + return [ + 'image' => $image, + 'tag' => $tag, + 'hash' => $metahash, + 'created' => $info->created??null, + ]; + } + +} diff --git a/src/State/PersistentState.php b/src/State/PersistentState.php new file mode 100644 index 0000000..24cdbbf --- /dev/null +++ b/src/State/PersistentState.php @@ -0,0 +1,47 @@ +filename = $filename; + register_shutdown_function([$this,"flush"]); + if (file_exists($filename)) { + $this->state = Yaml::parseFile($filename); + } + } + + public function flush() + { + if (!$this->dirty) return; + $yaml = Yaml::dump($this->state); + file_put_contents($this->filename."~", $yaml); + rename($this->filename."~", $this->filename); + } + + public function set(string $key, $value) + { + $this->state[$key] = $value; + $this->dirty = true; + } + + public function get(string $key, $default=null): mixed + { + return $this->state[$key] ?? $default; + } + + public function unset(string $key) + { + unset($this->state[$key]); + } +} \ No newline at end of file