diff --git a/.dockerignore b/.dockerignore index 7b6d2b2..6df0bc1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,3 @@ Dockerfile forgejo-runner +/examples/docker-compose/srv/ \ No newline at end of file diff --git a/.forgejo/workflows/example-docker-compose.yml b/.forgejo/workflows/example-docker-compose.yml index 6e017db..aeff9fc 100644 --- a/.forgejo/workflows/example-docker-compose.yml +++ b/.forgejo/workflows/example-docker-compose.yml @@ -28,6 +28,8 @@ jobs: - name: run the example run: | set -x + mkdir -p /srv/runner-data + chown 1000:1000 /srv/runner-data cd examples/docker-compose secret=$(openssl rand -hex 20) sed -i -e "s/{SHARED_SECRET}/$secret/" compose-forgejo-and-runner.yml @@ -35,7 +37,7 @@ jobs: # # Launch Forgejo & the runner # - $cli up -d + $cli up -d --remove-orphans for delay in $(seq 60) ; do test -f /srv/runner-data/.runner && break ; sleep 30 ; done test -f /srv/runner-data/.runner # diff --git a/Dockerfile b/Dockerfile index 6acc805..c43c34e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,10 @@ ENV HOME=/data USER 1000:1000 +COPY --chmod=555 entrypoint.sh /entrypoint.sh + WORKDIR /data VOLUME ["/data"] -CMD ["/bin/forgejo-runner"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index b28f9ad..e7f7ae4 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,9 +1,5 @@ # Release Notes -## 6.0.0 - -* Security: the container options a job is allowed to specify are limited to a [predefined allow list](https://forgejo.org/docs/next/user/actions/#jobsjob_idcontaineroptions). - ## 5.0.4 * Define FORGEJO_TOKEN as an alias to GITHUB_TOKEN diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..7eea913 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +set -e + +# Technically not necessary, but it cleans up the logs from having token/secret values +run_command() { + local cmd="$@" + # Replace any --token or --secret with [REDACTED] + local safe_cmd=$(echo "$cmd" | sed -E 's/--(token|secret) [^ ]+/--\1 [REDACTED]/g') + decho "Running command: $safe_cmd" + eval $cmd +} + +decho() { + if [[ "${DEBUG}" == "true" ]]; then + echo "[entrypoint] $@" + fi +} +decho $PWD + +# Check if the script is running as root +if [[ $(id -u) -eq 0 ]]; then + ISROOT=true + decho "[WARNING] Running as root user" +fi + +# Handle if `command` is passed, as command appends arguments to the entrypoint +if [ "$#" -gt 0 ]; then + run_command $@ + exit +fi + +# Set default values (if needed) +RUNNER__runner__FILE="${RUNNER__runner__FILE:-/data/runner.json}" +RUNNER__CONFIG_FILE="${RUNNER__CONFIG_FILE:-/data/runner.yml}" + +ENV_FILE="${ENV_FILE:-/data/.env}" +# Set config arguments +CONFIG_ARG="--config ${RUNNER__CONFIG_FILE}" +# Show config variables +decho "CONFIG: ${CONFIG_ARG}" + +# Generate config if not found +if [[ ! -f "${RUNNER__CONFIG_FILE}" ]]; then + echo "Creating ${RUNNER__CONFIG_FILE}" + run_command "forgejo-runner generate-config > ${RUNNER__CONFIG_FILE}" +fi + +# Use environment variables directly in the config, no need for sed edits +decho "Using config from: ${RUNNER__CONFIG_FILE}" +decho "Using environment file: ${ENV_FILE}" + +# Set extra arguments from environment variables +EXTRA_ARGS="" +if [[ -n "${RUNNER__container__LABELS}" ]]; then + EXTRA_ARGS="${EXTRA_ARGS} --labels ${RUNNER__container__LABELS}" +fi +decho "EXTRA_ARGS: ${EXTRA_ARGS}" + +if [[ "${SKIP_WAIT}" != "true" ]]; then + echo "Waiting 10s to allow other services to start up..." + sleep 10 +fi + +FORGEJO_URL="${FORGEJO_URL:-http://forgejo:3000}" + +# Try to register the runner +if [[ ! -s "${RUNNER__runner__FILE}" ]]; then + touch ${RUNNER__runner__FILE} + try=$((try + 1)) + success=0 + decho "try: ${try}, success: ${success}" + + while [[ $success -eq 0 ]] && [[ $try -lt ${MAX_REG_ATTEMPTS:-10} ]]; do + if [[ -n "${FORGEJO_SECRET}" ]]; then + run_command forgejo-runner create-runner-file --connect \ + --instance "${FORGEJO_URL}" \ + --name "${RUNNER__NAME:-$(hostname)}" \ + --secret "${FORGEJO_SECRET}" \ + ${CONFIG_ARG} \ + ${EXTRA_ARGS} 2>&1 | tee /tmp/reg.log + else + run_command forgejo-runner register \ + --instance "${FORGEJO_URL}" \ + --name "${RUNNER__NAME:-$(hostname)}" \ + --token "${RUNNER_TOKEN}" \ + --no-interactive \ + ${CONFIG_ARG} \ + ${EXTRA_ARGS} 2>&1 | tee /tmp/reg.log + fi + cat /tmp/reg.log | grep -E 'connection successful|registered successfully' >/dev/null + if [[ $? -eq 0 ]]; then + echo "SUCCESS" + success=1 + else + echo "Waiting to retry ..." + sleep 5 + fi + decho "try: ${try}, success: ${success}" + done +fi + +# Prevent reading the token from the forgejo-runner process +unset RUNNER_TOKEN +unset FORGEJO_SECRET + +run_command forgejo-runner daemon ${CONFIG_ARG} diff --git a/examples/docker-compose/.gitignore b/examples/docker-compose/.gitignore new file mode 100644 index 0000000..94bf3ec --- /dev/null +++ b/examples/docker-compose/.gitignore @@ -0,0 +1 @@ +srv diff --git a/examples/docker-compose/compose-demo-workflow.yml b/examples/docker-compose/compose-demo-workflow.yml index 90e7d52..844dbdc 100644 --- a/examples/docker-compose/compose-demo-workflow.yml +++ b/examples/docker-compose/compose-demo-workflow.yml @@ -7,6 +7,8 @@ services: image: code.forgejo.org/oci/alpine:3.19 links: - forgejo + depends_on: + - runner-daemon command: >- sh -ec ' apk add --quiet git curl jq ; @@ -14,14 +16,14 @@ services: cd /srv/demo ; git init --initial-branch=main ; mkdir -p .forgejo/workflows ; - echo "{ on: [push], jobs: { test: { runs-on: docker, steps: [ {uses: actions/checkout@v4}, { run: echo All Good } ] } } }" > .forgejo/workflows/demo.yml ; - echo "{ on: [push], jobs: { test_docker: { runs-on: ubuntu-22.04, steps: [ { run: docker info } ] } } }" > .forgejo/workflows/demo_docker.yml ; + echo "{ name: \"Demo All Good\", on: [push], jobs: { test: { runs-on: docker, steps: [ {uses: actions/checkout@v4}, { run: echo All Good } ] } } }" > .forgejo/workflows/demo.yml ; + echo "{ name: \"Demo Docker\", on: [push], jobs: { test_docker: { runs-on: ubuntu-22.04, steps: [ { run: docker info } ] } } }" > .forgejo/workflows/demo_docker.yml ; git add . ; git config user.email root@example.com ; git config user.name username ; git commit -m demo ; while : ; do - git push --set-upstream --force http://root:{ROOT_PASSWORD}@forgejo:3000/root/test main && break ; + git push --set-upstream --force http://root:ROOT_PASSWORD@forgejo:3000/root/test main && break ; sleep 5 ; done ; sha=`git rev-parse HEAD` ; diff --git a/examples/docker-compose/compose-forgejo-and-runner.yml b/examples/docker-compose/compose-forgejo-and-runner.yml index 4794985..3c3ae0d 100644 --- a/examples/docker-compose/compose-forgejo-and-runner.yml +++ b/examples/docker-compose/compose-forgejo-and-runner.yml @@ -11,83 +11,83 @@ # NOTE: a token obtained from the Forgejo web interface cannot be used # as a shared secret. # -# Replace {ROOT_PASSWORD} with a secure password +# Replace ${RUNNER_TOKEN} with the token obtained from the Forgejo web interface. +# +# Replace ROOT_PASSWORD with a secure password. # - volumes: docker_certs: services: - docker-in-docker: image: code.forgejo.org/oci/docker:dind - hostname: docker # Must set hostname as TLS certificates are only valid for docker or localhost + hostname: docker # Must set hostname for both internal DNS and TLS to work as certs are only valid for docker and localhost + restart: unless-stopped privileged: true environment: - DOCKER_TLS_CERTDIR: /certs - DOCKER_HOST: docker-in-docker + DOCKER_TLS_CERTDIR: "/certs" # set to "" to disable the use of TLS, also manually update existing runner configs to use port 2375 + DOCKER_HOST: "docker" # remove aswell to disable TLS volumes: - docker_certs:/certs forgejo: image: codeberg.org/forgejo/forgejo:1.21 - command: >- - bash -c ' - /bin/s6-svscan /etc/s6 & - sleep 10 ; - su -c "forgejo forgejo-cli actions register --secret {SHARED_SECRET}" git ; - su -c "forgejo admin user create --admin --username root --password {ROOT_PASSWORD} --email root@example.com" git ; - sleep infinity - ' - environment: - FORGEJO__security__INSTALL_LOCK: "true" - FORGEJO__log__LEVEL: "debug" - FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true" - FORGEJO__repository__DEFAULT_PUSH_CREATE_PRIVATE: "false" - FORGEJO__repository__DEFAULT_REPO_UNITS: "repo.code,repo.actions" + hostname: forgejo volumes: - /srv/forgejo-data:/data ports: - 8080:3000 - - runner-register: - image: code.forgejo.org/forgejo/runner:3.4.1 - links: - - docker-in-docker - - forgejo environment: - DOCKER_HOST: tcp://docker-in-docker:2376 - volumes: - - /srv/runner-data:/data - user: 0:0 + FORGEJO__security__INSTALL_LOCK: "true" # remove in production + FORGEJO__log__LEVEL: "debug" # remove in production + FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true" # enables the ability to create a repo when pushing + FORGEJO__repository__DEFAULT_PUSH_CREATE_PRIVATE: "false" # defaults above to public + FORGEJO__repository__DEFAULT_REPO_UNITS: "repo.code,repo.actions" + # `command` is not neecessary, but can be used to create an admin user as shown below when combined with INSTALL_LOCK command: >- - bash -ec ' - while : ; do - forgejo-runner create-runner-file --connect --instance http://forgejo:3000 --name runner --secret {SHARED_SECRET} && break ; - sleep 1 ; - done ; - sed -i -e "s|\"labels\": null|\"labels\": [\"docker:docker://code.forgejo.org/oci/node:20-bookworm\", \"ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04\"]|" .runner ; - forgejo-runner generate-config > config.yml ; - sed -i -e "s|network: .*|network: host|" config.yml ; - sed -i -e "s|^ envs:$$| envs:\n DOCKER_HOST: tcp://docker:2376\n DOCKER_TLS_VERIFY: 1\n DOCKER_CERT_PATH: /certs/client|" config.yml ; - sed -i -e "s|^ options:| options: -v /certs/client:/certs/client|" config.yml ; - sed -i -e "s| valid_volumes: \[\]$$| valid_volumes:\n - /certs/client|" config.yml ; - chown -R 1000:1000 /data + bash -c ' + /bin/s6-svscan /etc/s6 & + sleep 10 ; + su -c "forgejo admin user create --admin --username root --password ROOT_PASSWORD --email root@example.com" git ; + su -c "forgejo forgejo-cli actions register --secret {SHARED_SECRET}" git ; + sleep infinity ' + # all values that have defaults listed are optional + # only FORGEJO_SECRET or RUNNER_TOKEN is required, the secret will be prioritized + # FORGEJO_URL is required if forgejo is not in this compose file or docker network runner-daemon: - image: code.forgejo.org/forgejo/runner:3.4.1 - links: - - docker-in-docker - - forgejo - environment: - DOCKER_HOST: tcp://docker:2376 - DOCKER_CERT_PATH: /certs/client - DOCKER_TLS_VERIFY: "1" + ## TODO: Update image to the the release + ## made from this PR: https://code.forgejo.org/forgejo/runner/pulls/283 + + # image: code.forgejo.org/forgejo/runner:3.4.1 + build: ../../ + user: "1000" # defaults to 1000, + restart: unless-stopped # needed for fixing file ownership on restart volumes: - /srv/runner-data:/data - docker_certs:/certs - command: >- - bash -c ' - while : ; do test -w .runner && forgejo-runner --config config.yml daemon ; sleep 1 ; done - ' + depends_on: + - docker-in-docker + - forgejo + links: + - docker-in-docker + - forgejo + environment: + FORGEJO_URL: ${FORGEJO_URL} # defaults to http://forgejo:3000 + FORGEJO_SECRET: "{SHARED_SECRET}" # shared secret, must match Forgejo's, overrides RUNNER_TOKEN + RUNNER_TOKEN: ${RUNNER_TOKEN} # token obtained from Forgejo web interface + + # Docker Daemon Configs, needed for docker-in-docker + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: /certs/client + + # Runner Configs + RUNNER__log__LEVEL: "debug" + RUNNER__container__PRIVILEGED: "true" + RUNNER__runner__LABELS: | + docker:docker://code.forgejo.org/oci/node:20-bookworm + ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04 + DEBUG: "true" + SKIP_WAIT: "false" diff --git a/internal/pkg/config/config-env.go b/internal/pkg/config/config-env.go new file mode 100644 index 0000000..3937a62 --- /dev/null +++ b/internal/pkg/config/config-env.go @@ -0,0 +1,156 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +// loadEnvVars loads configuration settings from environment variables +// prefixed with "RUNNER__" and updates the provided Config struct accordingly. +func loadEnvVars(config *Config) { + // There probably is a better way to do this, but I'm not sure how to do it. + // This implementation causes env-vars to override config file settings, + // without writing to the config file. + + if debug, ok := os.LookupEnv("DEBUG"); ok && debug == "true" { + log.SetLevel(log.DebugLevel) + log.Debug("Debug logging enabled") + } + + // Log + loadEnvStr(config, "RUNNER__log__LEVEL", &config.Log.Level) + loadEnvStr(config, "RUNNER__log__JOB_LEVEL", &config.Log.JobLevel) + + // Runner + loadEnvStr(config, "RUNNER__runner__FILE", &config.Runner.File) + loadEnvInt(config, "RUNNER__runner__CAPACITY", &config.Runner.Capacity) + loadEnvTable(config, "RUNNER__runner__ENVS", &config.Runner.Envs) + loadEnvStr(config, "RUNNER__runner__ENV_FILE", &config.Runner.EnvFile) + loadEnvDuration(config, "RUNNER__runner__SHUTDOWN_TIMEOUT", &config.Runner.ShutdownTimeout) + loadEnvBool(config, "RUNNER__runner__INSECURE", &config.Runner.Insecure) + loadEnvDuration(config, "RUNNER__runner__FETCH_TIMEOUT", &config.Runner.FetchTimeout) + loadEnvDuration(config, "RUNNER__runner__FETCH_INTERVAL", &config.Runner.FetchInterval) + loadEnvDuration(config, "RUNNER__runner__REPORT_INTERVAL", &config.Runner.ReportInterval) + loadEnvList(config, "RUNNER__runner__LABELS", &config.Runner.Labels) + + // Cache + loadEnvBool(config, "RUNNER__cache__ENABLED", config.Cache.Enabled) + loadEnvStr(config, "RUNNER__cache__DIR", &config.Cache.Dir) + loadEnvStr(config, "RUNNER__cache__HOST", &config.Cache.Host) + loadEnvUInt16(config, "RUNNER__cache__PORT", &config.Cache.Port) + loadEnvStr(config, "RUNNER__cache__EXTERNAL_SERVER", &config.Cache.ExternalServer) + + // Container + loadEnvStr(config, "RUNNER__container__NETWORK", &config.Container.Network) + loadEnvStr(config, "RUNNER__container__NETWORK_MODE", &config.Container.NetworkMode) + loadEnvBool(config, "RUNNER__container__ENABLE_IPV6", &config.Container.EnableIPv6) + loadEnvBool(config, "RUNNER__container__PRIVILEGED", &config.Container.Privileged) + loadEnvStr(config, "RUNNER__container__OPTIONS", &config.Container.Options) + loadEnvStr(config, "RUNNER__container__WORKDIR_PARENT", &config.Container.WorkdirParent) + loadEnvList(config, "RUNNER__container__VALID_VOLUMES", &config.Container.ValidVolumes) + loadEnvStr(config, "RUNNER__container__DOCKER_HOST", &config.Container.DockerHost) + loadEnvBool(config, "RUNNER__container__FORCE_PULL", &config.Container.ForcePull) + + // Host + loadEnvStr(config, "RUNNER__host__WORKDIR_PARENT", &config.Host.WorkdirParent) +} + +// General Coverage Docs for below: +// loadEnvType, where Type is the type of the variable being loaded, loads an environment variable into the provided pointer if the environment variable exists and is not empty. +// The key parameter is the environment variable key to look for. +// The dest parameter is a pointer to the variable to load the environment variable into. +// Config is present but unused, it remains for future use to prevent the need to redo the above functions. + +func loadEnvStr(config *Config, key string, dest *string) { + // Example: RUNNER__LOG__LEVEL = "info" + if v := os.Getenv(key); v != "" { + log.Debug("Loading env var: ", key, "=", v) + *dest = v + } +} + +// loadEnvInt loads an environment variable into the provided int pointer if it exists. +func loadEnvInt(config *Config, key string, dest *int) { + // Example: RUNNER__RUNNER__CAPACITY = "1" + if v := os.Getenv(key); v != "" { + if intValue, err := strconv.Atoi(v); err == nil { + log.Debug("Loading env var: ", key, "=", v) + *dest = intValue + } + } +} + +// loadEnvUInt16 loads an environment variable into the provided uint16 pointer if it exists. +func loadEnvUInt16(config *Config, key string, dest *uint16) { + // Example: RUNNER__CACHE__PORT = "8080" + if v := os.Getenv(key); v != "" { + if uint16Value, err := strconv.ParseUint(v, 10, 16); err == nil { + log.Debug("Loading env var: ", key, "=", v) + *dest = uint16(uint16Value) + } + } +} + +// loadEnvDuration loads an environment variable into the provided time.Duration pointer if it exists. +func loadEnvDuration(config *Config, key string, dest *time.Duration) { + // Example: RUNNER__RUNNER__SHUTDOWN_TIMEOUT = "3h" + if v := os.Getenv(key); v != "" { + if durationValue, err := time.ParseDuration(v); err == nil { + log.Debug("Loading env var: ", key, "=", v) + *dest = durationValue + } + } +} + +// loadEnvBool loads an environment variable into the provided bool pointer if it exists. +func loadEnvBool(config *Config, key string, dest *bool) { + // Example: RUNNER__RUNNER__INSECURE = "false" + if v := os.Getenv(key); v != "" { + if boolValue, err := strconv.ParseBool(v); err == nil { + log.Debug("Loading env var: ", key, "=", v) + *dest = boolValue + } + } +} + +// loadEnvTable loads an environment variable into the provided map[string]string pointer if it exists. +func loadEnvTable(config *Config, key string, dest *map[string]string) { + // Example: RUNNER__RUNNER__ENVS = "key1=value1, key2=value2, key3=value3" + if v := os.Getenv(key); v != "" { + *dest = make(map[string]string) + for _, pair := range splitAndTrim(v) { + kv := strings.SplitN(pair, "=", 2) + if len(kv) == 2 { + (*dest)[kv[0]] = kv[1] + } + } + } +} + +// loadEnvList loads an environment variable into the provided []string pointer if it exists. +func loadEnvList(config *Config, key string, dest *[]string) { + // Example: RUNNER__RUNNER__LABELS = "label1, label2, label3" + if v := os.Getenv(key); v != "" { + log.Debug("Loading env var: ", key, "=", v) + *dest = splitAndTrim(v) + } +} + +func splitAndTrim(s string) []string { + lines := strings.Split(s, "\n") + var result []string + for _, line := range lines { + items := strings.Split(line, ",") + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + result = append(result, trimmed) + } + } + } + return result +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 60be651..80508de 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -170,5 +170,18 @@ func LoadDefault(file string) (*Config, error) { } } + // Load environment variables at the end to allow for overriding the default values. + loadEnvVars(cfg) + + // Write the state of the config, including default values, to a debug file. + debugFile := file + ".debug.yml" + content, err := yaml.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal config to debug file %q: %w", debugFile, err) + } + if err := os.WriteFile(debugFile, content, 0644); err != nil { + return nil, fmt.Errorf("write debug config file %q: %w", debugFile, err) + } + return cfg, nil }