258 lines
6.9 KiB
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
|
|
}
|