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) } if err := m.lockdownPermissions(releasePath); err != nil { return "", fmt.Errorf("failed to set read-only permissions: %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) } relPath, err := filepath.Rel(deployParentDir, releasePath) if err != nil { return "", fmt.Errorf("failed to calculate relative path: %w", err) } tmpLink := deployPath + ".tmp." + releaseID if err := os.Symlink(relPath, 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) lockdownPermissions(root string) error { return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if err := os.Chmod(path, 0550); err != nil { return fmt.Errorf("failed to chmod directory %s: %w", path, err) } } else { if err := os.Chmod(path, 0440); err != nil { return fmt.Errorf("failed to chmod file %s: %w", path, err) } } 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 }