diff --git a/Dockerfile b/Dockerfile index 816e9fe..d4ee5cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" # Install app requirements -WORKDIR /seafile-client +WORKDIR /dsc COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt @@ -26,11 +26,11 @@ COPY dsc ./dsc/ COPY start.py ./start.py # Create seafile user and init seafile client -RUN chmod +x /seafile-client/start.py && \ - useradd -U -d /seafile-client -s /bin/bash seafile && \ +RUN chmod +x /dsc/start.py && \ + useradd -U -d /dsc -s /bin/bash seafile && \ usermod -G users seafile && \ - chown seafile:seafile -R /seafile-client && \ - su - seafile -c "seaf-cli init -d /seafile-client" + mkdir -p /dsc/seafile-data && \ + chown seafile:seafile -R /dsc -VOLUME /seafile-client/seafile-data +VOLUME /dsc/seafile-data CMD ["./start.py"] diff --git a/LICENSE b/LICENSE index 35990e5..f703de9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2018 Robin Grönberg -Copyright (c) 2019 Maks Snegov +Copyright (c) 2019-2023 Maks Snegov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 140fc47..5356661 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,46 @@ # docker-seafile-client -Runs a seafile client in docker with possibility to sync seafile repositories. +Docker image for Seafile terminal client. -## Docker-compose example: +### Docker-compose example: ```yaml -version: '3' - services: seafile-client: restart: always image: snegov/seafile-client environment: - - LIBRARY_ID= - - SERVER_HOST= - - USERNAME= - - PASSWORD= - - SEAFILE_UID= - - SEAFILE_GID= - hostname: docker-seafile-client + - LIBRARY_ID="79867cbf-2944-488d-9105-852463ecdf9e:my_library" + - SERVER_HOST=seafile.example.com + - USERNAME=user + - PASSWORD=password + - SEAFILE_UID=1000 + - SEAFILE_GID=100 + hostname: dsc volumes: - - seafile-data:/seafile-client/seafile-data - - :/data + - /home/johndow/seafile:/dsc/seafile + - sync-data:/dsc/seafile-data + container_name: seafile-client volumes: - seafile-data: + sync-data: ``` -Library id could be found from "My Libraries" page in Seafile webUI - link to each library contains library ID in it. +### Environment variables: + - `LIBRARY_ID` - library to sync, ID or name. Multiple libraries could be separated by colon `:`. + - `SERVER_HOST` - hostname of your Seafile server, eg: `seafile.example.com`. If you're using non-standard port, you can specify it here, eg: `seafile.example.com:8080`. + - `USERNAME`/ `PASSWORD` - credentials to access Seafile server. + - `SEAFILE_UID` / `SEAFILE_GID` - UID/GID of user inside container. You can use it to set permissions on synced files. Default values are `1000`/`1000`. -Inside container libraries' content will be put in `/data` directory, so map your host directory to it. +### Volumes: + - `/dsc/seafile-data` Seafile client data directory (sync status, etc). + - `/dsc/seafile` Seafile libraries content. -`hostname` parameter in docker-compose will set client name in Seafile's "Linked devices" admin page. Resulting name will be prefixed by "terminal-". -Also you could check [docker-compose example](docker-compose.example.yml). +### Some notes +`LIBRARY_ID` could be library ID or library name. Library ID is a 36-character string, which is a part of URI when you open library in webUI. Library name is a name you gave to library when you created it. + +Libraries will be synced in subdirectories of `/dsc/seafile` directory inside container. You can mount it to host directory to access files. + +`hostname` parameter is optional, but it's recommended to set it to some unique value, it will be shown in Seafile webUI as client name (`terminal-dsc` in given example). + +`sync-data` volume is optional too, but it's recommended to use it. Otherwise, sync status will be lost when container is recreated. -## Environment variables: - - `LIBRARY_ID=` Library to sync, ID or name; multiple libraries could be separated by colon `:`. - - `SERVER_HOST=` Hostname of your seafile server, eg: `seafile.example.com`. If you're using non-standart port, specify it here, eg: `seafile.example.com:8080`. - - `USERNAME=` Seafile account username. - - `PASSWORD=` Seafile account password. - - `SEAFILE_UID=` Downloaded files will have this uid. - - `SEAFILE_GID=` Downloaded files will have this gid. diff --git a/docker-compose.example.yml b/docker-compose.example.yml deleted file mode 100644 index 8383bd5..0000000 --- a/docker-compose.example.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: '3' - -services: - seafile-client: - restart: always - image: snegov/seafile-client - environment: - - LIBRARY_ID="79867cbf-2944-488d-9105-859463ecdf9e:test_library" - - SERVER_HOST=seafile.example.com - - USERNAME=user - - PASSWORD=password - - SEAFILE_UID=1000 - - SEAFILE_GID=100 - hostname: docker-seafcli - volumes: - - seafile-data:/seafile-client/seafile-data - - /home/johndow/seafile:/data - container_name: seafile-client - -volumes: - seafile-data: diff --git a/dsc/__init__.py b/dsc/__init__.py index 654de67..65a524a 100644 --- a/dsc/__init__.py +++ b/dsc/__init__.py @@ -1 +1 @@ -from .client import SeafileClient, start_seaf_daemon +from .client import SeafileClient, const diff --git a/dsc/client.py b/dsc/client.py index 62101f9..dd9ccfb 100644 --- a/dsc/client.py +++ b/dsc/client.py @@ -8,33 +8,40 @@ from urllib.parse import urlparse from cached_property import cached_property_with_ttl import requests -from dsc import consts +from dsc import const from dsc.misc import create_dir, hide_password _lg = logging.getLogger(__name__) class SeafileClient: - def __init__(self, host: str, user: str, passwd: str): + def __init__(self, + host: str, + user: str, + passwd: str, + app_dir: str = const.DEFAULT_APP_DIR): up = urlparse(requests.get(f"http://{host}").url) self.url = f"{up.scheme}://{up.netloc}" self.user = user self.password = passwd + self.app_dir = os.path.abspath(app_dir) self.__token = None def __str__(self): return f"SeafileClient({self.user}@{self.url})" + def __gen_cmd(self, cmd: str) -> list: + return ["su", "-", const.DEFAULT_USERNAME, "-c", cmd] + @property def token(self): if self.__token is None: url = f"{self.url}/api2/auth-token/" _lg.info("Fetching token: %s", url) - r = requests.post(url, data={"username": self.user, - "password": self.password}) + r = requests.post(url, data={"username": self.user, "password": self.password}) if r.status_code != 200: raise RuntimeError(f"Can't get token: {r.text}") - self.__token = r.json()['token'] + self.__token = r.json()["token"] return self.__token @cached_property_with_ttl(ttl=60) @@ -48,36 +55,77 @@ class SeafileClient: r_libs = {lib["id"]: lib["name"] for lib in r.json()} return r_libs + @property + def config_initialized(self) -> bool: + return os.path.isdir(os.path.join(self.app_dir, ".ccnet")) + + @property + def daemon_ready(self) -> bool: + cmd = "seaf-cli status" + _lg.info("Checking seafile daemon status: %s", cmd) + proc = subprocess.run( + self.__gen_cmd(cmd), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return proc.returncode == 0 + + def init_config(self): + if self.config_initialized: + return + cmd = "seaf-cli init -d %s" % self.app_dir + _lg.info("Initializing seafile config: %s", cmd) + subprocess.run(self.__gen_cmd(cmd)) + + def start_daemon(self): + cmd = "seaf-cli start" + _lg.info("Starting seafile daemon: %s", cmd) + subprocess.run(self.__gen_cmd(cmd)) + _lg.info("Waiting for seafile daemon to start") + + while True: + if self.daemon_ready: + break + time.sleep(5) + + _lg.info("Seafile daemon is ready") + def get_library_id(self, library) -> Optional[str]: for lib_id, lib_name in self.remote_libraries.items(): if library in (lib_id, lib_name): return lib_id return None - def sync_lib(self, lib_id: str, data_dir: str): + def sync_lib(self, lib_id: str, parent_dir: str = const.DEFAULT_LIBS_DIR): lib_name = self.remote_libraries[lib_id] - lib_dir = os.path.join(data_dir, lib_name.replace(' ', '_')) + lib_dir = os.path.join(parent_dir, lib_name.replace(" ", "_")) create_dir(lib_dir) - cmd = ['seaf-cli', 'sync', - '-l', lib_id, - '-s', self.url, - '-d', lib_dir, - '-u', self.user, - '-p', self.password] - _lg.info("Syncing library %s: %s", lib_name, ' '.join(hide_password(cmd, self.password))) - subprocess.run(['su', '-', consts.DEFAULT_USERNAME, '-c', ' '.join(cmd)]) + cmd = [ + "seaf-cli", + "sync", + "-l", lib_id, + "-s", self.url, + "-d", lib_dir, + "-u", self.user, + "-p", self.password, + ] + _lg.info( + "Syncing library %s: %s", lib_name, + " ".join(hide_password(cmd, self.password)), + ) + subprocess.run(self.__gen_cmd(" ".join(cmd))) def get_status(self): - cmd = 'seaf-cli status' + cmd = "seaf-cli status" _lg.debug("Fetching seafile client status: %s", cmd) - out = subprocess.check_output(['su', '-', consts.DEFAULT_USERNAME, '-c', cmd]) + out = subprocess.check_output(self.__gen_cmd(cmd)) out = out.decode().splitlines() statuses = dict() for line in out: - if line.startswith('#') or not line.strip(): + if line.startswith("#") or not line.strip(): continue - lib, status = line.split(sep='\t', maxsplit=1) + lib, status = line.split(sep="\t", maxsplit=1) lib = lib.strip() status = " ".join(status.split()) statuses[lib] = status @@ -86,7 +134,7 @@ class SeafileClient: def watch_status(self): prev_status = dict() while True: - time.sleep(consts.STATUS_POLL_PERIOD) + time.sleep(const.STATUS_POLL_PERIOD) cur_status = self.get_status() for folder, state in cur_status.items(): if state != prev_status.get(folder): @@ -94,38 +142,13 @@ class SeafileClient: prev_status[folder] = cur_status[folder] def get_local_libraries(self) -> set: - cmd = 'seaf-cli list' + cmd = "seaf-cli list" _lg.info("Listing local libraries: %s", cmd) - out = subprocess.check_output(['su', '-', consts.DEFAULT_USERNAME, '-c', cmd]) - out = out.decode().splitlines()[1:] # first line is a header + out = subprocess.check_output(self.__gen_cmd(cmd)) + out = out.decode().splitlines()[1:] # first line is a header local_libs = set() for line in out: lib_name, lib_id, lib_path = line.rsplit(maxsplit=3) local_libs.add(lib_id) return local_libs - - -def check_seaf_daemon_is_ready() -> bool: - cmd = 'seaf-cli status' - _lg.info("Checking seafile daemon status: %s", cmd) - proc = subprocess.run( - ['su', '-', consts.DEFAULT_USERNAME, '-c', cmd], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - return proc.returncode == 0 - - -def start_seaf_daemon(): - cmd = 'seaf-cli start' - _lg.info("Starting seafile daemon: %s", cmd) - subprocess.run(['su', '-', consts.DEFAULT_USERNAME, '-c', cmd]) - _lg.info("Waiting for seafile daemon to start") - - while True: - if check_seaf_daemon_is_ready(): - break - time.sleep(5) - - _lg.info("Seafile daemon is ready") diff --git a/dsc/const.py b/dsc/const.py new file mode 100644 index 0000000..66ffdef --- /dev/null +++ b/dsc/const.py @@ -0,0 +1,5 @@ +DEFAULT_APP_DIR = "/dsc" +DEFAULT_LIBS_DIR = "/dsc/seafile" +DEPRECATED_LIBS_DIR = "/data" +DEFAULT_USERNAME = "seafile" +STATUS_POLL_PERIOD = 1 diff --git a/dsc/consts.py b/dsc/consts.py deleted file mode 100644 index f75d56b..0000000 --- a/dsc/consts.py +++ /dev/null @@ -1,2 +0,0 @@ -DEFAULT_USERNAME = "seafile" -STATUS_POLL_PERIOD = 1 diff --git a/dsc/misc.py b/dsc/misc.py index d66b3a3..0e328af 100644 --- a/dsc/misc.py +++ b/dsc/misc.py @@ -2,15 +2,34 @@ import os import pwd import subprocess -from dsc.consts import DEFAULT_USERNAME +from dsc.const import DEFAULT_USERNAME def setup_uid(uid: int, gid: int): + """ + Set GID and UID of default user so that seafile client creates files with + correct permissions. + If GID does not match, create a new group with the given GID. + Then update UID and GID of default user to match the given ones. + """ user_pwinfo = pwd.getpwnam(DEFAULT_USERNAME) + create_group(gid) if user_pwinfo.pw_uid != uid or user_pwinfo.pw_gid != gid: subprocess.call(["usermod", "-o", "-u", str(uid), "-g", str(gid), DEFAULT_USERNAME]) +def create_group(gid: int): + """Search for a group with the given GID. If not found, create a new one.""" + if not os.path.exists(f"/etc/group"): + raise RuntimeError(f"File /etc/group does not exist") + with open("/etc/group", "r") as f: + for line in f.readlines(): + cur_gid = line.split(sep=":", maxsplit=3)[2] + if int(cur_gid) == gid: + return + subprocess.call(["groupadd", "-g", str(gid), DEFAULT_USERNAME + "-data"]) + + def create_dir(dir_path: str): if not os.path.exists(dir_path): os.mkdir(dir_path) diff --git a/start.py b/start.py index fbe4a30..6dddc16 100755 --- a/start.py +++ b/start.py @@ -6,7 +6,7 @@ import os import os.path import sys -from dsc import SeafileClient, start_seaf_daemon +from dsc import SeafileClient, const from dsc.misc import setup_uid, create_dir _lg = logging.getLogger('dsc') @@ -17,8 +17,7 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--uid", default=os.getenv("SEAFILE_UID", default=1000), type=int) - parser.add_argument("--gid", default=os.getenv("SEAFILE_GID", default=100), type=int) - parser.add_argument("--data-dir", default=os.getenv("DATA_DIR", default="/data")) + parser.add_argument("--gid", default=os.getenv("SEAFILE_GID", default=1000), type=int) parser.add_argument("--host", default=os.getenv("SERVER_HOST")) parser.add_argument("--username", default=os.getenv("USERNAME")) parser.add_argument("--password", default=os.getenv("PASSWORD")) @@ -26,12 +25,13 @@ def main(): args = parser.parse_args() setup_uid(args.uid, args.gid) - create_dir(args.data_dir) - start_seaf_daemon() + create_dir(const.DEFAULT_APP_DIR) + + client = SeafileClient(args.host, args.username, args.password, const.DEFAULT_APP_DIR) + client.init_config() + client.start_daemon() libs_to_sync = set() - - client = SeafileClient(args.host, args.username, args.password) for arg_lib in args.libs.split(sep=":"): lib_id = client.get_library_id(arg_lib) if lib_id: @@ -42,8 +42,17 @@ def main(): # don't start to sync libraries already in sync libs_to_sync -= client.get_local_libraries() + # check for deprecated /data directory + if os.path.isdir(const.DEPRECATED_LIBS_DIR): + _lg.warning("*** DEPRECATED DIRECTORY FOUND ***") + _lg.warning("Deprecated directory %s is found, please mount your host directory with" + " libraries to %s instead", const.DEPRECATED_LIBS_DIR, const.DEFAULT_LIBS_DIR) + libs_dir = const.DEPRECATED_LIBS_DIR + else: + libs_dir = const.DEFAULT_LIBS_DIR + for lib_id in libs_to_sync: - client.sync_lib(lib_id, args.data_dir) + client.sync_lib(lib_id, libs_dir) client.watch_status() return 0