367 lines
9.8 KiB
Go
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
|
||
|
|
}
|