4 Commits

Author SHA1 Message Date
einarf
093dab93ca Make rcb dump env vars to properly escape them 2024-01-05 03:50:13 +01:00
einarf
405bd4af15 Various tweaks
* Use --no-tablespaces in mysqldump
* Dump to 0.7.0
* pin docker version
* Include missing packages in setup.py
2023-11-10 22:24:29 +01:00
dreadper
28dda6b09d fix TypeError: request() got an unexpected keyword argument 'chunked' by upgrading pip package docker (from 4.1.* to 5.1.*) 2023-10-28 14:54:20 +02:00
dreadper
b400138b73 fix [Plugin caching_sha2_password could not be loaded](https://github.com/arey/mysql-client/issues/5) 2023-10-28 14:20:40 +02:00
12 changed files with 113 additions and 121 deletions

View File

@@ -4,7 +4,7 @@ sudo: false
matrix: matrix:
include: include:
python: 3.8 python: 3.7
dist: bionic dist: bionic
sudo: true sudo: true

View File

@@ -4,7 +4,7 @@ services:
build: ./src build: ./src
env_file: env_file:
- restic_compose_backup.env - restic_compose_backup.env
- alerts.env # - alerts.env
labels: labels:
restic-compose-backup.volumes: true restic-compose-backup.volumes: true
restic-compose-backup.volumes.include: 'src' restic-compose-backup.volumes.include: 'src'
@@ -32,7 +32,7 @@ services:
- SOME_VALUE=test - SOME_VALUE=test
- ANOTHER_VALUE=1 - ANOTHER_VALUE=1
mysql: mysql5:
image: mysql:5 image: mysql:5
labels: labels:
restic-compose-backup.mysql: true restic-compose-backup.mysql: true
@@ -42,7 +42,19 @@ services:
- MYSQL_USER=myuser - MYSQL_USER=myuser
- MYSQL_PASSWORD=mypassword - MYSQL_PASSWORD=mypassword
volumes: volumes:
- mysqldata:/var/lib/mysql - mysqldata5:/var/lib/mysql
mysql8:
image: mysql:8
labels:
restic-compose-backup.mysql: true
environment:
- MYSQL_ROOT_PASSWORD=my-secret-pw
- MYSQL_DATABASE=mydb
- MYSQL_USER=myuser
- MYSQL_PASSWORD=mypassword
volumes:
- mysqldata8:/var/lib/mysql
mariadb: mariadb:
image: mariadb:10 image: mariadb:10
@@ -68,7 +80,8 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
volumes: volumes:
mysqldata: mysqldata5:
mysqldata8:
mariadbdata: mariadbdata:
pgdata: pgdata:

View File

@@ -1,6 +1,10 @@
FROM restic/restic:0.9.6 FROM restic/restic:0.9.6
RUN apk update && apk add python3 dcron mariadb-client postgresql-client RUN apk update && apk add python3 \
dcron \
mariadb-client \
postgresql-client \
mariadb-connector-c-dev
ADD . /restic-compose-backup ADD . /restic-compose-backup
WORKDIR /restic-compose-backup WORKDIR /restic-compose-backup

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# Dump all env vars so we can source them in cron jobs # Dump all env vars so we can source them in cron jobs
printenv | sed 's/^\(.*\)$/export \1/g' > /env.sh rcb dump-env > /env.sh
# Write crontab # Write crontab
rcb crontab > crontab rcb crontab > crontab

View File

@@ -1 +1 @@
__version__ = '0.6.0' __version__ = '0.7.1'

View File

@@ -51,6 +51,9 @@ def main():
elif args.action == "crontab": elif args.action == "crontab":
crontab(config) crontab(config)
elif args.action == "dump-env":
dump_env()
# Random test stuff here # Random test stuff here
elif args.action == "test": elif args.action == "test":
nodes = utils.get_swarm_nodes() nodes = utils.get_swarm_nodes()
@@ -105,10 +108,10 @@ def status(config, containers):
logger.info( logger.info(
' - %s (is_ready=%s) -> %s', ' - %s (is_ready=%s) -> %s',
instance.container_type, instance.container_type,
ping, ping == 0,
instance.backup_destination_path(), instance.backup_destination_path(),
) )
if not ping: if ping != 0:
logger.error("Database '%s' in service %s cannot be reached", logger.error("Database '%s' in service %s cannot be reached",
instance.container_type, container.service_name) instance.container_type, container.service_name)
@@ -290,6 +293,14 @@ def crontab(config):
print(cron.generate_crontab(config)) print(cron.generate_crontab(config))
def dump_env():
"""Dump all environment variables to a script that can be sourced from cron"""
print("#!/bin/bash")
print("# This file was generated by restic-compose-backup")
for key, value in os.environ.items():
print("export {}='{}'".format(key, value))
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(prog='restic_compose_backup') parser = argparse.ArgumentParser(prog='restic_compose_backup')
parser.add_argument( parser.add_argument(
@@ -303,6 +314,7 @@ def parse_args():
'cleanup', 'cleanup',
'version', 'version',
'crontab', 'crontab',
'dump-env',
'test', 'test',
], ],
) )

