From ac5a42d4cc38c81df28c88007abb2fa32284c91e Mon Sep 17 00:00:00 2001 From: Amine Ben hammou Date: Thu, 15 Aug 2024 05:26:50 +0200 Subject: [PATCH] WIP --- files.txt | 20 +++ src/ansible/index.ts | 15 +- src/ansible/roles/assert.ts | 140 ------------------ src/ansible/roles/create_hosty_directory.ts | 15 -- src/ansible/roles/create_service.ts | 75 ---------- src/ansible/roles/generate_ssh_key.ts | 16 -- src/ansible/roles/index.ts | 6 - src/ansible/roles/install_docker.ts | 23 --- src/ansible/roles/install_git.ts | 13 -- src/ansible/roles/install_nixpacks.ts | 16 -- src/ansible/tasks/builtin.ts | 20 ++- src/ansible/types.ts | 6 +- src/blocks/assert.ts | 129 ++++++++++++++++ src/blocks/block.ts | 8 + src/blocks/build_repo.ts | 46 ++++++ src/blocks/create_directory.ts | 9 ++ src/blocks/create_domain.ts | 26 ++++ src/blocks/create_service.ts | 92 ++++++++++++ src/blocks/delete_directory.ts | 5 + src/blocks/delete_docker_image.ts | 9 ++ src/blocks/delete_domain.ts | 15 ++ src/blocks/delete_service.ts | 18 +++ src/blocks/generate_ssh_key.ts | 21 +++ src/blocks/index.ts | 15 ++ src/blocks/install_caddy.ts | 39 +++++ src/blocks/install_docker.ts | 21 +++ src/blocks/install_git.ts | 16 ++ src/blocks/install_nixpacks.ts | 9 ++ src/blocks/set_available_port.ts | 20 +++ src/index.ts | 5 +- src/instance.ts | 45 +++--- src/operations.ts | 7 + src/server.ts | 31 ++-- src/services/app/git.ts | 62 ++++++++ src/services/app/index.ts | 1 + src/services/assertions.ts | 8 +- src/services/container.ts | 43 +++--- src/services/database.ts | 17 --- src/services/database/index.ts | 3 + src/services/database/mysql.ts | 49 ++++++ src/services/database/postgres.ts | 46 ++++++ src/services/database/redis.ts | 40 +++++ src/services/index.ts | 4 +- src/types.ts | 61 ++++++-- src/utils.ts | 10 ++ story.md | 0 tasks.todo | 30 ++-- tests/app-laravel-mysql-custom-docker.test.ts | 34 +++++ tests/app-node-postgres.test.ts | 38 +++++ tests/container.test.ts | 62 ++++---- tests/setup.test.ts | 13 +- tests/utils/index.ts | 77 ++++++---- 52 files changed, 1058 insertions(+), 491 deletions(-) create mode 100644 files.txt delete mode 100644 src/ansible/roles/assert.ts delete mode 100644 src/ansible/roles/create_hosty_directory.ts delete mode 100644 src/ansible/roles/create_service.ts delete mode 100644 src/ansible/roles/generate_ssh_key.ts delete mode 100644 src/ansible/roles/index.ts delete mode 100644 src/ansible/roles/install_docker.ts delete mode 100644 src/ansible/roles/install_git.ts delete mode 100644 src/ansible/roles/install_nixpacks.ts create mode 100644 src/blocks/assert.ts create mode 100644 src/blocks/block.ts create mode 100644 src/blocks/build_repo.ts create mode 100644 src/blocks/create_directory.ts create mode 100644 src/blocks/create_domain.ts create mode 100644 src/blocks/create_service.ts create mode 100644 src/blocks/delete_directory.ts create mode 100644 src/blocks/delete_docker_image.ts create mode 100644 src/blocks/delete_domain.ts create mode 100644 src/blocks/delete_service.ts create mode 100644 src/blocks/generate_ssh_key.ts create mode 100644 src/blocks/index.ts create mode 100644 src/blocks/install_caddy.ts create mode 100644 src/blocks/install_docker.ts create mode 100644 src/blocks/install_git.ts create mode 100644 src/blocks/install_nixpacks.ts create mode 100644 src/blocks/set_available_port.ts create mode 100644 src/operations.ts create mode 100644 src/services/app/git.ts create mode 100644 src/services/app/index.ts delete mode 100644 src/services/database.ts create mode 100644 src/services/database/index.ts create mode 100644 src/services/database/mysql.ts create mode 100644 src/services/database/postgres.ts create mode 100644 src/services/database/redis.ts create mode 100644 src/utils.ts delete mode 100644 story.md create mode 100644 tests/app-laravel-mysql-custom-docker.test.ts create mode 100644 tests/app-node-postgres.test.ts diff --git a/files.txt b/files.txt new file mode 100644 index 0000000..455d4c2 --- /dev/null +++ b/files.txt @@ -0,0 +1,20 @@ +/srv/hosty/ + services/ + db-foo/ + compose.yaml + ... + app-foo/ + .port + local port to use + compose.yaml + source.yaml + repo: 'repo url' + branch: 'deployed branch' + commit: 'last deployed commit hash' + Caddyfile + ... + backups/ + db-foo/ + yyyy-mm-dd_hh-mm-ss.sql.gz + ... + diff --git a/src/ansible/index.ts b/src/ansible/index.ts index e5301b5..f2ff1c5 100644 --- a/src/ansible/index.ts +++ b/src/ansible/index.ts @@ -1,27 +1,18 @@ -import { AnyTask, Host, Playbook, Role, Step } from './types.js' +import { AnyTask, Host, Playbook, Step, Tasks } from './types.js' export * from './types.js' export * as tasks from './tasks/index.js' -export * as roles from './roles/index.js' export function task(data: AnyTask) { return data } -export function role(data: Role) { - return data -} - export function host(data: Host) { return data } -export function step(host: Host, role: Role): Step { - return { - hosts: host.name, - tasks: role.tasks, - handlers: role.handlers, - } +export function step(host: Host, tasks: Tasks): Step { + return { hosts: host.name, tasks } } export function playbook(data: Playbook) { diff --git a/src/ansible/roles/assert.ts b/src/ansible/roles/assert.ts deleted file mode 100644 index 26c7fc2..0000000 --- a/src/ansible/roles/assert.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { builtin } from '../tasks/index.js' -import { AnyTask, CommonTaskAttrs, Role } from '../types.js' - -type CommandAsserts = { - success?: boolean - exit_code?: number - stdout?: string - stdout_contains?: string - stdout_doesnt_contain?: string - stderr?: string - stderr_contains?: string - stderr_doesnt_contain?: string -} -export function command(cmd: string, assertions: CommandAsserts, common: CommonTaskAttrs = {}): Role { - const tasks: AnyTask[] = [builtin.shell(`Run command '${cmd}'`, { cmd }, { ...common, register: 'command_result' })] - - if (assertions.success === true) { - tasks.push(builtin.assert(`Command '${cmd}' is successful`, { that: `command_result.rc == 0` })) - } - if (assertions.success === false) { - tasks.push(builtin.assert(`Command '${cmd}' failed`, { that: `command_result.rc != 0` })) - } - if (assertions.exit_code !== undefined) { - tasks.push(builtin.assert(`Command '${cmd}' exit code is ${assertions.exit_code}`, { that: `command_result.rc == ${assertions.exit_code}` })) - } - if (assertions.stdout !== undefined) { - tasks.push(builtin.assert(`Command '${cmd}' stdout is '${assertions.stdout}'`, { that: `command_result.stdout == '${assertions.stdout}'` })) - } - if (assertions.stdout_contains !== undefined) { - tasks.push( - builtin.assert(`Command '${cmd}' stdout contains '${assertions.stdout_contains}'`, { - that: `'${assertions.stdout_contains}' in command_result.stdout`, - }), - ) - } - if (assertions.stdout_doesnt_contain !== undefined) { - tasks.push( - builtin.assert(`Command '${cmd}' stdout doesn't contain '${assertions.stdout_doesnt_contain}'`, { - that: `'${assertions.stdout_doesnt_contain}' not in command_result.stdout`, - }), - ) - } - if (assertions.stderr !== undefined) { - tasks.push(builtin.assert(`Command '${cmd}' stderr is '${assertions.stderr}'`, { that: `command_result.stderr == '${assertions.stderr}'` })) - } - if (assertions.stderr_contains !== undefined) { - tasks.push( - builtin.assert(`Command '${cmd}' stderr contains '${assertions.stderr_contains}'`, { - that: `'${assertions.stderr_contains}'in command_result.stderr`, - }), - ) - } - if (assertions.stderr_doesnt_contain !== undefined) { - tasks.push( - builtin.assert(`Command '${cmd}' stderr doesn't contain '${assertions.stderr_doesnt_contain}'`, { - that: `'${assertions.stderr_doesnt_contain}' not in command_result.stderr`, - }), - ) - } - return { tasks, handlers: [] } -} - -type FileAsserts = { exists?: boolean; owner?: string; group?: string; mode?: string; content_equals?: string; contains?: string; doesnt_contain?: string } -export function file(path: string, assertions: FileAsserts): Role { - const tasks: AnyTask[] = [builtin.stat(`Get file stat for '${path}'`, { path }, { register: 'file' })] - if (assertions.exists === false) { - tasks.push(builtin.assert(`File '${path}' exists`, { that: 'not file.stat.exists' })) - } - if (assertions.exists === true) { - tasks.push(builtin.assert(`File '${path}' exists`, { that: 'file.stat.exists' })) - builtin.assert(`File '${path}' is a file`, { that: 'file.stat.isreg' }) - } - if (assertions.owner) { - tasks.push(builtin.assert(`File '${path}' is owned by '${assertions.owner}'`, { that: `file.stat.pw_name == '${assertions.owner}'` })) - } - if (assertions.group) { - tasks.push(builtin.assert(`File '${path}' group is '${assertions.group}'`, { that: `file.stat.gr_name == '${assertions.group}'` })) - } - if (assertions.mode) { - tasks.push(builtin.assert(`File '${path}' mode is '${assertions.mode}'`, { that: `file.stat.mode == '${assertions.mode}'` })) - } - if (assertions.content_equals) { - const x = command(`cat ${path}`, { stdout: assertions.content_equals }) - tasks.push(...x.tasks) - } - if (assertions.contains) { - const x = command(`cat ${path}`, { stdout_contains: assertions.contains }) - tasks.push(...x.tasks) - } - if (assertions.doesnt_contain) { - const x = command(`cat ${path}`, { stdout_doesnt_contain: assertions.doesnt_contain }) - tasks.push(...x.tasks) - } - return { tasks, handlers: [] } -} - -export function yaml(path: string, data: any): Role { - return { - tasks: [ - builtin.command(`Read the content of ${path}`, { cmd: `cat ${path}` }, { register: 'yaml_file_content' }), - builtin.set_facts(`Set Yaml content variable`, { parsed_yaml_content: '{{ yaml_file_content.stdout | from_yaml }}' }), - builtin.assert(`Compare the Yaml data`, { - that: `parsed_yaml_content == ${JSON.stringify(data)}`, - }), - ], - handlers: [], - } -} - -type DirAsserts = { owner?: string; group?: string; mode?: string } -export function dir(path: string, assertions: DirAsserts): Role { - const tasks: AnyTask[] = [ - builtin.stat(`Get directory stat for '${path}'`, { path }, { register: 'dir' }), - builtin.assert(`Directory '${path}' exists`, { that: 'dir.stat.exists' }), - builtin.assert(`'${path}' is a directory`, { that: 'dir.stat.isdir' }), - ] - if (assertions.owner) { - tasks.push(builtin.assert(`Directory '${path}' is owned by '${assertions.owner}'`, { that: `dir.stat.pw_name == '${assertions.owner}'` })) - } - if (assertions.group) { - tasks.push(builtin.assert(`Directory '${path}' group is '${assertions.group}'`, { that: `dir.stat.gr_name == '${assertions.group}'` })) - } - if (assertions.mode) { - tasks.push(builtin.assert(`Directory '${path}' mode is '${assertions.mode}'`, { that: `dir.stat.mode == '${assertions.mode}'` })) - } - return { tasks, handlers: [] } -} - -type ServiceState = 'not-found' | 'running' | 'stopped' | 'failed' -export function services(states: Record): Role { - const tasks: AnyTask[] = [builtin.service_facts(`Gather service facts`, {})] - for (const [name, state] of Object.entries(states)) { - let assert_condition = '' - if (state === 'not-found') assert_condition = `ansible_facts.services['${name}'] is not defined` - if (['running', 'stopped', 'failed'].includes(state)) assert_condition = `ansible_facts.services['${name}'].state == '${state}'` - if (!assert_condition) throw new Error(`Unknown service state: ${state}`) - tasks.push(builtin.assert(`Service '${name}' is in state '${state}'`, { that: assert_condition })) - } - return { tasks, handlers: [] } -} diff --git a/src/ansible/roles/create_hosty_directory.ts b/src/ansible/roles/create_hosty_directory.ts deleted file mode 100644 index 2a65644..0000000 --- a/src/ansible/roles/create_hosty_directory.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Role } from '../types.js' -import { builtin } from '../tasks/index.js' - -export function create_hosty_directory(path: string): Role { - return { - tasks: [ - builtin.file( - 'Create hosty directory', - { path, state: 'directory', mode: '0755', owner: '{{ansible_user}}', group: '{{ansible_user}}' }, - { become: true }, - ), - ], - handlers: [], - } -} diff --git a/src/ansible/roles/create_service.ts b/src/ansible/roles/create_service.ts deleted file mode 100644 index 9bea513..0000000 --- a/src/ansible/roles/create_service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import path from 'path' -import { AnyTask, Role } from '../types.js' -import { builtin } from '../tasks/index.js' - -export type ServiceConfig = { - name: string - service_dir: string - files_dir?: string - docker_network: string - docker_compose: string -} -export function create_service({ name, service_dir, docker_network, docker_compose, files_dir }: ServiceConfig): Role { - const tasks: AnyTask[] = [] - const handlers: AnyTask[] = [] - const restart_conditions: string[] = [] - - tasks.push(builtin.file(`Create service directory for ${name}`, { path: service_dir, state: 'directory', owner: '{{ansible_user}}' }, { become: true })) - - if (files_dir) { - tasks.push( - builtin.copy(`Copy service files for ${name}`, { src: '{{item}}', dest: service_dir }, { register: 'files', with_fileglob: `${files_dir}/*` }), - ) - restart_conditions.push('files.changed') - } - - tasks.push( - builtin.copy(`Create compose.yaml for ${name}`, { content: docker_compose, dest: path.join(service_dir, 'compose.yaml') }, { register: 'compose' }), - ) - restart_conditions.push('compose.changed') - - // tasks.push(docker.docker_network(`Create docker network`, { name: docker_network, state: 'present' }, { become: true })) - tasks.push( - builtin.command( - `Check if docker network exists`, - { - cmd: `docker network inspect ${docker_network}`, - }, - { - register: 'docker_network_check', - ignore_errors: true, - }, - ), - builtin.command( - `Create docker network`, - { - cmd: `docker network create ${docker_network}`, - }, - { - when: 'docker_network_check.rc != 0', - become: true, - }, - ), - ) - - tasks.push( - builtin.command( - `Check if service ${name} is running`, - { - cmd: `docker inspect --format='{{"{{"}}.State.Running{{"}}"}}' ${name}`, - }, - { register: 'container_running', ignore_errors: true, become: true }, - ), - ) - restart_conditions.push('container_running.stdout != "true"') - - tasks.push( - builtin.command( - `Start service ${name}`, - { chdir: service_dir, cmd: `docker compose up -d --force-recreate` }, - { become: true, when: restart_conditions.join(' or ') }, - ), - ) - - return { tasks, handlers } -} diff --git a/src/ansible/roles/generate_ssh_key.ts b/src/ansible/roles/generate_ssh_key.ts deleted file mode 100644 index 5f1157a..0000000 --- a/src/ansible/roles/generate_ssh_key.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Role } from '../types.js' -import { builtin, crypto } from '../tasks/index.js' - -export function generate_ssh_key(path: string, passphrase: string): Role { - return { - tasks: [ - builtin.file( - 'Ensure .ssh directory exists', - { path: '/home/{{ansible_user}}/.ssh', state: 'directory', owner: '{{ ansible_user }}', mode: '0700' }, - { become: true }, - ), - crypto.ssh_key('Generate SSH key', { type: 'rsa', path, passphrase }, { become: true }), - ], - handlers: [], - } -} diff --git a/src/ansible/roles/index.ts b/src/ansible/roles/index.ts deleted file mode 100644 index cd85cd8..0000000 --- a/src/ansible/roles/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as assert from './assert.js' -export * from './create_service.js' -export * from './generate_ssh_key.js' -export * from './install_docker.js' -export * from './install_git.js' -export * from './install_nixpacks.js' diff --git a/src/ansible/roles/install_docker.ts b/src/ansible/roles/install_docker.ts deleted file mode 100644 index 82a8e66..0000000 --- a/src/ansible/roles/install_docker.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Role } from '../types.js' -import { builtin } from '../tasks/index.js' - -export function install_docker(): Role { - return { - tasks: [ - builtin.command( - 'Check if Docker is installed', - { cmd: 'docker --version' }, - { register: 'docker_installed', ignore_errors: true, changed_when: false }, - ), - builtin.command( - 'Download Docker setup script', - { chdir: '/tmp', cmd: 'wget https://get.docker.com -O get-docker.sh' }, - { when: 'docker_installed is failed' }, - ), - builtin.command('Make Docker setup script executable', { chdir: '/tmp', cmd: 'chmod +x ./get-docker.sh' }, { when: 'docker_installed is failed' }), - builtin.command('Install Docker', { chdir: '/tmp', cmd: './get-docker.sh' }, { when: 'docker_installed is failed', become: true }), - builtin.command('Add user to Docker group', { cmd: `usermod -aG docker {{ansible_user}}` }, { when: 'docker_installed is failed', become: true }), - ], - handlers: [], - } -} diff --git a/src/ansible/roles/install_git.ts b/src/ansible/roles/install_git.ts deleted file mode 100644 index 9c127f5..0000000 --- a/src/ansible/roles/install_git.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Role } from '../types.js' -import { builtin, general } from '../tasks/index.js' - -export function install_git(name: string, email: string): Role { - return { - tasks: [ - builtin.apt('Install git', { name: 'git', state: 'present', update_cache: true, cache_valid_time: 3600 }, { become: true }), - general.git_config(`Set git user.name to ${name} globally`, { name: 'user.name', value: name, scope: 'global' }), - general.git_config(`Set git user.email to ${name} globally`, { name: 'user.email', value: email, scope: 'global' }), - ], - handlers: [], - } -} diff --git a/src/ansible/roles/install_nixpacks.ts b/src/ansible/roles/install_nixpacks.ts deleted file mode 100644 index e9984b1..0000000 --- a/src/ansible/roles/install_nixpacks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Role } from '../types.js' -import { builtin } from '../tasks/index.js' - -export function install_nixpacks(version: string): Role { - return { - tasks: [ - builtin.get_url('Download nixpacks installer', { - url: `https://github.com/railwayapp/nixpacks/releases/download/v${version}/nixpacks-v${version}-amd64.deb`, - dest: '/tmp/nixpacks.deb', - mode: '0644', - }), - builtin.apt('Install nixpacks', { deb: '/tmp/nixpacks.deb' }, { become: true }), - ], - handlers: [], - } -} diff --git a/src/ansible/tasks/builtin.ts b/src/ansible/tasks/builtin.ts index 3ccde5d..36ee744 100644 --- a/src/ansible/tasks/builtin.ts +++ b/src/ansible/tasks/builtin.ts @@ -4,8 +4,8 @@ export function add_host(name: string, attrs: Host, common: CommonTaskAttrs = {} return { name, 'ansible.builtin.add_host': attrs, ...common } } -type SetFactAttrs = Record -export function set_facts(name: string, attrs: SetFactAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.set_fact', SetFactAttrs> { +type SetFactsAttrs = Record +export function set_facts(name: string, attrs: SetFactsAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.set_fact', SetFactsAttrs> { return { name, 'ansible.builtin.set_fact': attrs, ...common } } @@ -19,9 +19,7 @@ export function stat(name: string, attrs: StatAttrs, common: CommonTaskAttrs = { return { name, 'ansible.builtin.stat': attrs, ...common } } -// add fn for set_fact - -type FileAttrs = { path: string; state: 'file' | 'directory'; mode?: string; owner?: string; group?: string; recurse?: boolean } +type FileAttrs = { path: string; state: 'file' | 'directory' | 'absent'; mode?: string; owner?: string; group?: string; recurse?: boolean } export function file(name: string, attrs: FileAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.file', FileAttrs> { return { name, 'ansible.builtin.file': attrs, ...common } } @@ -31,6 +29,11 @@ export function lineinfile(name: string, attrs: LineInFileAttrs, common: CommonT return { name, 'ansible.builtin.lineinfile': attrs, ...common } } +type TempFileAttrs = { state: 'file' | 'directory' } +export function tempfile(name: string, attrs: TempFileAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.tempfile', TempFileAttrs> { + return { name, 'ansible.builtin.tempfile': attrs, ...common } +} + type TemplateAttrs = { src: string; dest: string } export function template(name: string, attrs: TemplateAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.template', TemplateAttrs> { return { name, 'ansible.builtin.template': attrs, ...common } @@ -56,7 +59,7 @@ export function command(name: string, attrs: CommandAttrs, common: CommonTaskAtt return { name, 'ansible.builtin.command': attrs, ...common } } -type ShellAttrs = { cmd: string; chdir?: string; creates?: string; removes?: string } +type ShellAttrs = { cmd: string; chdir?: string; creates?: string; removes?: string; executable?: string } export function shell(name: string, attrs: ShellAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.shell', ShellAttrs> { return { name, 'ansible.builtin.shell': attrs, ...common } } @@ -93,3 +96,8 @@ type AssertAttrs = { that: string; fail_msg?: string; success_msg?: string } export function assert(name: string, attrs: AssertAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.assert', AssertAttrs> { return { name, 'ansible.builtin.assert': attrs, ...common } } + +type DebugAttrs = { msg?: string | string[]; var?: string; verbosity?: number } +export function debug(name: string, attrs: DebugAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.debug', DebugAttrs> { + return { name, 'ansible.builtin.debug': attrs, ...common } +} diff --git a/src/ansible/types.ts b/src/ansible/types.ts index 471bdfa..8c70c70 100644 --- a/src/ansible/types.ts +++ b/src/ansible/types.ts @@ -24,10 +24,8 @@ export type Handler = Task export type AnyHandler = Handler -export type Role = { - tasks: AnyTask[] - handlers: AnyTask[] -} +export type Tasks = AnyTask[] +export type Block = Task<'block', Tasks> export type Step = { hosts: string diff --git a/src/blocks/assert.ts b/src/blocks/assert.ts new file mode 100644 index 0000000..ba95021 --- /dev/null +++ b/src/blocks/assert.ts @@ -0,0 +1,129 @@ +import { block } from './block.js' +import { builtin } from '../ansible/tasks/index.js' +import { CommonTaskAttrs, Block } from '../ansible/index.js' + +type CommandAsserts = { + success?: boolean + exit_code?: number + stdout?: string + stdout_contains?: string + stdout_doesnt_contain?: string + stderr?: string + stderr_contains?: string + stderr_doesnt_contain?: string +} +export function command(cmd: string, assertions: CommandAsserts, common: CommonTaskAttrs = {}): Block { + const x = block(`Assert command: ${cmd}`) + + x.add( + builtin.shell(`Run command '${cmd}'`, { cmd }, { ...common, ignore_errors: true, register: 'command_result' }), + builtin.debug(`Show command results`, { var: 'command_result' }), + ) + + if (assertions.success === true) { + x.add(builtin.assert(`Command '${cmd}' is successful`, { that: `command_result.rc == 0` })) + } + if (assertions.success === false) { + x.add(builtin.assert(`Command '${cmd}' failed`, { that: `command_result.rc != 0` })) + } + if (assertions.exit_code !== undefined) { + x.add(builtin.assert(`Command '${cmd}' exit code is ${assertions.exit_code}`, { that: `command_result.rc == ${assertions.exit_code}` })) + } + if (assertions.stdout !== undefined) { + x.add(builtin.assert(`Command '${cmd}' stdout is '${assertions.stdout}'`, { that: `command_result.stdout == '${assertions.stdout}'` })) + } + if (assertions.stdout_contains !== undefined) { + x.add( + builtin.assert(`Command '${cmd}' stdout contains '${assertions.stdout_contains}'`, { + that: `'${assertions.stdout_contains}' in command_result.stdout`, + }), + ) + } + if (assertions.stdout_doesnt_contain !== undefined) { + x.add( + builtin.assert(`Command '${cmd}' stdout doesn't contain '${assertions.stdout_doesnt_contain}'`, { + that: `'${assertions.stdout_doesnt_contain}' not in command_result.stdout`, + }), + ) + } + if (assertions.stderr !== undefined) { + x.add(builtin.assert(`Command '${cmd}' stderr is '${assertions.stderr}'`, { that: `command_result.stderr == '${assertions.stderr}'` })) + } + if (assertions.stderr_contains !== undefined) { + x.add( + builtin.assert(`Command '${cmd}' stderr contains '${assertions.stderr_contains}'`, { + that: `'${assertions.stderr_contains}' in command_result.stderr`, + }), + ) + } + if (assertions.stderr_doesnt_contain !== undefined) { + x.add( + builtin.assert(`Command '${cmd}' stderr doesn't contain '${assertions.stderr_doesnt_contain}'`, { + that: `'${assertions.stderr_doesnt_contain}' not in command_result.stderr`, + }), + ) + } + return x.get() +} + +type FileAsserts = { exists?: boolean; owner?: string; group?: string; mode?: string; content_equals?: string; contains?: string; doesnt_contain?: string } +export function file(path: string, assertions: FileAsserts): Block { + const x = block(`Assert file: ${path}`) + x.add(builtin.stat(`Get file stat for '${path}'`, { path }, { register: 'file' })) + if (assertions.exists === false) { + x.add(builtin.assert(`File '${path}' exists`, { that: 'not file.stat.exists' })) + } + if (assertions.exists === true) { + x.add(builtin.assert(`File '${path}' exists`, { that: 'file.stat.exists' })) + builtin.assert(`File '${path}' is a file`, { that: 'file.stat.isreg' }) + } + if (assertions.owner) { + x.add(builtin.assert(`File '${path}' is owned by '${assertions.owner}'`, { that: `file.stat.pw_name == '${assertions.owner}'` })) + } + if (assertions.group) { + x.add(builtin.assert(`File '${path}' group is '${assertions.group}'`, { that: `file.stat.gr_name == '${assertions.group}'` })) + } + if (assertions.mode) { + x.add(builtin.assert(`File '${path}' mode is '${assertions.mode}'`, { that: `file.stat.mode == '${assertions.mode}'` })) + } + if (assertions.content_equals) { + x.add(command(`cat ${path}`, { stdout: assertions.content_equals })) + } + if (assertions.contains) { + x.add(command(`cat ${path}`, { stdout_contains: assertions.contains })) + } + if (assertions.doesnt_contain) { + x.add(command(`cat ${path}`, { stdout_doesnt_contain: assertions.doesnt_contain })) + } + return x.get() +} + +export function yaml(path: string, data: any): Block { + return block(`Assert YAML: ${path}`, {}, [ + builtin.command(`Read the content of ${path}`, { cmd: `cat ${path}` }, { register: 'yaml_file_content' }), + builtin.set_facts(`Set Yaml content variable`, { parsed_yaml_content: '{{ yaml_file_content.stdout | from_yaml }}' }), + builtin.assert(`Compare the Yaml data`, { + that: `parsed_yaml_content == ${JSON.stringify(data)}`, + }), + ]).get() +} + +type DirAsserts = { owner?: string; group?: string; mode?: string } +export function dir(path: string, assertions: DirAsserts): Block { + const x = block(`Assert directory: ${path}`) + x.add( + builtin.stat(`Get directory stat for '${path}'`, { path }, { register: 'dir' }), + builtin.assert(`Directory '${path}' exists`, { that: 'dir.stat.exists' }), + builtin.assert(`'${path}' is a directory`, { that: 'dir.stat.isdir' }), + ) + if (assertions.owner) { + x.add(builtin.assert(`Directory '${path}' is owned by '${assertions.owner}'`, { that: `dir.stat.pw_name == '${assertions.owner}'` })) + } + if (assertions.group) { + x.add(builtin.assert(`Directory '${path}' group is '${assertions.group}'`, { that: `dir.stat.gr_name == '${assertions.group}'` })) + } + if (assertions.mode) { + x.add(builtin.assert(`Directory '${path}' mode is '${assertions.mode}'`, { that: `dir.stat.mode == '${assertions.mode}'` })) + } + return x.get() +} diff --git a/src/blocks/block.ts b/src/blocks/block.ts new file mode 100644 index 0000000..f4bac24 --- /dev/null +++ b/src/blocks/block.ts @@ -0,0 +1,8 @@ +import { CommonTaskAttrs, Tasks } from '../ansible/types.js' + +export function block(name: string, attrs?: CommonTaskAttrs, tasks?: Tasks) { + const state = { name, ...attrs, block: tasks || [] } + const add = (...tasks: Tasks) => state.block.push(...tasks) + const get = () => state + return { add, get } +} diff --git a/src/blocks/build_repo.ts b/src/blocks/build_repo.ts new file mode 100644 index 0000000..92eb713 --- /dev/null +++ b/src/blocks/build_repo.ts @@ -0,0 +1,46 @@ +import path from 'path' +import * as YAML from 'yaml' +import { block } from './block.js' +import { Block } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { create_directory } from './create_directory.js' + +type Config = { + repo_url: string + branch: string + service_dir: string + image_name: string + facts: { + source_changed: string + } +} + +export function build_repo(config: Config): Block { + const source_path = path.join(config.service_dir, 'source.yaml') + const source_content = { repo: config.repo_url, branch: config.branch, commit: '{{commit_hash}}' } + return block(`Clone and build repo: ${config.repo_url}`, {}, [ + create_directory(config.service_dir), + builtin.command(`Get last commit hash`, { cmd: `git ls-remote ${config.repo_url} ${config.branch}` }, { register: 'git_ls_remote' }), + builtin.set_facts(`Set commit hash in a var`, { commit_hash: `{{git_ls_remote.stdout.split()[0]}}` }), + builtin.copy(`Write the source info`, { content: YAML.stringify(source_content), dest: source_path }, { register: 'source_file' }), + builtin.set_facts(`Set source changed fact`, { [config.facts.source_changed]: '{{source_file.changed}}' }), + builtin.tempfile(`Create a temp dir to clone the repo`, { state: 'directory' }, { register: 'clone_dir', when: 'source_file.changed' }), + builtin.git( + `Clone the repo`, + { repo: config.repo_url, version: config.branch, accept_hostkey: true, dest: '{{clone_dir.path}}' }, + { when: 'source_file.changed' }, + ), + builtin.stat(`Check if Dockerfile exists`, { path: '{{clone_dir.path}}/Dockerfile' }, { register: 'dockerfile', when: 'source_file.changed' }), + builtin.command( + `Build the app using Dockerfile`, + { cmd: `docker build -t ${config.image_name} {{clone_dir.path}}` }, + { when: 'source_file.changed and dockerfile.stat.exists' }, + ), + builtin.command( + `Build the app using nixpacks`, + { cmd: `nixpacks build {{clone_dir.path}} --name ${config.image_name}` }, + { when: 'source_file.changed and not dockerfile.stat.exists' }, + ), + builtin.file(`Delete clone dir`, { path: '{{clone_dir.path}}', state: 'absent' }, { when: 'source_file.changed' }), + ]).get() +} diff --git a/src/blocks/create_directory.ts b/src/blocks/create_directory.ts new file mode 100644 index 0000000..87888ed --- /dev/null +++ b/src/blocks/create_directory.ts @@ -0,0 +1,9 @@ +import { builtin } from '../ansible/tasks/index.js' + +export function create_directory(path: string) { + return builtin.file( + `Create directory ${path}`, + { path, state: 'directory', owner: '{{ansible_user}}', group: '{{ansible_user}}', mode: '0755' }, + { become: true }, + ) +} diff --git a/src/blocks/create_domain.ts b/src/blocks/create_domain.ts new file mode 100644 index 0000000..0e7ba45 --- /dev/null +++ b/src/blocks/create_domain.ts @@ -0,0 +1,26 @@ +import { builtin } from '../ansible/tasks/index.js' +import { Block } from '../ansible/types.js' +import { block } from './block.js' + +type Config = { + domain: string + port: string + caddyfile_path: string +} + +const reverse_proxy = (x: Config) => + `${x.domain} { + reverse_proxy 127.0.0.1:${x.port} +}` + +export function create_domain(config: Config): Block { + return block(`Configure domain: ${config.domain}`, {}, [ + builtin.lineinfile( + `Ensure ${config.domain} is in /etc/hosts`, + { path: '/etc/hosts', line: `127.0.0.1 ${config.domain}`, state: 'present' }, + { become: true }, + ), + builtin.copy(`Create Caddyfile for ${config.domain}`, { dest: config.caddyfile_path, content: reverse_proxy(config) }, { register: 'caddyfile' }), + builtin.command(`Reload caddy`, { cmd: `sudo systemctl reload caddy` }, { become: true, when: 'caddyfile.changed' }), + ]).get() +} diff --git a/src/blocks/create_service.ts b/src/blocks/create_service.ts new file mode 100644 index 0000000..5d57c14 --- /dev/null +++ b/src/blocks/create_service.ts @@ -0,0 +1,92 @@ +import path from 'path' +import * as YAML from 'yaml' +import { Block } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { ComposeFile, Service as ComposeService } from '../compose.types.js' +import { block } from './block.js' +import { create_directory } from './create_directory.js' + +type Config = { + name: string + service_dir: string + files_dir?: string + files?: Record + docker_network: string + compose: ComposeService + restart_conditions: string[] +} + +export function create_service({ name, service_dir, docker_network, compose, files_dir, files, restart_conditions }: Config): Block { + const x = block(`Create service: ${name}`) + const composeFile: ComposeFile = { + services: { + [name]: { + container_name: name, + networks: [docker_network], + restart: 'unless-stopped', + ...compose, + }, + }, + networks: { + [docker_network]: { + external: true, + }, + }, + } + + x.add(create_directory(service_dir)) + + if (files_dir) { + files_dir = path.resolve(files_dir) + x.add(builtin.copy(`Copy service files for ${name}`, { src: '{{item}}', dest: service_dir }, { register: 'files', with_fileglob: `${files_dir}/*` })) + restart_conditions.push('files.changed') + } + if (files) { + for (const filename of Object.keys(files)) { + const var_name = 'additional_file_' + filename.replace(/[^a-zA-Z0-9]/g, '_') + const destfile = path.join(service_dir, filename) + x.add( + create_directory(path.dirname(destfile)), + builtin.copy(`Create service file ${filename}`, { content: files[filename], dest: destfile }, { register: var_name }), + ) + restart_conditions.push(var_name + '.changed') + } + } + + x.add( + builtin.copy( + `Create compose.yaml for ${name}`, + { content: YAML.stringify(composeFile), dest: path.join(service_dir, 'compose.yaml') }, + { register: 'compose' }, + ), + ) + restart_conditions.push('compose.changed') + + x.add( + builtin.command( + `Check if docker network exists`, + { cmd: `docker network inspect ${docker_network}` }, + { register: 'docker_network_check', ignore_errors: true }, + ), + builtin.command(`Create docker network`, { cmd: `docker network create ${docker_network}` }, { when: 'docker_network_check.rc != 0', become: true }), + ) + + x.add( + builtin.command( + `Check if service ${name} is running`, + { cmd: `docker inspect --format='{{"{{"}}.State.Running{{"}}"}}' ${name}` }, + { register: 'container_running', ignore_errors: true, become: true }, + ), + ) + restart_conditions.push('container_running.stdout != "true"') + + x.add( + builtin.command( + `Start service ${name}`, + { chdir: service_dir, cmd: `docker compose up -d --force-recreate` }, + { become: true, when: restart_conditions.join(' or ') }, + ), + ) + + return x.get() +} diff --git a/src/blocks/delete_directory.ts b/src/blocks/delete_directory.ts new file mode 100644 index 0000000..2f3ffad --- /dev/null +++ b/src/blocks/delete_directory.ts @@ -0,0 +1,5 @@ +import { builtin } from '../ansible/tasks/index.js' + +export function delete_directory(path: string) { + return builtin.file(`Delete directory ${path}`, { path, state: 'absent' }, { become: true }) +} diff --git a/src/blocks/delete_docker_image.ts b/src/blocks/delete_docker_image.ts new file mode 100644 index 0000000..7b85e09 --- /dev/null +++ b/src/blocks/delete_docker_image.ts @@ -0,0 +1,9 @@ +import { builtin } from '../ansible/tasks/index.js' + +export function delete_docker_image(name: string) { + return builtin.shell( + `Delete docker image ${name}`, + { cmd: `docker rmi $(docker images -q ${name})`, executable: '/bin/bash' }, + { become: true, ignore_errors: true }, + ) +} diff --git a/src/blocks/delete_domain.ts b/src/blocks/delete_domain.ts new file mode 100644 index 0000000..2d1794b --- /dev/null +++ b/src/blocks/delete_domain.ts @@ -0,0 +1,15 @@ +import { block } from './block.js' +import { builtin } from '../ansible/tasks/index.js' + +type Config = { + domain: string + caddyfile_path: string +} + +export function delete_domain({ domain, caddyfile_path }: Config) { + return block(`Delete domain ${domain}`, {}, [ + builtin.lineinfile(`Remove ${domain} from /etc/hosts`, { path: '/etc/hosts', line: `127.0.0.1 ${domain}`, state: 'absent' }, { become: true }), + builtin.file(`Delete Caddyfile for ${domain}`, { path: caddyfile_path, state: 'absent' }, { register: 'caddyfile' }), + builtin.command(`Reload caddy`, { cmd: `sudo systemctl reload caddy` }, { become: true, when: 'caddyfile.changed' }), + ]).get() +} diff --git a/src/blocks/delete_service.ts b/src/blocks/delete_service.ts new file mode 100644 index 0000000..faec55c --- /dev/null +++ b/src/blocks/delete_service.ts @@ -0,0 +1,18 @@ +import { block } from './block.js' +import { Block } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { delete_directory } from './delete_directory.js' + +export function delete_service(service_dir: string): Block { + const x = block(`Delete service at ${service_dir}`) + x.add(builtin.stat(`Check the service directory`, { path: service_dir }, { register: 'service_dir' })) + x.add( + builtin.command( + `Stop service at ${service_dir}`, + { chdir: service_dir, cmd: `docker compose down -v` }, + { become: true, when: `service_dir.stat.exists` }, + ), + ) + x.add(delete_directory(service_dir)) + return x.get() +} diff --git a/src/blocks/generate_ssh_key.ts b/src/blocks/generate_ssh_key.ts new file mode 100644 index 0000000..909179d --- /dev/null +++ b/src/blocks/generate_ssh_key.ts @@ -0,0 +1,21 @@ +import { dirname } from 'path' +import { Block } from '../ansible/types.js' +import { builtin, crypto } from '../ansible/tasks/index.js' +import { block } from './block.js' + +type Config = { + path: string + passphrase: string +} + +export function generate_ssh_key({ path, passphrase }: Config): Block { + return block(`Generate ssh key at ${path}`, {}, [ + builtin.stat('Check if SSH key exists', { path }, { register: 'ssh_key' }), + builtin.file( + 'Ensure .ssh directory exists', + { path: dirname(path), state: 'directory', owner: '{{ansible_user}}', mode: '0700' }, + { when: 'not ssh_key.stat.exists', become: true }, + ), + crypto.ssh_key('Generate SSH key', { type: 'rsa', path, passphrase }, { when: 'not ssh_key.stat.exists', become: true }), + ]).get() +} diff --git a/src/blocks/index.ts b/src/blocks/index.ts new file mode 100644 index 0000000..9e3953b --- /dev/null +++ b/src/blocks/index.ts @@ -0,0 +1,15 @@ +export * as assert from './assert.js' +export * from './build_repo.js' +export * from './create_directory.js' +export * from './create_domain.js' +export * from './create_service.js' +export * from './delete_directory.js' +export * from './delete_docker_image.js' +export * from './delete_domain.js' +export * from './delete_service.js' +export * from './generate_ssh_key.js' +export * from './install_caddy.js' +export * from './install_docker.js' +export * from './install_git.js' +export * from './install_nixpacks.js' +export * from './set_available_port.js' diff --git a/src/blocks/install_caddy.ts b/src/blocks/install_caddy.ts new file mode 100644 index 0000000..bd8796f --- /dev/null +++ b/src/blocks/install_caddy.ts @@ -0,0 +1,39 @@ +import { Block } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { block } from './block.js' + +export function install_caddy(caddyfiles_pattern: string): Block { + return block(`Install Caddy`, {}, [ + builtin.apt( + `Install Caddy's dependencies`, + { name: ['debian-keyring', 'debian-archive-keyring', 'apt-transport-https', 'curl'], state: 'present' }, + { become: true }, + ), + builtin.shell( + `Add Caddy's official GPG key`, + { + cmd: `curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg`, + }, + { become: true }, + ), + builtin.shell( + `Add Caddy's apt repository`, + { + cmd: `curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list`, + creates: `/etc/apt/sources.list.d/caddy-stable.list`, + }, + { become: true }, + ), + builtin.apt(`Update apt cache`, { update_cache: true }, { become: true }), + builtin.apt(`Install Caddy`, { name: 'caddy', state: 'present' }, { become: true }), + builtin.copy( + `Configure Caddy`, + { + dest: '/etc/caddy/Caddyfile', + content: `import ${caddyfiles_pattern}\n`, + }, + { become: true }, + ), + builtin.command(`Reload Caddy config`, { cmd: `sudo systemctl start caddy` }, { become: true }), + ]).get() +} diff --git a/src/blocks/install_docker.ts b/src/blocks/install_docker.ts new file mode 100644 index 0000000..98c6a89 --- /dev/null +++ b/src/blocks/install_docker.ts @@ -0,0 +1,21 @@ +import { Block } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { block } from './block.js' + +export function install_docker(): Block { + return block(`Install Docker`, {}, [ + builtin.command( + 'Check if Docker is installed', + { cmd: 'docker --version' }, + { register: 'docker_installed', ignore_errors: true, changed_when: false }, + ), + builtin.command( + 'Download Docker setup script', + { chdir: '/tmp', cmd: 'wget https://get.docker.com -O get-docker.sh' }, + { when: 'docker_installed is failed' }, + ), + builtin.command('Make Docker setup script executable', { chdir: '/tmp', cmd: 'chmod +x ./get-docker.sh' }, { when: 'docker_installed is failed' }), + builtin.command('Install Docker', { chdir: '/tmp', cmd: './get-docker.sh' }, { when: 'docker_installed is failed', become: true }), + builtin.command('Add user to Docker group', { cmd: `usermod -aG docker {{ansible_user}}` }, { when: 'docker_installed is failed', become: true }), + ]).get() +} diff --git a/src/blocks/install_git.ts b/src/blocks/install_git.ts new file mode 100644 index 0000000..ec24f55 --- /dev/null +++ b/src/blocks/install_git.ts @@ -0,0 +1,16 @@ +import { Block } from '../ansible/types.js' +import { builtin, general } from '../ansible/tasks/index.js' +import { block } from './block.js' + +type Config = { + name?: string + email?: string +} + +export function install_git({ name, email }: Config): Block { + const x = block(`Install and configure Git`) + x.add(builtin.apt('Install git', { name: 'git', state: 'present', update_cache: true, cache_valid_time: 3600 }, { become: true })) + if (name) x.add(general.git_config(`Set git user.name to ${name} globally`, { name: 'user.name', value: name, scope: 'global' })) + if (email) x.add(general.git_config(`Set git user.email to ${name} globally`, { name: 'user.email', value: email, scope: 'global' })) + return x.get() +} diff --git a/src/blocks/install_nixpacks.ts b/src/blocks/install_nixpacks.ts new file mode 100644 index 0000000..19940f2 --- /dev/null +++ b/src/blocks/install_nixpacks.ts @@ -0,0 +1,9 @@ +import { builtin } from '../ansible/tasks/index.js' + +export function install_nixpacks() { + return builtin.shell( + `Install nixpacks`, + { cmd: `curl -sSL https://nixpacks.com/install.sh | sudo bash`, creates: `/usr/local/bin/nixpacks` }, + { become: true }, + ) +} diff --git a/src/blocks/set_available_port.ts b/src/blocks/set_available_port.ts new file mode 100644 index 0000000..457df8f --- /dev/null +++ b/src/blocks/set_available_port.ts @@ -0,0 +1,20 @@ +import path from 'path' +import { block } from './block.js' +import { builtin } from '../ansible/tasks/index.js' +import { create_directory } from './create_directory.js' + +export function set_available_port(service_dir: string, var_name: string) { + const port_file = path.join(service_dir, '.port') + return block(`Generate an available port for ${service_dir} into the var ${var_name}`, {}, [ + create_directory(service_dir), + builtin.shell(`Generate an available port in ${port_file}`, { + cmd: `for port in $(seq 8000 9000); do + (echo >/dev/tcp/localhost/$port) &>/dev/null && continue || { echo $port > ${port_file}; break; } + done`, + executable: '/bin/bash', + creates: port_file, + }), + builtin.command(`Read the port from ${port_file}`, { cmd: `cat ${port_file}` }, { register: 'cat_port' }), + builtin.set_facts(`Set the port in the var ${var_name}`, { [var_name]: `{{cat_port.stdout}}` }), + ]).get() +} diff --git a/src/index.ts b/src/index.ts index be3485a..38d4f9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,9 @@ import { instance } from './instance.js' -import * as ansible from './ansible/index.js' export * from './types.js' export * from './services/index.js' export { server } from './server.js' +export { assert } from './blocks/index.js' export { instance } -export const assert = ansible.roles.assert -export const { deploy, playbook, write, run } = instance() +export const { deploy, destroy, playbook, write, run } = instance() diff --git a/src/instance.ts b/src/instance.ts index b7e06b4..fa3456f 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -3,13 +3,13 @@ import YAML from 'yaml' import { spawn } from 'child_process' import { mkdir, writeFile } from 'fs/promises' import * as ansible from './ansible/index.js' -import { get_setup_roles, server_to_host } from './server.js' +import { get_setup_tasks, server_to_host } from './server.js' import { HostyInstance, RunOptions, Server, Service } from './types.js' -type Deployment = { server: Server; services: Service[] } +type Action = { type: 'deploy' | 'destroy'; server: Server; service: Service } type State = { servers: Record - deployments: Deployment[] + actions: Action[] } const defaultRunOptions: RunOptions = { @@ -18,23 +18,35 @@ const defaultRunOptions: RunOptions = { spawn_options: { stdio: 'inherit', }, + ansible_options: [], } export function instance(): HostyInstance { const state: State = { servers: {}, - deployments: [], + actions: [], } - const deploy = (server: Server, services: Service[]) => { + const deploy = (server: Server, ...services: Service[]) => { state.servers[server.name] = server - state.deployments.push({ server, services }) + for (const service of services) { + state.actions.push({ type: 'deploy', server, service }) + } + } + + const destroy = (server: Server, ...services: Service[]) => { + state.servers[server.name] = server + for (const service of services) { + state.actions.push({ type: 'destroy', server, service }) + } } const playbook = () => { const steps: ansible.Playbook = [] setup_servers(Object.values(state.servers), steps) - add_deployments(state.deployments, steps) + for (const action of state.actions) { + steps.push({ hosts: action.server.name, gather_facts: false, tasks: get_tasks(action) }) + } return steps } @@ -48,10 +60,11 @@ export function instance(): HostyInstance { await write(options.playbookPath) const args = [options.playbookPath] if (options.ask_sudo_pass) args.push('-K') + options.ansible_options.forEach((x) => args.push(x)) return spawn('ansible-playbook', args, options.spawn_options) } - return { deploy, playbook, write, run } + return { deploy, destroy, playbook, write, run } } function setup_servers(servers: Server[], steps: ansible.Step[]) { @@ -67,18 +80,12 @@ function setup_servers(servers: Server[], steps: ansible.Step[]) { gather_facts: false, tasks: [ansible.tasks.builtin.setup(`Gather facts of server ${server.name}`, {})], }) - for (const role of get_setup_roles(server)) { - steps.push({ hosts: server.name, gather_facts: false, ...role }) - } + steps.push({ hosts: server.name, gather_facts: false, tasks: get_setup_tasks(server) }) } } -function add_deployments(deployments: Deployment[], steps: ansible.Step[]) { - for (const { server, services } of deployments) { - for (const service of services) { - for (const role of service.get_roles(server)) { - steps.push({ hosts: server.name, gather_facts: false, ...role }) - } - } - } +function get_tasks({ type, server, service }: Action) { + if (type === 'deploy') return service.get_deploy_tasks(server) + if (type === 'destroy') return service.get_destroy_tasks(server) + return [] } diff --git a/src/operations.ts b/src/operations.ts new file mode 100644 index 0000000..754aa96 --- /dev/null +++ b/src/operations.ts @@ -0,0 +1,7 @@ +import { AnyTask } from './ansible/types.js' + +export function add_condition(task: AnyTask, condition: string): AnyTask { + if (task.when) task.when = `(${condition}) and (${task.when})` + else task.when = condition + return task +} diff --git a/src/server.ts b/src/server.ts index 5810ae6..bf10ec5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,18 +1,25 @@ -import { roles } from './ansible/index.js' +import path from 'path' import { Host } from './ansible/types.js' +import * as blocks from './blocks/index.js' import { Server, ServerConfig } from './types.js' export function server(config: ServerConfig): Server { let connection = config.connection - if (!connection && config.name === 'localhost') connection = { type: 'local' } - if (!connection) connection = { type: 'ssh', address: config.name } + if (!connection) { + if (config.name === 'localhost') connection = { type: 'local', user: process.env.USER } + else connection = { type: 'ssh', address: config.name } + } + const hosty_dir = config.hosty_dir || '/srv/hosty' return { connection, name: config.name, - ssh_key: config.ssh_key, - git_config: config.git_config, - hosty_dir: config.hosty_dir || '/srv/hosty', + ssh_key: config.ssh_key || { path: '~/.ssh/id_rsa', passphrase: '' }, + git_config: config.git_config || {}, + hosty_dir: hosty_dir, + backups_dir: path.join(hosty_dir, 'backups'), + services_dir: path.join(hosty_dir, 'services'), docker_network: config.docker_network || 'hosty', + docker_prefix: config.docker_prefix || '', } } @@ -38,11 +45,13 @@ export function server_to_host({ name, connection }: Server): Host { return host } -export function get_setup_roles(server: Server) { +export function get_setup_tasks(server: Server) { return [ - roles.install_docker(), - roles.install_git(server.git_config.name, server.git_config.email), - roles.generate_ssh_key(server.ssh_key.path, server.ssh_key.passphrase), - roles.install_nixpacks('1.24.0'), + blocks.install_docker(), + blocks.install_git(server.git_config), + blocks.generate_ssh_key(server.ssh_key), + blocks.install_nixpacks(), + blocks.create_directory(server.hosty_dir), + blocks.install_caddy(`${server.services_dir}/*/Caddyfile`), ] } diff --git a/src/services/app/git.ts b/src/services/app/git.ts new file mode 100644 index 0000000..1e800fd --- /dev/null +++ b/src/services/app/git.ts @@ -0,0 +1,62 @@ +import path from 'path' +import { Tasks } from '../../ansible/types.js' +import * as blocks from '../../blocks/index.js' +import { add_condition } from '../../operations.js' +import { GitApp, GitAppConfig, Server } from '../../types.js' + +export function git(config: GitAppConfig): GitApp { + return { + ...config, + get_deploy_tasks: (server) => get_deploy_service(server, config), + get_destroy_tasks: (server) => get_destroy_service(server, config), + } +} + +function get_deploy_service(server: Server, config: GitAppConfig): Tasks { + const tasks: Tasks = [] + const service_dir = path.join(server.hosty_dir, 'services', config.name) + const compose = make_compose(config) + + if (config.domain) { + tasks.push(blocks.set_available_port(service_dir, 'app_port')) + } + tasks.push( + blocks.build_repo({ repo_url: config.repo, branch: config.branch, service_dir, image_name: config.name, facts: { source_changed: 'source_changed' } }), + ) + + const service = blocks.create_service({ + name: config.name, + compose, + docker_network: server.docker_network, + service_dir, + restart_conditions: ['source_changed'], + }) + tasks.push(service) + + if (config.domain) { + tasks.push(blocks.create_domain({ domain: config.domain, port: '{{app_port}}', caddyfile_path: path.join(service_dir, 'Caddyfile') })) + } + return tasks +} + +function get_destroy_service(server: Server, config: GitAppConfig): Tasks { + const tasks: Tasks = [] + const service_dir = path.join(server.hosty_dir, 'services', config.name) + tasks.push(blocks.delete_service(service_dir)) + tasks.push(blocks.delete_docker_image(config.name)) + if (config.domain) { + tasks.push(blocks.delete_domain({ domain: config.domain, caddyfile_path: path.join(service_dir, 'Caddyfile') })) + } + return tasks +} + +function make_compose(config: GitAppConfig) { + const compose = config.compose || {} + compose.image = config.name + compose.environment = { ...(config.env || {}), ...(compose.environment || {}) } + if (config.domain) { + compose.ports ||= [] + compose.ports.push(`{{app_port}}:80`) + } + return compose +} diff --git a/src/services/app/index.ts b/src/services/app/index.ts new file mode 100644 index 0000000..65707f7 --- /dev/null +++ b/src/services/app/index.ts @@ -0,0 +1 @@ +export * from './git.js' diff --git a/src/services/assertions.ts b/src/services/assertions.ts index ad2f88b..62f8c44 100644 --- a/src/services/assertions.ts +++ b/src/services/assertions.ts @@ -1,6 +1,6 @@ -import { Role } from '../ansible/types.js' -import { Assertions, Container } from '../types.js' +import { Tasks } from '../ansible/types.js' +import { Assertions } from '../types.js' -export function assertions(...roles: Role[]): Assertions { - return { get_roles: () => roles } +export function assertions(...tasks: Tasks): Assertions { + return { get_deploy_tasks: () => tasks, get_destroy_tasks: () => [] } } diff --git a/src/services/container.ts b/src/services/container.ts index 71cdd49..1881716 100644 --- a/src/services/container.ts +++ b/src/services/container.ts @@ -1,37 +1,30 @@ import path from 'path' -import * as yaml from 'yaml' -import { Role } from '../ansible/types.js' -import { roles } from '../ansible/index.js' -import { ComposeFile } from '../compose.types.js' +import { Tasks } from '../ansible/types.js' +import * as blocks from '../blocks/index.js' import { Container, ContainerConfig, Server } from '../types.js' export function container(config: ContainerConfig): Container { - return { ...config, get_roles: (server) => get_roles(server, config) } + return { + ...config, + get_deploy_tasks: (server) => get_deploy_tasks(server, config), + get_destroy_tasks: (server) => get_destroy_tasks(server, config), + } } -function get_roles(server: Server, { name, compose, files_dir }: ContainerConfig): Role[] { - const composeFile: ComposeFile = { - services: { - [name]: { - container_name: name, - networks: [server.docker_network], - restart: 'unless-stopped', - ...compose, - }, - }, - networks: { - [server.docker_network]: { - external: true, - }, - }, - } +function get_deploy_tasks(server: Server, { name, compose, files_dir, files }: ContainerConfig): Tasks { return [ - roles.create_service({ - name, + blocks.create_service({ + name: server.docker_prefix + name, + compose, files_dir, - service_dir: path.join(server.hosty_dir, '/services', name), + files, docker_network: server.docker_network, - docker_compose: yaml.stringify(composeFile), + service_dir: path.join(server.hosty_dir, '/services', name), + restart_conditions: [], }), ] } + +function get_destroy_tasks(server: Server, { name, compose, files_dir, files }: ContainerConfig): Tasks { + return [blocks.delete_service(path.join(server.hosty_dir, '/services', name))] +} diff --git a/src/services/database.ts b/src/services/database.ts deleted file mode 100644 index abf9e00..0000000 --- a/src/services/database.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Postgres, PostgresConfig } from '../types.js' -import { container } from './container.js' - -export function postgres(config: PostgresConfig): Postgres { - const service = container({ - name: config.name, - compose: { - image: `postgres:${config.version || 'latest'}`, - // ... - }, - }) - return { - ...config, - host: config.name, - get_roles: service.get_roles, - } -} diff --git a/src/services/database/index.ts b/src/services/database/index.ts new file mode 100644 index 0000000..1448a13 --- /dev/null +++ b/src/services/database/index.ts @@ -0,0 +1,3 @@ +export * from './mysql.js' +export * from './postgres.js' +export * from './redis.js' diff --git a/src/services/database/mysql.ts b/src/services/database/mysql.ts new file mode 100644 index 0000000..451f747 --- /dev/null +++ b/src/services/database/mysql.ts @@ -0,0 +1,49 @@ +import { container } from '../container.js' +import { MySQL, MySQLConfig, Server } from '../../types.js' + +export function mysql(config: MySQLConfig): MySQL { + return { + ...config, + host: config.name, + port: 3306, + get_deploy_tasks: (server) => get_deploy_tasks(server, config), + get_destroy_tasks: (server) => get_destroy_tasks(server, config), + } +} + +function get_deploy_tasks(server: Server, config: MySQLConfig) { + const compose = config.compose || {} + if (!compose.image) { + compose.image = `mysql` + if (config.version) compose.image += ':' + config.version + } + compose.environment = { + MYSQL_ROOT_PASSWORD: config.root_password, + MYSQL_DATABASE: config.name, + MYSQL_USER: config.user, + MYSQL_PASSWORD: config.pass, + ...(compose.environment || {}), + } + compose.expose ||= [] + compose.expose.push('3306') + if (config.exposed_port) { + compose.ports ||= [] + compose.ports.push(`${config.exposed_port}:3306`) + } + + const files: Record = {} + if (config.config) { + files['custom.cnf'] = config.config + compose.volumes ||= [] + compose.volumes.push('./custom.cnf:/etc/mysql/conf.d/custom.cnf:ro') + } + + const tasks = container({ name: config.name, compose, files }).get_deploy_tasks(server) + return tasks +} + +function get_destroy_tasks(server: Server, config: MySQLConfig) { + const compose = config.compose || {} + const tasks = container({ name: config.name, compose }).get_destroy_tasks(server) + return tasks +} diff --git a/src/services/database/postgres.ts b/src/services/database/postgres.ts new file mode 100644 index 0000000..c436494 --- /dev/null +++ b/src/services/database/postgres.ts @@ -0,0 +1,46 @@ +import { container } from '../container.js' +import { Postgres, PostgresConfig, Server } from '../../types.js' + +export function postgres(config: PostgresConfig): Postgres { + return { + ...config, + host: config.name, + port: 5432, + get_deploy_tasks: (server) => get_deploy_tasks(server, config), + get_destroy_tasks: (server) => get_destroy_tasks(server, config), + } +} + +function get_deploy_tasks(server: Server, config: PostgresConfig) { + const compose = config.compose || {} + if (!compose.image) { + compose.image = `postgres` + if (config.version) compose.image += ':' + config.version + } + compose.environment = { + POSTGRES_USER: config.user, + POSTGRES_PASSWORD: config.pass, + POSTGRES_DB: config.name, + ...(compose.environment || {}), + } + compose.expose ||= [] + compose.expose.push('5432') + if (config.exposed_port) { + compose.ports ||= [] + compose.ports.push(`${config.exposed_port}:5432`) + } + const files: Record = {} + if (config.config) { + files['postgresql.conf'] = config.config + compose.volumes ||= [] + compose.volumes.push('./postgresql.conf:/etc/postgresql/postgresql.conf') + } + const tasks = container({ name: config.name, compose, files }).get_deploy_tasks(server) + return tasks +} + +function get_destroy_tasks(server: Server, config: PostgresConfig) { + const compose = config.compose || {} + const tasks = container({ name: config.name, compose }).get_destroy_tasks(server) + return tasks +} diff --git a/src/services/database/redis.ts b/src/services/database/redis.ts new file mode 100644 index 0000000..93d1d14 --- /dev/null +++ b/src/services/database/redis.ts @@ -0,0 +1,40 @@ +import { container } from '../container.js' +import { Redis, RedisConfig, Server } from '../../types.js' + +export function redis(config: RedisConfig): Redis { + return { + ...config, + host: config.name, + port: 6379, + get_deploy_tasks: (server) => get_deploy_tasks(server, config), + get_destroy_tasks: (server) => get_destroy_tasks(server, config), + } +} + +function get_deploy_tasks(server: Server, config: RedisConfig) { + const compose = config.compose || {} + if (!compose.image) { + compose.image = `redis` + if (config.version) compose.image += ':' + config.version + } + if (config.exposed_port) { + compose.ports ||= [] + compose.ports.push(`${config.exposed_port}:6379`) + } + + const files: Record = {} + if (config.config) { + files['redis.conf'] = config.config + compose.volumes ||= [] + compose.volumes.push('./redis.conf:/usr/local/etc/redis/redis.conf') + } + + const tasks = container({ name: config.name, compose, files }).get_deploy_tasks(server) + return tasks +} + +function get_destroy_tasks(server: Server, config: RedisConfig) { + const compose = config.compose || {} + const tasks = container({ name: config.name, compose }).get_destroy_tasks(server) + return tasks +} diff --git a/src/services/index.ts b/src/services/index.ts index 39938ab..d51b8b4 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,2 +1,4 @@ -export * from './assertions.js' export * from './container.js' +export * from './assertions.js' +export * as app from './app/index.js' +export * as db from './database/index.js' diff --git a/src/types.ts b/src/types.ts index b950603..9540529 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { Role, Playbook } from './ansible/types.js' +import { Tasks, Playbook } from './ansible/types.js' import * as compose from './compose.types.js' import { ChildProcess, SpawnOptions } from 'child_process' @@ -26,20 +26,25 @@ export type DockerConnection = { export type ServerConfig = { name: string - ssh_key: { + ssh_key?: { path: string passphrase: string } - git_config: { name: string; email: string } + git_config?: { name?: string; email?: string } hosty_dir?: string docker_network?: string + docker_prefix?: string connection?: LocalConnection | SshConnection | DockerConnection } -export type Server = Required +export type Server = Required & { + services_dir: string + backups_dir: string +} export type Service = { - get_roles: (server: Server) => Role[] + get_deploy_tasks: (server: Server) => Tasks + get_destroy_tasks: (server: Server) => Tasks } export type ContainerConfig = { @@ -50,17 +55,45 @@ export type ContainerConfig = { } export type Container = Service & ContainerConfig -export type PostgresConfig = { +export type PostgresConfig = Omit & { version?: string - name: string user: string pass: string exposed_port?: number + config?: string + compose?: compose.Service } -export type Postgres = Service & - PostgresConfig & { - host: string - } +export type Postgres = Service & PostgresConfig & { host: string; port: number } + +export type MySQLConfig = Omit & { + version?: string + user: string + pass: string + root_password: string + exposed_port?: number + config?: string + compose?: compose.Service +} +export type MySQL = Service & MySQLConfig & { host: string; port: number } + +export type RedisConfig = Omit & { + version?: string + exposed_port?: number + config?: string + compose?: compose.Service +} +export type Redis = Service & RedisConfig & { host: string; port: number } + +export type GitAppConfig = { + name: string + repo: string + branch: string + domain?: string + env?: Record + compose?: compose.Service +} + +export type GitApp = Service & GitAppConfig export type Assertions = Service @@ -68,11 +101,13 @@ export type RunOptions = { playbookPath: string ask_sudo_pass: boolean spawn_options: Partial + ansible_options: string[] } export type HostyInstance = { - deploy: (server: Server, services: Service[]) => void + deploy: (server: Server, ...services: Service[]) => void + destroy: (server: Server, ...services: Service[]) => void playbook: () => Playbook write: (playbookPath: string) => Promise - run: (options: Partial) => Promise + run: (options?: Partial) => Promise } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..78685ba --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,10 @@ +export function unindent(text: string) { + const lines = text.trim().split(`\n`) + const indents = lines.map((line) => { + let i = 0 + while (i < line.length && line.charAt(i) === ' ') i++ + return i + }) + const minIndent = indents.reduce((res, x) => Math.min(res, x), indents[0]) + return lines.map((line) => line.slice(minIndent)).join(`\n`) + `\n` +} diff --git a/story.md b/story.md deleted file mode 100644 index e69de29..0000000 diff --git a/tasks.todo b/tasks.todo index 0d28132..5c8b2cd 100644 --- a/tasks.todo +++ b/tasks.todo @@ -1,19 +1,29 @@ databases: - ☐ postgres - ☐ mysql - ☐ redis + ✔ postgres @done + ✔ mysql @done + ✔ redis @done ☐ mongodb features: ☐ auto backups -git_repo: - ☐ clone, package, run - ☐ redo only on change - ☐ specific branch +app.git: + ✔ clone, package, run + ✔ redo only on change + ✔ specific branch -github action: - ☐ deploy on merge into the deployed branch - ☐ deploy any branch on demand? (manual step on github actions) +add destroy: + ✔ add a `destroy` method to undo deploy of a service, app, ... @done + ✔ update tests to support destroy @done + +☐ custom dockerfile +☐ number of instances + use load balancing with ip_hash if has domain +☐ synchronous commands/asserts during deploy + +github actions: + ☐ Create an example repo: + deploy any branch on demand + destroy on branch deletion how to duplicate related containers like db? test apps: diff --git a/tests/app-laravel-mysql-custom-docker.test.ts b/tests/app-laravel-mysql-custom-docker.test.ts new file mode 100644 index 0000000..9c66aba --- /dev/null +++ b/tests/app-laravel-mysql-custom-docker.test.ts @@ -0,0 +1,34 @@ +import { test } from './utils/index.js' +import { app, db } from '../src/index.js' +import { setTimeout } from 'timers/promises' + +test('app: laravel + mysql + custom dockerfile', async ({ deploy, destroy, assert }) => { + const database = db.mysql({ name: 'laravel-db', user: 'laravel', pass: 'laravel', root_password: 'topsecret' }) + const laravel_app = app.git({ + name: 'laravel-app', + repo: 'https://github.com/webNeat/hosty-test-apps.git', + branch: 'laravel-mysql', + domain: 'laravel.local', + env: { + APP_PORT: '80', + DB_HOST: database.host, + DB_USER: database.user, + DB_PASS: database.pass, + DB_NAME: database.name, + }, + }) + + deploy(database, laravel_app) + assert.command(`sleep 30`, { success: true }) + assert.command(`curl -k https://laravel.local`, { success: true, stdout: `'{"hello":"world!"}'` }) + assert.command(`curl -k https://laravel.local/users`, { + success: true, + stdout_contains: '[{"id":1,"name":"Test User","email":"test@example.com","email_verified_at":null,', + }) + + destroy(laravel_app, database) + assert.file(`/srv/hosty/services/laravel-app`, { exists: false }) + assert.command(`docker ps -q --filter "name=laravel-app"`, { stdout: '' }, { become: true }) + assert.command(`docker ps -q --filter "name=laravel-db"`, { stdout: '' }, { become: true }) + assert.command(`curl -k https://laravel.local`, { success: false, stderr_contains: 'Could not resolve host: laravel.local' }) +}) diff --git a/tests/app-node-postgres.test.ts b/tests/app-node-postgres.test.ts new file mode 100644 index 0000000..a2e9ced --- /dev/null +++ b/tests/app-node-postgres.test.ts @@ -0,0 +1,38 @@ +import { test } from './utils/index.js' +import { app, db } from '../src/index.js' + +test('app: node + postgres', async ({ deploy, destroy, assert }) => { + const database = db.postgres({ name: 'todo-db', user: 'tasks_user', pass: 'tasks_pass' }) + const todo_app = app.git({ + name: 'todo', + repo: 'https://github.com/webNeat/hosty-test-apps.git', + branch: 'node-postgres', + domain: 'todo.local', + env: { + APP_PORT: '80', + DB_HOST: database.host, + DB_USER: database.user, + DB_PASS: database.pass, + DB_NAME: database.name, + }, + }) + + deploy(database, todo_app) + assert.command(`curl -k https://todo.local`, { success: true, stdout: '[]' }) + assert.command(`curl -k -X POST https://todo.local -H "Content-Type: application/json" -d '{"content":"first task"}'`, { + success: true, + stdout: '{"id":1,"content":"first task","state":"pending"}', + }) + assert.command(`curl -k -X POST https://todo.local -H "Content-Type: application/json" -d '{"content":"second task"}'`, { + success: true, + stdout: '{"id":2,"content":"second task","state":"pending"}', + }) + assert.command(`curl -k https://todo.local`, { + success: true, + stdout: '[{"id":1,"content":"first task","state":"pending"},{"id":2,"content":"second task","state":"pending"}]', + }) + + destroy(todo_app, database) + assert.file(`/srv/hosty/services/todo`, { exists: false }), assert.command(`docker ps -q --filter "name=todo"`, { stdout: '' }, { become: true }) + assert.command(`curl -k https://todo.local`, { success: false, stderr_contains: 'Could not resolve host: todo.local' }) +}) diff --git a/tests/container.test.ts b/tests/container.test.ts index 840ebf6..907bd8b 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -1,31 +1,43 @@ +import { readFile } from 'fs/promises' import { test } from './utils/index.js' -import { assert, assertions, container } from '../src/index.js' +import { container } from '../src/index.js' -test('simple docker container', { - services: [ - container({ - name: 'foo', - compose: { +test('simple container', async ({ deploy, destroy, assert }) => { + const test_container = container({ + name: 'foo', + compose: { + image: 'nginx', + ports: ['8080:8080'], + }, + files_dir: 'tests/files', + files: { + 'baz.txt': 'Yo', + 'inner/lorem.txt': 'ipsum', + }, + }) + + deploy(test_container) + assert.command(`docker ps --filter "name=foo"`, { stdout_contains: 'foo' }, { become: true }) // the `foo` container is running + assert.yaml(`/srv/hosty/services/foo/compose.yaml`, { + services: { + foo: { + container_name: 'foo', + networks: ['hosty'], + restart: 'unless-stopped', image: 'nginx', ports: ['8080:8080'], }, - }), - ], - assertions: assertions( - assert.command(`docker ps --filter "name=foo"`, { stdout_contains: 'foo' }, { become: true }), // the `foo` container is running - assert.yaml(`/srv/hosty/services/foo/compose.yaml`, { - services: { - foo: { - container_name: 'foo', - networks: ['hosty'], - restart: 'unless-stopped', - image: 'nginx', - ports: ['8080:8080'], - }, - }, - networks: { - hosty: { external: true }, - }, - }), - ), + }, + networks: { + hosty: { external: true }, + }, + }) + assert.file(`/srv/hosty/services/foo/foo.txt`, { content_equals: await readFile(`tests/files/foo.txt`, 'utf8') }) + assert.file(`/srv/hosty/services/foo/bar.txt`, { content_equals: await readFile(`tests/files/bar.txt`, 'utf8') }) + assert.file(`/srv/hosty/services/foo/baz.txt`, { content_equals: 'Yo' }) + assert.file(`/srv/hosty/services/foo/inner/lorem.txt`, { content_equals: 'ipsum' }) + + destroy(test_container) + assert.command(`docker ps -q --filter "name=foo"`, { stdout: '' }, { become: true }) // the `foo` doesn't exist + assert.file(`/srv/hosty/services/foo`, { exists: false }) }) diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 192e8b7..79da2c5 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -1,11 +1,8 @@ import { test } from './utils/index.js' -import { assert, assertions } from '../src/index.js' -test('setup', { - services: [], - assertions: assertions( - assert.command(`docker --version`, { success: true }), - assert.command(`git --version`, { success: true }), - assert.command(`nixpacks --version`, { success: true }), - ), +test('setup', async ({ assert }) => { + assert.command(`docker --version`, { success: true }) + assert.command(`git --version`, { success: true }) + assert.command(`nixpacks --version`, { success: true }) + assert.command(`systemctl is-active caddy`, { stdout: 'active' }) }) diff --git a/tests/utils/index.ts b/tests/utils/index.ts index df93c2a..a062a59 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,11 +1,19 @@ import * as zx from 'zx' -import { Assertions, Service, instance, server } from '../../src/index.js' import { ChildProcess } from 'child_process' +import { HostyInstance, Server, Service, assert, assertions, instance, server } from '../../src/index.js' +type Assert = { + [K in keyof typeof assert]: (...args: Parameters<(typeof assert)[K]>) => void +} +type TestContext = { + deploy: (...services: Service[]) => void + destroy: (...services: Service[]) => void + assert: Assert +} +type TestFn = (ctx: TestContext) => Promise type TestCase = { name: string - services: Service[] - assertions: Assertions + fn: TestFn } type Failure = { name: string @@ -15,28 +23,25 @@ type Failure = { const cases: TestCase[] = [] const $ = zx.$({ quiet: true }) -export async function test(name: string, config: { services: Service[]; assertions: Assertions }) { - cases.push({ name, ...config }) +export async function test(name: string, fn: TestFn) { + cases.push({ name, fn }) } export async function run() { console.log(`Checking dependencies ...`) - if ((await $`docker --version`).exitCode) { - throw new Error('`docker` is required to run tests, please install it first then try again!') - } if ((await $`ansible-playbook --version`).exitCode) { throw new Error('`ansible` is required to run tests, please install it first then try again!') } - - console.log(`Building hosty-test docker image ...`) - await $`docker build -t hosty-test tests/utils` + await $`docker ps -q --filter "name=^/hosty-" | xargs -r docker stop` + await $`docker ps -aq --filter "name=^/hosty-" | xargs -r docker rm` + await $`sudo rm -rf /srv/hosty` console.log(`Running test cases ...`) const failures: Failure[] = [] for (const x of cases) { try { console.log(`⏳ ${x.name}`) - await runTestCase(x) + await run_test_case(x) console.log(`✅ ${x.name}`) } catch (err) { console.log(`❌ ${x.name}`) @@ -60,41 +65,39 @@ export async function run() { process.exit(1) } -async function runTestCase({ name, services, assertions }: TestCase) { +async function run_test_case({ name, fn }: TestCase) { const test_name = name.replace(/[^a-zA-Z0-9]/g, '-') - const container_name = `hosty-test-${test_name}` const playbookPath = `.tests/${test_name}.yaml` - await $`docker rm -f ${container_name}` - await $`docker run -d --name ${container_name} --privileged -v /var/run/docker.sock:/var/run/docker.sock -e ANSIBLE_FORCE_COLOR=true hosty-test` + const user = (await $`whoami`).stdout.trim() const container = server({ name: 'localhost', - connection: { type: 'docker', container: container_name, user: 'foo', password: 'foo' }, - ssh_key: { path: '/home/foo/.ssh/id_rsa', passphrase: '' }, + connection: { type: 'local', user }, + ssh_key: { path: '~/.ssh/id_rsa', passphrase: '' }, git_config: { name: 'Amine Ben hammou', email: 'webneat@gmail.com' }, }) const test_instance = instance() - test_instance.deploy(container, [...services, assertions]) + await fn(make_test_context(container, test_instance)) - const res = await waitProcess(await test_instance.run({ playbookPath, ask_sudo_pass: false, spawn_options: { stdio: 'pipe' } })) - if (res.exitCode) throw res.stderr - await $`docker stop ${container_name}` - await $`docker rm ${container_name}` + const res = await wait_process(await test_instance.run({ playbookPath, ask_sudo_pass: false, spawn_options: { stdio: 'pipe' } })) + if (res.exitCode) throw res.output + await $`docker ps -q --filter "name=^/hosty-" | xargs -r docker stop` + await $`docker ps -aq --filter "name=^/hosty-" | xargs -r docker rm` + await $`sudo rm -rf /srv/hosty` } type ProcessResult = { exitCode: number | null - stdout: string - stderr: string + output: string } -async function waitProcess(ps: ChildProcess) { +async function wait_process(ps: ChildProcess) { return new Promise((resolve, reject) => { - const res: ProcessResult = { exitCode: null, stdout: '', stderr: '' } + const res: ProcessResult = { exitCode: null, output: '' } ps.stdout?.on('data', (data) => { - res.stdout += data.toString() + res.output += data.toString() }) ps.stderr?.on('data', (data) => { - res.stderr += data.toString() + res.output += data.toString() }) ps.on('close', (code) => { res.exitCode = code @@ -103,3 +106,19 @@ async function waitProcess(ps: ChildProcess) { ps.on('error', reject) }) } + +function make_test_context(server: Server, instance: HostyInstance): TestContext { + const boundAssert: Partial = {} + for (const name in assert) { + const fnName = name as keyof typeof assert + boundAssert[fnName] = (...args) => { + // @ts-ignore + instance.deploy(server, assertions(assert[fnName](...args))) + } + } + return { + deploy: (...services) => instance.deploy(server, ...services), + destroy: (...services) => instance.destroy(server, ...services), + assert: boundAssert as Assert, + } +}