Files
sub2api-cn-relay-manager/internal/overlay/executor.go

258 lines
6.9 KiB
Go

package overlay
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"sub2api-cn-relay-manager/internal/pack"
)
const metadataFileName = ".sub2api-cn-relay-manager-overlay.json"
type ApplyRequest struct {
PackDir string
SourceDir string
OutputDir string
Overlays []pack.HostOverlay
}
type ApplyResult struct {
OutputDir string
AppliedOverlays []pack.HostOverlay
MetadataFilePath string
}
func Apply(ctx context.Context, req ApplyRequest) (_ ApplyResult, err error) {
packDir := strings.TrimSpace(req.PackDir)
sourceDir := strings.TrimSpace(req.SourceDir)
if packDir == "" {
return ApplyResult{}, fmt.Errorf("pack dir is required")
}
if sourceDir == "" {
return ApplyResult{}, fmt.Errorf("source dir is required")
}
if len(req.Overlays) == 0 {
return ApplyResult{}, fmt.Errorf("at least one host overlay is required")
}
packAbs, err := filepath.Abs(packDir)
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve pack dir: %w", err)
}
sourceAbs, err := filepath.Abs(sourceDir)
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve source dir: %w", err)
}
sourceInfo, err := os.Stat(sourceAbs)
if err != nil {
return ApplyResult{}, fmt.Errorf("stat source dir: %w", err)
}
if !sourceInfo.IsDir() {
return ApplyResult{}, fmt.Errorf("source dir %q must be a directory", sourceAbs)
}
outputDir := strings.TrimSpace(req.OutputDir)
if outputDir == "" {
outputDir = defaultOutputDir(sourceAbs, req.Overlays)
}
outputAbs, err := filepath.Abs(outputDir)
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve output dir: %w", err)
}
if outputAbs == sourceAbs {
return ApplyResult{}, fmt.Errorf("output dir must differ from source dir")
}
if isPathWithin(outputAbs, sourceAbs) {
return ApplyResult{}, fmt.Errorf("output dir %q must not be nested inside source dir %q", outputAbs, sourceAbs)
}
if _, err := os.Stat(outputAbs); err == nil {
return ApplyResult{}, fmt.Errorf("output dir %q already exists", outputAbs)
} else if !os.IsNotExist(err) {
return ApplyResult{}, fmt.Errorf("stat output dir: %w", err)
}
if err := copyTree(sourceAbs, outputAbs); err != nil {
return ApplyResult{}, fmt.Errorf("copy source dir: %w", err)
}
cleanupOutput := true
defer func() {
if cleanupOutput {
_ = os.RemoveAll(outputAbs)
}
}()
for _, hostOverlay := range req.Overlays {
patchPath := strings.TrimSpace(hostOverlay.PatchPath)
if patchPath == "" {
return ApplyResult{}, fmt.Errorf("overlay %q does not define patch_path", hostOverlay.OverlayID)
}
patchAbs := filepath.Join(packAbs, patchPath)
if err := applyPatchFile(ctx, outputAbs, patchAbs); err != nil {
return ApplyResult{}, fmt.Errorf("apply overlay %q: %w", hostOverlay.OverlayID, err)
}
}
metadataPath := filepath.Join(outputAbs, metadataFileName)
if err := writeMetadata(metadataPath, sourceAbs, req.Overlays); err != nil {
return ApplyResult{}, fmt.Errorf("write overlay metadata: %w", err)
}
cleanupOutput = false
return ApplyResult{
OutputDir: outputAbs,
AppliedOverlays: append([]pack.HostOverlay(nil), req.Overlays...),
MetadataFilePath: metadataPath,
}, nil
}
func FilterOverlays(overlays []pack.HostOverlay, overlayID string) ([]pack.HostOverlay, error) {
trimmedOverlayID := strings.TrimSpace(overlayID)
if trimmedOverlayID == "" {
return append([]pack.HostOverlay(nil), overlays...), nil
}
filtered := make([]pack.HostOverlay, 0, len(overlays))
for _, hostOverlay := range overlays {
if strings.TrimSpace(hostOverlay.OverlayID) == trimmedOverlayID {
filtered = append(filtered, hostOverlay)
}
}
if len(filtered) == 0 {
return nil, fmt.Errorf("overlay %q did not match any resolved host overlays", trimmedOverlayID)
}
return filtered, nil
}
func applyPatchFile(ctx context.Context, outputDir string, patchPath string) error {
if _, err := os.Stat(patchPath); err != nil {
return fmt.Errorf("stat patch file %q: %w", patchPath, err)
}
cmd := exec.CommandContext(ctx, "patch", "-p1", "-i", patchPath, "-d", outputDir)
output, err := cmd.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf("%s", message)
}
return nil
}
func writeMetadata(path string, sourceDir string, overlays []pack.HostOverlay) error {
body, err := json.MarshalIndent(map[string]any{
"source_dir": sourceDir,
"applied_overlays": overlays,
}, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(body, '\n'), 0o644)
}
func defaultOutputDir(sourceDir string, overlays []pack.HostOverlay) string {
baseName := filepath.Base(sourceDir)
overlaySuffix := "overlay"
if len(overlays) > 0 {
overlaySuffix = sanitizePathToken(overlays[0].OverlayID)
if overlaySuffix == "" {
overlaySuffix = "overlay"
}
}
return filepath.Join(filepath.Dir(sourceDir), baseName+"-patched-"+overlaySuffix)
}
func sanitizePathToken(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case !lastDash:
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func isPathWithin(target string, root string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func copyTree(sourceDir string, outputDir string) error {
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return err
}
return filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
targetPath := filepath.Join(outputDir, rel)
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
return os.MkdirAll(targetPath, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(linkTarget, targetPath)
default:
return copyFile(path, targetPath, info.Mode().Perm())
}
})
}
func copyFile(sourcePath string, targetPath string, perm fs.FileMode) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
sourceFile, err := os.Open(sourcePath)
if err != nil {
return err
}
defer sourceFile.Close()
targetFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm)
if err != nil {
return err
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, sourceFile); err != nil {
return err
}
return nil
}