Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
timche committed Jun 19, 2020
0 parents commit c9927e2
Show file tree
Hide file tree
Showing 15 changed files with 5,490 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.github
node_modules
dist
tests
.gitignore
babel.config.js
LICENSE
README.md
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: ci

on:
push:
branches:
- master
pull_request:
branches:
- '*'

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: yarn
- run: yarn test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: yarn
- run: yarn build
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM mhart/alpine-node:12

WORKDIR /app

COPY package.json yarn.lock ./

RUN yarn --frozen-lockfile

COPY . ./

RUN yarn build \
&& rm -rf node_modules \
&& yarn --frozen-lockfile --prod

FROM mhart/alpine-node:slim-12

WORKDIR /app

COPY --from=0 /app/dist ./

COPY --from=0 /app/node_modules ./node_modules

ENV NODE_ENV=production

CMD [ "node", "index.js" ]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2020 Tim Cheung <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# docker-csgo-updater

<p>
<a href="https://github.com/timche/docker-csgo-updater">
<img alt="GitHub CI" src="https://github.com/timche/docker-csgo-updater/workflows/ci/badge.svg" />
</a>
<a href="https://hub.docker.com/r/timche/csgo-updater">
<img alt="Docker Image Version (latest semver)" src="https://img.shields.io/docker/v/timche/csgo-updater" />
</a>
<a href="https://hub.docker.com/r/timche/csgo-updater">
<img alt="Docker Image Size (latest semver)" src="https://img.shields.io/docker/image-size/timche/csgo-updater" />
</a>
<a href="https://hub.docker.com/r/timche/csgo-updater">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/timche/csgo-updater" />
</a>
<a href="https://hub.docker.com/r/timche/csgo-updater">
<img alt="Docker Stars" src="https://img.shields.io/docker/stars/timche/csgo-updater" />
</a>
</p>

> Automatically restart [timche/csgo](https://github.com/timche/docker-csgo) based containers to update CS:GO servers when an update is available
## How to Use This Image

```
$ docker run -d \
--name csgo-updater \
-v /var/run/docker.sock:/var/run/docker.sock \
timche/csgo-updater
```

### Environment Variables

##### `UPDATER_CONTAINER_IMAGE`

Default: `timche/csgo`

The Docker containers running the specified image name csgo-updater will watch.

##### `UPDATER_POLL_INTERVAL`

Default: `60`

The poll interval (in seconds) csgo-updater will poll for new containers.

## How It Works

csgo-updater is attaching to the stdout of the containers and will restart them when their CS:GO server process is logging `MasterRequestRestart`, which is a request from the Steam Master Server to tell the CS:GO server that an update is available and the server should restart.

To restart, csgo-updater will send `SIGINT` to the container, which is not immediately killing the CS:GO server process but instead the process will check if the server is empty or will wait for the server to be empty and then shut it down to stop the container. After that, csgo-updater will start the container again and [the CS:GO server will be updated before starting the server](https://github.com/timche/docker-csgo#updating-the-server).

**Note:** If the CS:GO server container has a restart policy set, the policy won't restart the container in this case, because csgo-updater is stopping the container manually. See [Docker restart policy details](https://docs.docker.com/config/containers/start-containers-automatically/#restart-policy-details).
6 changes: 6 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript'
]
}
39 changes: 39 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev src/index.ts",
"build": "tsc",
"test": "jest"
},
"dependencies": {
"delay": "^4.3.0",
"dockerode": "^3.0.2",
"pino": "^6.3.0"
},
"devDependencies": {
"@babel/core": "^7.8.4",
"@babel/preset-env": "^7.8.4",
"@babel/preset-typescript": "^7.8.3",
"@sindresorhus/tsconfig": "^0.6.0",
"@types/dockerode": "^2.5.31",
"@types/jest": "^25.1.2",
"@types/node": "^12.12.14",
"@types/pino": "^6.0.1",
"babel-jest": "^25.1.0",
"husky": "^3.1.0",
"jest": "^25.1.0",
"prettier": "^1.19.1",
"pretty-quick": "^2.0.1",
"ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.7.3"
},
"prettier": {
"semi": false,
"singleQuote": true
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
}
}
9 changes: 9 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const { UPDATER_CONTAINER_IMAGE = 'timche/csgo' } = process.env

