initial commit
This commit is contained in:
215
internal/deploy/manager.go
Normal file
215
internal/deploy/manager.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user