diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4e410f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +docker-compose.yml +env + +.vscode +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebd5622 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3-alpine as builder +RUN apk add --no-cache alpine-sdk libffi-dev openssl-dev python3-dev +COPY requirements.txt . +RUN pip install -r requirements.txt + + +FROM python:3-alpine +WORKDIR /plugin +COPY --from=builder /root/.cache /root/.cache +COPY --from=builder requirements.txt . +RUN pip install -r requirements.txt && rm -rf /root/.cache + +COPY main.py ./ + +WORKDIR /drone/src + +CMD ["python", "/plugin/main.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7fbe75 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Drone Deployment Plugin + +## Quickstart 🚀 + +```yaml +kind: pipeline +name: default + +steps: + + - name: build + image: node:11-alpine + pull: always + commands: + - npm i + - npm run build:prod + + - name: deploy + image: cupcakearmy/drone-deploy + pull: always + environment: + PLUGIN_KEY: + from_secret: ssh_key + settings: + host: example.org + user: root + port: 69 + target: /my/web/root/project + sources: + - ./public + - ./docker-compose.yml + - ./docker-compose.prod.yml + commands: + - docker-compose -f docker-compose.yml -f docker-compose.prod.yml down + - docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d + when: + event: push + branch: master +``` + +### Details 📒 + +The plugins creates a tarball compressing all the files included inside of `sources`. +Then the compressed tarball gets uploaded, extracted and deleted, leaving only the files specified by `sources` inside of the `target` folder. +Afterwards all the commands inside of `commands` will get executed at the `target` directory. \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e455255 --- /dev/null +++ b/main.py @@ -0,0 +1,114 @@ +import io +import os +import random +import string +import warnings +import subprocess +from typing import List +from os.path import abspath, join, dirname + +import paramiko +from paramiko import SSHClient + + +def get_random_string(length: int = 128) -> str: + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def execute(c: SSHClient, cmd: str, path: str = None) -> str: + if path is not None: + cmd = 'cd {}; {}'.format(path, cmd) + stdin, stdout, stderr = c.exec_command(cmd) + output = stdout.read().decode('utf-8').strip() + error = stderr.read().decode('utf-8') + + if len(error) is not 0: + print('ERROR: {}'.format(error)) + + return output + + +def main(): + host = os.environ.get('PLUGIN_HOST') + port = os.environ.get('PLUGIN_PORT', 22) + user = os.environ.get('PLUGIN_USER') + + password = os.environ.get('PLUGIN_PASSWORD') + key = os.environ.get('PLUGIN_KEY') + + def clean_array(s: str) -> List[str]: + return list(filter(None, s.split(','))) + + commands = clean_array(os.environ.get('PLUGIN_COMMANDS', '')) + sources = clean_array(os.environ.get('PLUGIN_SOURCES', '')) + deletes = clean_array(os.environ.get('PLUGIN_DELETE', '')) + target = os.environ.get('PLUGIN_TARGET') + + for env in [host, port, user]: + if env is None: + raise Exception('Missing ENV variable') + + if len(list(filter(lambda x: x is not None, [password, key]))) is 0: + raise Exception('No authentication method provided') + + if len(sources) is not 0 and target is None: + raise Exception('Target not set') + + ssh: SSHClient = paramiko.SSHClient() + try: + k = paramiko.RSAKey.from_private_key(io.StringIO(key)) + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(hostname=host, username=user, pkey=k, port=port, password=password) + + # If a target is set, make sure the directory is created and writable + if target is not None: + try: + execute(ssh, 'mkdir -p {}'.format(target)) + tmp_file = get_random_string() + execute(ssh, 'touch {}; rm {}'.format(tmp_file, tmp_file), target) + except Exception: + raise Exception('Could not create directory') + + sftp = ssh.open_sftp() + try: + # DELETE + for delete in deletes: + sftp.remove(join(target, delete)) + + # COPY + if len(sources) is not 0: + archive = get_random_string(100) + '.tar.gz' # Keep the max file name length under 128 chars + archive_local = abspath(archive) + archive_remote = join(target, archive) + + # sources = list(map(lambda x: join(dirname(archive_local), x), sources)) + # print(sources) + + # Compress + cmd = ['tar', '-czf', archive, '-C', dirname(archive_local), *sources] + run = subprocess.run(cmd, capture_output=True) + if run.returncode is not 0: + raise Exception('Error while compressing locally. {}'.format(run.stderr.decode('utf-8').strip())) + + # Upload + sftp.put(archive_local, archive_remote) + # Extract + execute(ssh, 'tar -xzf {}'.format(archive), target) + + # Delete Archives + sftp.remove(archive_remote) + subprocess.run(['rm', archive_local], capture_output=True) + finally: + sftp.close() + + for command in commands: + output = execute(ssh, command, target) + print(output) + + finally: + ssh.close() + + +with warnings.catch_warnings(): + warnings.simplefilter('ignore') + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2d2a643 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +asn1crypto==0.24.0 +bcrypt==3.1.6 +cffi==1.12.2 +cryptography==2.6.1 +paramiko==2.4.2 +pyasn1==0.4.5 +pycparser==2.19 +PyNaCl==1.3.0 +six==1.12.0