Compare commits

...

99 Commits

Author SHA1 Message Date
d392cb3c2d Latest update
Some checks failed
Build and test / requirements (push) Has been cancelled
2024-06-24 13:46:25 +00:00
66ad8aa592 Better matrix builds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline is running
ci/woodpecker/manual/woodpecker Pipeline was successful
Build and test / requirements (push) Failing after 14m39s
2024-03-21 10:12:50 +00:00
aa91f25d3c First matrix attempt
Some checks failed
ci/woodpecker/push/woodpecker/1 Pipeline failed
ci/woodpecker/push/woodpecker/2 Pipeline failed
ci/woodpecker/push/woodpecker/3 Pipeline was successful
Build and test / requirements (push) Failing after 3m40s
ci/woodpecker/push/woodpecker/4 Pipeline was successful
2024-03-21 10:09:22 +00:00
1f736ae059 Less warnings
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Failing after 3m34s
2024-03-21 09:56:50 +00:00
4b59f3dfee New buildserver format
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Build and test / requirements (push) Failing after 3m36s
2024-03-21 09:48:08 +00:00
8a699ccf79 Deprecated format
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Build and test / requirements (push) Failing after 3m17s
2024-03-21 09:41:09 +00:00
b67086464c Should now build
Some checks failed
Build and test / requirements (push) Has been cancelled
ci/woodpecker/push/woodpecker Pipeline failed
2024-03-21 09:39:44 +00:00
085006b073 Psalm warning. Investigate getting PHPunit plugin for Psalm.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Build and test / requirements (push) Failing after 1m41s
2024-02-07 11:05:54 +00:00
6ce774236e Quality of life things.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Build and test / requirements (push) Failing after 3m48s
2024-02-07 11:00:08 +00:00
aed385f5e0 Finding minimum required PHP version to be 8.1. (But testing with PHP8.3)
All checks were successful
Build and test / requirements (push) Successful in 3m0s
2024-01-18 14:40:39 +00:00
e826d9cfa9 Upgrade
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Build and test / requirements (push) Failing after 1m52s
2023-12-04 09:31:33 +00:00
de9270476f More docs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m51s
2023-11-27 09:58:23 +00:00
55cdcd86b6 File names for output
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m26s
2023-11-16 13:13:57 +00:00
7e8da1a973 Upload folders
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m34s
2023-11-16 11:02:19 +00:00
55e683f645 Old build script
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Failing after 4m10s
2023-11-16 10:39:37 +00:00
3acdcd297c Testing gitea actions
Some checks are pending
Gitea Actions Demo / Explore-Gitea-Actions (push) Waiting to run
ci/woodpecker/push/woodpecker Pipeline was successful
2023-11-16 10:36:58 +00:00
344dd52019 Syntax jitter
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-11-16 10:30:24 +00:00
cadb207758 More unittests 2023-11-14 09:09:15 +00:00
bedc666f7c Support for PHP infection testing
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-11-03 13:29:50 +00:00
66ccb3d8d6 Killing a few "mutants"
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-11-03 12:02:44 +00:00
61001edaa0 Requirements update. Experiementation with Infection.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-10-31 09:50:55 +00:00
14db929af1 Bump requirements.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-10-04 09:39:36 +00:00
456dc1b1e3 Update dependecies.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-25 07:15:41 +00:00
876702473c More documentation
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-19 06:34:20 +00:00
f5a34213b5 Rclone update.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-15 06:58:39 +00:00
68ee38d1ff Bump versions
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-04 06:53:56 +00:00
7ab7998ac7 Split progressbar output into two windows.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-31 13:15:06 +00:00
37049ba6f4 Template for error notification.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-29 09:44:05 +00:00
236f74c1d8 Remove S3 server
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-21 07:47:46 +00:00
6f1e3dcdd0 Bump dependencies
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-08-21 07:46:21 +00:00
e59e105394 Cleanup
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-08-17 07:16:41 +00:00
188a6ad9fa Cleaning up.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-08-15 11:43:12 +00:00
31891f3e53 More tests
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-08-14 14:24:19 +00:00
1d94738b04 Install script and updated requirements.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-07 07:33:11 +00:00
0c316480d5 Dependecies update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-08-01 07:38:47 +00:00
ca78223606 Update requirements. Include metrics
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-31 08:02:19 +00:00
1d898e350b 100% type coverage for psalm
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-28 12:12:47 +00:00
14b2155595 Dependecy update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-26 11:01:41 +00:00
f10f27f28b Remove dead code.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-25 06:35:51 +00:00
9459045e6c More testing. Removed Gitea actions.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-24 13:32:21 +00:00
bc00b36799 File path error.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m18s
2023-07-24 12:30:11 +00:00
936fa214e6 Directory upload not yet supported.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m15s
2023-07-24 12:26:22 +00:00
ff969aee2c Artifacts uploading
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Successful in 2m26s
2023-07-24 12:02:11 +00:00
83e667016d Typos.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Failing after 2m15s
2023-07-24 11:52:14 +00:00
58f0bc61c7 Testing
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Failing after 2m3s
2023-07-24 11:49:51 +00:00
51a06fa66c More steps
Some checks reported warnings
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Has been cancelled
2023-07-24 11:38:27 +00:00
8cbceb485c On every push
Some checks reported warnings
ci/woodpecker/push/woodpecker Pipeline was successful
Build and test / requirements (push) Has been cancelled
2023-07-24 11:27:45 +00:00
991a844836 Update build scripts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-24 11:23:55 +00:00
5732d6af63 Testing
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-24 11:20:16 +00:00
5e69975df2 Test of Gitea Actions
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m26s
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-24 11:11:04 +00:00
2d38d82540 Update dependencies
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-24 08:23:11 +00:00
9d7d8cf89c Requirement to make has been removed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-13 12:10:12 +00:00
6ac9fd5575 Quality of life improvements all around.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-13 11:34:56 +00:00
4645488b5d Removed dependecy on Make.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-11 12:36:27 +00:00
31ca54cdac Finetuning paths.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-11 11:18:23 +00:00
c38b088fff Secrets in CI
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-11 10:41:06 +00:00
f247734385 Document and upload. 2023-07-11 10:38:51 +00:00
3b9cea1c70 Static analysis of unittest code.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-10 09:20:36 +00:00
534c8cdbe6 More testing.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-10 07:47:28 +00:00
46121bfcca Requirement update.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-10 06:47:40 +00:00
5705822129 Full static code analysis
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-06 06:41:05 +00:00
50ae396000 100% analysis in Psalm
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-05 15:00:31 +00:00
c1fb42c4ff More type deconfusion
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-05 14:36:56 +00:00
f7b4a8f9cd Pass code sniffer.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-04 10:49:06 +00:00
ea18b8b9b3 More logging.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-04 10:15:34 +00:00
9add013038 Better file support
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-04 09:45:32 +00:00
51f8436e9d Configuration refactoring with schema support.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-04 09:43:27 +00:00
006a5410b5 Fewer warnings.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-04 08:19:02 +00:00
a070849293 More test coverage.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-03 14:28:53 +00:00
be2985c797 Deprecation warnings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-03 13:23:12 +00:00
49141719b5 More tests and error handling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-03 13:01:31 +00:00
27001e63c2 Update requirements
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-02 20:02:43 +02:00
9324def64a Graphs on phpDoc output
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-19 12:46:32 +00:00
0bddd2a1ef phpDocumentor
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-19 08:34:54 +00:00
c140d5883c Always improving
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-16 08:08:09 +00:00
fb2f37a189 Lint all yaml files.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-15 14:13:04 +00:00
f41571cfd2 More tests and refactoring
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-15 14:10:17 +00:00
72058335ca tabs to spaces
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-15 10:59:12 +00:00
ba4a3f273f Tabs to spaces 2023-06-15 10:58:18 +00:00
e041db6ba3 Alignment 2023-06-15 10:57:06 +00:00
baf34236f0 Dependecies for rclone 2023-06-15 10:56:05 +00:00
3efa791d5e Improvements to test.
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-15 10:15:44 +00:00
78c1ea0326 Rename
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-13 09:51:25 +00:00
286b5faff5 Readme
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-13 09:49:58 +00:00
4e9248c8fd Analyze on every build.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-13 09:44:53 +00:00
4ffac17b62 Static analysis cleanup.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-13 08:15:29 +00:00
0f3bbf1f47 More codestandards.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-12 09:30:10 +00:00
d0270b00ca PSR12 code standard.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-08 12:44:59 +00:00
afc4a1e3ce Upgrade to PHP8.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-07 09:56:23 +00:00
79dfcd6495 Code standards.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-05 09:43:50 +00:00
9d0d188d01 More code standards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-05 09:40:04 +00:00
668c375f4b Disable failing step.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-01 09:57:00 +00:00
c59de96874 Trying to get last step to pass
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-01 09:51:24 +00:00
5369de6377 Ignore errors on tools/analyzers.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-01 09:45:37 +00:00
2fb2dcaba8 Yaml syntax
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-01 09:44:39 +00:00
69e7962b6b Ignore results of analysis 2023-06-01 09:42:42 +00:00
36b043e307 Failure: ignore not yet supported
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-06-01 09:38:59 +00:00
f1c1c5456c Rename attempt 2023-06-01 09:29:20 +00:00
1f902ed6c3 Spaces over tabs 2023-06-01 09:25:38 +00:00
40 changed files with 3106 additions and 1116 deletions

