[release] v0.18.0-unstable91

This commit is contained in:
Yann Stepienik
2025-02-18 18:10:51 +00:00
parent 5cc02f9b3f
commit a490e8b176
9 changed files with 327 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ interface BackupConfig {
crontab?: string;
tags?: string[];
exclude?: string[];
autoStopContainers?: boolean;
}
interface RestoreConfig {

View File

@@ -9,7 +9,7 @@ import { PlusCircleOutlined } from "@ant-design/icons";
import { crontabToText } from "../../utils/indexs";
import { Trans, useTranslation } from 'react-i18next';
import { FilePickerButton } from '../../components/filePicker';
import { CosmosInputText } from "../config/users/formShortcuts";
import { CosmosCheckbox, CosmosInputText } from "../config/users/formShortcuts";
const isAbsolutePath = (path) => path.startsWith('/') || path.startsWith('rclone:') || /^[a-zA-Z]:\\/.test(path); // Unix & Windows support
@@ -23,7 +23,8 @@ const BackupDialogInternal = ({ refresh, open, setOpen, preSource, preName, data
repository: data.Repository || '/backups',
crontab: data.Crontab || '0 0 4 * * *',
crontabForget: data.CrontabForget || '0 0 12 * * *',
retentionPolicy: data.RetentionPolicy || '--keep-last 3 --keep-daily 7 --keep-weekly 8 --keep-yearly 3'
retentionPolicy: data.RetentionPolicy || '--keep-last 3 --keep-daily 7 --keep-weekly 8 --keep-yearly 3',
autoStopContainers: isEdit ? data.AutoStopContainers : true,
},
validationSchema: yup.object({
name: yup
@@ -156,6 +157,13 @@ const BackupDialogInternal = ({ refresh, open, setOpen, preSource, preName, data
helperText={formik.touched.retentionPolicy && formik.errors.retentionPolicy}
/>
<CosmosCheckbox
name="autoStopContainers"
label={t('mgmt.backup.autoStopContainers')}
formik={formik}
/>
{formik.errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{formik.errors.submit}</FormHelperText>

View File

@@ -88,7 +88,7 @@ const RestoreDialogInternal = ({ refresh, open, setOpen, candidatePaths, origina
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>{t('global.cancelAction')}</Button>
<Button onClick={() => setOpen(false)}>{done ? t('global.close') : t('global.cancelAction')}</Button>
{!done && <LoadingButton
color="primary"
variant="contained"

View File

@@ -325,7 +325,7 @@ const VolumeContainerSetup = ({
>
{t('global.unmount')}
</Button>
{r.Target ? <BackupDialog preName={`${containerInfo.Name.replace("/", "").replace("/", "-")}-${r.Target.replace("/", "").replaceAll("/", "_")}`} preSource={formatSource(r.Source)} refresh={() => setTimeout(refreshAll, 1500)} /> : null}
{!newContainer && containerInfo.Name && (r.Target ? <BackupDialog preName={`${containerInfo.Name.replace("/", "").replace("/", "-")}-${r.Target.replace("/", "").replaceAll("/", "_")}`} preSource={formatSource(r.Source)} refresh={() => setTimeout(refreshAll, 1500)} /> : null)}
</Stack>
);
},
@@ -374,19 +374,19 @@ const VolumeContainerSetup = ({
)}
</Formik>
</div>
<div
{!newContainer && <div
style={{
maxWidth: "1000px",
width: "100%",
margin: "",
position: "relative",
}}>
<MainCard title={t('mgmt.backup.backups')}>
{containerInfo && containerInfo.HostConfig && containerInfo.HostConfig.Mounts && <MainCard title={t('mgmt.backup.backups')}>
<Backups pathFilters={
containerInfo.HostConfig.Mounts.map((r) => formatSource(r.Source))
} />
</MainCard>
</div>
</MainCard>}
</div>}
</Stack>
);
};

View File

@@ -912,6 +912,7 @@
"mgmt.backup.files": "Files in Snapshot",
"mgmt.backup.restore.success": "Restore operation has been queued. You can monitor the progress in the tasks log on the top right.",
"mgmt.backup.restore.summary": "You are about to restore {count} file(s)/folder(s). This will overwrite existing files.",
"mgmt.backup.autoStopContainers": "Automatically Stop Containers using that folder before backup/restore operations",
"global.noData": "No Data To Show",
"global.open": "Open",
"global.absolutePath": "Must be an absolute path",

View File

@@ -143,6 +143,7 @@ func EditBackupRoute(w http.ResponseWriter, req *http.Request) {
current.Crontab = request.Crontab
current.CrontabForget = request.CrontabForget
current.RetentionPolicy = request.RetentionPolicy
current.AutoStopContainers = request.AutoStopContainers
config.Backup.Backups[request.Name] = current
utils.SetBaseMainConfig(config)

View File

@@ -7,10 +7,12 @@ import (
"os/exec"
"regexp"
"strings"
"context"
"github.com/creack/pty"
"github.com/azukaar/cosmos-server/src/utils"
"github.com/azukaar/cosmos-server/src/cron"
"github.com/azukaar/cosmos-server/src/docker"
"encoding/json"
)
@@ -262,7 +264,42 @@ func CreateBackupJob(config BackupConfig, crontab string) {
Scheduler: "Restic",
Name: fmt.Sprintf("Restic backup %s", config.Name),
Cancellable: true,
Job: cron.JobFromCommandWithEnv(env, "./restic", prependResticArgs(args)...),
Job: func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
var containers []string
var err error
if config.AutoStopContainers {
containers, err = docker.GetContainersUsingPath(config.Source)
if err != nil {
OnFail(err)
return
}
OnLog("Found container(s) using path: " + strings.Join(containers, ", "))
// Stop all containers
err = docker.StopContainers(containers)
if err != nil {
docker.StartContainers(containers)
OnFail(err)
return
}
OnLog("Stopped containers, starting backup")
}
cron.JobFromCommandWithEnv(env, "./restic", prependResticArgs(args)...)(OnLog, OnFail, OnSuccess, ctx, cancel)
if config.AutoStopContainers {
// Start all containers
err = docker.StartContainers(containers)
if err != nil {
OnFail(err)
return
}
}
},
Crontab: crontab,
Resource: "backup@" + config.Name,
})
@@ -337,7 +374,42 @@ func CreateRestoreJob(config RestoreConfig) {
Scheduler: "Restic",
Name: fmt.Sprintf("Restic restore %s", config.Name),
Cancellable: true,
Job: cron.JobFromCommandWithEnv(env, "./restic", prependResticArgs(args)...),
Job: func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
var containers []string
var err error
if config.AutoStopContainers {
containers, err = docker.GetContainersUsingPath(config.OriginalSource)
if err != nil {
OnFail(err)
return
}
OnLog("Found container(s) using path: " + strings.Join(containers, ", "))
// Stop all containers
err = docker.StopContainers(containers)
if err != nil {
docker.StartContainers(containers)
OnFail(err)
return
}
OnLog("Stopped containers, starting backup")
}
cron.JobFromCommandWithEnv(env, "./restic", prependResticArgs(args)...)(OnLog, OnFail, OnSuccess, ctx, cancel)
if config.AutoStopContainers {
// Start all containers
err = docker.StartContainers(containers)
if err != nil {
OnFail(err)
return
}
}
},
Resource: "backup@" + config.Name,
})
})()

View File

@@ -173,14 +173,7 @@ func setTerminalSize(ptmx *os.File, rows, cols uint16) error {
})
}
func WrapJobExecution(
jobExecuter func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc),
wrapFunc func(func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc)) func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc),
) func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
return func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
wrapFunc(jobExecuter)(OnLog, OnFail, OnSuccess, ctx, cancel)
}
}
type ExecuterFn func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc)
func JobFromContainerCommand(containerID string, command string, args ...string) func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {
return func(OnLog func(string), OnFail func(error), OnSuccess func(), ctx context.Context, cancel context.CancelFunc) {

233
src/docker/backups.go Normal file
View File

@@ -0,0 +1,233 @@
package docker
import (
"strings"
"path/filepath"
"fmt"
"time"
contstuff "github.com/docker/docker/api/types/container"
"github.com/azukaar/cosmos-server/src/utils"
)
// arePathsRelated checks if either path is a parent of the other or if they are the same
func arePathsRelated(path1, path2 string) bool {
// Clean and normalize both paths
path1 = filepath.Clean(path1)
path2 = filepath.Clean(path2)
// If paths are equal, return true
if path1 == path2 {
return true
}
// Check if path1 is a parent of path2
rel1, err1 := filepath.Rel(path1, path2)
if err1 == nil && !strings.HasPrefix(rel1, "..") {
return true
}
// Check if path2 is a parent of path1
rel2, err2 := filepath.Rel(path2, path1)
if err2 == nil && !strings.HasPrefix(rel2, "..") {
return true
}
return false
}
// getStackContainers returns all containers that are part of the same stack as the given container
func getStackContainers(containerID string) ([]string, error) {
var stackContainers []string
// Get the container details to find stack labels
container, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
return nil, err
}
// Check for compose project name (stack name)
stackName := ""
for label, value := range container.Config.Labels {
if label == "com.docker.compose.project" || label == "cosmos-stack" {
stackName = value
break
}
}
// If no stack found, return just this container
if stackName == "" {
return []string{containerID}, nil
}
// List all containers
containers, err := ListContainers()
if err != nil {
return nil, err
}
// Find all containers with the same stack name
for _, cont := range containers {
fullContainer, err := DockerClient.ContainerInspect(DockerContext, cont.ID)
if err != nil {
continue
}
for label, value := range fullContainer.Config.Labels {
if (label == "com.docker.compose.project" || label == "cosmos-stack") && value == stackName {
stackContainers = append(stackContainers, cont.ID)
break
}
}
}
return stackContainers, nil
}
// GetContainersUsingPath returns a list of container IDs that have volumes or binds
// that are either equal to or contained within the specified path
func GetContainersUsingPath(path string) ([]string, error) {
var affectedContainers []string
seenContainers := make(map[string]bool)
// Connect to Docker if not already connected
errD := Connect()
if errD != nil {
return nil, errD
}
// List all containers
containers, err := ListContainers()
if err != nil {
return nil, err
}
// Check each container
for _, container := range containers {
// Skip containers that are stopped
if container.State == "exited" || container.State == "dead" || container.State == "created" || container.State == "removing" || container.State == "paused" {
continue
}
// Get detailed container info
fullContainer, err := DockerClient.ContainerInspect(DockerContext, container.ID)
if err != nil {
continue
}
// Check mounts (both volumes and binds)
isAffected := false
for _, mount := range fullContainer.Mounts {
var sourcePath string
switch mount.Type {
case "bind":
sourcePath = mount.Source
case "volume":
// For volumes, we need to check in /var/lib/docker/volumes
sourcePath = filepath.Join("/var/lib/docker/volumes", mount.Name, "_data")
default:
continue
}
utils.Debug("Checking container " + container.ID + " mount " + sourcePath + " against " + path)
if arePathsRelated(path, sourcePath) {
isAffected = true
break
}
}
if isAffected {
// Get all containers in the same stack
stackContainers, err := getStackContainers(container.ID)
if err != nil {
continue
}
// Add all stack containers to the result list (avoiding duplicates)
for _, stackContainer := range stackContainers {
if !seenContainers[stackContainer] {
affectedContainers = append(affectedContainers, stackContainer)
seenContainers[stackContainer] = true
}
}
}
}
return affectedContainers, nil
}
// StopContainers stops the given containers and waits for them to stop
func StopContainers(containerIDs []string) error {
errD := Connect()
if errD != nil {
return errD
}
for _, containerID := range containerIDs {
// Get container state
container, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
return fmt.Errorf("failed to inspect container %s: %v", containerID, err)
}
// Skip if already stopped
if !container.State.Running {
continue
}
// Stop the container with a timeout
err = DockerClient.ContainerStop(DockerContext, containerID, contstuff.StopOptions{})
if err != nil {
return fmt.Errorf("failed to stop container %s: %v", containerID, err)
}
// Wait until container is actually stopped
for {
container, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
return fmt.Errorf("failed to inspect container %s: %v", containerID, err)
}
if !container.State.Running {
break
}
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
// StartContainers starts the given containers and waits for them to be running
func StartContainers(containerIDs []string) error {
errD := Connect()
if errD != nil {
return errD
}
for _, containerID := range containerIDs {
// Get container state
container, err := DockerClient.ContainerInspect(DockerContext, containerID)
if err != nil {
return fmt.Errorf("failed to inspect container %s: %v", containerID, err)
}
// Skip if already running
if container.State.Running {
continue
}
// Start the container
err = DockerClient.ContainerStart(DockerContext, containerID, contstuff.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container %s: %v", containerID, err)
}
}
return nil
}