Files
tingz/internal/deploy/manager.go
2025-10-14 21:20:25 +03:00

216 lines
5.3 KiB
Go

package deploy
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type Manager struct {
deployRoot string
releaseRoot string
releasesToKeep int
logger *slog.Logger
}
func NewManager(deployRoot, releaseRoot string, releasesToKeep int, logger *slog.Logger) *Manager {
return &Manager{
deployRoot: deployRoot,
releaseRoot: releaseRoot,
releasesToKeep: releasesToKeep,
logger: logger,
}
}
func (m *Manager) Deploy(ctx context.Context, username, project string, r io.Reader) (string, error) {
releaseID := time.Now().UTC().Format("20060102T150405")
m.logger.Info("starting deployment",
slog.String("username", username),
slog.String("project", project),
slog.String("release_id", releaseID))
tmpFile, err := os.CreateTemp("", fmt.Sprintf("deployer-upload-%s-%s-*.tar.gz", username, releaseID))
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
written, err := io.Copy(tmpFile, r)
if err != nil {
return "", fmt.Errorf("failed to write tarball: %w", err)
}
m.logger.Debug("tarball saved to temp file",
slog.String("path", tmpFile.Name()),
slog.Int64("bytes", written))
if _, err := tmpFile.Seek(0, 0); err != nil {
return "", fmt.Errorf("failed to seek temp file: %w", err)
}
releasePath := filepath.Join(m.releaseRoot, username, project, releaseID)
if err := os.MkdirAll(releasePath, 0755); err != nil {
return "", fmt.Errorf("failed to create release directory: %w", err)
}
if err := m.extractTarball(tmpFile, releasePath); err != nil {
os.RemoveAll(releasePath)
return "", fmt.Errorf("failed to extract tarball: %w", err)
}
deployPath := filepath.Join(m.deployRoot, username, project)
deployParentDir := filepath.Dir(deployPath)
if err := os.MkdirAll(deployParentDir, 0755); err != nil {
return "", fmt.Errorf("failed to create deploy parent directory: %w", err)
}
tmpLink := deployPath + ".tmp." + releaseID
if err := os.Symlink(releasePath, tmpLink); err != nil {
return "", fmt.Errorf("failed to create temporary symlink: %w", err)
}
if err := os.Rename(tmpLink, deployPath); err != nil {
os.Remove(tmpLink)
return "", fmt.Errorf("failed to swap symlink: %w", err)
}
m.logger.Info("deployment successful",
slog.String("username", username),
slog.String("project", project),
slog.String("release_id", releaseID))
if err := m.cleanupOldReleases(username, project); err != nil {
m.logger.Warn("failed to cleanup old releases",
slog.String("error", err.Error()))
}
return releaseID, nil
}
func (m *Manager) extractTarball(r io.Reader, destDir string) error {
gzr, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar header: %w", err)
}
if err := validateTarPath(header.Name); err != nil {
return err
}
cleanName := filepath.Clean(header.Name)
if cleanName == "." || cleanName == ".." {
continue
}
target := filepath.Join(destDir, cleanName)
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("path traversal detected: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return fmt.Errorf("failed to write file: %w", err)
}
f.Close()
}
}
return nil
}
func validateTarPath(path string) error {
clean := filepath.Clean(path)
parts := strings.Split(clean, string(filepath.Separator))
for _, part := range parts {
if part == ".." {
return fmt.Errorf("path contains '..' component which is not allowed: %s", path)
}
}
if filepath.IsAbs(path) {
return fmt.Errorf("absolute paths not allowed: %s", path)
}
return nil
}
func (m *Manager) cleanupOldReleases(username, project string) error {
releasesDir := filepath.Join(m.releaseRoot, username, project)
entries, err := os.ReadDir(releasesDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read releases directory: %w", err)
}
var releases []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
releases = append(releases, entry)
}
}
if len(releases) <= m.releasesToKeep {
return nil
}
sort.Slice(releases, func(i, j int) bool {
return releases[i].Name() > releases[j].Name()
})
for i := m.releasesToKeep; i < len(releases); i++ {
oldReleasePath := filepath.Join(releasesDir, releases[i].Name())
m.logger.Info("removing old release",
slog.String("path", oldReleasePath))
if err := os.RemoveAll(oldReleasePath); err != nil {
m.logger.Warn("failed to remove old release",
slog.String("path", oldReleasePath),
slog.String("error", err.Error()))
}
}
return nil
}