flamenco/internal/worker/command_file_mgmt.go
Bastien Montagne 71f2947c4b Add 'copy-file' command. (#104220)
Initial implementation with some basic tests.

The API command only accept one source and destination path, which must
be absolute. If the destination already exists, it will not be
ovewritten, and an error is generated.

JS job definition code using this new command can easily handle list of
paths and/or relative paths if needed.

Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104220
2023-06-08 16:20:43 +02:00

271 lines
8.1 KiB
Go

package worker
// SPDX-License-Identifier: GPL-3.0-or-later
/* This file contains the commands in the "file-management" type group. */
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/rs/zerolog"
"git.blender.org/flamenco/pkg/api"
)
// cmdMoveDirectory executes the "move-directory" command.
// It moves directory 'src' to 'dest'; if 'dest' already exists, it's moved to 'dest-{timestamp}'.
func (ce *CommandExecutor) cmdMoveDirectory(ctx context.Context, logger zerolog.Logger, taskID string, cmd api.Command) error {
var src, dest string
var ok bool
if src, ok = cmdParameter[string](cmd, "src"); !ok || src == "" {
logger.Warn().Interface("command", cmd).Msg("missing 'src' parameter")
return NewParameterMissingError("src", cmd)
}
if dest, ok = cmdParameter[string](cmd, "dest"); !ok || dest == "" {
logger.Warn().Interface("command", cmd).Msg("missing 'dest' parameter")
return NewParameterMissingError("dest", cmd)
}
logger = logger.With().
Str("src", src).
Str("dest", dest).
Logger()
if !fileExists(src) {
logger.Warn().Msg("source path does not exist, not moving anything")
msg := fmt.Sprintf("%s: source path %q does not exist, not moving anything", cmd.Name, src)
if err := ce.listener.LogProduced(ctx, taskID, msg); err != nil {
return err
}
return NewParameterInvalidError("src", cmd, "path does not exist")
}
if fileExists(dest) {
backup, err := timestampedPath(dest)
if err != nil {
logger.Error().Err(err).Str("path", dest).Msg("unable to determine timestamp of directory")
return err
}
if fileExists(backup) {
logger.Debug().Str("backup", backup).Msg("backup destination also exists, finding one that does not")
backup, err = uniquePath(backup)
if err != nil {
return err
}
}
logger.Info().
Str("toBackup", backup).
Msg("dest directory exists, moving to backup")
if err := ce.moveAndLog(ctx, taskID, cmd.Name, dest, backup); err != nil {
return err
}
}
// self._log.info("Moving %s to %s", src, dest)
// await self.worker.register_log(
// "%s: Moving %s to %s", self.command_name, src, dest
// )
// src.rename(dest)
logger.Info().Msg("moving directory")
return ce.moveAndLog(ctx, taskID, cmd.Name, src, dest)
}
// cmdCopyFiles executes the "copy-file" command.
// It takes an absolute source and destination file path,
// and copies the source file to its destination, if possible.
// Missing directories in destination path are created as needed.
// If the target path already exists, an error is returned. Destination will not be overwritten.
func (ce *CommandExecutor) cmdCopyFile(ctx context.Context, logger zerolog.Logger, taskID string, cmd api.Command) error {
var src, dest string
var ok bool
logger = logger.With().
Interface("command", cmd).
Str("src", src).
Str("dest", dest).
Logger()
if src, ok = cmdParameter[string](cmd, "src"); !ok || src == "" {
msg := "missing or empty 'src' parameter"
err := NewParameterMissingError("src", cmd)
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
if !filepath.IsAbs(src) {
msg := fmt.Sprintf("source path %q is not absolute, not copying anything", src)
err := NewParameterInvalidError("src", cmd, "path is not absolute")
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
if !fileExists(src) {
msg := fmt.Sprintf("source path %q does not exist, not copying anything", src)
err := NewParameterInvalidError("src", cmd, "path does not exist")
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
if dest, ok = cmdParameter[string](cmd, "dest"); !ok || dest == "" {
msg := "missing or empty 'dest' parameter"
err := NewParameterMissingError("dest", cmd)
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
if !filepath.IsAbs(dest) {
msg := fmt.Sprintf("destination path %q is not absolute, not copying anything", src)
err := NewParameterInvalidError("dest", cmd, "path is not absolute")
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
if fileExists(dest) {
msg := fmt.Sprintf("destination path %q already exists, not copying anything", dest)
err := NewParameterInvalidError("dest", cmd, "path already exists")
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, msg)
}
msg := fmt.Sprintf("copying %q to %q", src, dest)
if err := ce.errorLogProcess(ctx, logger, cmd, taskID, nil, msg); err != nil {
return err
}
err, logMsg := fileCopy(src, dest)
return ce.errorLogProcess(ctx, logger, cmd, taskID, err, logMsg)
}
func (ce *CommandExecutor) errorLogProcess(ctx context.Context, logger zerolog.Logger, cmd api.Command, taskID string, err error, logMsg string) error {
msg := fmt.Sprintf("%s: %s", cmd.Name, logMsg)
if err != nil {
logger.Warn().Msg(msg)
} else {
logger.Info().Msg(msg)
}
if logErr := ce.listener.LogProduced(ctx, taskID, msg); logErr != nil {
return logErr
}
return err
}
// moveAndLog renames a file/directory from `src` to `dest`, and logs the moveAndLog.
// The other parameters are just for logging.
func (ce *CommandExecutor) moveAndLog(ctx context.Context, taskID, cmdName, src, dest string) error {
msg := fmt.Sprintf("%s: moving %q to %q", cmdName, src, dest)
if err := ce.listener.LogProduced(ctx, taskID, msg); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
msg := fmt.Sprintf("%s: could not move %q to %q: %v", cmdName, src, dest, err)
if err := ce.listener.LogProduced(ctx, taskID, msg); err != nil {
return err
}
return err
}
return nil
}
func fileCopy(src, dest string) (error, string) {
src_file, err := os.Open(src)
if err != nil {
msg := fmt.Sprintf("failed to open source file %q: %v", src, err)
return err, msg
}
defer src_file.Close()
src_file_stat, err := src_file.Stat()
if err != nil {
msg := fmt.Sprintf("failed to stat source file %q: %v", src, err)
return err, msg
}
if !src_file_stat.Mode().IsRegular() {
err := &os.PathError{Op: "stat", Path: src, Err: errors.New("Not a regular file")}
msg := fmt.Sprintf("invalid source file %q: %v", src, err)
return err, msg
}
dest_dirpath := filepath.Dir(dest)
if !fileExists(dest_dirpath) {
if err := os.MkdirAll(dest_dirpath, 0750); err != nil {
msg := fmt.Sprintf("failed to create directories %q: %v", dest_dirpath, err)
return err, msg
}
}
dest_file, err := os.Create(dest)
if err != nil {
msg := fmt.Sprintf("failed to create destination file %q: %v", dest, err)
return err, msg
}
defer dest_file.Close()
if _, err := io.Copy(dest_file, src_file); err != nil {
msg := fmt.Sprintf("failed to copy %q to %q: %v", src, dest, err)
return err, msg
}
msg := fmt.Sprintf("copied %q to %q", src, dest)
return nil, msg
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !errors.Is(err, fs.ErrNotExist)
}
// timestampedPath returns the path with its modification time appended to the name.
func timestampedPath(filepath string) (string, error) {
stat, err := os.Stat(filepath)
if err != nil {
return "", fmt.Errorf("getting mtime of %s: %w", filepath, err)
}
// Round away the milliseconds, as those aren't all that interesting.
// Uniqueness can ensured by calling unique_path() later.
mtime := stat.ModTime().Round(time.Second)
iso := mtime.Local().Format("2006-01-02_150405") // YYYY-MM-DD_HHMMSS
return fmt.Sprintf("%s-%s", filepath, iso), nil
}
// uniquePath returns the path, or if it exists, the path with a unique suffix.
func uniquePath(path string) (string, error) {
matches, err := filepath.Glob(path + "-*")
if err != nil {
return "", err
}
suffixRe, err := regexp.Compile("-([0-9]+)$")
if err != nil {
return "", fmt.Errorf("compiling regular expression: %w", err)
}
var maxSuffix int64
for _, path := range matches {
matches := suffixRe.FindStringSubmatch(path)
if len(matches) < 2 {
continue
}
suffix := matches[1]
value, err := strconv.ParseInt(suffix, 10, 64)
if err != nil {
// Non-numeric suffixes are fine; they just don't count for this function.
continue
}
if value > maxSuffix {
maxSuffix = value
}
}
return fmt.Sprintf("%s-%03d", path, maxSuffix+1), nil
}