diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml new file mode 100644 index 0000000..40a32a0 --- /dev/null +++ b/.github/workflows/ci_check.yml @@ -0,0 +1,55 @@ +name: "CICD - Development" +on: + push: + branches: + - devel/* + paths: + - container/** + - container/** + workflow_dispatch: + +jobs: + ############################################################ + # Base Image Check + ############################################################ + tc_docker: + name: "Development Build - tc_docker" + runs-on: ubuntu-latest + strategy: + matrix: + container: [ tc_docker ] + env: + CONTAINER: ${{ matrix.container }} + steps: + - name: Check out from ${{ github.ref }} + id: checkout + uses: actions/checkout@v3 + - name: Run pre-build hooks + id: hooks + run: | + $GITHUB_WORKSPACE/ci/hooks/pre_build + - name: Set tag + id: tag + run: | + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + [[ "${{ github.ref }} == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + [ "$VERSION" == "main" ] && VERSION="latest" + [ "$VERSION" == "latest" ] && VERSION="latest" + [ "$VERSION" == "devel" ] && VERSION="devel" + echo "::set-output name=VERSION::$(echo $VERSION)" + - name: Setup qemu environment + uses: docker/setup-qemu-action@v2 + - name: Setup buildx environment + uses: docker/setup-buildx-action@v2 + - name: Log into ghcr.io + id: docker_login + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USER }} + password: ${{ secrets.GHCR_TOKEN }} + - name: Build ${{ matrix.container }} + id: docker_push + uses: docker/build-push-action@v3 + with: + tags: rootwyrm/${{ matrix.container }}:${{ steps.tag.outputs.VERSION }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63b1ca9..3e274fb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ SYSTEMS.md todo dbt* */Dockerfile.test +vpn* diff --git a/container/tc_sonarr/Dockerfile b/container/tc_sonarr/Dockerfile index b0d0cf7..adf3961 100644 --- a/container/tc_sonarr/Dockerfile +++ b/container/tc_sonarr/Dockerfile @@ -7,7 +7,7 @@ # See /LICENSE for details ################################################################################ ARG TALECASTER_BASE=latest -FROM docker.io/rootwyrm/tc_mono:$TALECASTER_BASE +FROM docker.io/rootwyrm/tc_docker:$TALECASTER_BASE ## Labels LABEL maintainer="Phillip 'RootWyrm' Jaenke " \ @@ -17,14 +17,6 @@ LABEL maintainer="Phillip 'RootWyrm' Jaenke " \ com.rootwyrm.license="CC-BY-NC-4.0" \ com.rootwyrm.vcs-type="github" \ com.rootwyrm.vcs.url="%%GITHUB_REPOSITORY%%" \ - ## label-schema.org - org.label-schema.schema-version="1.0.0-rc1" \ - org.label-schema.vendor="RootWyrm" \ - org.label-schema.name="tc_sonarr" \ - org.label-schema.url="%%GITHUB_REPOSITORY%%" \ - org.label-schema.vcs-ref="%%VCS_REF%%" \ - org.label-schema.version="%%REF%%" \ - org.label-schema.build-date="%%RW_BUILDDATE%%" \ ## OCI org.opencontainers.image.authors="RootWyrm" \ org.opencontainers.image.vendor="RootWyrm" \ @@ -43,4 +35,4 @@ COPY [ "cron/", "/etc/periodic/" ] ## Volumes VOLUME [ "/talecaster/blackhole", "/talecaster/downloads", "/talecaster/television", "/talecaster/archive" ] -# vim:sw=4:ts=4 +# vim:sw=4:ts=4 \ No newline at end of file diff --git a/container/tc_sonarr/application/build/20.sonarr.sh b/container/tc_sonarr/application/build/20.sonarr.sh index f9d0cef..29ddc96 100755 --- a/container/tc_sonarr/application/build/20.sonarr.sh +++ b/container/tc_sonarr/application/build/20.sonarr.sh @@ -7,16 +7,30 @@ # Licensed under CC-BY-NC-4.0 # See /LICENSE for details ################################################################################ -## build/20.nzbget.sh +## build/20.sonarr.sh . /opt/talecaster/lib/talecaster.lib.sh export app_name="Sonarr" export app_url="http://www.sonarr.tv/" export app_destdir="/opt/Sonarr" -export BRANCH="phantom-develop" -export VERSION="3" -export APPURL="https://services.sonarr.tv/v1/download/main/latest?version=3&os=linux" +#https://services.sonarr.tv/v1/download/develop/latest?version=4&os=linux-musl&arch=arm64 +export OSARCH="linux-musl" +export ARCH=$(uname -m) +case $ARCH in + x86*) + export ARCH="x64" + ;; + aarch64*) + export ARCH="arm64" + ;; + *) + echo "Unsupported architecture!" + exit 255 + ;; +esac +export VERSION="4" +export APPURL='https://services.sonarr.tv/v1/download/develop/latest?version='${VERSION}'&os='${OSARCH}'&arch='${ARCH}'' ###################################################################### ## Application Install @@ -44,4 +58,4 @@ echo "Entering $0" load_config LOG "[BUILD] Installing ${app_name}" -application_install +application_install \ No newline at end of file diff --git a/container/tc_sonarr/application/sonarr.version b/container/tc_sonarr/application/sonarr.version index 056324b..342fdb8 100644 --- a/container/tc_sonarr/application/sonarr.version +++ b/container/tc_sonarr/application/sonarr.version @@ -1 +1 @@ -3.0.7.1477 +4.0.0.443 \ No newline at end of file diff --git a/packages.json b/packages.json deleted file mode 100644 index 961827f..0000000 --- a/packages.json +++ /dev/null @@ -1,4 +0,0 @@ -{ "dependencies" : - { "git+https://SickRage/SickRage.git" - } -} diff --git a/python/TaleCaster.py b/python/TaleCaster.py new file mode 100644 index 0000000..bb50886 --- /dev/null +++ b/python/TaleCaster.py @@ -0,0 +1,168 @@ +import os +import sys +import subprocess +import threading +import rich + +## TaleCasterApplication object +class TaleCasterApplication(object): + def indirect(self,service): + method_name=service + method=getattr(self,method_name,lambda :'Invalid') + return method() + + ## Only supports Sonarr + def television(self): + self.application = 'television' + self.application_method = 'runtime' + self.application_bin = '/opt/Sonarr/Sonarr.exe' + self.application_args = [ f'-appdata={self.application_configdir}', f'-data={self.application_configdir}', '-nobrowser' ] + ## Only supports Radarr + def movies(self): + self.application = 'movies' + self.application_method = 'runtime' + self.application_bin = '/opt/Radarr/Radarr.exe' + self.application_args = [ f'-appdata={self.application_configdir}', f'-data={self.application_configdir}', '-nobrowser' ] + ## Only supports Lidarr + def music(self): + self.application = 'music' + self.application_method = 'runtime' + self.application_bin = '/opt/Lidarr/Lidarr.exe' + self.application_args = [ f'-appdata={self.application_configdir}', f'-data={self.application_configdir}', '-nobrowser' ] + ## Only supports Readarr + def books(self): + self.application = 'books' + self.application_method = 'runtime' + self.application_bin = '/opt/Readarr/Readarr.exe' + self.application_args = [ f'-appdata={self.application_configdir}', f'-data={self.application_configdir}', '-nobrowser' ] + ## Only supports Mylar (python) + def comics(self): + self.application = 'comics' + self.application_method = 'python' + self.application_bin = '/opt/mylar/mylar.py' + self.application_args = '' + self.python_venv_use = True + self.python_venv_dir = '/opt/talecaster/venv' + ## Only supports Prowlarr + def indexer(self): + self.application = 'indexer' + self.application_method = 'runtime' + self.application_bin = '/opt/Prowlarr/Prowlarr' + self.application_config = '/talecaster/config/config.xml' + self.application_configdir = '/talecaster/config' + self.application_pidfile = '/talecaster/config/prowlarr.pid' + self.application_args = [ f'-appdata={self.application_configdir}', f'-data={self.application_configdir}', '-nobrowser' ] + ## Uses nginx always + def frontend(self): + self.application = 'frontend' + self.application_method = 'direct' + self.application_bin = '/usr/sbin/nginx' + self.application_config = '/etc/nginx/nginx.conf' + self.application_args = [ f'-c {self.application_config}' ] + self.application_selftest = os.system(["", self.application_bin, self.application_args, '-t', '-q' ]) + + def nntp(self): + self.application = 'nntp' + self.application_method = 'direct' + self.application_bin = '/opt/nzbget/nzbget' + self.application_config = '/talecaster/config/nzbget.conf' + self.application_args = [ f"-c {self.application_config}" ] + + ## The torrent stuff is more complicated... + def qbittorrent(self): + self.application = 'torrent' + self.application_method = 'direct' + self.application_bin = '/usr/local/bin/qbittorrent-nox' + self.application_configdir = '/talecaster/config' + self.application_args = [ f'--profile={self.application_configdir}' ] + + ## XXX: Not Yet Implemented + def transmission(self): + self.application = 'torrent' + self.application_real = 'transmission' + self.application_method = 'direct' + self.application_bin = '/usr/local/bin/transmission' + self.application_configdir = '/talecaster/config' + + ## XXX: Not Yet Implemented + ## need to set default UI to web before running + ## --config /talecaster/config + def deluge(self): + self.application = 'torrent' + self.application_real = 'deluge' + self.application_method = 'direct' + self.application_bin = '/usr/local/bin/deluge' + self.application_configdir = '/talecaster/config' + +class TaleCaster_run_service(threading.Thread): + def __init__(self, service, queue): + super().__init__() + + self.queue = queue + self.service = service + global app + app=TaleCasterApplication() + app.indirect(service) + + method = (app.application_method) + #if app.application_selftest is not None: + # selftest = (app.application_selftest) + print(f"service {self.service}") + self.run() + + def run(self): + print("enter run") + if app.application_method == 'runtime': + print("os chdir") + os.chdir(app.application_configdir) + os.setgid(int(os.environ["tcgid"])) + os.setuid(int(os.environ["tcuid"])) + service_process = subprocess.Popen(executable=app.application_bin, args=app.application_args, shell=False)#, stdout=subprocess.STDOUT, stderr=subprocess.STDOUT) + try: + self.queue.put(service_process.communicate()) + except OSError: + rich.print(prefix_service, "failed to start service {app.application}!") + os._exit(service_process.returncode) + +class TaleCaster_run_openvpn(threading.Thread): + def __init__(self, openvpn_config, queue): + super().__init__() + + self.openvpn_config = openvpn_config + self.queue = queue + + global prefix_openvpn + prefix_openvpn = "[[bold dark_orange]OpenVPN[/]]" + ## Test for /dev/tun during __init__ to bail out quickly + try: + open("/dev/net/tun", "r") + except: + rich.print(prefix_openvpn, "[bold red]FATAL:[/] unable to open /dev/tun, check compose configuration.") + ## Bail out quickly + os._exit(100) + + self.run() + + ## This check should be simplified and before calling class. + def run(self): + message_prefix = "[[bold dark_orange]OpenVPN[/]]" + ovpn_pidfile = "/run/openvpn.pid" + openvpn_cmd = f'/usr/sbin/openvpn --config {self.openvpn_config} --log /var/log/openvpn.log --cd /talecaster/shared' + + ## XXX: needs /dev/tun check + if self.openvpn_config is None: + rich.print(prefix_openvpn, "[bold red]FATAL:[/] openvpn_config is undefined!") + os._exit(200) + try: + ## Always run as root in the shared directory + os.chdir("/talecaster/shared") + os.setgid(0) + os.setuid(0) + ## Has to be done this way; if args aren't a single string, OpenVPN bails out. + openvpn_process = subprocess.Popen(openvpn_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, user="root", group="root") + rich.print(prefix_openvpn, "running as PID", openvpn_process.pid) + self.queue.put(openvpn_process.pid) + self.queue.put(openvpn_process.communicate()) + except: + rich.print(prefix_openvpn, "[bold red]FATAL:[/] OpenVPN failed to start!") + os._exit(100) \ No newline at end of file diff --git a/python/entrypoint.py b/python/entrypoint.py new file mode 100644 index 0000000..a61a5d2 --- /dev/null +++ b/python/entrypoint.py @@ -0,0 +1,153 @@ +################################################################################ +# TaleCaster - https://github.com/rootwyrm/talecaster +# Copyright (C) 2015-* its contributors, all rights reserved +# +# Licensed under CC-BY-NC-4.0 +# See /LICENSE for details +################################################################################ + +import TaleCaster +import os +import sys +import time +import threading +import subprocess +import concurrent.futures +import logging +import queue +import rich +from subprocess import PIPE, TimeoutExpired +from rich.logging import RichHandler +from rich.pretty import pprint +from rich.console import Console + +## Run firstboot operations +def firstboot(): + ## XXX: single-step for now to ease debugging + if os.environ["tcuser"] is None: + print("tcuser undefined!") + os._exit(99) + if os.environ["tcuid"] is None: + print("tcuid is undefined!") + os._exit(99) + if os.environ["tcgroup"] is None: + print("tcgroup is undefined!") + os._exit(99) + if os.environ["tcgid"] is None: + print("tcgid is undefined") + os._exit(99) + #endif + + ## User creation + tcuser=str(os.environ["tcuser"]) + tcgroup=str(os.environ["tcgroup"]) + tcuid=int(os.environ["tcuid"]) + tcgid=int(os.environ["tcgid"]) + createuser(tcuser,tcuid,tcgroup,tcgid) + permission_repair(tcuid,tcgid) + +## NYI: needs existing user check and improved error handling +def createuser(tcuser,tcuid,tcgroup,tcgid): + ## Do group first + print("Adding group %s with gid %s" % (tcgroup, tcgid)) + cgroup = os.system(f"/usr/sbin/addgroup -g {tcgid} {tcgroup}") + if cgroup != 0: + print("Error creating group %s" % tcgroup) + print("Adding user %s with uid %s" % (tcuser, tcuid)) + cuser = os.system(f"/usr/sbin/adduser -h /home/{tcuser} -g 'TaleCaster User' -u {tcuid} -G {tcgroup} -D -s /bin/bash {tcuser}") + if cuser != 0: + print("Error creating user %s" % tcuser) + +## Repair permissions on static directories +## XXX: Missing permission repair on service storage directory +def permission_repair(tcuid,tcgid): + for path in [ '/talecaster/config', '/talecaster/shared', '/talecaster/blackhole', '/talecaster/downloads' ]: + print("Setting ownership on %s" % path) + for dirpath, dirnames, filenames in os.walk(path): + os.chown(dirpath, uid=tcuid, gid=tcgid) + for filename in filenames: + os.chown(os.path.join(dirpath, filename), uid=tcuid, gid=tcgid) + +## XXX: Reserved for Dragon North MediaBox +def dragonnorth_mediabox(): + ## XXX: RESERVED: BD-ROM device check and init + ## XXX: RESERVED: BD-ROM device key update + return None + +def __main__(): + ## Constants + global prefix_openvpn + global prefix_talecaster + prefix_openvpn = "[[bold dark_orange]OpenVPN[/]]" + prefix_talecaster = "[[bold dark_blue]TaleCaster[/]]" + openvpn_queue = queue.Queue() + service_queue = queue.Queue() + + global console + console = Console() + + ## Perform firstboot + firstboot_check = os.path.exists('/firstboot') + if firstboot_check is True: + rich.print(prefix_talecaster, "Beginning firstboot procedures...") + firstboot() + + try: + global service + service = open('/opt/talecaster/id.provides', 'r').read() + global prefix_service + prefix_service = f"[[bold green]]{service}[/]]" + except: + rich.print(prefix_talecaster, "[bold red]FATAL:[/] No application defined!") + os._exit(255) + ## Fix the service by removing newline internally. It needs to remain in the original + service = service.replace('\n','') + + ## OpenVPN + ## Enable around the _CONFIG being non-null + openvpn_enable = f"{str.upper(service)}_VPN_CONFIG" + if openvpn_enable not in os.environ: + rich.print(prefix_openvpn, "OpenVPN not enabled.") + pass + else: + import netifaces + from netifaces import AF_LINK, AF_INET + rich.print(prefix_openvpn, "using configuration", os.environ[openvpn_enable]) + openvpn_config = os.environ[openvpn_enable] + thread_openvpn = threading.Thread(target=TaleCaster.TaleCaster_run_openvpn, args=(openvpn_config, openvpn_queue,), daemon=False, name="openvpn") + thread_openvpn.start() + ## Wait for OpenVPN to be up before starting applications. + while thread_openvpn.is_alive() is not True: + pprint(thread_openvpn) + pprint(thread_openvpn.is_alive()) + rich.print(prefix_openvpn, "connection still starting...") + time.sleep(1) + ## Give OpenVPN time to settle. + vpn_active = None + while vpn_active is None: + if 'tun0' not in netifaces.interfaces(): + rich.print(prefix_openvpn, "tun0 not ready yet.") + time.sleep(2) + else: + vpn_active = True + + print(TaleCaster) + app = TaleCaster.TaleCasterApplication() + app.indirect(service) + + ## Service + thread_service = threading.Thread(target=TaleCaster.TaleCaster_run_service, args=(service, service_queue,), daemon=False, name="service") + try: + thread_service.start() + except: + rich.print(prefix_talecaster, "[bold red]FATAL:[/] Unable to start service thread!") + os._exit(2) + + print("get from queue") + ## XXX: Needs to be a log handler for prefixing, probably + while not service_queue.empty(): + prefix_service = "[[bold green] {service}[/]]" + service_msg = service_queue.get() + rich.print(prefix_service, service_msg) + +__main__() \ No newline at end of file