initial commit
This commit is contained in:
+366
@@ -0,0 +1,366 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user