Skip to content

Commit 9f2ca75

Browse files
committed
First commit
0 parents  commit 9f2ca75

15 files changed

+1195
-0
lines changed

.gitignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.egg-info/
6+
dist/
7+
logfile.log
8+
.idea
9+
build/
10+
dist/

LICENSE

+661
Large diffs are not rendered by default.

README.md

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# DevStroke ZDD
2+
3+
Zero Downtime Deployment system with Docker
4+
5+
## Installation
6+
7+
### Pythonic way
8+
9+
```sh
10+
pip install https://github.com/devstroke-io/zdd/archive/master.zip
11+
```
12+
13+
### Download binary
14+
15+
@TODO
16+
17+
## Usage
18+
19+
```sh
20+
Usage: zdd [OPTIONS] PROJECT [TAG]
21+
22+
Deploy PROJECT with TAG (default 'latest')
23+
24+
Options:
25+
-V, --version Show the version and exit.
26+
-q, --quiet no output
27+
-v, --verbose verbosity level
28+
-h, --help Show this message and exit.
29+
```
30+
31+
## Build binary
32+
33+
```sh
34+
pyinstaller --onefile zdd.py
35+
```
36+
37+
Produce binary `dist/zdd`

setup.py

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from os.path import dirname, join
2+
3+
from setuptools import find_packages, setup
4+
5+
KEYWORDS = []
6+
CLASSIFIERS = [
7+
'Intended Audience :: Developers',
8+
'Programming Language :: Python :: 3.7',
9+
'Programming Language :: Python :: Implementation :: CPython',
10+
'Programming Language :: Python',
11+
'Topic :: Software Development',
12+
'Topic :: Utilities',
13+
]
14+
INSTALL_REQUIRES = [
15+
'click==7.*',
16+
'colorama==0.4.*',
17+
'docker==3.*',
18+
'pyinstaller==3.*',
19+
]
20+
21+
PROJECT_DIR = dirname(__file__)
22+
README_FILE = join(PROJECT_DIR, 'README.md')
23+
ABOUT_FILE = join(PROJECT_DIR, 'src', 'zdd', '__about__.py')
24+
25+
26+
def get_readme():
27+
with open(README_FILE) as fileobj:
28+
return fileobj.read()
29+
30+
31+
def get_about():
32+
about = {}
33+
with open(ABOUT_FILE) as fileobj:
34+
exec(fileobj.read(), about)
35+
return about
36+
37+
38+
ABOUT = get_about()
39+
40+
setup(
41+
name=ABOUT['__title__'],
42+
version=ABOUT['__version__'],
43+
description=ABOUT['__summary__'],
44+
long_description=get_readme(),
45+
author=ABOUT['__author__'],
46+
author_email=ABOUT['__email__'],
47+
url=ABOUT['__uri__'],
48+
keywords=KEYWORDS,
49+
license=ABOUT['__license__'],
50+
classifiers=CLASSIFIERS,
51+
package_dir={'': 'src'},
52+
packages=find_packages('src'),
53+
entry_points={
54+
'console_scripts': [
55+
'zdd=zdd.__main__:main',
56+
],
57+
},
58+
install_requires=INSTALL_REQUIRES,
59+
python_requires='>=3.7',
60+
zip_safe=False
61+
)

