feat: build runtime releases from pinned Git branch commits
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type releaseManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
DeploymentSchema int `json:"deployment_schema"`
|
||||
Platforms []string `json:"platforms"`
|
||||
}
|
||||
type releaseInfo struct {
|
||||
releaseManifest
|
||||
Repository string `json:"source_repository"`
|
||||
Commit string `json:"source_commit"`
|
||||
}
|
||||
|
||||
var errReleaseIncompatible = errors.New("release layout or platform is incompatible")
|
||||
|
||||
var releaseCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
var releaseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
const releaseLimit = 32 << 20
|
||||
|
||||
func releaseSource() (string, string, error) {
|
||||
repository := os.Getenv("DSH_RELEASE_REPOSITORY")
|
||||
if repository == "" {
|
||||
repository = "https://git.misaka.ren/m1saka/dsh-web.git"
|
||||
}
|
||||
branch := os.Getenv("DSH_RELEASE_BRANCH")
|
||||
if branch == "" {
|
||||
branch = "dockerfile"
|
||||
}
|
||||
u, err := url.Parse(repository)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(repository, "\r\n") || !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(branch) || strings.Contains(branch, "..") {
|
||||
return "", "", errors.New("invalid trusted release source configuration")
|
||||
}
|
||||
return repository, branch, nil
|
||||
}
|
||||
|
||||
type boundedReleaseBuffer struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedReleaseBuffer) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > releaseLimit {
|
||||
return 0, errors.New("release exceeds size limit")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
func gitReleaseCommand(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
gitArgs := append([]string{"-c", "credential.helper=", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "fetch.unpackLimit=1"}, args...)
|
||||
// The inherited file-size limit also bounds Git's on-disk pack, not just stdout.
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", append([]string{"-c", "ulimit -f 32768; exec git \"$@\"", "git"}, gitArgs...)...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
cmd.Dir = dir
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_LFS_SKIP_SMUDGE=1"}
|
||||
cmd.WaitDelay = time.Second
|
||||
var out boundedReleaseBuffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.New("trusted release Git operation failed")
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
var releaseFetchSlot = make(chan struct{}, 1)
|
||||
|
||||
func readRelease(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
||||
var info releaseInfo
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case releaseFetchSlot <- struct{}{}:
|
||||
defer func() { <-releaseFetchSlot }()
|
||||
case <-ctx.Done():
|
||||
return info, nil, ctx.Err()
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "dsh-release-")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if _, err = gitReleaseCommand(ctx, dir, "init", "--quiet"); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if _, err = gitReleaseCommand(ctx, dir, "fetch", "--quiet", "--depth=1", "--no-tags", repository, ref); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
commit, err := gitReleaseCommand(ctx, dir, "rev-parse", "FETCH_HEAD^{commit}")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
info.Commit = strings.TrimSpace(string(commit))
|
||||
info.Repository = repository
|
||||
if !releaseCommit.MatchString(info.Commit) || (releaseCommit.MatchString(ref) && info.Commit != ref) {
|
||||
return info, nil, errors.New("release commit mismatch")
|
||||
}
|
||||
archive, err := gitReleaseCommand(ctx, dir, "archive", "--format=tar", info.Commit, "--", "release.json", "image")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
return parseReleaseArchive(info, archive)
|
||||
}
|
||||
|
||||
func parseReleaseArchive(info releaseInfo, archive []byte) (releaseInfo, io.Reader, error) {
|
||||
var manifest []byte
|
||||
tr := tar.NewReader(bytes.NewReader(archive))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if h.Name == "release.json" && h.Typeflag == tar.TypeReg {
|
||||
if h.Size > 16384 {
|
||||
return info, nil, errors.New("release manifest too large")
|
||||
}
|
||||
manifest, err = io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(manifest))
|
||||
dec.DisallowUnknownFields()
|
||||
if dec.Decode(&info.releaseManifest) != nil || dec.Decode(new(any)) != io.EOF {
|
||||
return info, nil, errors.New("invalid release manifest")
|
||||
}
|
||||
m := info.releaseManifest
|
||||
compatible := false
|
||||
for _, p := range m.Platforms {
|
||||
if p == imagePlatform {
|
||||
compatible = true
|
||||
}
|
||||
}
|
||||
if m.SchemaVersion != 1 || !releaseID.MatchString(m.ReleaseID) || !validBuildVersion(m.Version) || m.DeploymentSchema < 1 || len(m.Platforms) == 0 {
|
||||
return info, nil, errors.New("unsupported release manifest or platform")
|
||||
}
|
||||
if m.DeploymentSchema != 7 || !compatible {
|
||||
return info, nil, errReleaseIncompatible
|
||||
}
|
||||
var out bytes.Buffer
|
||||
tw := tar.NewWriter(&out)
|
||||
tr = tar.NewReader(bytes.NewReader(archive))
|
||||
dockerfile := false
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
name := h.Name
|
||||
if !strings.HasPrefix(name, "image/") {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimPrefix(name, "image/")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if path.IsAbs(name) || path.Clean(strings.TrimSuffix(name, "/")) != strings.TrimSuffix(name, "/") || name == ".." || strings.HasPrefix(name, "../") {
|
||||
return info, nil, errors.New("unsafe release context path")
|
||||
}
|
||||
excluded := false
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".git" || part == ".env" || strings.HasPrefix(part, ".env.") || part == ".netrc" || part == ".npmrc" || part == ".ssh" || part == ".git-credentials" {
|
||||
excluded = true
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir {
|
||||
return info, nil, errors.New("release context links and special files are not supported")
|
||||
}
|
||||
h.Name = name
|
||||
h.PAXRecords = nil
|
||||
h.Uname = ""
|
||||
h.Gname = ""
|
||||
h.Uid = 0
|
||||
h.Gid = 0
|
||||
if name == "Dockerfile" && h.Typeflag == tar.TypeReg {
|
||||
dockerfile = true
|
||||
}
|
||||
if err = tw.WriteHeader(h); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if _, err = io.Copy(tw, tr); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
}
|
||||
if !dockerfile {
|
||||
return info, nil, errors.New("release context Dockerfile missing")
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
return info, bytes.NewReader(out.Bytes()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user