diff --git a/.gitignore b/.gitignore index f1ed431..d0d4f24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules dist .tests hvps +lab +.aider* diff --git a/README.md b/README.md index ee4afef..7946f90 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,59 @@ # Hosty -**This package is still under development, not ready for use yet!** - A code based opinionated way to self-host and manage web apps. -1. You write code describing what you want to deploy. For example: +# Quick Example + ```ts -import {app, deploy, host, database} from 'hosty' - -// a Postgres database -const db = database({ - type: 'postgres', - name: 'awesome', - user: 'myuser', - pass: 'mypass', +import {app, db, deploy, run} from 'hosty' + +// 1. Specify what you want to deploy + +// A postgres database +const database = db.postgres({ + name: 'my-db', + user: 'db_user', + pass: 'db_pass' }) -// a web app that uses the db above -const myapp = app({ - domain: 'your-domain.com', - repo: 'https://github.com/....git', +// An application from a Git repo +const api = app.git({ + name: 'my-api', + repo: 'https://url-to-my-repo.git', branch: 'main', + domain: 'my-api-domain.com', env: { - DB_HOST: db.host, - DB_USER: db.user, - DB_PASS: db.pass, - DB_NAME: db.name, - } + PORT: '80', + DB_HOST: database.host, + DB_USER: database.user, + DB_PASS: database.pass, + DB_NAME: database.name, + }, }) -// The server to which you want to deploy -const server = host({ - address: 'domain name or IP' +// 2. Specify where you want deploy +const myVPS = server({ + name: '188.114.97.6' // hostname or IP }) -// Deploy the app and database to the server -deploy(server, [db, myapp]) +// 3. Deploy +deploy(myVPS, database, api) +run() ``` -2. You run `npx hosty deploy` to apply what you described. - -That's it, the database is created and your app is now deployed to `https://your-domain.com` (Yes, the SSL certificate is also taken care off!). -## Prerequisits +This code will do the following: +1. Connect to your server via SSH +2. Create the postgres database +3. Clone your repo, build and run it +4. Configure the domain with https support -1. A Linux server to which you have SSH access. - - This can be a VPS, a home-lab server or any Linux machine that has a static IP. - - The user by which you connect should have the `sudo` ability. - - Only **Ubuntu** servers are supported right now. - -2. [Ansible](https://www.ansible.com/) installed on your local machine. - -## Get started - -``` -npm i hosty -``` +# Requirements +**On local machine:** +- [Ansible](https://www.ansible.com/) (tested with v2.16.6) +- Nodejs (tested with v22.8) +**On the server** +- A Linux server that uses `apt` and `systemctl` (tested on Ubuntu 22.04) +- A user with `sudo` ability (using the root user is not recommended) -**This package is still under development, not ready for use yet!** \ No newline at end of file +... \ No newline at end of file diff --git a/files.txt b/files.txt new file mode 100644 index 0000000..a062896 --- /dev/null +++ b/files.txt @@ -0,0 +1,20 @@ +/srv/hosty/ + services/ + db-foo/ + compose.yaml + ... + app-foo/ + .ports + local ports 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..e2c1de0 100644 --- a/src/ansible/tasks/builtin.ts +++ b/src/ansible/tasks/builtin.ts @@ -4,79 +4,97 @@ 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> { +export 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 } } -type SetupAttrs = { fact_path?: string; filter?: string[]; gather_subset?: string[]; gather_timeout?: number } +export type SetupAttrs = { fact_path?: string; filter?: string[]; gather_subset?: string[]; gather_timeout?: number } export function setup(name: string, attrs: SetupAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.setup', SetupAttrs> { return { name, 'ansible.builtin.setup': attrs, ...common } } -type StatAttrs = { path: string } +export type StatAttrs = { path: string } export function stat(name: string, attrs: StatAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.stat', StatAttrs> { 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 } +export 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 } } -type LineInFileAttrs = { path: string; line: string; state: 'present' | 'absent' } +export type LineInFileAttrs = { path: string; line: string; state: 'present' | 'absent' } export function lineinfile(name: string, attrs: LineInFileAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.lineinfile', LineInFileAttrs> { return { name, 'ansible.builtin.lineinfile': attrs, ...common } } -type TemplateAttrs = { src: string; dest: string } +export 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 } +} + +export 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 } } -type CopyAttrs = { src: string; dest: string } | { content: string; dest: string } +export type CopyAttrs = { src: string; dest: string } | { content: string; dest: string } export function copy(name: string, attrs: CopyAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.copy', CopyAttrs> { return { name, 'ansible.builtin.copy': attrs, ...common } } -type GetUrlAttrs = { url: string; dest: string; mode?: string } +export type GetUrlAttrs = { url: string; dest: string; mode?: string } export function get_url(name: string, attrs: GetUrlAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.get_url', GetUrlAttrs> { return { name, 'ansible.builtin.get_url': attrs, ...common } } -type GitAttrs = { repo: string; dest: string; version: string; accept_hostkey: boolean } +export type GitAttrs = { repo: string; dest: string; version: string; accept_hostkey: boolean } export function git(name: string, attrs: GitAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.git', GitAttrs> { return { name, 'ansible.builtin.git': attrs, ...common } } -type CommandAttrs = { cmd: string; chdir?: string; creates?: string; removes?: string } +export type CommandAttrs = { cmd: string; chdir?: string; creates?: string; removes?: string } export function command(name: string, attrs: CommandAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.command', CommandAttrs> { return { name, 'ansible.builtin.command': attrs, ...common } } -type ShellAttrs = { cmd: string; chdir?: string; creates?: string; removes?: string } +export 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 } } -type ServiceAttrs = { name: string; state: 'started' | 'stopped' | 'restarted' } +export type CronAttrs = { + name: string + job: string + state?: 'present' | 'absent' + minute?: string | number + hour?: string | number + day?: string | number + weekday?: string | number + month?: string | number + special_time?: 'annually' | 'daily' | 'hourly' | 'monthly' | 'reboot' | 'weekly' | 'yearly' +} +export function cron(name: string, attrs: CronAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.cron', CronAttrs> { + return { name, 'ansible.builtin.cron': attrs, ...common } +} + +export type ServiceAttrs = { name: string; state: 'started' | 'stopped' | 'restarted' } export function service(name: string, attrs: ServiceAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.service', ServiceAttrs> { return { name, 'ansible.builtin.service': attrs, ...common } } -type AptAttrs = { name?: string | string[]; deb?: string; state?: 'present' | 'absent'; update_cache?: boolean; cache_valid_time?: number } +export type AptAttrs = { name?: string | string[]; deb?: string; state?: 'present' | 'absent'; update_cache?: boolean; cache_valid_time?: number } export function apt(name: string, attrs: AptAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.apt', AptAttrs> { return { name, 'ansible.builtin.apt': attrs, ...common } } -type AptKeyAttrs = { url: string; state: 'present' | 'absent' } +export type AptKeyAttrs = { url: string; state: 'present' | 'absent' } export function apt_key(name: string, attrs: AptKeyAttrs, common: CommonTaskAttrs = {}): Task<'ansible.builtin.apt_key', AptKeyAttrs> { return { name, 'ansible.builtin.apt_key': attrs, ...common } } -type AptRepositoryAttrs = { repo: string; state: 'present' | 'absent'; filename?: string } +export type AptRepositoryAttrs = { repo: string; state: 'present' | 'absent'; filename?: string } export function apt_repository( name: string, attrs: AptRepositoryAttrs, @@ -89,7 +107,12 @@ export function service_facts(name: string, common: CommonTaskAttrs = {}): Task< return { name, 'ansible.builtin.service_facts': {}, ...common } } -type AssertAttrs = { that: string; fail_msg?: string; success_msg?: string } +export 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 } } + +export 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..50fc749 --- /dev/null +++ b/src/blocks/build_repo.ts @@ -0,0 +1,48 @@ +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 + } + path?: 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}}', path: config.path } + const build_path = config.path ? path.join('{{clone_dir.path}}', config.path) : '{{clone_dir.path}}' + 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: path.join(build_path, 'Dockerfile') }, { register: 'dockerfile', when: 'source_file.changed' }), + builtin.command( + `Build the app using Dockerfile`, + { cmd: `docker build -t ${config.image_name} ${build_path}` }, + { when: 'source_file.changed and dockerfile.stat.exists' }, + ), + builtin.command( + `Build the app using nixpacks`, + { cmd: `nixpacks build ${build_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..f88b445 --- /dev/null +++ b/src/blocks/create_domain.ts @@ -0,0 +1,28 @@ +import { builtin } from '../ansible/tasks/index.js' +import { Block } from '../ansible/types.js' +import { block } from './block.js' + +type Config = { + domain: string + ports_var: string + caddyfile_path: string +} + +const reverse_proxy = (x: Config) => + `${x.domain} { + reverse_proxy {{ ${x.ports_var} | map('regex_replace', '^', '127.0.0.1:') | join(' ') }} { + lb_policy client_ip_hash + } +}` + +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..f55c1ec --- /dev/null +++ b/src/blocks/create_service.ts @@ -0,0 +1,122 @@ +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 | ComposeService[] + restart_conditions?: string[] + before_start?: string[] +} + +export function create_service({ name, service_dir, docker_network, compose, files_dir, files, restart_conditions, before_start }: Config): Block { + restart_conditions ||= [] + const x = block(`Create service: ${name}`) + + 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(get_compose_file({ name, compose, docker_network })), 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"') + + const restart_condition = restart_conditions.join(' or ') + + const container_name = `${name}-1` + if (before_start) { + for (const cmd of before_start) { + x.add( + builtin.command( + `Run command before start: ${cmd}`, + { chdir: service_dir, cmd: `docker compose run --rm ${container_name} ${cmd}` }, + { become: true, when: restart_condition }, + ), + ) + } + } + + x.add( + builtin.command( + `Start service ${name}`, + { chdir: service_dir, cmd: `docker compose up -d --force-recreate --remove-orphans` }, + { become: true, when: restart_condition }, + ), + ) + + return x.get() +} + +function get_compose_file({ name, compose, docker_network }: Pick): ComposeFile { + const networks = { + [docker_network]: { external: true }, + } + const services: ComposeFile['services'] = {} + if (!Array.isArray(compose)) { + services[name] = { + container_name: name, + networks: [docker_network], + restart: 'unless-stopped', + ...compose, + } + } else { + let instance = 1 + for (const service of compose) { + const container_name = `${name}-${instance}` + services[container_name] = { + container_name, + networks: [docker_network], + restart: 'unless-stopped', + ...service, + } + instance++ + } + } + return { services, networks } +} 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..aa20042 --- /dev/null +++ b/src/blocks/set_available_port.ts @@ -0,0 +1,59 @@ +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_ports(service_dir: string, count: number, var_name: string) { + const port_file = path.join(service_dir, '.ports') + const cmd = ` + # Initialize variables + ports=() + existing_ports=() + count=0 + desired_count=${count} + + # Read existing ports from the file, if it exists + if [ -f ${port_file} ]; then + while IFS= read -r line; do + existing_ports+=("$line") + done < ${port_file} + fi + + # Add existing ports to the final list + for port in "\${existing_ports[@]}"; do + ports+=("$port") + count=$((count + 1)) + [ $count -eq $desired_count ] && break + done + + # If we still need more ports, find and add available ones + if [ $count -lt $desired_count ]; then + for port in $(seq 8000 9000); do + # Skip ports already in the list + if [[ " \${ports[*]} " == *" $port "* ]]; then + continue + fi + (echo >/dev/tcp/localhost/$port) &>/dev/null && continue || ports+=($port) + count=$((count + 1)) + [ $count -eq $desired_count ] && break + done + fi + + # If we have too many ports, trim the list + if [ $count -gt $desired_count ]; then + ports=("\${ports[@]:0:$desired_count}") + fi + + # Write the ports to the file, one per line + > ${port_file} # Clear the file before writing + for port in "\${ports[@]}"; do + echo "$port" >> ${port_file} + done + ` + return block(`Generate ${count} available ports for ${service_dir} into the var ${var_name}`, {}, [ + create_directory(service_dir), + builtin.shell(`Generate an available port in ${port_file}`, { cmd, executable: '/bin/bash' }), + builtin.command(`Read the ports from ${port_file}`, { cmd: `cat ${port_file}` }, { register: 'cat_ports' }), + builtin.set_facts(`Set the ports in the var ${var_name}`, { [var_name]: `{{cat_ports.stdout_lines}}` }), + ]).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..b74bb24 100644 --- a/src/instance.ts +++ b/src/instance.ts @@ -3,38 +3,50 @@ 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 = { ask_sudo_pass: true, - playbookPath: 'hosty-playbook.yaml', + playbook_path: 'hosty-playbook.yaml', 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 } @@ -45,13 +57,14 @@ export function instance(): HostyInstance { const run = async (userOptions: Partial = {}) => { const options = { ...defaultRunOptions, ...userOptions } - await write(options.playbookPath) - const args = [options.playbookPath] + await write(options.playbook_path) + const args = [options.playbook_path] 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..d33b0e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,18 +1,29 @@ -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' + const backups_dir = path.join(hosty_dir, 'backups') + const services_dir = path.join(hosty_dir, 'services') return { connection, + hosty_dir, + backups_dir, + services_dir, 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 || {}, docker_network: config.docker_network || 'hosty', + docker_prefix: config.docker_prefix || '', + get_service_dir: (name) => path.join(services_dir, name), + get_backups_dir: (name) => path.join(backups_dir, name), } } @@ -38,11 +49,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..e12dbf2 --- /dev/null +++ b/src/services/app/git.ts @@ -0,0 +1,77 @@ +import path from 'path' +import { Tasks } from '../../ansible/types.js' +import * as blocks from '../../blocks/index.js' +import { GitApp, GitAppConfig, Server } from '../../types.js' + +export function git(config: GitAppConfig): GitApp { + return { + ...config, + type: 'app.git', + 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: GitAppConfig): Tasks { + config.path ||= '.' + config.instances ||= 1 + const tasks: Tasks = [] + const service_dir = path.join(server.hosty_dir, 'services', config.name) + + if (config.domain) { + tasks.push(blocks.set_available_ports(service_dir, config.instances, 'app_ports')) + } + + tasks.push( + blocks.build_repo({ + repo_url: config.repo, + branch: config.branch, + service_dir, + image_name: config.name, + facts: { source_changed: 'source_changed' }, + path: config.path, + }), + ) + + const service = blocks.create_service({ + name: config.name, + compose: make_composes(config), + docker_network: server.docker_network, + service_dir, + restart_conditions: ['source_changed'], + before_start: config.before_start, + }) + tasks.push(service) + + if (config.domain) { + tasks.push(blocks.create_domain({ domain: config.domain, ports_var: 'app_ports', caddyfile_path: path.join(service_dir, 'Caddyfile') })) + } + return tasks +} + +function get_destroy_tasks(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_composes(config: GitAppConfig) { + const compose = config.compose || {} + compose.image = config.name + compose.environment = { ...(config.env || {}), ...(compose.environment || {}) } + compose.ports ||= [] + + const composes = [] + for (let i = 1; i <= config.instances!; i++) { + composes.push({ + ...compose, + ports: [...compose.ports, `{{app_ports[${i - 1}]}}:80`], + }) + } + return composes +} 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..25382d7 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 { type: 'assertions', get_deploy_tasks: () => tasks, get_destroy_tasks: () => [] } } diff --git a/src/services/command.ts b/src/services/command.ts new file mode 100644 index 0000000..2d095af --- /dev/null +++ b/src/services/command.ts @@ -0,0 +1,45 @@ +import { Tasks } from '../ansible/types.js' +import { builtin } from '../ansible/tasks/index.js' +import { Command, CommandConfig, Server } from '../types.js' + +export function command(config: CommandConfig): Command { + return { + ...config, + type: 'command', + 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: CommandConfig): Tasks { + const cmd = get_command_prefix(server, config.service) + config.cmd + if (config.cron) { + let attrs: builtin.CronAttrs = { name: config.name, job: cmd } + if (typeof config.cron === 'string') { + attrs.special_time = config.cron + } else { + attrs = { ...attrs, ...config.cron } + } + return [builtin.cron(`Setup cron ${config.name}`, attrs)] + } + return [builtin.shell(`Run command ${config.name}`, { cmd, executable: '/bin/bash' })] +} + +function get_command_prefix(server: Server, service?: CommandConfig['service']) { + if (!service) return '' + const service_dir = server.get_service_dir(service.name) + let container_name = service.name + if (service.type === 'app.git') container_name += '-1' + return `cd ${service_dir} && docker compose run --rm ${container_name} ` +} + +function get_destroy_tasks(_: Server, config: CommandConfig): Tasks { + if (!config.cron) return [] + return [ + builtin.cron(`Delete cron ${config.name}`, { + name: config.name, + job: config.cmd, + state: 'absent', + }), + ] +} diff --git a/src/services/container.ts b/src/services/container.ts index 71cdd49..a0b6837 100644 --- a/src/services/container.ts +++ b/src/services/container.ts @@ -1,37 +1,31 @@ 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, + type: 'container', + 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 }: 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..87844f1 --- /dev/null +++ b/src/services/database/mysql.ts @@ -0,0 +1,50 @@ +import { container } from '../container.js' +import { MySQL, MySQLConfig, Server } from '../../types.js' + +export function mysql(config: MySQLConfig): MySQL { + return { + ...config, + type: 'db.mysql', + 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..1c94c25 --- /dev/null +++ b/src/services/database/postgres.ts @@ -0,0 +1,47 @@ +import { container } from '../container.js' +import { Postgres, PostgresConfig, Server } from '../../types.js' + +export function postgres(config: PostgresConfig): Postgres { + return { + ...config, + type: 'db.postgres', + 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..9e3048e --- /dev/null +++ b/src/services/database/redis.ts @@ -0,0 +1,41 @@ +import { container } from '../container.js' +import { Redis, RedisConfig, Server } from '../../types.js' + +export function redis(config: RedisConfig): Redis { + return { + ...config, + type: 'db.redis', + 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..ff95c91 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,2 +1,5 @@ +export * as app from './app/index.js' export * from './assertions.js' +export * from './command.js' export * from './container.js' +export * as db from './database/index.js' diff --git a/src/types.ts b/src/types.ts index b950603..7eff248 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,28 @@ 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 + get_service_dir: (name: string) => string + get_backups_dir: (name: string) => string +} -export type Service = { - get_roles: (server: Server) => Role[] +export type Service = { + type: Type + get_deploy_tasks: (server: Server) => Tasks + get_destroy_tasks: (server: Server) => Tasks } export type ContainerConfig = { @@ -47,32 +55,93 @@ export type ContainerConfig = { files_dir?: string files?: Record compose: compose.Service + before_start?: string[] } -export type Container = Service & ContainerConfig +export type Container = Service<'container'> & ContainerConfig + +export type Database = Postgres | MySQL | Redis -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<'db.postgres'> & 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<'db.mysql'> & MySQLConfig & { host: string; port: number } + +export type RedisConfig = Omit & { + version?: string + exposed_port?: number + config?: string + compose?: compose.Service +} +export type Redis = Service<'db.redis'> & RedisConfig & { host: string; port: number } + +export type App = GitApp + +export type GitAppConfig = { + name: string + repo: string + branch: string + domain?: string + instances?: number + env?: Record + compose?: compose.Service + before_start?: string[] + path?: string +} + +export type GitApp = Service<'app.git'> & GitAppConfig + +export type CommandConfig = { + name: string + cmd: string + service?: Container | App | Database + cron?: + | 'annually' + | 'daily' + | 'hourly' + | 'monthly' + | 'reboot' + | 'weekly' + | 'yearly' + | { + minute?: number | string // 0 - 59 + hour?: number | string // 0 - 23 + day?: number | string // 1 - 31 + weekday?: number | string // 0 - 6 + month?: number | string // 1 - 12 + } +} + +export type Command = Service<'command'> & CommandConfig -export type Assertions = Service +export type Assertions = Service<'assertions'> export type RunOptions = { - playbookPath: string + playbook_path: 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..4504af2 100644 --- a/tasks.todo +++ b/tasks.todo @@ -1,25 +1,42 @@ 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 + ✔ custom dockerfile @done + ✔ synchronous commands/asserts during deploy @done + ✔ number of instances @done -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 + +commands: + ✔ run commands before starting/restarting an app @done + ✔ run commands after starting/restarting an app @done + ✔ run commands periodically (a cron job) @done + ✔ choose to run the command inside a container or in the host machine @done + +☐ try monitoring with vector and axiom + +github actions: + ☐ Create an example repo: + deploy any branch on demand + destroy on branch deletion how to duplicate related containers like db? test apps: - ☐ static - ☐ Nextjs + ✔ static @done + ✔ Laravel @done + ✔ Adonis @done + ✔ Nextjs @done + ✔ Rust @done ☐ Remix - ☐ Laravel - ☐ Adonis ☐ Wordpress diff --git a/tests/app-adonis-sqlite.test.ts b/tests/app-adonis-sqlite.test.ts new file mode 100644 index 0000000..a5f91c3 --- /dev/null +++ b/tests/app-adonis-sqlite.test.ts @@ -0,0 +1,42 @@ +import { test } from './utils/index.js' +import { app, command } from '../src/index.js' + +test('app: adonis + migrations + custom dockerfile', async ({ deploy, destroy, assert }) => { + const api = app.git({ + name: 'adonis-api', + repo: 'https://github.com/webNeat/hosty-test-apps.git', + branch: 'adonis-sqlite', + domain: 'adonis-api.local', + compose: { + volumes: ['./storage:/app/storage'], + }, + env: { + TZ: 'UTC', + PORT: '80', + HOST: '0.0.0.0', + LOG_LEVEL: 'info', + APP_KEY: 'LijKdtScbgJP93CIPahDX_l5T8QSQ2-D', + NODE_ENV: 'production', + SESSION_DRIVER: 'cookie', + DB_PATH: './storage/db.sqlite', + }, + }) + const migration = command({ + name: 'run-migration', + cmd: 'node ace migration:run --force', + service: api, + }) + + deploy(api, migration) + assert.command(`docker ps --filter "name=adonis-api-1"`, { stdout_contains: 'adonis-api-1' }, { become: true }) + assert.command(`curl -k https://adonis-api.local`, { success: true, stdout: `{"hello":"world"}` }) + assert.command(`curl -k https://adonis-api.local/users`, { + success: true, + stdout: '[]', + }) + + destroy(api) + assert.file(`/srv/hosty/services/adonis-api`, { exists: false }) + assert.command(`docker ps -q --filter "name=adonis-api-1"`, { stdout: '' }, { become: true }) + assert.command(`curl -k https://adonis-api.local`, { success: false, stderr_contains: 'Could not resolve host: adonis-api.local' }) +}) 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..9c721c4 --- /dev/null +++ b/tests/app-laravel-mysql-custom-docker.test.ts @@ -0,0 +1,80 @@ +import { test } from './utils/index.js' +import { app, db } from '../src/index.js' + +test('app: laravel + mysql + custom dockerfile', async ({ deploy, destroy, assert }) => { + const database = db.mysql({ name: 'laravel-db', user: 'laravel_user', pass: 'laravel_pass', root_password: 'supersecretpass' }) + 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_NAME: 'Laravel App', + APP_ENV: 'production', + APP_KEY: 'base64:MwtP4zRRiQnznkVkAPbKwwB9768wKSwHp4hYF7P5B8k=', + APP_DEBUG: 'true', + APP_TIMEZONE: 'UTC', + APP_PORT: '80', + APP_URL: 'https://laravel-app.local', + APP_LOCALE: 'en', + APP_FALLBACK_LOCALE: 'en', + APP_FAKER_LOCALE: 'en_US', + APP_MAINTENANCE_DRIVER: 'file', + BCRYPT_ROUNDS: '12', + LOG_CHANNEL: 'stack', + LOG_STACK: 'single', + LOG_DEPRECATIONS_CHANNEL: 'null', + LOG_LEVEL: 'debug', + DB_CONNECTION: 'mysql', + DB_HOST: database.host, + DB_PORT: `${database.port}`, + DB_DATABASE: database.name, + DB_USERNAME: database.user, + DB_PASSWORD: database.pass, + SESSION_DRIVER: 'database', + SESSION_LIFETIME: '120', + SESSION_ENCRYPT: 'false', + SESSION_PATH: '/', + SESSION_DOMAIN: 'null', + BROADCAST_CONNECTION: 'log', + FILESYSTEM_DISK: 'local', + QUEUE_CONNECTION: 'database', + CACHE_STORE: 'database', + CACHE_PREFIX: '', + MEMCACHED_HOST: '127.0.0.1', + REDIS_CLIENT: 'phpredis', + REDIS_HOST: '127.0.0.1', + REDIS_PASSWORD: 'null', + REDIS_PORT: '6379', + MAIL_MAILER: 'log', + MAIL_HOST: '127.0.0.1', + MAIL_PORT: '2525', + MAIL_USERNAME: 'null', + MAIL_PASSWORD: 'null', + MAIL_ENCRYPTION: 'null', + MAIL_FROM_ADDRESS: '"hello@example.com"', + MAIL_FROM_NAME: '"${APP_NAME}"', + AWS_ACCESS_KEY_ID: '', + AWS_SECRET_ACCESS_KEY: '', + AWS_DEFAULT_REGION: 'us-east-1', + AWS_BUCKET: '', + AWS_USE_PATH_STYLE_ENDPOINT: 'false', + VITE_APP_NAME: '"${APP_NAME}"', + }, + }) + + deploy(database, laravel_app) + assert.command(`docker ps --filter "name=laravel-app-1"`, { stdout_contains: 'laravel-app-1' }, { become: true }) + assert.command(`docker ps --filter "name=laravel-db"`, { stdout_contains: 'laravel-db' }, { become: 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-1"`, { 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-monorepo-rust-next.test.ts b/tests/app-monorepo-rust-next.test.ts new file mode 100644 index 0000000..4808484 --- /dev/null +++ b/tests/app-monorepo-rust-next.test.ts @@ -0,0 +1,40 @@ +import { test } from './utils/index.js' +import { app } from '../src/index.js' + +test('app: monorepo rust + nextjs', async ({ deploy, destroy, assert }) => { + const api = app.git({ + name: 'rust-api', + repo: 'https://github.com/webNeat/hosty-test-apps.git', + branch: 'monorepo', + path: 'api', + domain: 'rust-api.local', + }) + + const web = app.git({ + name: 'next-web', + repo: 'https://github.com/webNeat/hosty-test-apps.git', + branch: 'monorepo', + path: 'web', + domain: 'next-web.local', + env: { + PORT: '80', + API_URL: 'http://rust-api-1', + }, + }) + + deploy(api, web) + assert.command(`docker ps --filter "name=rust-api-1"`, { stdout_contains: 'rust-api-1' }, { become: true }) + assert.command(`docker ps --filter "name=next-web-1"`, { stdout_contains: 'next-web-1' }, { become: true }) + assert.command(`curl -k https://rust-api.local/greet/foo`, { success: true, stdout: '{"hello":"foo"}' }) + assert.command(`curl -k https://rust-api.local/fibonacci/10`, { success: true, stdout: '{"value":55}' }) + assert.command(`curl -k https://next-web.local`, { success: true, stdout_contains: '

