package updater import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "syscall" "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..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. // // composeFile is the stack's first compose file. The new file gets the owner // of the existing override, or of composeFile when there is none or the // existing one is owned by root (see pickOwner). The agent runs as root and the // rename creates a new file, so without this the stack's owner would lose // control of the file. ownerErr reports that the owner could not be read or // set; the override is still written. err means it was not written. func WriteOverride(dir, service, ref, composeFile string) (path string, ownerErr error, err error) { overridePath := filepath.Join(dir, OverrideName) return writeOverride(dir, service, ref, func() (fileOwner, error) { return resolveOwner(overridePath, composeFile) }) } // fileOwner is the uid and gid of a file. type fileOwner struct{ uid, gid int } // readOwner returns the owner of the file at path. func readOwner(path string) (fileOwner, error) { fi, err := os.Stat(path) if err != nil { return fileOwner{}, fmt.Errorf("read owner: %w", err) } st, ok := fi.Sys().(*syscall.Stat_t) if !ok { return fileOwner{}, fmt.Errorf("read owner of %s: not available on this platform", path) } return fileOwner{int(st.Uid), int(st.Gid)}, nil } // pickOwner chooses the owner of a new override: the existing override's // (nil when there is none), unless that is root, then the compose file's. // Overrides written before the agent set owners are root-owned and would // otherwise stay so. compose is only called when needed. func pickOwner(override *fileOwner, compose func() (fileOwner, error)) (fileOwner, error) { if override != nil && override.uid != 0 { return *override, nil } return compose() } // resolveOwner applies pickOwner to the files on disk. func resolveOwner(overridePath, composeFile string) (fileOwner, error) { var existing *fileOwner if o, err := readOwner(overridePath); err == nil { existing = &o } else if !errors.Is(err, os.ErrNotExist) { return fileOwner{}, err } return pickOwner(existing, func() (fileOwner, error) { return readOwner(composeFile) }) } // writeOverride is WriteOverride with the owner lookup injected, for tests. func writeOverride(dir, service, ref string, owner func() (fileOwner, error)) (string, error, error) { for _, name := range overrideNames { if name == OverrideName { continue } if _, err := os.Lstat(filepath.Join(dir, name)); err == nil { return "", nil, 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 "", nil, 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 "", nil, fmt.Errorf("read override: %w", err) case !bytes.HasPrefix(raw, []byte(overrideHeader)): return "", nil, fmt.Errorf("refused: %s exists and was not written by package-updater", path) default: if err := yaml.Unmarshal(raw, &doc); err != nil { return "", nil, 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 "", nil, 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 "", nil, 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 "", nil, fmt.Errorf("write override: %w", err) } if err := tmp.Chmod(0o644); err != nil { tmp.Close() return "", nil, fmt.Errorf("write override: %w", err) } // Chown the open file, not the path after the rename: a path could have // been swapped for a symlink by someone with write access to the stack. var ownerErr error if o, err := owner(); err != nil { ownerErr = err } else if err := tmp.Chown(o.uid, o.gid); err != nil { ownerErr = fmt.Errorf("set owner %d:%d: %w", o.uid, o.gid, err) } if err := tmp.Close(); err != nil { return "", nil, fmt.Errorf("write override: %w", err) } if err := os.Rename(tmp.Name(), path); err != nil { return "", nil, fmt.Errorf("write override: %w", err) } return path, ownerErr, 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) { insp, err := inspectSelf(ctx, cli, selfID) if err != nil { return nil, err } return rootsOf(insp), nil } // inspectSelf inspects the agent's own container. func inspectSelf(ctx context.Context, cli SelfInspector, selfID string) (container.InspectResponse, error) { if selfID == "" { return container.InspectResponse{}, errNoRoots } insp, err := cli.ContainerInspect(ctx, selfID) if err != nil { return container.InspectResponse{}, fmt.Errorf("inspect own container %s: %w", selfID, err) } return insp, nil } // rootsOf returns the directories MountRoots describes, from an inspected // container. func rootsOf(insp container.InspectResponse) []string { 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 } // 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 its // directory after resolving symlinks, must be inside one of roots. func composeFiles(roots []string, configFilesLabel string) (files []string, dir string, err error) { files, dir, err = stackFiles(roots, configFilesLabel) if err != nil { return nil, "", err } return append(files, filepath.Join(dir, OverrideName)), dir, nil } // stackFiles is composeFiles without the override: the operator's own config // files, checked against roots. dir is the first file's directory. func stackFiles(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) realRoots := realDirs(roots) out := files[:0] for _, f := range files { if !filepath.IsAbs(f) || !insideAny(roots, f) { return nil, "", notMounted(filepath.Dir(f)) } real, err := filepath.EvalSymlinks(filepath.Dir(f)) if err != nil { return nil, "", fmt.Errorf("resolve %s: %w", filepath.Dir(f), err) } if !insideAny(realRoots, real) { return nil, "", notMounted(filepath.Dir(f)) } if filepath.Clean(f) != override { out = append(out, f) } } return out, dir, nil } // envFiles returns the --env-file paths from the container's // com.docker.compose.project.environment_file label (empty label: none). // Like config files, each must be inside one of roots, also after resolving // symlinks of the file itself. func envFiles(roots []string, label string) ([]string, error) { var files []string realRoots := realDirs(roots) for _, f := range strings.Split(label, ",") { if f = strings.TrimSpace(f); f == "" { continue } if !filepath.IsAbs(f) || !insideAny(roots, f) { return nil, fmt.Errorf("env file %s is not inside a directory mounted into the agent at the same path (read-write)", f) } real, err := filepath.EvalSymlinks(f) if err != nil { return nil, fmt.Errorf("resolve env file %s: %w", f, err) } if !insideAny(realRoots, real) { return nil, fmt.Errorf("env file %s is not inside a directory mounted into the agent at the same path (read-write)", f) } files = append(files, f) } return files, nil } // realDirs returns roots with symlinks resolved. Only directories count once // resolved: this drops a same-path mount of a single file such as // /var/run/docker.sock. func realDirs(roots []string) []string { var out []string for _, r := range roots { if rr, err := filepath.EvalSymlinks(r); err == nil { if fi, err := os.Stat(rr); err == nil && fi.IsDir() { out = append(out, rr) } } } return out } 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) }