-
Notifications
You must be signed in to change notification settings - Fork 2
v0.31.7 #114
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
v0.31.7 #114
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,4 +7,4 @@ | |
| A Command Line Interface for Lager Data | ||
| """ | ||
|
|
||
| __version__ = '0.31.6' | ||
| __version__ = '0.31.7' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| #!/usr/bin/env python3 | ||
| # Copyright 2024-2026 Lager Data | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Choose usable upstream resolvers for Docker's container DNS. | ||
|
|
||
| Runs on the box under the system python3, so stdlib only. | ||
|
|
||
| Docker validates every entry of daemon.json's "dns" list as a bare IP address and | ||
| refuses to start -- not warn, not skip the entry -- if one of them does not parse. | ||
| A resolver learned over IPv6 router advertisement is a link-local address carrying | ||
| a zone id (``fe80::1%3``), which does not parse. Writing one out therefore takes | ||
| the daemon down, and it stays down across reboots, because daemon.json is | ||
| persistent. | ||
|
|
||
| Link-local resolvers are dropped rather than stripped of their zone: a container's | ||
| network namespace cannot reach the host's link-local scope, so such an address is | ||
| useless to the containers this list exists to serve. | ||
| """ | ||
|
|
||
| import argparse | ||
| import ipaddress | ||
| import json | ||
| import os | ||
|
|
||
| FALLBACKS = ("1.1.1.1", "8.8.8.8") | ||
| MAX_UPSTREAM = 3 | ||
| DEFAULT_RESOLV_CONF = "/run/systemd/resolve/resolv.conf" | ||
| DEFAULT_DAEMON_JSON = "/etc/docker/daemon.json" | ||
|
|
||
|
|
||
| def rejection_reason(value): | ||
| """Why `value` is unusable as a Docker DNS server, or None if it is usable.""" | ||
| address = value.split("%", 1)[0] | ||
| try: | ||
| ip = ipaddress.ip_address(address) | ||
| except ValueError: | ||
| return "not an IP address" | ||
| if ip.is_loopback: | ||
| return "loopback" | ||
| if ip.is_link_local: | ||
| return "link-local" | ||
| if ip.is_unspecified: | ||
| return "unspecified" | ||
| if ip.is_multicast: | ||
| return "multicast" | ||
| return None | ||
|
|
||
|
|
||
| def _nameservers(text): | ||
| for line in text.splitlines(): | ||
| fields = line.split() | ||
| if len(fields) >= 2 and fields[0] == "nameserver": | ||
| yield fields[1] | ||
|
|
||
|
|
||
| def usable_resolvers(text, max_upstream=MAX_UPSTREAM): | ||
| """Usable resolvers from resolv.conf `text`, in order, deduplicated.""" | ||
| servers = [] | ||
| for value in _nameservers(text): | ||
| if rejection_reason(value) or value in servers: | ||
| continue | ||
| servers.append(value) | ||
| if len(servers) == max_upstream: | ||
| break | ||
| return servers | ||
|
|
||
|
|
||
| def rejected_resolvers(text): | ||
| """(value, reason) pairs for every nameserver we refuse to hand to Docker.""" | ||
| return [(v, rejection_reason(v)) for v in _nameservers(text) if rejection_reason(v)] | ||
|
|
||
|
|
||
| def merge_dns(cfg, servers): | ||
| """Return `cfg` with "dns" set, preserving every operator-set key.""" | ||
| dns = list(servers) | ||
| for fallback in FALLBACKS: | ||
| if fallback not in dns: | ||
| dns.append(fallback) | ||
| merged = dict(cfg) | ||
| merged["dns"] = dns | ||
| return merged | ||
|
|
||
|
|
||
| def load_daemon_json(path): | ||
| """Existing daemon.json, or {} if it is absent, unreadable, or not an object.""" | ||
| if not os.path.exists(path): | ||
| return {} | ||
| try: | ||
| with open(path) as fh: | ||
| cfg = json.load(fh) | ||
| except (OSError, ValueError): | ||
| # Docker cannot parse it either, so there is nothing worth preserving. | ||
| return {} | ||
| # Valid JSON that isn't an object (a list, say) is not a config Docker can use, | ||
| # and would break the merge. Same treatment as unparseable. | ||
| return cfg if isinstance(cfg, dict) else {} | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser(description="Build Docker's daemon.json DNS config") | ||
| parser.add_argument("--resolv-conf", default=DEFAULT_RESOLV_CONF) | ||
| parser.add_argument("--daemon-json", default=DEFAULT_DAEMON_JSON) | ||
| parser.add_argument("--out", required=True, help="where to write the merged daemon.json") | ||
| args = parser.parse_args() | ||
|
|
||
| try: | ||
| with open(args.resolv_conf) as fh: | ||
| resolv = fh.read() | ||
| except OSError: | ||
| resolv = "" | ||
|
|
||
| cfg = merge_dns(load_daemon_json(args.daemon_json), usable_resolvers(resolv)) | ||
|
|
||
| with open(args.out, "w") as fh: | ||
| json.dump(cfg, fh, indent=2) | ||
| fh.write("\n") | ||
|
|
||
| for value, reason in rejected_resolvers(resolv): | ||
| print("Ignoring unusable resolver {} ({})".format(value, reason)) | ||
| print("Docker container DNS ->", ", ".join(cfg["dns"])) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,85 @@ | ||||||||||||||||||||||||||||||||||||||
| #!/bin/bash | ||||||||||||||||||||||||||||||||||||||
| # Copyright 2024-2026 Lager Data | ||||||||||||||||||||||||||||||||||||||
| # SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||||||||||
| # Point Docker's container DNS at the box's real uplink resolvers. Runs on the box. | ||||||||||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||||||||||
| # On a systemd-resolved box, /etc/resolv.conf lists only the 127.0.0.53 stub, which | ||||||||||||||||||||||||||||||||||||||
| # a container cannot use, so Docker falls back to 8.8.8.8; where that resolver is | ||||||||||||||||||||||||||||||||||||||
| # unreachable, `docker build` cannot resolve github.com/pypi and the image build | ||||||||||||||||||||||||||||||||||||||
| # dies partway through. systemd-resolved's own resolv.conf names the real upstream | ||||||||||||||||||||||||||||||||||||||
| # servers, so we merge those into daemon.json. configure_docker_dns.py decides | ||||||||||||||||||||||||||||||||||||||
| # which of them Docker can actually use. | ||||||||||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||||||||||
| # This is an optimization, so it must never leave the box worse off than it found | ||||||||||||||||||||||||||||||||||||||
| # it: if Docker will not come back with the new config, the previous daemon.json is | ||||||||||||||||||||||||||||||||||||||
| # restored and Docker is restarted on it before we exit non-zero. | ||||||||||||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||||||||||||
| # Every privileged action here is one the box's sudoers file already grants | ||||||||||||||||||||||||||||||||||||||
| # NOPASSWD (see setup_and_deploy_box.sh): `install` from the fixed path | ||||||||||||||||||||||||||||||||||||||
| # /tmp/lager_daemon.json, and `systemctl restart docker`. Staging anywhere else -- | ||||||||||||||||||||||||||||||||||||||
| # a mktemp path, say -- silently falls outside the grant and makes every run prompt | ||||||||||||||||||||||||||||||||||||||
| # for a password. Snapshot and restore therefore route through that same path, and | ||||||||||||||||||||||||||||||||||||||
| # the backup lives in /tmp, where no privileges are needed to write it. | ||||||||||||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||||||||||||||||||||||||||||||||||||
| DAEMON_JSON="${DAEMON_JSON:-/etc/docker/daemon.json}" | ||||||||||||||||||||||||||||||||||||||
| RESOLV_CONF="${RESOLV_CONF:-/run/systemd/resolve/resolv.conf}" | ||||||||||||||||||||||||||||||||||||||
| STAGED="${STAGED:-/tmp/lager_daemon.json}" | ||||||||||||||||||||||||||||||||||||||
| BACKUP="${BACKUP:-/tmp/lager_daemon.json.bak}" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| rm -f "$STAGED" "$BACKUP" 2>/dev/null || true | ||||||||||||||||||||||||||||||||||||||
| trap 'rm -f "$STAGED" "$BACKUP" 2>/dev/null || true' EXIT | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| python3 "${SCRIPT_DIR}/configure_docker_dns.py" \ | ||||||||||||||||||||||||||||||||||||||
| --resolv-conf "$RESOLV_CONF" --daemon-json "$DAEMON_JSON" --out "$STAGED" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # Snapshot the current config so a failed restart can be undone. daemon.json is | ||||||||||||||||||||||||||||||||||||||
| # world-readable, so this needs no sudo. Track whether there was one at all. | ||||||||||||||||||||||||||||||||||||||
| if [ -f "$DAEMON_JSON" ]; then | ||||||||||||||||||||||||||||||||||||||
| HAD_CONFIG=1 | ||||||||||||||||||||||||||||||||||||||
| cp -f "$DAEMON_JSON" "$BACKUP" | ||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||
| HAD_CONFIG=0 | ||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The script uses DAEMON_JSON="${DAEMON_JSON:-/etc/docker/daemon.json}"
RESOLV_CONF="${RESOLV_CONF:-/run/systemd/resolve/resolv.conf}"
STAGED="${STAGED:-/tmp/lager_daemon.json}"
BACKUP="${BACKUP:-/tmp/lager_daemon.json.bak}"
# Clean up any stale files from previous runs
rm -f "$STAGED" "$BACKUP"
trap 'rm -f "$STAGED" "$BACKUP"' EXIT
python3 "${SCRIPT_DIR}/configure_docker_dns.py" \
--resolv-conf "$RESOLV_CONF" --daemon-json "$DAEMON_JSON" --out "$STAGED"
# Snapshot the current config so a failed restart can be undone. Track whether
# there was one at all, so we restore the old file or write an empty config accordingly.
# Since /etc/docker/daemon.json is world-readable, we can copy it to /tmp without sudo.
if [ -f "$DAEMON_JSON" ]; then
HAD_CONFIG=1
cp -f "$DAEMON_JSON" "$BACKUP"
else
HAD_CONFIG=0
fi |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| daemon_is_up() { | ||||||||||||||||||||||||||||||||||||||
| # `systemctl is-active` reads the unit's own state: unlike `docker info` it | ||||||||||||||||||||||||||||||||||||||
| # needs neither root nor docker-group membership, which a freshly added user | ||||||||||||||||||||||||||||||||||||||
| # may not have picked up in this SSH session yet. | ||||||||||||||||||||||||||||||||||||||
| local _ | ||||||||||||||||||||||||||||||||||||||
| for _ in 1 2 3 4 5; do | ||||||||||||||||||||||||||||||||||||||
| if systemctl is-active --quiet docker; then | ||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||
| sleep 1 | ||||||||||||||||||||||||||||||||||||||
| done | ||||||||||||||||||||||||||||||||||||||
| return 1 | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| restore_previous() { | ||||||||||||||||||||||||||||||||||||||
| # Where there was no daemon.json, an empty object restores Docker's defaults -- | ||||||||||||||||||||||||||||||||||||||
| # which is what the absent file meant. We write that rather than removing the | ||||||||||||||||||||||||||||||||||||||
| # file because `rm` on /etc/docker is not in the sudoers grant, and a recovery | ||||||||||||||||||||||||||||||||||||||
| # path that stops to ask for a password is a recovery path that does not run. | ||||||||||||||||||||||||||||||||||||||
| if [ "$HAD_CONFIG" -eq 1 ]; then | ||||||||||||||||||||||||||||||||||||||
| cp -f "$BACKUP" "$STAGED" | ||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||
| echo '{}' > "$STAGED" | ||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||
| sudo install -m 0644 "$STAGED" "$DAEMON_JSON" \ | ||||||||||||||||||||||||||||||||||||||
| || echo "WARNING: could not restore ${DAEMON_JSON}" >&2 | ||||||||||||||||||||||||||||||||||||||
| sudo systemctl restart docker || true | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+61
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To maintain passwordless execution, we should avoid running
Suggested change
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| sudo install -m 0644 "$STAGED" "$DAEMON_JSON" | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if ! sudo systemctl restart docker || ! daemon_is_up; then | ||||||||||||||||||||||||||||||||||||||
| restore_previous | ||||||||||||||||||||||||||||||||||||||
| echo "ERROR: Docker would not start with the new DNS configuration." >&2 | ||||||||||||||||||||||||||||||||||||||
| echo " Restored the previous ${DAEMON_JSON} and restarted Docker." >&2 | ||||||||||||||||||||||||||||||||||||||
| exit 1 | ||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| echo "Docker restarted with the new DNS configuration" | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
load_daemon_jsonfunction currently returnsjson.load(fh) or {}. If the JSON file contains a valid JSON array or other non-object types, this can return a list or string, which will causemerge_dnsto raise aTypeErrororValueErrorwhen callingdict(cfg). It is safer to explicitly verify that the loaded JSON is a dictionary.