src/zdd/__about__.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Project metadata."""
2+
3+
__version__ = '1.0.0'
4+
5+
__title__ = 'zdd'
6+
__summary__ = "TODO"
7+
__uri__ = 'https://gitlabs.mybestpro/MyBestProLabs/JTChatBot/TODO'
8+
9+
__author__ = "Damien JARRY"
10+
__email__ = '[email protected]'
11+
12+
__copyright__ = "2018, DevStroke.io"
13+
__license__ = "AGPL-3.0-only"

src/zdd/__init__.py

Whitespace-only changes.

src/zdd/__main__.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import signal
2+
import sys
3+
4+
import click
5+
from colorama import Fore, Style
6+
7+
from .__about__ import __version__
8+
from .services.deploy import Deploy
9+
from .services.logger import Logger
10+
11+
logo = """
12+
███████╗██████╗ ██████╗
13+
╚══███╔╝██╔══██╗██╔══██╗
14+
███╔╝ ██║ ██║██║ ██║
15+
███╔╝ ██║ ██║██║ ██║
16+
███████╗██████╔╝██████╔╝
17+
╚══════╝╚═════╝ ╚═════╝
18+
██████╗ ███████╗██╗ ██╗███████╗████████╗██████╗ ██████╗ ██╗ ██╗███████╗
19+
██╔══██╗██╔════╝██║ ██║██╔════╝╚══██╔══╝██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝
20+
██║ ██║█████╗ ██║ ██║███████╗ ██║ ██████╔╝██║ ██║█████╔╝ █████╗
21+
██║ ██║██╔══╝ ╚██╗ ██╔╝╚════██║ ██║ ██╔══██╗██║ ██║██╔═██╗ ██╔══╝
22+
██████╔╝███████╗ ╚████╔╝ ███████║ ██║ ██║ ██║╚██████╔╝██║ ██╗███████╗
23+
╚═════╝ ╚══════╝ ╚═══╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
24+
"""
25+
26+
27+
def signal_handler(sig: int, frame: object) -> None:
28+
"""Handler for signals captured
29+
:param sig: Signal identifier
30+
:type sig: int
31+
:param frame: Current stack frame
32+
:type frame: object
33+
:return: None
34+
:rtype: None
35+
"""
36+
if sig == signal.SIGINT:
37+
sig_type = "SIGINT"
38+
elif sig == signal.SIGTERM:
39+
sig_type = "SIGTERM"
40+
else:
41+
sig_type = f"UNKNOWN ({sig})"
42+
Logger.get('main').critical(f"Process interrupted ({sig_type})")
43+
sys.exit(200)
44+
45+
46+
@click.command(context_settings={'help_option_names': ['-h', '--help']})
47+
@click.version_option(__version__, '-V', '--version')
48+
@click.argument('project', type=click.STRING, required=True)
49+
@click.argument('tag', type=click.STRING, required=False, default="latest")
50+
@click.option('-q', '--quiet', is_flag=True, help="no output")
51+
@click.option('-v', '--verbose', count=True, help="verbosity level")
52+
def main(project, tag, quiet, verbose) -> None:
53+
"""
54+
Deploy PROJECT with TAG (default 'latest')
55+
"""
56+
print(Fore.MAGENTA + Style.BRIGHT + logo + Style.RESET_ALL)
57+
for sig_name in [signal.SIGINT, signal.SIGTERM]:
58+
signal.signal(sig_name, signal_handler)
59+
Logger.prepare('main', 1000 if quiet else 50 - verbose * 10)
60+
deploy = Deploy(project, tag)
61+
deploy.pull()
62+
deploy.reload()
63+
64+
65+
if __name__ == '__main__':
66+
main()

src/zdd/services/__init__.py

Whitespace-only changes.

src/zdd/services/cursor/__init__.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import sys
2+
3+
4+
class Cursor:
5+
@staticmethod
6+
def move(line, column):
7+
sys.stdout.write(f'\033[{line};{column}H')
8+
sys.stdout.flush()
9+
10+
@staticmethod
11+
def move_start():
12+
sys.stdout.write(f'\r')
13+
sys.stdout.flush()
14+
15+
@staticmethod
16+
def erase_to_the_end():
17+
sys.stdout.write(f'\033[K')
18+
sys.stdout.flush()
19+
20+
@staticmethod
21+
def move_up(count):
22+
sys.stdout.write(f'\033[{count}A')
23+
sys.stdout.flush()
24+
25+
@staticmethod
26+
def move_down(count):
27+
sys.stdout.write(f'\033[{count}B')
28+
sys.stdout.flush()
29+
30+
@staticmethod
31+
def move_forward(count):
32+
sys.stdout.write(f'\033[{count}C')
33+
sys.stdout.flush()
34+
35+
@staticmethod
36+
def move_backward(count):
37+
sys.stdout.write(f'\033[{count}D')
38+
sys.stdout.flush()
39+
40+
@staticmethod
41+
def clear_screen():
42+
sys.stdout.write(f'\033[2J')
43+
sys.stdout.flush()
44+
45+
@staticmethod
46+
def save_position():
47+
sys.stdout.write(f'\033[s')
48+
sys.stdout.flush()
49+
50+
@staticmethod
51+
def restore_position():
52+
sys.stdout.write(f'\033[u')
53+
sys.stdout.flush()

src/zdd/services/deploy/__init__.py

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import json
2+
import sys
3+
from pathlib import Path
4+
from time import sleep
5+
6+
import docker
7+
from docker import DockerClient
8+
from docker.errors import NotFound, APIError
9+
10+
from .configuration import Configuration
11+
from ...services.cursor import Cursor
12+
from ...services.logger import Logger
13+
14+
15+
class Deploy:
16+
_client: DockerClient = None
17+
_config: Configuration = None
18+
_logger: Logger = Logger.get('main.zdd')
19+
_project: str = None
20+
_tag: str = None
21+
22+
def __init__(self, project: str, tag: str) -> None:
23+
self._project = project
24+
self._tag = tag
25+
config = self.__fetch_config()
26+
self._config = Configuration(**config)
27+
self._client = docker.from_env()
28+
29+
def reload(self) -> None:
30+
self._logger.info(f"Reload {self._config.docker_image}:{self._tag}")
31+
image: dict = self._client.images.get(name=f'{self._config.docker_image}:{self._tag}')
32+
if not image:
33+
self._logger.critical(f"Cannot find image {self._config.docker_image}:{self._tag} locally")
34+
sys.exit(120)
35+
self.__reload_instance(1)
36+
self.__reload_instance(2)
37+
38+
def pull(self) -> None:
39+
"""Pull project's Docker image
40+
:return: None
41+
:rtype: None
42+
"""
43+
self._logger.info(f"Pull {self._config.docker_image}:{self._tag}")
44+
lines = {}
45+
api_client = docker.APIClient()
46+
try:
47+
for line in api_client.pull(self._config.docker_image, tag=self._tag, stream=True):
48+
line = json.loads(line)
49+
if 'id' in line:
50+
if line['id'] not in lines:
51+
lines[line['id']] = {
52+
'status': None,
53+
'progress': None
54+
}
55+
sys.stdout.write('\n')
56+
if 'progress' in line:
57+
lines[line['id']]['progress'] = line['progress']
58+
if 'status' in line:
59+
lines[line['id']]['status'] = line['status']
60+
index = list(lines).index(line['id'])
61+
move = len(list(lines)) - index
62+
Cursor.move_up(move)
63+
Cursor.move_start()
64+
Cursor.erase_to_the_end()
65+
sys.stdout.write(f"{line['id']}: {lines[line['id']]['status']}")
66+
if lines[line['id']]['status'] == 'Downloading' and lines[line['id']]['progress'] is not None:
67+
sys.stdout.write(f" {lines[line['id']]['progress']}")
68+
Cursor.move_down(move)
69+
Cursor.move_start()
70+
elif 'status' in line:
71+
print(f"{line['status']}")
72+
except NotFound:
73+
self._logger.critical(f"'{self._config.docker_image}:{self._tag}' not found on Docker HUB")
74+
sys.exit(110)
75+
76+
def __reload_instance(self, instance: int) -> None:
77+
image = f'{self._config.docker_image}:{self._tag}'
78+
container_name = f'{self._project}_{instance}'
79+
params = self._config.get_params(instance)
80+
# stop instance
81+
try:
82+
container = self._client.containers.get(container_name)
83+
except NotFound:
84+
container = None
85+
if container:
86+
self._logger.info(f"Stop {container_name} instance...")
87+
container.stop()
88+
self._logger.info(f"{container_name} instance stopped")
89+
# run instance
90+
self._logger.info(f"Start {container_name} instance...")
91+
try:
92+
self._client.containers.run(
93+
image,
94+
name=container_name,
95+
**params
96+
)
97+
self._logger.info(f"{container_name} instance running")
98+
except APIError as e:
99+
self._logger.critical(f"Fail to run {container_name} with {str(e)}")
100+
sys.exit(130)
101+
# @TODO(damien): Check if instance is really up (request :port ?)
102+
sleep(1)
103+
104+
def __fetch_config(self) -> dict:
105+
"""Get and decode JSON configuration file content
106+
:return: Project configuration from JSON
107+
:rtype: dict
108+
"""
109+
try:
110+
file = str(Path.home()) + '/.config/zdd.json'
111+
self._logger.info(f"Fetch configuration file ({file})")
112+
with open(file) as content:
113+
config = json.load(content)
114+
except FileNotFoundError:
115+
self._logger.critical(f"Cannot find configuration file ({file})")
116+
sys.exit(100)
117+
except json.JSONDecodeError:
118+
self._logger.critical(f"Cannot decode configuration file ({file})")
119+
sys.exit(101)
120+
if 'projects' not in config:
121+
self._logger.critical(f"'projects' key missing in configuration file ({file})")
122+
sys.exit(102)
123+
if self._project not in config['projects']:
124+
self._logger.critical(f"Project '{self._project}' not in configuration file ({file})")
125+
sys.exit(103)
126+
if not config['projects'][self._project]['active']:
127+
self._logger.critical(f"Project '{self._project}' is deactivated in configuration file ({file})")
128+
sys.exit(104)
129+
if 'docker_image' not in config['projects'][self._project]:
130+
self._logger.critical(
131+
f"Missing 'docker_image' key for project '{self._project}' in configuration file ({file})")
132+
sys.exit(105)
133+
self._logger.debug(config['projects'][self._project])
134+
return config['projects'][self._project]

0 commit comments

Comments
 (0)