Properly abstract deployment asset downlod

This commit is contained in:
2026-05-05 17:56:02 +02:00
parent 596e94794e
commit ce4ed9744b
4 changed files with 52 additions and 13 deletions
+18
View File
@@ -0,0 +1,18 @@
package gitprovider
import (
"fmt"
"strings"
)
func NewProvider(serverType, protocol, baseUrl string) (GitProvider, error) {
switch strings.ToLower(serverType) {
case "github":
return &GitHubProvider{
protocol: protocol,
baseUrl: baseUrl,
}, nil
default:
return nil, fmt.Errorf("unsupported git provider: %s", serverType)
}
}
+244
View File
@@ -0,0 +1,244 @@
package gitprovider
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
)
type GitHubProvider struct {
protocol string
baseUrl string
}
var _ GitProvider = (*GitHubProvider)(nil)
func (p *GitHubProvider) FetchAndDeployBranch(owner, repo, branch, authToken, destPath string) (*DeployResult, error) {
baseUrl := strings.TrimSuffix(p.baseUrl, "/")
apiUrl := fmt.Sprintf("%s://api.%s/", p.protocol, baseUrl)
result, err := fetchBranchHead(apiUrl, owner, repo, branch, authToken)
if err != nil {
return nil, fmt.Errorf("fetching branch head: %w", err)
}
if err = downloadAndExtract(owner, repo, branch, authToken, destPath); err != nil {
return nil, err
}
return result, nil
}
// fetchBranchHead returns the SHA and commit message for the tip of the given branch.
func fetchBranchHead(apiUrl, owner, repo, branch, pat string) (*DeployResult, error) {
url := fmt.Sprintf(
"%srepos/%s/%s/commits/%s",
apiUrl, owner, repo, branch,
)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+pat)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching commit: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub returned %s for %s", resp.Status, url)
}
var payload struct {
SHA string `json:"sha"`
Commit struct {
Message string `json:"message"`
} `json:"commit"`
}
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decoding commit response: %w", err)
}
return &DeployResult{
CommitHash: payload.SHA,
CommitMessage: strings.SplitN(payload.Commit.Message, "\n", 2)[0],
}, nil
}
func downloadAndExtract(repoOwner, repoName, branch, pat, destDir string) error {
archiveURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s/zipball/%s",
repoOwner, repoName, branch,
)
req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+pat)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetching archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub returned %s for %s", resp.Status, archiveURL)
}
storageRoot := filepath.Dir(destDir)
tmpZip, err := os.CreateTemp(storageRoot, "ghbranch-*.zip")
if err != nil {
return fmt.Errorf("creating temp zip: %w", err)
}
defer os.Remove(tmpZip.Name())
if _, err = io.Copy(tmpZip, resp.Body); err != nil {
tmpZip.Close()
return fmt.Errorf("writing zip: %w", err)
}
tmpZip.Close()
tmpDir, err := os.MkdirTemp(storageRoot, "ghbranch-unpack-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err = unzip(tmpZip.Name(), tmpDir); err != nil {
return fmt.Errorf("unzipping: %w", err)
}
// GitHub wraps everything in one top-level folder ({owner}-{repo}-{sha}/).
entries, err := os.ReadDir(tmpDir)
if err != nil {
return fmt.Errorf("reading temp dir: %w", err)
}
if len(entries) != 1 || !entries[0].IsDir() {
return fmt.Errorf("unexpected archive layout: expected a single root directory")
}
extractedRoot := filepath.Join(tmpDir, entries[0].Name())
if err = os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
return fmt.Errorf("creating destination parent dirs: %w", err)
}
os.RemoveAll(destDir)
if err = os.Rename(extractedRoot, destDir); err == nil {
return nil
}
if err = copyDir(extractedRoot, destDir); err != nil {
return fmt.Errorf("cross-device copy to destination: %w", err)
}
return nil
}
// unzip extracts the zip at src into the dest directory,
// guarding against zip-slip path traversal attacks.
func unzip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
destClean := filepath.Clean(dest) + string(os.PathSeparator)
for _, f := range r.File {
target := filepath.Join(dest, filepath.Clean("/"+f.Name))
if !strings.HasPrefix(target, destClean) {
return fmt.Errorf("zip slip attempt: %q resolves outside destination", f.Name)
}
if f.FileInfo().IsDir() {
if err = os.MkdirAll(target, f.Mode()); err != nil {
return err
}
continue
}
if err = os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err = writeZipEntry(f, target); err != nil {
return err
}
}
return nil
}
func writeZipEntry(f *zip.File, target string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
// copyDir recursively copies src to dst, preserving file modes.
// Used as a cross-device fallback when os.Rename fails.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
return copyFile(path, target, info.Mode())
})
}
func copyFile(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+10
View File
@@ -0,0 +1,10 @@
package gitprovider
type DeployResult struct {
CommitHash string
CommitMessage string
}
type GitProvider interface {
FetchAndDeployBranch(owner, repo, branch, authToken, destPath string) (*DeployResult, error)
}