mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: Update AppSidebar to include new Environments and Credentials sections feat: Enhance node form with branch selection for triggers and deployment feat: Create CredentialForm and CredentialRow components for managing credentials feat: Add API endpoints for credential management fix: Update node catalog defaults for deploy and promote nodes
541 lines
14 KiB
Go
541 lines
14 KiB
Go
package executors
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lyzrai/flow/pkg/engine"
|
|
"github.com/lyzrai/flow/pkg/github"
|
|
"github.com/lyzrai/flow/pkg/models"
|
|
"github.com/lyzrai/flow/pkg/storage"
|
|
)
|
|
|
|
// BuildExecutor clones the agent's repo and either runs a user shell
|
|
// command (mode=shell) or solves a Dockerfile via BuildKit and pushes the
|
|
// resulting image (mode=docker). Used by the Langship Build pipeline node.
|
|
//
|
|
// Common parameters:
|
|
// - mode "docker" (default) | "shell"
|
|
// - timeoutSeconds number hard timeout (default: 600)
|
|
//
|
|
// docker-mode parameters:
|
|
// - dockerfile string path to Dockerfile (default "Dockerfile")
|
|
// - context string build context dir relative to clone (default ".")
|
|
// - imageName string image name; default "<owner>/<repo>" from agent
|
|
// - registry string registry host (default "localhost:5000")
|
|
// - platform string target platform (default "linux/amd64")
|
|
// - buildArgs string "k=v, k=v" CSV
|
|
//
|
|
// shell-mode parameters:
|
|
// - command string shell command (default: docker build .)
|
|
// - workdir string relative dir inside the clone (default ".")
|
|
//
|
|
// Trigger payload (from the executing pipeline):
|
|
// - agentId string (required) — provided by Server.dispatchAgent / webhook
|
|
// - commit string sha to check out; empty = use the agent's configured ref
|
|
// - ref string git ref override
|
|
//
|
|
// On success the executor emits each input item with a __build object
|
|
// describing the run. On failure it returns an error so the runner halts.
|
|
type BuildExecutor struct {
|
|
Agents storage.AgentStore
|
|
}
|
|
|
|
func (e *BuildExecutor) Execute(ctx context.Context, node models.NodeDef, inputs [][]models.Item, _ *engine.ExecutionContext) (map[int][]models.Item, error) {
|
|
if e.Agents == nil {
|
|
return nil, errors.New("build executor: AgentStore not configured")
|
|
}
|
|
|
|
trigger := firstItem(inputs)
|
|
agentID, _ := trigger["agentId"].(string)
|
|
if agentID == "" {
|
|
return nil, errors.New("build: trigger payload missing agentId")
|
|
}
|
|
a, err := e.Agents.Get(ctx, agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build: load agent %q: %w", agentID, err)
|
|
}
|
|
|
|
mode := strParam(node.Parameters, "mode", "docker")
|
|
timeoutSec := intParam(node.Parameters, "timeoutSeconds", 600)
|
|
if timeoutSec < 10 {
|
|
timeoutSec = 10
|
|
}
|
|
if timeoutSec > 3600 {
|
|
timeoutSec = 3600
|
|
}
|
|
|
|
commitSHA, _ := trigger["commit"].(string)
|
|
// `git clone --branch` wants a bare name like "main"; if a webhook
|
|
// payload (or older trigger record) carried "refs/heads/main", trim it
|
|
// so the clone doesn't fail with "Remote branch refs/heads/main not
|
|
// found in upstream origin".
|
|
// Prefer the Trigger node's explicit `fromBranch` over the older
|
|
// webhook `ref` field, then fall back to the agent's configured ref.
|
|
ref := stripRefsHeads(strFirst(
|
|
strFromAny(trigger["fromBranch"]),
|
|
strFromAny(trigger["ref"]),
|
|
a.Ref,
|
|
"main",
|
|
))
|
|
|
|
cloneDir, cleanup, err := cloneRepo(ctx, a, ref, commitSHA, time.Duration(timeoutSec)*time.Second)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cleanup()
|
|
|
|
hardCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
|
|
var (
|
|
exitCode int
|
|
logTail string
|
|
runErr error
|
|
summary map[string]any
|
|
)
|
|
|
|
switch mode {
|
|
case "shell":
|
|
exitCode, logTail, runErr = runShellMode(hardCtx, node, a, cloneDir, ref, commitSHA)
|
|
summary = map[string]any{
|
|
"mode": "shell",
|
|
"command": strParam(node.Parameters, "command", ""),
|
|
"exit_code": exitCode,
|
|
"log_tail": logTail,
|
|
}
|
|
case "docker", "":
|
|
summary, logTail, runErr = runDockerMode(hardCtx, node, a, cloneDir, ref, commitSHA)
|
|
default:
|
|
return nil, fmt.Errorf("build: unknown mode %q (want docker|shell)", mode)
|
|
}
|
|
|
|
out := make([]models.Item, 0, max(1, totalLen(inputs)))
|
|
base := map[string]any{
|
|
"agent_id": agentID,
|
|
"agent_name": a.Name,
|
|
"repo_url": a.RepoURL,
|
|
"ref": ref,
|
|
"commit": commitSHA,
|
|
"finished_at": time.Now().UTC(),
|
|
}
|
|
for k, v := range summary {
|
|
base[k] = v
|
|
}
|
|
|
|
for _, in := range inputs {
|
|
for _, item := range in {
|
|
ci := copyItem(item)
|
|
ci["__build"] = base
|
|
out = append(out, ci)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
out = append(out, models.Item{"__build": base})
|
|
}
|
|
|
|
if runErr != nil {
|
|
return nil, fmt.Errorf("build (%s) failed: %w", mode, runErr)
|
|
}
|
|
return map[int][]models.Item{0: out}, nil
|
|
}
|
|
|
|
// --- shell mode ----------------------------------------------------------
|
|
|
|
func runShellMode(ctx context.Context, node models.NodeDef, a *storage.Agent, cloneDir, ref, commitSHA string) (int, string, error) {
|
|
command := strParam(node.Parameters, "command", "docker build -t agent:latest .")
|
|
workdirRel := strParam(node.Parameters, "workdir", ".")
|
|
workdir := filepath.Join(cloneDir, filepath.Clean("/"+workdirRel))
|
|
|
|
env := append(os.Environ(),
|
|
"AGENT_NAME="+sanitizeEnvValue(a.Name),
|
|
"REPO_URL="+a.RepoURL,
|
|
"REF="+ref,
|
|
"COMMIT_SHA="+commitSHA,
|
|
)
|
|
|
|
slog.InfoContext(ctx, "build_run_shell",
|
|
slog.String("node", node.Name),
|
|
slog.String("command", command),
|
|
slog.String("workdir", workdir),
|
|
)
|
|
|
|
return runShellCapture(ctx, workdir, env, command)
|
|
}
|
|
|
|
// --- docker (BuildKit) mode ---------------------------------------------
|
|
|
|
func runDockerMode(ctx context.Context, node models.NodeDef, a *storage.Agent, cloneDir, ref, commitSHA string) (map[string]any, string, error) {
|
|
bkAddr := os.Getenv("BUILDKIT_HOST")
|
|
if bkAddr == "" {
|
|
// docker-compose publishes buildkitd on 127.0.0.1:1234, so a host
|
|
// `make watch` can reach it without extra config. In docker-compose,
|
|
// the flow service overrides this with tcp://buildkitd:1234.
|
|
bkAddr = "tcp://127.0.0.1:1234"
|
|
}
|
|
|
|
dockerfile := strParam(node.Parameters, "dockerfile", "Dockerfile")
|
|
contextRel := strParam(node.Parameters, "context", ".")
|
|
imageName := strParam(node.Parameters, "imageName", "")
|
|
registry := strParam(node.Parameters, "registry", "registry:5000")
|
|
platform := strParam(node.Parameters, "platform", "linux/amd64")
|
|
buildArgs := parseKVCSV(strParam(node.Parameters, "buildArgs", ""))
|
|
|
|
if imageName == "" {
|
|
imageName = a.Name // "owner/repo"
|
|
}
|
|
tag := commitSHA
|
|
if tag == "" {
|
|
tag = "latest"
|
|
}
|
|
imageRef := fmt.Sprintf("%s/%s:%s", strings.TrimRight(registry, "/"), imageName, tag)
|
|
|
|
contextDir := filepath.Join(cloneDir, filepath.Clean("/"+contextRel))
|
|
|
|
auth, insecure := authForRegistry(registry, a)
|
|
|
|
slog.InfoContext(ctx, "build_run_docker",
|
|
slog.String("node", node.Name),
|
|
slog.String("addr", bkAddr),
|
|
slog.String("dockerfile", dockerfile),
|
|
slog.String("context", contextDir),
|
|
slog.String("image", imageRef),
|
|
slog.String("platform", platform),
|
|
slog.Bool("insecure", insecure),
|
|
)
|
|
|
|
logTail, err := runDockerBuild(ctx, dockerBuildOpts{
|
|
BuildKitAddr: bkAddr,
|
|
ContextDir: contextDir,
|
|
Dockerfile: dockerfile,
|
|
ImageRef: imageRef,
|
|
Platform: platform,
|
|
BuildArgs: buildArgs,
|
|
Insecure: insecure,
|
|
RegistryAuth: auth,
|
|
})
|
|
|
|
summary := map[string]any{
|
|
"mode": "docker",
|
|
"image": imageRef,
|
|
"dockerfile": dockerfile,
|
|
"context": contextRel,
|
|
"platform": platform,
|
|
"build_args": buildArgs,
|
|
"log_tail": logTail,
|
|
}
|
|
return summary, logTail, err
|
|
}
|
|
|
|
// authForRegistry decides what creds to send to BuildKit for `registry`.
|
|
// ghcr.io: use the agent's PAT (must include write:packages).
|
|
// localhost:* and registry:* (compose-internal): anonymous + insecure.
|
|
// Anything else: anonymous; user can wire a real auth path later.
|
|
func authForRegistry(registry string, a *storage.Agent) (map[string]registryCreds, bool) {
|
|
host := registryHostname(registry)
|
|
insecure := isInsecureRegistry(host)
|
|
|
|
if host == "ghcr.io" && a.PAT != "" {
|
|
owner := agentOwner(a)
|
|
if owner == "" {
|
|
owner = "x-access-token"
|
|
}
|
|
return map[string]registryCreds{
|
|
"ghcr.io": {Username: owner, Password: a.PAT},
|
|
}, false
|
|
}
|
|
return map[string]registryCreds{}, insecure
|
|
}
|
|
|
|
func registryHostname(registry string) string {
|
|
r := strings.TrimSpace(registry)
|
|
r = strings.TrimPrefix(r, "https://")
|
|
r = strings.TrimPrefix(r, "http://")
|
|
if i := strings.Index(r, "/"); i >= 0 {
|
|
r = r[:i]
|
|
}
|
|
return r
|
|
}
|
|
|
|
func isInsecureRegistry(host string) bool {
|
|
h := strings.ToLower(host)
|
|
if strings.HasPrefix(h, "localhost") || strings.HasPrefix(h, "127.") {
|
|
return true
|
|
}
|
|
// Compose-internal service hostnames typically resolve only inside the
|
|
// docker network; users override the registry default to point at one.
|
|
if h == "registry" || strings.HasPrefix(h, "registry:") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// agentOwner extracts "owner" from an agent name shaped like "owner/repo".
|
|
// Used as the GHCR username when pushing.
|
|
func agentOwner(a *storage.Agent) string {
|
|
if i := strings.Index(a.Name, "/"); i > 0 {
|
|
return a.Name[:i]
|
|
}
|
|
if r, err := github.ParseRepo(a.RepoURL); err == nil {
|
|
return r.Owner
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseKVCSV parses "k=v, k2=v2" into a map.
|
|
func parseKVCSV(s string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, p := range strings.Split(s, ",") {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
eq := strings.Index(p, "=")
|
|
if eq <= 0 {
|
|
continue
|
|
}
|
|
k := strings.TrimSpace(p[:eq])
|
|
v := strings.TrimSpace(p[eq+1:])
|
|
if k != "" {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- clone helper --------------------------------------------------------
|
|
|
|
// cloneRepo shallow-clones a's repo into a fresh tmp dir, optionally
|
|
// checking out a specific commit. Returns (cloneDir, cleanupFn, err).
|
|
func cloneRepo(ctx context.Context, a *storage.Agent, ref, commit string, timeout time.Duration) (string, func(), error) {
|
|
cloneDir, err := os.MkdirTemp("", "flow-build-*")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("build: tmp dir: %w", err)
|
|
}
|
|
cleanup := func() { os.RemoveAll(cloneDir) }
|
|
|
|
cloneURL, err := authedCloneURL(a.RepoURL, a.PAT)
|
|
if err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("build: clone url: %w", err)
|
|
}
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
slog.InfoContext(ctx, "build_clone",
|
|
slog.String("agent", a.Name),
|
|
slog.String("ref", ref),
|
|
slog.String("dir", cloneDir),
|
|
)
|
|
|
|
if err := runCmd(cctx, "", "git", "clone", "--depth", "1",
|
|
"--branch", ref, cloneURL, cloneDir); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("git clone: %w", err)
|
|
}
|
|
if commit != "" {
|
|
_ = runCmd(cctx, cloneDir, "git", "fetch", "--depth", "1", "origin", commit)
|
|
if err := runCmd(cctx, cloneDir, "git", "checkout", commit); err != nil {
|
|
slog.WarnContext(ctx, "build_checkout_failed",
|
|
slog.String("commit", commit),
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
}
|
|
return cloneDir, cleanup, nil
|
|
}
|
|
|
|
// --- helpers (unchanged from previous version) ---------------------------
|
|
|
|
func authedCloneURL(repoURL, pat string) (string, error) {
|
|
if pat == "" || strings.HasPrefix(repoURL, "git@") {
|
|
return repoURL, nil
|
|
}
|
|
u, err := url.Parse(repoURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
u.User = url.UserPassword("x-access-token", pat)
|
|
return u.String(), nil
|
|
}
|
|
|
|
func runCmd(ctx context.Context, cwd string, name string, args ...string) error {
|
|
logger := engine.NodeLoggerFromContext(ctx)
|
|
logger.Log("$ " + name + " " + strings.Join(args, " "))
|
|
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
if cwd != "" {
|
|
cmd.Dir = cwd
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd.Stderr = cmd.Stdout
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 512*1024)
|
|
for scanner.Scan() {
|
|
logger.Log(scanner.Text())
|
|
}
|
|
return cmd.Wait()
|
|
}
|
|
|
|
func runShellCapture(ctx context.Context, cwd string, env []string, command string) (int, string, error) {
|
|
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
|
|
if cwd != "" {
|
|
cmd.Dir = cwd
|
|
}
|
|
cmd.Env = env
|
|
|
|
logger := engine.NodeLoggerFromContext(ctx)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return -1, "", err
|
|
}
|
|
cmd.Stderr = cmd.Stdout // merge
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return -1, "", err
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
logger.Log(line)
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
}
|
|
// Best-effort wait. Ignore scanner error since the cmd Wait is the
|
|
// authoritative signal.
|
|
err = cmd.Wait()
|
|
tail := lastLines(buf.String(), 80)
|
|
|
|
if err == nil {
|
|
return 0, tail, nil
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.ExitCode(), tail, fmt.Errorf("exit %d: %s",
|
|
exitErr.ExitCode(), oneLineSummary(tail))
|
|
}
|
|
return -1, tail, err
|
|
}
|
|
|
|
func firstItem(inputs [][]models.Item) models.Item {
|
|
for _, in := range inputs {
|
|
for _, it := range in {
|
|
if len(it) > 0 {
|
|
return it
|
|
}
|
|
}
|
|
}
|
|
return models.Item{}
|
|
}
|
|
|
|
func totalLen(inputs [][]models.Item) int {
|
|
n := 0
|
|
for _, in := range inputs {
|
|
n += len(in)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func strParam(p map[string]any, key, def string) string {
|
|
if p == nil {
|
|
return def
|
|
}
|
|
if v, ok := p[key].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func intParam(p map[string]any, key string, def int) int {
|
|
if p == nil {
|
|
return def
|
|
}
|
|
switch v := p[key].(type) {
|
|
case int:
|
|
return v
|
|
case int32:
|
|
return int(v)
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
}
|
|
return def
|
|
}
|
|
|
|
func strFromAny(v any) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func strFirst(ss ...string) string {
|
|
for _, s := range ss {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func lastLines(s string, n int) string {
|
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
if len(lines) <= n {
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
return strings.Join(lines[len(lines)-n:], "\n")
|
|
}
|
|
|
|
func oneLineSummary(s string) string {
|
|
for _, line := range strings.Split(s, "\n") {
|
|
l := strings.TrimSpace(line)
|
|
if l != "" {
|
|
if len(l) > 200 {
|
|
return l[:200] + "…"
|
|
}
|
|
return l
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// stripRefsHeads turns "refs/heads/main" into "main"; passes any other
|
|
// shape through unchanged. Tags ("refs/tags/v1") would still need a
|
|
// different clone strategy (--branch works for both branches and tags so
|
|
// we leave those alone).
|
|
func stripRefsHeads(s string) string {
|
|
const p = "refs/heads/"
|
|
if strings.HasPrefix(s, p) {
|
|
return s[len(p):]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func sanitizeEnvValue(s string) string {
|
|
r := strings.NewReplacer("\n", " ", "\r", " ", "\x00", "")
|
|
return r.Replace(s)
|
|
}
|