193 lines
6.4 KiB
Go
193 lines
6.4 KiB
Go
package updater
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/api/types/mount"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// OverrideName is the override file the agent owns. docker compose loads it
|
|
// automatically next to compose.yaml or docker-compose.yml.
|
|
const OverrideName = "compose.override.yaml"
|
|
|
|
// overrideHeader marks a file the agent may rewrite. A file without it
|
|
// belongs to the operator and is never touched.
|
|
const overrideHeader = "# managed by package-updater — do not edit\n"
|
|
|
|
// overrideNames are the override files docker compose auto-loads. It loads
|
|
// only the first one present (in this order), so any other one next to ours
|
|
// would either hide ours or be hidden by it.
|
|
var overrideNames = []string{
|
|
"compose.override.yml", "compose.override.yaml",
|
|
"docker-compose.override.yml", "docker-compose.override.yaml",
|
|
}
|
|
|
|
// WriteOverride sets services.<service>.image to ref in dir/OverrideName,
|
|
// keeping every other entry in the file, and returns the file's path. It
|
|
// refuses when the file exists without the header, or when another
|
|
// auto-loaded override file exists in dir. The write is atomic.
|
|
func WriteOverride(dir, service, ref string) (string, error) {
|
|
for _, name := range overrideNames {
|
|
if name == OverrideName {
|
|
continue
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
|
return "", fmt.Errorf("refused: %s exists; the agent only manages %s and never edits operator files", name, OverrideName)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", fmt.Errorf("check %s: %w", name, err)
|
|
}
|
|
}
|
|
path := filepath.Join(dir, OverrideName)
|
|
doc := map[string]any{}
|
|
raw, err := os.ReadFile(path)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
case err != nil:
|
|
return "", fmt.Errorf("read override: %w", err)
|
|
case !bytes.HasPrefix(raw, []byte(overrideHeader)):
|
|
return "", fmt.Errorf("refused: %s exists and was not written by package-updater", path)
|
|
default:
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
return "", fmt.Errorf("parse override: %w", err)
|
|
}
|
|
if doc == nil {
|
|
doc = map[string]any{}
|
|
}
|
|
}
|
|
services, _ := doc["services"].(map[string]any)
|
|
if services == nil {
|
|
services = map[string]any{}
|
|
}
|
|
entry, _ := services[service].(map[string]any)
|
|
if entry == nil {
|
|
entry = map[string]any{}
|
|
}
|
|
entry["image"] = ref
|
|
services[service] = entry
|
|
doc["services"] = services
|
|
body, err := yaml.Marshal(doc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode override: %w", err)
|
|
}
|
|
// Temp file in the same directory + rename: a crash never leaves a
|
|
// half-written override that compose would then load.
|
|
tmp, err := os.CreateTemp(dir, "."+OverrideName+".*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("write override: %w", err)
|
|
}
|
|
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
|
if _, err := tmp.WriteString(overrideHeader + string(body)); err != nil {
|
|
tmp.Close()
|
|
return "", fmt.Errorf("write override: %w", err)
|
|
}
|
|
if err := tmp.Chmod(0o644); err != nil {
|
|
tmp.Close()
|
|
return "", fmt.Errorf("write override: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return "", fmt.Errorf("write override: %w", err)
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
return "", fmt.Errorf("write override: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// SelfInspector is the subset of the Docker SDK MountRoots needs.
|
|
type SelfInspector interface {
|
|
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
|
}
|
|
|
|
// errNoRoots is returned when the agent has no directory it may write stacks in.
|
|
var errNoRoots = errors.New("refused: no stack directory is mounted into the agent at the same path (read-write); " +
|
|
"mount each stacks directory as /path:/path and check that SELF_ID (default: the hostname) names the agent's container")
|
|
|
|
// MountRoots returns the directories the agent may update Compose stacks in:
|
|
// its own container's read-write bind mounts whose host path equals the
|
|
// path inside the container. Compose labels hold host paths, so only those
|
|
// mounts make a label path usable as is.
|
|
func MountRoots(ctx context.Context, cli SelfInspector, selfID string) ([]string, error) {
|
|
if selfID == "" {
|
|
return nil, errNoRoots
|
|
}
|
|
insp, err := cli.ContainerInspect(ctx, selfID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect own container %s: %w", selfID, err)
|
|
}
|
|
var roots []string
|
|
for _, m := range insp.Mounts {
|
|
if m.Type == mount.TypeBind && m.RW && filepath.Clean(m.Source) == filepath.Clean(m.Destination) {
|
|
roots = append(roots, filepath.Clean(m.Destination))
|
|
}
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
// composeFiles returns the -f arguments for a job: the stack's config files
|
|
// from the container label with any copy of the override removed, then the
|
|
// override last so it wins. Every file, and the real location of the stack
|
|
// directory after resolving symlinks, must be inside one of roots.
|
|
func composeFiles(roots []string, configFilesLabel string) (files []string, dir string, err error) {
|
|
if len(roots) == 0 {
|
|
return nil, "", errNoRoots
|
|
}
|
|
for _, f := range strings.Split(configFilesLabel, ",") {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, "", errors.New("container has no com.docker.compose.project.config_files label")
|
|
}
|
|
dir = filepath.Dir(files[0])
|
|
override := filepath.Join(dir, OverrideName)
|
|
out := files[:0]
|
|
for _, f := range files {
|
|
if !filepath.IsAbs(f) || !insideAny(roots, f) {
|
|
return nil, "", notMounted(filepath.Dir(f))
|
|
}
|
|
if filepath.Clean(f) != override {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
real, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve %s: %w", dir, err)
|
|
}
|
|
// Only directories count once resolved: this drops a same-path mount of
|
|
// a single file such as /var/run/docker.sock.
|
|
var realRoots []string
|
|
for _, r := range roots {
|
|
if rr, err := filepath.EvalSymlinks(r); err == nil {
|
|
if fi, err := os.Stat(rr); err == nil && fi.IsDir() {
|
|
realRoots = append(realRoots, rr)
|
|
}
|
|
}
|
|
}
|
|
if !insideAny(realRoots, real) {
|
|
return nil, "", notMounted(dir)
|
|
}
|
|
return append(out, override), dir, nil
|
|
}
|
|
|
|
func insideAny(roots []string, path string) bool {
|
|
for _, r := range roots {
|
|
if IsInsideRoot(r, path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func notMounted(dir string) error {
|
|
return fmt.Errorf("stack directory %s is not mounted into the agent at the same path (read-write)", dir)
|
|
}
|