mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Git-sourced deploys never materialized a checkout: sourcePath was build_path resolved against the server cwd, so builds consumed the backend's own directory. Remote placement could only run registry-pulled images. - internal/source: per-deployment checkout (init/fetch/checkout) at branch or commit; GIT_ASKPASS keeps tokens out of argv, .git/config, and the build context; dumb-transport and SHA-refusal fallbacks; build_path validated inside the checkout. - BuildManager.PackageContext: produces the same tar context Build ships to the daemon — generated Dockerfile at tar root, .git excluded. Fixes nixpacks passing the Dockerfile path as content. - Agent artifact endpoint: GET /api/agents/artifacts/:id streams build contexts and docker-save tarballs behind agent-token auth; hex-validated ids, per-deployment cleanup, boot-time purge. - Agent commands: build_image (docker build -, --build-arg values via process env, not argv) and load_image (docker load for containr-* rollback tags no registry serves). - Deployment flow: remote git builds package context → artifact → node docker-builds the deployment tag; remote rollbacks of local tags export via SaveImage → artifact → node docker-loads. Verified end-to-end: agent-pinned git service built on the node (build_image → create_container → deployed) and rollback shipped a docker-save tarball (load_image → deployed). Artifact auth returns 401/404 correctly; workspaces and blobs are removed after deploy. Authored By: TDvorak <[email protected]>
191 lines
6.0 KiB
Go
191 lines
6.0 KiB
Go
// Package source materializes git checkouts for builds. Services reference
|
|
// a repository by URL (or owner/repo); deployments fetch the exact branch or
|
|
// commit into an isolated workdir, then point the builders at build_path
|
|
// inside it. Credentials travel through GIT_ASKPASS so tokens never land in
|
|
// argv, .git/config, logs, or the build context.
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Spec describes one checkout.
|
|
type Spec struct {
|
|
CloneURL string // https URL (NormalizeCloneURL output); local paths allowed for tests
|
|
Branch string // ref to fetch when Commit is empty
|
|
Commit string // optional SHA pinned over Branch
|
|
BuildPath string // subdirectory inside the repo; "" or "." = repo root
|
|
Token string // optional http credential
|
|
User string // credential username; defaults per provider in ResolveUser
|
|
}
|
|
|
|
// NormalizeCloneURL converts the forms services store into an https clone
|
|
// URL: ssh remotes (git@host:org/repo, ssh://git@host/org/repo) map onto
|
|
// https so token auth works uniformly. A bare "owner/repo" is returned
|
|
// unchanged — the caller resolves the host through git_repositories.
|
|
func NormalizeCloneURL(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", fmt.Errorf("empty repository reference")
|
|
}
|
|
// scp-style ssh remote: [email protected]:org/repo(.git)
|
|
if strings.HasPrefix(raw, "git@") && strings.Contains(raw, ":") {
|
|
host := raw[len("git@"):strings.Index(raw, ":")]
|
|
path := strings.TrimPrefix(raw[strings.Index(raw, ":")+1:], "/")
|
|
return "https://" + host + "/" + path, nil
|
|
}
|
|
if strings.HasPrefix(raw, "ssh://") {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid ssh remote %q", raw)
|
|
}
|
|
return "https://" + u.Host + u.Path, nil
|
|
}
|
|
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
|
return raw, nil
|
|
}
|
|
if strings.Contains(raw, "://") {
|
|
return "", fmt.Errorf("unsupported repository scheme in %q", raw)
|
|
}
|
|
if strings.Count(raw, "/") == 1 && !strings.ContainsAny(raw, " @:") {
|
|
return raw, nil // owner/repo — resolved against git_repositories upstream
|
|
}
|
|
return "", fmt.Errorf("unsupported repository reference %q", raw)
|
|
}
|
|
|
|
// ValidateRemoteURL rejects non-http(s) clones before a deploy accepts them.
|
|
// Local checkouts (tests, mounted workspaces) bypass this via Checkout.
|
|
func ValidateRemoteURL(cloneURL string) error {
|
|
if strings.HasPrefix(cloneURL, "http://") || strings.HasPrefix(cloneURL, "https://") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("clone URL must be http(s), got %q", cloneURL)
|
|
}
|
|
|
|
// Checkout fetches spec into destDir/repo and returns the directory the
|
|
// builders should consume (checkout root + build_path). Callers
|
|
// RemoveAll(destDir) — the checkout is a per-deployment workspace, never
|
|
// shared. The askpass helper lives outside repo so it can't leak into the
|
|
// build context.
|
|
func Checkout(ctx context.Context, destDir string, spec Spec) (string, error) {
|
|
repoDir := filepath.Join(destDir, "repo")
|
|
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
|
if spec.Token != "" {
|
|
// Askpass keeps the token out of argv and .git/config.
|
|
askpass := filepath.Join(destDir, ".git-askpass")
|
|
script := "#!/bin/sh\ncase \"$1\" in\n*sername*) printf '%s' \"$CONTAINR_GIT_USER\" ;;\n*) printf '%s' \"$CONTAINR_GIT_TOKEN\" ;;\nesac\n"
|
|
if err := os.WriteFile(askpass, []byte(script), 0o700); err != nil {
|
|
return "", fmt.Errorf("write askpass: %w", err)
|
|
}
|
|
user := spec.User
|
|
if user == "" {
|
|
user = "x-access-token"
|
|
}
|
|
env = append(env,
|
|
"GIT_ASKPASS="+askpass,
|
|
"CONTAINR_GIT_USER="+user,
|
|
"CONTAINR_GIT_TOKEN="+spec.Token,
|
|
)
|
|
}
|
|
|
|
run := func(args ...string) error {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Dir = repoDir
|
|
cmd.Env = env
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("git %s: %s", args[0], strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := run("init", "-q"); err != nil {
|
|
return "", err
|
|
}
|
|
if err := run("remote", "add", "origin", spec.CloneURL); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ref := spec.Commit
|
|
if ref == "" {
|
|
ref = spec.Branch
|
|
}
|
|
if ref == "" {
|
|
ref = "HEAD"
|
|
}
|
|
// Cheap path first, widening on failure: dumb transports reject
|
|
// --depth outright, and some hosts refuse direct SHA fetches so a
|
|
// pinned commit may only be reachable through its branch.
|
|
attempts := [][]string{
|
|
{"fetch", "--depth", "1", "origin", ref},
|
|
{"fetch", "origin", ref},
|
|
}
|
|
if spec.Commit != "" {
|
|
branchRef := spec.Branch
|
|
if branchRef == "" {
|
|
branchRef = "HEAD"
|
|
}
|
|
attempts = append(attempts,
|
|
[]string{"fetch", "--depth", "100", "origin", branchRef},
|
|
[]string{"fetch", "origin", branchRef},
|
|
)
|
|
}
|
|
var fetchErr error
|
|
for _, args := range attempts {
|
|
if fetchErr = run(args...); fetchErr == nil {
|
|
break
|
|
}
|
|
}
|
|
if fetchErr != nil {
|
|
return "", fetchErr
|
|
}
|
|
if err := run("checkout", "-q", spec.CommitOrFetchHead()); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return ResolveBuildPath(repoDir, spec.BuildPath)
|
|
}
|
|
|
|
// CommitOrFetchHead is the checkout target: the pinned commit or FETCH_HEAD.
|
|
func (s Spec) CommitOrFetchHead() string {
|
|
if s.Commit != "" {
|
|
return s.Commit
|
|
}
|
|
return "FETCH_HEAD"
|
|
}
|
|
|
|
// ResolveBuildPath maps build_path onto the checkout and rejects escapes —
|
|
// a path like ../../etc must never leave the workspace.
|
|
func ResolveBuildPath(checkoutDir, buildPath string) (string, error) {
|
|
bp := strings.TrimSpace(buildPath)
|
|
if bp == "" || bp == "." {
|
|
return checkoutDir, nil
|
|
}
|
|
if filepath.IsAbs(bp) {
|
|
return "", fmt.Errorf("build_path must be relative, got %q", bp)
|
|
}
|
|
clean := filepath.Clean(bp)
|
|
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("build_path escapes the checkout: %q", bp)
|
|
}
|
|
resolved := filepath.Join(checkoutDir, clean)
|
|
info, err := os.Stat(resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build_path %q not found in checkout", bp)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("build_path %q is not a directory", bp)
|
|
}
|
|
return resolved, nil
|
|
}
|