diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..cddc8d0 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,15 @@ +name: PR checks +on: + pull_request: + branches: [main] +jobs: + check_pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 22 + - run: npm i + - run: npm run build + - run: npm test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aca43d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.log +.DS_Store +node_modules +dist +.tests +hvps +lab diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..63fc5c5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 155, + "trailingComma": "all" +} \ No newline at end of file diff --git a/README.md b/README.md index c2a66f8..7946f90 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,59 @@ # Hosty -Self host your web apps and services with ease. +A code based opinionated way to self-host and manage web apps. -**This package is still under development, not ready for use yet!** \ No newline at end of file +# Quick Example + +```ts +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' +}) + +// 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: { + PORT: '80', + DB_HOST: database.host, + DB_USER: database.user, + DB_PASS: database.pass, + DB_NAME: database.name, + }, +}) + +// 2. Specify where you want deploy +const myVPS = server({ + name: '188.114.97.6' // hostname or IP +}) + +// 3. Deploy +deploy(myVPS, database, api) +run() +``` + +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 + +# 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) + +... \ 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/index.js b/index.js deleted file mode 100644 index 7c6d6c7..0000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..935ca0c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,630 @@ +{ + "name": "hosty", + "version": "0.0.1-alpha.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hosty", + "version": "0.0.1-alpha.3", + "license": "MIT", + "dependencies": { + "yaml": "^2.4.5" + }, + "devDependencies": { + "@types/node": "^20.12.12", + "@types/yaml": "^1.9.7", + "@types/yamljs": "^0.2.34", + "prettier": "^3.2.5", + "tsx": "^4.10.5", + "typescript": "^5.4.5", + "zx": "^8.1.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/yaml": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz", + "integrity": "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==", + "deprecated": "This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "*" + } + }, + "node_modules/@types/yamljs": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.34.tgz", + "integrity": "sha512-gJvfRlv9ErxdOv7ux7UsJVePtX54NAvQyd8ncoiFqK8G5aeHIfQfGH2fbruvjAQ9657HwAaO54waS+Dsk2QTUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.10.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.10.5.tgz", + "integrity": "sha512-twDSbf7Gtea4I2copqovUiNTEDrT8XNFXsuHpfGbdpW/z9ZW4fTghzzhAG0WfrCuJmJiOEY1nLIjq4u3oujRWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.20.2", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zx": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.1.4.tgz", + "integrity": "sha512-QFDYYpnzdpRiJ3dL2102Cw26FpXpWshW4QLTGxiYfIcwdAqg084jRCkK/kuP/NOSkxOjydRwNFG81qzA5r1a6w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + }, + "optionalDependencies": { + "@types/fs-extra": ">=11", + "@types/node": ">=20" + } + } + } +} diff --git a/package.json b/package.json index 7552aab..154a4d8 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "hosty", - "version": "0.0.1-alpha.2", + "version": "0.0.1-alpha.3", "description": "Self host your web apps and services with ease.", + "type": "module", "main": "dist/index.js", "scripts": { - "test": "tsx tests/run.ts" + "build": "tsc -p tsconfig.build.json", + "test": "node --import tsx test.ts" }, "repository": { "type": "git", @@ -23,5 +25,17 @@ "bugs": { "url": "https://github.com/webNeat/hosty/issues" }, - "homepage": "https://github.com/webNeat/hosty#readme" + "homepage": "https://github.com/webNeat/hosty", + "devDependencies": { + "@types/node": "^20.12.12", + "@types/yaml": "^1.9.7", + "@types/yamljs": "^0.2.34", + "prettier": "^3.2.5", + "tsx": "^4.10.5", + "typescript": "^5.4.5", + "zx": "^8.1.4" + }, + "dependencies": { + "yaml": "^2.4.5" + } } diff --git a/src/ansible/index.ts b/src/ansible/index.ts new file mode 100644 index 0000000..f2ff1c5 --- /dev/null +++ b/src/ansible/index.ts @@ -0,0 +1,20 @@ +import { AnyTask, Host, Playbook, Step, Tasks } from './types.js' + +export * from './types.js' +export * as tasks from './tasks/index.js' + +export function task(data: AnyTask) { + return data +} + +export function host(data: Host) { + return data +} + +export function step(host: Host, tasks: Tasks): Step { + return { hosts: host.name, tasks } +} + +export function playbook(data: Playbook) { + return data +} diff --git a/src/ansible/tasks/builtin.ts b/src/ansible/tasks/builtin.ts new file mode 100644 index 0000000..e2c1de0 --- /dev/null +++ b/src/ansible/tasks/builtin.ts @@ -0,0 +1,118 @@ +import { CommonTaskAttrs, Host, Task } from '../types.js' + +export function add_host(name: string, attrs: Host, common: CommonTaskAttrs = {}): Task<'ansible.builtin.add_host', Host> { + return { name, 'ansible.builtin.add_host': attrs, ...common } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +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 } +} + +export type AptRepositoryAttrs = { repo: string; state: 'present' | 'absent'; filename?: string } +export function apt_repository( + name: string, + attrs: AptRepositoryAttrs, + common: CommonTaskAttrs = {}, +): Task<'ansible.builtin.apt_repository', AptRepositoryAttrs> { + return { name, 'ansible.builtin.apt_repository': attrs, ...common } +} + +export function service_facts(name: string, common: CommonTaskAttrs = {}): Task<'ansible.builtin.service_facts', {}> { + return { name, 'ansible.builtin.service_facts': {}, ...common } +} + +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/tasks/crypto.ts b/src/ansible/tasks/crypto.ts new file mode 100644 index 0000000..b0e8247 --- /dev/null +++ b/src/ansible/tasks/crypto.ts @@ -0,0 +1,6 @@ +import { CommonTaskAttrs, Task } from '../types.js' + +type SshKeyAttrs = { path: string; type?: 'rsa' | 'dsa' | 'rsa1' | 'ecdsa' | 'ed25519'; passphrase?: string } +export function ssh_key(name: string, attrs: SshKeyAttrs, common: CommonTaskAttrs = {}): Task<'community.crypto.openssh_keypair', SshKeyAttrs> { + return { name, 'community.crypto.openssh_keypair': attrs, ...common } +} diff --git a/src/ansible/tasks/general.ts b/src/ansible/tasks/general.ts new file mode 100644 index 0000000..e3d799a --- /dev/null +++ b/src/ansible/tasks/general.ts @@ -0,0 +1,11 @@ +import { CommonTaskAttrs, Task } from '../types.js' + +type UfwAttrs = { rule: 'allow' | 'deny'; port: string; proto: 'tcp' | 'udp'; direction: 'in' | 'out' } +export function ufw(name: string, attrs: UfwAttrs, common: CommonTaskAttrs = {}): Task<'community.general.ufw', UfwAttrs> { + return { name, 'community.general.ufw': attrs, ...common } +} + +type GitConfigAttrs = { name: string; value: string; scope: 'global' | 'local' } +export function git_config(name: string, attrs: GitConfigAttrs, common: CommonTaskAttrs = {}): Task<'community.general.git_config', GitConfigAttrs> { + return { name, 'community.general.git_config': attrs, ...common } +} diff --git a/src/ansible/tasks/index.ts b/src/ansible/tasks/index.ts new file mode 100644 index 0000000..8df98ec --- /dev/null +++ b/src/ansible/tasks/index.ts @@ -0,0 +1,3 @@ +export * as builtin from './builtin.js' +export * as crypto from './crypto.js' +export * as general from './general.js' diff --git a/src/ansible/types.ts b/src/ansible/types.ts new file mode 100644 index 0000000..8c70c70 --- /dev/null +++ b/src/ansible/types.ts @@ -0,0 +1,37 @@ +export type Host = { + name: string + ansible_connection?: 'local' | 'docker' | 'ssh' + ansible_host?: string + ansible_user?: string + ansible_port?: number + ansible_password?: string + ansible_ssh_private_key_file?: string +} + +export type CommonTaskAttrs = { + become?: boolean + register?: string + when?: string + changed_when?: boolean + ignore_errors?: boolean + notify?: string + with_fileglob?: string | string[] +} + +export type Task = { name: string } & { [key in ModuleName]: ModuleAttrs } & CommonTaskAttrs +export type Handler = Task + +export type AnyTask = Task +export type AnyHandler = Handler + +export type Tasks = AnyTask[] +export type Block = Task<'block', Tasks> + +export type Step = { + hosts: string + tasks: AnyTask[] + gather_facts?: boolean + handlers?: AnyHandler[] +} + +export type Playbook = Step[] 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/compose.types.ts b/src/compose.types.ts new file mode 100644 index 0000000..8c65e47 --- /dev/null +++ b/src/compose.types.ts @@ -0,0 +1,255 @@ +// This file was auto-generated and may contain wrong/imprecise errors +// types here will be improved as errors are found + +export type ComposeFile = { + services: Record + volumes?: Record + networks?: Record + configs?: Record + secrets?: Record +} + +export type Service = { + build?: Build + cap_add?: string[] + cap_drop?: string[] + cgroup_parent?: string + command?: string | string[] + configs?: Array + container_name?: string + depends_on?: string[] + deploy?: Deploy + device_cgroup_rules?: string[] + devices?: string[] + dns?: string | string[] + dns_search?: string | string[] + domainname?: string + entrypoint?: string | string[] + environment?: Record + expose?: string[] + extends?: string | { file: string; service: string } + external_links?: string[] + extra_hosts?: Record + healthcheck?: Healthcheck + hostname?: string + image?: string + init?: boolean + ipc?: string + isolation?: string + labels?: Record + links?: string[] + logging?: Logging + networks?: string[] + pid?: string + ports?: Array + privileged?: boolean + profiles?: string[] + read_only?: boolean + restart?: 'no' | 'always' | 'on-failure' | 'unless-stopped' + runtime?: string + scale?: number + security_opt?: string[] + shm_size?: string + secrets?: Array + stdin_open?: boolean + stop_grace_period?: string + stop_signal?: string + tmpfs?: string[] + tty?: boolean + ulimits?: Ulimits + user?: string + userns_mode?: string + volumes?: Array + working_dir?: string +} + +export type Build = { + context: string + dockerfile?: string + args?: Record + cache_from?: string[] + labels?: Record + network?: string + shm_size?: string + target?: string +} + +export type Config = { + name: string + file?: string + external?: boolean | { name: string } + labels?: Record +} + +export type ConfigReference = { + source: string + target?: string + uid?: string + gid?: string + mode?: number +} + +export type Deploy = { + mode?: string + replicas?: number + labels?: Record + update_config?: UpdateConfig + rollback_config?: RollbackConfig + resources?: Resources + restart_policy?: RestartPolicy + placement?: Placement + endpoint_mode?: string +} + +export type UpdateConfig = { + parallelism?: number + delay?: string + failure_action?: string + monitor?: string + max_failure_ratio?: number + order?: string +} + +export type RollbackConfig = { + parallelism?: number + delay?: string + failure_action?: string + monitor?: string + max_failure_ratio?: number + order?: string +} + +export type Resources = { + limits?: Resource + reservations?: Resource +} + +export type Resource = { + cpus?: string + memory?: string +} + +export type RestartPolicy = { + condition?: string + delay?: string + max_attempts?: number + window?: string +} + +export type Placement = { + constraints?: string[] + preferences?: PlacementPreference[] + max_replicas_per_node?: number +} + +export type PlacementPreference = { + spread: string +} + +export type Healthcheck = { + test: string[] + interval?: string + timeout?: string + retries?: number + start_period?: string + disable?: boolean +} + +export type Logging = { + driver: string + options?: Record +} + +export type NetworkReference = { + aliases?: string[] + ipv4_address?: string + ipv6_address?: string + link_local_ips?: string[] + priority?: number +} + +export type Port = { + target: number + published?: number + protocol?: string + mode?: string +} + +export type Ulimits = { + nproc?: number + nofile?: { soft: number; hard: number } +} + +export type VolumeMount = { + type?: string + source?: string + target: string + read_only?: boolean + consistency?: string + bind?: Bind + volume?: VolumeOptions + tmpfs?: TmpfsOptions +} + +export type Bind = { + propagation?: string + create_host_path?: boolean +} + +export type VolumeOptions = { + nocopy?: boolean +} + +export type TmpfsOptions = { + size?: number +} + +export type Volume = { + driver?: string + driver_opts?: Record + external?: boolean | { name: string } + labels?: Record + name?: string +} + +export type Network = { + driver?: string + driver_opts?: Record + external?: boolean | { name: string } + labels?: Record + name?: string + attachable?: boolean + enable_ipv6?: boolean + internal?: boolean + ipam?: IPAM +} + +export type IPAM = { + driver?: string + config?: IPAMConfig[] + options?: Record +} + +export type IPAMConfig = { + subnet?: string + ip_range?: string + gateway?: string + aux_addresses?: Record +} + +export type Secret = { + name: string + file?: string + external?: boolean | { name: string } + labels?: Record + driver?: string + driver_opts?: Record +} + +export type SecretReference = { + source: string + target?: string + uid?: string + gid?: string + mode?: number +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..38d4f9a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import { instance } from './instance.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 { deploy, destroy, playbook, write, run } = instance() diff --git a/src/instance.ts b/src/instance.ts new file mode 100644 index 0000000..b74bb24 --- /dev/null +++ b/src/instance.ts @@ -0,0 +1,91 @@ +import path from 'path' +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_tasks, server_to_host } from './server.js' +import { HostyInstance, RunOptions, Server, Service } from './types.js' + +type Action = { type: 'deploy' | 'destroy'; server: Server; service: Service } +type State = { + servers: Record + actions: Action[] +} + +const defaultRunOptions: RunOptions = { + ask_sudo_pass: true, + playbook_path: 'hosty-playbook.yaml', + spawn_options: { + stdio: 'inherit', + }, + ansible_options: [], +} + +export function instance(): HostyInstance { + const state: State = { + servers: {}, + actions: [], + } + + const deploy = (server: Server, ...services: Service[]) => { + state.servers[server.name] = server + 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) + for (const action of state.actions) { + steps.push({ hosts: action.server.name, gather_facts: false, tasks: get_tasks(action) }) + } + return steps + } + + const write = async (playbookPath: string) => { + await mkdir(path.dirname(playbookPath), { recursive: true }) + await writeFile(playbookPath, YAML.stringify(playbook())) + } + + const run = async (userOptions: Partial = {}) => { + const options = { ...defaultRunOptions, ...userOptions } + 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, destroy, playbook, write, run } +} + +function setup_servers(servers: Server[], steps: ansible.Step[]) { + for (const server of servers) { + const host = server_to_host(server) + steps.push({ + hosts: 'localhost', + gather_facts: false, + tasks: [ansible.tasks.builtin.add_host(`Define server ${server.name}`, host)], + }) + steps.push({ + hosts: server.name, + gather_facts: false, + tasks: [ansible.tasks.builtin.setup(`Gather facts of server ${server.name}`, {})], + }) + steps.push({ hosts: server.name, gather_facts: false, tasks: get_setup_tasks(server) }) + } +} + +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 new file mode 100644 index 0000000..d33b0e8 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,61 @@ +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) { + 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 || { 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), + } +} + +export function server_to_host({ name, connection }: Server): Host { + const host: Host = { name } + if (connection?.user) host.ansible_user = connection.user + if (connection?.password) host.ansible_password = connection.password + + if (connection.type === 'docker') { + host.ansible_connection = 'docker' + host.ansible_host = connection.container + } + if (connection.type === 'local') { + host.ansible_connection = 'local' + } + if (connection.type === 'ssh') { + host.ansible_connection = 'ssh' + host.ansible_host = connection.address || name + if (connection?.port) host.ansible_port = connection.port + if (connection?.private_key_path) host.ansible_ssh_private_key_file = connection.private_key_path + } + + return host +} + +export function get_setup_tasks(server: Server) { + return [ + 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 new file mode 100644 index 0000000..25382d7 --- /dev/null +++ b/src/services/assertions.ts @@ -0,0 +1,6 @@ +import { Tasks } from '../ansible/types.js' +import { Assertions } from '../types.js' + +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 new file mode 100644 index 0000000..a0b6837 --- /dev/null +++ b/src/services/container.ts @@ -0,0 +1,31 @@ +import path from 'path' +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, + type: 'container', + get_deploy_tasks: (server) => get_deploy_tasks(server, config), + get_destroy_tasks: (server) => get_destroy_tasks(server, config), + } +} + +function get_deploy_tasks(server: Server, { name, compose, files_dir, files }: ContainerConfig): Tasks { + return [ + blocks.create_service({ + name: server.docker_prefix + name, + compose, + files_dir, + files, + docker_network: server.docker_network, + 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/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 new file mode 100644 index 0000000..ff95c91 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +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 new file mode 100644 index 0000000..7eff248 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,147 @@ +import { Tasks, Playbook } from './ansible/types.js' +import * as compose from './compose.types.js' +import { ChildProcess, SpawnOptions } from 'child_process' + +export type LocalConnection = { + type: 'local' + user?: string + password?: string +} + +export type SshConnection = { + type: 'ssh' + address: string + port?: number + user?: string + password?: string + private_key_path?: string +} + +export type DockerConnection = { + type: 'docker' + container: string + user?: string + password?: string +} + +export type ServerConfig = { + name: string + ssh_key?: { + path: string + passphrase: string + } + git_config?: { name?: string; email?: string } + hosty_dir?: string + docker_network?: string + docker_prefix?: string + connection?: LocalConnection | SshConnection | DockerConnection +} + +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 = { + type: Type + get_deploy_tasks: (server: Server) => Tasks + get_destroy_tasks: (server: Server) => Tasks +} + +export type ContainerConfig = { + name: string + files_dir?: string + files?: Record + compose: compose.Service + before_start?: string[] +} +export type Container = Service<'container'> & ContainerConfig + +export type Database = Postgres | MySQL | Redis + +export type PostgresConfig = Omit & { + version?: string + user: string + pass: string + exposed_port?: number + config?: string + compose?: compose.Service +} +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<'assertions'> + +export type RunOptions = { + playbook_path: string + ask_sudo_pass: boolean + spawn_options: Partial + ansible_options: string[] +} + +export type HostyInstance = { + deploy: (server: Server, ...services: Service[]) => void + destroy: (server: Server, ...services: Service[]) => void + playbook: () => Playbook + write: (playbookPath: string) => 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/tasks.todo b/tasks.todo new file mode 100644 index 0000000..4504af2 --- /dev/null +++ b/tasks.todo @@ -0,0 +1,42 @@ +databases: + ✔ postgres @done + ✔ mysql @done + ✔ redis @done + ☐ mongodb + features: + ☐ auto backups + +app.git: + ✔ clone, package, run + ✔ redo only on change + ✔ specific branch + ✔ custom dockerfile @done + ✔ synchronous commands/asserts during deploy @done + ✔ number of instances @done + +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 @done + ✔ Laravel @done + ✔ Adonis @done + ✔ Nextjs @done + ✔ Rust @done + ☐ Remix + ☐ Wordpress diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..cf8fa90 --- /dev/null +++ b/test.ts @@ -0,0 +1,17 @@ +import { glob } from 'zx' +import { run } from './tests/utils/index.js' + +let filenames = process.argv.slice(2) +filenames = filenames.map((filename) => { + if (!filename.startsWith('./')) filename = './' + filename + return filename +}) +if (filenames.length === 0) { + filenames = await glob('./tests/*.test.ts') +} + +for (const filename of filenames) { + await import(filename) +} + +run() 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 new file mode 100644 index 0000000..0454145 --- /dev/null +++ b/tests/container.test.ts @@ -0,0 +1,43 @@ +import { readFile } from 'fs/promises' +import { test } from './utils/index.js' +import { container } from '../src/index.js' + +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'], + }, + }, + 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/files/bar.txt b/tests/files/bar.txt new file mode 100644 index 0000000..548ab5e --- /dev/null +++ b/tests/files/bar.txt @@ -0,0 +1 @@ +Hello again! \ No newline at end of file diff --git a/tests/files/foo.txt b/tests/files/foo.txt new file mode 100644 index 0000000..c57eff5 --- /dev/null +++ b/tests/files/foo.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/tests/setup.test.ts b/tests/setup.test.ts new file mode 100644 index 0000000..79da2c5 --- /dev/null +++ b/tests/setup.test.ts @@ -0,0 +1,8 @@ +import { test } from './utils/index.js' + +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/Dockerfile b/tests/utils/Dockerfile new file mode 100644 index 0000000..23ed94e --- /dev/null +++ b/tests/utils/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:22.04 +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt update && apt install -y sudo +RUN useradd -m -s /bin/bash foo && echo 'foo:foo' | chpasswd +RUN usermod -aG sudo foo +RUN echo 'foo ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/foo && chmod 0440 /etc/sudoers.d/foo + +USER foo +RUN sudo apt update && sudo DEBIAN_FRONTEND=noninteractive apt install -y wget git +RUN wget https://get.docker.com -O /tmp/get-docker.sh && chmod +x /tmp/get-docker.sh && /tmp/get-docker.sh +RUN sudo usermod -aG docker foo +ENV PATH="/home/foo/.local/bin:$PATH" + +WORKDIR /home/foo + +CMD ["tail", "-f", "/dev/null"] diff --git a/tests/utils/index.ts b/tests/utils/index.ts new file mode 100644 index 0000000..f35c098 --- /dev/null +++ b/tests/utils/index.ts @@ -0,0 +1,124 @@ +import * as zx from 'zx' +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 + fn: TestFn +} +type Failure = { + name: string + output: string +} + +const cases: TestCase[] = [] +const $ = zx.$({ quiet: true }) + +export async function test(name: string, fn: TestFn) { + cases.push({ name, fn }) +} + +export async function run() { + console.log(`Checking dependencies ...`) + if ((await $`ansible-playbook --version`).exitCode) { + throw new Error('`ansible` is required to run tests, please install it first then try again!') + } + 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 run_test_case(x) + console.log(`✅ ${x.name}`) + } catch (err) { + console.log(`❌ ${x.name}`) + failures.push({ name: x.name, output: err as string }) + } + } + + if (failures.length === 0) { + await $`rm -rf .tests` + return + } + + for (const { name, output } of failures) { + console.log('') + console.log(`------------------------------------------`) + console.log(name) + console.log(`------------------------------------------`) + console.log(output) + } + + process.exit(1) +} + +async function run_test_case({ name, fn }: TestCase) { + const test_name = name.replace(/[^a-zA-Z0-9]/g, '-') + const playbook_path = `.tests/${test_name}.yaml` + const user = (await $`whoami`).stdout.trim() + + const container = server({ + name: 'localhost', + 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() + await fn(make_test_context(container, test_instance)) + + 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 + output: string +} +async function wait_process(ps: ChildProcess) { + return new Promise((resolve, reject) => { + const res: ProcessResult = { exitCode: null, output: '' } + ps.stdout?.on('data', (data) => { + res.output += data.toString() + }) + ps.stderr?.on('data', (data) => { + res.output += data.toString() + }) + ps.on('close', (code) => { + res.exitCode = code + resolve(res) + }) + 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, + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..189665d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a75e49 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "noEmit": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + }, + "include": ["./**/*.ts"], + "exclude": ["./dist", "./node_modules"] +}