From 878de447aeeba8803a0ca1aec38e2338614260a5 Mon Sep 17 00:00:00 2001 From: craisin Date: Wed, 31 Dec 2025 20:06:55 -0800 Subject: [PATCH] Initial Commit --- .gitignore | 3 + Dockerfile | 8 + composer/__pycache__/agent.cpython-312.pyc | Bin 0 -> 11397 bytes composer/agent.py | 174 +++++++++++++++++++++ composer/entrypoint.py | 10 ++ composer/requirements.txt | 2 + 6 files changed, 197 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 composer/__pycache__/agent.cpython-312.pyc create mode 100644 composer/agent.py create mode 100644 composer/entrypoint.py create mode 100644 composer/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..522b986 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +store +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddfc87b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM docker +WORKDIR / +COPY composer /composer +COPY stack /stack +RUN apk update +RUN apk add --no-cache git python3 py3-pip +RUN pip3 install -r composer/requirements.txt +ENTRYPOINT ['python3', 'composer/entrypoint.py'] \ No newline at end of file diff --git a/composer/__pycache__/agent.cpython-312.pyc b/composer/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2167aca39253e54c932c16205121c153ec172c36 GIT binary patch literal 11397 zcmbtaTWl0pnm$#1tL`q_ZMU%-yJNTAfN5-O#?C^(fegkZfQf^`CBY%n>2wu#n|8NT zRR-Lch>4;d7-g3*`(UHd2qHzBM2R%yDa^|bxo9`iN~E+z5`hRz zmP{}s41;Ml%Z{)RyRxnc_lTS3xwD=L?}(SCJz3ubH^R}hH|w7Wj0B49f+IoNo*&^^ zBC_L6)_|p0@O=nlVi>6wtH)c-bUB4~vGU($+G@oRXcd37oQ}8Q(R`5~-}Gp%)tePF zgeA`qfqRn({tsER{zyH|3qW4b&Ra+Gc*v`=^BShvMD?GrQGzkt&x@1!VMusKMMcb= z#jGPKWej3|FrPjnO8c@IF{i-q8uR!5^J#HX$>ehdJ~av@TeJDmQBg{l&$>7zaz6^W zvqU5#j6g2(9rpaGv=UuB^oG|5WtE@SdV!<~D0Hv>9m~kETwaOEqS6(Urzf(R+!>mYmc*1I3Wb2I z?pYPIL7+lo5)g1YZTHG1-1XylJp;r3gv ztJ|NxBd9|^Q4b~6=qvX^sc)BA1_y*`9KLc`ZQgl%>+M!`{lVYyhaSpxFzbKbTQ{(d ze7dt4=x6Ji27crkPIwG1nargo#AMP4B$E?)VJeGhKAC)NDwQpk@UoIpGU*9X8OsaO zI_T>Iavvy>>X9J#QBn;FxdF(VeN4hU7w}e%I`Sv-&H= z<9sP?rN;3qRfO@{iVXNEl!-beY?TYlC?j}AnUB1dc!!lxg8*y5O7KKfgmF9)6=57q zhsq%MKH{u33x2F11kUV$v$amK75?~Rm93yw&}zlt3>;Ly^+joU+oL_KDvv)}F7Txh z%I31sp=MR7<}^uE4;LCuQ41`TK4VFqZV;@6P8O^|YCa1Dl z30chW3e@~De8pWbyrveA@bDY%b15ljz+R^$B{?+-ibYJ0=U+8KNlUmFCkE52Un#)WSe|DH6TmzwQT35!rTm@U4T3d`Rb`8Xuh- zxyNt*V%4{}sQ^t=G8qIv3Npn9gm~xQ#c*7Py|JhW5qg$JM64TWtN2 z+S2>L<7y0CcE1%|<_I6U+JB{A54LE*7CpF83vRr%K@D!45AL|_)q+pm*>N{`$W~#} z0D!bJ6i5|QQ(Ypk zHgiW?HLmq0_kR2y*KIDvQcO@6Owyt%X@}gkmNH0Q2H|s&abD;QgCfmoxh3)QR+}5Q z$yNBY#$#>bkPFsDEonZ&bRVK}5NSb{aL@CUbQG(VOGt3NF({z>&iUTb1qO$xa z_N`Vo^nfc(T8-f|rCEmNo+BSH!wH|kSxj$u(pj{;wnAm88%Ylk!*|r=JunnguZmeY z;W9s@uh`D0vN}i#nY3crd8XCT3q2Ipmp*O|BS&x9j|7jo*Ba-?Hq2vOfS92CoP8NT(L*)FZuGq<22@xE|T9MRtRgH>&(D zGNqnM&o#c=@qWjx-FF(bzJq$-5v}it`uvIc_7}AEFWikxU3ULN)&D^(qj37Pt$l!Z zr^`_Kg$8Hii`2OgYVNAkt1i_-y&AN~h7u zcoE#?`(=$m(U~LHK#BKP9;+ThPLj8Ne$K*CzG1_A{0M-k{eQv_B;n2$Hd`kuHkOh> zbHy^bv#D%Gh?zBG@MB7h`N{@+Xp#xkxD@xOb zuh_Z43VFk8c3PB%(gA2uuCAmX;qpohQ>hVujP>sT0gcrVoqO@-Yx50>%fV&V<3G+W zwYJ~vpKtBCJh;F|0o>@}xE7AzOx+E4sCL58dN_{OtoDscn5Xpw@J*5Ei;#Y>m-gm-w&=T=sqO^p{V}4<1wZ z9aoQ^SEnz21M_AMv0t0fG9lB<5DU=|dlaLlW9%*cDM+Si2 z-L&`K@6o&M0x69Mt!?%x<$^U{cEssu-tRq{tprN_{%xZvwyGGng-xjsaActKg!`54 zuqg_JkW~kSK>J0utQQ+=ST(@H_fzMKjlqzS1#*#not-Ym=NOx<1H%$pU&j(|!}ID? zCM$r2D1!ApW_Vtk%7}_ljrQDGF`1Vp$5J`ja0~f!xepi#trWC0DQ7&aJ)Q&h22TUn zuGA=uJ#3C`jZMiSFgmb4wCO@KxLCgs2bTer1jX7QBGN^xFm{@{OLoN)fie|{lax*% zi6eOt3GOB3bqT+|!KI}P0Pj@xgQ8~inw?9yd#lx9ALJCmmhkE>*|LYQssZE@S!ToF zAy}+gH&_4j*B3+e=xNYHaV-?rLz}eFrp2a~#pYI|@s4FbsqOeP@zmBW^Q0~^xBYHy zTSb%2T4?iPOZ$z?^~~H$%Pvxz_%mUlz8CB7yj#1WqJF0q>ReU7i`ECxQQK%aJ-qLq zS9d(GMvvYL9s72vrUjL@>C|}c%5!SV-a8xbG^){qI)6yx5B&wz_7L+>2F4|CbPwz# zA8!Zp>1L##wXR3H)4y+n>vJ};uhsQ=s|V7nlri!sl>>8B4tx*pEOIxrx5HY7pQGvK zz{gHr0Kr!MfL+%xEFEQ&!~7_gei7E_;BYLNpiu6_$7Q^y*t|RL#H+3!wu`IXp3?-Z zfA_G2pq{dIAv!FcvpB1WUj=b8o1YeiHe@h73x<0P9F_1L9r%nM}m0BTs_(X<`&@M zBy_Ks-Z|>fLm!KTqz(RLYy}Rzn%YYTR@@=a?v)LUr)S0M_MBx_c+RtzS*fo_M_-rM zbA&N_!M17mvxB8Q@8dj5K0bi~A0K-6+|;}0q24_&_3rtocaIY|!S^NuUOpz_m-ay8 z;dJ@Q#(pXPqVxn}Z&QbGgcRcF?aD7eGyWlxZUMLk9_uDW;!-wBemh z;VGdeRmqr3$!8L7dX7p}B?0Qo*cmveO}_#C7KKm|``8|XELOzB3kRw`0;t`;*bqhV zZbi+ldh<4|dE0z*?`8KrF7hZy#hswLaS9S^fz(S3TwGu+8l0de@ULyITlI*24TJjI z+z~gmF{Q&$#|WfTxwM!?*NP-U2B=fSZ3059f_f0v9IVlU&04To z4aUL1T;f7H*Q{~PbAx)cON(~h$FrSTn#-2 ztbR%{m|nJkworxy=HF4c})+ut@-g0gO4~`=sI}7H`>gzqHk0MYf zgtejN{W^bGfh`BUYu4ETpJJfo%XacrJGZrF;skV^JlW8T?Xia4EC#EWqrgD`iERr& zu@4r7#6O{4YO7IM{MYtevGux>k6%E&Y-{dPw_u&Sp#()#MKHfY5nMa469w1ywL1mZ z|6*I=zTrZRW&d`YD$@kmT=5jg3W-XHT~WV;wWa+qHR)*}iE5K|4qyh#V(A$q2ay~? zGK6Fsk~k6+PSUeThLJpngsQv>Ml;cI#Nsdt0m|IP1!yPNgD?X@)!cfc|9ZdP+^eA= zUf?2&!RSr*d%<^tTCfw~a@7TPDZc5Z^4^7aE~tHjTF3tR_|tE@7eX;`M490D{kFS} zooc94YwV=1SyS@Oy>=I_5yQ>1d#>%dxj}F5)!KXK!+mDS}CwD>MM=2EC}mcPc&oqyl`YySuS zn-|pHm()|Q=%+@tQ={tGw05eXzI0(3olHqY)31Q^`Uay(1l$-wW|Dmkqu1DJj9Ll^ zxN9#U;D#|x!2MzOKo|L}Ywwo9^{y`>4A51887RA36hD0F>tNx6MGYF9jX1 z0@niZwucZv{2+k%QZEjELIClOUj)QRpz>|SclT8OcH=u70YS?V;E|q(4Uvu_IfjIq z#K$pp0twa86n~$@pCdpD%@uGS9xzyUSh0e?gy@46s@&A|RQAxm9bmom5{`m4nv5)A z+l3Uu+e*HK;D3fS;aL<2pJjHPKVAvo+qF=;9@?UXw&+--7K~e;m;bh4wpdZ}@HEH;LQLcMg6j ze))nLJ-)!7D3;Fc*ZFqv*Z&1weDQ}e!nil~y7$G&r@p-eqR%)QeePcmKR%E92e-LC z@7guk>H4D61L;+w$oBpH7i{S)-0b{7ws#UhX#F?}pO0)8|M$`Ij)JciHlZTGv#Yg7 z_E^_9@c4+g8ag6)uk-9zBXg+Zz~qqIX!Tu+EnH<;Kt&&*ZTEoq*tBX^P96xfUmW%n z?U!=zB0iKVvqFx6KiuZ|LZb9=iMj^CjfYR?Vwnke3KUz#<1OIqGeHf!o*D&@L-E~F z(ajG}mFhF2Ie0~t#H;8ey7=I(TrL|dbevu{+jrkFxC0PiQ%ZKfDVM0Qz*AJ_{=*Ya zZ=p?;o`s*5V;?2TqyC9%%XBeqts7YBUxA>^@cAR?P=MD-I^Ux4EjqtR<2UJim&SK3 zHZ*~}xRJP?m}_44kgBZ@h^wj^WQ4y8uXc5=N#mMyu3h8Wb*@9>Iu_eDg2Mxvp#$UX zm%#Pl-~52M{ecGo;;*6C!3}8rOusXI^Nd>GyTJ9KH-ugp->A7>GapIZxmyp^S(9jVA|olh1Z{?dx)V-pK$>FvlKscZg8a zbax=&lyK*$aksX+1KkSLw?J*eP4Elc9d!+thF08ePu~jf@^r5R7|+YhiZ|$~UukOa z#2;*{@pPKL4s4xhG+gk2gFa%Q7O27IW%ItrhcDE!nODu%4x!0ucte#-!vAw)DR{eK zK4ECC%|xt zC>d2p6}Ty%uzrI3SjbN)=8G+K($HmBxPKmUPnz$*_DC0@3L<#2jm)Z_@ltb9?6fTP}GQiT7tUKdpJ&H(PbBYJs%C zZ=KX?q*kr#c)P=Nl)dlzb>M@*d|lrHdHfr9Ei=S?O^|-GX+Ogpfgd#bW|)1NVc@z5 IDc$t{2cx*Gga7~l literal 0 HcmV?d00001 diff --git a/composer/agent.py b/composer/agent.py new file mode 100644 index 0000000..ae9644e --- /dev/null +++ b/composer/agent.py @@ -0,0 +1,174 @@ +from git import Repo +import logging +from os import getenv +from pathlib import Path +from python_on_whales import DockerClient +from python_on_whales.exceptions import DockerException +import schedule +import sys + +logger = logging.getLogger("agent-logger") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler(sys.stdout)) + +class Setup: + + @staticmethod + def create_symlink(): + host_data_env = getenv("HOST_DATA_PATH", None) + if host_data_env is None: + logger.info(f"HOST_DATA_PATH is not set, symlink not created") + return None + try: + host_data_path = Path(host_data_env) + container_data_path = Path("store/data") + if not host_data_path.is_symlink(): + host_data_path.symlink_to(container_data_path) + logger.info(f"Created symlink: {host_data_path} -> {container_data_path}") + else: + logger.info(f"Symlink already exists: {host_data_path} -> {container_data_path}") + except: + logger.error(f"Failed to create symlink from {host_data_env}") + +class Composer: + + services = [] + + def __init__(self, remote_url): + if (not Path("store/stack/compose.yml").exists()) or (not Path("store/stack/.git").exists()): + self.repo = Repo.init("store/stack") + self._add_remote(remote_url) + else: + self.repo = Repo("store/stack") + self.repo.remotes.origin.pull('master') + self.docker = DockerClient( + compose_files=["store/stack/compose.yml"], + compose_env_files=["store/stack/.env"] + ) + if self.docker.compose.config(return_json=True)["name"] != "stack": + logger.warn(f"Composer stack name is wrong, either make sure it is set manually or keep it store/stack") + self.start_update_job() + self._create_services() + self.set_status("up") + logger.info("Composer started and services created") + + def _add_remote(self, url): + self.repo.create_remote('origin', url) + + def _create_services(self): + for service in self.services: + self.services[service].close() + services_dict = self.docker.compose.config().services + self.services = {service: Service(service, services_dict[service].labels, self) for service in services_dict} + + def start_update_job(self): + update_time = getenv("UPDATE", 1) + try: + self.update_time = int(update_time) + assert self.update_time > 0 + except (AssertionError, ValueError): + self.update_time = 1 + logging.warn(f"Composer has an invalid UPDATE value of {getenv("UPDATE")}, defaulting to update interval of {self.update_time} minutes") + schedule.every(self.update_time).minutes.do(self.update) + + def set_status(self, status): + try: + if status == "up": + self.docker.compose.up( + build=True, + detach=True, + quiet=True, + remove_orphans=True + ) + elif status == "down": + self.docker.compose.down( + services=[self.name] + ) + elif status == "restart": + self.docker.compose.restart( + services=[self.name] + ) + else: + logger.warn(f"Undefined status {status} - use up, down, or restart") + except DockerException as error: + logger.critical(f"Setting status of composer failed: {error}") + + def update(self): + current = self.repo.head.commit + self.repo.remotes.origin.pull('master') + if current != self.repo.head.commit: + self._create_services() + self.set_status("up") + logger.info(f"New compose stack deployed! Commit: {self.repo.head.commit}") + +class Service: + + update_task = None + + def __init__(self, name, labels, parent): + self.name = name + self.labels = labels + self.parent = parent + self.update_task = self.start_update_job() + + def close(self): + if self.update_time is None: + return None + schedule.cancel_job(self.update_task) + + def get_status(self): + return "up" if bool(self.parent.docker.compose.ps(services=[self.name])) else "down" + + def start_update_job(self): + self.update_time = self.parent.update_time + if 'composer.update' in self.labels: + try: + self.update_time = int(self.labels['composer.update']) + assert self.update_time > 0 + except (AssertionError, ValueError): + self.update_time = self.parent.update_time + logging.warn(f"Service {self.name} has an invalid composer.update value of {self.labels['composer.update']}, defaulting to update interval of {self.update_time} minutes") + if self.update_time != 0: + return schedule.every(self.update_time).minutes.do(self.update) + return None + + def set_status(self, status): + try: + if status == "up": + self.parent.docker.compose.up( + build=True, + detach=True, + quiet=True, + services=[self.name], + remove_orphans=True + ) + elif status == "down": + self.parent.docker.compose.down( + services=[self.name] + ) + elif status == "restart": + self.parent.docker.compose.restart( + services=[self.name] + ) + else: + logger.warn(f"Undefined status {status} - use up, down, or restart") + except DockerException as error: + logger.critical(f"Setting status for service {self.name} failed: {error}") + logger.critical(error) + + def update(self, restart=True): + try: + self.parent.docker.compose.build( + quiet=True, + services=[self.name] + ) + except DockerException: + logger.warn(f"Failed to build docker image for service {self.name} - cancelling container update") + return None + self.parent.docker.compose.pull( + ignore_pull_failures=True, + quiet=True, + services=[self.name] + ) + logger.info(f"Updated service {self.name}") + self.set_status(self.get_status()) \ No newline at end of file diff --git a/composer/entrypoint.py b/composer/entrypoint.py new file mode 100644 index 0000000..0bd2674 --- /dev/null +++ b/composer/entrypoint.py @@ -0,0 +1,10 @@ +from agent import * +import logging +from os import getenv +import time + +Setup.create_symlink() +composer = Composer(getenv("REMOTE_REPO")) +while True: + schedule.run_pending() + time.sleep(1) \ No newline at end of file diff --git a/composer/requirements.txt b/composer/requirements.txt new file mode 100644 index 0000000..3f03705 --- /dev/null +++ b/composer/requirements.txt @@ -0,0 +1,2 @@ +python-on-whales +GitPython \ No newline at end of file