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..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 its // 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) // 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) } } } 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 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) }