216 lines
5.3 KiB
Go
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
|
|
}
|
|
|