Refactoring and cleanup
* Moved logic from entrypoint to a dedicated class. * Disabled automatic flush of state. * Added locking support to prevent multiple instances. * Added logging * Added base interface for CredentialsLoader
This commit is contained in:
parent
1f44d9f44b
commit
ec1659e96d
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
/vendor
|
/vendor
|
||||||
/composer.lock
|
/composer.lock
|
||||||
/*.phar
|
/*.phar
|
||||||
|
/src/version.php
|
||||||
|
/dist
|
||||||
|
53
README.md
53
README.md
@ -1,5 +1,8 @@
|
|||||||
# Fresh: Keeping your docker stacks up to date
|
# Fresh: Keeping your docker stacks up to date
|
||||||
|
|
||||||
|
Fresh was written to scratch an itch. It works by querying the respective repositories
|
||||||
|
returning
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Build using NoccyLabs Pharlite.
|
Build using NoccyLabs Pharlite.
|
||||||
@ -7,30 +10,38 @@ Build using NoccyLabs Pharlite.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Options:
|
To check for updates, pull updated images and recreate any containers defined in the
|
||||||
|
`docker-compose.yml` in the current directory:
|
||||||
```
|
|
||||||
-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
|
$ 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
|
|
||||||
```
|
|
||||||
|
153
bin/freshdocker
153
bin/freshdocker
@ -1,154 +1,13 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?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";
|
require_once __DIR__."/../vendor/autoload.php";
|
||||||
|
|
||||||
$opts = getopt("hd:c:i:qv", [ "help", "dir:", "config:", "image:", "pull", "credentials:", "check", "slack:", "quiet", "prune" ]);
|
if (file_exists(__DIR__."/../src/version.php")) {
|
||||||
|
require_once __DIR__."/../src/version.php";
|
||||||
if (array_key_exists('h', $opts) || array_key_exists('help', $opts)) {
|
} else {
|
||||||
|
define("APP_VERSION", "DEV");
|
||||||
printf("fresh.phar - (c) 2022, NoccyLabs / GPL v3 or later\n");
|
|
||||||
printf("Check for updates to docker images or compose stacks.\n\n");
|
|
||||||
printf("Usage:\n %s [options]\n\n", basename($argv[0]));
|
|
||||||
printf("Options:\n");
|
|
||||||
printf(" General:\n");
|
|
||||||
foreach([
|
|
||||||
'-h,--help' => "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);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$credentialsLoaderType = $opts['credentials'] ?? 'auto';
|
$r = new NoccyLabs\FreshDocker\Refresher();
|
||||||
$path = realpath($opts['d'] ?? ($opts['dir'] ?? getcwd()));
|
$r->run();
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ namespace NoccyLabs\FreshDocker\Configuration;
|
|||||||
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class DockerComposeConfiguration
|
class ComposeConfiguration
|
||||||
{
|
{
|
||||||
|
|
||||||
private array $checks = [];
|
private array $checks = [];
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace NoccyLabs\FreshDocker\Credentials;
|
namespace NoccyLabs\FreshDocker\Credentials;
|
||||||
|
|
||||||
class BasicCredentialsLoader
|
class BasicCredentialsLoader implements CredentialsLoaderInterface
|
||||||
{
|
{
|
||||||
|
|
||||||
private array $auth = [];
|
private array $auth = [];
|
||||||
|
10
src/Credentials/CredentialsLoaderInterface.php
Normal file
10
src/Credentials/CredentialsLoaderInterface.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\FreshDocker\Credentials;
|
||||||
|
|
||||||
|
interface CredentialsLoaderInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
public function getCredentials(string $repo): ?array;
|
||||||
|
|
||||||
|
}
|
362
src/Refresher.php
Normal file
362
src/Refresher.php
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\FreshDocker;
|
||||||
|
|
||||||
|
use NoccyLabs\FreshDocker\State\Log;
|
||||||
|
use NoccyLabs\FreshDocker\Configuration\ComposeConfiguration;
|
||||||
|
use NoccyLabs\FreshDocker\Configuration\LocalConfiguration;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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" ],
|
||||||
|
],
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
36
src/State/Lockfile.php
Normal file
36
src/State/Lockfile.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\FreshDocker\State;
|
||||||
|
|
||||||
|
class Lockfile
|
||||||
|
{
|
||||||
|
private string $filename;
|
||||||
|
|
||||||
|
private int $maxLock = 3600;
|
||||||
|
|
||||||
|
public function __construct(string $filename)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
39
src/State/Log.php
Normal file
39
src/State/Log.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\FreshDocker\State;
|
||||||
|
|
||||||
|
class Log
|
||||||
|
{
|
||||||
|
private bool $verbose = false;
|
||||||
|
|
||||||
|
private array $log = [];
|
||||||
|
|
||||||
|
public function __construct(bool $verbose=false)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
@ -15,15 +15,17 @@ class PersistentState
|
|||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
$this->filename = $filename;
|
$this->filename = $filename;
|
||||||
register_shutdown_function([$this,"flush"]);
|
// register_shutdown_function([$this,"flush"]);
|
||||||
if (file_exists($filename)) {
|
if (file_exists($filename)) {
|
||||||
$this->state = Yaml::parseFile($filename);
|
$this->state = Yaml::parseFile($filename) ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function flush()
|
public function flush()
|
||||||
{
|
{
|
||||||
if (!$this->dirty) return;
|
if (!$this->dirty) return;
|
||||||
|
$this->dirty = false;
|
||||||
|
|
||||||
$yaml = Yaml::dump($this->state);
|
$yaml = Yaml::dump($this->state);
|
||||||
file_put_contents($this->filename."~", $yaml);
|
file_put_contents($this->filename."~", $yaml);
|
||||||
rename($this->filename."~", $this->filename);
|
rename($this->filename."~", $this->filename);
|
||||||
|
Loading…
Reference in New Issue
Block a user