View File

@@ -1,9 +1,7 @@
import logging import logging
from typing import List, Tuple, Union from typing import List, Tuple
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from restic_compose_backup import utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,33 +9,37 @@ def test():
return run(['ls', '/volumes']) return run(['ls', '/volumes'])
def ping_mysql(container_id, host, port, username, password) -> int: def ping_mysql(host, port, username) -> int:
"""Check if the mysql is up and can be reached""" """Check if the mysql is up and can be reached"""
return docker_exec(container_id, [ return run([
'mysqladmin', 'mysqladmin',
'ping', 'ping',
'--host',
host,
'--port',
port,
'--user', '--user',
username, username,
], environment={ ])
'MYSQL_PWD': password
})
def ping_mariadb(container_id, host, port, username, password) -> int: def ping_mariadb(host, port, username) -> int:
"""Check if the mariadb is up and can be reached""" """Check if the mariadb is up and can be reached"""
return docker_exec(container_id, [ return run([
'mysqladmin', 'mysqladmin',
'ping', 'ping',
'--host',
host,
'--port',
port,
'--user', '--user',
username, username,
], environment={ ])
'MYSQL_PWD': password
})
def ping_postgres(container_id, host, port, username, password) -> int: def ping_postgres(host, port, username, password) -> int:
"""Check if postgres can be reached""" """Check if postgres can be reached"""
return docker_exec(container_id, [ return run([
"pg_isready", "pg_isready",
f"--host={host}", f"--host={host}",
f"--port={port}", f"--port={port}",
@@ -45,22 +47,6 @@ def ping_postgres(container_id, host, port, username, password) -> int:
]) ])
def docker_exec(container_id: str, cmd: List[str], environment: Union[dict, list] = []) -> int:
"""Execute a command within the given container"""
client = utils.docker_client()
logger.debug('docker exec inside %s: %s', container_id, ' '.join(cmd))
exit_code, (stdout, stderr) = client.containers.get(container_id).exec_run(cmd, demux=True, environment=environment)
if stdout:
log_std('stdout', stdout.decode(),
logging.DEBUG if exit_code == 0 else logging.ERROR)
if stderr:
log_std('stderr', stderr.decode(), logging.ERROR)
return exit_code
def run(cmd: List[str]) -> int: def run(cmd: List[str]) -> int:
"""Run a command with parameters""" """Run a command with parameters"""
logger.debug('cmd: %s', ' '.join(cmd)) logger.debug('cmd: %s', ' '.join(cmd))

View File

@@ -25,36 +25,35 @@ class MariadbContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
return commands.ping_mariadb( with utils.environment('MYSQL_PWD', creds['password']):
self.id, return commands.ping_mariadb(
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
creds['password'] )
) == 0
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"mysqldump", "mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}", f"--user={creds['username']}",
"--all-databases", "--all-databases",
"--no-tablespaces",
] ]
def backup(self): def backup(self):
config = Config() config = Config()
creds = self.get_credentials() creds = self.get_credentials()
return restic.backup_from_stdin( with utils.environment('MYSQL_PWD', creds['password']):
config.repository, return restic.backup_from_stdin(
self.backup_destination_path(), config.repository,
self.id, self.backup_destination_path(),
self.dump_command(), self.dump_command(),
environment={ )
'MYSQL_PWD': creds['password']
}
)
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")
@@ -86,36 +85,35 @@ class MysqlContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
return commands.ping_mysql( with utils.environment('MYSQL_PWD', creds['password']):
self.id, return commands.ping_mysql(
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
creds['password'] )
) == 0
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"mysqldump", "mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}", f"--user={creds['username']}",
"--all-databases", "--all-databases",
"--no-tablespaces",
] ]
def backup(self): def backup(self):
config = Config() config = Config()
creds = self.get_credentials() creds = self.get_credentials()
return restic.backup_from_stdin( with utils.environment('MYSQL_PWD', creds['password']):
config.repository, return restic.backup_from_stdin(
self.backup_destination_path(), config.repository,
self.id, self.backup_destination_path(),
self.dump_command(), self.dump_command(),
environment={ )
"MYSQL_PWD": creds['password']
}
)
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")
@@ -148,12 +146,11 @@ class PostgresContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
return commands.ping_postgres( return commands.ping_postgres(
self.id,
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
creds['password'], creds['password'],
) == 0 )
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
@@ -161,19 +158,22 @@ class PostgresContainer(Container):
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"pg_dump", "pg_dump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--username={creds['username']}", f"--username={creds['username']}",
creds['database'], creds['database'],
] ]
def backup(self): def backup(self):
config = Config() config = Config()
creds = self.get_credentials()
return restic.backup_from_stdin( with utils.environment('PGPASSWORD', creds['password']):
config.repository, return restic.backup_from_stdin(
self.backup_destination_path(), config.repository,
self.id, self.backup_destination_path(),
self.dump_command(), self.dump_command(),
) )
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")