View File

@ -0,0 +1,44 @@
name: Build and test
run-name: Perform a regular build and test
on: push
jobs:
requirements:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, curl, zip
tools: composer, phpdoc
coverage: xdebug
- name: Install dependencies
run: |
composer config github-oauth.github.com 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
composer install
wget https://downloads.rclone.org/rclone-current-linux-amd64.deb
dpkg -i rclone-current-linux-amd64.deb
- name: Dry run
run: ./backup show config.example.yml
- name: Static analysis
run: composer analyze
- name: Test
run: composer test-full
- name: Upload Test report
uses: actions/upload-artifact@v3
with:
path: output/test.html
name: test.html
- uses: actions/upload-artifact@v3
with:
path: output/coverage
name: test-coverage.zip
- name: Document
run: phpdoc run
- uses: actions/upload-artifact@v3
with:
path: output/docs
name: documentation.zip

6
.gitignore vendored
View File

@ -1,6 +1,10 @@
/vendor/ /vendor/
/output/ /output/
/temp/ /temp/
/.phpdoc/
*.log *.log
config.yml config.yml
composer.phar *.phar
*.deb
.phpunit.result.cache
*cache/

51
.phpcs.xml Normal file
View File

@ -0,0 +1,51 @@
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="PHP_CodeSniffer" xsi:noNamespaceSchemaLocation="phpcs.xsd">
<description>Coding standard</description>
<file>src/</file>
<file>tests/</file>
<rule ref="PSR1">
<exclude name="Generic.Files.LineLength"/>
</rule>
<rule ref="PSR2"></rule>
<rule ref="PSR12"></rule>
<rule ref="Generic">
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed"/>
<exclude name="Generic.Files.LowercasedFilename.NotFound"/>
<exclude name="Generic.PHP.ClosingPHPTag.NotFound"/>
<exclude name="Generic.Files.EndFileNoNewline.Found"/>
<exclude name="Generic.Files.EndFileNoNewline.Found"/>
<exclude name="Generic.Arrays.DisallowShortArraySyntax.Found"/>
<exclude name="Generic.Functions.OpeningFunctionBraceKernighanRitchie.BraceOnNewLine"/>
<exclude name="Generic.Classes.OpeningBraceSameLine.BraceOnNewLine"/>
<exclude name="Generic.PHP.LowerCaseConstant.Found"/>
<exclude name="Generic.Formatting.SpaceAfterCast"/>
<exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning"/>
<exclude name="Generic.Commenting.DocComment.MissingShort"/>
<exclude name="Generic.NamingConventions.AbstractClassNamePrefix.Missing"/>
<exclude name="Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed"/>
<exclude name="Generic.NamingConventions.InterfaceNameSuffix.Missing"/>
<exclude name="Generic.Commenting.Todo.TaskFound"/>
<exclude name="Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUse"/>
<exclude name="Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed"/>
<exclude name="Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed"/>
<exclude name="Generic.Formatting.SpaceBeforeCast.NoSpace"/>
<exclude name="Generic.CodeAnalysis.UselessOverridingMethod.Found"/>
<exclude name="Squiz.Functions.MultiLineFunctionDeclaration.NewlineBeforeOpenBrace"/>
</rule>
<!-- Ban some functions -->
<rule ref="Generic.PHP.ForbiddenFunctions">
<properties>
<property name="forbiddenFunctions" type="array">
<element key="sizeof" value="count"/>
<element key="delete" value="unset"/>
<element key="print" value="echo"/>
<element key="is_null" value="null"/>
<element key="create_function" value="null"/>
</property>
</properties>
</rule>
</ruleset>

16
.vscode/launch.json vendored
View File

