5 Commits
0.1.0 ... 0.1.3

Author SHA1 Message Date
35f88303fa Improved hooks and scripts
* Invoke hooks before and after updating
* Scripts can now be run --before and --after
2022-03-19 23:22:08 +01:00
e77e61d0b0 Bugfixes and improvements
* Implemented the --image option
* Implemented the --write-state option to go with --check
* Fixed bug in Lockfile class
2022-03-11 01:55:35 +01:00
66abd9d3c5 Updated readme 2022-03-10 02:32:47 +01:00
0cf2380b7e Bugfixes, added tools
* Added missing parameter for hook invocation
* Added pharlite for building fresh.phar
2022-03-10 02:27:25 +01:00
ec1659e96d 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
2022-03-09 01:09:28 +01:00
12 changed files with 611 additions and 174 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
/vendor
/composer.lock
/*.phar
/src/version.php
/dist

View File

@ -1,36 +1,85 @@
# Fresh: Keeping your docker stacks up to date
Fresh was written to scratch an itch. It works by querying the respective repositories
for the various manifests in order to determine if the images have been changed. If so
it can do a combination of things:
- Set the exitcode to indicate the freshness, `0` means up-to-date and `1` outdated.
- Pull the updated images with docker-compose, and optionally recreate the containers
with `docker-compose up`.
- Run a script before and after updating, f.ex. to enable maintenance mode or update
permission on volumes.
- Notify webhooks when updating. Currently only Slack and Mattermost are supported.
Fresh is designed to be invoked using cron or systemd timers, and as such provides
a light-weight easy-to-use alternative to more complex toolkits.
## Building
Build using NoccyLabs Pharlite.
Build using NoccyLabs Pharlite:
$ tools/pharlite
## Installing
Download the latest version (or build it yourself) and move it into `/usr/bin`.
Go grab it at [https://dev.noccylabs.info/noccy/fresh/releases](https://dev.noccylabs.info/noccy/fresh/releases)
## 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
```
Manually pulling when changed:
Specify a directory to chdir into; very useful with cron:
```
if ! fresh.phar --check; then
docker-compose pull
docker-compose up -d
fi
```
$ fresh.phar --dir /srv/docker/mystack
To invoke scripts or webhooks:
$ fresh.phar --before scripts/sitedown.sh --after scripts/siteup.sh \
--slack https://my.slack.or/mattermost/webhook
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.
## 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.
- **What are these hashes?** Fresh grabs the manifest for the image from the registry
and proceeds to hash a concatenation of all the various build layer hashes. This
should mean if the image is new but the layers are the same nothing will be updated.
- **How do I notify a Mattermost webook?** Mattermost webhooks are compatible with
Slack webhooks, so simply use the `--slack` flag.
## 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.
**0.1.2**
- Fixed a bug in lockfile class preventing release of stale lockfile.
- The `--image` option finally works.
- Added a `--write-state`/`-w` option to write updated hashes to the state file.
- Implemented `--config` and `--config-type`options.
**0.1.3**
- Added a `--before` script hook, to complement the `--after` hook.
- Hooks now invoked both before and after deploy.

View File

@ -1,154 +1,14 @@
#!/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:qv", [ "help", "dir:", "config:", "image:", "pull", "credentials:", "check", "slack:", "quiet", "prune" ]);
if (array_key_exists('h', $opts) || array_key_exists('help', $opts)) {
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';
$path = realpath($opts['d'] ?? ($opts['dir'] ?? getcwd()));
$onlyPull = array_key_exists('p', $opts) || array_key_exists('pull', $opts);
$onlyCheck = array_key_exists('check', $opts);
$quiet = array_key_exists('q', $opts) || array_key_exists('quiet', $opts);
$verbose = array_key_exists('v', $opts);
$prune = array_key_exists('prune', $opts);
$slackUrl = array_key_exists('slack', $opts) ? $opts['slack'] : null;
$slackHook = null;
if ($slackUrl) {
$slackHook = new SlackHook([
'url' => $slackUrl
]);
}
if (!is_dir($path)) {
fwrite(STDERR, "error: invalid path for --dir/-d\n");
exit(2);
}
chdir($path);
$path = getcwd();
if (!file_exists($path."/docker-compose.yml")) {
fwrite(STDERR, "error: no docker-compose.yml in the current directory\n");
exit(2);
}
switch ($credentialsLoaderType) {
case 'auto':
case 'basic':
$credentialsLoader = new BasicCredentialsLoader();
break;
default:
fwrite(STDERR, "error: invalid credentials loader {$credentialsLoaderType}\n");
exit(2);
}
//$configuration = new LocalConfiguration(getcwd()."/freshdocker.conf");
$configuration = new DockerComposeConfiguration("{$path}/docker-compose.yml");
$state = new PersistentState("{$path}/fresh.yml");
$checks = $configuration->getChecks();
if (count($checks) === 0) {
fwrite(STDERR, "error: couldn't find any images to check\n");
exit(2);
}
$update = false;
$log = [];
foreach ($checks as $check) {
$ref = new ImageReference($check);
$reg = $ref->getRegistry();
$credentials = $credentialsLoader->getCredentials($reg);
if (!$credentials) {
if ($verbose) printf("%s: missing credentials for %s\n", $ref->getImage(), $ref->getRegistry());
continue;
}
$client = new RegistryV2Client($reg, $credentials);
$status = $client->getImageStatus($ref->getImage(), $ref->getTag());
$image = $status['image'].":".$status['tag'];
$oldHash = $state->get($image);
$newHash = $status['hash'];
$state->set($image, $newHash);
if ($oldHash != $newHash) {
if (!$quiet) printf("%s: %s → %s\n", $image, truncate($oldHash), truncate($newHash));
$log[] = sprintf("%s: %s → %s\n", $image, truncate($oldHash), truncate($newHash));
$update = true;
if (file_exists(__DIR__."/../src/version.php")) {
require_once __DIR__."/../src/version.php";
} else {
if (!$quiet) printf("%s: %s\n", $image, truncate($newHash));
}
define("APP_VERSION", "DEV");
define("BUILD_DATE", "src");
}
if ($onlyCheck) {
exit($update?1:0);
}
if ($update) {
if ($slackHook) {
$msg = "Deploying new container versions:\n* ".join("\n* ", $log);
$slackHook->sendMessage($msg, []);
}
passthru("docker-compose pull");
if (!$onlyPull) {
passthru("docker-compose up -d");
if ($prune) {
passthru("docker image prune -f");
}
}
}
exit($update?1:0);
function truncate(?string $hash): string
{
if ($hash === null) return '*';
return substr($hash, 0, 4) . ".." . substr($hash, -4, 4);
}
$r = new NoccyLabs\FreshDocker\Refresher();
$r->run();

View File

@ -4,7 +4,7 @@ namespace NoccyLabs\FreshDocker\Configuration;
use Symfony\Component\Yaml\Yaml;
class DockerComposeConfiguration
class ComposeConfiguration
{
private array $checks = [];

View File

@ -0,0 +1,21 @@
<?php
namespace NoccyLabs\FreshDocker\Configuration;
use Symfony\Component\Yaml\Yaml;
class SingleImageConfiguration
{
private $image;
public function __construct(string $image)
{
$this->image = $image;
}
public function getChecks(): array
{
return [ $this->image ];
}
}

View File

@ -2,7 +2,7 @@
namespace NoccyLabs\FreshDocker\Credentials;
class BasicCredentialsLoader
class BasicCredentialsLoader implements CredentialsLoaderInterface
{
private array $auth = [];

View File

@ -0,0 +1,10 @@
<?php
namespace NoccyLabs\FreshDocker\Credentials;
interface CredentialsLoaderInterface
{
public function getCredentials(string $repo): ?array;
}

418
src/Refresher.php Normal file
View File

@ -0,0 +1,418 @@
<?php
namespace NoccyLabs\FreshDocker;
use NoccyLabs\FreshDocker\State\Log;
use NoccyLabs\FreshDocker\Configuration\ComposeConfiguration;
use NoccyLabs\FreshDocker\Configuration\LocalConfiguration;
use NoccyLabs\FreshDocker\Configuration\SingleImageConfiguration;
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" ],
'write-state' => [ 'w', 'write-state', "Always write updated state (only useful with --check)", false ],
],
'Hooks' => [
'slack' => [ null, 'slack:', "Notify a slack webhook when updating", "FRESH_SLACK" ],
'script-after' => [ null, 'after:', "Invoke script after updating", "FRESH_AFTER" ],
'script-before' => [ null, 'before:', "Invoke script before updating", "FRESH_BEFORE" ],
],
'Config' => [
'config' => [ 'c:', 'config:', "Use custom configuration file", "FRESH_CONFIG" ],
'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[1],':');
}
if ($h !== null) {
$parsed[$o] = $h ? $v : true;
}
}
}
$this->options = $parsed;
$this->log->setVerbose($parsed['verbose']);
}
public function printUsage()
{
$tty = posix_isatty(STDOUT);
printf("fresh.phar v%s (%s) - (c) 2022, NoccyLabs / GPL v3 or later\n", APP_VERSION, BUILD_DATE);
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";
$out = sprintf(" %-25s %s", $optkey, $opt[2]);
if ($d) $out.= sprintf(" (default: %s)", $d);
if ($e) $out.= sprintf(" [\$%s]", $e);
echo $out . "\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']) {
if (($updated !== null) && $this->options['write-state']) {
// Flush state if called with -w/--write-state
$this->log->append("Flushing state...");
$this->state->flush();
}
exit(($updated === null) ? 0 : 1);
}
if ($updated !== null) {
$this->callHooks($updated, 'before');
$this->callScript('before');
$this->doUpdate();
$this->callHooks($updated, 'after');
$this->callScript('after');
}
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 ($this->options['image']) {
$this->config = new SingleImageConfiguration($this->options['image']);
return;
}
$configType = $this->options['config-type'];
$configFile = $this->options['config'] ? realpath($this->options['config']) : null;
switch ($configType) {
case 'auto':
if ($configFile) {
if (basename($configFile) == "docker-compose.yml") {
$this->log->append("Using configuration file ".$configFile);
$this->config = new ComposeConfiguration($configFile);
return;
}
} else {
if (file_exists($this->path."/docker-compose.yml")) {
$this->log->append("Using configuration file ".$this->path."/docker-compose.yml");
$this->config = new ComposeConfiguration($this->path."/docker-compose.yml");
return;
}
}
break;
case 'compose':
if ($configFile) {
if (basename($configFile) == "docker-compose.yml") {
$this->log->append("Using configuration file ".$configFile);
$this->config = new ComposeConfiguration($configFile);
return;
}
} else {
if (file_exists($this->path."/docker-compose.yml")) {
$this->log->append("Using configuration file ".$this->path."/docker-compose.yml");
$this->config = new ComposeConfiguration($this->path."/docker-compose.yml");
return;
}
}
break;
}
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 --quiet");
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, string $event)
{
switch ($event) {
case 'before':
$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);
break;
case 'after':
$msg = "Deploy complete";
break;
default:
return;
}
foreach ($this->hooks as $hook) {
$hook->sendMessage($msg, []);
}
}
private function callScript(string $event)
{
$script = $this->options["script-{$event}"] ?? null;
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
View 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
View 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);
}
}

View File

@ -15,15 +15,17 @@ class PersistentState
public function __construct(string $filename)
{
$this->filename = $filename;
register_shutdown_function([$this,"flush"]);
// register_shutdown_function([$this,"flush"]);
if (file_exists($filename)) {
$this->state = Yaml::parseFile($filename);
$this->state = Yaml::parseFile($filename) ?? [];
}
}
public function flush()
{
if (!$this->dirty) return;
$this->dirty = false;
$yaml = Yaml::dump($this->state);
file_put_contents($this->filename."~", $yaml);
rename($this->filename."~", $this->filename);

BIN
tools/pharlite Executable file

Binary file not shown.