├── .editorconfig ├── .github └── workflows │ └── pipeline.yml ├── .gitignore ├── README.md ├── index.mjs ├── package-lock.json ├── package.json ├── src └── RedisBrain.mjs └── test ├── RedisBrain.mjs └── e2e └── Cconnect.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Pipeline 2 | 3 | on: 4 | - push 5 | permissions: 6 | contents: read 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | issues: write 14 | pull-requests: write 15 | id-token: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 'lts/*' 25 | cache: 'npm' 26 | - name: Install Dependencies 27 | run: npm ci 28 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 29 | run: npm audit signatures 30 | test: 31 | name: Fast Tests 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: write 35 | issues: write 36 | pull-requests: write 37 | id-token: write 38 | needs: build 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | with: 43 | fetch-depth: 0 44 | - name: Set up Node.js 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: 'lts/*' 48 | cache: 'npm' 49 | - name: Install Dependencies 50 | run: npm ci 51 | - name: Test 52 | run: npm test 53 | e2e: 54 | name: E2E Tests 55 | runs-on: ubuntu-latest 56 | services: 57 | redis: 58 | image: redis 59 | ports: 60 | - 6379:6379 61 | permissions: 62 | contents: write 63 | issues: write 64 | pull-requests: write 65 | id-token: write 66 | needs: build 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v3 70 | with: 71 | fetch-depth: 0 72 | - name: Set up Node.js 73 | uses: actions/setup-node@v3 74 | with: 75 | node-version: 'lts/*' 76 | cache: 'npm' 77 | - name: Install Dependencies 78 | run: npm ci 79 | - name: E2E Test 80 | run: npm run e2e 81 | release: 82 | needs: [build, test, e2e] 83 | runs-on: ubuntu-latest 84 | permissions: 85 | contents: write 86 | issues: write 87 | pull-requests: write 88 | id-token: write 89 | if: github.ref == 'refs/heads/main' && ${{ success() }} 90 | steps: 91 | - name: Checkout 92 | uses: actions/checkout@v3 93 | - name: Set up Node.js 94 | uses: actions/setup-node@v3 95 | with: 96 | node-version: 'lts/*' 97 | cache: 'npm' 98 | - name: Install Dependencies 99 | run: npm ci 100 | - name: Release 101 | env: 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 103 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 104 | run: npx semantic-release 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output/ 3 | coverage 4 | .hubot_history -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/hubotio/hubot-redis-brain/actions/workflows/pipeline.yml/badge.svg) 2 | 3 | ![E2E Tests](https://github.com/hubotio/hubot-redis-brain/actions/workflows/e2e.yml/badge.svg) 4 | 5 | # hubot-redis-brain 6 | 7 | A hubot script to persist hubot's brain using redis 8 | 9 | See [`src/RedisBrain.js`](src/RedisBrain.js) for full documentation. 10 | 11 | ## Installation 12 | 13 | In hubot project repo, run: 14 | 15 | `npm install hubot-redis-brain --save` 16 | 17 | Then add **hubot-redis-brain** to your `external-scripts.json`: 18 | 19 | ```json 20 | [ 21 | "hubot-redis-brain" 22 | ] 23 | ``` 24 | 25 | ## Configuration 26 | 27 | hubot-redis-brain requires a redis server to work. It uses the `REDIS_URL` environment variable for determining 28 | where to connect to. The default is on localhost, port 6379 (ie the redis default). 29 | 30 | The following attributes can be set using the `REDIS_URL` 31 | 32 | * authentication 33 | * hostname 34 | * port 35 | * key prefix 36 | 37 | For example, `export REDIS_URL=redis://:password@192.168.0.1:16379/prefix` would 38 | authenticate with `password`, connecting to `192.168.0.1` on port `16379`, and store 39 | data using the `prefix:storage` key. 40 | 41 | For a UNIX domain socket, `export REDIS_URL=redis://:password@/var/run/redis.sock?prefix` would authenticate with `password`, connecting to `/var/run/redis.sock`, and store data using the `prefix:storage` key. 42 | 43 | ### Installing your own 44 | 45 | If you need to install and 46 | run your own, most package managers have a package for redis: 47 | 48 | * Mac OS X with homebrew: `brew install redis` 49 | * Ubuntu/Debian with apt: `apt-get install redis-server` 50 | * Compile from source: http://redis.io/topics/quickstart 51 | 52 | ### Boxen 53 | 54 | If you are using [boxen](https://boxen.github.com/) to manage your environment, 55 | hubot-redis-brain will automatically use the boxen-managed redis (ie by using `BOXEN_REDIS_URL`). 56 | 57 | ### Heroku 58 | 59 | If you are deploying on [Heroku](https://www.heroku.com/), you can add the 60 | Redis Cloud or Redis To Go addon to have automatically configure itself to use it: 61 | 62 | * [Redis Cloud](https://addons.heroku.com/rediscloud) 63 | * [Redis To Go](https://addons.heroku.com/redistogo) 64 | 65 | 66 | Other redis addons would need to be configured using `REDIS_URL` until support 67 | is added to hubot-redis-brain (or hubot-redis-brain needs to be updated to look 68 | for the environment variable the service uses) 69 | 70 | ### Redis Twemproxy 71 | 72 | If you are using [Twemproxy](https://github.com/twitter/twemproxy) to cluster redis, 73 | you need to turn off the redis ready check which uses the unsupported INFO cmd. 74 | 75 | `REDIS_NO_CHECK = 1` 76 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import path from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | export default async (robot) => { 8 | const scriptsPath = path.resolve(__dirname, 'src') 9 | await robot.loadFile(scriptsPath, 'RedisBrain.mjs') 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubot-redis-brain", 3 | "description": "A hubot script to persist hubot's brain using redis", 4 | "version": "0.0.0-development", 5 | "author": "Josh Nichols ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "hubot", 9 | "hubot-scripts" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/hubotio/hubot-redis-brain.git" 14 | }, 15 | "engines": { 16 | "node": ">= 18" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/hubotio/hubot-redis-brain/issues" 20 | }, 21 | "main": "index.mjs", 22 | "scripts": { 23 | "pretest": "standard", 24 | "test": "node --test test/*.mjs", 25 | "test:watch": "node --watch --test", 26 | "e2e": "node --test test/e2e/*" 27 | }, 28 | "peerDependencies": { 29 | "hubot": ">= 11 || 0.0.0-development" 30 | }, 31 | "release": { 32 | "branches": [ 33 | "main", 34 | "next" 35 | ], 36 | "dryRun": false 37 | }, 38 | "dependencies": { 39 | "redis": "^4.6.10" 40 | }, 41 | "devDependencies": { 42 | "standard": "^17.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/RedisBrain.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Description: 4 | // Persist hubot's brain to redis 5 | // 6 | // Configuration: 7 | // REDISTOGO_URL or REDISCLOUD_URL or BOXEN_REDIS_URL or REDIS_URL. 8 | // URL format: redis://:[/] 9 | // URL format (UNIX socket): redis://[?] 10 | // If not provided, '' will default to 'hubot'. 11 | // REDIS_NO_CHECK - set this to avoid ready check (for exampel when using Twemproxy) 12 | // 13 | // Commands: 14 | // None 15 | 16 | import { URL } from 'url' 17 | import Redis from 'redis' 18 | 19 | export default (robot, redis = Redis) => { 20 | const redisUrlEnv = getRedisEnv() 21 | const redisUrl = process.env[redisUrlEnv] || 'redis://localhost:6379' 22 | robot.config = Object.assign(robot.config || {}, { redisUrl }) 23 | if (redisUrlEnv) { 24 | robot.logger.info(`hubot-redis-brain: Discovered redis from ${redisUrlEnv} environment variable: ${redisUrl}`) 25 | } else { 26 | robot.logger.info('hubot-redis-brain: Using default redis on localhost:6379') 27 | } 28 | 29 | if (process.env.REDIS_NO_CHECK) { 30 | robot.logger.info('Turning off redis ready checks') 31 | } 32 | 33 | let info = null 34 | let prefix = '' 35 | let database = null 36 | try { 37 | info = new URL(redisUrl) 38 | database = Number((info.pathname ? info.pathname.replace('/', '') : undefined) || 0) 39 | } catch (err) { 40 | if (err.code === 'ERR_INVALID_URL') { 41 | const urlPath = redisUrl.replace(/rediss?:\/{2}:?(.*@)?/, '') 42 | info = new URL(`redis://${urlPath}`) 43 | } 44 | } 45 | prefix = info.search?.replace('?', '') || 'hubot' 46 | 47 | let redisOptions = { 48 | url: redisUrl 49 | } 50 | 51 | if (database) { 52 | redisOptions = Object.assign(redisOptions || {}, { database }) 53 | } 54 | 55 | let redisSocket = null 56 | 57 | if (info.protocol === 'rediss:') { 58 | redisSocket = { tls: true } 59 | } 60 | 61 | if (process.env.REDIS_REJECT_UNAUTHORIZED) { 62 | redisSocket.rejectUnauthorized = process.env.REDIS_REJECT_UNAUTHORIZED === 'true' 63 | } 64 | 65 | if (info.auth || process.env.REDIS_NO_CHECK) { 66 | redisOptions = Object.assign(redisOptions || {}, { no_ready_check: true }) 67 | } 68 | 69 | if (redisSocket) { 70 | redisOptions = Object.assign(redisOptions || {}, { socket: redisSocket }) 71 | } 72 | 73 | const client = redis.createClient(redisOptions) 74 | 75 | robot.brain.setAutoSave(false) 76 | 77 | const getData = () => { 78 | client.get(`${prefix}:storage`).then((reply) => { 79 | if (reply) { 80 | robot.logger.info(`hubot-redis-brain: Data for ${prefix} brain retrieved from Redis`) 81 | robot.brain.mergeData(JSON.parse(reply.toString())) 82 | robot.brain.emit('connected') 83 | } else { 84 | robot.logger.info(`hubot-redis-brain: Initializing new data for ${prefix} brain`) 85 | robot.brain.mergeData({}) 86 | robot.brain.emit('connected') 87 | } 88 | 89 | robot.brain.setAutoSave(true) 90 | }).catch(err => { 91 | robot.logger.error(`hubot-redis-brain: Unable to get data from Redis: ${err}`) 92 | }) 93 | } 94 | 95 | if (info.auth) { 96 | client.auth(info.auth.split(':')[1], function (err) { 97 | if (err) { 98 | return robot.logger.error('hubot-redis-brain: Failed to authenticate to Redis') 99 | } 100 | 101 | robot.logger.info('hubot-redis-brain: Successfully authenticated to Redis') 102 | getData() 103 | }) 104 | } 105 | 106 | client.on('error', function (err) { 107 | if (!/ECONNREFUSED/.test(err.message)) { 108 | robot.logger.error(err.stack) 109 | } 110 | }) 111 | 112 | client.on('connect', function () { 113 | robot.logger.debug('hubot-redis-brain: Successfully connected to Redis') 114 | if (!info.auth) { getData() } 115 | }) 116 | 117 | robot.brain.on('save', (data) => { 118 | if (!data) { 119 | data = {} 120 | } 121 | client.set(`${prefix}:storage`, JSON.stringify(data)) 122 | }) 123 | 124 | robot.brain.on('close', () => client.quit()) 125 | client.connect().then(() => {}).catch(robot.logger.error) 126 | } 127 | 128 | function getRedisEnv () { 129 | if (process.env.REDISTOGO_URL) { 130 | return 'REDISTOGO_URL' 131 | } 132 | 133 | if (process.env.REDISCLOUD_URL) { 134 | return 'REDISCLOUD_URL' 135 | } 136 | 137 | if (process.env.BOXEN_REDIS_URL) { 138 | return 'BOXEN_REDIS_URL' 139 | } 140 | 141 | if (process.env.REDIS_URL) { 142 | return 'REDIS_URL' 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/RedisBrain.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { describe, it } from 'node:test' 4 | import assert from 'node:assert/strict' 5 | import { Robot, Adapter } from 'hubot' 6 | import Shell from 'hubot/src/adapters/Shell.mjs' 7 | import redisBrain from '../src/RedisBrain.mjs' 8 | import EventEmitter from 'events' 9 | import HubotRedis from '../index.mjs' 10 | 11 | class RedisMock extends EventEmitter { 12 | constructor (delegate) { 13 | super() 14 | this.data = {} 15 | this.delegate = delegate 16 | } 17 | 18 | async connect () { 19 | this.emit('connect') 20 | } 21 | 22 | async get (key) { 23 | if (this.delegate?.get) return this.delegate.get(key) 24 | return this.data[key] 25 | } 26 | 27 | async set (key, value) { 28 | if (this.delegate?.set) return this.delegate.set(key) 29 | this.data[key] = value 30 | } 31 | 32 | quit () { 33 | if (this.delegate?.quit) return this.delegate.quit() 34 | } 35 | } 36 | 37 | describe('redis-brain', () => { 38 | it('exports a function', () => { 39 | assert.equal(typeof HubotRedis, 'function') 40 | }) 41 | 42 | it('Hostname should never be empty', async () => { 43 | process.env.REDIS_URL = 'redis://' 44 | const robot = new Robot('Shell', false, 'hubot') 45 | await robot.loadAdapter() 46 | redisBrain(robot, { 47 | createClient: (options) => { 48 | return new RedisMock() 49 | } 50 | }) 51 | await robot.run() 52 | assert.deepEqual(robot.config.redisUrl, process.env.REDIS_URL) 53 | robot.shutdown() 54 | delete process.env.REDIS_URL 55 | }) 56 | 57 | it('Connect to redis without setting the REDIS_URL environment variable', async () => { 58 | delete process.env.REDIS_URL 59 | const robot = new Robot('Shell', false, 'hubot') 60 | await robot.loadAdapter() 61 | redisBrain(robot, { 62 | createClient: (options) => { 63 | return new RedisMock() 64 | } 65 | }) 66 | await robot.run() 67 | assert.deepEqual(robot.config.redisUrl, 'redis://localhost:6379') 68 | robot.shutdown() 69 | }) 70 | 71 | it('Connect vis SSL: Check that the options are set by environment variables', async () => { 72 | Shell.use = robot => { 73 | return new Adapter() 74 | } 75 | const robot = new Robot('Shell', false, 'hubot') 76 | await robot.loadAdapter() 77 | process.env.REDIS_URL = 'rediss://localhost:6379' 78 | process.env.REDIS_REJECT_UNAUTHORIZED = 'false' 79 | process.env.REDIS_NO_CHECK = 'true' 80 | redisBrain(robot, { 81 | createClient: (options) => { 82 | assert.deepEqual(options.url, process.env.REDIS_URL) 83 | assert.deepEqual(options.socket.tls, true) 84 | assert.deepEqual(options.no_ready_check, true) 85 | assert.deepEqual(options.socket.rejectUnauthorized, false) 86 | return new RedisMock() 87 | } 88 | }) 89 | await robot.run() 90 | robot.shutdown() 91 | delete process.env.REDIS_URL 92 | delete process.env.REDIS_REJECT_UNAUTHORIZED 93 | delete process.env.REDIS_NO_CHECK 94 | }) 95 | 96 | it('Setting the prefix with redis://localhost:6379/1?prefix-for-redis-key', async () => { 97 | process.env.REDIS_URL = 'redis://localhost:6379/1?prefix-for-redis-key' 98 | const robot = new Robot('Shell', false, 'hubot') 99 | await robot.loadAdapter() 100 | const delegate = { 101 | data: {}, 102 | async get (key) { 103 | assert.deepEqual(key, 'prefix-for-redis-key:storage') 104 | robot.shutdown() 105 | delete process.env.REDIS_URL 106 | return this.data[key] 107 | } 108 | } 109 | redisBrain(robot, { 110 | createClient: (options) => { 111 | assert.deepEqual(options.database, 1) 112 | return new RedisMock(delegate) 113 | } 114 | }) 115 | await robot.run() 116 | }) 117 | 118 | it('Setting the prefix with no database number specified redis://localhost?prefix-for-redis-key', async () => { 119 | process.env.REDIS_URL = 'redis://localhost?prefix-for-redis-key' 120 | const robot = new Robot('Shell', false, 'hubot') 121 | await robot.loadAdapter() 122 | const delegate = { 123 | data: {}, 124 | async get (key) { 125 | assert.deepEqual(key, 'prefix-for-redis-key:storage') 126 | robot.shutdown() 127 | delete process.env.REDIS_URL 128 | return this.data[key] 129 | } 130 | } 131 | redisBrain(robot, { 132 | createClient: (options) => { 133 | assert.deepEqual(options.database, undefined) 134 | return new RedisMock(delegate) 135 | } 136 | }) 137 | await robot.run() 138 | }) 139 | 140 | it('Setting the prefix with no database number specified and a trailing slash redis://localhost:6379/?prefix-for-redis-key', async () => { 141 | process.env.REDIS_URL = 'redis://localhost:6379/?prefix-for-redis-key' 142 | const robot = new Robot('Shell', false, 'hubot') 143 | await robot.loadAdapter() 144 | const delegate = { 145 | data: {}, 146 | async get (key) { 147 | assert.deepEqual(key, 'prefix-for-redis-key:storage') 148 | robot.shutdown() 149 | delete process.env.REDIS_URL 150 | return this.data[key] 151 | } 152 | } 153 | redisBrain(robot, { 154 | createClient: (options) => { 155 | assert.deepEqual(options.database, undefined) 156 | return new RedisMock(delegate) 157 | } 158 | }) 159 | await robot.run() 160 | }) 161 | 162 | it('Setting the prefix in the query string redis://:password@/var/run/redis.sock?prefix-for-redis-key', async () => { 163 | process.env.REDIS_URL = 'redis://username:test@/var/run/redis.sock?prefix-for-redis-key' 164 | const robot = new Robot('Shell', false, 'hubot') 165 | await robot.loadAdapter() 166 | const delegate = { 167 | data: {}, 168 | async get (key) { 169 | assert.deepEqual(key, 'prefix-for-redis-key:storage') 170 | robot.shutdown() 171 | delete process.env.REDIS_URL 172 | return this.data[key] 173 | } 174 | } 175 | redisBrain(robot, { 176 | createClient: (options) => { 177 | assert.deepEqual(options.database, undefined) 178 | return new RedisMock(delegate) 179 | } 180 | }) 181 | await robot.run() 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /test/e2e/Cconnect.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { describe, it } from 'node:test' 4 | import assert from 'node:assert/strict' 5 | import path from 'node:path' 6 | import { Robot } from 'hubot' 7 | 8 | describe('e2e', () => { 9 | it('connects to redis', async () => { 10 | const robot = new Robot('Shell', false, 'hubot') 11 | await robot.loadAdapter() 12 | robot.brain.on('loaded', actual => { 13 | const expected = { users: {}, _private: {} } 14 | assert.deepEqual(actual, expected) 15 | }) 16 | robot.brain.on('connected', () => { 17 | assert.ok(true) 18 | }) 19 | await robot.loadFile(path.resolve('src/'), 'RedisBrain.mjs') 20 | await robot.run() 21 | robot.shutdown() 22 | }) 23 | }) 24 | --------------------------------------------------------------------------------