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:
		
							
								
								
									
										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);
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user