diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index f3163052..6342e3c0 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -14,7 +14,19 @@ on: - published jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm test + build: + needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@master diff --git a/.gitignore b/.gitignore index f623bb5f..9e513f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,6 @@ node_modules npm-debug.log *.pem !mock-cert.pem -.env* package-lock.json coverage -.idea -.token* -.scss \ No newline at end of file +.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a816106f..47deb599 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,9 +17,7 @@ RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit && apk del .build-deps # App sources -COPY app /usr/app/ -COPY index.js /usr/ -COPY robokit.js /usr/ +COPY src /usr/src/ COPY env /usr/.env EXPOSE 7777 diff --git a/README.md b/README.md index 56778e8b..562b299c 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,355 @@ -## The problem +# Robokit -Continues delivey is important to speed up the development of your team. and its more relevant with complex microservices architecture. the more microservices are added to your echo system the more hassle is invoved for creating a standard how to continuesly deploy these services without making them slow down your ability to deliver them. +A GitHub App that automates Continuous Deployment (CD) by listening to GitHub check run events and triggering deployments through an environment service. +When a designated CI check run completes successfully on a protected branch (`develop` or `master`), Robokit calls out to an environment service over WebSocket, streams the deployment log back, and updates the GitHub check run with live status — all visible directly in the GitHub UI on the commit. +--- -## This solution +## How it works -`robokit` is a github application that track your git-flow development process and continuesly deploy the artifacts and triggers your continues delivery server pipelines. in such way that pull-requests, push events to develop, master branches continuesly delivered to your kubernetes evniroments. +``` +GitHub CI check run completes + │ + ▼ +Robokit receives check_run webhook + │ + ├─ Is branch "develop" or "master"? ──No──▶ ignore + │ + ├─ Is check run named "robokit-deploy"? ──No──▶ ignore + │ + ▼ +Create GitHub Check Run ("Robokit CD" — Starting) +Create GitHub Deployment (in_progress) + │ + ▼ +Send deploy request to Environment Service (WebSocket) + │ + ├─ Stream log events back ──▶ update GitHub Check Run + │ + ├─ On error ──▶ cancel Check Run, set Deployment inactive + │ + └─ On success ──▶ complete Check Run, set Deployment success +``` -## Setup +Users can also manually trigger or cancel a deployment by clicking the **Re-Deploy** or **Cancel-Deploy** buttons on the GitHub check run. -> `robokit` is a GitHub Application built with [Probot](https://github.com/probot/probot). +--- -```sh -# Install dependencies -npm install +## Architecture + +Hexagonal (ports & adapters) layout with a pure domain layer: -# Run the bot -npm start ``` -## Enviroment variables: -For reverence please see env file located at the root of this project. +src/ + domain/ + DeploymentContext.js Immutable value object — all deploy state + DeploymentPolicy.js Pure trigger / user-action evaluation (no I/O) + PipelineStatus.js CD status → GitHub status/conclusion/marker mapping + + application/ + DeploymentOrchestrator.js Use-case: run the full deploy pipeline + deploymentLock.js Per-repo concurrency guard (no parallel deploys) -## Getting started -1. `robokit` once github application installed on a repositoy and asking for relevant access rights to listen on activity in github and update `check_run` status events. after the CI is completed it triggers continues delivery pipeline as webhook events that essetially will deploy the repos and artifacts to an enviroment for example kubernetes namespace. + adapters/ + cd-service/ + CdServiceClient.js CD service WebSocket adapter (real) + CdServiceMock.js In-process mock for LOCAL_DEV + github/ + CheckRunAdapter.js GitHub Check Run adapter (create / update) + DeploymentAdapter.js GitHub Deployments adapter (create / status) + OctokitRegistry.js Octokit instance cache (owner/repo → client) + vault/ + VaultAdapter.js HashiCorp Vault HTTP client -the Continues delivery trigger bellow named `robo_kit_deploy` is activated when build docker and creation of helm push is completed: + templates/ + renderer.js Safe markdown template renderer + starting.md Check run output — deployment starting + canceled.md Check run output — deployment canceled + status.md Check run output — deployment log -add robokit.yml to your .github folder. + config.js Validates and exposes all env-var config + logger.js Structured pino logger (child loggers per deploy) + server.js Entry point: Vault auth (prod) or .env (LOCAL_DEV) + webhook.js Probot event routing — check_run, installation ``` -registry: - helm: nexus - docker: docker-hub -kubernetes: - cluster_name: scalecube.io +--- + +## Prerequisites + +- **Node.js** >= 18 +- A **GitHub App** installed on the target repositories +- An **Environment Service** reachable over WebSocket (`wss://`) +- **Production only:** HashiCorp Vault with Kubernetes auth, reachable from the pod + +--- + +## GitHub App setup + +1. Go to **GitHub → Settings → Developer settings → GitHub Apps → New GitHub App** +2. Set the webhook URL to your public endpoint (or smee.io proxy for local dev): + ``` + https://your-domain.com/api/github/webhooks + ``` +3. Enable the following **permissions**: + - Checks: Read & Write + - Deployments: Read & Write + - Contents: Read +4. Subscribe to the following **events**: + - Check run + - Installation +5. Generate and download the **private key** — this becomes `PRIVATE_KEY` in your config +6. Note the **App ID** shown on the app settings page — this becomes `APP_ID` +7. Note the **Webhook Secret** you set — this becomes `WEBHOOK_SECRET` +8. Install the app on the target org/repos and note the **Installation ID** from the URL: + ``` + https://github.com/organizations//settings/installations/ + ``` + +--- + +## Configuration + +All configuration is read from environment variables. In production these are loaded from Vault. For local development, copy the values into a `.env` file at the project root. + +### Required — GitHub App + +| Variable | Description | +|---|---| +| `APP_ID` | GitHub App ID (shown on the App settings page) | +| `PRIVATE_KEY` | RSA private key for the GitHub App (PEM format, newlines as `\n`) | +| `WEBHOOK_SECRET` | Secret used to sign and verify incoming webhooks | + +### Required — Environment Service + +| Variable | Description | +|---|---| +| `ENV_SERVICE_ADDRESS` | WebSocket URL of the environment service, e.g. `wss://env-service.example.com` | +| `ENV_SERVICE_ROLE` | OIDC role name used to obtain an env-service token from Vault | + +### Required — Vault (production only, ignored when `LOCAL_DEV=true`) + +| Variable | Description | +|---|---| +| `VAULT_ADDR` | Vault server URL, e.g. `https://vault.example.com` | +| `VAULT_ROLE` | Kubernetes auth role name used to log in to Vault | +| `VAULT_JWT_PROVIDER` | Vault Kubernetes auth mount path, e.g. `kubernetes-nexus` | +| `VAULT_JWT_PATH` | Path to the Kubernetes service account JWT token file. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token` | +| `VAULT_SECRETS_PATH` | Vault KV path that contains all other secrets, e.g. `secretv2/scalecube/robokit/prod` | + +At startup, Robokit logs into Vault using the pod's service account JWT, reads all secrets from `VAULT_SECRETS_PATH`, and injects them into `process.env` before starting the server. This means every other variable (`APP_ID`, `PRIVATE_KEY`, `WEBHOOK_SECRET`, etc.) can live in Vault and be omitted from the pod spec. + +### Optional — Server + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | HTTP port the server listens on | +| `LOG_LEVEL` | `info` | Probot log level: `debug`, `info`, `warn`, `error` | +| `WEBHOOK_PROXY_URL` | — | [smee.io](https://smee.io) proxy URL for forwarding GitHub webhooks to localhost | + +### Local development only + +| Variable | Description | +|---|---| +| `LOCAL_DEV` | Set to `true` to skip Vault auth and env-service WebSocket. Uses a mock deploy instead. | + +### E2E tests only + +| Variable | Default | Description | +|---|---|---| +| `WEBHOOKS_PATH` | `/api/github/webhooks` | Webhook endpoint path | +| `GITHUB_OWNER` | `scalecube` | GitHub org used in test payloads | +| `GITHUB_REPO` | `robokit` | GitHub repo used in test payloads | +| `GITHUB_SHA` | `000...000` | Commit SHA used in test payloads | +| `GITHUB_INSTALLATION_ID` | `0` | App installation ID — required for real check run creation | + +--- + +## Running locally + +### 1. Create `.env` + +```env +# ── Server ──────────────────────────────────────────────────────────────────── +LOCAL_DEV=true +PORT=7777 +LOG_LEVEL=debug + +# ── GitHub App ──────────────────────────────────────────────────────────────── +APP_ID= +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +WEBHOOK_SECRET= +WEBHOOK_PROXY_URL=https://smee.io/your-channel # get a URL from https://smee.io/new + +# ── Continuous Deployment Service ───────────────────────────────────────────── +ENV_SERVICE_ADDRESS=wss://env-service.example.com +ENV_SERVICE_ROLE=your-oidc-role + +# ── Vault (production only — ignored when LOCAL_DEV=true) ───────────────────── +VAULT_ADDR= +VAULT_SECRETS_PATH= +VAULT_ROLE= +VAULT_JWT_PROVIDER= +VAULT_JWT_PATH= +``` + +### 2. Install dependencies + +```bash +npm install ``` +### 3. Start the server + +```bash +npm run robokit ``` + +The server starts on `PORT` and listens for webhooks at `/api/github/webhooks`. + +If `WEBHOOK_PROXY_URL` is set, Robokit connects to the smee.io channel and forwards all events from GitHub to your local server automatically — no tunnel required. + +In `LOCAL_DEV` mode: +- Vault auth is skipped entirely +- The env-service WebSocket connection is skipped +- Deployments are simulated locally with a mock that progresses through `RUNNING → RUNNING → SUCCEEDED` over ~3 seconds, so you can observe the full GitHub check run status update flow without a real environment service + +### 4. Watch mode (auto-restart on file change) + +```bash +npm run dev +``` + +Uses nodemon; watches all source files and `.env`. + +--- + +## Trigger conditions + +Robokit triggers a deployment when **all** of the following are true: + +| Condition | Value | +|---|---| +| GitHub event | `check_run` | +| `check_run.name` | `robokit-deploy` | +| `check_run.status` | `completed` | +| `check_run.conclusion` | `success` | +| Branch | `develop` or `master` | + +Any other check run (different name, branch, status, or conclusion) is accepted by the webhook endpoint and returns `200`, but is silently ignored internally. + +### Wiring the trigger in GitHub Actions + +Add a job named `robokit-deploy` to your workflow that depends on your build/test jobs. GitHub will create a check run with that name when the job completes, which Robokit picks up: + +```yaml robokit-deploy: + needs: + - docker-build-push + - helm-package-push + runs-on: ubuntu-latest + steps: + - name: Trigger Robokit deploy + run: echo "Deploy triggered" +``` + +The job itself does nothing — its completion is the signal. + +### Manual actions + +Once Robokit creates a "Robokit CD" check run, two buttons appear in the GitHub UI on that commit: + +| Button | Effect | +|---|---| +| **Re-Deploy** | Immediately re-triggers the full deploy sequence | +| **Cancel-Deploy** | Cancels the running deployment | + +--- + +## Running in production (Kubernetes) + +No `.env` file is used in production. All secrets live in Vault. + +**Startup sequence:** + +1. Pod starts; Robokit reads the service account JWT from `VAULT_JWT_PATH` +2. Authenticates to Vault using the Kubernetes auth method (`VAULT_ROLE`, `VAULT_JWT_PROVIDER`) +3. Reads all secrets from `VAULT_SECRETS_PATH` and injects them into `process.env` +4. Connects to the environment service over WebSocket using a Vault-issued OIDC token +5. Starts the Probot HTTP server + +The only variables that must be set in the pod spec (not Vault) are those needed to reach Vault itself: + +``` +VAULT_ADDR +VAULT_ROLE +VAULT_JWT_PROVIDER +VAULT_JWT_PATH +VAULT_SECRETS_PATH +``` - needs: - - docker-build-push - - helm-package-post +--- - runs-on: ubuntu-latest +## Testing - steps: - - name: Robo-Kit Deploy - run: | - echo 'Run Robo-Kit Deploy' +### Unit tests + +```bash +npm test +``` + +Runs `test/unit/**` via Jest. + +### E2E tests + +Requires the server to be running first: + +```bash +# Terminal 1 +npm run robokit + +# Terminal 2 +npm run test:e2e +``` + +The e2e suite sends real signed HTTP requests to the running server and verifies: + +- Server is reachable +- Webhooks with an invalid signature are rejected with HTTP `400` +- A valid `robokit-deploy` check run on `develop` is accepted (`200`/`202`) +- A check run with a different name is accepted but does nothing +- A check run on a feature branch is accepted but does nothing + +All config is read from `.env` automatically — no hardcoded values in the test file. + +### Health check + +``` +GET /health +``` + +Returns `200` with: + +```json +{ "status": "ok", "uptime": 42.3, "cdService": "connected" } ``` -# Check Run status update from spinnaker: -![image](https://user-images.githubusercontent.com/1706296/73777078-7ceda300-4791-11ea-9095-2bc58cdf7d61.png) +`cdService` is `"connected"` or `"disconnected"` depending on the WebSocket state (always `"connected"` in `LOCAL_DEV` mode). + +--- + +## npm scripts + +| Script | Description | +|---|---| +| `npm run robokit` | Start the server (Vault auth in prod, `.env` in LOCAL\_DEV) | +| `npm run dev` | Start with nodemon — auto-restarts on file or `.env` changes | +| `npm run start` | Start via Probot CLI directly (no Vault auth) | +| `npm test` | Run unit tests | +| `npm run test:e2e` | Run e2e tests (server must already be running) | +| `npm run lint` | Lint and auto-fix with StandardJS | + +--- + +![Robokit](https://user-images.githubusercontent.com/1706296/73777078-7ceda300-4791-11ea-9095-2bc58cdf7d61.png) diff --git a/app/cache.js b/app/cache.js deleted file mode 100644 index 21de3b03..00000000 --- a/app/cache.js +++ /dev/null @@ -1,16 +0,0 @@ -const contexts = new Map() - -class Cache { - set (owner, repo, ctx) { - contexts.set(owner + '/' + repo, ctx) - } - - get (owner, repo) { - return contexts.get(owner + '/' + repo) - } - - keys () { - return contexts.keys() - } -} -module.exports = new Cache() diff --git a/app/config.js b/app/config.js deleted file mode 100644 index 1c8dfeaa..00000000 --- a/app/config.js +++ /dev/null @@ -1,81 +0,0 @@ -const deploy_label = ':rocket: DEPLOY (robo-kit)' - -module.exports = { - ROBOKIT_DEPLOY: 'robokit-deploy', - ROBOKIT_LABEL: deploy_label, - state: 'success', - deploy: { - check: { - name: 'Robokit CD', - queued: { - title: 'Waiting', - summary: 'Continues-Delivery pipeline is pending...', - text: 'About to trigger a deployment pipeline', - template: 'waiting' - }, - starting: { - title: 'Starting', - summary: 'Continues-Delivery pipeline is starting...', - text: 'Waiting for Continues Delivery pipeline acknowledgment', - template: 'starting' - }, - running: { - title: 'Running', - summary: 'Continues-Delivery pipeline is running', - text: 'Waiting for Continues Delivery pipeline status updates', - template: 'running' - }, - canceled: { - title: 'Canceled', - summary: 'Continues-Delivery pipeline was not found', - text: 'The Continues Delivery pipelines installation is not completed.', - template: 'canceled' - }, - update: { - title: '${status}', - summary: 'Continues-Delivery pipeline: ${conclusion}', - text: 'Namespace: `${namespace}`', - template: 'status' - } - }, - on: { - push: { - branches: [ - 'master', - 'develop' - ], - actions: [{ - name: 'Travis CI - Branch', create_on: 'in_progress', trigger_on: 'completed' - }, { - name: 'robokit-deploy', create_on: 'queued', trigger_on: 'completed' - }] - }, - pull_request: { - labeled: [deploy_label], - actions: [{ - name: 'Travis CI - Pull Request', create_on: 'in_progress', trigger_on: 'completed' - }, { - name: 'robokit-deploy', create_on: 'queued', trigger_on: 'queued' - }] - } - } - }, - label: deploy_label, - labels: [{ - name: deploy_label, - description: 'if Labeled Triggers deployment on next commit, Unlabeled/Merge will trigger environment destruction.', - color: '73ed58' - }], - user_actions: { - done: [{ - label: "Re-Deploy", - description: "Trigger the Deploy pipeline", - identifier: "deploy_now" - }], - in_progress: [{ - label: "Cancel-Deploy", - description: "Cancel the Deploy pipeline", - identifier: "cancel_deploy_now" - }] - } -} diff --git a/app/environments.js b/app/environments.js deleted file mode 100644 index 6f10ad69..00000000 --- a/app/environments.js +++ /dev/null @@ -1,150 +0,0 @@ -const WebSocketClient = require('websocket').client -const axios = require('axios') -const vault = new (require('./vault-api'))(process.env.VAULT_ADDR) -const Rx = require('rxjs/Rx') - -var ws = null -class Environments { - constructor () { - this.responses = new Map() - } - - getEnviromentServiceToken (clientToken) { - const path = `${process.env.VAULT_ADDR.replace(/\/$/, '')}/v1/identity/oidc/token/${process.env.ENV_SERVICE_ROLE}` - console.log('get Enviroment Service Token: ' + path) - // curl -H "X-Vault-Token: $clientToken" path - return this.httpGet(path, clientToken) - } - - connect (address) { - if (!address) { - address = process.env.ENV_SERVICE_ADDRESS - } - console.log('connect websocket: ' + address) - return new Promise((resolve, reject) => { - vault.k8sLogin( - process.env.VAULT_ROLE, - process.env.VAULT_JWT_PATH) - .then(async token => { - const res = await this.getEnviromentServiceToken(token.client_token) - resolve(this.openWebSocketConnection(address, res.data.data.token)) - }) - }) - } - - openWebSocketConnection (address, token) { - return new Promise((resolve, reject) => { - const client = new WebSocketClient() - - client.on('connectFailed', (error) => { - console.log('Connect Error: ' + error.toString()) - }) - - client.on('connect', (connection) => { - console.log('WebSocket Connected to Environment Service: ' + process.env.ENV_SERVICE_ADDRESS) - connection.on('error', (error) => { - console.log('Connection Error: ' + error.toString()) - reject(error) - }) - - connection.on('close', async () => { - console.log('echo-protocol Connection Closed') - ws = null - }) - - connection.on('message', (message) => { - if (message.type === 'utf8') { - const json = JSON.parse(message.utf8Data) - const p = this.responses.get(json.sid) - if (p) { - if (json.sig) { - this.responses.delete(json.sid) - } - if (json.sig === 1) { - p.complete() - } else if (json.sig === 2) { - p.error(json) - } else if (json.sig === 3) { - p.error(json) - } else { - p.next(json) - } - } - } - }) - ws = connection - resolve(connection) - }) - client.connect(address, undefined, undefined, { 'X-Exberry-Token': token }) - }) - } - - deploy (data) { - return this.deployService(this.toDeployRequest(data)) - } - - /* - branch: - {"site":"site","service":{"owner":"scalecube","repo":"abc-service","version":"develop"},"branch":"develop"}} - prerelease: - {"site":"site","service":{"owner":"scalecube","repo":"abc-service","version":"v1.2.3-rc1"},"isPrerelease":true}} - release: - {"site":"site","service":{"owner":"scalecube","repo":"abc-service","version":"v1.2.3"},"isPrerelease":false}} - */ - toDeployRequest (data) { - const res = { - service: { - owner: data.owner, - repo: data.repo - } - } - - if (data.release && data.prerelease) { - // res.isPrerelease = true - // res.service.version = data.tag_name - } else if (data.release) { - // res.isPrerelease = false - // res.service.version = data.tag_name - } else { - res.branch = data.branch_name - } - return res - } - - deployService (deployRequest) { - return this.requestStream('v3/deployments/deployService', deployRequest) - } - - requestStream (qualifier, data) { - const sid = Date.now() - const subject = new Rx.Subject() - this.send(qualifier, data, sid) - this.responses.set(sid, subject) - return subject.asObservable() - } - - async send (qualifier, data, sid) { - const msg = { - q: qualifier, - sid: sid, - d: data - } - if (!ws) { - console.log('Reconnect to Environment Service: ' + process.env.ENV_SERVICE_ADDRESS) - await this.connect() - console.log('Connected! to Environment Service: ' + process.env.ENV_SERVICE_ADDRESS) - } - const payload = JSON.stringify(msg) - console.log('>>> SEND JSON TO TARGET DEST (' + process.env.ENV_SERVICE_ADDRESS + ') \n' + payload) - ws.sendUTF(payload) - } - - httpGet (url, vaultToken) { - return axios.get(url, { - headers: { - 'X-Vault-Token': vaultToken - } - }) - } -} -module.exports = new Environments() diff --git a/app/github-service.js b/app/github-service.js deleted file mode 100644 index 9ca37113..00000000 --- a/app/github-service.js +++ /dev/null @@ -1,78 +0,0 @@ -class GithubService { - constructor (app, cache) { - this.app = app - this.cache = cache - } - - async createCheckRun (octokit, checks) { - const all = [] - for (const check of checks) { - if (!octokit) octokit = this.cache.get(check.owner, check.repo) - const p = octokit.checks.create(check) - console.log('>>>> UPDATE GITHUB JOB STATUS >>>> \n' + JSON.stringify(check)) - all.push(p) - } - return await Promise.all(all) - } - - labels (owner, repo, issueNumber) { - return new Promise((resolve, reject) => { - const ctx = this.cache.get(owner, repo) - if (ctx) { - ctx.request(`GET /repos/${owner}/${repo}/issues/${issueNumber}/labels`) - .then(res => { - resolve(res.data) - }).catch((err) => { - reject(err) - }) - } - }) - } - - release (owner, repo, releaseId) { - return new Promise((resolve, reject) => { - const ctx = this.cache.get(owner, repo) - if (!ctx) { return } - // /repos/:owner/:repo/releases/tags/:tag - // /repos/{owner}/{repo}/releases/tags/{tag} - ctx.request(`GET /repos/${owner}/${repo}/releases/tags/${releaseId}`) - .then(res => { - resolve(res.data) - }).catch((err) => { - reject(err) - }) - }) - } - - // POST /repos/:owner/:repo/labels - // Example: - // { - // "name": "bug", - // "description": "Something isn't working", - // "color": "f29513" - // } - createLabel (owner, repo, label) { - const github = this.cache.get(owner, repo) - return github.request(`POST /repos/${owner}/${repo}/labels`, label) - } - - content (owner, repo, branch, path, base64) { - return new Promise((resolve, reject) => { - const ctx = this.cache.get(owner, repo) - if (ctx) { - ctx.repos.getContents({ owner: owner, repo: repo, ref: branch, path: path }) - .then(res => { - if (!base64) { - resolve(Buffer.from(res.data.content, 'base64').toString('ascii')) - } else { - resolve(res.data.content) - } - }).catch((err) => { - reject(err) - }) - } - }) - } -} - -module.exports = GithubService diff --git a/app/http-gateway.js b/app/http-gateway.js deleted file mode 100644 index b7bfe2ad..00000000 --- a/app/http-gateway.js +++ /dev/null @@ -1,366 +0,0 @@ -const GithubService = require('./github-service') -const cfg = require('./config') -const U = require('./utils') -const templates = require('./statuses/templates') -const envService = require('./environments') - -class ApiGateway { - constructor (app, cache) { - this.app = app - this.cache = cache - this.githubService = new GithubService(app, cache) - envService.connect() - } - - /** - ref string Required. The ref to deploy. This can be a branch, tag, or SHA. - task string Specifies a task to execute (e.g., deploy or deploy:migrations). Default: deploy - auto_merge boolean Attempts to automatically merge the default branch into the requested ref, if it's behind the default branch. Default: true - required_contexts array The status contexts to verify against commit status checks. If you omit this parameter, GitHub verifies all unique contexts before creating a deployment. To bypass checking entirely, pass an empty array. Defaults to all unique contexts. - payload string JSON payload with extra information about the deployment. Default: "" - environment string Name for the target deployment environment (e.g., production, staging, qa). Default: production - description string Short description of the deployment. Default: "" - transient_environment boolean Specifies if the given environment is specific to the deployment and will no longer exist at some point in the future. Default: false - Note: This parameter requires you to use the application/vnd.github.ant-man-preview+json custom media type. - production_environment boolean Specifies if the given environment is one that end-users directly interact with. Default: true when environment is production and false otherwise. - Note: This parameter requires you to use the application/vnd.github.ant-man-preview+json custom media type. - */ - createDeployment (context, deploy) { - const deployment = ApiGateway.toDeployment(deploy) - deployment.environment = deploy.namespace - return context.octokit.repos.createDeployment(deployment) - } - - deploymentStatus (context, deploy, state) { - const deployment = ApiGateway.toDeployment(deploy, state) - deployment.deployment_id = deploy.deployment_id - deployment.description = 'Deployment status: ' + state - deployment.log_url = `https://github.com/${deploy.owner}/${deploy.repo}/runs/${deploy.check_run_id}` - deployment.environment_url = process.env.DEPLOYMENT_URL - return context.octokit.repos.createDeploymentStatus(deployment) - } - - static toDeployment (deploy, state) { - const deployment = {} - deployment.task = 'deploy' - deployment.auto_merge = false - deployment.payload = deploy - deployment.owner = deploy.owner - deployment.repo = deploy.repo - deployment.ref = deploy.branch_name - deployment.required_contexts = [] - if (state) deployment.state = state - if (state === 'inactive') { - deployment.headers = { - accept: 'application/vnd.github.ant-man-preview+json' - } - } else if (state === 'in_progress' || state === 'queued') { - deployment.headers = { - accept: 'application/vnd.github.flash-preview+json' - } - } - return deployment - } - - async deploy (context, deploy) { - console.log(deploy.check_run_name + ' : ' + deploy.owner + '/' + deploy.repo + '/' + deploy.namespace + ' - ' + deploy.user + ' - ' + deploy.status + ' - ' + deploy.conclusion) - const checkRunName = deploy.check_run_name - const conclusion = deploy.conclusion - const status = deploy.status - if (context.user_action === 'cancel_deploy_now') { - if (context.payload.check_run.external_id) { - this.updateCheckRunStatus(context, deploy, 'cancelled', cfg.deploy.check.canceled) - } - } else if (this.checkDeploy(deploy, context.user_action, checkRunName, status, conclusion)) { - if (!this.isFeatureBranch(deploy)) { - const deployBranch = this.clone(deploy) - deployBranch.is_pull_request = false - deployBranch.check_run_name = cfg.deploy.check.name - if (!deploy.release) { - deployBranch.namespace = deploy.branch_name - } - delete deployBranch.issue_number - delete deployBranch.base_branch_name - this.enviromentDeploy(context, deployBranch) - } - - if (deploy.is_pull_request && U.isLabeled(deploy.labels, cfg.ROBOKIT_LABEL)) { - const deployPullRequest = this.clone(deploy) - deployPullRequest.check_run_name = cfg.deploy.check.name + ' (pull_request)' - this.enviromentDeploy(context, deployPullRequest) - } - } - return 'OK' - } - - checkDeploy (deploy, userAction, checkRunName, status, conclusion) { - if (this.isRobokitTrigger(checkRunName, status, conclusion)) { - console.log('robokit deploy job finished successfully') - } - - if (deploy.check_run_name === 'pull_request' && this.isFeatureBranch(deploy)) { - return true - } else if (userAction === 'deploy_now') { - return true - } else if (this.isRobokitTrigger(checkRunName, status, conclusion)) { - return deploy.release || this.isKnownBranch(deploy) || this.isFeatureBranch(deploy) - } else return !!this.isRobokitRelease(checkRunName, status, conclusion) - } - - isFeatureBranch (deploy) { - return (deploy.is_pull_request && U.isLabeled(deploy.labels, [cfg.ROBOKIT_LABEL])) - } - - isRobokitRelease (checkRunName, status, conclusion) { - return (checkRunName === 'Robokit CD (release)' && status === 'completed' && conclusion === 'success') - } - - isRobokitTrigger (checkRunName, status, conclusion) { - return (checkRunName === cfg.ROBOKIT_DEPLOY && status === 'completed' && conclusion === 'success') - } - - isKnownBranch (deploy) { - return (deploy.branch_name === 'develop' || deploy.branch_name === 'master') - } - - async enviromentDeploy (context, deploy) { - const res = await this.updateCheckRunStatus(context, deploy, 'in_progress', cfg.deploy.check.starting) - deploy.check_run_id = res[0].data.id - - this.createDeployment(context, deploy, 'in_progress') - .then(async res => { - deploy.deployment_id = res.data.id - const log = [] - const self = this - console.log('Environment Service Deploy Request: ' + JSON.stringify(deploy)) - envService.deploy(deploy).subscribe({ - next (resp) { - log.push(resp.d) - deploy.details = log - }, - async error (err) { - const output = cfg.deploy.check.canceled - output.text = output.text + '\n reason: ' + err.d.errorMessage - const res = await self.updateCheckRunStatus(context, deploy, 'cancelled', output) - deploy.check_run_id = res[0].data.id - self.deploymentStatus(context, deploy, 'inactive') - }, - async complete () { - console.log('Stream completed updating status') - const res = await self.checkRunStatus(context, deploy, log, U.tail(log).status) - deploy.check_run_id = res[0].data.id - /** - state string Required. - The state of the status. Can be one of error, failure, inactive, in_progress, queued pending, or success. - To use the in_progress and queued states, you must provide the application/vnd.github.flash-preview+json custom media type. - */ - self.deploymentStatus(context, deploy, ApiGateway.getState(U.tail(log).status)) - } - }) - }).catch(err => { - U.printError(err.message, err) - if (err.code === 403 && err.message === 'Resource not accessible by integration') { - const cancel = cfg.deploy.check.canceled - const url = `https://github.com/${deploy.owner}/${deploy.repo}/settings/installations` - cancel.text = `Robokit Github Application requires permissions to create deployments\n ${url}\n ${err.request.url} \n ${err.documentation_url}` - this.updateCheckRunStatus(context, deploy, 'cancelled', cancel) - } else { - const cancel = cfg.deploy.check.canceled - cancel.text = cancel.text + '\n error message: ' + err.message - this.updateCheckRunStatus(context, deploy, 'cancelled', cancel) - } - }) - } - - static getState (status) { - let state = 'in_progress' - if (status === 'ERROR') { - state = 'error' - } else if (status === 'SUCCESS') { - state = 'success' - } - return state - } - - async deployContext (context) { - let deploy = {} - if (context.payload.check_run) { - deploy = U.toCheckRunDeployContext(context) - await this.tryReleaseVersion(deploy) - } else if (context.payload.release) { - deploy = U.toReleaseDeployContext(context) - } else if (context.payload.pull_request) { - deploy = U.toPullRequestDeployContext(context) - } - - deploy.namespace = U.targetNamespace(deploy) - deploy.id = context.id - deploy.user = context.payload.sender.login - deploy.avatar = context.payload.sender.avatar_url - deploy.node_id = context.payload.installation.node_id - - return deploy - } - - async tryReleaseVersion (deploy) { - try { - const release = await this.githubService.release(deploy.owner, deploy.repo, deploy.branch_name) - deploy.branch_name = release.target_commitish - deploy.release = true - deploy.prerelease = release.prerelease - deploy.tag_name = release.tag_name.replace(/^v/, '') - deploy.draft = release.draft - deploy.release_id = release.id - console.log('Release Version') - } catch (e) { - deploy.release = false - } - } - - toChecks (deploy, log, status) { - const startDate = new Date(U.head(log).timestamp) - const endDate = new Date(U.tail(log).timestamp) - const check = { - name: deploy.check_run_name, - owner: deploy.owner, - repo: deploy.repo, - head_sha: deploy.sha, - status: U.getStatus(status).status, - output: this.toOutput(cfg.deploy.check.update, log, deploy), - external_id: log[0].id - } - - if (U.getStatus(status).conclusion) { - try { - check.completed_at = endDate.toISOString() - check.started_at = startDate.toISOString() - } catch (e) {} - check.conclusion = U.getStatus(status).conclusion - check.actions = cfg.user_actions.done - } else { - check.actions = cfg.user_actions.in_progress - } - return [check] - } - - toOutput (template, log, deploy) { - const startDate = new Date(U.head(log).timestamp) - const endDate = new Date(U.tail(log).timestamp) - const duration = endDate.getSeconds() - startDate.getSeconds() - const status = U.tail(log).status - let md = templates.get(template.template) - - Object.entries(deploy).forEach((e) => { - md = md.split('${' + e[0] + '}').join(e[1]) - }) - - // eslint-disable-next-line no-template-curly-in-string - md = md.split('${progress}').join(status) - // eslint-disable-next-line no-template-curly-in-string - md = md.split('${duration}').join(duration + 's') - // eslint-disable-next-line no-template-curly-in-string - md = md.split('${log_details}').join('> DATE: ' + startDate + '\n' + U.toDetails(log)) - - if (md.includes('object')) { - console.log(md) - } - - return { - title: status, - // eslint-disable-next-line no-template-curly-in-string - summary: template.summary.replace('${conclusion}', U.getStatus(status).conclusion), - text: md - } - } - - updateCheckRunStatus (context, deploy, status, output) { - const checkrun = ApiGateway.checkStatus(deploy, status) - checkrun.output = output - return this.githubService.createCheckRun(context.octokit, [checkrun], deploy) - } - - checkRunStatus (context, deploy, log, status) { - const checks = this.toChecks(deploy, log, status) - return this.githubService.createCheckRun(context.octokit, checks, deploy) - } - - /** - * The current status. Can be one of queued, in_progress, or completed. Default: queued - * - * Required if you provide completed_at or a status of completed. - * The final conclusion of the check. - * Can be one of success, failure, neutral, cancelled, timed_out, or action_required. - * When the conclusion is action_required, additional details should be provided on the site specified by details_url. - * Note: Providing conclusion will automatically set the status parameter to completed. - * @param deploy - * @param status - * @returns {{owner: *, repo: *, name: *, sha: (*|number), status: *}} - */ - static checkStatus (deploy, status) { - const result = { - name: deploy.check_run_name, - owner: deploy.owner, - repo: deploy.repo, - head_sha: deploy.sha, - status: status - } - if (deploy.external_id) { - result.external_id = deploy.external_id - } - if (status === 'completed') { - result.status = 'completed' - result.conclusion = 'success' - result.completed_at = new Date().toISOString() - result.actions = cfg.user_actions.done - } else if (status === 'cancelled') { - result.status = 'completed' - result.conclusion = 'cancelled' - result.actions = cfg.user_actions.done - } else if (status === 'in_progress') { - result.status = 'in_progress' - result.started_at = new Date().toISOString() - } else if (status === 'queued') { - result.status = 'queued' - } - return result - } - - onAppInstall (context) { - const owner = context.payload.installation.account.login - if (context.payload.repositories) { - context.payload.repositories.forEach(repo => { - const repoName = repo.name - if (context.payload.action === 'created') { - this.installCache(owner, repoName, context) - this.installAppLabels(owner, repoName) - } else if (context.payload.action === 'deleted') { - } - }) - } - } - - installCache (owner, repo, context) { - this.cache.set(owner, repo, context.octokit) - } - - installAppLabels (owner, repo) { - cfg.labels.forEach(label => { - this.githubService.createLabel(owner, repo, label) - }) - } - - static sendResponse (response, result) { - if (result === 'ok') { - response.send(result) - } else { - response.status(500) - response.send(result) - } - }; - - clone (obj) { - return JSON.parse(JSON.stringify(obj)) - } -} - -module.exports = ApiGateway diff --git a/app/statuses/running.md b/app/statuses/running.md deleted file mode 100644 index 86dc4dca..00000000 --- a/app/statuses/running.md +++ /dev/null @@ -1,18 +0,0 @@ -## Robokit Continues Delivery - - - -${progress} - -:black_circle: Details: -``` -Namespace: ${namespace} -branch: ${branch_name} -sha: ${sha} -``` - -Pipeline stages: -```diff -> (Trigger) robo-kit pipeline queued. -> (Trigger) robo-kit deployment pipeline was triggered successfully -``` \ No newline at end of file diff --git a/app/statuses/templates.js b/app/statuses/templates.js deleted file mode 100644 index d54a8831..00000000 --- a/app/statuses/templates.js +++ /dev/null @@ -1,29 +0,0 @@ -const fs = require('fs') -const path = require('path') - -class Templates { - constructor () { - this.statusContent = new Map() - this.statusContent.set('running', this.env(fs.readFileSync(path.resolve(__dirname, './running.md'), 'utf8'))) - this.statusContent.set('starting', this.env(fs.readFileSync(path.resolve(__dirname, './starting.md'), 'utf8'))) - this.statusContent.set('waiting', this.env(fs.readFileSync(path.resolve(__dirname, './waiting.md'), 'utf8'))) - this.statusContent.set('canceled', this.env(fs.readFileSync(path.resolve(__dirname, './canceled.md'), 'utf8'))) - this.statusContent.set('status', this.env(fs.readFileSync(path.resolve(__dirname, './status.md'), 'utf8'))) - } - - get (key) { - return this.statusContent.get(key) - } - - env (data) { - // eslint-disable-next-line no-template-curly-in-string - //data = data.replace('${GRAPHANA_URL}', process.env.GRAPHANA_URL) - // eslint-disable-next-line no-template-curly-in-string - //data = data.replace('${ROBOKIT_URL}', process.env.ROBOKIT_URL) - // eslint-disable-next-line no-template-curly-in-string - //data = data.replace('${VAULT_URL}', process.env.VAULT_URL) - return data - } -} - -module.exports = new Templates() diff --git a/app/statuses/waiting.md b/app/statuses/waiting.md deleted file mode 100644 index 930fe963..00000000 --- a/app/statuses/waiting.md +++ /dev/null @@ -1,17 +0,0 @@ -## Robokit Continues Delivery - - - -${progress} - -:black_circle: Details: -``` -Namespace: ${namespace} -branch: ${branch_name} -sha: ${sha} -``` - -Pipeline stages: -```diff -> (Trigger) robo-kit pipeline queued. -``` \ No newline at end of file diff --git a/app/utils.js b/app/utils.js deleted file mode 100644 index 1955224c..00000000 --- a/app/utils.js +++ /dev/null @@ -1,295 +0,0 @@ - -class Utils { - static urlConcat (array) { - let baseUrl = new URL(array[0]) - for (let i = 1; i < array.length; i++) { - baseUrl = new URL(array[i], baseUrl) - } - return baseUrl.toString() - } - - static isLabeled (labels, names) { - let result = false - if (labels && Array.isArray(labels)) { - labels.forEach(label => { - names.forEach(name => { - if (label === name) { - result = true - return true - } - }) - }) - } - return result - } - - static printError (message, err) { - console.error(message) - console.error(err) - const out = { - message: err?.message, - name: err?.name, - code: err?.code, // ECONNREFUSED, ETIMEDOUT, etc. - errno: err?.errno, - syscall: err?.syscall, - isAxiosError: !!err?.isAxiosError, - request: { - method: err?.config?.method, - url: err?.config?.url || err?.config?.baseURL, - timeout: err?.config?.timeout - } - } - - if (err?.response) { - out.response = { - status: err.response.status, - statusText: err.response.statusText, - headers: err.response.headers, - data: err.response.data - } - } else if (err?.request) { - out.response = null // request made, no response - } else { - out.response = undefined // failed before sending - } - - console.dir(out, { depth: null, colors: true }) - } - - static isPullRequest (context) { - if (context.payload.check_run) { - return (context.payload.check_run.pull_requests && context.payload.check_run.pull_requests.length > 0) - } else { - return (context.payload.check_run.pull_requests && context.payload.check_run.pull_requests.length > 0) - } - } - - static issueNumber (context) { - if (context.payload.check_run) { - if (context.payload.check_run.check_suite) { - if (context.payload.check_run.check_suite.pull_requests.length > 0) { - return context.payload.check_run.check_suite.pull_requests[0].number - } - } else { - if (context.payload.check_run.pull_requests.length > 0) { - return context.payload.check_run.pull_requests[0].number - } - } - } - if (context.payload.pull_request) { - return context.payload.pull_request.number - } - } - - static baseBranchName (context) { - if (context.payload.check_run) { - if (context.payload.check_run.check_suite.pull_requests.length > 0) { - return context.payload.check_run.check_suite.pull_requests[0].base.ref - } - } - - if (context.payload.pull_request) { - return context.payload.pull_request.base.ref - } - } - - static branchName (context) { - if (context.payload.check_suite) { - return context.payload.check_suite.head_branch - } else if (context.payload.check_run) { - return context.payload.check_run.check_suite.head_branch - } - - if (context.payload.pull_request) { - return context.payload.pull_request.head.ref - } - } - - static targetNamespace (deploy) { - if (deploy.prerelease) { - return '' - } else if (deploy.release) { - return '' - } else if (deploy.base_branch_name) { - return `${deploy.repo}-${deploy.issue_number}` - } else if (deploy.branch_name === 'master' || deploy.branch_name === 'develop') { - return deploy.branch_name - } - } - - static toReleaseDeployContext (context) { - let ctx = { - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name - } - ctx = Object.assign(ctx, context.payload.release) - return ctx - } - - static toPullRequestDeployContext (context) { - const ctx = { - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - branch_name: Utils.branchName(context), - base_branch_name: Utils.baseBranchName(context), - sha: context.payload.pull_request.head.sha, - is_pull_request: true, - check_run_name: 'pull_request', - conclusion: null, - status: context.payload.action, - action: context.payload.action - } - if (ctx.is_pull_request) { ctx.issue_number = Utils.issueNumber(context) } - ctx.labels = context.payload.pull_request.labels.map(e => e.name) - ctx.labled = ctx.labels.length > 0 - return ctx - } - - static toCheckRunDeployContext (context) { - const ctx = { - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - branch_name: Utils.branchName(context), - base_branch_name: Utils.baseBranchName(context), - sha: context.payload.check_run.head_sha, - check_run_name: context.payload.check_run.name, - - conclusion: context.payload.check_run.conclusion, - status: context.payload.check_run.status, - action: context.payload.action - } - if (ctx.is_pull_request) { ctx.issue_number = Utils.issueNumber(context) } - return ctx - } - - static mapToChecks (req) { - const all = [] - for (let i = 0; i < req.checks.length; i++) { - const check = { - owner: req.owner, - repo: req.repo, - head_sha: req.sha, - name: req.checks[i].name, - status: req.checks[i].status, - output: req.checks[i].output - } - if (req.checks[i].conclusion && req.checks[i].conclusion != null) { - check.conclusion = req.checks[i].conclusion - } - all.push(check) - } - return all - } - - static format (field, values) { - Object.entries(values).forEach((e) => { - field = field.replace('${' + e[0] + '}', e[1]) - }) - return field - } - - static toDetails (logs) { - let details = '' - for (let i = 0; i < logs.length; i++) { - const log = logs[i] - const message = JSON.stringify(log.data, null, 2) - let status = log.status - if (i < logs.length - 1) { - status = 'SUCCESS' - } - if (message !== undefined) { - for (const line of message.split(/\r?\n/)) { - const str = line.replace(/(\r\n|\r|\n)/g, ' ') - details += `${Utils.getMarker(status)} ${str} \n` - } - } - } - return details - } - - static time (t) { - if (t) { - return new Date(t).toISOString() - } else return '....-..-..T..:..:.....z' - } - - static getPrgress (status, conclusion) { - if (conclusion === 'success') { - return ':heavy_check_mark:     Deployed! ' - } else if (conclusion === 'cancelled') { - return ':no_entry_sign:     CANCELLED! ' - } else if (status === 'completed' && conclusion) { - return ':x:     FAILED! ' - } else { - console.log('Deploying ' + status + ' ' + conclusion) - return ' Deploying...' - } - } - - static toPrgress (status) { - if (status === 'SUCCEEDED') { - return ':heavy_check_mark:     Deployed! ' - } else if (status === 'TERMINAL' || status === 'FAILED_CONTINUE') { - return ':x:     FAILED! ' - } else if (status === 'NOT_STARTED' || status === 'RUNNING') { - console.log('Deploying ' + status) - return ' Deploying...' - } else if (status === 'CANCELED' || status === 'PAUSED' || status === 'SUSPENDED') { - return `:no_entry_sign:     ${status}!` - } - } - - static getMarker (status) { - if (status === 'SUCCEEDED' || status === 'SUCCESS') { - return '>' - } else if (status === 'TERMINAL' || status === 'FAILED_CONTINUE' || status === 'ERROR') { - return '<' - } else if (status === 'CANCELED' || status === 'PAUSED' || status === 'SUSPENDED') { - return '#' - } else if (status === 'RUNNING') { - return '*' - } else return ' ' - } - - /* - Required if you provide completed_at or a status of completed. The final conclusion of the check. - Can be one of success, failure, neutral, cancelled, timed_out, or action_required. - When the conclusion is action_required, additional details should be provided on the site specified by details_url. - Note: Providing conclusion will automatically set the status parameter to completed. Only GitHub can change a check run conclusion to stale. - */ - static getStatus (status) { - if (status === 'SUCCEEDED' || status === 'SUCCESS') { - return { - status: 'completed', - conclusion: 'success' - } - } else if (status === 'TERMINAL' || status === 'FAILED_CONTINUE' || status === 'ERROR') { - return { - status: 'completed', - conclusion: 'failure' - } - } else if (status === 'NOT_STARTED' || status === 'RUNNING') { - return { - status: 'in_progress' - } - } else if (status === 'CANCELLED' || status === 'PAUSED' || status === 'SUSPENDED') { - return { - status: 'completed', - conclusion: 'cancelled' - } - } else { - return { - status: 'in_progress' - } - } - } - - static tail (log) { - return log[log.length - 1] - } - - static head (log) { - return log[0] - } -} -module.exports = Utils diff --git a/app/vault-api.js b/app/vault-api.js deleted file mode 100644 index 7f08669d..00000000 --- a/app/vault-api.js +++ /dev/null @@ -1,37 +0,0 @@ -const axios = require('axios') -const fs = require('fs') -const utils = require('./utils') - -class Vault { - constructor (options) { - this.vaultAddress = options.VAULT_ADDR || process.env.VAULT_ADDR - } - - read (token, path) { - return new Promise((resolve, reject) => { - const uri = utils.urlConcat([this.vaultAddress, '/v1/', path]) - axios.get(uri, { headers: { 'X-Vault-Token': token } }).then(response => { - resolve(response.data.data) - }).catch((error) => { - reject(error) - }) - }) - } - - k8sLogin (role, jwtPath) { - if (!jwtPath) jwtPath = '/var/run/secrets/kubernetes.io/serviceaccount/token' - return new Promise((resolve, reject) => { - const token = fs.readFileSync(jwtPath) - const params = { - role: role, - jwt: token.toString('utf8') - } - const url = utils.urlConcat([this.vaultAddress, `/v1/auth/${process.env.VAULT_JWT_PROVIDER}/login`]) - console.log(url) - axios.post(url, params).then(resp => { - resolve(resp.data.auth) - }).catch(err => reject(err)) - }) - } -} -module.exports = Vault diff --git a/index.js b/index.js deleted file mode 100644 index 1881253c..00000000 --- a/index.js +++ /dev/null @@ -1,81 +0,0 @@ -const cache = require('./app/cache') -const { Webhooks } = require('@octokit/webhooks') -const { Probot } = require('probot') -/** - * This is the main entrypoint to your Probot app - * @param {import('probot').Application} app - */ -const robokit = app => { - const ApiGateway = require('./app/http-gateway') - const cache = require('./app/cache') - console.log('Starting the TxBot service.') - const api = new ApiGateway(app, cache) - - app.on('installation', context => { - cache.set(context.payload.repository.owner.login, context.payload.repository.name, context.octokit) - api.onAppInstall(context) - console.log('installation event:' + JSON.stringify(context)) - }) - - app.on('check_run', context => { - cache.set(context.payload.repository.owner.login, context.payload.repository.name, context.octokit) - console.log(context.payload.check_run.name + ' - ' + context.payload.check_run.status + ' - ' + context.payload.check_run.conclusion) - if (context.payload.requested_action) { - const action = context.payload.requested_action.identifier - context.user_action = action - } - - api.deployContext(context).then(deploy => { - console.log(deploy.check_run_name) - if (deploy.is_pull_request || api.isKnownBranch(deploy)) { - api.deploy(context, deploy) - } - }) - }) - - app.on([ - 'pull_request.synchronize', - 'pull_request.labeled', - 'pull_request.opened', - 'pull_request.reopened', - 'pull_request.unlabeled', - 'pull_request.closed' - ], context => { - if (context.payload.action === 'opened' || context.payload.action === 'reopened') { - const deploy = api.deployContext(context) - if (deploy.is_pull_request || api.isKnownBranch(deploy)) { - api.deploy(context, deploy) - } - } - }) - - app.on([ - 'issue_comment', - 'issues', - 'push'], async context => { - // api.route(context); - }) - - console.log('Server Started.') - // smee() -} -function smee () { - if (process.env.WEBHOOK_PROXY_URL) { - const SmeeClient = require('smee-client') - const smee = new SmeeClient({ - source: process.env.WEBHOOK_PROXY_URL, - target: `http://localhost:${global.env.PORT}`, - logger: console - }) - - smee.start() - } -} -module.exports = robokit - -// For more information on building apps: -// https://probot.github.io/docs/ - -// To get your app running against GitHub, see: -// https://probot.github.io/docs/development/ -// Authenticate as the App diff --git a/package.json b/package.json index bd7f9ac2..91c40b88 100644 --- a/package.json +++ b/package.json @@ -8,43 +8,47 @@ "repository": "https://github.com/scalecube/robokit.git", "homepage": "https://github.com/scalecube/robolit", "bugs": "https://github.com/scalecube/robokit/issues", - "keywords": ["probot", "github", "probot-app"], + "keywords": [ + "probot", + "github", + "probot-app" + ], "scripts": { "dev": "./node_modules/.bin/nodemon", - "robokit": "node ./robokit.js", - "start": "./node_modules/.bin/probot run ./index.js", + "robokit": "node ./src/server.js", + "start": "./node_modules/.bin/probot run ./src/webhook.js", "lint": "./node_modules/.bin/standard --fix", - "build": "browserify ./app/views/main.js -o ./app/views/bundle.js --debug -t [ babelify --presets [ \"@babel/preset-env\" \"@babel/preset-react\" ] ]", - "watch": "watchify ./app/views/main.js -o ./app/views/bundle.js --debug -t [ babelify --presets [ \"@babel/preset-env\" \"@babel/preset-react\" ] ]" + "test": "jest --testPathPattern=test/unit", + "test:e2e": "jest --testPathPattern=test/e2e" }, "dependencies": { "axios": "^1.8.2", - "body-parser": "^1.20.2", - "lodash": "^4.17.21", + "pino": "^9.14.0", "probot": "^13.4.5", "rxjs": "^6.6.7", - "rxjs-compat": "^6.6.7", "websocket": "^1.0.34" }, "devDependencies": { - "@babel/core": "^7.23.2", - "@babel/preset-env": "^7.23.2", - "@babel/preset-react": "^7.23.2", - "babelify": "^10.0.0", + "jest": "^29.7.0", "nock": "^11.4.0", "nodemon": "^2.0.0", - "smee-client": "^1.1.0", + "smee-client": "^5.0.0", "standard": "^17.1.2" }, "engines": { "node": ">= 18" }, "standard": { - "env": ["jest"] + "env": [ + "jest" + ] }, "nodemonConfig": { "exec": "npm start", - "watch": [".env", "."] + "watch": [ + ".env", + "." + ] }, "jest": { "testEnvironment": "node" diff --git a/robokit.js b/robokit.js deleted file mode 100644 index 9ea73206..00000000 --- a/robokit.js +++ /dev/null @@ -1,57 +0,0 @@ -const U = require('./app/utils') - -async function start () { - require('dotenv').config() - const vault = new (require('./app/vault-api'))(process.env.VAULT_ADDR) - - vault.k8sLogin(process.env.VAULT_ROLE, process.env.VAULT_JWT_PATH) - .then(async token => { - vault.read(token.client_token, process.env.VAULT_SECRETS_PATH) - .then(async values => { - for (var key in values) { - process.env[key] = values[key] - } - console.log('process.env.PORT:' + process.env.PORT) - const { Server, Probot } = require('probot') - const app = require('./index.js') - async function startServer () { - const serverOptions = { - port: process.env.PORT || 3000, - webhooks: { - path: '/', - secret: process.env.WEBHOOK_SECRET - }, - Probot: Probot.defaults({ - appId: process.env.APP_ID, - privateKey: process.env.PRIVATE_KEY, - secret: process.env.WEBHOOK_SECRET - }) - } - - // only add webhookProxy if defined - if (process.env.WEBHOOK_PROXY_URL) { - serverOptions.webhookProxy = process.env.WEBHOOK_PROXY_URL - console.log('process.env.WEBHOOK_PROXY_URL: ' + process.env.WEBHOOK_PROXY_URL) - } - - const server = new Server(serverOptions) - - await server.load(app) - await server.start() - } - - startServer().catch(err => { - console.error(err) - process.exit(1) - }) - }).catch(err => { - U.printError(`ERROR reading variables from vault \n - VAULT_SECRETS_PATH:${process.env.VAULT_SECRETS_PATH}\n - VAULT_ADDR:${process.env.VAULT_ADDR} - error:`, err) - }) - }).catch(err => { - U.printError('k8sLogin failed with error:', err) - }) -} -start() diff --git a/src/adapters/cd-service/CdServiceClient.js b/src/adapters/cd-service/CdServiceClient.js new file mode 100644 index 00000000..6509eb44 --- /dev/null +++ b/src/adapters/cd-service/CdServiceClient.js @@ -0,0 +1,81 @@ +const WebSocketClient = require('websocket').client +const axios = require('axios') +const { Subject } = require('rxjs') +const VaultAdapter = require('../vault/VaultAdapter') +const logger = require('../../logger') +const cfg = require('../../config') + +class CdServiceClient { + #ws = null + #connecting = null + #responses = new Map() + + async connect () { + logger.info({ address: cfg.cdServiceAddress }, 'cd-service.connecting') + const vault = new VaultAdapter() + const token = await vault.k8sLogin(cfg.vault.role, cfg.vault.jwtPath) + const oidcUrl = `${cfg.vault.addr.replace(/\/$/, '')}/v1/identity/oidc/token/${cfg.cdServiceRole}` + const oidcRes = await axios.get(oidcUrl, { headers: { 'X-Vault-Token': token.client_token } }) + return this.#openConnection(cfg.cdServiceAddress, oidcRes.data.data.token) + } + + #openConnection (address, token) { + return new Promise((resolve, reject) => { + const client = new WebSocketClient() + client.on('connectFailed', reject) + client.on('connect', connection => { + logger.info({ address }, 'cd-service.connected') + connection.on('error', err => { logger.error({ err }, 'cd-service.connection-error'); this.#ws = null }) + connection.on('close', () => { logger.info('cd-service.connection-closed'); this.#ws = null }) + connection.on('message', msg => this.#onMessage(msg)) + this.#ws = connection + resolve(connection) + }) + client.connect(address, undefined, undefined, { 'X-Exberry-Token': token }) + }) + } + + #onMessage (message) { + if (message.type !== 'utf8') return + const json = JSON.parse(message.utf8Data) + const subject = this.#responses.get(json.sid) + if (!subject) return + if (json.sig) this.#responses.delete(json.sid) + if (json.sig === 1) subject.complete() + else if (json.sig === 2 || json.sig === 3) subject.error(json) + else subject.next(json) + } + + async #getConnection () { + if (this.#ws?.connected) return this.#ws + if (this.#connecting) return this.#connecting + this.#connecting = this.connect().finally(() => { this.#connecting = null }) + return this.#connecting + } + + async #send (qualifier, data, sid) { + const conn = await this.#getConnection() + const payload = JSON.stringify({ q: qualifier, sid, d: data }) + logger.debug({ qualifier, sid }, 'cd-service.send') + conn.sendUTF(payload) + } + + deploy (context) { + const sid = `${context.owner}/${context.repo}:${Date.now()}` + const subject = new Subject() + this.#responses.set(sid, subject) + const request = context.release + ? { service: { owner: context.owner, repo: context.repo } } + : { service: { owner: context.owner, repo: context.repo }, branch: context.branch } + logger.info({ request, sid }, 'cd-service.deploy') + this.#send('v3/deployments/deployService', request, sid) + .catch(err => { this.#responses.delete(sid); subject.error(err) }) + return subject.asObservable() + } + + isConnected () { + return this.#ws?.connected ?? false + } +} + +module.exports = new CdServiceClient() diff --git a/src/adapters/cd-service/CdServiceMock.js b/src/adapters/cd-service/CdServiceMock.js new file mode 100644 index 00000000..d232e471 --- /dev/null +++ b/src/adapters/cd-service/CdServiceMock.js @@ -0,0 +1,23 @@ +const { Subject } = require('rxjs') +const logger = require('../../logger') + +function deploy (context) { + logger.info({ owner: context.owner, repo: context.repo, branch: context.branch }, 'cd-service.mock.deploy') + const subject = new Subject() + const id = `mock-${context.sha}` + const ts = () => new Date().toISOString() + const event = (status, message) => ({ sig: 0, d: { status, id, timestamp: ts(), data: { message } } }) + + setTimeout(() => subject.next(event('RUNNING', 'Preparing deployment (mock)')), 200) + setTimeout(() => subject.next(event('RUNNING', 'Deploying (mock)...')), 1200) + setTimeout(() => { + subject.next(event('SUCCEEDED', 'Deployment complete (mock)')) + subject.complete() + }, 2500) + + return subject.asObservable() +} + +function isConnected () { return true } + +module.exports = { deploy, isConnected } diff --git a/src/adapters/github/CheckRunAdapter.js b/src/adapters/github/CheckRunAdapter.js new file mode 100644 index 00000000..aab93d90 --- /dev/null +++ b/src/adapters/github/CheckRunAdapter.js @@ -0,0 +1,120 @@ +const cfg = require('../../config') +const registry = require('./OctokitRegistry') +const renderer = require('../../templates/renderer') +const { toGitHub, toMarker } = require('../../domain/PipelineStatus') +const logger = require('../../logger') + +async function upsert (octokit, context, checkRunId, check) { + const s = check.conclusion ? `${check.status}/${check.conclusion}` : check.status + logger.info({ repo: `${check.owner}/${check.repo}`, name: check.name, status: s }, 'github.check-run.upsert') + const client = octokit ?? registry.get(check.owner, check.repo) + if (checkRunId) { + return client.checks.update({ + check_run_id: checkRunId, + owner: check.owner, + repo: check.repo, + status: check.status, + conclusion: check.conclusion, + completed_at: check.completed_at, + started_at: check.started_at, + output: check.output, + actions: check.actions + }) + } + return client.checks.create(check) +} + +function buildInitialCheck (context, status) { + const check = { + name: context.checkRunName, + owner: context.owner, + repo: context.repo, + head_sha: context.sha, + status + } + if (status === 'completed') { + check.conclusion = 'success' + check.completed_at = new Date().toISOString() + check.actions = cfg.userActions.done + } else if (status === 'cancelled') { + check.status = 'completed' + check.conclusion = 'cancelled' + check.actions = cfg.userActions.done + } else if (status === 'in_progress') { + check.started_at = new Date().toISOString() + } + return check +} + +function buildCheckFromLog (context, log, envStatus) { + const first = log[0] + const last = log[log.length - 1] + const { status, conclusion } = toGitHub(envStatus) + const durationSeconds = Math.round((new Date(last.timestamp) - new Date(first.timestamp)) / 1000) + const vars = { + owner: context.owner, + repo: context.repo, + sha: context.sha, + branch: context.branch, + namespace: context.namespace ?? '', + user: context.user, + progress: envStatus, + duration: `${durationSeconds}s`, + log_details: toLogDetails(log), + conclusion: conclusion ?? 'in_progress' + } + const check = { + name: context.checkRunName, + owner: context.owner, + repo: context.repo, + head_sha: context.sha, + status, + external_id: first.id, + output: { + title: envStatus, + summary: `Continuous Delivery pipeline: ${conclusion ?? 'in_progress'}`, + text: renderer.render('status', vars) + } + } + if (conclusion) { + try { + check.started_at = new Date(first.timestamp).toISOString() + check.completed_at = new Date(last.timestamp).toISOString() + } catch (e) { + logger.warn({ err: e.message }, 'check-run.timestamp-parse-failed') + } + check.conclusion = conclusion + check.actions = cfg.userActions.done + } else { + check.actions = cfg.userActions.inProgress + } + return check +} + +function toLogDetails (log) { + let details = '' + for (let i = 0; i < log.length; i++) { + const entry = log[i] + const message = JSON.stringify(entry.data, null, 2) + const status = i < log.length - 1 ? 'SUCCESS' : entry.status + if (message !== undefined) { + for (const line of message.split(/\r?\n/)) { + details += `${toMarker(status)} ${line.replace(/(\r\n|\r|\n)/g, ' ')} \n` + } + } + } + return details +} + +async function setStatus (octokit, context, checkRunId, status, output) { + const check = buildInitialCheck(context, status) + check.output = output + return upsert(octokit, context, checkRunId, check) +} + +async function setStatusFromLog (octokit, context, checkRunId, log, envStatus) { + const check = buildCheckFromLog(context, log, envStatus) + return upsert(octokit, context, checkRunId, check) +} + +module.exports = { setStatus, setStatusFromLog } diff --git a/src/adapters/github/DeploymentAdapter.js b/src/adapters/github/DeploymentAdapter.js new file mode 100644 index 00000000..fd2bfb7b --- /dev/null +++ b/src/adapters/github/DeploymentAdapter.js @@ -0,0 +1,37 @@ +const logger = require('../../logger') +const cfg = require('../../config') + +async function create (octokit, context) { + logger.info({ owner: context.owner, repo: context.repo, env: context.namespace }, 'github.deployment.create') + return octokit.repos.createDeployment({ + task: 'deploy', + auto_merge: false, + payload: {}, + owner: context.owner, + repo: context.repo, + ref: context.branch, + environment: context.namespace, + required_contexts: [] + }) +} + +async function setStatus (octokit, context, checkRunId, deploymentId, state) { + logger.info({ state, deploymentId }, 'github.deployment.set-status') + return octokit.repos.createDeploymentStatus({ + owner: context.owner, + repo: context.repo, + deployment_id: deploymentId, + state, + description: `Deployment: ${state}`, + log_url: `https://github.com/${context.owner}/${context.repo}/runs/${checkRunId}`, + environment_url: cfg.deploymentUrl || undefined + }) +} + +function toGitHubState (envStatus) { + if (envStatus === 'ERROR') return 'error' + if (envStatus === 'SUCCESS' || envStatus === 'SUCCEEDED') return 'success' + return 'in_progress' +} + +module.exports = { create, setStatus, toGitHubState } diff --git a/src/adapters/github/OctokitRegistry.js b/src/adapters/github/OctokitRegistry.js new file mode 100644 index 00000000..1797ba15 --- /dev/null +++ b/src/adapters/github/OctokitRegistry.js @@ -0,0 +1,16 @@ +const logger = require('../../logger') + +const registry = new Map() +const key = (owner, repo) => `${owner}/${repo}` + +function register (owner, repo, octokit) { + registry.set(key(owner, repo), octokit) +} + +function get (owner, repo) { + const octokit = registry.get(key(owner, repo)) + if (!octokit) logger.warn({ owner, repo }, 'octokit-registry.miss') + return octokit +} + +module.exports = { register, get } diff --git a/src/adapters/vault/VaultAdapter.js b/src/adapters/vault/VaultAdapter.js new file mode 100644 index 00000000..94dbd66c --- /dev/null +++ b/src/adapters/vault/VaultAdapter.js @@ -0,0 +1,30 @@ +const axios = require('axios') +const { readFile } = require('fs').promises + +function urlConcat (parts) { + let url = new URL(parts[0]) + for (let i = 1; i < parts.length; i++) url = new URL(parts[i], url) + return url.toString() +} + +class VaultAdapter { + constructor (addr = process.env.VAULT_ADDR) { + this.addr = addr + } + + async read (token, path) { + const res = await axios.get(urlConcat([this.addr, '/v1/', path]), { + headers: { 'X-Vault-Token': token } + }) + return res.data.data + } + + async k8sLogin (role, jwtPath = '/var/run/secrets/kubernetes.io/serviceaccount/token') { + const jwt = await readFile(jwtPath, 'utf8') + const url = urlConcat([this.addr, `/v1/auth/${process.env.VAULT_JWT_PROVIDER}/login`]) + const res = await axios.post(url, { role, jwt }) + return res.data.auth + } +} + +module.exports = VaultAdapter diff --git a/src/application/DeploymentOrchestrator.js b/src/application/DeploymentOrchestrator.js new file mode 100644 index 00000000..c651feb7 --- /dev/null +++ b/src/application/DeploymentOrchestrator.js @@ -0,0 +1,209 @@ +const cfg = require('../config') +const registry = require('../adapters/github/OctokitRegistry') +const checkRunAdapter = require('../adapters/github/CheckRunAdapter') +const deploymentAdapter = require('../adapters/github/DeploymentAdapter') +const { DeploymentContext } = require('../domain/DeploymentContext') +const policy = require('../domain/DeploymentPolicy') +const { withLock } = require('./deploymentLock') +const logger = require('../logger') + +let cdService + +function setCdService (service) { + cdService = service +} + +async function handle (probotContext, userAction) { + const context = await buildContext(probotContext) + const log = logger.child({ + deployId: context.eventId, + repo: `${context.owner}/${context.repo}`, + branch: context.branch, + sha: context.sha ? context.sha.slice(0, 8) : '?' + }) + + const actionDecision = policy.evaluateUserAction(userAction) + + if (actionDecision.cancel) { + if (probotContext.payload.check_run?.external_id) { + await checkRunAdapter.setStatus(probotContext.octokit, context, null, 'cancelled', cfg.checkRunTemplates.cancelled) + } + return + } + + if (actionDecision.deploy) { + log.info({ trigger: 'user:deploy_now' }, 'deploy.trigger') + const deployContext = context + .withCheckRunName(cfg.deployCheckName) + .withNamespace(context.release ? '' : context.branch) + await withLock(`${context.owner}/${context.repo}:${context.branch}`, () => + runPipeline(probotContext.octokit, deployContext, log) + ) + return + } + + const decision = policy.evaluate(context) + log.info({ decision: decision.reason }, 'deploy.evaluate') + if (!decision.deploy) return + + const deployContext = context + .withCheckRunName(cfg.deployCheckName) + .withNamespace(context.release ? '' : context.branch) + + await withLock(`${context.owner}/${context.repo}:${context.branch}`, () => + runPipeline(probotContext.octokit, deployContext, log) + ) +} + +async function runPipeline (octokit, context, log) { + const state = { checkRunId: null, deploymentId: null } + + const startRes = await checkRunAdapter.setStatus( + octokit, context, null, 'in_progress', cfg.checkRunTemplates.starting + ) + state.checkRunId = startRes.data.id + log.info({ checkRunId: state.checkRunId }, 'check-run.created') + + let deploymentRes + try { + deploymentRes = await deploymentAdapter.create(octokit, context) + } catch (err) { + return cancelPipeline(octokit, context, state, err, log) + } + state.deploymentId = deploymentRes.data.id + log.info({ deploymentId: state.deploymentId }, 'github-deployment.created') + + const succeeded = await streamDeploy(octokit, context, state, log) + if (!succeeded) return + + const finalStatus = state.lastEnvStatus + await checkRunAdapter.setStatusFromLog(octokit, context, state.checkRunId, state.log, finalStatus) + await deploymentAdapter.setStatus(octokit, context, state.checkRunId, state.deploymentId, deploymentAdapter.toGitHubState(finalStatus)) + log.info({ finalStatus }, 'deploy.complete') +} + +function streamDeploy (octokit, context, state, log) { + state.log = [] + return new Promise(resolve => { + cdService.deploy(context).subscribe({ + next: resp => { + state.log.push(resp.d) + state.lastEnvStatus = resp.d.status + log.info({ status: resp.d.status, message: resp.d.data?.message ?? '' }, 'cd-service.event') + checkRunAdapter + .setStatusFromLog(octokit, context, state.checkRunId, state.log, resp.d.status) + .catch(err => log.error({ err }, 'check-run.stream-update-failed')) + }, + error: err => { + log.error({ err }, 'cd-service.stream-error') + const output = { + ...cfg.checkRunTemplates.cancelled, + text: `${cfg.checkRunTemplates.cancelled.text}\nreason: ${err.d?.errorMessage ?? err.message}` + } + checkRunAdapter + .setStatus(octokit, context, state.checkRunId, 'cancelled', output) + .then(res => { state.checkRunId = res.data.id }) + deploymentAdapter.setStatus(octokit, context, state.checkRunId, state.deploymentId, 'inactive') + resolve(false) + }, + complete: () => { + log.info('cd-service.stream-complete') + resolve(true) + } + }) + }) +} + +async function cancelPipeline (octokit, context, state, err, log) { + log.error({ err }, 'deploy.cancelled') + const output = { ...cfg.checkRunTemplates.cancelled } + if (err.status === 403 && err.message === 'Resource not accessible by integration') { + const url = `https://github.com/${context.owner}/${context.repo}/settings/installations` + output.text = `Robokit GitHub App requires permission to create deployments\n${url}` + } else { + output.text = `${output.text}\nerror: ${err.message}` + } + await checkRunAdapter.setStatus(octokit, context, state.checkRunId, 'cancelled', output) +} + +async function buildContext (probotContext) { + const { payload } = probotContext + const repo = payload.repository + const sender = payload.sender + const installation = payload.installation + + if (payload.check_run) { + const cr = payload.check_run + const releaseFields = await resolveRelease( + probotContext.octokit, repo.owner.login, repo.name, cr.check_suite?.head_branch + ) + return new DeploymentContext({ + owner: repo.owner.login, + repo: repo.name, + sha: cr.head_sha, + branch: releaseFields.branch ?? cr.check_suite?.head_branch, + checkRunName: cr.name, + status: cr.status, + conclusion: cr.conclusion, + user: sender.login, + avatar: sender.avatar_url, + installationNodeId: installation.node_id, + eventId: probotContext.id, + ...releaseFields + }) + } + + if (payload.release) { + const r = payload.release + return new DeploymentContext({ + owner: repo.owner.login, + repo: repo.name, + sha: r.target_commitish, + branch: r.tag_name, + namespace: '', + checkRunName: cfg.robokitReleaseCheck, + status: 'completed', + conclusion: 'success', + user: sender.login, + avatar: sender.avatar_url, + installationNodeId: installation.node_id, + eventId: probotContext.id, + release: true, + prerelease: r.prerelease, + tagName: r.tag_name.replace(/^v/, ''), + releaseId: r.id, + draft: r.draft + }) + } + + throw new Error('Unsupported event: payload contains neither check_run nor release') +} + +async function resolveRelease (octokit, owner, repo, branch) { + if (cfg.isLocalDev || !branch) return { release: false } + try { + const res = await octokit.request(`GET /repos/${owner}/${repo}/releases/tags/${branch}`) + const r = res.data + logger.info({ owner, repo, tag: r.tag_name }, 'release.detected') + return { + release: true, + branch: r.target_commitish, + prerelease: r.prerelease, + tagName: r.tag_name.replace(/^v/, ''), + releaseId: r.id, + draft: r.draft + } + } catch { + return { release: false } + } +} + +function onInstall (probotContext) { + if (probotContext.payload.action !== 'created') return + const owner = probotContext.payload.installation.account.login + probotContext.payload.repositories?.forEach(repo => + registry.register(owner, repo.name, probotContext.octokit) + ) +} + +module.exports = { handle, onInstall, setCdService } diff --git a/src/application/deploymentLock.js b/src/application/deploymentLock.js new file mode 100644 index 00000000..20b31560 --- /dev/null +++ b/src/application/deploymentLock.js @@ -0,0 +1,19 @@ +const logger = require('../logger') + +const active = new Map() + +function withLock (key, fn) { + if (active.has(key)) { + logger.warn({ key }, 'deploy.skipped.already-in-progress') + return Promise.resolve({ skipped: true }) + } + const promise = fn().finally(() => active.delete(key)) + active.set(key, promise) + return promise +} + +function isLocked (key) { + return active.has(key) +} + +module.exports = { withLock, isLocked } diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000..2fe83cda --- /dev/null +++ b/src/config.js @@ -0,0 +1,71 @@ +const REQUIRED = ['APP_ID', 'PRIVATE_KEY', 'WEBHOOK_SECRET'] + +function load () { + const missing = REQUIRED.filter(k => !process.env[k]) + if (missing.length > 0) { + console.error(`[fatal] Missing required configuration: ${missing.join(', ')}`) + process.exit(1) + } + + return { + appId: process.env.APP_ID, + privateKey: process.env.PRIVATE_KEY, + webhookSecret: process.env.WEBHOOK_SECRET, + webhookProxyUrl: process.env.WEBHOOK_PROXY_URL, + webhooksPath: process.env.WEBHOOKS_PATH || '/api/github/webhooks', + port: parseInt(process.env.PORT || '7777', 10), + logLevel: process.env.LOG_LEVEL || 'info', + isLocalDev: process.env.LOCAL_DEV === 'true' || process.env.LOCAL_DEV === '1', + deploymentUrl: process.env.DEPLOYMENT_URL || '', + + knownBranches: (process.env.KNOWN_BRANCHES || 'develop,master').split(',').map(s => s.trim()), + robokitDeploy: process.env.ROBOKIT_DEPLOY || 'robokit-deploy', + robokitReleaseCheck: process.env.ROBOKIT_RELEASE_CHECK || 'Robokit CD (release)', + deployCheckName: 'Robokit CD', + + cdServiceAddress: process.env.ENV_SERVICE_ADDRESS, + cdServiceRole: process.env.ENV_SERVICE_ROLE, + + vault: { + addr: process.env.VAULT_ADDR, + secretsPath: process.env.VAULT_SECRETS_PATH, + role: process.env.VAULT_ROLE, + jwtProvider: process.env.VAULT_JWT_PROVIDER, + jwtPath: process.env.VAULT_JWT_PATH + }, + + checkRunTemplates: { + starting: { + title: 'Starting', + summary: 'Continuous Delivery pipeline is starting...', + text: 'Waiting for Continuous Delivery pipeline acknowledgment', + template: 'starting' + }, + cancelled: { + title: 'Cancelled', + summary: 'Continuous Delivery pipeline was not found', + text: 'The Continuous Delivery pipeline installation is not completed.', + template: 'canceled' + }, + update: { + summary: 'Continuous Delivery pipeline: ${conclusion}', + template: 'status' + } + }, + + userActions: { + done: [{ + label: 'Re-Deploy', + description: 'Trigger the Deploy pipeline', + identifier: 'deploy_now' + }], + inProgress: [{ + label: 'Cancel-Deploy', + description: 'Cancel the Deploy pipeline', + identifier: 'cancel_deploy_now' + }] + } + } +} + +module.exports = load() diff --git a/src/domain/DeploymentContext.js b/src/domain/DeploymentContext.js new file mode 100644 index 00000000..f326759c --- /dev/null +++ b/src/domain/DeploymentContext.js @@ -0,0 +1,38 @@ +class DeploymentContext { + constructor (fields) { + const { + owner, repo, sha, branch, namespace, + checkRunName, status, conclusion, + user, avatar, installationNodeId, eventId, + release, prerelease, tagName, releaseId, draft + } = fields + this.owner = owner + this.repo = repo + this.sha = sha + this.branch = branch + this.namespace = namespace ?? null + this.checkRunName = checkRunName + this.status = status ?? null + this.conclusion = conclusion ?? null + this.user = user + this.avatar = avatar + this.installationNodeId = installationNodeId + this.eventId = eventId + this.release = release ?? false + this.prerelease = prerelease ?? false + this.tagName = tagName ?? null + this.releaseId = releaseId ?? null + this.draft = draft ?? false + Object.freeze(this) + } + + withCheckRunName (name) { + return new DeploymentContext({ ...this, checkRunName: name }) + } + + withNamespace (namespace) { + return new DeploymentContext({ ...this, namespace }) + } +} + +module.exports = { DeploymentContext } diff --git a/src/domain/DeploymentPolicy.js b/src/domain/DeploymentPolicy.js new file mode 100644 index 00000000..e0a6edf7 --- /dev/null +++ b/src/domain/DeploymentPolicy.js @@ -0,0 +1,37 @@ +const cfg = require('../config') + +function evaluate (checkRun) { + if (checkRun.name === cfg.deployCheckName) { + return { deploy: false, reason: 'own-check-run' } + } + if (isCiTrigger(checkRun)) { + if (!isDeployableBranch(checkRun.branch) && !checkRun.release) { + return { deploy: false, reason: `branch-not-deployable:${checkRun.branch}` } + } + return { deploy: true, reason: `ci:${cfg.robokitDeploy}`, kind: 'branch' } + } + if (isReleaseTrigger(checkRun)) { + return { deploy: true, reason: `release:${checkRun.tagName}`, kind: 'release' } + } + return { deploy: false, reason: `not-a-trigger:${checkRun.name}/${checkRun.status}` } +} + +function evaluateUserAction (action) { + if (action === 'deploy_now') return { deploy: true, cancel: false } + if (action === 'cancel_deploy_now') return { deploy: false, cancel: true } + return { deploy: false, cancel: false } +} + +function isCiTrigger (cr) { + return cr.name === cfg.robokitDeploy && cr.status === 'completed' && cr.conclusion === 'success' +} + +function isReleaseTrigger (cr) { + return cr.name === cfg.robokitReleaseCheck && cr.status === 'completed' && cr.conclusion === 'success' +} + +function isDeployableBranch (branch) { + return cfg.knownBranches.includes(branch) +} + +module.exports = { evaluate, evaluateUserAction } diff --git a/src/domain/PipelineStatus.js b/src/domain/PipelineStatus.js new file mode 100644 index 00000000..f8dd11fa --- /dev/null +++ b/src/domain/PipelineStatus.js @@ -0,0 +1,37 @@ +const ENV_TO_GITHUB = new Map([ + ['RUNNING', { status: 'in_progress' }], + ['SUCCEEDED', { status: 'completed', conclusion: 'success' }], + ['SUCCESS', { status: 'completed', conclusion: 'success' }], + ['TERMINAL', { status: 'completed', conclusion: 'failure' }], + ['FAILED_CONTINUE', { status: 'completed', conclusion: 'failure' }], + ['ERROR', { status: 'completed', conclusion: 'failure' }], + ['CANCELLED', { status: 'completed', conclusion: 'cancelled' }], + ['PAUSED', { status: 'completed', conclusion: 'cancelled' }], + ['SUSPENDED', { status: 'completed', conclusion: 'cancelled' }], +]) + +const MARKER = new Map([ + ['SUCCEEDED', '>'], + ['SUCCESS', '>'], + ['TERMINAL', '<'], + ['FAILED_CONTINUE', '<'], + ['ERROR', '<'], + ['CANCELLED', '#'], + ['PAUSED', '#'], + ['SUSPENDED', '#'], + ['RUNNING', '*'], +]) + +function toGitHub (envStatus) { + return ENV_TO_GITHUB.get(envStatus) ?? { status: 'in_progress' } +} + +function toMarker (envStatus) { + return MARKER.get(envStatus) ?? ' ' +} + +function isTerminal (envStatus) { + return toGitHub(envStatus).status === 'completed' +} + +module.exports = { toGitHub, toMarker, isTerminal } diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 00000000..578b6300 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,6 @@ +const pino = require('pino') + +module.exports = pino({ + level: process.env.LOG_LEVEL || 'info', + base: { service: 'robokit' } +}) diff --git a/src/server.js b/src/server.js new file mode 100644 index 00000000..d57b1f3a --- /dev/null +++ b/src/server.js @@ -0,0 +1,73 @@ +require('dotenv').config() + +const { Server, Probot } = require('probot') +const cfg = require('./config') +const logger = require('./logger') +const orchestrator = require('./application/DeploymentOrchestrator') +const VaultAdapter = require('./adapters/vault/VaultAdapter') + +const cdServiceReal = require('./adapters/cd-service/CdServiceClient') +const cdServiceMock = require('./adapters/cd-service/CdServiceMock') + +async function startServer () { + const serverOptions = { + port: cfg.port, + webhooks: { + path: cfg.webhooksPath, + secret: cfg.webhookSecret + }, + Probot: Probot.defaults({ + appId: cfg.appId, + privateKey: cfg.privateKey, + secret: cfg.webhookSecret + }) + } + + if (cfg.webhookProxyUrl) { + serverOptions.webhookProxy = cfg.webhookProxyUrl + logger.info({ url: cfg.webhookProxyUrl }, 'webhook-proxy.enabled') + } + + const cdService = cfg.isLocalDev ? cdServiceMock : cdServiceReal + orchestrator.setCdService(cdService) + + if (!cfg.isLocalDev) { + await cdServiceReal.connect() + } + + const server = new Server(serverOptions) + await server.load(require('./webhook')) + + server.expressApp.get('/health', (req, res) => { + res.json({ + status: 'ok', + uptime: Math.round(process.uptime()), + cdService: cdService.isConnected() ? 'connected' : 'disconnected' + }) + }) + + await server.start() + logger.info({ port: cfg.port }, 'robokit.started') +} + +async function start () { + if (cfg.isLocalDev) { + logger.info('LOCAL_DEV mode — skipping Vault auth') + await startServer() + return + } + + try { + const vault = new VaultAdapter() + const token = await vault.k8sLogin(cfg.vault.role, cfg.vault.jwtPath) + const secrets = await vault.read(token.client_token, cfg.vault.secretsPath) + Object.assign(process.env, secrets) + logger.info('Vault secrets loaded') + await startServer() + } catch (err) { + logger.fatal({ err }, 'startup.failed') + process.exit(1) + } +} + +start() diff --git a/app/statuses/canceled.md b/src/templates/canceled.md similarity index 79% rename from app/statuses/canceled.md rename to src/templates/canceled.md index bee18052..6f5f2099 100644 --- a/app/statuses/canceled.md +++ b/src/templates/canceled.md @@ -1,4 +1,4 @@ -## Robokit Continues Delivery +## Robokit Continuous Delivery @@ -7,7 +7,7 @@ ${progress} :black_circle: Details: ``` Namespace: ${namespace} -branch: ${branch_name} +branch: ${branch} sha: ${sha} ``` @@ -18,6 +18,6 @@ Pipeline stages: # (Trigger) pipeline was cancelled # ----------------------------------------------------------------------------------- # The installation was not completed. -# please verify service appears robokit.yml configuation or contact for support +# please verify service appears robokit.yml configuration or contact for support # ----------------------------------------------------------------------------------- -``` \ No newline at end of file +``` diff --git a/src/templates/renderer.js b/src/templates/renderer.js new file mode 100644 index 00000000..c408f329 --- /dev/null +++ b/src/templates/renderer.js @@ -0,0 +1,23 @@ +const fs = require('fs') +const path = require('path') + +const cache = new Map() + +function load (name) { + if (!cache.has(name)) { + cache.set(name, fs.readFileSync(path.join(__dirname, `${name}.md`), 'utf8')) + } + return cache.get(name) +} + +function render (templateName, vars) { + let md = load(templateName) + for (const [key, value] of Object.entries(vars)) { + if (typeof value === 'string' || typeof value === 'number') { + md = md.split('${' + key + '}').join(String(value)) + } + } + return md +} + +module.exports = { render } diff --git a/app/statuses/starting.md b/src/templates/starting.md similarity index 83% rename from app/statuses/starting.md rename to src/templates/starting.md index 86dc4dca..925d9faf 100644 --- a/app/statuses/starting.md +++ b/src/templates/starting.md @@ -1,4 +1,4 @@ -## Robokit Continues Delivery +## Robokit Continuous Delivery @@ -7,7 +7,7 @@ ${progress} :black_circle: Details: ``` Namespace: ${namespace} -branch: ${branch_name} +branch: ${branch} sha: ${sha} ``` @@ -15,4 +15,4 @@ Pipeline stages: ```diff > (Trigger) robo-kit pipeline queued. > (Trigger) robo-kit deployment pipeline was triggered successfully -``` \ No newline at end of file +``` diff --git a/app/statuses/status.md b/src/templates/status.md similarity index 93% rename from app/statuses/status.md rename to src/templates/status.md index e44cdd32..789d1d22 100644 --- a/app/statuses/status.md +++ b/src/templates/status.md @@ -8,7 +8,7 @@ ${owner}/${repo}@${sha} ``` user: ${user} namespace: ${namespace} -branch: ${branch_name} +branch: ${branch} sha: ${sha} duration: ${duration} ``` @@ -21,4 +21,3 @@ Pipeline stages: > (Trigger) robo-kit deployment pipeline was triggered successfully ${log_details} ``` - diff --git a/src/webhook.js b/src/webhook.js new file mode 100644 index 00000000..7b261d23 --- /dev/null +++ b/src/webhook.js @@ -0,0 +1,39 @@ +const orchestrator = require('./application/DeploymentOrchestrator') +const registry = require('./adapters/github/OctokitRegistry') +const cfg = require('./config') +const logger = require('./logger') + +const robokit = app => { + app.on('installation', context => { + orchestrator.onInstall(context) + }) + + app.on('check_run', context => { + const { check_run: cr, repository: repo, sender, requested_action } = context.payload + const userAction = requested_action?.identifier ?? null + logger.info({ + action: context.payload.action, + name: cr.name, + status: cr.status, + conclusion: cr.conclusion, + branch: cr.check_suite?.head_branch ?? null, + repo: `${repo.owner.login}/${repo.name}`, + sender: sender.login, + userAction + }, 'github.check_run') + registry.register(repo.owner.login, repo.name, context.octokit) + orchestrator.handle(context, userAction).catch(err => + logger.error({ err }, 'orchestrator.unhandled-error') + ) + }) + + if (cfg.logLevel === 'debug') { + app.onAny(context => { + logger.debug({ event: context.name, action: context.payload.action ?? '?' }, 'github.event') + }) + } + + logger.info('Server started.') +} + +module.exports = robokit diff --git a/test/e2e.test.js b/test/e2e.test.js new file mode 100644 index 00000000..c7d9ef67 --- /dev/null +++ b/test/e2e.test.js @@ -0,0 +1,117 @@ +/** + * E2E tests — requires the server to be running first: + * npm run robokit + * + * All config is read from .env-local automatically. + * Set GITHUB_INSTALLATION_ID in .env-local for GitHub API calls (check run creation) to succeed. + * + * Run: + * npm run test:e2e + */ + +const crypto = require('crypto') +const axios = require('axios') +const path = require('path') + +require('dotenv').config({ path: path.join(__dirname, '../.env-local') }) + +const SECRET = process.env.WEBHOOK_SECRET +const PORT = process.env.PORT || '7777' +const WEBHOOK_PATH = process.env.WEBHOOKS_PATH || '/api/github/webhooks' +const BASE_URL = `http://localhost:${PORT}` + +const OWNER = process.env.GITHUB_OWNER || 'scalecube' +const REPO = process.env.GITHUB_REPO || 'robokit' +const SHA = process.env.GITHUB_SHA || '0000000000000000000000000000000000000000' +const INSTALLATION_ID = parseInt(process.env.GITHUB_INSTALLATION_ID || '0') + +function sign (body) { + return 'sha256=' + crypto.createHmac('sha256', SECRET).update(body, 'utf8').digest('hex') +} + +async function sendWebhook (event, payload) { + const body = JSON.stringify(payload) + return axios.post(BASE_URL + WEBHOOK_PATH, body, { + headers: { + 'X-GitHub-Event': event, + 'X-GitHub-Delivery': crypto.randomUUID(), + 'X-Hub-Signature-256': sign(body), + 'Content-Type': 'application/json' + }, + validateStatus: () => true + }) +} + +function checkRunPayload (overrides = {}) { + return { + action: 'completed', + installation: { + id: INSTALLATION_ID, + node_id: `install-${INSTALLATION_ID}` + }, + sender: { + login: 'e2e-test', + avatar_url: 'https://github.com/ghost.png' + }, + repository: { + name: REPO, + owner: { login: OWNER } + }, + check_run: { + id: Date.now(), + name: 'robokit-deploy', + head_sha: SHA, + status: 'completed', + conclusion: 'success', + external_id: '', + pull_requests: [], + check_suite: { + head_branch: 'develop', + pull_requests: [] + }, + ...overrides.check_run + }, + ...overrides + } +} + +describe('Robokit e2e', () => { + test('server is reachable', async () => { + const res = await axios.get(BASE_URL, { validateStatus: () => true }) + expect(res.status).not.toBe(0) + }) + + test('GET /health returns ok', async () => { + const res = await axios.get(`${BASE_URL}/health`, { validateStatus: () => true }) + expect(res.status).toBe(200) + expect(res.data.status).toBe('ok') + expect(typeof res.data.uptime).toBe('number') + }) + + test('rejects webhook with invalid signature', async () => { + const body = JSON.stringify(checkRunPayload()) + const res = await axios.post(BASE_URL + WEBHOOK_PATH, body, { + headers: { + 'X-GitHub-Event': 'check_run', + 'X-GitHub-Delivery': crypto.randomUUID(), + 'X-Hub-Signature-256': 'sha256=badsignature', + 'Content-Type': 'application/json' + }, + validateStatus: () => true + }) + expect(res.status).toBe(400) + }) + + test('check_run robokit-deploy on develop triggers deploy', async () => { + const res = await sendWebhook('check_run', checkRunPayload()) + expect([200, 202]).toContain(res.status) + }) + + test('check_run with non-trigger name does not deploy', async () => { + const res = await sendWebhook('check_run', checkRunPayload({ + check_run: { name: 'some-other-ci-job' } + })) + // still accepted by the server, just ignored internally + expect([200, 202]).toContain(res.status) + }) +}, 15000) diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 077b0b36..00000000 --- a/test/index.test.js +++ /dev/null @@ -1,58 +0,0 @@ -const nock = require('nock') -// Requiring our app implementation -const myProbotApp = require('..') -const { Probot } = require('probot') -// Requiring our fixtures -const payload = require('./fixtures/issues.opened') -const issueCreatedBody = { body: 'Thanks for opening this issue!' } -const fs = require('fs') -const path = require('path') - -describe('My Probot app', () => { - let probot - let mockCert - - beforeAll((done) => { - fs.readFile(path.join(__dirname, 'fixtures/mock-cert.pem'), (err, cert) => { - if (err) return done(err) - mockCert = cert - done() - }) - }) - beforeEach(() => { - nock.disableNetConnect() - probot = new Probot({ id: 123, cert: mockCert }) - // Load our app into probot - probot.load(myProbotApp) - }) - - test('creates a comment when an issue is opened', async () => { - // Test that we correctly return a test token - /* nock('https://api.github.com') - .post('/app/installations/2/access_tokens') - .reply(200, { token: 'test' }) - - // Test that a comment is posted - nock('https://api.github.com') - .post('/repos/hiimbex/testing-things/issues/1/comments', (body) => { - expect(body).toMatchObject(issueCreatedBody) - return true - }) - .reply(200) - - // Receive a webhook event - await probot.receive({ name: 'issues', payload } - */ - }) - - afterEach(() => { - nock.cleanAll() - nock.enableNetConnect() - }) -}) - -// For more information about testing with Jest see: -// https://facebook.github.io/jest/ - -// For more information about testing with Nock see: -// https://github.com/nock/nock diff --git a/test/unit/application/deploymentLock.test.js b/test/unit/application/deploymentLock.test.js new file mode 100644 index 00000000..b099f635 --- /dev/null +++ b/test/unit/application/deploymentLock.test.js @@ -0,0 +1,60 @@ +process.env.APP_ID = 'test' +process.env.PRIVATE_KEY = 'test' +process.env.WEBHOOK_SECRET = 'test' + +// Fresh module per test to reset the active map +let lock + +beforeEach(() => { + jest.resetModules() + lock = require('../../../src/application/deploymentLock') +}) + +describe('deploymentLock', () => { + test('runs the function and resolves its return value', async () => { + const result = await lock.withLock('repo:branch', () => Promise.resolve('done')) + expect(result).toBe('done') + }) + + test('second call for same key while first is running returns {skipped: true}', async () => { + let resolve + const first = lock.withLock('repo:branch', () => new Promise(r => { resolve = r })) + const second = lock.withLock('repo:branch', () => Promise.resolve('second')) + + const secondResult = await second + expect(secondResult).toEqual({ skipped: true }) + + resolve('first') + expect(await first).toBe('first') + }) + + test('lock is released after completion — second call then succeeds', async () => { + await lock.withLock('repo:branch', () => Promise.resolve()) + const result = await lock.withLock('repo:branch', () => Promise.resolve('second run')) + expect(result).toBe('second run') + }) + + test('lock is released even if the function throws', async () => { + await lock.withLock('repo:branch', () => Promise.reject(new Error('boom'))).catch(() => {}) + const result = await lock.withLock('repo:branch', () => Promise.resolve('after error')) + expect(result).toBe('after error') + }) + + test('different keys run concurrently without blocking each other', async () => { + const results = await Promise.all([ + lock.withLock('repo:develop', () => Promise.resolve('develop')), + lock.withLock('repo:master', () => Promise.resolve('master')) + ]) + expect(results).toEqual(['develop', 'master']) + }) + + test('isLocked reflects active state', async () => { + expect(lock.isLocked('repo:branch')).toBe(false) + let resolve + const p = lock.withLock('repo:branch', () => new Promise(r => { resolve = r })) + expect(lock.isLocked('repo:branch')).toBe(true) + resolve() + await p + expect(lock.isLocked('repo:branch')).toBe(false) + }) +}) diff --git a/test/unit/domain/DeploymentContext.test.js b/test/unit/domain/DeploymentContext.test.js new file mode 100644 index 00000000..ba2c2aa2 --- /dev/null +++ b/test/unit/domain/DeploymentContext.test.js @@ -0,0 +1,58 @@ +const { DeploymentContext } = require('../../../src/domain/DeploymentContext') + +const BASE = { + owner: 'acme', repo: 'my-service', sha: 'abc123', + branch: 'develop', namespace: 'develop', + checkRunName: 'robokit-deploy', status: 'completed', conclusion: 'success', + user: 'alice', avatar: 'https://github.com/alice.png', + installationNodeId: 'install-node-1', eventId: 'event-1' +} + +describe('DeploymentContext', () => { + test('sets all provided fields', () => { + const ctx = new DeploymentContext(BASE) + expect(ctx.owner).toBe('acme') + expect(ctx.repo).toBe('my-service') + expect(ctx.sha).toBe('abc123') + expect(ctx.branch).toBe('develop') + expect(ctx.checkRunName).toBe('robokit-deploy') + }) + + test('defaults optional release fields to false/null', () => { + const ctx = new DeploymentContext(BASE) + expect(ctx.release).toBe(false) + expect(ctx.prerelease).toBe(false) + expect(ctx.tagName).toBeNull() + expect(ctx.releaseId).toBeNull() + expect(ctx.draft).toBe(false) + }) + + test('is frozen — Object.isFrozen returns true', () => { + const ctx = new DeploymentContext(BASE) + expect(Object.isFrozen(ctx)).toBe(true) + }) + + test('withCheckRunName returns a new context with updated name', () => { + const ctx = new DeploymentContext(BASE) + const next = ctx.withCheckRunName('Robokit CD') + expect(next.checkRunName).toBe('Robokit CD') + expect(ctx.checkRunName).toBe('robokit-deploy') + expect(next).not.toBe(ctx) + }) + + test('withNamespace returns a new context with updated namespace', () => { + const ctx = new DeploymentContext(BASE) + const next = ctx.withNamespace('feature-foo') + expect(next.namespace).toBe('feature-foo') + expect(ctx.namespace).toBe('develop') + expect(next).not.toBe(ctx) + }) + + test('chaining with* preserves unrelated fields', () => { + const ctx = new DeploymentContext(BASE) + const next = ctx.withCheckRunName('Robokit CD').withNamespace('master') + expect(next.owner).toBe('acme') + expect(next.repo).toBe('my-service') + expect(next.sha).toBe('abc123') + }) +}) diff --git a/test/unit/domain/DeploymentPolicy.test.js b/test/unit/domain/DeploymentPolicy.test.js new file mode 100644 index 00000000..20ff9af0 --- /dev/null +++ b/test/unit/domain/DeploymentPolicy.test.js @@ -0,0 +1,112 @@ +// DeploymentPolicy depends on config which reads process.env at load time +process.env.APP_ID = 'test' +process.env.PRIVATE_KEY = 'test' +process.env.WEBHOOK_SECRET = 'test' + +const policy = require('../../../src/domain/DeploymentPolicy') + +const CI_TRIGGER = { + name: 'robokit-deploy', + status: 'completed', + conclusion: 'success', + branch: 'develop', + release: false +} + +describe('DeploymentPolicy.evaluate', () => { + describe('own check run', () => { + test('Robokit CD events are always ignored', () => { + const result = policy.evaluate({ ...CI_TRIGGER, name: 'Robokit CD' }) + expect(result.deploy).toBe(false) + expect(result.reason).toBe('own-check-run') + }) + }) + + describe('CI trigger (robokit-deploy)', () => { + test('completed/success on develop → deploy', () => { + const result = policy.evaluate(CI_TRIGGER) + expect(result.deploy).toBe(true) + expect(result.reason).toMatch(/^ci:/) + }) + + test('completed/success on master → deploy', () => { + const result = policy.evaluate({ ...CI_TRIGGER, branch: 'master' }) + expect(result.deploy).toBe(true) + }) + + test('completed/success on feature branch → skip', () => { + const result = policy.evaluate({ ...CI_TRIGGER, branch: 'feature/my-thing' }) + expect(result.deploy).toBe(false) + expect(result.reason).toMatch(/branch-not-deployable/) + }) + + test('not completed → skip', () => { + const result = policy.evaluate({ ...CI_TRIGGER, status: 'in_progress', conclusion: null }) + expect(result.deploy).toBe(false) + }) + + test('completed but failed → skip', () => { + const result = policy.evaluate({ ...CI_TRIGGER, conclusion: 'failure' }) + expect(result.deploy).toBe(false) + }) + + test('release flag bypasses branch check', () => { + const result = policy.evaluate({ ...CI_TRIGGER, branch: 'feature/my-thing', release: true }) + expect(result.deploy).toBe(true) + }) + }) + + describe('release trigger', () => { + const RELEASE_TRIGGER = { + name: 'Robokit CD (release)', + status: 'completed', + conclusion: 'success', + branch: 'main', + tagName: '1.2.3' + } + + test('Robokit CD (release) completed/success → deploy', () => { + const result = policy.evaluate(RELEASE_TRIGGER) + expect(result.deploy).toBe(true) + expect(result.reason).toMatch(/^release:/) + }) + + test('Robokit CD (release) not completed → skip', () => { + const result = policy.evaluate({ ...RELEASE_TRIGGER, status: 'in_progress', conclusion: null }) + expect(result.deploy).toBe(false) + }) + }) + + describe('unrelated check runs', () => { + test('any other job name → skip', () => { + const result = policy.evaluate({ ...CI_TRIGGER, name: 'jest' }) + expect(result.deploy).toBe(false) + expect(result.reason).toMatch(/not-a-trigger/) + }) + + test('robokit-deploy on unknown conclusion → skip', () => { + const result = policy.evaluate({ ...CI_TRIGGER, conclusion: 'cancelled' }) + expect(result.deploy).toBe(false) + }) + }) +}) + +describe('DeploymentPolicy.evaluateUserAction', () => { + test('deploy_now → deploy: true', () => { + const r = policy.evaluateUserAction('deploy_now') + expect(r.deploy).toBe(true) + expect(r.cancel).toBe(false) + }) + + test('cancel_deploy_now → cancel: true', () => { + const r = policy.evaluateUserAction('cancel_deploy_now') + expect(r.deploy).toBe(false) + expect(r.cancel).toBe(true) + }) + + test('null / unknown → no-op', () => { + expect(policy.evaluateUserAction(null)).toEqual({ deploy: false, cancel: false }) + expect(policy.evaluateUserAction('unknown')).toEqual({ deploy: false, cancel: false }) + expect(policy.evaluateUserAction(undefined)).toEqual({ deploy: false, cancel: false }) + }) +}) diff --git a/test/unit/domain/PipelineStatus.test.js b/test/unit/domain/PipelineStatus.test.js new file mode 100644 index 00000000..4adefa21 --- /dev/null +++ b/test/unit/domain/PipelineStatus.test.js @@ -0,0 +1,63 @@ +const { toGitHub, toMarker, isTerminal } = require('../../../src/domain/PipelineStatus') + +describe('PipelineStatus.toGitHub', () => { + test.each([ + ['RUNNING', { status: 'in_progress' }], + ['SUCCEEDED', { status: 'completed', conclusion: 'success' }], + ['SUCCESS', { status: 'completed', conclusion: 'success' }], + ['TERMINAL', { status: 'completed', conclusion: 'failure' }], + ['FAILED_CONTINUE', { status: 'completed', conclusion: 'failure' }], + ['ERROR', { status: 'completed', conclusion: 'failure' }], + ['CANCELLED', { status: 'completed', conclusion: 'cancelled' }], + ['PAUSED', { status: 'completed', conclusion: 'cancelled' }], + ['SUSPENDED', { status: 'completed', conclusion: 'cancelled' }], + ])('%s → %o', (envStatus, expected) => { + expect(toGitHub(envStatus)).toEqual(expected) + }) + + test('unknown status falls back to in_progress', () => { + expect(toGitHub('WHATEVER')).toEqual({ status: 'in_progress' }) + expect(toGitHub(undefined)).toEqual({ status: 'in_progress' }) + }) + + test('CANCELED (one L) is NOT a known status — would be a silent bug', () => { + // Guard against reintroducing the old CANCELED/CANCELLED typo + expect(toGitHub('CANCELED')).toEqual({ status: 'in_progress' }) + expect(toGitHub('CANCELLED')).toEqual({ status: 'completed', conclusion: 'cancelled' }) + }) +}) + +describe('PipelineStatus.toMarker', () => { + test.each([ + ['SUCCEEDED', '>'], + ['SUCCESS', '>'], + ['TERMINAL', '<'], + ['FAILED_CONTINUE', '<'], + ['ERROR', '<'], + ['CANCELLED', '#'], + ['PAUSED', '#'], + ['SUSPENDED', '#'], + ['RUNNING', '*'], + ])('%s → "%s"', (envStatus, marker) => { + expect(toMarker(envStatus)).toBe(marker) + }) + + test('unknown status returns space', () => { + expect(toMarker('WHATEVER')).toBe(' ') + expect(toMarker(undefined)).toBe(' ') + }) +}) + +describe('PipelineStatus.isTerminal', () => { + test.each(['SUCCEEDED', 'SUCCESS', 'TERMINAL', 'FAILED_CONTINUE', 'ERROR', 'CANCELLED', 'PAUSED', 'SUSPENDED'])( + '%s is terminal', status => { + expect(isTerminal(status)).toBe(true) + } + ) + + test.each(['RUNNING', 'WHATEVER', undefined])( + '%s is not terminal', status => { + expect(isTerminal(status)).toBe(false) + } + ) +}) diff --git a/trigger-deploy b/trigger-deploy deleted file mode 100644 index ec635144..00000000 --- a/trigger-deploy +++ /dev/null @@ -1 +0,0 @@ -9