Fibonacci of 1 is 1

' }) + assert.command(`curl -k https://next-web.local?n=11`, { success: true, stdout_contains: '

Fibonacci of 11 is 89

' }) + + destroy(web, api) + assert.file(`/srv/hosty/services/rust-api`, { exists: false }) + assert.file(`/srv/hosty/services/next-web`, { exists: false }) + assert.command(`docker ps -q --filter "name=rust-api-1"`, { stdout: '' }, { become: true }) + assert.command(`docker ps -q --filter "name=next-web-1"`, { stdout: '' }, { become: true }) + assert.command(`curl -k https://rust-api.local`, { success: false, stderr_contains: 'Could not resolve host: rust-api.local' }) + assert.command(`curl -k https://next-web.local`, { success: false, stderr_contains: 'Could not resolve host: next-web.local' }) +}) diff --git a/tests/app-node-postgres.test.ts b/tests/app-node-postgres.test.ts new file mode 100644 index 0000000..8bad4b4 --- /dev/null +++ b/tests/app-node-postgres.test.ts @@ -0,0 +1,40 @@ +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(`docker ps --filter "name=todo-1"`, { stdout_contains: 'todo-1' }, { become: true }) + 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-1"`, { stdout: '' }, { become: true }) + assert.command(`curl -k https://todo.local`, { success: false, stderr_contains: 'Could not resolve host: todo.local' }) +}) diff --git a/tests/command-cron-host.test.ts b/tests/command-cron-host.test.ts new file mode 100644 index 0000000..33bf899 --- /dev/null +++ b/tests/command-cron-host.test.ts @@ -0,0 +1,26 @@ +import { test } from './utils/index.js' +import { command } from '../src/index.js' + +test('add/delete cron to/from the server', async ({ deploy, destroy, assert }) => { + const cron = command({ + name: 'simple cron task', + cmd: 'echo "$(date) - CPU: $(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\\([0-9.]*\\)%* id.*/\\1/" | awk "{print 100 - $1}") - MEM: $(free -m | awk "/Mem:/ {printf "%.2f%%", $3*100/$2}")" >> /tmp/system_usage.log', + cron: 'hourly', + }) + + deploy(cron) + assert.command('crontab -l', { + stdout_contains: 'simple cron task', + }) + assert.command('crontab -l', { + stdout_contains: '/tmp/system_usage.log', + }) + + destroy(cron) + assert.command('crontab -l', { + stdout_doesnt_contain: 'simple cron task', + }) + assert.command('crontab -l', { + stdout_doesnt_contain: '/tmp/system_usage.log', + }) +}) diff --git a/tests/command-cron-service.test.ts b/tests/command-cron-service.test.ts new file mode 100644 index 0000000..092176b --- /dev/null +++ b/tests/command-cron-service.test.ts @@ -0,0 +1,31 @@ +import { test } from './utils/index.js' +import { db, command } from '../src/index.js' + +test('add/delete cron to/from a service', async ({ deploy, destroy, assert }) => { + const database = db.postgres({ + name: 'my-db', + user: 'demo', + pass: 'supersecret', + }) + const cron = command({ + name: 'backup my db', + cmd: 'pg_dump -U demo my-db > /srv/backups/my-db/$(date +%Y-%m-%d).sql', + cron: 'daily', + service: database, + }) + + const service_dir = `/srv/hosty/services/my-db` + + deploy(cron) + assert.command('crontab -l', { + stdout_contains: 'backup my db', + }) + assert.command('crontab -l', { + stdout_contains: `@daily cd ${service_dir} && docker compose run --rm my-db pg_dump -U demo my-db > /srv/backups/my-db/$(date +%Y-%m-%d).sql`, + }) + + destroy(cron) + assert.command('crontab -l', { + stdout_doesnt_contain: `cd ${service_dir} && docker compose run --rm my-db`, + }) +}) diff --git a/tests/container.test.ts b/tests/container.test.ts index 840ebf6..0454145 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 }) + 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..f35c098 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 playbook_path = `.tests/${test_name}.yaml` + 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({ playbook_path, 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, + } +}