crabbox/internal/cli/artifacts.go
2026-05-08 06:25:10 +01:00

608 lines
20 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type artifactFile struct {
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
URL string `json:"url,omitempty"`
}
type artifactBundleMetadata struct {
CreatedAt string `json:"createdAt"`
Version string `json:"crabboxVersion"`
LeaseID string `json:"leaseId,omitempty"`
Slug string `json:"slug,omitempty"`
Provider string `json:"provider,omitempty"`
Network string `json:"network,omitempty"`
TargetOS string `json:"targetOS,omitempty"`
RunID string `json:"runId,omitempty"`
}
type artifactCollectResult struct {
Directory string `json:"directory"`
Files []artifactFile `json:"files"`
Metadata artifactBundleMetadata `json:"metadata"`
Warnings []artifactWarning `json:"warnings,omitempty"`
Error *artifactCollectError `json:"error,omitempty"`
}
type artifactCollectError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type artifactWarning struct {
Problem string `json:"problem"`
Detail string `json:"detail,omitempty"`
Rescue []string `json:"rescue,omitempty"`
Fallback string `json:"fallback,omitempty"`
}
type artifactPublishOptions struct {
Directory string
Storage string
Bucket string
Prefix string
BaseURL string
PR int
Repo string
Template string
Summary string
SummaryFile string
Region string
Profile string
EndpointURL string
ACL string
Presign bool
Expires time.Duration
DryRun bool
NoComment bool
}
func (a App) artifactsCollect(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("artifacts collect", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
output := fs.String("output", "", "artifact bundle directory")
runID := fs.String("run", "", "optional run id whose retained logs should be copied")
all := fs.Bool("all", false, "collect screenshot, video, GIF, doctor/status, logs, and metadata")
screenshot := fs.Bool("screenshot", true, "capture desktop screenshot")
video := fs.Bool("video", false, "record desktop video")
gif := fs.Bool("gif", false, "create trimmed GIF from recorded video")
doctor := fs.Bool("doctor", true, "write desktop doctor output")
webvncStatus := fs.Bool("webvnc-status", true, "write WebVNC portal status when coordinator is configured")
metadata := fs.Bool("metadata", true, "write metadata.json")
duration := fs.Duration("duration", 10*time.Second, "video capture duration")
fps := fs.Float64("fps", 15, "video frames per second")
gifWidth := fs.Int("gif-width", 640, "trimmed GIF width")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
jsonOut := fs.Bool("json", false, "print machine-readable result")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *all {
*video = true
*gif = true
}
if *gif && !*video {
return exit(2, "artifacts collect --gif requires --video or --all")
}
if *duration <= 0 {
return exit(2, "artifacts collect --duration must be positive")
}
if *fps <= 0 {
return exit(2, "artifacts collect --fps must be positive")
}
if *gifWidth <= 0 {
return exit(2, "artifacts collect --gif-width must be positive")
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return exit(2, "artifacts collect is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if err := requireLeaseID(*id, "crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>]", cfg); err != nil {
return err
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
return exit(2, "desktop artifacts are not collected from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
return err
}
dir := strings.TrimSpace(*output)
if dir == "" {
dir = defaultArtifactBundleDir(leaseID, serverSlug(server))
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return exit(2, "create artifact directory: %v", err)
}
result := artifactCollectResult{
Directory: dir,
Metadata: artifactBundleMetadata{
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Version: version,
LeaseID: leaseID,
Slug: serverSlug(server),
Provider: cfg.Provider,
Network: string(cfg.Network),
TargetOS: target.TargetOS,
RunID: strings.TrimSpace(*runID),
},
}
addFile := func(kind, path string) {
result.Files = append(result.Files, artifactFile{Kind: kind, Name: filepath.Base(path), Path: path})
}
fail := func(err error, warning artifactWarning) error {
return a.finishArtifactCollectFailure(&result, *jsonOut, err, warning)
}
if *metadata {
path := filepath.Join(dir, "metadata.json")
if err := writeJSONFile(path, result.Metadata); err != nil {
return err
}
addFile("metadata", path)
}
if *screenshot {
if err := waitForLoopbackVNC(ctx, &target); err != nil {
return fail(err, artifactWarning{
Problem: rescueVNCTargetUnreachable,
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
path := filepath.Join(dir, "screenshot.png")
if err := captureDesktopScreenshot(ctx, target, path); err != nil {
return fail(err, artifactWarning{
Problem: classifyDesktopFailure(err.Error()),
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
addFile("screenshot", path)
}
if *doctor {
path := filepath.Join(dir, "doctor.txt")
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
if err != nil {
doctorErr := exit(5, "desktop doctor failed: %v", err)
return fail(doctorErr, artifactWarning{
Problem: classifyDesktopFailure(out),
Detail: trimFailureDetail(out),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
if err := os.WriteFile(path, []byte(out+"\n"), 0o644); err != nil {
return exit(2, "write doctor artifact: %v", err)
}
addFile("doctor", path)
}
if *webvncStatus {
if path, ok, err := a.writeArtifactWebVNCStatus(ctx, cfg, target, leaseID, dir, &result.Warnings); err != nil {
return err
} else if ok {
addFile("webvnc-status", path)
}
}
if strings.TrimSpace(*runID) != "" {
logPath, runPath, err := writeArtifactRunLogs(ctx, strings.TrimSpace(*runID), dir)
if err != nil {
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
Rescue: []string{"crabbox logs " + strings.TrimSpace(*runID)},
})
}
addFile("logs", logPath)
addFile("run", runPath)
}
if *video {
if target.TargetOS != targetLinux {
err := exit(2, "artifacts collect --video currently requires target=linux with ffmpeg/x11grab")
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
path := filepath.Join(dir, "screen.mp4")
if err := captureDesktopVideo(ctx, target, path, *duration, *fps); err != nil {
return fail(err, artifactWarning{
Problem: classifyDesktopFailure(err.Error()),
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
addFile("video", path)
if *gif {
gifPath := filepath.Join(dir, "screen.trimmed.gif")
trimmedPath := filepath.Join(dir, "screen.trimmed.mp4")
preview, err := createMediaPreview(ctx, mediaPreviewOptions{
Input: path,
Output: gifPath,
TrimmedVideoOutput: trimmedPath,
Width: *gifWidth,
FPS: 4,
TrimStatic: true,
TrimPadding: 750 * time.Millisecond,
FreezeDuration: 500 * time.Millisecond,
FreezeNoise: "-50dB",
MinDuration: 1500 * time.Millisecond,
})
if err != nil {
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
})
}
addFile("gif", preview.Output)
if preview.TrimmedVideoOutput != "" {
addFile("trimmed-video", preview.TrimmedVideoOutput)
}
}
}
sortArtifactFiles(result.Files)
if result.Files == nil {
result.Files = []artifactFile{}
}
if *jsonOut {
enc := json.NewEncoder(a.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
for _, warning := range result.Warnings {
printArtifactWarning(a.Stdout, warning)
}
fmt.Fprintf(a.Stdout, "artifacts: %s\n", dir)
for _, file := range result.Files {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
}
fmt.Fprintf(a.Stdout, "publish: crabbox artifacts publish --dir %s --pr <n>\n", strings.Join(readableShellWords([]string{dir}), " "))
return nil
}
func (a App) finishArtifactCollectFailure(result *artifactCollectResult, jsonOut bool, err error, warning artifactWarning) error {
if result == nil {
return err
}
sortArtifactFiles(result.Files)
if result.Files == nil {
result.Files = []artifactFile{}
}
if strings.TrimSpace(warning.Problem) != "" {
result.Warnings = append(result.Warnings, normalizeArtifactWarning(warning))
}
result.Error = &artifactCollectError{
Code: artifactErrorCode(result.Warnings),
Message: strings.TrimSpace(err.Error()),
}
if jsonOut {
enc := json.NewEncoder(a.Stdout)
enc.SetIndent("", " ")
if encodeErr := enc.Encode(result); encodeErr != nil {
return encodeErr
}
return err
}
for _, warning := range result.Warnings {
printArtifactWarning(a.Stdout, warning)
}
return err
}
func (a App) artifactsVideo(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "artifacts video", args, false)
if err != nil {
return err
}
output, _ := stringFlagValue(args, "output")
if strings.TrimSpace(output) == "" {
output = "crabbox-" + normalizeLeaseSlug(leaseID) + "-screen.mp4"
}
duration := durationFlagValue(args, "duration", 10*time.Second)
fps := floatFlagValue(args, "fps", 15)
if duration <= 0 {
return exit(2, "artifacts video --duration must be positive")
}
if fps <= 0 {
return exit(2, "artifacts video --fps must be positive")
}
if target.TargetOS != targetLinux {
return exit(2, "artifacts video currently requires target=linux with ffmpeg/x11grab")
}
if err := captureDesktopVideo(ctx, target, output, duration, fps); err != nil {
printRescue(a.Stdout, classifyDesktopFailure(err.Error()), err.Error(), desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
return err
}
fmt.Fprintf(a.Stdout, "video: %s\n", output)
return nil
}
func (a App) artifactsGif(ctx context.Context, args []string) error {
return a.mediaPreview(ctx, args)
}
func (a App) artifactsTemplate(ctx context.Context, args []string) error {
_ = ctx
initialKind := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
initialKind = args[0]
args = args[1:]
}
fs := newFlagSet("artifacts template", a.Stderr)
kind := fs.String("kind", initialKind, "template kind: openclaw or mantis")
before := fs.String("before", "", "before screenshot/GIF URL or path")
after := fs.String("after", "", "after screenshot/GIF URL or path")
summary := fs.String("summary", "", "summary text")
summaryFile := fs.String("summary-file", "", "summary markdown file")
output := fs.String("output", "", "output markdown path; stdout when omitted")
if err := parseFlags(fs, args); err != nil {
return err
}
text, err := summaryText(*summary, *summaryFile)
if err != nil {
return err
}
body := artifactTemplateMarkdown(*kind, text, *before, *after, nil)
if strings.TrimSpace(*output) == "" {
fmt.Fprint(a.Stdout, body)
return nil
}
if err := os.WriteFile(*output, []byte(body), 0o644); err != nil {
return exit(2, "write template: %v", err)
}
fmt.Fprintf(a.Stdout, "template: %s\n", *output)
return nil
}
func (a App) artifactsPublish(ctx context.Context, args []string) error {
opts, err := parseArtifactPublishOptions(args, a.Stderr)
if err != nil {
return err
}
var coord *CoordinatorClient
if opts.Storage == "auto" || opts.Storage == "broker" {
cfg, cfgErr := loadConfig()
if cfgErr != nil {
return cfgErr
}
var useCoordinator bool
coord, useCoordinator, err = newCoordinatorClient(cfg)
if err != nil {
return err
}
if opts.Storage == "auto" {
if useCoordinator && coord != nil && coord.Token != "" {
opts.Storage = "broker"
} else {
opts.Storage = "local"
}
}
}
ensureArtifactPublishPrefix(&opts)
files, err := listArtifactBundleFiles(opts.Directory)
if err != nil {
return err
}
if len(files) == 0 {
return exit(2, "artifact directory has no files: %s", opts.Directory)
}
summary, err := summaryText(opts.Summary, opts.SummaryFile)
if err != nil {
return err
}
var published []artifactFile
if opts.Storage == "broker" {
published, err = publishArtifactFilesBroker(ctx, coord, opts, files)
} else {
published, err = publishArtifactFiles(ctx, opts, files)
}
if err != nil {
return err
}
body := artifactTemplateMarkdown(opts.Template, summary, "", "", published)
bodyPath := filepath.Join(opts.Directory, "published-artifacts.md")
if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil {
return exit(2, "write publish markdown: %v", err)
}
if opts.PR > 0 && !opts.NoComment {
if opts.Storage == "local" && opts.BaseURL == "" {
return exit(2, "artifacts publish --pr needs brokered publishing, --storage s3|r2|cloudflare, or --base-url for already-hosted local assets")
}
if opts.DryRun {
fmt.Fprintf(a.Stdout, "dry-run comment: gh issue comment %d --body-file %s\n", opts.PR, bodyPath)
} else if err := postGitHubPRComment(ctx, opts.PR, opts.Repo, bodyPath); err != nil {
return err
}
}
for _, file := range published {
if file.URL != "" {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.URL)
} else {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
}
}
fmt.Fprintf(a.Stdout, "markdown: %s\n", bodyPath)
return nil
}
func defaultArtifactBundleDir(leaseID, slug string) string {
name := strings.TrimSpace(slug)
if name == "" {
name = leaseID
}
if name == "" {
name = time.Now().UTC().Format("20060102-150405")
}
return filepath.Join("artifacts", normalizeLeaseSlug(name))
}
func writeJSONFile(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return exit(2, "encode %s: %v", path, err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return exit(2, "write %s: %v", path, err)
}
return nil
}
func (a App) writeArtifactWebVNCStatus(ctx context.Context, cfg Config, target SSHTarget, leaseID, dir string, warnings *[]artifactWarning) (string, bool, error) {
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
return "", false, nil
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil || !useCoordinator || coord == nil || coord.Token == "" {
return "", false, nil
}
status, err := coord.WebVNCStatus(ctx, leaseID)
path := filepath.Join(dir, "webvnc-status.json")
payload := map[string]any{"leaseId": leaseID, "target": target.TargetOS}
if err != nil {
payload["error"] = err.Error()
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
appendArtifactWarning(warnings, rescueVNCBridgeDisconnected, err.Error(), "", webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else {
payload["status"] = status
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
if !status.BridgeConnected {
appendArtifactWarning(warnings, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else if webVNCObserverSlotsExhausted(status) {
appendArtifactWarning(warnings, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
}
}
if err := writeJSONFile(path, payload); err != nil {
return "", false, err
}
return path, true, nil
}
func appendArtifactWarning(warnings *[]artifactWarning, problem, detail, fallback string, rescue ...string) {
if warnings == nil {
return
}
clean := normalizeArtifactWarning(artifactWarning{Problem: problem, Detail: detail, Fallback: fallback, Rescue: rescue})
if clean.Problem != "" {
*warnings = append(*warnings, clean)
}
}
func normalizeArtifactWarning(warning artifactWarning) artifactWarning {
clean := artifactWarning{
Problem: strings.TrimSpace(warning.Problem),
Detail: strings.TrimSpace(warning.Detail),
Fallback: strings.TrimSpace(warning.Fallback),
}
for _, command := range warning.Rescue {
if strings.TrimSpace(command) != "" {
clean.Rescue = append(clean.Rescue, strings.TrimSpace(command))
}
}
return clean
}
func artifactErrorCode(warnings []artifactWarning) string {
if len(warnings) == 0 || strings.TrimSpace(warnings[len(warnings)-1].Problem) == "" {
return "artifact_collect_failed"
}
return normalizeLeaseSlug(warnings[len(warnings)-1].Problem)
}
func printArtifactWarning(w io.Writer, warning artifactWarning) {
printRescueWithFallback(w, warning.Problem, warning.Detail, warning.Fallback, warning.Rescue...)
}
func writeArtifactRunLogs(ctx context.Context, runID, dir string) (string, string, error) {
coord, err := configuredCoordinator()
if err != nil {
return "", "", err
}
logText, err := coord.RunLogs(ctx, runID)
if err != nil {
return "", "", err
}
run, err := coord.Run(ctx, runID)
if err != nil {
return "", "", err
}
logPath := filepath.Join(dir, "logs.txt")
runPath := filepath.Join(dir, "run.json")
if err := os.WriteFile(logPath, []byte(logText), 0o644); err != nil {
return "", "", exit(2, "write logs artifact: %v", err)
}
if err := writeJSONFile(runPath, run); err != nil {
return "", "", err
}
return logPath, runPath, nil
}
func captureDesktopVideo(ctx context.Context, target SSHTarget, outputPath string, duration time.Duration, fps float64) error {
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil && filepath.Dir(outputPath) != "." {
return exit(2, "create video directory: %v", err)
}
file, err := os.Create(outputPath)
if err != nil {
return exit(2, "create video %s: %v", outputPath, err)
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(outputPath)
}
}()
if err := runSSHToWriter(ctx, target, desktopVideoRemoteCommand(duration, fps), file); err != nil {
return exit(5, "capture video: %v", err)
}
ok = true
return nil
}
func desktopVideoRemoteCommand(duration time.Duration, fps float64) string {
seconds := strconv.FormatFloat(duration.Seconds(), 'f', 3, 64)
frameRate := strconv.FormatFloat(fps, 'f', 3, 64)
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "missing ffmpeg; warm a new --desktop lease or install ffmpeg" >&2
exit 127
fi
if command -v xdpyinfo >/dev/null 2>&1; then
size="$(xdpyinfo | awk '/dimensions:/{print $2; exit}')"
else
size=""
fi
if [ -z "$size" ]; then size="1920x1080"; fi
ffmpeg -hide_banner -loglevel error -y -f x11grab -video_size "$size" -framerate %s -i "$DISPLAY" -t %s -pix_fmt yuv420p -an -movflags frag_keyframe+empty_moov -f mp4 -
`, frameRate, seconds)
}