Files
2026-07-27 14:41:04 +02:00

367 lines
9.8 KiB
Go

package models
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
var composeFileNames = []string{
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
// Host paths excluded from backup folder deduction.
var excludedPrefixes = []string{"/var/run/", "/proc/", "/sys/", "/dev/", "/run/", "/etc/"}
var excludedExact = []string{
"/var/run/docker.sock",
"/etc/timezone", "/etc/localtime", "/etc/hosts", "/etc/hostname", "/etc/resolv.conf",
}
// ScanAll discovers compose-based services under all default paths plus an optional
// extra folder. Existing services matched by compose file path are updated (status,
// and backup folder if previously unset). New services are created.
func ScanAll(extraFolder string) ScanResult {
paths := resolveScanPaths(extraFolder)
result := ScanResult{
ScannedPaths: paths,
Created: []ServiceScanInfo{},
Updated: []ServiceScanInfo{},
Errors: []string{},
}
for _, p := range paths {
r := scanFolder(p)
result.Found += r.Found
result.Created = append(result.Created, r.Created...)
result.Updated = append(result.Updated, r.Updated...)
result.Errors = append(result.Errors, r.Errors...)
}
return result
}
// resolveScanPaths returns the directories to scan: /opt/compose, /root/compose,
// /home/*/compose, and any explicitly supplied extra path.
func resolveScanPaths(extra string) []string {
var paths []string
for _, candidate := range []string{"/opt/compose", "/root/compose"} {
if isAccessibleDir(candidate) {
paths = append(paths, candidate)
}
}
if entries, err := os.ReadDir("/home"); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
p := filepath.Join("/home", e.Name(), "compose")
if isAccessibleDir(p) {
paths = append(paths, p)
}
}
}
if extra != "" && isAccessibleDir(extra) {
paths = append(paths, extra)
}
return paths
}
func isAccessibleDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
// scanFolder scans the immediate subdirectories of folder for compose files.
func scanFolder(folder string) ScanResult {
result := ScanResult{Created: []ServiceScanInfo{}, Updated: []ServiceScanInfo{}}
entries, err := os.ReadDir(folder)
if err != nil {
result.Errors = []string{fmt.Sprintf("cannot read %s: %v", folder, err)}
return result
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
serviceDir := filepath.Join(folder, entry.Name())
composeFile := findComposeFile(serviceDir)
if composeFile == "" {
continue
}
result.Found++
info, err := processComposeFile(entry.Name(), composeFile)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", composeFile, err))
continue
}
created, isNew := upsertService(info)
if created {
if isNew {
result.Created = append(result.Created, info)
} else {
result.Updated = append(result.Updated, info)
}
}
}
return result
}
func findComposeFile(dir string) string {
for _, name := range composeFileNames {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// processComposeFile parses a compose file and produces a ServiceScanInfo.
func processComposeFile(name, composeFile string) (ServiceScanInfo, error) {
hostPaths, err := extractHostPaths(composeFile)
if err != nil {
return ServiceScanInfo{}, err
}
return ServiceScanInfo{
Name: name,
ComposeFile: composeFile,
BackupFolder: deduceBackupFolder(hostPaths, name),
Status: probeServiceStatus(composeFile),
}, nil
}
// extractHostPaths parses all volume bind-mount host paths from a compose YAML file,
// filtering out system/runtime paths that should not be backed up.
// If yaml.v3 strict parsing fails (e.g. duplicate mapping keys in real-world compose
// files), it falls back to a lenient text-based extraction.
func extractHostPaths(composeFile string) ([]string, error) {
data, err := os.ReadFile(composeFile)
if err != nil {
return nil, err
}
var doc map[string]interface{}
if err := yaml.Unmarshal(data, &doc); err != nil {
// Malformed YAML (duplicate keys etc.) — fall back to text scan.
return extractHostPathsFallback(data), nil
}
servicesRaw, ok := doc["services"]
if !ok {
return nil, nil
}
services, _ := servicesRaw.(map[string]interface{})
seen := map[string]bool{}
var paths []string
for _, svcRaw := range services {
svc, ok := svcRaw.(map[string]interface{})
if !ok {
continue
}
volumes, _ := svc["volumes"].([]interface{})
for _, v := range volumes {
var hostPath string
switch vv := v.(type) {
case string:
hostPath = hostPathFromShortForm(vv)
case map[string]interface{}:
if t, _ := vv["type"].(string); t == "bind" {
hostPath, _ = vv["source"].(string)
}
}
hostPath = filepath.Clean(hostPath)
if hostPath != "" && hostPath != "." && isBackupablePath(hostPath) && !seen[hostPath] {
seen[hostPath] = true
paths = append(paths, hostPath)
}
}
}
return paths, nil
}
// extractHostPathsFallback uses simple line scanning to extract volume bind-mounts
// from compose files that fail strict YAML parsing (duplicate keys, etc.).
// It looks for list items that contain an absolute host path followed by a colon.
func extractHostPathsFallback(data []byte) []string {
seen := map[string]bool{}
var paths []string
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
// Volume list entries: "- /abs/path:/container/path" (with or without quotes)
if !strings.HasPrefix(trimmed, "- ") {
continue
}
spec := strings.Trim(strings.TrimPrefix(trimmed, "- "), "\"' ")
if !strings.HasPrefix(spec, "/") {
continue
}
hostPath := hostPathFromShortForm(spec)
if hostPath == "" {
continue
}
hostPath = filepath.Clean(hostPath)
if isBackupablePath(hostPath) && !seen[hostPath] {
seen[hostPath] = true
paths = append(paths, hostPath)
}
}
return paths
}
// hostPathFromShortForm extracts the host path from "host:container[:opts]" volume spec.
func hostPathFromShortForm(spec string) string {
parts := strings.SplitN(spec, ":", 3)
if len(parts) < 2 {
return ""
}
src := parts[0]
if !filepath.IsAbs(src) {
return "" // named volume or relative path
}
return src
}
// isBackupablePath returns false for system/runtime paths that should not be backed up.
func isBackupablePath(path string) bool {
for _, exact := range excludedExact {
if path == exact {
return false
}
}
for _, prefix := range excludedPrefixes {
if strings.HasPrefix(path, prefix) {
return false
}
}
return true
}
// deduceBackupFolder finds the most appropriate backup root for a service.
//
// Priority:
// 1. /opt/data/containers/<serviceName> — standard layout in the examples
// 2. Longest common directory prefix across all collected host paths
// 3. First collected path as-is
func deduceBackupFolder(hostPaths []string, serviceName string) string {
if len(hostPaths) == 0 {
return ""
}
// 1. Standard layout
standard := "/opt/data/containers/" + serviceName
for _, p := range hostPaths {
if strings.HasPrefix(p, standard) {
return standard
}
}
// 2. Common prefix
prefix := commonPathPrefix(hostPaths)
if prefix != "" && prefix != "/" {
return prefix
}
// 3. Fallback
return hostPaths[0]
}
// commonPathPrefix returns the longest common directory prefix shared by all paths.
func commonPathPrefix(paths []string) string {
if len(paths) == 0 {
return ""
}
if len(paths) == 1 {
return paths[0]
}
sep := string(filepath.Separator)
parts := strings.Split(paths[0], sep)
for _, p := range paths[1:] {
other := strings.Split(p, sep)
limit := len(parts)
if len(other) < limit {
limit = len(other)
}
i := 0
for i < limit && parts[i] == other[i] {
i++
}
parts = parts[:i]
}
if len(parts) <= 1 {
return "/"
}
return strings.Join(parts, sep)
}
// probeServiceStatus checks whether the service has any running containers.
// Uses the docker project label set by compose, then falls back to compose CLI.
func probeServiceStatus(composeFile string) string {
projectName := strings.ToLower(filepath.Base(filepath.Dir(composeFile)))
// Method 1: docker ps with project label (no compose binary required)
cmd := exec.Command("docker", "ps", "-q",
"--filter", "label=com.docker.compose.project="+projectName)
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
// Method 2: docker compose ps
cmd = exec.Command("docker", "compose", "-f", composeFile, "ps", "-q")
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
// Method 3: legacy docker-compose
cmd = exec.Command("docker-compose", "-f", composeFile, "ps", "-q")
if out, err := cmd.Output(); err == nil {
if strings.TrimSpace(string(out)) != "" {
return "up"
}
return "down"
}
return "unknown"
}
// upsertService creates or updates a service entry indexed by compose file path.
// Returns (true, true) = created; (true, false) = updated; (false, _) = no change.
func upsertService(info ServiceScanInfo) (bool, bool) {
services := GetAllServices()
for _, svc := range services {
if svc.ComposeFile != info.ComposeFile {
continue
}
// Found — update status and backup folder if previously unset
changed := svc.Status != info.Status
if svc.BackupFolder == "" && info.BackupFolder != "" {
svc.BackupFolder = info.BackupFolder
changed = true
}
svc.Status = info.Status
if changed {
_, _ = UpdateService(svc.ID, svc)
return true, false
}
return false, false
}
// Not found — create
_, _ = AddService(Service{
Name: info.Name,
ComposeFile: info.ComposeFile,
BackupFolder: info.BackupFolder,
Status: info.Status,
ComposeType: "docker compose",
})
return true, true
}