@ -4,6 +4,22 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Debug test script",
"type": "php",
"request": "launch",
"program": "",
"cwd": "${workspaceFolder}",
"port": 0,
"args": ["vendor/bin/phpunit", "${file}"],
"runtimeArgs": [
"-dxdebug.start_with_request=yes"
],
"env": {
"XDEBUG_MODE": "debug,develop",
"XDEBUG_CONFIG": "client_port=${port}"
}
},
{ {
"name": "Launch currently open script", "name": "Launch currently open script",
"type": "php", "type": "php",

View File

@ -1,22 +1,27 @@
pipeline: when:
requirements: - event: [push, pull_request, pull_request_closed, tag, release, manual]
steps:
- name: requirements
image: composer image: composer
commands: commands:
- composer install --no-dev - composer install --no-dev
run: - name: run
image: php image: php:cli-alpine
commands: commands:
- ./backup show config.example.yml - ./backup show config.example.yml
test: - name: dependencies
image: composer image: composer
commands: commands:
- composer install - composer install
- ./vendor/bin/phpunit tests - composer analyze
analyze: - name: test
image: php image: php:cli-bookworm
commands: commands:
- ./vendor/bin/phpmd src text cleancode,codesize,controversial,design,naming,unusedcode - apt update
- ./vendor/bin/phpstan analyze --level=7 src/ backup - apt install rclone
- ./vendor/bin/psalm - vendor/bin/phpunit --no-coverage
- ./vendor/bin/phpcs src - name: document
failure: ignore image: phpdoc/phpdoc
commands:
- phpdoc

4
Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM php:8.3-cli
COPY . /app
WORKDIR /app

View File

@ -1,16 +0,0 @@
analyze-all:
@echo PHPMessDetector
-./vendor/bin/phpmd src text cleancode,codesize,controversial,design,naming,unusedcode
@echo PHPStan
-./vendor/bin/phpstan analyze --level=7 src/ backup
@echo Psalm
-./vendor/bin/psalm
@echo PHP_CodeSniffer
-./vendor/bin/phpcs src
install:
php composer.phar install --no-dev
install-dev:
php composer.phar install
test:
./vendor/bin/phpunit tests --testdox --coverage-filter src --coverage-html output/coverage

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# Backup script
[![Build status](https://build.jcktrue.dk/api/badges/jct/backupscript/status.svg)](https://build.jcktrue.dk/jct/backupscript)
Backup script utilizing Rclone to backup local file systems and send notifications.
# Minimum requirements
- PHP8.1
- Composer to install required packages.
- Rclone installed
# Rclone install
rm rclone-current-linux-amd64.deb
wget https://downloads.rclone.org/rclone-current-linux-amd64.deb
sudo dpkg -i rclone-current-linux-amd64.deb
# For development
# PHP latest for debian
curl -sSL https://packages.sury.org/php/README.txt | sudo bash -x
sudo apt update
sudo apt upgrade
sudo apt install php8.3-cli php8.3-xml php8.3-curl php8.3-zip php8.3-xdebug php8.3-mbstring unzip wget graphviz plantuml
# PHP Docs
rm phpDocumentor.phar
wget https://phpdoc.org/phpDocumentor.phar
chmod +x phpDocumentor.phar
# Infection install
rm infection.phar
wget https://github.com/infection/infection/releases/download/0.27.8/infection.phar
chmod +x infection.phar
# PHP CopyPasteDetector install
rm phpcpd.phar
wget https://phar.phpunit.de/phpcpd.phar
chmod +x phpcpd.phar
# Test, analyze, metrics, document
./composer.phar test-full && ./composer.phar analyze && ./composer.phar metrics && ./composer.phar doc

34
backup
View File

@ -1,26 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
$autoload = null; require __DIR__ . '/vendor/autoload.php';
$autoloadFiles = [
__DIR__ . '/../vendor/autoload.php',
__DIR__ . '/../../../autoload.php',
__DIR__ . '/vendor/autoload.php'
];
foreach ($autoloadFiles as $autoloadFile) {
if (file_exists($autoloadFile)) {
$autoload = $autoloadFile;
break;
}
}
if (! $autoload) {
echo "Autoload file not found; try 'composer dump-autoload' first." . PHP_EOL;
exit(1);
}
require $autoload;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Composer\InstalledVersions; use Composer\InstalledVersions;
@ -28,8 +8,12 @@ use Composer\InstalledVersions;
$package = \Composer\InstalledVersions::getRootPackage(); $package = \Composer\InstalledVersions::getRootPackage();
$application = new Application('backup', $package['version']); $application = new Application('backup', $package['version']);
try {
$application->add(new App\CommandBackup());
$application->add(new App\CommandShow());
$application->add(new App\CommandBackup()); $application->run();
$application->add(new App\CommandShow()); } catch(Exception $e)
{
$application->run(); echo("Critical error: ". $e->getMessage());
}

View File

@ -1,14 +1,21 @@
{ {
"name": "furyfire/backup", "name": "furyfire/backupscript",
"description": "Wrapper for Rclone based backup", "description": "Wrapper for Rclone based backup with push notifications",
"homepage": "https://jcktrue.dk", "homepage": "https://jcktrue.dk",
"version": "0.1.0", "version": "0.1.1",
"require": { "require": {
"symfony/console": "^5.4", "php": "^8.1",
"symfony/yaml": "^5.4", "ext-date": "*",
"twig/twig": "^3.6", "ext-SPL": "*",
"symfony/process": "^5.4", "ext-json": "*",
"monolog/monolog": "^2.9" "symfony/console": "^6.3.4",
"symfony/yaml": "^6.3.3",
"twig/twig": "^3.7.1",
"symfony/process": "^6.3.4",
"monolog/monolog": "^3.4",
"verifiedjoseph/ntfy-php-library": "^4.3",
"league/config": "^1.2",
"psr/log": "^3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -16,10 +23,30 @@
} }
}, },
"require-dev": { "require-dev": {
"squizlabs/php_codesniffer": "*", "phpunit/phpunit": "^10.3.5",
"phpmetrics/phpmetrics": "^2.8.2",
"rector/rector": "^1.0",
"vimeo/psalm": "^5.23",
"phpmd/phpmd": "^2.15",
"phpstan/phpstan": "^1.10", "phpstan/phpstan": "^1.10",
"vimeo/psalm": "^5.12", "squizlabs/php_codesniffer": "^3.9"
"phpmd/phpmd": "^2.13", },
"phpunit/phpunit": "^9.6" "scripts": {
"test": "vendor/bin/phpunit tests --display-warnings",
"test-full": "vendor/bin/phpunit -c phpunit.full.xml",
"metrics": "vendor/bin/phpmetrics --report-html=output/metrics --junit=output/test.xml src/",
"docs": "phpDocumentor --setting=graphs.enabled=true",
"analyze": [
"@analyze-yaml",
"@analyze-phpmd",
"@analyze-phpstan",
"@analyze-psalm",
"@analyze-phpcs"
],
"analyze-yaml": "vendor/bin/yaml-lint *.yml .*.yml *.json",
"analyze-phpmd": "phpmd src,tests text cleancode,codesize,controversial,design,naming,unusedcode",
"analyze-phpstan":"phpstan",
"analyze-psalm": "psalm --no-cache",
"analyze-phpcs": "phpcs"
} }
} }

2549
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,11 @@
notification: notification:
domain: ntfy.jcktrue.dk - type: Ntfy
topic: backup domain: https://ntfy.jcktrue.dk
log: output.log topic: testing
log: output.log
rclone: rclone:
bwlimit: 6M options:
bwlimit: 6M
backup: backup:
- title: Example - title: Example
source: temp/source source: temp/source
@ -18,4 +20,7 @@ templates:
Destination after: {{ destination_size_after | formatBytes}} Destination after: {{ destination_size_after | formatBytes}}
Destination change : {{ (destination_size_after - destination_size_before) | formatBytes}} Destination change : {{ (destination_size_after - destination_size_before) | formatBytes}}
Backup completed: {{ end | date }} Backup completed: {{ end | date }}
error: |
{{ config.title }}
Error {{ config.source }} to {{ config.destination }}
{{ exception }}

16
infection.json5 Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://raw.githubusercontent.com/infection/infection/0.27.0/resources/schema.json",
"source": {
"directories": [
"src"
]
},
"logs": {
"text": "output/mutation/infection.log",
"html": "output/mutation/infection.html",
"summary": "output/mutation/summary.log",
},
"mutators": {
"@default": true
}
}

View File

@ -1,11 +0,0 @@
[2023-05-31T14:30:00.890085+00:00] app.INFO: Initialization complete [] []
[2023-05-31T14:30:00.946882+00:00] rclone.INFO: Execute command ["'rclone' 'size' '--json' 'temp/source'"] []
[2023-05-31T14:30:00.989664+00:00] rclone.INFO: Return code [0] []
[2023-05-31T14:30:00.989960+00:00] rclone.INFO: Execute command ["'rclone' 'size' '--json' 'temp/destination'"] []
[2023-05-31T14:30:01.047504+00:00] rclone.INFO: Return code [0] []
[2023-05-31T14:30:01.047810+00:00] rclone.INFO: Execute command ["'rclone' 'copy' 'temp/source' 'temp/destination' '--bwlimit' '6M'"] []
[2023-05-31T14:30:01.097485+00:00] rclone.INFO: Return code [0] []
[2023-05-31T14:30:01.097797+00:00] rclone.INFO: Execute command ["'rclone' 'size' '--json' 'temp/destination'"] []
[2023-05-31T14:30:01.142119+00:00] rclone.INFO: Return code [0] []
[2023-05-31T14:30:01.174134+00:00] notification.DEBUG: Sending ntfy notification {"topic":"backup","title":"Example","message":"Example\nFrom temp/source to temp/destination\nBackup started: May 31, 2023 14:30\nSource size: 8.00B\nDestination before: 8.00B\nDestination after: 8.00B\nDestination change : 0.00B\nBackup completed: May 31, 2023 14:30\n"} []
[2023-05-31T14:30:01.363282+00:00] notification.DEBUG: Result of ntfy notification ["{\"id\":\"iHfqUGi4dUsC\",\"time\":1685543401,\"expires\":1685586601,\"event\":\"message\",\"topic\":\"backup\",\"title\":\"Example\",\"message\":\"Example\\nFrom temp/source to temp/destination\\nBackup started: May 31, 2023 14:30\\nSource size: 8.00B\\nDestination before: 8.00B\\nDestination after: 8.00B\\nDestination change : 0.00B\\nBackup completed: May 31, 2023 14:30\"}\n"] []

18
phpdoc.dist.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor
configVersion="3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://www.phpdoc.org"
>
<title>backupscript</title>
<paths>
<output>output/docs</output>
</paths>
<version number="latest">
<api>
<source dsn=".">
<path>src</path>
</source>
</api>
</version>
</phpdocumentor>

5
phpstan.neon Normal file
View File

@ -0,0 +1,5 @@
parameters:
level: 8
paths:
- src
- tests

24
phpunit.xml Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false" displayDetailsOnTestsThatTriggerWarnings="true">
<testsuites>
<testsuite name="Backupscript Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src/</directory>
</include>
</source>
<logging>
<junit outputFile="output/test/junit.xml"/>
<testdoxHtml outputFile="output/test/index.html"/>
</logging>
<coverage>
<report>
<html outputDirectory="output/coverage" />
<clover outputFile ="output/coverage/clover.xml" />
</report>
</coverage>
</phpunit>

View File

@ -1,15 +1,24 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<psalm <psalm
errorLevel="2" errorLevel="1"
resolveFromConfigFile="true" resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config" xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true" findUnusedBaselineEntry="true"
findUnusedCode="false"
strictBinaryOperands="true"
checkForThrowsInGlobalScope="true"
ignoreInternalFunctionFalseReturn="false"
ignoreInternalFunctionNullReturn ="false"
findUnusedVariablesAndParams="true"
findUnusedPsalmSuppress="true"
restrictReturnTypes="true"
> >
<projectFiles> <projectFiles>
<directory name="src/" /> <directory name="src/" />
<file name="backup" /> <file name="backup" />
<directory name="tests" />
<ignoreFiles> <ignoreFiles>
<directory name="vendor" /> <directory name="vendor" />
</ignoreFiles> </ignoreFiles>

View File

@ -1,38 +1,107 @@
<?php <?php
declare(strict_types=1);
namespace App; namespace App;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Exception\ParseException;
use Monolog\Logger; use Monolog\Logger;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use League\Config\Configuration;
use Nette\Schema\Expect;
/**
* Mostly working as a register pattern for the logging and configuration.
*/
class App class App
{ {
/// Logging instance
protected Logger $logger; protected Logger $logger;
protected array $config; /// Configuration singleton
protected Configuration $config;
function __construct(string $configFile) /**
* Create a new instance providing a config file.
*
* @param string $configFile Relative or full path to YML config.
*
* @SuppressWarnings(PHPMD.StaticAccess)
*/
public function __construct(string $configFile)
{ {
$this->config = Yaml::parseFile($configFile); // Define your configuration schema
$this->config = new Configuration([
'rclone' => Expect::structure([
'path' => Expect::string()->default('rclone'),
'options' => Expect::arrayOf('string', 'string')
]),
'backup' => Expect::arrayOf(Expect::structure([
'title' => Expect::string()->required(),
'source' => Expect::string()->required(),
'destination' => Expect::string()->required(),
]))->required(),
'notification' => Expect::arrayOf(Expect::structure([
'type' => Expect::string()->required(),
'domain' => Expect::string()->required(),
'topic' => Expect::string()->required(),
])),
'log' => Expect::string()->assert(
function (string $path): bool {
return touch($path);
}
),
'templates' => Expect::structure(
[
'notify' => Expect::string()->required(),
'error' => Expect::string()->required()
]
)->required()
]);
$parser = new Yaml();
/**
* @var array<string, mixed>
*/
$parsedConfig = $parser->parseFile($configFile);
// Merge those values into the configuration schema:
$this->config->merge($parsedConfig);
$logger = new Logger('app'); $logger = new Logger('app');
if (isset($this->config['log'])) { if ($this->config->get('log')) {
$logger->pushHandler(new StreamHandler($this->getConfig()['log'])); $logger->pushHandler(new StreamHandler((string)$this->config->get('log')));
$logger->info("Logging enabled");
} }
$logger->info("Initialization complete"); $logger->info("Initialization complete");
$this->logger = $logger; $this->logger = $logger;
} }
/**
function getConfig() : array * Get configuration from key
*
* @param non-empty-string $key Key to fetch
*
* @return mixed Configuration value
*/
public function getConfig(string $key): mixed
{ {
return $this->config; /**
* @var string|array<string, string>
*/
$ret = $this->config->get($key);
$this->logger->debug("Fetching configuration key", [$key, $ret]);
return $ret;
} }
function getLogger() : Logger /**
* Get the logger instance.
*
* @return Logger Instance of logger
*/
public function getLogger(): Logger
{ {
return $this->logger; return $this->logger;
} }

View File

@ -1,74 +1,115 @@
<?php <?php
declare(strict_types=1);
namespace App; namespace App;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use App\Template\Twig;
use App\Notification\Notification;
use Twig\Environment; use App\Rclone\Rclone;
use Twig\Loader\ArrayLoader;
use DateTime; use DateTime;
#[AsCommand(
name: 'backup',
description: 'Start backup to assigned buckets',
)]
class CommandBackup extends Command class CommandBackup extends Command
{ {
static $defaultName = "backup";
static $defaultDescription = "Start backup to assigned buckets";
protected function configure(): void protected function configure(): void
{ {
$this->addArgument('config', InputArgument::OPTIONAL, 'Configuration file', "config.yml"); $this->addArgument(
'config',
InputArgument::REQUIRED,
'Configuration file'
);
} }
/**
* Start the backup process.
*
* 1. Read the configuration file.
* 2. For each configured backup entry
* 1. Get the size of the source
* 2. Get the size of the destination
* 3. Perform the backup
* 4. Get the new size of the destination
* 5. Send push notifications.
* 3. Report final success
*/
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $sio = new SymfonyStyle($input, $output);
$io->title('Start backup process'); $sioProgressbar = &$sio;
if ($output instanceof ConsoleOutputInterface) {
$sio = new SymfonyStyle($input, $output->section());
$sioProgressbar = new SymfonyStyle($input, $output->section());
}
$sio->title('Start backup process');
try { try {
$app = new App($input->getArgument('config')); $app = new App((string)$input->getArgument('config'));
} } catch (\Throwable $e) {
catch (\Throwable $e) { $sio->error('Configuration error:' . $e->getMessage());
$io->error('Unable to parse the YAML string: '. $e->getMessage());
return Command::FAILURE; return Command::FAILURE;
} }
$rclone = new Rclone($app->getLogger()->withName('rclone'), (string)$app->getConfig('rclone.path'));
$rclone = new Rclone\Rclone(); $notification = new Notification();
$rclone->setLogger($app->getLogger()->withName('rclone')); /**
* @var array<array-key,array<string,string>>
*/
$notificationConfig = $app->getConfig('notification');
$notification->loadMany($notificationConfig);
$ntfy = new Ntfy\Ntfy($app->getConfig()['notification']['domain']); /**
$ntfy->setLogger($app->getLogger()->withName('notification')); * @var array<string,string>
*/
$templateConfig = $app->getConfig('templates');
$render = new Twig($templateConfig);
$loader = new ArrayLoader($app->getConfig()['templates']); /**
$twig = new Environment($loader); * @var array{title: string, source: string, destination: string}[]
$twig->addExtension(new Twig\AppExtension()); */
$backupElements = $app->getConfig('backup');
foreach ($io->progressIterate($app->getConfig()['backup']) as $conf) { foreach ($sioProgressbar->progressIterate($backupElements) as $conf) {
$title = $conf['title'];
$template = [];
$template['config'] = $conf;
try { try {
$template = array();
$template['config'] = $conf;
$template['start'] = new DateTime(); $template['start'] = new DateTime();
$template['source_size'] = $rclone->getSize($conf['source']); $template['source_size'] = $rclone->getSize($conf['source']);
$template['rclone_version'] = $rclone->getVersion();
$template['destination_size_before'] = $rclone->getSize($conf['destination']); $template['destination_size_before'] = $rclone->getSize($conf['destination']);
$rclone->copy($conf['source'], $conf['destination'], "6M"); /**
* @var array<array-key, string>
*/
$rcloneOptions = $app->getConfig('rclone.options');
$rclone->copy($conf['source'], $conf['destination'], $rcloneOptions);
$template['destination_size_after'] = $rclone->getSize($conf['destination']); $template['destination_size_after'] = $rclone->getSize($conf['destination']);
$template['end'] = new DateTime(); $template['end'] = new DateTime();
$message = $twig->render('notify', $template); $message = $render->render('notify', $template);
} catch (\Exception $e) {
} catch (\Throwable $e) { $template['exception'] = $e->getMessage();
$message = $e->getMessage(); $message = $render->render('error', $template);
$sio->error($message);
} }
$ntfy->send($app->getConfig()['notification']['topic'], $conf['title'], $message); $notification->send($title, $message);
} }
$io->success("Complete"); $sio->success("Complete");
return Command::SUCCESS; return Command::SUCCESS;
} }
} }

