Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build and publish Docker images #257

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# remember: the last(!) line that matches a specific file defines the behavior

# ignore everything, then unblock one by one (note: . does not work "for historical reasons")
*

#!.git/
!README.md
!CMakeLists.txt
!config/
!data/
!doc/
!docker/
!src/

# ignore the Dockerfile so changes in it don't retrigger an entire build
!docker/Dockerfile
68 changes: 68 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Build Docker images for Blue Nebula server

on:
push:
tags:
- "*"
branches:
- "*"
pull_request:
branches:
- master
workflow_dispatch:
inputs:
include_arm64:
description: Build arm64 Docker image
type: boolean
required: false
default: false

jobs:
build-and-push:
runs-on: ubuntu-latest

permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v3

- name: Fetch relevant submodules
run: |
git submodule update --init src/enet
git submodule update --init data/maps

- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/blue-nebula/server
tags: |
type=semver,pattern={{version}},event=tag
type=ref,event=branch
type=sha

- name: Set up QEMU
uses: docker/setup-qemu-action@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: .
file: docker/Dockerfile
# enable arm64 by default once native runners are available
platforms: ${{ inputs.include_arm64 && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ out
*.core
*.diff
*.patch
!docker/serverip.patch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove this now as it does not exist anymore.

*.out
home/
cache/
Expand All @@ -54,3 +55,5 @@ GTAGS
GRTAGS
.idea/
*build*/
*.*swp*
docker-compose.yml
1 change: 1 addition & 0 deletions doc/examples/servinit.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ if (= $rehashing 0) [
// servertype 3 // type of server, 1 = private (does not register with masterserver), 2 = public, 3 = dedicated
// serveruprate 0 // maximum upload speed; cmdline: -suN
// serverip "127.0.0.1" // host which server binds to; cmdline: -siN
// serverpublicip "127.0.0.1" // host the server advertises to the master server (useful if behind NAT, e.g., in Docker); cmdline: -sjN
// servermaster "master.blue-nebula.org" // host server tries to use as master by default; cmdline: -smS
// serverport 28801 // port which server binds to (you must open this port [UDP] and this plus one, default 28801 and 28802); cmdline: -spN
// servermasterport 28800 // master server port which server *connects* to; cmdline: -saN
Expand Down
65 changes: 65 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# first, let's build the software
# this is going to take the most time
FROM alpine:3.17 AS builder

RUN apk add gcc g++ binutils sdl2-dev zlib-dev perl git wget ca-certificates coreutils \
sdl2_image-dev sdl2_mixer-dev psmisc cmake ninja

RUN mkdir /blue-nebula
WORKDIR /blue-nebula

COPY src/ /blue-nebula/src
COPY config/ /blue-nebula/config
COPY data/ /blue-nebula/data

COPY CMakeLists.txt /blue-nebula/
COPY README.md /blue-nebula/

RUN mkdir build && \
cd build && \
# TODO: server-only build configuration
cmake .. -DCMAKE_BUILD_TYPE=Release -G Ninja -DCMAKE_INSTALL_PREFIX=/blue-nebula/install -DBUILD_CLIENT=OFF && \
ninja -v && \
ninja install


# next, we convert the example server config to a template
# this is done in no time, so we do not have to benefit from Docker's caching a lot there
FROM python:3.10-alpine AS template

COPY docker/generate-servinit-template.py doc/examples/servinit.cfg /

RUN python3 /generate-servinit-template.py /servinit.cfg > /servinit.tmpl


# finally, let's build the final runtime image (doesn't need all of the build dependencies)
FROM alpine:3.17

LABEL maintainer="https://blue-nebula.org"

RUN apk add tini && \
apk add --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing dockerize

RUN adduser -S -D -h /blue-nebula blue-nebula
WORKDIR /blue-nebula

COPY --from=template /servinit.tmpl /
COPY --from=builder /blue-nebula/install /blue-nebula/install

# keep the directory read-only but allow writing to the file
# this is needed since dockerize runs as blue-nebula and needs to modify the file
# we might want to run dockerize separately and then run the process as blue-nebula
RUN mkdir /blue-nebula/.blue-nebula && \
install -o blue-nebula -m 0644 /dev/null /blue-nebula/.blue-nebula/servinit.cfg

# install runtime dependencies
RUN apk add libstdc++ libgcc

# keep files copied from elsewhere read-only to prevent the process from writing files
USER blue-nebula

EXPOSE 28799/udp 28800/udp 28801/udp 28802/udp

ENTRYPOINT ["/sbin/tini", "--"]

CMD dockerize -template /servinit.tmpl:/blue-nebula/.blue-nebula/servinit.cfg install/bin/blue-nebula_server_linux
25 changes: 25 additions & 0 deletions docker/docker-compose.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: "3"

services:
bn-server:
image: ghcr.io/blue-nebula/server:latest
restart: unless-stopped

# adjust accordingly if you change the ports in the config
# note: the ports must be in sync! otherwise, the server will announce the wrong ports to the master
ports:
- "0.0.0.0:28801:28801/udp"
- "0.0.0.0:28802:28802/udp"

# most-if-not-all environment variables are exposed as upper-case environment variables as shown below
environment:
ADMIN_PASS: "so-secret"
SERVER_TYPE: 1
SERVER_PORT: 28801
SV_SERVERCLIENTS: 16
SV_SERVERDESC: "Example BN server running in Docker"
SV_SERVERMOTD: "Welcome to this example Blue Nebula server hosted in a Docker container!"

# to add additional maps, you can mount a directory as shown below
volumes:
- ./custom-maps:/blue-nebula/.blue-nebula/maps/
164 changes: 164 additions & 0 deletions docker/generate-servinit-template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#! /usr/bin/env python3

import re
import sys
import textwrap


class ParsingException(Exception):
pass


def convert_variable(variable_name: str, default_value: str, comment: str, indentation: int = None):
if indentation is None:
indentation = 0

variable_name_upper = variable_name.upper()
env_var_name = ".Env.%s" % variable_name_upper

lines = []
lines.append("{{ if (contains .Env \"%s\") -}}" % variable_name_upper)
#lines.append(comment)
#lines.append("// default/example value: %s" % default_value)
lines.append("%s \"{{ %s }}\"" % (variable_name, env_var_name))
lines.append("{{ else -}}")
lines.append("// %s %s %s" % (variable_name, default_value, comment))
lines.append("{{ end -}}")
lines.append("")

return textwrap.indent("\n".join(lines), " "*indentation)


def parse_and_convert_variable(pattern: str, line: str, indentation: int = None):
match = re.match(pattern, line)

if not match:
return

return convert_variable(*match.groups(), indentation)


def parse_regular_variable(line: str):
return parse_and_convert_variable(r"^//\s?((?:sv_|admin|server)\w+)\s+(.+)\s+(\/\/.*)$", line)


def parse_rehashing_variable(line: str):
return parse_and_convert_variable(r"^ //\s*((?!irc)\w+)\s+(.+)\s+(\/\/.*)$", line, 4)


def parse_add_variable(line: str):
match = re.match(r"^//\s? (add\w+)\s+(.+)\s*(|\/\/.*)$", line)

if not match:
return

variable_name, default_value, comment = match.groups()

variable_name_upper = variable_name.upper()
env_var_name = ".Env.%s" % variable_name_upper

lines = []
lines.append("{{ if (contains .Env \"%s\") -}}" % variable_name_upper)
#lines.append(comment)
#lines.append("// default/example value: %s" % default_value)
lines.append("{{ range $e := ( split %s \";\" ) -}}" % env_var_name)
lines.append("%s {{ $e }}" % variable_name)
lines.append("{{ end -}}")
lines.append("{{ else -}}")
lines.append(line)
lines.append("{{ end -}}")
lines.append("")

return "\n".join(lines)


def parse_and_convert_line(line: str) -> str:
for f in [parse_regular_variable, parse_rehashing_variable, parse_add_variable]:
parsed = f(line)

if parsed is not None:
return parsed

else:
return line


def make_irc_section():
return textwrap.dedent(
"""
{{ if (contains .Env "ENABLE_IRC") -}}
// special single-server IRC configuration, suitable for our Docker deployment
// setting ENABLE_IRC to some value will be sufficient in most cases
if (= $rehashing 0) [
ircfilter {{ default .Env.IRC_FILTER "1" }} // defines the way the colour-to-irc filter works; 0 = off, "1" = convert, 2 = strip

ircaddrelay ircrelay {{ default .Env.IRC_RELAY_HOSTNAME "localhost" }} {{ default .Env.IRC_RELAY_PORT "6667" }} {{ default .Env.IRC_RELAY_NICK "re-server" }}

{{ if (contains .Env "IRC_BIND_ADDRESS") -}}
ircbind ircrelay {{ .Env.IRC_BIND_ADDRESS }} // use this only if you need to bind to a specific address, eg. multihomed machines
{{ end -}}

{{ if (contains .Env "IRC_SERVER_PASS" ) -}}
ircpass ircrelay {{ .Env.IRC_SERVER_PASS }} // some networks can use the PASS field to identify to nickserv
{{ end -}}

{{ if (contains .Env "IRC_CHANNELS") -}}
{{ range $e := ( split .Env.IRC_CHANNELS "," ) -}}
ircaddchan ircrelay "{{ $e }}"
ircrelaychan ircrelay "{{ $e }}" 3
{{ end -}}
{{ end -}}

ircconnect ircrelay // and tell it to connect!
]
{{ end -}}
"""
)


def make_additional_vars_section():
text = textwrap.dedent(
"""
{{ if (contains .Env "ADDITIONAL_VARS") -}}
// additional variables
{{ range $e := (split .Env.ADDITIONAL_VARS ";") -}}
{{ $a := (split $e "=") -}}
{{ index $a 0 }} {{ index $a 1 }}
{{ end -}}
{{ end -}}

{{ if (contains .Env "SV_DUELMAXQUEUED") -}}
sv_duelmaxqueued "{{ .Env.SV_DUELMAXQUEUED }}"
{{ end -}}
"""
)

for i in ["duelmaxqueued", "teamneutralcolour"]:
pass
text += textwrap.dedent(
"""
{{{{ if (contains .Env "SV_{upper}") -}}}}
sv_{lower} "{{{{ .Env.SV_{upper} }}}}"
{{{{ end -}}}}
""".format(lower=i, upper=i.upper())
)

return text


def main():
with open(sys.argv[1]) as f:
lines = f.read().splitlines()

for line in lines:
print(parse_and_convert_line(line))

print(make_irc_section())
print(make_additional_vars_section())


if __name__ == "__main__":
try:
sys.exit(main())
except BrokenPipeError:
pass
Loading