Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/python-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Build

on:
push:
branches: ["master", "main"]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pylint pytest pyright
- name: Lint
run: pylint zdeploy
- name: Type check
run: pyright zdeploy
- name: Test
run: pytest
- name: Compile
run: python -m compileall -q zdeploy
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,23 @@ Here is a list of all supported config parameters to date:
| cache | Deployment cache directory path | No | String | cache |
| logs | Deployment logs directory path | No | String | logs |
| installer | Default installer, used when an unrecognized dependency is found in the `require` file | No | String | apt-get install -y |
| force | Force entire deployment every time (default is to pick up with a previous failing deployment left off | No | String | no |
| force | Force entire deployment every time (default is to pick up with a previous failing deployment left off | No | Boolean | False |
| user | Default username (used for recipes that don't specify a username, i.e. RECIPE_USER). | No | String | root |
| password | Default password (used in case a private key isn't auto-detected). | No | String | None |
| port | Default port number (used for recipes that don't specify a port number, i.e. RECIPE_PORT). | No | Integer | 22 |

> NOTE: This table will be updated to always support the most recent release of Zdeploy.
> NOTE: This table will be updated to always support the most recent release of Zdeploy.

## Development

Install development tools and run lint, type checks, and the test suite:

```bash
pip install -r requirements.txt
pylint zdeploy
pyright zdeploy
pytest
```

## Author
[Fadi Hanna Al-Kass](https://github.com/alkass)
3 changes: 0 additions & 3 deletions TODO

This file was deleted.

13 changes: 8 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
paramiko
requests
python-dotenv
scp
cryptography>=39.0.1 # not directly required, pinned by Snyk to avoid a vulnerability
paramiko==3.5.1
requests==2.32.4
python-dotenv==1.1.1
scp==0.15.0
cryptography>=45.0.4 # not directly required, pinned to avoid a vulnerability
pylint==3.3.7
pytest==8.4.1
pyright==1.1.402
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9 changes: 9 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from zdeploy import config

def test_load_defaults(tmp_path):
cfg = config.load(str(tmp_path / 'missing.json'))
assert cfg.configs == 'configs'
assert cfg.recipes == 'recipes'
assert cfg.cache == 'cache'
assert cfg.logs == 'logs'
assert cfg.force is False
25 changes: 25 additions & 0 deletions tests/test_recipeset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import zdeploy.recipe as recipe_mod
from zdeploy.recipeset import RecipeSet
import logging
from zdeploy.config import Config


def test_recipeset_iterable(tmp_path):
cfg = Config(recipes=str(tmp_path))
log = logging.getLogger("test")
r = recipe_mod.Recipe(
"pkg1",
None,
tmp_path / "cfg",
"host",
"user",
None,
22,
log,
cfg,
)
rs = RecipeSet(cfg, log)
rs.add(r)
for item in rs:
assert item is r
assert len(list(rs)) == 1
6 changes: 6 additions & 0 deletions tests/test_shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from zdeploy.shell import execute

def test_execute_echo():
output, rc = execute('echo hello')
assert output.strip() == 'hello'
assert rc == 0
12 changes: 12 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import zdeploy.utils as utils

def test_reformat_time():
assert utils.reformat_time('1:02:03') == '1h, 2m, and 3s'


def test_str2bool():
assert utils.str2bool('yes') is True
assert utils.str2bool('true') is True
assert utils.str2bool('enable') is True
assert utils.str2bool('no') is False
assert utils.str2bool('maybe') is False
107 changes: 59 additions & 48 deletions zdeploy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,71 @@
from argparse import ArgumentParser
from os.path import isdir
from os import listdir, makedirs
from sys import stdout
"""Command line interface for zdeploy."""

from argparse import ArgumentParser, Namespace
from datetime import datetime
from zdeploy.log import Log
import logging
from os import listdir
from pathlib import Path
from sys import stdout

from zdeploy.utils import str2bool

from zdeploy.app import deploy
from zdeploy.config import load as load_config

def str2bool(v):
if isinstance(v, bool):
return v
if v.lower() in ('yes', 'y'):
return True
elif v.lower() in ('no', 'n'):
return False
raise Exception('Invalid value: %s' % v)

def handle_config(config_name, args, cfg):
# TODO: document
log_dir_path = '%s/%s' % (cfg.logs, config_name)
cache_dir_path = '%s/%s' % (cfg.cache, config_name)
if not isdir(log_dir_path):
makedirs(log_dir_path)
if not isdir(cache_dir_path):
makedirs(cache_dir_path)
log = Log()
log.register_logger(stdout)
log.register_logger(open('%s/%s.log' % (log_dir_path, '{0:%Y-%m-%d %H:%M:%S}'.format(datetime.now())), 'w'))
deploy(config_name, cache_dir_path, log, args, cfg)

def handle_configs(args, cfg):
'''
Iterate over all retrieved configs and deploy them in a pipelined order.
'''
from zdeploy.config import load as load_config, Config


def deploy_config(config_name: str, args: Namespace, cfg: Config) -> None:
"""Deploy a single configuration."""
log_dir_path = Path(cfg.logs) / config_name
cache_dir_path = Path(cfg.cache) / config_name
if not log_dir_path.is_dir():
log_dir_path.mkdir(parents=True)
if not cache_dir_path.is_dir():
cache_dir_path.mkdir(parents=True)
log_file_path = log_dir_path / f"{datetime.now():%Y-%m-%d %H:%M:%S}.log"

logger = logging.getLogger(config_name)
logger.setLevel(logging.INFO)
formatter = logging.Formatter("%(message)s")
stream_handler = logging.StreamHandler(stdout)
stream_handler.setFormatter(formatter)
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
logger.addHandler(file_handler)

try:
deploy(config_name, cache_dir_path, logger, args, cfg)
finally:
logger.removeHandler(file_handler)
file_handler.close()


def deploy_configs(args: Namespace, cfg: Config) -> None:
"""Deploy each config provided on the command line."""
for config_name in args.configs:
handle_config(config_name, args, cfg)
deploy_config(config_name, args, cfg)


def main():
# Default config file name is config.json, so it needs not be specified in our case.
def main() -> None:
"""CLI entry point."""
cfg = load_config()
parser = ArgumentParser()
parser.add_argument(
'-c',
'--configs',
help='Deployment destination(s)',
nargs='+',
"-c",
"--configs",
help="Deployment destination(s)",
nargs="+",
required=True,
choices=listdir(cfg.configs) if isdir(cfg.configs) else ())
choices=listdir(cfg.configs) if Path(cfg.configs).is_dir() else (),
)
parser.add_argument(
'-f',
'--force',
help='Force full deployment (overlooks the cache)',
nargs='?',
"-f",
"--force",
help="Force full deployment (overlooks the cache)",
nargs="?",
required=False,
default=cfg.force, # Default behavior can be defined by the user in a config file
default=cfg.force,
const=True,
type=str2bool
type=str2bool,
)
handle_configs(parser.parse_args(), cfg)
deploy_configs(parser.parse_args(), cfg)
Loading