Version 3

Support for multiple libraries synchronization (#44, #43, #41)
Support for Docker Secrets (#25)
Support for Seafile client's version through Docker tags (#9)
Mock Seafile server for testings (#6)
Revised project layout and workflow (#38, #39)
Working Docker Hub description publishing (#10)
This commit is contained in:
flow.gunso
2024-03-16 21:58:04 +00:00
parent 4c347b9156
commit f25b0182d2
42 changed files with 1196 additions and 788 deletions

55
seafile-client/Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
ARG TARGET=unstable
FROM debian:${TARGET}-slim
ARG UID
ARG GID
ENV UID 1000
ENV GID 1000
ENV PYTHONUNBUFFERED 1
RUN apt-get update && \
apt-get install \
--no-install-recommends \
--yes \
seafile-cli \
oathtool \
ca-certificates \
gnupg \
sudo && \
apt-get clean && apt-get autoclean && \
rm -rf \
/var/log/fsck/*.log \
/var/log/apt/*.log \
/var/cache/debconf/*.dat-old \
/var/lib/apt/lists/*
COPY --chmod=755 entrypoint-docker.sh /entrypoint.sh
COPY issue /etc/issue
RUN echo '[ ! -z $TERM ] && cat /etc/issue' >> /root/.bashrc && \
groupadd --gid $GID seafile && \
useradd --uid $UID --gid $GID --shell /bin/bash --create-home seafile && \
mkdir /library /seafile && \
chown seafile:seafile /seafile /library && \
apt-cache show seafile-cli | grep 'Version: ' | awk '{print $2}' > /SEAFILE_VERSION
COPY --chmod=755 --chown=seafile:seafile entrypoint-seafile.py /home/seafile/entrypoint.py
ARG CREATED
ARG REVISION
ARG VERSION
LABEL org.opencontainers.image.created=${CREATED}
LABEL org.opencontainers.image.authors="flow.gunso@gmail.com"
LABEL org.opencontainers.image.url="https://hub.docker.com/r/flowgunso/seafile-client"
LABEL org.opencontainers.image.documentation="https://gitlab.com/flwgns-docker/seafile-client"
LABEL org.opencontainers.image.source="https://gitlab.com/flwgns-docker/seafile-client"
LABEL org.opencontainers.image.version=${VERSION}
LABEL org.opencontainers.image.revision=${REVISION}
LABEL org.opencontainers.image.licenses="GPL-3.0"
LABEL org.opencontainers.image.title="Seafile Docker client"
LABEL org.opencontainers.image.description="Sync Seafile librairies within Docker containers."
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/home/seafile/entrypoint.py"]
HEALTHCHECK \
CMD ["/entrypoint.sh", "/home/seafile/entrypoint.py", "--healthcheck"]

View File

@@ -1,66 +0,0 @@
#!/bin/bash
# Docker Seafile client, help you mount a Seafile library as a volume.
# Copyright (C) 2019-2020, flow.gunso@gmail.com
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
function fail_with_message {
echo "$1"
echo "Exiting container."
exit 1
}
# Check mandatory Seafile configuration have been properly set.
[[ -z "$SEAF_SERVER_URL" ]] && fail_with_message "The \$SEAF_SERVER_URL is not defined."
[[ -z "$SEAF_USERNAME" ]] && fail_with_message "The \$SEAF_USERNAME is not defined."
[[ -z "$SEAF_PASSWORD" ]] && fail_with_message "The \$SEAF_PASSWORD is not defined."
[[ -z "$SEAF_LIBRARY_UUID" ]] && fail_with_message "The \$SEAF_LIBRARY_UUID is not defined."
[[ -n "$SEAF_UPLOAD_LIMIT" && $SEAF_UPLOAD_LIMIT =~ ^[0-9]+$ && "$SEAF_UPLOAD_LIMIT" -gt 0 ]] && \
fail_with_message "The \$SEAF_UPLOAD_LIMIT is not an integer greater than 0."
[[ -n "$SEAF_DOWNLOAD_LIMIT" && $SEAF_DOWNLOAD_LIMIT =~ ^[0-9]+$ && "$SEAF_DOWNLOAD_LIMIT" -gt 0 ]] && \
fail_with_message "The \$SEAF_DOWNLOAD_LIMIT is not an integer greater than 0."
# Update the user ID, if the $UID changed.
# TODO: What if the $UID already exists ?
[[ "$UID" != "1000" ]] && usermod -u $UID $UNAME
# Change the group, if the $GID changed.
if [ "$GID" != "1000" ]; then
getent group | grep ":$GID:" >/dev/null
if [ $? -eq 0 ]; then
usermod -g $GID -G 1000 $UNAME
else
groupmod -g $GID $UNAME
fi
fi
# Set the files ownership.
#chown $UID.$GID /home/seafuser/healthcheck.sh
chown $UID.$GID /home/seafuser/entrypoint.sh
chown $UID.$GID -R /library
# Run the Seafile client as the container user.
su - $UNAME << EO
export SEAF_SERVER_URL=$SEAF_SERVER_URL
export SEAF_USERNAME=$SEAF_USERNAME
export SEAF_PASSWORD=$SEAF_PASSWORD
export SEAF_LIBRARY_UUID=$SEAF_LIBRARY_UUID
[[ "$SEAF_SKIP_SSL_CERT" ]] && export SEAF_SKIP_SSL_CERT=$SEAF_SKIP_SSL_CERT
[[ "$SEAF_UPLOAD_LIMIT" ]] && export SEAF_UPLOAD_LIMIT=$SEAF_UPLOAD_LIMIT
[[ "$SEAF_DOWNLOAD_LIMIT" ]] && export SEAF_DOWNLOAD_LIMIT=$SEAF_DOWNLOAD_LIMIT
[[ "$SEAF_2FA_SECRET" ]] && export SEAF_2FA_SECRET=$SEAF_2FA_SECRET
[[ "$SEAF_LIBRARY_PASSWORD" ]] && export SEAF_LIBRARY_PASSWORD=$SEAF_LIBRARY_PASSWORD
/bin/bash /home/seafuser/entrypoint.sh
EO

View File

@@ -16,9 +16,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# Grab the status of the active repos then return as healthy/unhealthy
# depending the healthy statuses.
#!/bin/bash
su - $UNAME << EO
~/healthcheck.py -c ~/.seafile/seafile-data/ $SEAF_LIBRARY_UUID
EO
set -e
groupmod -g $GID seafile &> /dev/null
usermod -u $UID -g $GID seafile &> /dev/null
sudo \
-HE \
-u seafile \
-- "$@"

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import Any
import argparse
import logging
import os
import subprocess
import sys
import time
import seafile
class BadConfiguration(Exception):
pass
def get_configuration(variable: str, *args) -> Any:
"""Helper function to get a configuration.
see https://gitlab.com/-/snippets/1941025
"""
# Assign the default value from the first item of *args.
if args:
default = args[0]
# Try to get the variable from a Docker Secret.
try:
file = os.environ[f"{variable}_FILE"]
except KeyError:
pass
else:
with open(file, "rt") as fo:
return fo.read()
# Try to get the variable from an environment variable.
try:
return os.environ[variable]
except KeyError:
pass
# Try to return the default value,
# if no default exist, then it is a required variable.
try:
return default
except UnboundLocalError:
raise BadConfiguration(
f"Environment variable {variable} was not found but is required."
)
class Client:
def __init__(self) -> None:
# Client configuration
self.username: str = get_configuration("SEAF_USERNAME")
self.password: str = get_configuration("SEAF_PASSWORD")
self.url: str = get_configuration("SEAF_SERVER_URL")
self.skip_ssl_cert: bool = bool(get_configuration("SEAF_SKIP_SSL_CERT", None))
self.upload_limit: int = get_configuration("SEAF_UPLOAD_LIMIT", None)
self.download_limit: int = get_configuration("SEAF_DOWNLOAD_LIMIT", None)
self.mfa_secret: str = get_configuration("SEAF_2FA_SECRET", None)
# Paths
self.ini = Path.home().joinpath(".ccnet", "seafile.ini")
self.log = Path.home().joinpath(".ccnet", "logs", "seafile.log")
self.seafile = Path("/seafile")
self.socket = self.seafile.joinpath("seafile-data", "seafile.sock")
self.target = Path("/library")
# Binaries, instances.
if self.socket.exists():
self.rpc = seafile.RpcClient(str(self.socket))
self.binary = ["seaf-cli"]
self._get_librairies()
def _get_librairies(self):
self.libraries = {}
# Single library use case. Mutually exclusive to mulitple labraries use case.
single_library_variables = ["SEAF_LIBRARY", "SEAF_LIBRARY_UUID", "SEAF_LIBRARY_PASSWORD"]
if any(environ in single_library_variables for environ in os.environ):
logger.info("Single library detected. Multiple libraries will be ignored.")
library = {}
# Grab the UUID, usin both the
uuid = None
if legacy := os.getenv("SEAF_LIBRARY_UUID", None):
logger.warning("SEAF_LIBRARY_UUID is obsolete, please use SEAF_LIBRARY instead.")
uuid = legacy
if current := os.getenv("SEAF_LIBRARY", None):
uuid = current
# Exit if no UUID was provided, continue otherwise.
if uuid is None:
raise Exception("Please provide an UUID with SEAF_LIBRARY for single library usage.")
library["uuid"] = uuid
if password := os.getenv("SEAF_LIBRARY_PASSWORD", None):
library["password"] = password
# Assign and return a default library.
self.libraries["_"] = library
return
# Multiple libraries use case.
# Loop over all sorted variables prefixed with SEAF_LIBRARY.
for variable in sorted(os.environ):
if variable.startswith("SEAF_LIBRARY"):
# Get the variable name.
name = variable.split("_")[2]
# Read the password as a secret.
if "_PASSWORD" in variable:
password = get_configuration(variable, None)
try:
if password:
self.libraries[name]["password"] = password
except KeyError:
logger.warning(f"Cannot set a password to unknown library {name}")
# Or got the name, build the dictionary with the name and uuid.
else:
self.libraries[name] = {}
uuid = os.environ[variable]
self.libraries[name]["uuid"] = uuid
def initialize(self):
# Initialize the Seafile client.
logger.info("Initializing `seaf-cli`.")
if not self.ini.exists():
logger.info("Seafile .ini file not found, running `seaf-cli init`")
#self.ini.parent.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(self.binary + ["init", "-d", str(self.seafile)])
while not self.ini.exists():
logging.debug("Waiting for the .ini file to be created...")
time.sleep(1)
# Start the Seafile client.
logger.info("Starting `seaf-cli`.")
subprocess.run(self.binary + ["start"])
while not self.socket.exists():
logger.debug("Waiting for the Seafile client socket to be created.")
time.sleep(1)
def configure(self):
command = self.binary + ["config"]
if self.skip_ssl_cert:
subprocess.run(command +["-k", "disable_verify_certificate", "-v", self.skip_ssl_cert])
if self.download_limit:
subprocess.run(command +["-k", "download_limit", "-v", self.download_limit])
if self.upload_limit:
subprocess.run(command +["-k", "upload_limit", "-v", self.upload_limit])
def synchronize(self):
core = self.binary + ["sync", "-u", self.username, "-p", self.password, "-s", self.url]
for name, configuration in self.libraries.items():
uuid = configuration["uuid"]
# Check if repository is already synced.
repository = self.rpc.get_repo(uuid)
if repository is not None:
logger.info(f"Library {name} is already synced.")
continue
command = core + ["-l", uuid]
if "password" in configuration:
password = configuration["password"]
command += ["-e", password]
target = self.target if name == "_" else self.target.joinpath(name)
target.mkdir(parents=True, exist_ok=True)
command += ["-d", str(target)]
if self.mfa_secret:
totp = subprocess.run(
f"oathtool --base32 --totp {self.mfa_secret}",
text=True,
capture_stdout=True).stdout
command += ["-a", totp]
logging.debug(f"Running {' '.join(command)}")
subprocess.run(command)
def follow(self):
logging.debug(f"Running `tail -v -f {self.log}`")
subprocess.run(["tail", "-v", "-f", self.log])
def healthcheck(self):
tasks = self.rpc.get_clone_tasks()
healthy = True
for task in tasks:
name = task.repo_name
state = task.state
if task.state == 'done':
continue
elif state == "fetch":
tx_task = self.rpc.find_transfer_task(task.repo_id)
percentage = 0 if tx_task.block_done == 0 else tx_task.block_done / tx_task.block_total * 100
rate = 0 if tx_task.rate == 0 else tx_task.rate / 1024.0
print(f"{name:<50s}\t{state:<20s}\t{percentage:<.1f}%, {rate:<.1f}KB/s")
elif task.state == "error":
healthy = False
error = self.rpc.sync_error_id_to_str(task.error)
print(f"{name:<50s}\t{state:<20s}\t{error}")
else:
print(f"{name:<50s}\t{state:<20s}")
repos = self.rpc.get_repo_list(-1, -1)
for repo in repos:
name = repo.name
auto_sync_enabled = self.rpc.is_auto_sync_enabled()
if not auto_sync_enabled or not repo.auto_sync:
state = "auto sync disabled"
print(f"{name:<50s}\t{state:<20s}")
continue
task = self.rpc.get_repo_sync_task(repo.id)
if task is None:
state = "waiting for sync"
print(f"{name:<50s}\t{state:<20s}")
continue
state = task.state
if state in ['uploading', 'downloading']:
tx_task = self.rpc.find_transfer_task(repo.id)
if tx_task.rt_state == "data":
state += " files"
percentage = 0 if tx_task.block_done == 0 else tx_task.block_done / tx_task.block_total * 100
rate = 0 if tx_task.rate == 0 else tx_task.rate / 1024.0
print(f"{name:<50s}\t{state:<20s}\t{percentage:<.1f}%, {rate:<.1f}KB/s")
elif tx_task.rt_state == "fs":
state += " files list"
percentage = 0 if tx_task.fs_objects_done == 0 else tx_task.fs_objects_done / tx_task.fs_objects_total * 100
print(f"{name:<50s}\t{state:<20s}\t{percentage:<.1f}%")
elif state == 'error':
healthy = False
error = self.rpc.sync_error_id_to_str(task.error)
print(f"{name:<50s}\t{state:<20s}\t{error}")
else:
print(f"{name:<50s}\t{state:<20s}")
def entrypoint():
try:
logging.debug("Instanciating the client")
client = Client()
except BadConfiguration as e:
logger.error(f"Bad configuration: {e}")
sys.exit(1)
logging.debug("Initializing the client")
client.initialize()
logging.debug("Configuring the client")
client.configure()
logging.debug("Synchronizing the client")
client.synchronize()
logging.debug("Following the client")
client.follow()
debug = get_configuration("DEBUG", False)
level = logging.INFO
format = "%(asctime)s - %(levelname)s - %(message)s"
if debug:
level = logging.DEBUG
format = "%(asctime)s - %(filename)s:%(lineno)d - %(levelname)s - %(message)s"
logging.basicConfig(format=format, level=level)
logger = logging.getLogger()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog="",
description="",
epilog=""
)
parser.add_argument("--healthcheck", action="store_true")
args = parser.parse_args()
healthcheck = args.healthcheck
if healthcheck:
logger.disabled = True
try:
logging.debug("Instanciating the client")
client = Client()
except BadConfiguration as e:
logger.error(f"Bad configuration: {e}")
sys.exit(1)
if healthcheck:
logging.debug("Running healthchecks")
sys.exit(client.healthcheck())
logging.debug("Initializing the client")
client.initialize()
logging.debug("Configuring the client")
client.configure()
logging.debug("Synchronizing the client")
client.synchronize()
logging.debug("Following the client")
client.follow()

4
seafile-client/issue Normal file
View File

@@ -0,0 +1,4 @@
┌───────────────────────┐
│ Seafile Docker client │
└───────────────────────┘
Run `./entrypoint.sh bash` to login as the seafile user.

View File

@@ -1,55 +0,0 @@
#!/bin/bash
# Docker Seafile client, help you mount a Seafile library as a volume.
# Copyright (C) 2019-2020, flow.gunso@gmail.com
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# Initialise the Seafile client, if not already initialised.
seafile_ini="$HOME/.ccnet/seafile.ini"
if [ ! -f "$seafile_ini" ]; then
echo "Initializing Seafile client..."
seaf-cli init -d ~/.seafile
while [ ! -f "$seafile_ini" ]; do sleep 1; done
fi
# Start the Seafile daemon.
echo "Starting Seafile client..."
seaf-cli start
while [ ! -S "$HOME/.seafile/seafile-data/seafile.sock" ]; do sleep 1; done
# Synchronize the library, if not already synchronized.
if [ -z "$(seaf-cli status | grep -v ^\#)" ]; then
echo "Synchronizing Seafile library..."
# Set the disable_verify_certificate key to true only if the environment variable exists.
[[ "$SEAF_SKIP_SSL_CERT" ]] && seaf-cli config -k disable_verify_certificate -v true
# Set the upload/download limits
[[ "$SEAF_UPLOAD_LIMIT" ]] && seaf-cli config -k upload_limit -v $SEAF_UPLOAD_LIMIT
[[ "$SEAF_DOWNLOAD_LIMIT" ]] && seaf-cli config -k download_limit -v $SEAF_DOWNLOAD_LIMIT
# Build the seaf-cli sync command.
cmd="seaf-cli sync -u $SEAF_USERNAME -p $SEAF_PASSWORD -s $SEAF_SERVER_URL -l $SEAF_LIBRARY_UUID -d /library"
[[ "$SEAF_2FA_SECRET" ]] && cmd+=" -a $(oathtool --base32 --totp $SEAF_2FA_SECRET)"
[[ "$SEAF_LIBRARY_PASSWORD" ]] && cmd+=" -e $SEAF_LIBRARY_PASSWORD"
# Run it.
if ! eval $cmd; then echo "Failed to synchronize."; exit 1; fi
fi
# Continously print the log, infinitely.
while true; do
tail -v -f ~/.ccnet/logs/seafile.log
echo $?
done

View File

@@ -1,65 +0,0 @@
#!/usr/bin/env python2
# Docker Seafile client, help you mount a Seafile library as a volume.
# Copyright (C) 2019-2020, flow.gunso@gmail.com
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# Given a path to a valid Seafile confdir and a repository, check the
# synchronization status and otherwise the transfer status of that
# repository.
import argparse
import os
import sys
import seafile
if __name__ == "__main__":
# Parse Seafile's confdir and repository ID to check.
parser = argparse.ArgumentParser(description="Check the status of a synced repository.")
parser.add_argument('repository_id', type=str, help="Repository ID to check the status of.")
parser.add_argument('-c', '--confdir', type=str, required=True, help="Seafile configuration directory to load the Socket from.")
args = parser.parse_args()
# Instanciate Seafile RPC.
seafile_socket = os.path.join(args.confdir, "seafile.sock")
if not os.path.exists(seafile_socket):
raise Exception("Could not find a Seafile socket at {}".format(args.confdir))
seafile_rpc = seafile.RpcClient(seafile_socket)
# Fetch the sync task of the repository.
repository_sync_task = seafile_rpc.get_repo_sync_task(args.repository_id)
if repository_sync_task is not None:
sync_state = repository_sync_task.state
msg = "Repository synchronization state: {}".format(sync_state)
if sync_state == "error":
raise Exception(msg)
else:
print(msg)
sys.exit(0)
# Fetch the transfer task of the repository.
repository_transfer_task = seafile_rpc.find_transfer_task(args.repository_id)
if repository_transfer_task is not None:
transfer_state = repository_transfer_task.state
msg = "Repository transfer state: {}".format(transfer_state)
if transfer_state == "error":
raise Exception(msg)
else:
print(msg)
sys.exit(0)
raise Exception("Could not find any information about any repository synchronization or transfer tasks.")