Compare commits

..
5 changed files with 16 additions and 84 deletions
-2
View File
@@ -122,8 +122,6 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- **Swarm updates need manager-node API access** for the agent doing the update.
- **The image's default entrypoint is `updater-agent`.** The master overrides it with
`updater-master`.
- **The example stack runs the master as nobody (`user: "65534:65534"`).** Never add `USER`
to the Dockerfile: the agent uses the same image and needs root for the Docker socket.
- **The webhook waits via a broker signal plus a store re-read, with a fallback poll**
(the broker drops signals for slow subscribers). Marking and starting jobs runs on
`context.WithoutCancel`, so a client that disconnects cannot leave a queued job no agent
+5 -10
View File
@@ -28,16 +28,14 @@ a time per service, and one job at a time per agent.
Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack;
needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default
`8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`,
pinned TLS, reached directly on the internal network). The example runs the master as
`nobody` (`65534`); it needs no root. The agent runs as root because access to the Docker
socket is root on the host anyway.
pinned TLS, reached directly on the internal network).
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. |
| `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). |
| `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. |
| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling, readable by the master's user. Only inline `auths` entries work; see [Registry polling](#registry-polling). |
| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling. Only inline `auths` entries work; see [Registry polling](#registry-polling). |
The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`,
`ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in
@@ -148,14 +146,11 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s
tags continue. A later success clears it.
- **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by
`docker login`), mounted read-only into the container; without it registries are accessed
anonymously. The directory and file must be readable by the master's user (`65534` in the
example stack) without being world-readable: `chown -R 65534:65534` the directory and keep
`0700`/`0600`. Only inline `auths` entries are supported. A `credsStore` or `credHelpers`
anonymously. Only inline `auths` entries are supported. A `credsStore` or `credHelpers`
entry makes every lookup fail with `error getting credentials ... executable file not found
in $PATH`, because the container has no credential helper binaries. The master logs
`registry_credentials` at startup with the directory, `config_json_found` and
`config_json_readable`, never the contents. If the file exists but cannot be opened it also
logs a `registry_credentials_unreadable` warning with the error.
`registry_credentials` at startup with the directory and whether `config.json` exists,
never its contents.
- **Requests go wherever the image name points.** The master sends the lookup to the
registry host in the image name, including a `GET /v2/` ping that follows redirects.
Anyone who can start containers on a managed host can therefore make the master send
+8 -22
View File
@@ -48,29 +48,19 @@ const pollFirstDelay = time.Minute
const pollWorkers = 4
// registryCredentialsDir returns the directory the registry client reads
// credentials from (DOCKER_CONFIG, else ~/.docker), whether a config.json is
// in it, and the error from opening it when it exists but cannot be read (for
// example because the master runs as another user than the file owner). The
// file is opened and closed, never read; only the path is logged.
func registryCredentialsDir() (dir string, found bool, readErr error) {
// credentials from (DOCKER_CONFIG, else ~/.docker) and whether a config.json
// is in it. Only the path is logged, never the file.
func registryCredentialsDir() (dir string, found bool) {
dir = os.Getenv("DOCKER_CONFIG")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", false, nil
return "", false
}
dir = filepath.Join(home, ".docker")
}
path := filepath.Join(dir, "config.json")
if _, err := os.Stat(path); err != nil {
return dir, false, nil
}
f, err := os.Open(path)
if err != nil {
return dir, true, err
}
_ = f.Close()
return dir, true, nil
_, err := os.Stat(filepath.Join(dir, "config.json"))
return dir, err == nil
}
func run() error {
@@ -164,12 +154,8 @@ func run() error {
BaseContext: baseContext,
}
credDir, credFound, credReadErr := registryCredentialsDir()
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
"config_json_readable", credFound && credReadErr == nil)
if credReadErr != nil {
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
}
credDir, credFound := registryCredentialsDir()
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound)
pollCtx, stopPolling := context.WithCancel(ctx)
defer stopPolling()
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
-42
View File
@@ -1,42 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRegistryCredentialsDir(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOCKER_CONFIG", dir)
got, found, readErr := registryCredentialsDir()
assert.Equal(t, dir, got)
assert.False(t, found)
assert.NoError(t, readErr)
})
t.Run("readable file", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o600))
t.Setenv("DOCKER_CONFIG", dir)
_, found, readErr := registryCredentialsDir()
assert.True(t, found)
assert.NoError(t, readErr)
})
t.Run("unreadable file", func(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can read any file")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o000))
t.Setenv("DOCKER_CONFIG", dir)
_, found, readErr := registryCredentialsDir()
assert.True(t, found)
assert.Error(t, readErr)
})
}
+3 -8
View File
@@ -12,17 +12,12 @@
# The reverse proxy must allow a read timeout longer than UPDATE_TIMEOUT (default 5m).
# 5. Optional, for registry polling of private images: put a config.json written by
# `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and
# uncomment DOCKER_CONFIG and the volumes entry below. The master runs as 65534:65534, so
# the directory and config.json must be readable by that user while staying private:
# `chown -R 65534:65534 /path/to/docker-config` (keep 0700/0600), or set `user:` below
# to the owner's uid:gid. Do not make config.json world-readable; it holds registry
# passwords. The directory must exist on every manager node (the master is placed on a
# manager), or Swarm rejects the task. Without it registries are accessed anonymously.
# uncomment DOCKER_CONFIG and the volumes entry below. The directory must exist on every
# manager node (the master is placed on a manager), or Swarm rejects the task. Without
# it registries are accessed anonymously.
services:
master:
image: gitea.shcizo.se/shcizo/package-updater:latest
# Runs as nobody: the master needs no root (the agent does, for the Docker socket).
user: "65534:65534"
entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) UPDATER_API_KEY=$$(cat /run/secrets/pu_updater_api_key) exec /usr/local/bin/updater-master"]
environment:
PUBLIC_URL: https://updater.example.com