View File

@@ -2,10 +2,9 @@
Restic commands Restic commands
""" """
import logging import logging
from typing import List, Tuple, Union from typing import List, Tuple
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from restic_compose_backup import commands from restic_compose_backup import commands
from restic_compose_backup import utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,10 +27,9 @@ def backup_files(repository: str, source='/volumes'):
])) ]))
def backup_from_stdin(repository: str, filename: str, container_id: str, def backup_from_stdin(repository: str, filename: str, source_command: List[str]):
source_command: List[str], environment: Union[dict, list] = None):
""" """
Backs up from stdin running the source_command passed in within the given container. Backs up from stdin running the source_command passed in.
It will appear in restic with the filename (including path) passed in. It will appear in restic with the filename (including path) passed in.
""" """
dest_command = restic(repository, [ dest_command = restic(repository, [
@@ -41,43 +39,20 @@ def backup_from_stdin(repository: str, filename: str, container_id: str,
filename, filename,
]) ])
client = utils.docker_client() # pipe source command into dest command
source_process = Popen(source_command, stdout=PIPE, bufsize=65536)
logger.debug('docker exec inside %s: %s', container_id, ' '.join(source_command)) dest_process = Popen(dest_command, stdin=source_process.stdout, stdout=PIPE, stderr=PIPE, bufsize=65536)
# Create and start source command inside the given container
handle = client.api.exec_create(container_id, source_command, environment=environment)
exec_id = handle["Id"]
stream = client.api.exec_start(exec_id, stream=True, demux=True)
source_stderr = ""
# Create the restic process to receive the output of the source command
dest_process = Popen(dest_command, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=65536)
# Send the ouptut of the source command over to restic in the chunks received
for stdout_chunk, stderr_chunk in stream:
if stdout_chunk:
dest_process.stdin.write(stdout_chunk)
if stderr_chunk:
source_stderr += stderr_chunk.decode()
# Wait for restic to finish
stdout, stderr = dest_process.communicate() stdout, stderr = dest_process.communicate()
# Ensure both processes exited with code 0 # Ensure both processes exited with code 0
source_exit = client.api.exec_inspect(exec_id).get("ExitCode") source_exit, dest_exit = source_process.poll(), dest_process.poll()
dest_exit = dest_process.poll() exit_code = 0 if (source_exit == 0 and dest_exit == 0) else 1
exit_code = source_exit or dest_exit
if stdout: if stdout:
commands.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR) commands.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR)
if source_stderr:
commands.log_std(f'stderr ({source_command[0]})', source_stderr, logging.ERROR)
if stderr: if stderr:
commands.log_std('stderr (restic)', stderr, logging.ERROR) commands.log_std('stderr', stderr, logging.ERROR)
return exit_code return exit_code

View File

@@ -3,7 +3,6 @@ import logging
from typing import List, TYPE_CHECKING from typing import List, TYPE_CHECKING
from contextlib import contextmanager from contextlib import contextmanager
import docker import docker
from docker import DockerClient
if TYPE_CHECKING: if TYPE_CHECKING:
from restic_compose_backup.containers import Container from restic_compose_backup.containers import Container
@@ -13,7 +12,7 @@ logger = logging.getLogger(__name__)
TRUE_VALUES = ['1', 'true', 'True', True, 1] TRUE_VALUES = ['1', 'true', 'True', True, 1]
def docker_client() -> DockerClient: def docker_client():
""" """
Create a docker client from the following environment variables:: Create a docker client from the following environment variables::

View File

@@ -3,12 +3,15 @@ from setuptools import setup, find_namespace_packages
setup( setup(
name="restic-compose-backup", name="restic-compose-backup",
url="https://github.com/ZettaIO/restic-compose-backup", url="https://github.com/ZettaIO/restic-compose-backup",
version="0.6.0", version="0.7.1",
author="Einar Forselv", author="Einar Forselv",
author_email="eforselv@gmail.com", author_email="eforselv@gmail.com",
packages=find_namespace_packages(include=['restic_compose_backup']), packages=find_namespace_packages(include=[
'restic_compose_backup',
'restic_compose_backup.*',
]),
install_requires=[ install_requires=[
'docker==4.1.*', 'docker~=6.1.3',
], ],
entry_points={'console_scripts': [ entry_points={'console_scripts': [
'restic-compose-backup = restic_compose_backup.cli:main', 'restic-compose-backup = restic_compose_backup.cli:main',

View File

@@ -53,4 +53,4 @@ norecursedirs = tests/* .venv/* .tox/* build/ docs/
ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503 ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503
show-source = True show-source = True
max-line-length = 120 max-line-length = 120
exclude = .tox,env,tests,build,conf.py,.venv exclude = .tox,env,tests,build,conf.py