Initial commit
This commit is contained in:
commit
6cdb155dc5
35
README.md
Normal file
35
README.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Fresh: Keeping your docker stacks up to date
|
||||
|
||||
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Options:
|
||||
|
||||
```
|
||||
-d,--dir <path> Chdir to path on startup
|
||||
-c,--config <file> Read file as docker-compose.yml
|
||||
-i,--image <image> Only check if a newer image exists, set exitcode
|
||||
-p,--pull Only pull, don't restart anything
|
||||
--credentials <type> Credentials loader type (auto, basic)
|
||||
--check Only check (set exitcode)
|
||||
--slack <url> 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
|
||||
```
|
133
bin/freshdocker
Executable file
133
bin/freshdocker
Executable file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
|
||||
use NoccyLabs\FreshDocker\Configuration\DockerComposeConfiguration;
|
||||
use NoccyLabs\FreshDocker\Configuration\LocalConfiguration;
|
||||
use NoccyLabs\FreshDocker\Credentials\BasicCredentialsLoader;
|
||||
use NoccyLabs\FreshDocker\Hooks\SlackHook;
|
||||
use NoccyLabs\FreshDocker\ImageReference;
|
||||
use NoccyLabs\FreshDocker\Registry\RegistryV2Client;
|
||||
use NoccyLabs\FreshDocker\State\PersistentState;
|
||||
|
||||
require_once __DIR__."/../vendor/autoload.php";
|
||||
|
||||
$opts = getopt("hd:c:i:q", [ "help", "dir:", "config:", "image:", "pull", "credentials:", "check", "slack:", "quiet" ]);
|
||||
|
||||
if (array_key_exists('h', $opts) || array_key_exists('help', $opts)) {
|
||||
|
||||
printf("Usage:\n %s [options]\n\n", basename($argv[0]));
|
||||
printf("Options:\n");
|
||||
foreach([
|
||||
'-h,--help' => "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);
|
||||
}
|
||||
|
||||
|
25
composer.json
Normal file
25
composer.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
33
src/Configuration/DockerComposeConfiguration.php
Normal file
33
src/Configuration/DockerComposeConfiguration.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\Configuration;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class DockerComposeConfiguration
|
||||
{
|
||||
|
||||
private array $checks = [];
|
||||
|
||||
public function __construct(string $configfile)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
21
src/Configuration/LocalConfiguration.php
Normal file
21
src/Configuration/LocalConfiguration.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\Configuration;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalConfiguration
|
||||
{
|
||||
|
||||
private array $config = [];
|
||||
|
||||
public function __construct(string $configfile)
|
||||
{
|
||||
$this->config = Yaml::parseFile($configfile);
|
||||
}
|
||||
|
||||
public function getChecks(): array
|
||||
{
|
||||
return $this->config['check']??[];
|
||||
}
|
||||
}
|
31
src/Credentials/BasicCredentialsLoader.php
Normal file
31
src/Credentials/BasicCredentialsLoader.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\Credentials;
|
||||
|
||||
class BasicCredentialsLoader
|
||||
{
|
||||
|
||||
private array $auth = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
56
src/Hooks/SlackHook.php
Normal file
56
src/Hooks/SlackHook.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\Hooks;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use RuntimeException;
|
||||
|
||||
class SlackHook
|
||||
{
|
||||
|
||||
private array $options = [];
|
||||
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$this->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<!channel> 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;
|
||||
}
|
||||
}
|
56
src/ImageReference.php
Normal file
56
src/ImageReference.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker;
|
||||
|
||||
|
||||
class ImageReference
|
||||
{
|
||||
private $image;
|
||||
|
||||
private $tag;
|
||||
|
||||
private $registry;
|
||||
|
||||
private $scheme;
|
||||
|
||||
public function __construct(string $image)
|
||||
{
|
||||
// if (!preg_match('/^http[s]:\/\//i', $image)) {
|
||||
$parts = explode("/", $image);
|
||||
if (count($parts) < 3) {
|
||||
$image = "https://registry.hub.docker.com/{$image}";
|
||||
} else {
|
||||
$image = "https://{$image}";
|
||||
}
|
||||
|
||||
$parts = parse_url($image);
|
||||
$image = ltrim($parts['path'],'/');
|
||||
if (str_contains($image,':')) {
|
||||
[$image, $tag] = explode(":", $image, 2);
|
||||
} else {
|
||||
$tag = 'latest';
|
||||
}
|
||||
if (!str_contains($image,'/')) {
|
||||
$image = "library/{$image}";
|
||||
}
|
||||
$this->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;
|
||||
}
|
||||
}
|
50
src/Registry/RegistryV2Client.php
Normal file
50
src/Registry/RegistryV2Client.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\Registry;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
class RegistryV2Client
|
||||
{
|
||||
private Client $client;
|
||||
|
||||
public function __construct(string $registry, ?array $auth)
|
||||
{
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
47
src/State/PersistentState.php
Normal file
47
src/State/PersistentState.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace NoccyLabs\FreshDocker\State;
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class PersistentState
|
||||
{
|
||||
private string $filename;
|
||||
|
||||
private array $state = [];
|
||||
|
||||
private bool $dirty = false;
|
||||
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user