Files
Containr/app/backend/internal/source/source.go
Tomáš Dvořák 3cc84553f4 feat: build-on-node — git checkouts, remote image builds, image transport (#60)
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]>
2026-10-09 20:30:33 +02:00

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
}