From 843aaadc8f3d6c1f1f8c3d99d8b503fe660ee379 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Mon, 12 Oct 2026 09:05:28 -0500 Subject: [PATCH] flash: --build/--repo/--ref flash a build uploaded to embeddedci.com `benchpod flash` can flash a build uploaded with the upload-artifact action or the pytest build_report fixture instead of a local --file. Over embeddedci.com (--connection embeddedci:) the server runs OpenOCD through the pod, so a cloud pod can now be flashed from the CLI; on the network or USB the file is downloaded and flashed by the local OpenOCD. --artifact picks a file, --server-side forces one path. The server picks the artifact (newest matching build; .elf, then .hex, then .bin) and an omitted --target falls back to the one the build recorded. Co-Authored-By: Claude Opus 5.5 --- README.md | 39 +++++-- cmd/benchpod-cli/connection.go | 2 +- cmd/benchpod-cli/flash.go | 63 +++++++++++- cmd/benchpod-cli/flash_build.go | 148 +++++++++++++++++++++++++++ cmd/benchpod-cli/flash_build_test.go | 136 ++++++++++++++++++++++++ internal/serverapi/client.go | 143 ++++++++++++++++++++++++++ 6 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 cmd/benchpod-cli/flash_build.go create mode 100644 cmd/benchpod-cli/flash_build_test.go diff --git a/README.md b/README.md index 2b1573e..e525155 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,12 @@ API takes, so the output is the same. It needs: `https://www.embeddedci.com`), as it does for the SDK. Commands that stream data or need a direct link to the pod do not go through -embeddedci.com: `capture`, `stream`, `measure`, `test`, `flash`, `spi-flash`, +embeddedci.com: `capture`, `stream`, `measure`, `test`, `flash --file`, `spi-flash`, `cloud ca set`, `register`, the USB-only commands and `discover`. Over a cloud connection they refuse with a message pointing to the web app, the Python SDK or pytest plugin (which take `embeddedci:` for those too), or a network/USB -connection. The CLI has no UART command; use the web app's Terminal or the SDK. +connection. `flash --build` (a build uploaded to embeddedci.com) does work over a +cloud connection: embeddedci.com runs OpenOCD. The CLI has no UART command; use the web app's Terminal or the SDK. ### One wiring profile @@ -167,7 +168,8 @@ MCP read the same one) says which LA channel carries which signal. `flash` and | `flash` | `--target` | `swd_target` | | `spi-flash` | `--sck`, `--mosi`, `--miso`, `--cs` | `spi_sclk`, `spi_mosi`, `spi_miso`, `spi_cs` | -That happens when the connection is the pod's network address, this machine is +That happens when the connection is the pod's network address (or, for `flash --build`, +`embeddedci:`), this machine is signed in (`benchpod login` or `BENCHPOD_API_KEY`) and the pod is on that account (its `cloud_status` device id is in the account's pod list, as `status` and `discover` check). Flags always win. The log says which values came from the @@ -240,7 +242,7 @@ benchpod show-network # the pod's IP (wired lease included), SSID, state, RS benchpod clear-wifi # Firmware: -benchpod flash ... # flash a TARGET/DUT wired to the pod (SWD via OpenOCD CMSIS-DAP) +benchpod flash ... # flash a TARGET/DUT wired to the pod (SWD via OpenOCD CMSIS-DAP; --build: an uploaded build) benchpod spi-flash ... # read/erase/program an SPI NOR flash on four LA pins (network) benchpod bootsel # reboot a (retired) RP2350 pod into its UF2 bootloader (USB console only) benchpod dfu # reboot an STM32 pod into its USB DFU bootloader (USB console only) @@ -778,17 +780,38 @@ Pulse an LA pin N times, for step/dir stepper drivers. The FPGA times the train. ### `benchpod flash` Flash an SWD target wired to the pod, through host-side OpenOCD and the pod's -CMSIS-DAP backend (network or USB; not through embeddedci.com). See -[Flashing: CMSIS-DAP](#flashing-cmsis-dap). Over the network, `--swclk`, `--swdio`, -`--nreset` and `--target` default to the pod's wiring profile (see +CMSIS-DAP backend (network or USB). See +[Flashing: CMSIS-DAP](#flashing-cmsis-dap). Over the network and embeddedci.com, +`--swclk`, `--swdio`, `--nreset` and `--target` default to the pod's wiring profile (see [One wiring profile](#one-wiring-profile)). +`--build`, `--repo` and `--ref` flash a build uploaded to embeddedci.com (the +`upload-artifact` GitHub action or the pytest `build_report` fixture) instead of a +`--file`. Over embeddedci.com (`--connection embeddedci:`) embeddedci.com runs +OpenOCD, so nothing needs installing here; on the network or USB the file is downloaded +and flashed by the local OpenOCD. Both need `benchpod login` or `BENCHPOD_API_KEY`. + +```bash +benchpod flash --connection embeddedci:bench-1 --repo acme/firmware --ref main +benchpod flash --build 3f9c2a1e-... --target-power 1 +``` + +Without `--artifact` the build's `.elf` is flashed, else its `.hex`, else its `.bin`. +An omitted `--target` falls back to the target the build recorded. A flash run by +embeddedci.com always verifies and resets: `--no-verify`, `--no-reset`, +`--keep-reset-init`, `--openocd`, `-c` and `--openocd-arg` only apply to a local run. + | Flag | Default | Purpose | |----------------------------|---------|-------------------------------------------------------------------------| | `--swclk` | (profile `swd_swclk`, else required) | LA pin for SWCLK, 1-14 (`1` or `la1`). | | `--swdio` | (profile `swd_swdio`, else required) | LA pin for SWDIO, 1-14. | | `--target` | (profile `swd_target`, else none) | OpenOCD target config, passed as `-f`, e.g. `target/stm32f1x.cfg`. | | `--file` | (none) | Firmware image to flash, used with `--target`. | +| `--build` | (none) | Flash a build uploaded to embeddedci.com instead of `--file`: its build id (the upload's `build_id`), or `latest` for the newest one matching `--repo`/`--ref`. | +| `--repo` | (none) | With `--build`: the GitHub repository of the uploaded build, `owner/name`. | +| `--ref` | (none) | With `--build`: its branch or tag, e.g. `main`. | +| `--artifact` | (`.elf`, `.hex`, then `.bin`) | With `--build`: the file to flash when the build holds several. | +| `--server-side` | (on over embeddedci.com) | With `--build`: embeddedci.com runs OpenOCD. `--server-side=false` downloads the file and flashes it here (the default on the network and USB). | | `--load-address` | (none) | Load address for a raw `.bin` image. | | `--nreset` | (profile `swd_nreset`, else off) | The target's NRST is wired to the pod's reset pin (DUT header J1 pin 22); turns on connect-under-reset. `--nreset=false` overrides the profile. | | `--no-connect-under-reset` | off | Do not hold the target in reset while connecting. | @@ -800,7 +823,7 @@ CMSIS-DAP backend (network or USB; not through embeddedci.com). See | `--command`, `-c` | (none) | Extra OpenOCD command, repeatable, appended last. | | `--openocd-arg` | (none) | Extra raw OpenOCD argument, repeatable, appended last. | -Pass `--target` (with `--file`) or at least one `-c`/`--openocd-arg`. +Pass `--target` (with `--file` or `--build`) or at least one `-c`/`--openocd-arg`. ### `benchpod spi-flash` diff --git a/cmd/benchpod-cli/connection.go b/cmd/benchpod-cli/connection.go index a01097d..8515ee3 100644 --- a/cmd/benchpod-cli/connection.go +++ b/cmd/benchpod-cli/connection.go @@ -152,7 +152,7 @@ func cloudRefusal(cmd string, c ConnSpec, usb bool) error { // cloudCommandList names the commands that work over embeddedci.com, for messages and help. const cloudCommandList = "status, ping, la voltage, la pullup, la status, la step, generate, " + - "lan-policy, sig-policy, cloud ca show/clear, cloud proxy, deregister" + "lan-policy, sig-policy, cloud ca show/clear, cloud proxy, deregister, flash --build" // ── set-connection ─────────────────────────────────────────────────────────── diff --git a/cmd/benchpod-cli/flash.go b/cmd/benchpod-cli/flash.go index 366d963..b5b3d70 100644 --- a/cmd/benchpod-cli/flash.go +++ b/cmd/benchpod-cli/flash.go @@ -11,6 +11,7 @@ import ( "time" "github.com/embeddedci-com/benchpod-cli/internal/openocd" + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" "github.com/spf13/cobra" ) @@ -66,6 +67,11 @@ type flashFlags struct { extraConfigs []string extraArgs []string + // An uploaded build instead of --file (see flashFromBuild). + build, repo, ref, artifact string + // serverSide: --server-side was given (serverSideSet) and its value. + serverSide, serverSideSet bool + // nresetSet says --nreset was given (either way), so the wiring profile does not override it. nresetSet bool @@ -89,10 +95,15 @@ func newFlashCmd(g *globalFlags) *cobra.Command { f := &flashFlags{} cmd := &cobra.Command{ Use: "flash", - Short: "Flash an SWD target via OpenOCD's CMSIS-DAP backend (network or USB)", + Short: "Flash an SWD target via OpenOCD's CMSIS-DAP backend (network, USB, or an uploaded build)", Long: "Flash an SWD target wired to the pod's LA pins: the pod becomes a CMSIS-DAP\n" + "probe and a local OpenOCD programs the target through it. Works over the network\n" + - "and over USB; not over embeddedci.com (use the web app or the Python SDK there).\n\n" + + "and over USB.\n\n" + + "--build, --repo, --ref and --artifact flash a build uploaded to embeddedci.com\n" + + "(the upload-artifact action or the pytest build_report fixture) instead of --file.\n" + + "Over embeddedci.com (--connection embeddedci:) embeddedci.com runs OpenOCD, so\n" + + "none is needed here; on the network or USB the file is downloaded and flashed by the\n" + + "local OpenOCD. Both need `benchpod login` or BENCHPOD_API_KEY.\n\n" + "--swclk, --swdio, --nreset and --target default to swd_swclk, swd_swdio,\n" + "swd_nreset and swd_target in the pod's wiring profile on embeddedci.com when this\n" + "machine is signed in (`benchpod login` or BENCHPOD_API_KEY), the pod is on that\n" + @@ -101,6 +112,7 @@ func newFlashCmd(g *globalFlags) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { f.nresetSet = cmd.Flags().Changed("nreset") + f.serverSideSet = cmd.Flags().Changed("server-side") return runFlash(g, f) }, } @@ -110,6 +122,11 @@ func newFlashCmd(g *globalFlags) *cobra.Command { fl.BoolVar(&f.nreset, "nreset", false, "the target's NRST is wired to the pod's reset pin ("+nrstPinLocation+"); enables connect-under-reset (default: swd_nreset from the pod's wiring profile)") fl.StringVar(&f.target, "target", "", "OpenOCD target config (passed as -f), e.g. target/stm32f1x.cfg (default: swd_target from the pod's wiring profile)") fl.StringVar(&f.file, "file", "", "firmware image to flash (used with --target)") + fl.StringVar(&f.build, "build", "", "flash a build uploaded to embeddedci.com instead of --file: its build id, or latest (the newest one matching --repo/--ref)") + fl.StringVar(&f.repo, "repo", "", "with --build: the GitHub repository of the uploaded build, owner/name (newest build of that repo)") + fl.StringVar(&f.ref, "ref", "", "with --build: the branch or tag of the uploaded build, e.g. main") + fl.StringVar(&f.artifact, "artifact", "", "with --build: the file to flash when the build holds several (default: its .elf, then .hex, then .bin)") + fl.BoolVar(&f.serverSide, "server-side", false, "with --build: have embeddedci.com run OpenOCD (default over embeddedci.com); --server-side=false downloads the file and flashes it here (default on the network and USB)") fl.StringVar(&f.loadAddr, "load-address", "", "load address for a raw .bin image (appended to the program command)") fl.BoolVar(&f.noVerify, "no-verify", false, "do not verify after programming") fl.BoolVar(&f.noReset, "no-reset", false, "do not reset the target after programming") @@ -127,12 +144,42 @@ func runFlash(g *globalFlags, f *flashFlags) error { if err != nil { return err } - if spec.IsCloud() { - return cloudRefusal("flash", spec, true) + fromBuild := f.fromBuild() + if fromBuild && strings.TrimSpace(f.file) != "" { + return fmt.Errorf("pass either --file or an uploaded build (--build, --repo, --ref, --artifact), not both") } - if spec.IsNetwork() { + if !fromBuild && f.serverSideSet { + return fmt.Errorf("--server-side only applies to an uploaded build (--build, --repo, --ref)") + } + serverSide := spec.IsCloud() + if f.serverSideSet { + serverSide = f.serverSide + } + if spec.IsCloud() && !fromBuild { + return fmt.Errorf("flash over embeddedci.com (--connection %s%s) flashes a build uploaded there: pass --build, --repo or --ref. "+ + "A local --file needs a direct link to the pod: pass its network address (`benchpod discover` finds it) or `usb`", + cloudConnPrefix, spec.Name) + } + if spec.IsCloud() && !serverSide { + return fmt.Errorf("--server-side=false needs a direct link to the pod: pass its network address or `usb` as --connection") + } + if spec.IsNetwork() || spec.IsCloud() { f.fillFromWiring(g) } + var art *serverapi.BuildArtifact + if fromBuild { + var cleanup func() + var err error + art, cleanup, err = f.resolveBuild(g, !serverSide) + if err != nil { + return err + } + defer cleanup() + if strings.TrimSpace(f.target) == "" && art.Target() != "" { + f.target = art.Target() + log.Printf("flash: target %s from the build", f.target) + } + } if strings.TrimSpace(f.swclk) == "" || strings.TrimSpace(f.swdio) == "" { return fmt.Errorf("--swclk and --swdio are required (or set swd_swclk and swd_swdio in the pod's wiring profile on embeddedci.com)") } @@ -148,11 +195,17 @@ func runFlash(g *globalFlags, f *flashFlags) error { return fmt.Errorf("--swclk and --swdio must be different pins") } if strings.TrimSpace(f.file) != "" && strings.TrimSpace(f.target) == "" { + if fromBuild { + return fmt.Errorf("--target is required: the build recorded none and the wiring profile has no swd_target") + } return fmt.Errorf("--file requires --target") } if f.targetPower != 0 && f.targetPower != 1 && f.targetPower != 2 { return fmt.Errorf("--target-power must be 1 (internal 5V) or 2 (external)") } + if fromBuild && serverSide { + return f.flashOnServer(g, spec, art, swclk, swdio) + } // 1. Identify OpenOCD. 2. Validate it actually runs. bin := strings.TrimSpace(f.openocdBin) if bin == "" { diff --git a/cmd/benchpod-cli/flash_build.go b/cmd/benchpod-cli/flash_build.go new file mode 100644 index 0000000..84d6722 --- /dev/null +++ b/cmd/benchpod-cli/flash_build.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/serverapi" +) + +// flash_build.go: `benchpod flash --build/--repo/--ref/--artifact` flashes a build uploaded to +// embeddedci.com instead of a local --file. Over embeddedci.com the server runs OpenOCD through +// the pod (POST /api/benchpod/devices/{id}/flash); on the network or USB the CLI downloads the file +// and runs the usual local OpenOCD flash. The server picks the artifact (newest matching build, +// .elf before .hex before .bin), so every client flashes the same file for the same selector. + +// buildLookupTimeout bounds resolving and downloading the build (images are a few MB at most). +const buildLookupTimeout = 2 * time.Minute + +// fromBuild says the flash names an uploaded build rather than a --file. +func (f *flashFlags) fromBuild() bool { + for _, v := range []string{f.build, f.repo, f.ref, f.artifact} { + if strings.TrimSpace(v) != "" { + return true + } + } + return false +} + +func (f *flashFlags) buildSelector() serverapi.BuildSelector { + return serverapi.BuildSelector{Build: f.build, Repo: f.repo, Ref: f.ref, Artifact: f.artifact} +} + +// resolveBuild asks embeddedci.com which file the selector names. With download it also fetches +// the file into a temp dir and points f.file at it; the returned cleanup removes it. +func (f *flashFlags) resolveBuild(g *globalFlags, download bool) (*serverapi.BuildArtifact, func(), error) { + noop := func() {} + ctx, cancel := context.WithTimeout(context.Background(), buildLookupTimeout) + defer cancel() + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) + if err != nil { + return nil, noop, fmt.Errorf("flash: %w", err) + } + art, err := api.ResolveBuildArtifact(ctx, cred, f.buildSelector()) + if err != nil { + return nil, noop, fmt.Errorf("flash: find the uploaded build: %w", err) + } + if !download { + log.Printf("flash: %s", art.Describe()) + return art, noop, nil + } + dir, err := os.MkdirTemp("", "benchpod-build-") + if err != nil { + return nil, noop, err + } + cleanup := func() { _ = os.RemoveAll(dir) } + path := filepath.Join(dir, filepath.Base(art.Name)) + out, err := os.Create(path) + if err != nil { + cleanup() + return nil, noop, err + } + err = api.DownloadBuildArtifact(ctx, cred, art, out) + if cerr := out.Close(); err == nil { + err = cerr + } + if err != nil { + cleanup() + return nil, noop, fmt.Errorf("flash: download %s: %w", art.Name, err) + } + log.Printf("flash: downloaded %s", art.Describe()) + f.file = path + return art, cleanup, nil +} + +// flashOnServer has embeddedci.com flash art through the pod with its own OpenOCD. +func (f *flashFlags) flashOnServer(g *globalFlags, spec ConnSpec, art *serverapi.BuildArtifact, swclk, swdio int) error { + var localOnly []string + for name, set := range map[string]bool{ + "--no-verify": f.noVerify, "--no-reset": f.noReset, "--keep-reset-init": f.keepResetInit, + "--openocd": strings.TrimSpace(f.openocdBin) != "", "-c/--command": len(f.extraConfigs) > 0, + "--openocd-arg": len(f.extraArgs) > 0, + } { + if set { + localOnly = append(localOnly, name) + } + } + sort.Strings(localOnly) + if len(localOnly) > 0 { + return fmt.Errorf("%s only apply to the local OpenOCD; embeddedci.com always verifies and resets. Drop them, or flash from a direct connection with --server-side=false", + strings.Join(localOnly, ", ")) + } + if strings.TrimSpace(f.target) == "" { + return fmt.Errorf("--target is required (an OpenOCD config such as target/stm32f4x.cfg), or set swd_target in the pod's wiring profile") + } + if spec.IsSerial() { + return fmt.Errorf("--server-side needs the pod's network address or --connection embeddedci:, not a USB connection") + } + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(flashTimeout)+time.Minute) + defer cancel() + defer installSignalHandler(ctx, cancel)() + api := serverapi.New(cloudServerURL()) + cred, err := cloudCredential(ctx, api) + if err != nil { + return fmt.Errorf("flash: %w", err) + } + deviceID, err := cloudDeviceID(ctx, api, cred, spec) + if err != nil { + return fmt.Errorf("flash: %w", err) + } + if deviceID == "" { + return fmt.Errorf("flash: the pod is not registered on the signed-in embeddedci.com account; flash it here with --server-side=false") + } + req := serverapi.FlashRequest{ + JobID: art.JobID, PackID: art.PackID, ArtifactName: art.Name, Target: f.target, + LoadAddr: f.loadAddr, SWCLK: swclk, SWDIO: swdio, NReset: f.nreset, Efuse: f.targetPower, + } + if f.noConnectUnderReset { + off := false + req.ConnectUnderReset = &off + } + fmt.Fprintln(os.Stderr, "flash: embeddedci.com is flashing the target (OpenOCD runs on the server)") + resp, err := api.FlashDevice(ctx, cred, deviceID, req) + if err != nil { + return fmt.Errorf("flash: %w", err) + } + if resp.Output != "" { + fmt.Fprint(os.Stdout, resp.Output) + if !strings.HasSuffix(resp.Output, "\n") { + fmt.Fprintln(os.Stdout) + } + } + if !resp.OK { + if resp.TargetUnreachable { + f.hasNRST = true // the server does not say; assume the pod has its reset pin (rev3+) + printTargetUnreachableHint(f) + } + return fmt.Errorf("flash: %s", resp.Error) + } + fmt.Fprintln(os.Stderr, "flash: success") + return nil +} diff --git a/cmd/benchpod-cli/flash_build_test.go b/cmd/benchpod-cli/flash_build_test.go new file mode 100644 index 0000000..e6b6bfb --- /dev/null +++ b/cmd/benchpod-cli/flash_build_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" +) + +// fakeBuilds is embeddedci.com with one pod ("bench-1", id "id-1") and one uploaded build. +type fakeBuilds struct { + mu sync.Mutex + queries []string + flashes []map[string]any + reply string +} + +func (f *fakeBuilds) start(t *testing.T) { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.URL.Path == "/api/benchpod/devices": + fmt.Fprint(w, `{"devices":[{"id":"id-1","name":"bench-1"}]}`) + case r.URL.Path == "/api/benchpod/builds/artifact": + f.queries = append(f.queries, r.URL.RawQuery) + fmt.Fprint(w, `{"artifact":{"job_id":"job-1","pack_id":"pack-1","name":"out/fw.elf","repo":"acme/fw","ref":"main","sha":"abc","wiring_defaults":{"target":"target/stm32f4x.cfg"}}}`) + case r.URL.Path == "/api/benchpod/builds/job-1/packs/pack-1/artifacts/out/fw.elf": + fmt.Fprint(w, "ELFDATA") + case r.Method == http.MethodPost && r.URL.Path == "/api/benchpod/devices/id-1/flash": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.flashes = append(f.flashes, body) + fmt.Fprint(w, f.reply) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(ts.Close) + t.Setenv("BENCHPOD_API_BASE", ts.URL) + t.Setenv("BENCHPOD_API_KEY", "eci_test") +} + +func TestFlashBuildOverCloudRunsOnTheServer(t *testing.T) { + f := &fakeBuilds{reply: `{"ok":true,"output":"** Verified OK **"}`} + f.start(t) + g := &globalFlags{connection: "embeddedci:bench-1"} + err := runFlash(g, &flashFlags{swclk: "11", swdio: "12", nreset: true, nresetSet: true, + repo: "acme/fw", ref: "main", targetPower: 1, noConnectUnderReset: true}) + if err != nil { + t.Fatalf("runFlash: %v", err) + } + if len(f.queries) != 1 || f.queries[0] != "ref=main&repo=acme%2Ffw" { + t.Fatalf("lookup query = %v", f.queries) + } + if len(f.flashes) != 1 { + t.Fatalf("flashes = %v", f.flashes) + } + got, _ := json.Marshal(f.flashes[0]) + want := `{"artifact_name":"out/fw.elf","connect_under_reset":false,"efuse":1,"job_id":"job-1","nreset":true,"pack_id":"pack-1","swclk":11,"swdio":12,"target":"target/stm32f4x.cfg"}` + if string(got) != want { + t.Fatalf("flash body\n got %s\nwant %s", got, want) + } +} + +func TestFlashBuildServerFailureIsAnError(t *testing.T) { + f := &fakeBuilds{reply: `{"ok":false,"output":"Error: no device found","error":"openocd exited 1","target_unreachable":true}`} + f.start(t) + err := runFlash(&globalFlags{connection: "embeddedci:bench-1"}, &flashFlags{swclk: "1", swdio: "2", build: "job-1"}) + if err == nil || !strings.Contains(err.Error(), "openocd exited 1") { + t.Fatalf("err = %v", err) + } +} + +func TestFlashBuildDownloadsForALocalFlash(t *testing.T) { + f := &fakeBuilds{} + f.start(t) + fl := &flashFlags{build: "latest", artifact: "fw.elf"} + art, cleanup, err := fl.resolveBuild(&globalFlags{}, true) + if err != nil { + t.Fatalf("resolveBuild: %v", err) + } + b, err := os.ReadFile(fl.file) + if err != nil || string(b) != "ELFDATA" || !strings.HasSuffix(fl.file, "/fw.elf") { + t.Fatalf("downloaded %q to %s: %v", b, fl.file, err) + } + if art.Target() != "target/stm32f4x.cfg" || f.queries[0] != "artifact=fw.elf&build=latest" { + t.Fatalf("target %q, query %v", art.Target(), f.queries) + } + cleanup() + if _, err := os.Stat(fl.file); !os.IsNotExist(err) { + t.Fatalf("temp file not removed: %v", err) + } +} + +func TestFlashBuildRefusals(t *testing.T) { + cloud := &globalFlags{connection: "embeddedci:bench-1"} + lan := &globalFlags{connection: "192.0.2.1"} + cases := []struct { + name string + g *globalFlags + f *flashFlags + want string + }{ + {"file and build", lan, &flashFlags{file: "a.elf", build: "x"}, "either --file or an uploaded build"}, + {"server-side without build", lan, &flashFlags{serverSideSet: true, serverSide: true}, "--server-side only applies"}, + {"cloud without build", cloud, &flashFlags{file: "a.elf"}, "flashes a build uploaded there"}, + {"cloud local flash", cloud, &flashFlags{build: "x", serverSideSet: true}, "--server-side=false needs a direct link"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runFlash(tc.g, tc.f) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +func TestFlashBuildServerRefusesLocalOnlyFlags(t *testing.T) { + f := &fakeBuilds{reply: `{"ok":true}`} + f.start(t) + err := runFlash(&globalFlags{connection: "embeddedci:bench-1"}, + &flashFlags{swclk: "1", swdio: "2", build: "job-1", noVerify: true, extraArgs: []string{"-d3"}}) + if err == nil || !strings.Contains(err.Error(), "--no-verify, --openocd-arg only apply to the local OpenOCD") { + t.Fatalf("err = %v", err) + } + if len(f.flashes) != 0 { + t.Fatal("nothing should be flashed") + } +} diff --git a/internal/serverapi/client.go b/internal/serverapi/client.go index bd322ad..88644e6 100644 --- a/internal/serverapi/client.go +++ b/internal/serverapi/client.go @@ -617,3 +617,146 @@ func (c *Client) DeviceCommand(ctx context.Context, accessToken, deviceID string } return &out, nil } + +// ── uploaded builds (benchpod flash --build) ──────────────────────────────── + +// BuildSelector names an uploaded build to flash: Build is the build id the upload printed +// ("" or "latest": the newest build that matches the rest), Repo "owner/name" or "name", Ref a +// branch or tag, Artifact a file name in the build (default: its .elf, then .hex, then .bin). +type BuildSelector struct { + Build string `json:"build,omitempty"` + Repo string `json:"repo,omitempty"` + Ref string `json:"ref,omitempty"` + Artifact string `json:"artifact,omitempty"` +} + +// BuildArtifact is one flashable file of an uploaded build. +type BuildArtifact struct { + JobID string `json:"job_id"` + PackID string `json:"pack_id"` + Name string `json:"name"` + Repo string `json:"repo,omitempty"` + Ref string `json:"ref,omitempty"` + Sha string `json:"sha,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + WiringDefaults json.RawMessage `json:"wiring_defaults,omitempty"` +} + +// Target is the OpenOCD target config the build recorded, or "". +func (a *BuildArtifact) Target() string { + var w struct { + Target string `json:"target"` + } + _ = json.Unmarshal(a.WiringDefaults, &w) + return strings.TrimSpace(w.Target) +} + +// Describe is "name (build id, repo ref sha)" for log lines. +func (a *BuildArtifact) Describe() string { + var parts []string + for _, p := range []string{a.Repo, a.Ref, a.Sha} { + if p != "" { + parts = append(parts, p) + } + } + s := a.Name + " from build " + a.JobID + if len(parts) > 0 { + s += " (" + strings.Join(parts, " ") + ")" + } + return s +} + +// ResolveBuildArtifact calls GET /api/benchpod/builds/artifact: the one file a flash of sel uses. +func (c *Client) ResolveBuildArtifact(ctx context.Context, accessToken string, sel BuildSelector) (*BuildArtifact, error) { + q := url.Values{} + for k, v := range map[string]string{"build": sel.Build, "repo": sel.Repo, "ref": sel.Ref, "artifact": sel.Artifact} { + if strings.TrimSpace(v) != "" { + q.Set(k, strings.TrimSpace(v)) + } + } + path := "/api/benchpod/builds/artifact" + if len(q) > 0 { + path += "?" + q.Encode() + } + var out struct { + Artifact *BuildArtifact `json:"artifact"` + } + if err := c.doAuthedJSON(ctx, http.MethodGet, path, accessToken, nil, &out); err != nil { + return nil, err + } + if out.Artifact == nil { + return nil, errors.New("the server returned no artifact for this build") + } + return out.Artifact, nil +} + +// DownloadBuildArtifact streams a file ResolveBuildArtifact returned into w. +func (c *Client) DownloadBuildArtifact(ctx context.Context, accessToken string, a *BuildArtifact, w io.Writer) error { + segs := strings.Split(a.Name, "/") + for i, s := range segs { + segs[i] = url.PathEscape(s) + } + path := "/api/benchpod/builds/" + url.PathEscape(a.JobID) + "/packs/" + url.PathEscape(a.PackID) + + "/artifacts/" + strings.Join(segs, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", Authorization(accessToken)) + httpc := c.HTTP + if httpc == nil { + httpc = http.DefaultClient + } + resp, err := httpc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + var errBody struct { + Error string `json:"error"` + } + _ = json.Unmarshal(data, &errBody) + return &APIError{Method: http.MethodGet, Path: path, Status: resp.StatusCode, + Message: strings.TrimSpace(errBody.Error), Body: strings.TrimSpace(string(data))} + } + _, err = io.Copy(w, resp.Body) + return err +} + +// FlashRequest is the body of POST /api/benchpod/devices/{id}/flash: the artifact (JobID, PackID, +// ArtifactName) and the SWD settings. Efuse 0 leaves target power alone. +type FlashRequest struct { + JobID string `json:"job_id"` + PackID string `json:"pack_id"` + ArtifactName string `json:"artifact_name"` + Target string `json:"target"` + LoadAddr string `json:"load_addr,omitempty"` + SWCLK int `json:"swclk"` + SWDIO int `json:"swdio"` + NReset bool `json:"nreset"` + Efuse int `json:"efuse"` + ConnectUnderReset *bool `json:"connect_under_reset,omitempty"` +} + +// FlashResponse is the server's verdict: OpenOCD's output and, on failure, the error. +type FlashResponse struct { + OK bool `json:"ok"` + Output string `json:"output"` + Error string `json:"error"` + TargetUnreachable bool `json:"target_unreachable"` +} + +// FlashDevice calls POST /api/benchpod/devices/{id}/flash: embeddedci.com flashes an uploaded build +// with its own OpenOCD through the pod. A flash that ran and failed is a response with OK false. +func (c *Client) FlashDevice(ctx context.Context, accessToken, deviceID string, body FlashRequest) (*FlashResponse, error) { + if strings.TrimSpace(deviceID) == "" || strings.Contains(deviceID, "/") { + return nil, fmt.Errorf("invalid device id %q", deviceID) + } + var out FlashResponse + if err := c.doAuthedJSON(ctx, http.MethodPost, "/api/benchpod/devices/"+url.PathEscape(deviceID)+"/flash", accessToken, body, &out); err != nil { + return nil, err + } + return &out, nil +}