View File

@ -1,41 +1,57 @@
<?php <?php
declare(strict_types=1);
namespace App; namespace App;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Yaml\Yaml; #[AsCommand(
use Symfony\Component\Yaml\Exception\ParseException; name: 'show',
description: 'Show all backup entries.',
)]
class CommandShow extends Command class CommandShow extends Command
{ {
static $defaultName = "show";
static $defaultDescription = "Show all backup entries";
protected function configure(): void protected function configure(): void
{ {
$this->addArgument('config', InputArgument::OPTIONAL, 'Configuration file', "config.yml"); $this->addArgument(
'config',
InputArgument::REQUIRED,
'Configuration file'
);
} }
/**
* 1. Read the configuration file.
* 2. For each configured backup print the details
* 3. Exit
*/
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $sio = new SymfonyStyle($input, $output);
$io->title('List backup entities'); $sio->title('List backup entities');
try { try {
$app = new App($input->getArgument('config')); $app = new App((string)$input->getArgument('config'));
} } catch (\Throwable $e) {
catch (\Throwable $e) { $sio->error('Configuration error: ' . $e->getMessage());
$io->error('Unable to parse the YAML string: '. $e->getMessage());
return Command::FAILURE; return Command::FAILURE;
} }
/**
* @var array{title: string, source: string, destination: string}[]
*/
$backupElements = $app->getConfig('backup');
$sio->table(
['Description', 'Source', 'Destination'],
$backupElements
);
$io->table(['Description', 'Source', 'Destination'], $app->getConfig()['backup']); $sio->success("Done");
$io->success("Done");
return Command::SUCCESS; return Command::SUCCESS;
} }
} }

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Notification;
use App\Notification\Ntfy;
use Psr\Log\NullLogger;
class Notification
{
/**
* @var NotificationInterface[] $notifiers
*/
private array $notifiers = [];
public function __construct(private NullLogger $logger = new NullLogger())
{
}
/**
* Load multiple configurations
*
* @param array<string[]> $config Array of notifier configurations.
*/
public function loadMany(array $config): void
{
foreach ($config as $conf) {
$this->loadSingle($conf['type'], $conf);
}
}
/**
* Load a single configuration
*
* @param string $key Notification class
* @param string[] $config Implementation specific configuration
*
* @SuppressWarnings(PHPMD)
*/
public function loadSingle(string $key, array $config): void
{
switch ($key) {
case 'ntfy':
case 'Ntfy':
case 'NTFY':
$this->addNotifier(Ntfy::factory($config));
break;
default:
break;
}
}
/**
* Add a single notifier instance.
*/
public function addNotifier(NotificationInterface $instance): void
{
$this->notifiers[] = $instance;
}
/**
* Get all active notifiers.
*
* @return NotificationInterface[] All notifiers.
*/
public function getNotifiers(): array
{
return $this->notifiers;
}
/**
* Push a notification to all notifiers.
*
* Logs an error if sending fails.
*/
public function send(string $title, string $message): void
{
foreach ($this->getNotifiers() as $notifier) {
try {
$notifier->send($title, $message);
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
}
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Notification;
interface NotificationInterface
{
/**
* @param string[] $config Configuration
*/
public static function factory(array $config): self;
/**
* @throw Exception on error.
*/
public function send(string $title, string $message): void;
}

83
src/Notification/Ntfy.php Normal file
View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Notification;
use Ntfy\Server;
use Ntfy\Message;
use Ntfy\Client;
use InvalidArgumentException;
/**
* Send a notification through a ntfy server
*/
class Ntfy implements NotificationInterface
{
public const TOPIC_MAX_LENGTH = 256;
public const TITLE_MAX_LENGTH = 256;
public const MESSAGE_MAX_LENGTH = 4096;
private string $topic = 'default';
/**
* Initialize with configuration.
*
* Factory method.
*
* @param string[] $config Configuration
*/
public static function factory(array $config): self
{
$instance = new self(new Client(new Server($config['domain'])));
if (isset($config['topic'])) {
$instance->setTopic($config['topic']);
}
return $instance;
}
public function __construct(private Client $client)
{
}
/**
* Set the topic of the notification message.
*
* @param string $topic Topic length between 1 and TOPIC_MAX_LENGTH characters.
*/
public function setTopic(string $topic): void
{
if (! strlen($topic) || strlen($topic) > self::TOPIC_MAX_LENGTH) {
throw new InvalidArgumentException("Invalid topic length");
}
$this->topic = $topic;
}
/**
* Return the currently set topic.
*/
public function getTopic(): string
{
return $this->topic;
}
/**
* Push a message with Ntfy
*/
public function send(string $title, string $message): void
{
if (! strlen($title) || strlen($title) > self::TITLE_MAX_LENGTH) {
throw new InvalidArgumentException("Invalid title length");
}
if (! strlen($message) || strlen($message) > self::MESSAGE_MAX_LENGTH) {
throw new InvalidArgumentException("Invalid message length");
}
$msg = new Message();
$msg->topic($this->getTopic());
$msg->title($title);
$msg->body($message);
$this->client->send($msg);
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Ntfy;
use Psr\Log\LoggerAwareTrait;
class Ntfy
{
use LoggerAwareTrait;
protected string $domain;
function __construct(string $domain)
{
$this->domain = $domain;
}
function send(string $topic, string $title, string $message): void
{
$this->logger->debug("Sending ntfy notification", ["topic"=>$topic, "title"=>$title, "message"=>$message]);
$result = file_get_contents(
'https://'.$this->domain.'/'.$topic, false, stream_context_create(
['http' => [
'method' => 'POST',
'header' =>
"Content-Type: text/plain\r\n" .
"Title: $title\r\n",
'content' => $message]
]
)
);
$this->logger->debug("Result of ntfy notification", [$result]);
}
}

View File

@ -1,102 +1,135 @@
<?php <?php
declare(strict_types=1);
namespace App\Rclone; namespace App\Rclone;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessFailedException;
use Exception; use Exception;
/**
* Wrapper for the rclone command line utility
*
* Installation of rclone is required.
* Configuration of the mounts must be done before use.
* Tested using rclone v1.64.0
*/
class Rclone class Rclone
{ {
use LoggerAwareTrait; private const MAX_RUNTIME = 4 * 3600; //4 hours maximum
protected string $rclonePath;
/**
* Global options
*
* @var array<string>
*/
protected array $globalOptions = [];
protected string $version = ""; protected string $version = "";
function __construct(string $rclonePath = "rclone") /**
* Create a new instance.
*
* Default it looks for "rclone" on the path.
* But the path can be configured to be absolute.
*
* @param string $rclonePath Relative or absolute path
*/
public function __construct(protected LoggerInterface $logger, protected string $rclonePath = "rclone")
{ {
$this->rclonePath = $rclonePath; $this->rclonePath = $rclonePath;
$this->setLogger(new NullLogger);
try $process = $this->exec('--version');
{ if (! $process->isSuccessful()) {
$version = $this->exec('--version');
}
catch(ProcessFailedException $e)
{
throw new Exception("Check installation of rclone"); throw new Exception("Check installation of rclone");
} }
$this->version = explode("\n", $version)[0]; $this->version = explode("\n", $process->getOutput())[0];
if (!\str_contains($this->version, 'rclone')) { if (! \str_contains($this->version, 'rclone')) {
throw new Exception("Rclone not recognized"); throw new Exception("rclone not recognized");
} }
} }
function getVersion(): string /**
* Get the rclone version.
*
* @return string Version string
*/
public function getVersion(): string
{ {
return $this->version; return $this->version;
} }
function getSize(string $path): int /**
* Calculate the size of a mount/path.
*
* @param string $path mount/path.
*
* @return int Size in bytes
*/
public function getSize(string $path): int
{ {
$output = $this->exec('size', ['--json', $path]); $process = $this->exec('size', ['--json', $path]);
return (int)json_decode($output)->bytes;
}
function copy(string $src, string $dest, string $bandwidth = null): string if (! $process->isSuccessful()) {
{ throw new Exception($process->getErrorOutput());
$options = array();
$options[] = $src;
$options[] = $dest;
if ($bandwidth) {
$options[] = "--bwlimit";
$options[] = $bandwidth;
} }
return $this->exec('copy', $options); /**
* @var array{bytes: int}
*/
$output = json_decode($process->getOutput(), TRUE);
return $output['bytes'];
} }
/** /**
* Execute a command on the rclone binary * Copy from source to destination.
*
* @param $src Source mount and path
* @param $dest Destination mount and path
* @param string[] $additionalOptions Additional options
*/
public function copy(string $src, string $dest, array $additionalOptions = []): void
{
$options = [];
$options[] = $src;
$options[] = $dest;
foreach ($additionalOptions as $key => $value) {
$options[] = '--' . (string)$key;
$options[] = $value;
}
$process = $this->exec('copy', $options);
if (! $process->isSuccessful()) {
throw new Exception($process->getErrorOutput());
}
}
/**
* Execute a command on the rclone binary.
* *
* @param string $command Top level Rclone command * @param string $command Top level Rclone command
* @param array<String> $options Array of additional options * @param array<String> $options Array of additional options
* *
* @return string stdout data. * @return Process Instance.
*/ */
protected function exec(string $command, array $options = array()) : string private function exec(string $command, array $options = []): Process
{ {
$process = new Process( $process = new Process(
array_merge( array_merge(
[$this->rclonePath], [$this->rclonePath],
$this->globalOptions,
[$command], [$command],
$options $options
) )
); );
$this->logger->info("Execute command", [$process->getCommandLine()]); $this->logger->info("Execute command", [$process->getCommandLine()]);
$process->setTimeout(4*3600);
$process->setTimeout(self::MAX_RUNTIME);
$process->run(); $process->run();
// executes after the command finishes // executes after the command finishes
if (!$process->isSuccessful()) { if (! $process->isSuccessful()) {
$this->logger->error("Failed execution"); $this->logger->error("Failed execution");
throw new ProcessFailedException($process);
} }
$this->logger->info("Return code", [$process->getExitCode()]); $this->logger->info("Return code", [$process->getExitCode()]);
return $process->getOutput(); return $process;
} }
} }

22
src/Template/Twig.php Normal file
View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Template;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use App\Template\TwigExtension;
class Twig extends Environment
{
/**
* @param string[] $templates Array of templates
*/
public function __construct(array $templates)
{
$loader = new ArrayLoader($templates);
parent::__construct($loader);
$this->addExtension(new TwigExtension());
}
}

View File

@ -1,18 +1,30 @@
<?php <?php
namespace App\Twig; declare(strict_types=1);
namespace App\Template;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\TwigFilter; use Twig\TwigFilter;
class AppExtension extends AbstractExtension /**
* Twig extension
*
* Additional formatters for templates
*/
class TwigExtension extends AbstractExtension
{ {
public function getFilters() /**
* Extend the filters
*
* @return TwigFilter[]
*/
public function getFilters(): array
{ {
return array( return [
new TwigFilter('formatBytes', array($this, 'formatBytes')), new TwigFilter('formatBytes', [$this, 'formatBytes']),
); ];
} }
/** /**
@ -26,8 +38,7 @@ class AppExtension extends AbstractExtension
public function formatBytes($bytes, $precision = 2) public function formatBytes($bytes, $precision = 2)
{ {
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB']; $size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];
$fact = (int)(floor((strlen((string)$bytes) - 1) / 3)); $fact = (int)floor((strlen((string)$bytes) - 1) / 3);
return sprintf("%.{$precision}f", $bytes / pow(1024, $fact)) . $size[$fact]; return sprintf("%.{$precision}f", $bytes / pow(1024, $fact)) . $size[$fact];
} }
} }

80
tests/AppTest.php Normal file
View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\LoggerInterface;
use App\App;
#[CoversClass(App::class)]
final class AppTest extends \PHPUnit\Framework\TestCase
{
public function testNonexistentConfigFile(): void
{
$this->expectException(\Exception::class);
new App('no_file');
$this->fail('Exception was not thrown');
}
public function testGoodConfig(): void
{
$app = new App('config.example.yml');
$this->assertEquals('output.log', $app->getConfig('log'));
$this->assertInstanceOf(LoggerInterface::class, $app->getLogger());
}
public function testBadConfigFileSyntaxError(): void
{
$this->expectException(\TypeError::class);
$app = new App('tests/config/bad/syntaxerror.yml');
$app->getConfig('rclone');
$app->getConfig('rclone.path');
$app->getConfig('backup');
$app->getConfig('notification');
$app->getConfig('log');
$app->getConfig('templates');
$app->getConfig('templates.notify');
$app->getConfig('templates.error');
$this->fail('Exception was not thrown');
}
public function testBadConfigFileEmpty(): void
{
$this->expectException(\TypeError::class);
$app = new App('tests/config/bad/empty.yml');
$app->getConfig('rclone');
$app->getConfig('rclone.path');
$app->getConfig('backup');
$app->getConfig('notification');
$app->getConfig('log');
$app->getConfig('templates');
$app->getConfig('templates.notify');
$app->getConfig('templates.error');
$this->fail('Exception was not thrown');
}
public function testBadConfigFileTypos(): void
{
$this->expectException(\League\Config\Exception\ValidationException::class);
$app = new App('tests/config/bad/typos.yml');
$app->getConfig('rclone');
$app->getConfig('rclone.path');
$app->getConfig('backup');
$app->getConfig('notification');
$app->getConfig('log');
$app->getConfig('templates');
$app->getConfig('templates.notify');
$app->getConfig('templates.error');
$this->fail('Exception was not thrown');
}
}

110
tests/CommandBackupTest.php Normal file
View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use App\CommandBackup;
#[CoversClass(CommandBackup::class)]
final class CommandBackupTest extends \PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
if (! is_dir('temp')) {
mkdir('temp');
}
if (! is_dir('temp/destination')) {
mkdir('temp/destination');
}
exec('rclone test makefiles --files 10 temp/source 2>&1');
}
protected function tearDown(): void
{
exec('rclone purge temp 2>&1');
if (is_dir('temp/destination')) {
rmdir('temp/destination');
}
if (is_dir("temp")) {
rmdir('temp');
}
}
public function testBadConfig(): void
{
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandBackup());
$command = $applicationd->find('backup');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "bad_file"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[ERROR] Configuration error:File "bad_file" does not exist.', $output);
}
public function testNoCommand(): void
{
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandBackup());
$command = $applicationd->find('backup');
$commandTester = new CommandTester($command);
$this->expectException(\Exception::class);
$commandTester->execute([]);
$this->fail('Exception was not thrown');
}
public function testGoodConfig(): void
{
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandBackup());
$command = $applicationd->find('backup');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "config.example.yml"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] Complete ', $output);
}
public function testNoDestinationFolder(): void
{
exec('rclone purge temp/destination 2>&1', $output);
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandBackup());
$command = $applicationd->find('backup');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "config.example.yml"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] Complete ', $output);
}
public function testNoSourceFolder(): void
{
exec('rclone purge temp/source 2>&1', $output);
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandBackup());
$command = $applicationd->find('backup');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "config.example.yml"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] Complete ', $output);
}
}

47
tests/CommandShowTest.php Normal file
View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use App\CommandShow;
#[CoversClass(CommandShow::class)]
final class CommandShowTest extends \PHPUnit\Framework\TestCase
{
public function testInvalidConfig(): void
{
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandShow());
$command = $applicationd->find('show');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "bad_file"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('[ERROR] Configuration error: File "bad_file" does not exist.', $output);
}
public function testGoodConfig(): void
{
exec('rclone test makefiles temp/source 2>&1');
$applicationd = new Application('backup', "1.1.1");
$applicationd->add(new CommandShow());
$command = $applicationd->find('show');
$commandTester = new CommandTester($command);
$commandTester->execute(['config' => "config.example.yml"]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Example', $output);
$this->assertStringContainsString('temp/source', $output);
$this->assertStringContainsString('temp/destination', $output);
}
}

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use App\Notification\Notification;
use App\Notification\NotificationInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Exception;
final class NotificationTest extends TestCase
{
/**
* @var array<string, string>
*/
private static array $config = ['type' => 'ntfy', 'domain' => 'https://test.com', 'topic' => 'testing'];
public function testloadSingle(): void
{
$dut = new Notification();
$dut->loadSingle('ntfy', self::$config);
$this->assertEquals(1, count($dut->getNotifiers()));
$dut->loadSingle('Ntfy', self::$config);
$this->assertEquals(2, count($dut->getNotifiers()));
$dut->loadSingle('NTFY', self::$config);
$this->assertEquals(3, count($dut->getNotifiers()));
$dut->loadSingle('invalid', self::$config);
$this->assertEquals(3, count($dut->getNotifiers()));
}
public function testloadMany(): void
{
$dut = new Notification();
$arr = [];
$arr[] = self::$config;
$arr[] = self::$config;
$arr[] = self::$config;
$arr[] = self::$config;
$dut->loadMany($arr);
$this->assertEquals(4, count($dut->getNotifiers()));
}
public function testSendNoNotifier(): void
{
$dut = new Notification();
$dut->send('title', 'topic');
$this->assertEquals(0, count($dut->getNotifiers()));
}
public function testSendOneNotifier(): void
{
$dut = new Notification();
$mock1 = $this->createMock(NotificationInterface::class);
$dut->addNotifier($mock1);
$mock1->expects($this->once())->method('send');
$dut->send('title', 'topic');
}
public function testSendMoreNotifiers(): void
{
$dut = new Notification();
$mock1 = $this->createMock(NotificationInterface::class);
$mock2 = $this->createMock(NotificationInterface::class);
$dut->addNotifier($mock1);
$dut->addNotifier($mock2);
$mock1->expects($this->once())->method('send');
$mock2->expects($this->once())->method('send');
$dut->send('title', 'topic');
}
public function testSendErrorInNotifiers(): void
{
$dut = new Notification();
$mock1 = $this->createMock(NotificationInterface::class);
$mock2 = $this->createMock(NotificationInterface::class);
$dut->addNotifier($mock1);
$dut->addNotifier($mock2);
$mock1->expects($this->once())->method('send')->willThrowException(new Exception());
$mock2->expects($this->once())->method('send');
$dut->send('title', 'topic');
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use App\Notification\Ntfy;
use Ntfy\Client;
use Ntfy\Message;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
final class NtfyTest extends TestCase
{
/**
* @psalm-suppress PropertyNotSetInConstructor
*/
private Ntfy $instance;
/**
* @psalm-suppress PropertyNotSetInConstructor
*/
private MockObject $client;
protected function setUp(): void
{
$this->client = $this->createMock(Client::class);
$this->instance = new Ntfy($this->client);
}
/**
* @SuppressWarnings(PHPMD.StaticAccess)
*/
public function testFactory(): void
{
$config = ['domain' => 'https://test.com', 'topic' => 'something'];
$instance = Ntfy::factory($config);
$this->assertInstanceOf(Ntfy::class, $instance);
$this->assertEquals($instance->getTopic(), "something");
}
public function testSendShort(): void
{
$this->client->expects($this->once())->method('send')->with($this->isInstanceOf(Message::class));
$this->instance->send('t', 's');
}
public function testSendLong(): void
{
$this->client->expects($this->once())->method('send')->with($this->isInstanceOf(Message::class));
$this->instance->send(str_repeat("t", Ntfy::TITLE_MAX_LENGTH), str_repeat("t", Ntfy::MESSAGE_MAX_LENGTH));
}
/**
* @return array<int, array<int, string>>
*/
public static function sendBadParameterProvider(): array
{
return [
['', ''],
['', 'text'],
['title', ''],
[str_repeat("t", Ntfy::TITLE_MAX_LENGTH + 1), 'text'],
['title',str_repeat("t", Ntfy::MESSAGE_MAX_LENGTH + 1)],
];
}
#[DataProvider('sendBadParameterProvider')]
public function testSendInvalidParameters(string $title, string $message): void
{
$this->expectException(\InvalidArgumentException::class);
$this->instance->send($title, $message);
}
public function testSetTopic(): void
{
$topic = "a";
$this->instance->setTopic($topic);
$this->assertEquals($topic, $this->instance->getTopic());
$topic = str_repeat("a", Ntfy::TOPIC_MAX_LENGTH);
$this->instance->setTopic($topic);
$this->assertEquals($topic, $this->instance->getTopic());
}
/**
* @return array<int, array<int, string>>
*/
public static function topicBadParameterProvider(): array
{
return [
[''],
[str_repeat("t", Ntfy::TOPIC_MAX_LENGTH + 1)],
];
}
#[DataProvider('topicBadParameterProvider')]
public function testSetTopicInvalidParameters(string $topic): void
{
$this->expectException(\InvalidArgumentException::class);
$this->instance->setTopic($topic);
}
}

View File

@ -1,23 +1,86 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace App\Tests;
use App\Rclone\Rclone;
use Psr\Log\NullLogger;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class RcloneTest extends TestCase final class RcloneTest extends TestCase
{ {
protected function setUp(): void
{
exec('rclone test makefiles temp/source 2>&1');
}
protected function tearDown(): void
{
exec('rclone purge temp 2>&1');
}
public function testRclonePath(): void public function testRclonePath(): void
{ {
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
$rclone = new \App\Rclone\Rclone('invalid'); new Rclone(new NullLogger(), 'invalid');
$this->fail('Exception was not thrown');
} }
public function testRcloneInvalidVersion(): void public function testRcloneInvalidVersion(): void
{ {
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
$rclone = new \App\Rclone\Rclone('uname'); new Rclone(new NullLogger(), 'uname');
$this->fail('Exception was not thrown');
} }
public function testRcloneValidVersion(): void public function testRcloneValidVersion(): void
{ {
$rclone = new \App\Rclone\Rclone('./tests/mock-rclone'); $rclone = new Rclone(new NullLogger());
$this->assertStringContainsString('rclone', $rclone->getVersion()); $this->assertStringStartsWith('rclone', $rclone->getVersion());
}
public function testRcloneSize(): void
{
$rclone = new Rclone(new NullLogger());
$size = $rclone->getSize('temp/source');
$this->assertGreaterThan(10000, $size);
$this->expectException(\Exception::class);
$this->expectExceptionMessage("ERROR");
$rclone->getSize('temp/bogus-source');
$this->fail('Exception was not thrown');
}
public function testRcloneCopy(): void
{
$rclone = new Rclone(new NullLogger());
$rclone->copy('temp/source', 'temp/destination');
$this->assertDirectoryExists('temp/destination');
}
public function testRcloneCopyParam(): void
{
$rclone = new Rclone(new NullLogger());
$rclone->copy('temp/source', 'temp/destination', ['bwlimit' => '6M']);
$this->assertDirectoryExists('temp/destination');
}
public function testRcloneCopyBad(): void
{
$rclone = new Rclone(new NullLogger());
$this->expectException(\Exception::class);
$this->expectExceptionMessage("ERROR");
$rclone->copy('temp/bogus-source', 'temp/bogus-destination');
$this->fail('Exception was not thrown');
}
public function testRcloneCopyBadParam(): void
{
$rclone = new Rclone(new NullLogger());
$this->expectException(\Exception::class);
$this->expectExceptionMessage("ERROR");
$rclone->copy('temp/bogus-source', 'temp/bogus-destination', ['bwlimit' => '6M']);
$this->fail('Exception was not thrown');
} }
} }

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use App\Template\TwigExtension;
use Twig\TwigFilter;
final class TwigExtensionTest extends \PHPUnit\Framework\TestCase
{
public function testGetFilters(): void
{
$obj = new TwigExtension();
$filters = $obj->getFilters();
$this->assertNotEmpty($filters, "Filters must not be empty");
$this->assertContainsOnlyInstancesOf(TwigFilter::class, $filters);
}
public function testformatBytes(): void
{
$obj = new TwigExtension();
$this->assertEquals('1.00B', $obj->formatBytes(1, 2));
$this->assertEquals('2.00B', $obj->formatBytes(2, 2));
$this->assertEquals('10.00B', $obj->formatBytes(10, 2));
$this->assertEquals('0.98kB', $obj->formatBytes(1000, 2));
$this->assertEquals('1.00kB', $obj->formatBytes(1024, 2));
$this->assertEquals('512.00MB', $obj->formatBytes(1024 ** 3 / 2, 2));
$this->assertEquals('1.00GB', $obj->formatBytes(1024 ** 3 - 1, 2));
$this->assertEquals('1.00GB', $obj->formatBytes(1024 ** 3, 2));
$this->assertEquals('512.00GB', $obj->formatBytes(1024 ** 4 / 2, 2));
$this->assertEquals('1.00TB', $obj->formatBytes(1024 ** 4, 2));
$this->assertEquals('1.00B', $obj->formatBytes(1));
$this->assertEquals('1.00TB', $obj->formatBytes(1024 ** 4));
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\TestCase;
use App\Template\Twig;
use Twig\TwigFilter;
final class TwigTest extends \PHPUnit\Framework\TestCase
{
public function testInstance(): void
{
$obj = new Twig(['template' => 'start {{ var }} end']);
$template = $obj->load('template');
$output = $template->render(['var' => 'middle']);
$this->assertEquals('start middle end', $output);
}
public function testExtensionsLoaded(): void
{
$obj = new Twig(['template' => 'start {{ var | formatBytes}} end']);
$template = $obj->load('template');
$output = $template->render(['var' => 1]);
$this->assertEquals('start 1.00B end', $output);
}
}

View File

View File

@ -0,0 +1,6 @@
<note>
<to>Someone</to>
<from>Me</from>
<heading>Title</heading>
<body>Text</body>
</note>

View File

@ -0,0 +1,26 @@
notifikation:
- type: Ntfy
domain: https://ntfy.jcktrue.dk
topic: testing
logger: output.log
rclone:
option:
bwlimit: 6M
backup:
- titel: Example
src: temp/source
dest: temp/destination
template:
notification: |
{{ config.title }}
From {{ config.source }} to {{ config.destination }}
Backup started: {{ start | date }}
Source size: {{ source_size | formatBytes}}
Destination before: {{ destination_size_before | formatBytes}}
Destination after: {{ destination_size_after | formatBytes}}
Destination change : {{ (destination_size_after - destination_size_before) | formatBytes}}
Backup completed: {{ end | date }}
warn: |
{{ config.title }}
Error {{ config.source }} to {{ config.destination }}
{{ exception }}

View File

@ -1,2 +0,0 @@
#!/bin/bash
echo rclone v1.53.3-DEV