Let CI containers join a named engine network

CI containers use the engine's default network. A rootful Docker or
Podman bridge is IPv4-only, so a step cannot reach a host that publishes
only an AAAA record. CI_NETWORK names a network to join instead, created
by the admin with whatever the site needs. Empty keeps the engine
default, so nothing changes for existing deployments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commite9047b43d229199bb769246205601c3dcd53e98a
Parente1f72fa
5 files changed, 65 insertions(+)
▾MCI.md
@@ -77,6 +77,7 @@ containerised Hearthforge works the same as a host install.
| `CI_MAX_HISTORY` | `50` | Runs kept per repository, artifacts included |
| `CI_DEFAULT_TIMEOUT` | `3600` | Step timeout in seconds when the config sets none |
| `CI_ENGINE_SOCKET` | `0` | Allow `engine_socket = true` steps |
| `CI_NETWORK` | _(engine default)_ | Network the CI container joins |
## Example
@@ -170,6 +171,27 @@ or split form is not caught.
![CI](https://your-forge.example.com/REPO_NAME/ci/badge.svg)
```
## Container networking
CI containers join the engine's default network. `CI_NETWORK` names a
different one. Hearthforge never creates it; the network must exist before a
run starts.
The common reason to set it is IPv6. A rootful Docker or Podman bridge is
IPv4-only, so a step cannot reach a host that publishes only an AAAA record.
Create a network once and name it:
```bash
podman network create --ipv6 hearthforge-ci # or: docker network create --ipv6 ...
```
`CI_NETWORK=host` puts the container on the host's network stack instead,
which also gives it the host's IPv6. A step then sees every port the host
listens on, so use it only where that is acceptable.
Rootless Podman needs none of this. Its containers already share the host's
addresses.
## Building container images
Steps run in a plain container without an engine of their own. A step with
▾MREADME.md
@@ -103,6 +103,7 @@ All settings are environment variables:
| `CI_DEFAULT_TIMEOUT` | `3600` | Default step timeout in seconds |
| `CI_MAX_CONCURRENT` | `2` | Advisory max concurrent runs |
| `CI_ENGINE_SOCKET` | `0` | Allow CI steps with `engine_socket = true` to use the engine socket |
| `CI_NETWORK` | _(engine default)_ | Engine network for CI containers, e.g. one created with IPv6 |
| `REGISTRY_PULL` | `admin` | Who may pull container images: `admin`, `users`, or `public` |
\* Set `TRUSTED_PROXY=1` only when Hearthforge is behind a reverse proxy that strips any incoming `X-Forwarded-For` from clients. Caddy and Traefik do this by default; nginx requires `proxy_set_header X-Forwarded-For $remote_addr;` (rather than the common `$proxy_add_x_forwarded_for`, which appends to a client-supplied value). Setting `TRUSTED_PROXY=1` in front of a proxy that does not strip means rate limits and any audit logging are spoofable per request.
▾Minternal/ci/docker.go
@@ -333,6 +333,11 @@ func (r *Runner) createContainer(ctx context.Context, runID int64, repoName stri
}
hostConfig := map[string]any{"Binds": binds}
// Empty means the engine's default network. A named network is the way
// to give CI containers IPv6 or an isolated segment.
if r.cfg.CINetwork != "" {
hostConfig["NetworkMode"] = r.cfg.CINetwork
}
if cfg.CPULimit > 0 {
hostConfig["NanoCpus"] = int64(cfg.CPULimit * 1e9)
}
▾Minternal/config/config.go
@@ -45,6 +45,7 @@ type Config struct {
CIMaxConcurrent int
CIMaxArtifactBytes int64
CIEngineSocket bool
CINetwork string // engine network for CI containers, empty = engine default
RegistryPull string // admin | users | public
MaxConcurrentArchives int
}
@@ -123,6 +124,7 @@ func Load() (*Config, error) {
CIMaxConcurrent: int(intEnv("CI_MAX_CONCURRENT", 2, 1)),
CIMaxArtifactBytes: intEnv("CI_MAX_ARTIFACT_BYTES", 512<<20, 1),
CIEngineSocket: boolEnv("CI_ENGINE_SOCKET"),
CINetwork: os.Getenv("CI_NETWORK"),
RegistryPull: strEnv("REGISTRY_PULL", "admin"),
MaxConcurrentArchives: int(intEnv("MAX_CONCURRENT_ARCHIVE_JOBS", 2, 1)),
}
▾Minternal/web/e2e/ci_socket_test.go
@@ -114,3 +114,38 @@ func TestCIEngineSocketEnabled(t *testing.T) {
t.Errorf("plain step env = %v, want no DOCKER_HOST", plainEnv)
}
}
// TestCINetwork checks that CI_NETWORK reaches the container create call.
// An engine network is how a CI container gets IPv6 or an isolated segment.
func TestCINetwork(t *testing.T) {
networkMode := func(m *mockDocker) any {
host, _ := m.createBody()["HostConfig"].(map[string]any)
return host["NetworkMode"]
}
t.Run("unset leaves the engine default", func(t *testing.T) {
e, m, admin := ciEnv(t)
m.queueExec(execResp{output: "hi\n"})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if got := networkMode(m); got != nil {
t.Errorf("NetworkMode = %v, want absent", got)
}
})
t.Run("set joins that network", func(t *testing.T) {
e, m, admin := ciEnv(t, "CI_NETWORK", "hearthforge-ci")
m.queueExec(execResp{output: "hi\n"})
sha := ciSeedToml(e, ciSimpleTOML)
runID := ciTrigger(e, admin, sha, nil)
if got := ciWaitForRun(e, runID); got != "success" {
t.Fatalf("status = %q", got)
}
if got := networkMode(m); got != "hearthforge-ci" {
t.Errorf("NetworkMode = %v", got)
}
})
}