diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..9a4d2747 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,11 @@ +# TODO: extend prod#builder + +FROM node:20-slim +RUN apt update +RUN apt upgrade --yes + +# Necessary to install devDependencies +ENV NODE_ENV=development + +WORKDIR /cs-bot +ENTRYPOINT [ "bash" ] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9cc8e49d..270a67b2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ // Sets the run context to one level up instead of the .devcontainer folder. "context": "..", // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. - "dockerfile": "../Dockerfile" + "dockerfile": "./Dockerfile" } // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/.editorconfig b/.editorconfig index f999431d..c8c8bc00 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,16 @@ +# https://EditorConfig.org + +# top-most EditorConfig file root = true +# Unix-style newlines and file endings, with tab indentation for accessibility (no size specified) [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true +indent_style = tab + +# YAML doesn't support tab indentation +[*.{yml,yaml}] +indent_style = space diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml new file mode 100644 index 00000000..b2581e60 --- /dev/null +++ b/.github/workflows/deploy-docker.yml @@ -0,0 +1,58 @@ +# Builds the Docker image and publishes to the GitHub container registry + +name: Publish Docker Image + +on: + push: + branches: ["main"] + # Publish semver tags as releases. + tags: ["v*.*.*"] + +env: + # TODO: Also publish to Codeberg + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to container registry + # docker/login-action@v3.3.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + # docker/metadata-action@v5.5.1 + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 + with: + # e.g. 'ghcr.io/byu-cs-discord/csbot' + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Build and push Docker image + # docker/build-push-action@v6.7.0 + uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2add2d..c7b30768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Node built-in `.env` file support - Automatic updates for dependencies with Dependabot - A read-only code mirror on [Codeberg](https://codeberg.org/BYU-CS-Discord/CSBot/) +- Docker support in production! ### Changed - BREAKING: Node version to 20 LTS - BREAKING: Use SQLite instead of PostgreSQL for simplicity and ease of transferring data between hosts. -- BREAKING: All previous automatic database migrations were removed, because Prisma cannot automatically migrate between database providers. Be sure to upgrade to v0.12.1 before using this version, as any data you might have had won't be migrated for you. See [this migration guide](https://web.archive.org/web/20231216021706/https://serverfault.com/questions/274355/how-to-convert-a-postgres-database-to-sqlite/276213#276213) for help migrating your existing database. - Tests to use `vitest` instead of `jest` - TypeScript build settings to be simplified and and follow `typescript-eslint` standards - ESLint config to use new flat configuration @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release script to use `tsx` instead of `ts-node` to resolve ESM problems - `/talk` to use `dectalk-tts` package instead of `dectalk` +### Removed + +- BREAKING: All previous automatic database migrations were removed, because Prisma cannot automatically migrate between database providers. Be sure to upgrade to v0.12.1 before using this version, as any data you might have had won't be migrated for you. See [this migration guide](https://web.archive.org/web/20231216021706/https://serverfault.com/questions/274355/how-to-convert-a-postgres-database-to-sqlite/276213#276213) for help migrating your existing database. Future migrations in SQLite will happen on startup. +- BREAKING: Removed `/update`. Use Docker instead for easy upgrades. + ### Fixed - All package vulnerabilities diff --git a/Dockerfile b/Dockerfile index 3e3b960a..2abc5d0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,33 @@ -FROM node:20-slim -RUN apt update -RUN apt upgrade --yes +FROM node:20-slim as builder -# Necessary for /update -RUN apt install git --yes +RUN apt-get update -y +RUN apt-get install -y openssl -# Necessary to install devDependencies -ENV NODE_ENV=development +WORKDIR /app -WORKDIR /cs-bot -ENTRYPOINT [ "bash" ] +COPY . . + +RUN npm ci +RUN npm run export-version +RUN npm run build --omit=dev + +FROM node:20-slim as runner + +RUN apt-get update -y +RUN apt-get install -y openssl + +WORKDIR /app + +COPY --from=builder /app/dist/ ./dist/ +COPY --from=builder /app/res/ ./res/ +COPY package*.json ./ +COPY prisma prisma/ +COPY scripts/launch_in_docker.sh . + +RUN npm ci --omit=dev + +# Path internal to container; use `volumes` config to specify real system path of `/db/` +ENV DATABASE_URL="file:/db/db.sqlite" + +# Using bash here to get consistent behavior and configurations +CMD ["bash", "/app/launch_in_docker.sh"] diff --git a/README.md b/README.md index 525569f3..9c88595b 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,6 @@ By using this command, you are acknowleding that your input will be sent to a th Begins a new game of Evil Hangman. -### /update - -Pulls the latest changes from the repository and restarts the bot. - ### /xkcd Retrieves the most recent [xkcd](https://xkcd.com/) comic, or the given one. @@ -165,6 +161,8 @@ Note that, by running this bot, you agree to be bound by the Discord's [Develope ### Configure the bot +This section only applies if running directly from source. See [Run the bot](#run-the-bot) for how to configure for running in [Podman](https://podman.io/) or [Docker](https://www.docker.com/). + Create a file called `.env` in the root of this project folder. Paste your token into that file, and fill in other config items as desired: ```sh @@ -175,9 +173,6 @@ DISCORD_TOKEN=YOUR_TOKEN_GOES_HERE DATABASE_URL=YOUR_DATABASE_URL_GOES_HERE # Required for any DB functionality, we will get this URL in a later section - -ADMINISTRATORS=COMMA,SEPARATED,ID,LIST -# Required for the update command. WARNING: The users whose ids are listed here will be able to pull, build, and run code from this repository on the machine the bot is running on. Do not include any users you do not trust. ``` **Do not commit this file to git** or your bot _will_ get "hacked". @@ -218,9 +213,9 @@ $ npm run setup ### Build the bot database -_As we use Prisma for managing our database, it is up to you what relational database framework to use._ +CSBot uses SQLite. All persistent data is stored in a single file. -By default, CSBot uses SQLite. All persistent data is stored in a single file. +If you're running in Docker, the database file will be created for you, at the path specified in your volume config. If the database is found, any pending database migrations will be run on startup. (See [docker-compose.yml](docker-compose.yml).) Otherwise, you'll need to configure the database URL and initialize it yourself, as described below. First decide where you want your database file to go, then edit this line in your `.env` file: @@ -262,18 +257,30 @@ If you have added new code, you should write new unit tests to cover all the cod ### Run the bot -For development purposes (the update command will not work properly, but logs are outputed to the console): +For development purposes: ```sh -$ node . +$ node --env-file=.env . # or $ npm run dev ``` -For production purposes (this will spawn a separate thread using [PM2](https://pm2.io/) that will run in the background): +For production purposes, consider using [Podman](https://podman.io/) or [Docker](https://www.docker.com/) Compose. Copy the example [docker-compose.yml](docker-compose.yml) file to your system, and configure it accoring to your setup. Pay special attention to: + +- Use the `build` field if you've cloned this repo directly to build the image from source. Use the `image` field instead if you wish to use our published image. +- Configure the `volumes` secion appropriately. By default, the SQLite database will go in an adjacent directory to your compose file. If you want the data to live somewhere else on your system, change the configuration accordingly. +- Create a `.env` file adjacent to your compose file and populate it with your Discord bot token, like so: + +```sh +DISCORD_TOKEN=YOUR_TOKEN_HERE +``` + +Alternatively, you can run directly like so: ```sh $ npm start $ npm run stop $ npm run restart ``` + +This will spawn a separate thread using [PM2](https://pm2.io/) that will run in the background. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..bc4bc4be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Docker compose reference guide at +# https://docs.docker.com/compose/compose-file/ + +# Here the instructions define your application as a service called +# "csbot". This service is built from the Dockerfile in +# the current directory. +services: + csbot: + build: + context: . + # Comment out the "build" section above and uncomment "image" below + # to pull from the published container repository instead of building + # from the local Dockerfile: + # image: ghcr.io/byu-cs-discord/csbot:latest + container_name: csbot + restart: unless-stopped + environment: + # Create a .env file and set DISCORD_TOKEN there: + DISCORD_TOKEN: ${DISCORD_TOKEN} + env_file: + - .env + volumes: + # Stores DB at ./db/db.sqlite, by default. Be sure to point the volume instead where your data should go: + - './db/:/db/' diff --git a/package.json b/package.json index 4f98b28d..c6319253 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "main": "./dist/main.js", "scripts": { "build": "rm -rf dist && ./node_modules/.bin/tsc && npm run db:generate", - "commands:deploy": "node --env-file=.env . --deploy", + "commands:deploy": "node --env-file=.env . --deploy # TODO: Replace these with automatic command deployment", "commands:revoke": "node --env-file=.env . --revoke", "db:generate": "./node_modules/.bin/prisma generate --no-hints --schema ./prisma/schema.prisma", "db:init": "npm run db:migrate:initial || ./node_modules/.bin/prisma db push && npm run db:migrate:initial", @@ -43,8 +43,7 @@ "setup": "npm ci && npm run export-version && npm run build --production && npm run commands:deploy", "start": "./node_modules/.bin/pm2 start ./dist/main.js --name cs-bot --node-args=\"--env-file=.env\"", "stop": "./node_modules/.bin/pm2 delete cs-bot", - "test": "./node_modules/.bin/vitest", - "update": "git pull && npm run setup" + "test": "./node_modules/.bin/vitest" }, "dependencies": { "@discordjs/voice": "0.17.0", diff --git a/scripts/launch_in_docker.sh b/scripts/launch_in_docker.sh new file mode 100755 index 00000000..90b40ba2 --- /dev/null +++ b/scripts/launch_in_docker.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Fail early on nonzero exit +set -euo pipefail + +# Prepare database +DATABASE_FILENAME="$(echo $DATABASE_URL | sed "s/file://g")" +if [[ -f "$DATABASE_FILENAME" ]] ; then + echo "Database found! Running migrations..." + npm run db:migrate +else + echo "No database detected, initializing..." + npm run db:init +fi + +# Deploy commands +node . --deploy + +# Launch +node . diff --git a/src/commands/index.ts b/src/commands/index.ts index 70495ceb..1397e798 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -38,7 +38,6 @@ import { sendtag } from './sendtag.js'; import { stats } from './stats.js'; import { talk } from './talk.js'; import { toTheGallows } from './toTheGallows.js'; -import { update } from './update.js'; import { xkcd } from './xkcd.js'; import { altText } from './contextMenu/altText.js'; @@ -54,7 +53,6 @@ _add(sendtag); _add(stats); _add(talk); _add(toTheGallows); -_add(update); _add(xkcd); _add(altText); diff --git a/src/commands/update.test.ts b/src/commands/update.test.ts deleted file mode 100644 index f6598deb..00000000 --- a/src/commands/update.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { exec } from 'node:child_process'; - -import type { User } from 'discord.js'; - -// Mock the logger to prevent extra output -vi.mock('../logger.js'); - -// Overwrite the exec function -const mockExec = vi.hoisted(() => vi.fn()); -vi.mock('node:child_process', async () => { - const cp = await vi.importActual('node:child_process'); - return { - ...cp, - exec: mockExec, - }; -}); - -import { update } from './update.js'; - -describe('update', () => { - const ADMINISTRATORS_VARIABLE = 'ADMINISTRATORS'; - const adminUser: User = { - username: 'TheHost', - id: '1', - } as unknown as User; - const otherUser: User = { - username: 'TheCitizen', - id: '3', - } as unknown as User; - const mockReplyPrivately = vi.fn(); - const mockEditReply = vi.fn(); - - let context: TextInputCommandContext; - let originalAdministrators: string | undefined; - - beforeEach(() => { - // Overwrite the environment variable for token - const mockAdmins = '1,2'; - originalAdministrators = process.env[ADMINISTRATORS_VARIABLE]; - process.env[ADMINISTRATORS_VARIABLE] = mockAdmins; - - context = { - replyPrivately: mockReplyPrivately, - user: adminUser, - interaction: { - editReply: mockEditReply, - }, - } as unknown as TextInputCommandContext; - mockExec.mockImplementation((command: string, callback: () => void) => { - callback(); - }); - }); - - afterEach(() => { - process.env[ADMINISTRATORS_VARIABLE] = originalAdministrators; - vi.resetAllMocks(); - }); - - test('can be used by Bot admins', async () => { - await update.execute(context); - expect(mockExec).toHaveBeenCalledTimes(2); - expect(mockReplyPrivately).toHaveBeenCalledOnce(); - expect(mockEditReply).toHaveBeenCalledOnce(); - }); - - test('cannot be used by Non-bot-admins', async () => { - context = { ...context, user: otherUser }; - - await expect(update.execute(context)).rejects.toThrow(); - expect(mockExec).not.toHaveBeenCalled(); - expect(mockReplyPrivately).not.toHaveBeenCalled(); - expect(mockEditReply).not.toHaveBeenCalled(); - }); - - test('fails if the ADMINISTRATORS environment variable is not set', async () => { - process.env[ADMINISTRATORS_VARIABLE] = undefined; - - await expect(update.execute(context)).rejects.toThrow(); - expect(mockExec).not.toHaveBeenCalled(); - expect(mockReplyPrivately).not.toHaveBeenCalled(); - expect(mockEditReply).not.toHaveBeenCalled(); - }); - - test('fails if there is already an instance running', async () => { - void update.execute(context); - await expect(update.execute(context)).rejects.toThrow(); - }); -}); diff --git a/src/commands/update.ts b/src/commands/update.ts deleted file mode 100644 index 5625c8d7..00000000 --- a/src/commands/update.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { exec as __unsafeExecuteCommand } from 'node:child_process'; - -import { UserMessageError } from '../helpers/UserMessageError.js'; -import { debug, error } from '../logger.js'; - -let numInvocations: number = 0; -const info = new SlashCommandBuilder() - .setName('update') - .setDescription('Pulls the latest changes and restarts the bot') - .setDefaultMemberPermissions('0'); - -export const update: GlobalCommand = { - info, - requiresGuild: false, - async execute({ replyPrivately, user, interaction }) { - const admin_ids = process.env['ADMINISTRATORS']?.split(','); - if (!admin_ids) { - throw new UserMessageError( - 'There is no ADMINISTRATORS variable. You must set ADMINISTRATORS in .env' - ); - } - - if (!admin_ids.includes(user.id)) { - throw new UserMessageError( - 'You do not have permission to perform this command. Contact the bot administrator.' - ); - } - - numInvocations += 1; - try { - if (numInvocations > 1) { - throw new Error( - `Cannot run update, there are already ${numInvocations - 1} update invocations running` - ); - } - - await replyPrivately('Updating...'); - await execAsync('npm run update'); - await interaction.editReply('Finished updating. Restarting now.'); - await restart(); - } finally { - numInvocations -= 1; - } - }, -}; - -async function execAsync(command: string): Promise { - await new Promise((resolve, reject) => { - __unsafeExecuteCommand(command, (err, stdout, stderr) => { - debug(stdout); - error(stderr); - - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - -/** - * Restarts the bot. - */ -async function restart(): Promise { - await execAsync('npm run restart'); - error('The above line should have killed the process. If you got here, something went wrong'); - return undefined as never; -}