const csgoImageNameRegExp = new RegExp(
`${UPDATER_CONTAINER_IMAGE}$|${UPDATER_CONTAINER_IMAGE}:.+`
)

export function isCSGOImage(imageName: string) {
return csgoImageNameRegExp.test(imageName)
}
36 changes: 36 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import delay from 'delay'
import * as pino from 'pino'
import logger from './logger'
import watchContainers from './watchContainers'

const {
UPDATER_POLL_INTERVAL = 60 // Seconds
} = process.env

process.on(
'uncaughtException',
pino.final(logger, (err, finalLogger) => {
finalLogger.error(err, 'uncaughtException')
process.exit(1)
})
)

process.on(
// TS overload bug:
// Argument of type '"unhandledRejection"' is not assignable to parameter of type 'Signals'.
// @ts-ignore
'unhandledRejection',
pino.final(logger, (err, finalLogger) => {
finalLogger.error(err, 'unhandledRejection')
process.exit(1)
})
)

async function dockerCSGOUpdater() {
while (true) {
watchContainers()
await delay(Number(UPDATER_POLL_INTERVAL) * 1000)
}
}

dockerCSGOUpdater()
5 changes: 5 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as pino from 'pino'

const logger = pino()

export default logger
108 changes: 108 additions & 0 deletions src/watchContainers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as Docker from 'dockerode'
import { isCSGOImage } from './helpers'
import logger from './logger'

const docker = new Docker()

let watchingContainers: string[] = []

async function watchContainer(containerInfo: Docker.ContainerInfo) {
try {
const container = docker.getContainer(containerInfo.Id)
const containerName = containerInfo.Names[0]
const containerId = container.id

let updating = false

watchingContainers = watchingContainers.concat(containerId)

const attachContainer = async () => {
logger.info(`Watching ${containerName} (${containerId}) ...`)

const stdoutStream = await container.attach({
stream: true,
stdout: true
})

stdoutStream.on('error', logger.error)

const checkForUpdate = async (data: Buffer) => {
try {
const stdout = data.toString()

if (stdout.includes('MasterRequestRestart')) {
updating = true

logger.info(
`Update available for ${containerName} (${container.id}), restarting with SIGINT ...`
)

stdoutStream.removeListener('data', checkForUpdate)

await Promise.all([
container.kill({ signal: 'SIGINT' }),
container.wait()
])

updating = false

logger.debug(`Starting ${containerName} (${container.id}) ...`)

await container.start()

logger.debug(`${containerName} (${container.id}) started`)

await attachContainer()
}
} catch (error) {
logger.error(error)
}
}

stdoutStream.on('data', checkForUpdate)

stdoutStream.on('close', async () => {
try {
stdoutStream.removeAllListeners()

logger.debug(`${containerName} (${containerId}) stopped`)

if (!updating) {
logger.debug(`Stopped watching ${containerName} (${containerId})`)

watchingContainers = watchingContainers.filter(
watchingContainerId => watchingContainerId !== containerId
)
}
} catch (error) {
logger.error(error)
}
})
}

await attachContainer()
} catch (error) {
logger.error(error)
}
}

async function getUnwatchedContainers() {
const containers = await docker.listContainers()

return containers.filter(
({ Id: id, Image: image }) =>
isCSGOImage(image) && !watchingContainers.includes(id)
)
}

export default async function watchContainers() {
try {
logger.debug('Looking for unwatched containers ...')

const unwatchedContainers = await getUnwatchedContainers()

unwatchedContainers.forEach(containerInfo => watchContainer(containerInfo))
} catch (error) {
logger.error(error)
}
}
13 changes: 13 additions & 0 deletions tests/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { isCSGOImage } from '../src/helpers'

describe('isCSGOImageName', () => {
test('returns true', () => {
expect(isCSGOImage('timche/csgo')).toBe(true)
expect(isCSGOImage('timche/csgo:pug-practice')).toBe(true)
})

test('returns false', () => {
expect(isCSGOImage('timche/csgo-foo')).toBe(false)
expect(isCSGOImage('foo/bar')).toBe(false)
})
})
8 changes: 8 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"outDir": "dist",
"declaration": false
},
"exclude": ["tests"]
}
Loading

0 comments on commit c9927e2

Please sign in to comment.