From 332dfae53bc33a54ffbdd3123b33730b5e0ca822 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Mon, 12 Aug 2024 17:23:19 +0200 Subject: [PATCH] Import go files back for archive --- .github/workflows/release.yaml | 27 ++ Makefile | 21 ++ cmd/attachStorage.go | 47 +++ cmd/console.go | 84 ++++++ cmd/createAnsibleInventory.go | 40 +++ cmd/detachStorage.go | 44 +++ cmd/getConfig.go | 60 ++++ cmd/listDevices.go | 104 +++++++ cmd/listDrivers.go | 31 ++ cmd/power.go | 73 +++++ cmd/powerOff.go | 44 +++ cmd/powerOn.go | 80 +++++ cmd/reset.go | 51 ++++ cmd/root.go | 44 +++ cmd/run.go | 43 +++ cmd/runScript.go | 34 +++ cmd/setConfig.go | 48 +++ cmd/setControl.go | 42 +++ cmd/setDiskImage.go | 46 +++ cmd/setName.go | 43 +++ cmd/setTags.go | 56 ++++ cmd/setUsbConsole.go | 43 +++ cmd/version.go | 22 ++ containers/Containerfile | 7 + containers/Containerfile.guestfs | 4 + containers/install.sh | 21 ++ etc/jumpstarter/sd-wire/device1.yaml | 9 + go.mod | 34 +++ go.sum | 111 +++++++ main.go | 13 + pkg/console/usb_console.go | 116 ++++++++ pkg/drivers/all/drivers.go | 10 + pkg/drivers/dutlink-board/config.go | 238 +++++++++++++++ pkg/drivers/dutlink-board/device.go | 146 ++++++++++ pkg/drivers/dutlink-board/driver.go | 37 +++ pkg/drivers/dutlink-board/serial.go | 245 ++++++++++++++++ pkg/drivers/dutlink-board/storage.go | 190 ++++++++++++ pkg/drivers/dutlink-board/udev.go | 157 ++++++++++ pkg/drivers/mock/console.go | 67 +++++ pkg/drivers/mock/device.go | 194 +++++++++++++ pkg/drivers/mock/driver.go | 24 ++ pkg/drivers/mock/init.go | 11 + pkg/drivers/sd-wire/config.go | 44 +++ pkg/drivers/sd-wire/device.go | 117 ++++++++ pkg/drivers/sd-wire/driver.go | 39 +++ pkg/drivers/sd-wire/storage.go | 64 ++++ pkg/drivers/sd-wire/udev.go | 125 ++++++++ pkg/harness/device.go | 34 +++ pkg/harness/driver.go | 99 +++++++ pkg/harness/errors.go | 11 + pkg/locking/locking.go | 71 +++++ pkg/runner/errors.go | 8 + pkg/runner/run.go | 245 ++++++++++++++++ pkg/runner/script.go | 162 +++++++++++ pkg/runner/script_test.go | 141 +++++++++ pkg/runner/step_comment.go | 20 ++ pkg/runner/step_expect.go | 78 +++++ pkg/runner/step_local_shell.go | 49 ++++ pkg/runner/step_pause.go | 20 ++ pkg/runner/step_power.go | 43 +++ pkg/runner/step_reset.go | 40 +++ pkg/runner/step_send.go | 95 ++++++ pkg/runner/step_set_disk_image.go | 21 ++ pkg/runner/step_storage.go | 24 ++ pkg/runner/step_write_ansible_inventory.go | 42 +++ pkg/storage/writer.go | 81 ++++++ pkg/tools/ansible_inventory.go | 47 +++ pkg/tools/run_command.go | 59 ++++ pkg/tools/shell.go | 15 + script-examples/orin-agx.yaml | 118 ++++++++ script-examples/xavier-nx-test.yaml | 69 +++++ tekton/image/copy-to-cluster.sh | 7 + tekton/image/dummy.yaml | 24 ++ tekton/image/files/.gitkeep | 0 tekton/image/files/your-image-would-go-here | 0 tekton/image/pvc-image.yaml | 12 + tekton/jumpstarter-pipelines-scc.yaml | 37 +++ .../pipeline-jumpstarter-orin-nx.yaml | 106 +++++++ tekton/pipelines/task-git-clone.yaml | 274 ++++++++++++++++++ tekton/pipelines/task-jumpstarter-script.yaml | 81 ++++++ tekton/pipelines/task-prepare-image.yaml | 116 ++++++++ tekton/run-pipeline.sh | 8 + tekton/setup.sh | 19 ++ .../image-workspace-template.yaml | 6 + .../workspace-template.yaml | 6 + 85 files changed, 5438 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 Makefile create mode 100644 cmd/attachStorage.go create mode 100644 cmd/console.go create mode 100644 cmd/createAnsibleInventory.go create mode 100644 cmd/detachStorage.go create mode 100644 cmd/getConfig.go create mode 100644 cmd/listDevices.go create mode 100644 cmd/listDrivers.go create mode 100644 cmd/power.go create mode 100644 cmd/powerOff.go create mode 100644 cmd/powerOn.go create mode 100644 cmd/reset.go create mode 100644 cmd/root.go create mode 100644 cmd/run.go create mode 100644 cmd/runScript.go create mode 100644 cmd/setConfig.go create mode 100644 cmd/setControl.go create mode 100644 cmd/setDiskImage.go create mode 100644 cmd/setName.go create mode 100644 cmd/setTags.go create mode 100644 cmd/setUsbConsole.go create mode 100644 cmd/version.go create mode 100644 containers/Containerfile create mode 100644 containers/Containerfile.guestfs create mode 100755 containers/install.sh create mode 100644 etc/jumpstarter/sd-wire/device1.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/console/usb_console.go create mode 100644 pkg/drivers/all/drivers.go create mode 100644 pkg/drivers/dutlink-board/config.go create mode 100644 pkg/drivers/dutlink-board/device.go create mode 100644 pkg/drivers/dutlink-board/driver.go create mode 100644 pkg/drivers/dutlink-board/serial.go create mode 100644 pkg/drivers/dutlink-board/storage.go create mode 100644 pkg/drivers/dutlink-board/udev.go create mode 100644 pkg/drivers/mock/console.go create mode 100644 pkg/drivers/mock/device.go create mode 100644 pkg/drivers/mock/driver.go create mode 100644 pkg/drivers/mock/init.go create mode 100644 pkg/drivers/sd-wire/config.go create mode 100644 pkg/drivers/sd-wire/device.go create mode 100644 pkg/drivers/sd-wire/driver.go create mode 100644 pkg/drivers/sd-wire/storage.go create mode 100644 pkg/drivers/sd-wire/udev.go create mode 100644 pkg/harness/device.go create mode 100644 pkg/harness/driver.go create mode 100644 pkg/harness/errors.go create mode 100644 pkg/locking/locking.go create mode 100644 pkg/runner/errors.go create mode 100644 pkg/runner/run.go create mode 100644 pkg/runner/script.go create mode 100644 pkg/runner/script_test.go create mode 100644 pkg/runner/step_comment.go create mode 100644 pkg/runner/step_expect.go create mode 100644 pkg/runner/step_local_shell.go create mode 100644 pkg/runner/step_pause.go create mode 100644 pkg/runner/step_power.go create mode 100644 pkg/runner/step_reset.go create mode 100644 pkg/runner/step_send.go create mode 100644 pkg/runner/step_set_disk_image.go create mode 100644 pkg/runner/step_storage.go create mode 100644 pkg/runner/step_write_ansible_inventory.go create mode 100644 pkg/storage/writer.go create mode 100644 pkg/tools/ansible_inventory.go create mode 100644 pkg/tools/run_command.go create mode 100644 pkg/tools/shell.go create mode 100644 script-examples/orin-agx.yaml create mode 100644 script-examples/xavier-nx-test.yaml create mode 100755 tekton/image/copy-to-cluster.sh create mode 100644 tekton/image/dummy.yaml create mode 100644 tekton/image/files/.gitkeep create mode 100644 tekton/image/files/your-image-would-go-here create mode 100644 tekton/image/pvc-image.yaml create mode 100644 tekton/jumpstarter-pipelines-scc.yaml create mode 100644 tekton/pipelines/pipeline-jumpstarter-orin-nx.yaml create mode 100644 tekton/pipelines/task-git-clone.yaml create mode 100644 tekton/pipelines/task-jumpstarter-script.yaml create mode 100644 tekton/pipelines/task-prepare-image.yaml create mode 100755 tekton/run-pipeline.sh create mode 100755 tekton/setup.sh create mode 100644 tekton/workspace-templates/image-workspace-template.yaml create mode 100644 tekton/workspace-templates/workspace-template.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a1c5b55 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,27 @@ +# .github/workflows/release.yaml + +on: + release: + types: [created] + +permissions: + contents: write + packages: write + +jobs: + releases-matrix: + name: Release Go Binary + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v3 + - uses: wangyoucao577/go-release-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + binary_name: "jumpstarter" + extra_files: LICENSE README.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe0737d --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ + +VERSION := $(shell git describe --tags --always) +LDFLAGS := -ldflags="-X 'github.com/jumpstarter-dev/jumpstarter/cmd.VERSION=${VERSION}'" + +jumpstarter: main.go pkg/drivers/dutlink-board/*.go pkg/runner/* pkg/harness/*.go cmd/*.go pkg/tools/*.go pkg/drivers/sd-wire/*.go pkg/console/*.go + go build ${LDFLAGS} + +containers: + podman build ./containers/ -f Containerfile -t quay.io/mangelajo/jumpstarter:latest + podman build ./containers/ -f Containerfile.guestfs -t quay.io/mangelajo/guestfs-tools:latest + +push-containers: containers + podman push quay.io/mangelajo/jumpstarter:latest + podman push quay.io/mangelajo/guestfs-tools:latest + +fmt: + gofmt -w -s . + +all: jumpstarter + +.PHONY: all fmt containers push-containers diff --git a/cmd/attachStorage.go b/cmd/attachStorage.go new file mode 100644 index 0000000..c90b8e2 --- /dev/null +++ b/cmd/attachStorage.go @@ -0,0 +1,47 @@ +/* +Copyright © 2023 Miguel Angel Ajo Pelayo 1 { + k = strings.ToLower(args[1]) + } + + driver := cmd.Flag("driver").Value.String() + device, err := harness.FindDevice(driver, device_id) + handleErrorAsFatal(err) + + cfg, err := device.GetConfig() + + handleErrorAsFatal(err) + + if k != "" { + if v, ok := cfg[k]; ok { + fmt.Println(v) + } else { + color.Set(color.FgRed) + fmt.Println("Not found") + color.Unset() + os.Exit(1) + } + } else { + for k, v := range cfg { + fmt.Printf("%s: %s\n", k, v) + } + } + }, +} + +func init() { + rootCmd.AddCommand(getConfig) + getConfig.Flags().StringP("driver", "d", "", "Only list devices for the specified driver") +} diff --git a/cmd/listDevices.go b/cmd/listDevices.go new file mode 100644 index 0000000..c64ee60 --- /dev/null +++ b/cmd/listDevices.go @@ -0,0 +1,104 @@ +/* +Copyright © 2023 Miguel Angel Ajo Pelayo +*/ +package cmd + +import ( + "fmt" + "strings" + + "github.com/jedib0t/go-pretty/table" + "github.com/jedib0t/go-pretty/text" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/spf13/cobra" +) + +// listDevicesCmd represents the listDevices command +var listDevicesCmd = &cobra.Command{ + Use: "list-devices", + Short: "Lists available devices", + Long: `Iterates over the available drivers and gets a list of devices.`, + Run: func(cmd *cobra.Command, args []string) { + driver := cmd.Flag("driver").Value.String() + tags, err := cmd.Flags().GetStringArray("tag") + handleErrorAsFatal(err) + + devices, _, err := harness.FindDevices(driver, tags) + handleErrorAsFatal(err) + if cmd.Flag("only-names").Value.String() == "true" { + printDeviceNames(devices) + return + } + printDeviceTable(devices) + + }, +} + +func init() { + rootCmd.AddCommand(listDevicesCmd) + listDevicesCmd.Flags().StringP("driver", "d", "", "Only list devices for the specified driver") + listDevicesCmd.Flags().Bool("only-names", false, "Only list the device names") + listDevicesCmd.Flags().StringArrayP("tag", "t", []string{}, "Only list devices with the specified tag(s) can be used multiple times") +} + +func printDeviceTable(devices []harness.Device) { + t := table.NewWriter() + + t.AppendHeader(table.Row{"Device Name", "Serial Number", "Driver", "Version", "Device", "Tags"}) + + for _, device := range devices { + deviceName := device.Name() + deviceSerial, err := device.Serial() + handleErrorAsFatal(err) + deviceVersion, err := device.Version() + handleErrorAsFatal(err) + dev, err := device.Device() + handleErrorAsFatal(err) + tags := device.Tags() + str_tags := strings.Join(tags, ", ") + + t.AppendRow([]interface{}{deviceName, deviceSerial, device.Driver().Name(), deviceVersion, dev, str_tags}) + } + + t.SetStyle(table.Style{ + Name: "myNewStyle", + Box: table.BoxStyle{ + BottomLeft: "+", + BottomRight: "+", + BottomSeparator: "+", + Left: "|", + LeftSeparator: "+", + MiddleHorizontal: "-", + MiddleSeparator: "+", + MiddleVertical: "|", + PaddingLeft: " ", + PaddingRight: " ", + Right: "|", + RightSeparator: "+", + TopLeft: "+", + TopRight: "+", + TopSeparator: "+", + UnfinishedRow: " ~", + }, + Color: table.ColorOptions{ + Header: text.Colors{text.FgGreen}, + IndexColumn: text.Colors{text.FgGreen}, + }, + // Options: table.Options{ + // DrawBorder: true, + // SeparateColumns: true, + // SeparateFooter: true, + // SeparateHeader: true, + // SeparateRows: true, + // }, + }) + + fmt.Println(t.Render()) +} + +func printDeviceNames(devices []harness.Device) { + for _, device := range devices { + deviceName := device.Name() + fmt.Printf("%s\n", deviceName) + } +} diff --git a/cmd/listDrivers.go b/cmd/listDrivers.go new file mode 100644 index 0000000..0696df5 --- /dev/null +++ b/cmd/listDrivers.go @@ -0,0 +1,31 @@ +/* +Copyright © 2023 Miguel Angel Ajo Pelayo %s=%s ... ", device_id, k, v) + color.Unset() + + err = device.SetConfig(k, v) + handleErrorAsFatal(err) + + color.Set(COLOR_CMD_INFO) + fmt.Println("done") + color.Unset() + }, +} + +func init() { + rootCmd.AddCommand(setConfig) + setConfig.Flags().StringP("driver", "d", "", "Only list devices for the specified driver") +} diff --git a/cmd/setControl.go b/cmd/setControl.go new file mode 100644 index 0000000..e834fb0 --- /dev/null +++ b/cmd/setControl.go @@ -0,0 +1,42 @@ +/* +Copyright © 2023 Miguel Angel Ajo Pelayo +*/ +package main + +import ( + "github.com/jumpstarter-dev/jumpstarter/cmd" + _ "github.com/jumpstarter-dev/jumpstarter/pkg/drivers/all" // make sure the driver is registered +) + +func main() { + cmd.Execute() +} diff --git a/pkg/console/usb_console.go b/pkg/console/usb_console.go new file mode 100644 index 0000000..778cbbf --- /dev/null +++ b/pkg/console/usb_console.go @@ -0,0 +1,116 @@ +package console + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "go.bug.st/serial" +) + +func OpenUSBSerial(device string) (serial.Port, error) { + + mode := &serial.Mode{ + BaudRate: 115200, + } + + var port serial.Port + var err error + + // sometimes the device shows up and it is not ready yet, so we need to retry + retries := 5 + for retries > 0 { + port, err = serial.Open(device, mode) + if err == nil { + break + } + retries -= 1 + time.Sleep(1 * time.Second) + } + + if err != nil { + return nil, fmt.Errorf("USBConsole: openSerial: %w", err) + } + return port, nil +} +func FindUSBSerial(device string) (serial.Port, error) { + start := time.Now() + max_wait_time := 15 * time.Second + + fmt.Fprintln(os.Stderr, "Looking up for usb console: ", device) + + for { + devices, err := scanForSerialDevices(device) + if err != nil { + return nil, fmt.Errorf("USBConsole: %w", err) + } + if devices.Cardinality() > 1 { + return nil, fmt.Errorf("USBConsole: more than one device found: %v", devices) + } + if devices.Cardinality() == 1 { + dev, _ := devices.Pop() + return OpenUSBSerial(dev) + } + + if time.Since(start) > max_wait_time { + break + } + time.Sleep(500 * time.Millisecond) + } + return nil, fmt.Errorf("outOfBandConsole: timeout waiting for serial device containing %s, "+ + "please note that out-of-band consoles usually require the device to be powered on", device) +} + +const BASE_SERIALSBYID = "/dev/serial/by-id/" + +var ErrDeviceNotFound = errors.New("device not found") + +func FindUSBSerialDevice(substring string) (string, error) { + + devices, err := scanForSerialDevices(substring) + if err != nil { + return "", fmt.Errorf("USBConsole: %w", err) + } + if devices.Cardinality() > 1 { + return "", fmt.Errorf("USBConsole: more than one device found: %v", devices) + } + if devices.Cardinality() == 1 { + dev, _ := devices.Pop() + return dev, nil + } + return "", fmt.Errorf("USBConsole: no device found containing %s, %w", substring, ErrDeviceNotFound) +} + +func scanForSerialDevices(substring string) (mapset.Set[string], error) { + + interfaceSet := mapset.NewSet[string]() + + err := filepath.Walk(BASE_SERIALSBYID, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() && info.Name() == "devices" { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + baseName := filepath.Base(path) + + if strings.Contains(baseName, substring) { + interfaceSet.Add(path) + } + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("scanForSerialDevices: %w", err) + } + + return interfaceSet, nil +} diff --git a/pkg/drivers/all/drivers.go b/pkg/drivers/all/drivers.go new file mode 100644 index 0000000..9ec2b02 --- /dev/null +++ b/pkg/drivers/all/drivers.go @@ -0,0 +1,10 @@ +package all + +import ( + _ "github.com/jumpstarter-dev/jumpstarter/pkg/drivers/dutlink-board" + _ "github.com/jumpstarter-dev/jumpstarter/pkg/drivers/mock" + _ "github.com/jumpstarter-dev/jumpstarter/pkg/drivers/sd-wire" +) + +// The purpose of this package is to import all the drivers so that they are +// registered and available to the rest of the codebase. diff --git a/pkg/drivers/dutlink-board/config.go b/pkg/drivers/dutlink-board/config.go new file mode 100644 index 0000000..ec4c62c --- /dev/null +++ b/pkg/drivers/dutlink-board/config.go @@ -0,0 +1,238 @@ +package dutlink_board + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func newJumpstarter(ttyname string, version string, serial string) (JumpstarterDevice, error) { + jp := JumpstarterDevice{ + devicePath: "/dev/" + ttyname, + version: version, + serialNumber: serial, + mutex: &sync.Mutex{}, + singletonMutex: &sync.Mutex{}, + busy: false, + name: "", + json_config: map[string]string{}, + storage_filter: "", + tags: []string{}, + consoleMode: true, // let's asume it's in console mode so we will force exit at start + } + err := jp.readConfig() + + if err == nil || errors.Is(err, harness.ErrDeviceInUse) { + return jp, nil + } else { + return JumpstarterDevice{}, err + } +} + +func (d *JumpstarterDevice) IsBusy() (bool, error) { + return d.busy, nil +} + +func (d *JumpstarterDevice) GetConfig() (map[string]string, error) { + config := map[string]string{} + + buf, err := d.getConfigLines() + if err != nil { + return config, fmt.Errorf("readConfig: %w", err) + } + lines := strings.Split(buf, "\r\n") + for _, line := range lines { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + k := strings.Trim(parts[0], " ") + v := strings.Trim(parts[1], " ") + config[k] = v + } + return config, nil +} + +func (d *JumpstarterDevice) getConfigLines() (string, error) { + err := d.ensureSerial() + if errors.Is(err, harness.ErrDeviceInUse) { + d.name = "**BUSY**" + d.busy = true + return "", nil + } + + if err != nil { + return "", fmt.Errorf("getConfigLines: %w", err) + } + + defer d.closeSerial() + + if err := d.exitConsole(); err != nil { + return "", fmt.Errorf("getConfigLines: %w", err) + } + + if err := d.sendAndExpectNoPrompt("get-config", "get-config\r\n"); err != nil { + return "", fmt.Errorf("getConfigLines: %w", err) + } + d.serialPort.SetReadTimeout(100 * time.Millisecond) + + buf := make([]byte, 1024) + c := make([]byte, 1) + p := 0 + // keep reading until we reach the prompt or the read times out + for p < len(buf) { + n, err := d.serialPort.Read(c) + if err != nil { + return "", fmt.Errorf("getConfigLines: %w", err) + } + if n == 0 || c[0] == '#' { + break + } + buf[p] = c[0] + p += 1 + } + + buf = buf[:p] + return string(buf), nil +} +func (d *JumpstarterDevice) readConfig() error { + + buf, err := d.getConfigLines() + if err != nil { + return fmt.Errorf("readConfig: %w", err) + } + lines := strings.Split(buf, "\r\n") + for _, line := range lines { + if strings.HasPrefix(line, "name:") { + d.name = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "name:"))) + } + if strings.HasPrefix(line, "tags:") { + d.tags = strings.Split(strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "tags:"))), ",") + } + if strings.HasPrefix(line, "json:") { + json_str := strings.TrimSpace(strings.TrimPrefix(line, "json:")) + if json_str != "" { + // umarshall json into a map[string] string + var objmap map[string]string + err := json.Unmarshal([]byte(json_str), &objmap) + if err != nil { + return fmt.Errorf("readConfig: json unmarshal %w", err) + } + d.json_config = objmap + if storage_filter, ok := objmap["storage_filter"]; ok { + d.storage_filter = storage_filter + } + } + + } + if strings.HasPrefix(line, "usb_console:") { + d.usb_console = strings.TrimSpace(strings.TrimPrefix(line, "usb_console:")) + } + } + if d.name == "" { + d.name = "jp-" + d.serialNumber + } + return nil +} + +func (d *JumpstarterDevice) setConfig(k, v string) error { + k = strings.ToLower(k) + if strings.Contains(v, " ") || strings.Contains(v, "\r") || strings.Contains(v, "\n") { + return fmt.Errorf("SetConfig(%v, %v): invalid value, cannot contain spaces or \\r \\n", k, v) + } + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("SetConfig(%v, %v): %w", k, v, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("SetConfig(%v, %v): %w", k, v, err) + } + + if err := d.sendAndExpect("set-config "+k+" "+v, "Set "+k+" to "+v); err != nil { + return fmt.Errorf("SetConfig(%v, %v) %w", k, v, err) + } + + return nil +} + +func (d *JumpstarterDevice) SetConfig(k, v string) error { + // for parameters that are directly stored in the config list + if k == "name" || k == "tags" || k == "usb_console" || + k == "power_on" || k == "power_off" || k == "power_rescue" { + return d.setConfig(k, v) + } + // for parameters that we mash in the json field + if k == "storage_filter" { + if err := d.readConfig(); err != nil { + return fmt.Errorf("SetConfig(%v, %v): %w", k, v, err) + } + d.json_config[k] = v + json_str, err := json.Marshal(d.json_config) + if err != nil { + return fmt.Errorf("SetConfig(%v, %v): json.Marshal %w", k, v, err) + } + return d.setConfig("json", string(json_str)) + } + return nil +} + +func (d *JumpstarterDevice) SetName(name string) error { + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("SetName(%v): %w", name, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("SetName(%v): %w", name, err) + } + + if err := d.sendAndExpect("set-config name "+name, "Set name to "+name); err != nil { + return fmt.Errorf("SetName(%v) %w", name, err) + } + + return nil +} + +func (d *JumpstarterDevice) SetUsbConsole(console_substring string) error { + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("SetUsbConsole(%v): %w", console_substring, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("SetUsbConsole(%v): %w", console_substring, err) + } + + if err := d.sendAndExpect("set-config usb_console "+console_substring, "Set usb_console to "+console_substring); err != nil { + return fmt.Errorf("SetUsbConsole(%v) %w", console_substring, err) + } + + return nil +} +func (d *JumpstarterDevice) Name() string { + return d.name +} + +func (d *JumpstarterDevice) SetTags(tags []string) error { + joinTags := strings.Join(tags, ",") + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("SetTags(%s): %w", joinTags, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("SetTags(%s): %w", joinTags, err) + } + + if err := d.sendAndExpect("set-config tags "+strings.Join(tags, ","), "Set tags to "+joinTags); err != nil { + return fmt.Errorf("SetTags(%s) %w", joinTags, err) + } + + return nil +} + +func (d *JumpstarterDevice) Tags() []string { + return d.tags +} diff --git a/pkg/drivers/dutlink-board/device.go b/pkg/drivers/dutlink-board/device.go new file mode 100644 index 0000000..bef52a7 --- /dev/null +++ b/pkg/drivers/dutlink-board/device.go @@ -0,0 +1,146 @@ +package dutlink_board + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/jumpstarter-dev/jumpstarter/pkg/locking" + "go.bug.st/serial" +) + +type JumpstarterDevice struct { + driver *DUTLinkDriver + devicePath string + version string + serialNumber string + serialPort serial.Port + fileLock locking.Lock + mutex *sync.Mutex + singletonMutex *sync.Mutex + name string + storage_filter string + json_config map[string]string + oobSerialPort serial.Port + usb_console string + tags []string + busy bool + consoleMode bool +} + +func (d *JumpstarterDevice) Lock() error { + return d.ensureSerial() +} + +func (d *JumpstarterDevice) Unlock() error { + return d.closeSerial() +} + +func (d *JumpstarterDevice) Power(action string) error { + action = strings.ToLower(action) + expected_response := "Device " + + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("Power(%q): %w", action, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("Power(%q): %w", action, err) + } + + d.serialPort.SetReadTimeout(30 * time.Second) + + switch action { + case "on": + expected_response = "Device powered on" + case "off": + expected_response = "Device powered off" + case "force-off": + expected_response = "Device forced off" + case "force-on": + expected_response = "Device forced on" + case "rescue": + expected_response = "Device powered on to rescue" + + } + + if err := d.sendAndExpect_t("power "+action, expected_response, 30*time.Second); err != nil { + return fmt.Errorf("Power(%q): %w", action, err) + } + + return nil +} + +func (d *JumpstarterDevice) Console() (harness.ConsoleInterface, error) { + if d.usb_console == "" { + return d.inBandConsole() + } + return d.outOfBandConsole() +} + +func (d *JumpstarterDevice) SetConsoleSpeed(bps int) error { + return harness.ErrNotImplemented +} + +func (d *JumpstarterDevice) Driver() harness.HarnessDriver { + return d.driver +} + +func (d *JumpstarterDevice) Version() (string, error) { + return d.version, nil +} + +func (d *JumpstarterDevice) Serial() (string, error) { + return d.serialNumber, nil +} + +func (d *JumpstarterDevice) SetControl(signal string, value string) error { + + signal = strings.ToLower(signal) + value = strings.ToLower(value) + + switch value { + case "low": + value = "l" + case "high": + value = "h" + case "hiz": + value = "z" + } + + if signal == "reset" { + signal = "r" + } + + // check if is valid (r, a, b, c, or d) + if !strings.Contains("rabcd", signal) { + return fmt.Errorf("SetControl(%q,%q): invalid signal, must be any of reset|r|a|b|c|d", signal, value) + } + + // check if value is valid (h, l or z) + if !strings.Contains("hlz", value) { + return fmt.Errorf("SetControl(%q,%q): invalid value, must be any of h|l|z|high|low|hiz", signal, value) + } + + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("SetControl(%q,%q): %w", signal, value, err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("SetControl(%q,%q): %w", signal, value, err) + } + + setCmd := fmt.Sprintf("set %s %s", signal, value) + + if err := d.sendAndExpect(setCmd, setCmd+"\r\nSet "); err != nil { + return fmt.Errorf("SetControl(%q,%q): %w", signal, value, err) + } + + return nil +} + +func (d *JumpstarterDevice) Device() (string, error) { + return d.devicePath, nil +} diff --git a/pkg/drivers/dutlink-board/driver.go b/pkg/drivers/dutlink-board/driver.go new file mode 100644 index 0000000..d7f3e92 --- /dev/null +++ b/pkg/drivers/dutlink-board/driver.go @@ -0,0 +1,37 @@ +package dutlink_board + +import ( + "fmt" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +type DUTLinkDriver struct{} + +func (d *DUTLinkDriver) Name() string { + return "dutlink-board" +} + +func (d *DUTLinkDriver) Description() string { + return `OpenSource HIL USB harness (https://github.com/jumpstarter-dev/dutlink-board) + enables the control of Edge and Embedded devices via USB. + It has the following capabilities: power metering, power cycling, and serial console + access, and USB storage switching. + ` +} + +func (d *DUTLinkDriver) FindDevices() ([]harness.Device, error) { + hdList := []harness.Device{} + jumpstarters, err := scanUdev() + if err != nil { + return nil, fmt.Errorf("FindDevices: %w", err) + } + for _, jumpstarter := range jumpstarters { + hdList = append(hdList, jumpstarter) + } + return hdList, nil +} + +func init() { + harness.RegisterDriver(&DUTLinkDriver{}) +} diff --git a/pkg/drivers/dutlink-board/serial.go b/pkg/drivers/dutlink-board/serial.go new file mode 100644 index 0000000..3c76d51 --- /dev/null +++ b/pkg/drivers/dutlink-board/serial.go @@ -0,0 +1,245 @@ +package dutlink_board + +import ( + "fmt" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/console" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/jumpstarter-dev/jumpstarter/pkg/locking" + "go.bug.st/serial" +) + +const PROMPT = "#> " +const ESCAPE_SEQUENCE = "\x02\x02\x02\x02\x02" // CTRL+B 5 times to exit from console to prompt (just in case) + +func (d *JumpstarterDevice) ensureSerial() error { + d.singletonMutex.Lock() + defer d.singletonMutex.Unlock() + if d.serialPort == nil { + return d.openSerial() + } + return nil +} + +func (d *JumpstarterDevice) closeSerial() error { + d.mutex.Lock() + defer d.mutex.Unlock() + if d.serialPort != nil { + err := d.serialPort.Close() + d.fileLock.Unlock() + d.serialPort = nil + return err + } + return nil +} + +func (d *JumpstarterDevice) openSerial() error { + + mode := &serial.Mode{ + BaudRate: 115200, + } + + d.mutex.Lock() + defer d.mutex.Unlock() + + if d.serialPort != nil { + d.serialPort.Close() + + } + lock, err := locking.TryLock(d.devicePath) + d.fileLock = lock + if err != nil { + return fmt.Errorf("openSerial: locking %q %w", d.devicePath, err) + } + port, err := serial.Open(d.devicePath, mode) + if err != nil { + return fmt.Errorf("openSerial: opening %q %w", d.devicePath, err) + } + + d.serialPort = port + return nil +} + +func (d *JumpstarterDevice) exitConsole() error { + + if !d.consoleMode { + return nil + } + // make sure we are not in console mode + if err := d.sendAndExpect(ESCAPE_SEQUENCE, "\n"); err != nil { + return fmt.Errorf("exitConsole: %w", err) + } + + // and disable monitor if enabled, as monitor will print any data received in the middle of the output + if err := d.sendAndExpect("monitor off", "Monitor disabled"); err != nil { + return fmt.Errorf("exitConsole: %w", err) + } + d.consoleMode = false + return nil +} + +func (d *JumpstarterDevice) sendAndExpect(cmd, expected string) error { + d.mutex.Lock() + defer d.mutex.Unlock() + if err := d.send(cmd); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) sending: %w", cmd, expected, err) + } + + if err := d.expect(expected); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) expecting response: %w", cmd, expected, err) + } + + if err := d.expect(PROMPT); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) waiting for prompt: %w", cmd, expected, err) + } + return nil +} + +func (d *JumpstarterDevice) sendAndExpect_t(cmd, expected string, timeout time.Duration) error { + d.mutex.Lock() + defer d.mutex.Unlock() + if err := d.send(cmd); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) sending: %w", cmd, expected, err) + } + + if err := d.expect_t(expected, timeout); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) expecting response: %w", cmd, expected, err) + } + + if err := d.expect(PROMPT); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) waiting for prompt: %w", cmd, expected, err) + } + return nil +} + +func (d *JumpstarterDevice) sendAndExpectNoPrompt(cmd, expected string) error { + d.mutex.Lock() + defer d.mutex.Unlock() + if err := d.send(cmd); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) sending: %w", cmd, expected, err) + } + + if err := d.expect(expected); err != nil { + return fmt.Errorf("sendAndExpect(%q, %q) expecting response: %w", cmd, expected, err) + } + + return nil +} + +func (d *JumpstarterDevice) send(cmd string) error { + _, err := d.serialPort.Write([]byte(cmd + "\r\n")) + if err != nil { + return fmt.Errorf("sendCommand: %w", err) + } + + return nil +} + +func (d *JumpstarterDevice) expect(expected string) error { + return d.expect_t(expected, 1*time.Second) +} + +func (d *JumpstarterDevice) expect_t(expected string, timeout time.Duration) error { + d.serialPort.SetReadTimeout(timeout) + p := 0 + received := "" + buf := make([]byte, 1) + for p < len(expected) { + n, err := d.serialPort.Read(buf) + if err != nil { + return fmt.Errorf("expect(%q): %w", expected, err) + } + if n == 0 { + return fmt.Errorf("expect(%q): timeout, received=%q", expected, received) + } + c := buf[0] + received += string(c) + if c == expected[p] { + p++ + } else { + if c != expected[0] { + p = 0 + } else { + p = 1 + } + } + } + return nil +} + +func (d *JumpstarterDevice) inBandConsole() (harness.ConsoleInterface, error) { + if err := d.ensureSerial(); err != nil { + return nil, fmt.Errorf("Console: %w", err) + } + + if d.consoleMode { + return d.getConsoleWrapper(), nil + } + + if err := d.exitConsole(); err != nil { + return nil, fmt.Errorf("Console: %w", err) + } + + if err := d.sendAndExpectNoPrompt("console", "Entering console mode, type CTRL+B 5 times to exit\r\n"); err != nil { + return nil, fmt.Errorf("Console: %w", err) + } + + d.consoleMode = true + + return d.getConsoleWrapper(), nil +} + +func (d *JumpstarterDevice) outOfBandConsole() (harness.ConsoleInterface, error) { + // TODO: in most cases the oob console would go away when we power off the device + // so we need to add a wrapper to try reopening the port when it's gone away + if d.oobSerialPort != nil { + return d.oobSerialPort, nil + } + + if serial_console, err := console.FindUSBSerial(d.usb_console); err != nil { + return nil, fmt.Errorf("outOfBandConsole: %w", err) + } else { + d.oobSerialPort = serial_console + return d.oobSerialPort, nil + } +} + +type JumpstarterConsoleWrapper struct { + serialPort serial.Port + jumpstarterDevice *JumpstarterDevice +} + +func (c *JumpstarterDevice) getConsoleWrapper() harness.ConsoleInterface { + return &JumpstarterConsoleWrapper{ + serialPort: c.serialPort, + jumpstarterDevice: c, + } +} + +func (c *JumpstarterConsoleWrapper) Write(p []byte) (n int, err error) { + if c.serialPort == nil { + return 0, fmt.Errorf("JumpstarterConsoleWrapper: console has been closed") + } + return c.serialPort.Write(p) +} + +func (c *JumpstarterConsoleWrapper) Read(p []byte) (n int, err error) { + if c.serialPort == nil { + return 0, fmt.Errorf("JumpstarterConsoleWrapper: console has been closed") + } + return c.serialPort.Read(p) +} + +func (c *JumpstarterConsoleWrapper) Close() error { + err := c.jumpstarterDevice.exitConsole() + c.serialPort = nil + return err +} + +func (c *JumpstarterConsoleWrapper) SetReadTimeout(t time.Duration) error { + if c.serialPort == nil { + return fmt.Errorf("JumpstarterConsoleWrapper: console has been closed") + } + return c.serialPort.SetReadTimeout(t) +} diff --git a/pkg/drivers/dutlink-board/storage.go b/pkg/drivers/dutlink-board/storage.go new file mode 100644 index 0000000..8baee50 --- /dev/null +++ b/pkg/drivers/dutlink-board/storage.go @@ -0,0 +1,190 @@ +package dutlink_board + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + mapset "github.com/deckarep/golang-set/v2" + "github.com/jumpstarter-dev/jumpstarter/pkg/storage" +) + +const BASE_DISKSBYID = "/dev/disk/by-id/" + +const BLOCK_SIZE = 32 * 1024 * 1024 + +const WAIT_TIME_USB_STORAGE = 12 * time.Second +const WAIT_TIME_USB_STORAGE_OFF = 2 * time.Second +const WAIT_TIME_USB_STORAGE_DISCONNECT = 6 * time.Second + +type StorageTarget int + +const ( + HOST StorageTarget = iota + DUT + OFF +) + +func (d *JumpstarterDevice) SetDiskImage(path string, offset uint64) error { + + fmt.Print("🔍 Detecting USB storage device and connecting to host: ") + diskPath, err := d.detectStorageDevice() + if err != nil { + return fmt.Errorf("SetDiskImage: %w", err) + } + fmt.Println("done") + + fmt.Printf("📋 %s -> %s offset 0x%x: \n", path, diskPath, offset) + + if err := storage.WriteImageToDisk(path, diskPath, offset, BLOCK_SIZE, true); err != nil { + return fmt.Errorf("SetDiskImage: %w", err) + } + + if err := d.connectStorageTo(OFF); err != nil { + return fmt.Errorf("SetDiskImage: %w", err) + } + + return nil +} + +func (d *JumpstarterDevice) AttachStorage(connected bool) error { + var err error + switch connected { + case true: + err = d.connectStorageTo(DUT) + case false: + err = d.connectStorageTo(OFF) + } + if err != nil { + return fmt.Errorf("ConnectDiskImage(%v): %w", connected, err) + } + return nil +} + +func (d *JumpstarterDevice) connectStorageTo(target StorageTarget) error { + if err := d.ensureSerial(); err != nil { + return fmt.Errorf("connectStorageTo: %w", err) + } + + if err := d.exitConsole(); err != nil { + return fmt.Errorf("connectStorageTo: %w", err) + } + + var cmd string + var response string + switch target { + case HOST: + cmd = "host" + response = "connected to host" + case DUT: + cmd = "dut" + response = "connected to device" + case OFF: + cmd = "off" + response = "storage disconnected" + default: + return fmt.Errorf("connectStorageTo: invalid target %v", target) + } + + if err := d.sendAndExpect("storage "+cmd, response); err != nil { + return fmt.Errorf("connectStorageTo(%q): %w", cmd, err) + } + return nil +} + +func scanForStorageDevices(prefix string) (*mapset.Set[string], error) { + + diskSet := mapset.NewSet[string]() + + err := filepath.Walk(BASE_DISKSBYID, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() && info.Name() == "devices" { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + baseName := filepath.Base(path) + re := regexp.MustCompile(`part\d+$`) + if strings.HasPrefix(baseName, prefix) && !re.MatchString(baseName) { + diskSet.Add(path) + } + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("scanForStorageDevices: %w", err) + } + + return &diskSet, nil +} + +func (d *JumpstarterDevice) detectStorageDevice() (string, error) { + if err := d.connectStorageTo(OFF); err != nil { + return "", fmt.Errorf("detectStorageDevice: %w", err) + } + time.Sleep(WAIT_TIME_USB_STORAGE_OFF) + + // by default filter only for usb based devices, but once + // a storage filter has been configured we can ignore this filter, + // i.e. this is necessary for usb devices that enumerate as ata- + // or something else. + prefixFilter := "usb-" + + if d.storage_filter != "" { + prefixFilter = "" + } + diskSetOff, err := scanForStorageDevices(prefixFilter) + if err != nil { + return "", fmt.Errorf("detectStorageDevice: %w", err) + } + + if err := d.connectStorageTo(HOST); err != nil { + return "", fmt.Errorf("detectStorageDevice: %w", err) + } + + // get current timestamp so we can measure how long it takes to detect the new disk + start := time.Now() + + var diskSetOn *mapset.Set[string] + + for { + time.Sleep(500 * time.Millisecond) + diskSetOn, err = scanForStorageDevices("usb-") + if err != nil { + return "", fmt.Errorf("detectStorageDevice: %w", err) + } + newDiskSet := (*diskSetOn).Difference(*diskSetOff) + + diskSetFiltered := mapset.NewSet[string]() + // if more than one, attempt to filter by storage_filter + for diskPath := range newDiskSet.Iter() { + if d.storage_filter == "" || strings.Contains(diskPath, d.storage_filter) { + diskSetFiltered.Add(diskPath) + } + } + + if diskSetFiltered.Cardinality() == 1 { + diskPath, _ := diskSetFiltered.Pop() + return diskPath, nil + } + + if time.Since(start) > WAIT_TIME_USB_STORAGE { + if diskSetFiltered.Cardinality() > 1 { + return "", fmt.Errorf("detectStorageDevice: more than one new disk detected: %v, try using or narrowing the storage_filter setting", diskSetFiltered) + } + + if diskSetFiltered.Cardinality() == 0 && newDiskSet.Cardinality() != 0 { + return "", fmt.Errorf("detectStorageDevice: some disks detected %v, but nothing matches your storage_filter: %q", newDiskSet, d.storage_filter) + } + return "", fmt.Errorf("detectStorageDevice: no new disk detected after 30 seconds") + } + } + +} diff --git a/pkg/drivers/dutlink-board/udev.go b/pkg/drivers/dutlink-board/udev.go new file mode 100644 index 0000000..e62e418 --- /dev/null +++ b/pkg/drivers/dutlink-board/udev.go @@ -0,0 +1,157 @@ +package dutlink_board + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + BASE_UDEVPATH = "/sys/bus/usb/devices/" + USB_VID = "2b23" + USB_PID = "1012" +) + +/* we are looking for this info but without using udevadm +$ udevadm info --name=ttyACM0 | grep SERIAL +E: ID_SERIAL=Red_Hat_Inc._Jumpstarter_e6058905 +E: ID_SERIAL_SHORT=e6058905 +E: ID_USB_SERIAL=Red_Hat_Inc._Jumpstarter_e6058905 +E: ID_USB_SERIAL_SHORT=e6058905 + +$ udevadm info --name=ttyACM0 | grep MODEL +E: ID_MODEL=Jumpstarter +E: ID_MODEL_ENC=Jumpstarter +E: ID_MODEL_ID=1012 +E: ID_USB_MODEL=Jumpstarter +E: ID_USB_MODEL_ENC=Jumpstarter +E: ID_USB_MODEL_ID=1012 + +$ ls /sys/bus/usb/devices/1-2.5/ +1-2.5:1.0 avoid_reset_quirk bDeviceProtocol bMaxPower configuration devpath idProduct maxchild quirks serial uevent +1-2.5:1.1 bcdDevice bDeviceSubClass bNumConfigurations descriptors devspec idVendor port removable speed urbnum +1-2.5:1.2 bConfigurationValue bmAttributes bNumInterfaces dev driver ltm_capable power remove subsystem version +authorized bDeviceClass bMaxPacketSize0 busnum devnum ep_00 manufacturer product rx_lanes tx_lanes + +$ cat /sys/bus/usb/devices/1-2.5/idProduct +1012 + +$ cat /sys/bus/usb/devices/1-2.5/idVendor +2b23 + +$ cat /sys/bus/usb/devices/1-2.5/product +Jumpstarter + +$ cat /sys/bus/usb/devices/1-2.5/bcdDevice +0004 + +$ cat /sys/bus/usb/devices/1-2.5/serial +e6058905 + +$ cat /sys/bus/usb/devices/1-2.5/1-2.5\:1.0/tty/ttyACM0/uevent +MAJOR=166 +MINOR=0 +DEVNAME=ttyACM0 +*/ + +// write a function that scans the BASE_UDEVPATH for devices that match the +// vendor and product id of the DUTlink board using filepath.Walk and +// reading the right files based on the info above +// +// return a list of devices that match +// return an error if there is a problem +func scanUdev() ([]*JumpstarterDevice, error) { + res := []*JumpstarterDevice{} + + err := filepath.Walk(BASE_UDEVPATH, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() && info.Name() == "devices" { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + idProduct, err := readUdevAttribute(path, "idProduct") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + idVendor, err := readUdevAttribute(path, "idVendor") + if err != nil { + return err + } + + if idVendor != "2b23" || idProduct != "1012" { + return nil + } + + version, err := readUdevAttribute(path, "bcdDevice") + if err != nil { + return err + } + + // convert the version bcd string like 0004 to 0.04 + major, err := strconv.Atoi(version[0:2]) + if err != nil { + return fmt.Errorf("scanUdev: error parsing bcdDevice %w", err) + } + version = fmt.Sprintf("%d.%s", major, version[2:]) + + serial, err := readUdevAttribute(path, "serial") + if err != nil { + return err + } + + usbRootDevice := getUsbRootDevice(path) + ttyDir := filepath.Join(path, usbRootDevice+":1.0", "tty") + ttynames, err := ioutil.ReadDir(ttyDir) + if err != nil { + return err + } + + if len(ttynames) != 1 { + panic("expected only one tty device") + } + + jp, err := newJumpstarter(ttynames[0].Name(), version, serial) + if err != nil { + return err + } + res = append(res, &jp) + + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("scanUdev: %w", err) + } + return res, nil +} + +func readUdevAttribute(path string, attribute string) (string, error) { + value, err := ioutil.ReadFile(filepath.Join(path, attribute)) + if err != nil { + return "", err + } + valueStr := strings.TrimRight(string(value), "\r\n") + return valueStr, nil +} + +// getUsbRootDevice +// converts something like /sys/bus/usb/devices/1-2.5/ into 1-2.5 +func getUsbRootDevice(path string) string { + parts := strings.Split(path, "/") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} diff --git a/pkg/drivers/mock/console.go b/pkg/drivers/mock/console.go new file mode 100644 index 0000000..5cca459 --- /dev/null +++ b/pkg/drivers/mock/console.go @@ -0,0 +1,67 @@ +package mock + +import ( + "fmt" + "io" + "net" + "time" + + "golang.org/x/term" +) + +type MockConsole struct { + timeout time.Duration + stdin net.Conn + stdout io.ReadCloser +} + +type ReadWriter struct { + io.Reader + io.Writer +} + +func newMockConsole() (*MockConsole, error) { + ir, iw := net.Pipe() + or, ow := io.Pipe() + + terminal := term.NewTerminal(ReadWriter{Reader: ir, Writer: ow}, "[mock@jumpstarter:~]$ ") + + go func() { + for { + line, err := terminal.ReadLine() + if err != nil { + break + } + if line == "ip a show dev eth0" { + fmt.Fprintf(terminal, + `1: eth0: mtu 1500 qdisc noqueue state UP group default qlen 1000 + link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff + inet 192.0.2.2/24 metric 2048 brd 192.0.2.255 scope global dynamic eth0 + valid_lft forever preferred_lft forever +`) // FIXME: check err + } else { + fmt.Fprintf(terminal, "Error: Not Implemented\n") // FIXME: check err + } + } + }() + + return &MockConsole{timeout: time.Second, stdin: iw, stdout: or}, nil +} + +func (c *MockConsole) Read(p []byte) (n int, err error) { + c.stdin.SetReadDeadline(time.Now().Add(c.timeout)) + return c.stdout.Read(p) +} + +func (c *MockConsole) Write(p []byte) (int, error) { + return c.stdin.Write(p) +} + +func (c *MockConsole) SetReadTimeout(t time.Duration) error { + c.timeout = t + return nil +} + +func (c *MockConsole) Close() error { + return nil +} diff --git a/pkg/drivers/mock/device.go b/pkg/drivers/mock/device.go new file mode 100644 index 0000000..dff9e7d --- /dev/null +++ b/pkg/drivers/mock/device.go @@ -0,0 +1,194 @@ +package mock + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/jumpstarter-dev/jumpstarter/pkg/locking" +) + +const JUMPSTARTER_MOCK_PATH = "/tmp/jumpstarter-mock.json" + +type MockDeviceData struct { + file *os.File + Name string `json:"name"` + Device string `json:"device"` + Version string `json:"version"` + Serial string `json:"serial"` + Tags []string `json:"tags"` + Config map[string]string `json:"config"` + Control map[string]string `json:"control"` + Power string `json:"power"` + ConsoleSpeed int `json:"console_speed"` + UsbConsole string `json:"usb_console"` + ImagePath string `json:"image_path"` + ImageOffset uint64 `json:"image_offset"` + Storage bool `json:"storage"` + Busy bool `json:"busy"` +} + +func (d *MockDeviceData) load() error { + _, err := d.file.Seek(0, 0) + if err != nil { + return err + } + return json.NewDecoder(d.file).Decode(d) +} + +func (d *MockDeviceData) save() error { + _, err := d.file.Seek(0, 0) + if err != nil { + return err + } + return json.NewEncoder(d.file).Encode(d) +} + +type MockDevice struct { + driver *MockDriver + fileLock locking.Lock + data MockDeviceData +} + +func newMockDevice() (*MockDevice, error) { + file, err := os.OpenFile(JUMPSTARTER_MOCK_PATH, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + return nil, err + } + device := &MockDevice{ + driver: &MockDriver{}, + data: MockDeviceData{ + file: file, + Name: "mock", + Device: "/dev/jumpstarter-mock", + Version: "1.0.0", + Serial: "001", + Tags: []string{}, + Config: map[string]string{}, + Control: map[string]string{}, + Power: "off", + ConsoleSpeed: 0, + UsbConsole: "", + ImagePath: "", + ImageOffset: 0, + Storage: false, + Busy: false, + }, + } + device.data.load() // ignoring error + err = device.data.save() + if err != nil { + return nil, err + } + return device, nil +} + +func (d *MockDevice) Driver() harness.HarnessDriver { + return d.driver +} + +func (d *MockDevice) Version() (string, error) { + err := d.data.load() + return d.data.Version, err +} + +func (d *MockDevice) Serial() (string, error) { + err := d.data.load() + return d.data.Serial, err +} + +func (d *MockDevice) Name() string { + d.data.load() // FIXME: check err + return d.data.Name +} + +func (d *MockDevice) SetName(name string) error { + d.data.Name = name + return d.data.save() +} + +func (d *MockDevice) Tags() []string { + d.data.load() // FIXME: check err + return d.data.Tags +} + +func (d *MockDevice) SetTags(tags []string) error { + d.data.Tags = tags + return d.data.save() +} + +func (d *MockDevice) GetConfig() (map[string]string, error) { + err := d.data.load() + return d.data.Config, err +} + +func (d *MockDevice) SetConfig(k, v string) error { + d.data.Config[k] = v + return d.data.save() +} + +func (d *MockDevice) SetControl(key string, value string) error { + d.data.Control[key] = value + return d.data.save() +} + +func (d *MockDevice) Power(action string) error { + d.data.Power = action + return d.data.save() +} + +func (d *MockDevice) SetConsoleSpeed(bps int) error { + d.data.ConsoleSpeed = bps + return d.data.save() +} + +func (d *MockDevice) SetUsbConsole(name string) error { + d.data.UsbConsole = name + return d.data.save() +} + +func (d *MockDevice) SetDiskImage(path string, offset uint64) error { + d.data.ImagePath = path + d.data.ImageOffset = offset + return d.data.save() +} + +func (d *MockDevice) AttachStorage(connect bool) error { + d.data.Storage = connect + return d.data.save() +} + +func (d *MockDevice) Device() (string, error) { + err := d.data.load() + return d.data.Device, err +} + +func (d *MockDevice) IsBusy() (bool, error) { + err := d.data.load() + return d.data.Busy, err +} + +func (d *MockDevice) Console() (harness.ConsoleInterface, error) { + return newMockConsole() +} + +func (d *MockDevice) Lock() error { + err := d.data.load() + if err != nil { + return err + } + + lock, err := locking.TryLock(d.data.Device) + if err != nil { + return fmt.Errorf("Lock: locking %q %w", d.data.Device, err) + } + + d.fileLock = lock + + return nil +} + +func (d *MockDevice) Unlock() error { + return d.fileLock.Unlock() +} diff --git a/pkg/drivers/mock/driver.go b/pkg/drivers/mock/driver.go new file mode 100644 index 0000000..c9adf5a --- /dev/null +++ b/pkg/drivers/mock/driver.go @@ -0,0 +1,24 @@ +package mock + +import ( + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +type MockDriver struct{} + +func (d *MockDriver) Name() string { + return "mock" +} + +func (d *MockDriver) Description() string { + return `Mock harness for testing.` +} + +func (d *MockDriver) FindDevices() ([]harness.Device, error) { + device, err := newMockDevice() + if err != nil { + return nil, err + } + hdList := []harness.Device{device} + return hdList, nil +} diff --git a/pkg/drivers/mock/init.go b/pkg/drivers/mock/init.go new file mode 100644 index 0000000..ba01564 --- /dev/null +++ b/pkg/drivers/mock/init.go @@ -0,0 +1,11 @@ +//go:build mock + +package mock + +import ( + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func init() { + harness.RegisterDriver(&MockDriver{}) +} diff --git a/pkg/drivers/sd-wire/config.go b/pkg/drivers/sd-wire/config.go new file mode 100644 index 0000000..87c3f2a --- /dev/null +++ b/pkg/drivers/sd-wire/config.go @@ -0,0 +1,44 @@ +package sdwire + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// yaml parser + +// see etc/jumpstarter/sd-wire/device0.yaml example file +type SDWireConfig struct { + Serial string `yaml:"serial"` + Tags []string `yaml:"tags"` + USBConsole string `yaml:"usb_console"` + SmartPlug *SmartPlugConfig `yaml:"smartplug"` +} + +type SmartPlugConfig struct { + Generic *SmartPlugGenericConfig `yaml:"generic"` +} + +type SmartPlugGenericConfig struct { + OnCommand string `yaml:"on_command"` + OffCommand string `yaml:"off_command"` +} + +const CONFIG_BASE_PATH = "/etc/jumpstarter/sd-wire" + +func ReadConfig(serial string) (*SDWireConfig, error) { + yaml_file := CONFIG_BASE_PATH + "/" + serial + ".yaml" + script_data, err := os.ReadFile(yaml_file) + if err != nil { + return nil, fmt.Errorf("ReadConfig(%q): Error reading yaml file: %w", yaml_file, err) + } + config := SDWireConfig{} + if err := yaml.Unmarshal([]byte(script_data), &config); err != nil { + return nil, fmt.Errorf("ReadConfig(%q): %w", yaml_file, err) + } + + // TODO: Apply sanity checks to configuration + return &config, nil +} diff --git a/pkg/drivers/sd-wire/device.go b/pkg/drivers/sd-wire/device.go new file mode 100644 index 0000000..671dce1 --- /dev/null +++ b/pkg/drivers/sd-wire/device.go @@ -0,0 +1,117 @@ +package sdwire + +import ( + "fmt" + "os" + "sync" + + "github.com/jumpstarter-dev/jumpstarter/pkg/console" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/jumpstarter-dev/jumpstarter/pkg/locking" + "github.com/jumpstarter-dev/jumpstarter/pkg/tools" + "go.bug.st/serial" +) + +type SDWireDevice struct { + driver *SDWireDriver + config *SDWireConfig + devicePath string + storagePath string + name string + serialPort serial.Port + fileLock locking.Lock + mutex *sync.Mutex + singletonMutex *sync.Mutex + busy bool +} + +func (d *SDWireDevice) Lock() error { + lock, err := locking.TryLock(d.devicePath) + d.fileLock = lock + if err != nil { + return fmt.Errorf("Lock: locking %q %w", d.devicePath, err) + } + return nil +} + +func (d *SDWireDevice) Unlock() error { + return d.fileLock.Unlock() +} + +func (d *SDWireDevice) Power(action string) error { + if d.config.SmartPlug == nil || d.config.SmartPlug.Generic == nil { + return fmt.Errorf("Power: no smart plug configured %+v", d.config) + } + + if action == "on" || action == "force-on" { + return tools.RunBash(d.config.SmartPlug.Generic.OnCommand, nil, os.Stderr) + } + + if action == "off" || action == "force-off" { + return tools.RunBash(d.config.SmartPlug.Generic.OffCommand, nil, os.Stderr) + } + + return nil +} + +func (d *SDWireDevice) Console() (harness.ConsoleInterface, error) { + return console.OpenUSBSerial(d.devicePath) +} + +func (d *SDWireDevice) SetConsoleSpeed(bps int) error { + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) Driver() harness.HarnessDriver { + return d.driver +} + +func (d *SDWireDevice) Version() (string, error) { + return "0.1", nil +} + +func (d *SDWireDevice) Serial() (string, error) { + return d.name, nil +} + +func (d *SDWireDevice) SetControl(signal string, value string) error { + + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) Device() (string, error) { + return d.devicePath, nil +} + +func (d *SDWireDevice) GetConfig() (map[string]string, error) { + config := map[string]string{} + return config, nil +} + +func (d *SDWireDevice) SetConfig(k, v string) error { + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) SetName(name string) error { + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) SetUsbConsole(usb_console string) error { + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) SetTags(tags []string) error { + return harness.ErrNotImplemented +} + +func (d *SDWireDevice) Tags() []string { + return d.config.Tags +} + +func (d *SDWireDevice) IsBusy() (bool, error) { + return d.busy, nil +} + +func (d *SDWireDevice) Name() string { + return d.name +} diff --git a/pkg/drivers/sd-wire/driver.go b/pkg/drivers/sd-wire/driver.go new file mode 100644 index 0000000..bf7c2eb --- /dev/null +++ b/pkg/drivers/sd-wire/driver.go @@ -0,0 +1,39 @@ +package sdwire + +import ( + "fmt" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +type SDWireDriver struct{} + +func (d *SDWireDriver) Name() string { + return "sd-wire-splug" +} + +func (d *SDWireDriver) Description() string { + return `SD-Wire + smart plug + usb-serial based driver + enables the control of Edge and Embedded devices via smart plug and usb-serial, + controlling storage via an Open Hardware sd-wire device. + https://shop.3mdeb.com/shop/open-source-hardware/sdwire/ + It has the following capabilities: power metering, power cycling, and serial console + access, and USB storage switching. + ` +} + +func (d *SDWireDriver) FindDevices() ([]harness.Device, error) { + hdList := []harness.Device{} + sdwires, err := scanUdev() + if err != nil { + return nil, fmt.Errorf("FindDevices: %w", err) + } + for _, jumpstarter := range sdwires { + hdList = append(hdList, jumpstarter) + } + return hdList, nil +} + +func init() { + harness.RegisterDriver(&SDWireDriver{}) +} diff --git a/pkg/drivers/sd-wire/storage.go b/pkg/drivers/sd-wire/storage.go new file mode 100644 index 0000000..3345437 --- /dev/null +++ b/pkg/drivers/sd-wire/storage.go @@ -0,0 +1,64 @@ +package sdwire + +import ( + "fmt" + "os" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/storage" + "github.com/jumpstarter-dev/jumpstarter/pkg/tools" +) + +const BASE_DISKSBYID = "/dev/disk/by-id/" + +const BLOCK_SIZE = 8 * 1024 * 1024 + +const WAIT_TIME_USB_STORAGE = 8 * time.Second + +type StorageTarget int + +const ( + HOST StorageTarget = iota + DUT +) + +func (d *SDWireDevice) SetDiskImage(path string, offset uint64) error { + + err := d.AttachStorage(false) // attach to host, detach from DUT + if err != nil { + return fmt.Errorf("SetDiskImage: %w", err) + } + for i := 0; i < 10; i++ { + // check if path exists + if _, err := os.Stat(d.storagePath); err == nil { + break + } + time.Sleep(1 * time.Second) + } + + if _, err := os.Stat(d.storagePath); err != nil { + return fmt.Errorf("SetDiskImage: timeout waiting for device %q to come up", path) + } + + fmt.Printf("📋 %s -> %s offset 0x%x: \n", path, d.storagePath, offset) + + if err := storage.WriteImageToDisk(path, d.storagePath, offset, BLOCK_SIZE, false); err != nil { + return fmt.Errorf("SetDiskImage: %w", err) + } + + return d.AttachStorage(true) // attach to DUT, detach from host +} + +func (d *SDWireDevice) AttachStorage(connected bool) error { + var err error + switch connected { + case true: + err = tools.RunBash("sd-mux-ctrl --device-serial="+d.name+" --dut", os.Stdout, os.Stderr) + case false: + err = tools.RunBash("sd-mux-ctrl --device-serial="+d.name+" --ts", os.Stdout, os.Stderr) + } + if err != nil { + return fmt.Errorf("AttachStorage(%v): %w", connected, err) + } + return nil +} diff --git a/pkg/drivers/sd-wire/udev.go b/pkg/drivers/sd-wire/udev.go new file mode 100644 index 0000000..f836ce1 --- /dev/null +++ b/pkg/drivers/sd-wire/udev.go @@ -0,0 +1,125 @@ +package sdwire + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jumpstarter-dev/jumpstarter/pkg/console" +) + +const ( + BASE_UDEVPATH = "/sys/bus/usb/devices/" + USB_VID = "0424" + USB_PID = "2640" +) + +func scanUdev() ([]*SDWireDevice, error) { + res := []*SDWireDevice{} + + err := filepath.Walk(BASE_UDEVPATH, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() && info.Name() == "devices" { + return nil + } + + if info.Mode()&os.ModeSymlink != 0 { + idProduct, err := readUdevAttribute(path, "idProduct") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + idVendor, err := readUdevAttribute(path, "idVendor") + if err != nil { + return err + } + + if idVendor != USB_VID || idProduct != USB_PID { + return nil + } + + usbRootDevice := getUsbRootDevice(path) + + // read serial from the ftdi device inside the hub + serial, err := readUdevAttribute(path+"/"+usbRootDevice+".2", "serial") + if err != nil { + return err + } + + // find the block device name + bdDir := fmt.Sprintf("%s/%s.1/%s.1:1.0/host2/target2:0:0/2:0:0:0/block/", path, usbRootDevice, usbRootDevice) + var bdDev string + entries, err := os.ReadDir(bdDir) + if err != nil { + return fmt.Errorf("scanUdev: scanning for block files in sd-wire device %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + bdDev = "/dev/" + entry.Name() + break + } + } + + if bdDev == "" { + return fmt.Errorf("scanUdev: no block device found in sd-wire device %s", serial) + } + + cfg, err := ReadConfig(serial) + + if err != nil && !os.IsNotExist(err) { + fmt.Printf("Warning: a sdwire device with serial %q was found but no config file was found: %v\n", serial, err) + return nil + } + + device, err := console.FindUSBSerialDevice(cfg.USBConsole) + if errors.Is(err, console.ErrDeviceNotFound) { + fmt.Printf("Warning: a sdwire device with serial %q cannot find serial interface matching: %v\n", cfg.USBConsole, err) + return nil + } + res = append(res, &SDWireDevice{ + name: serial, + devicePath: device, + storagePath: bdDev, + config: cfg, + driver: &SDWireDriver{}, + }) + + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("scanUdev: %w", err) + } + return res, nil +} + +// TODO Refactor into udev utils module +func readUdevAttribute(path string, attribute string) (string, error) { + value, err := os.ReadFile(filepath.Join(path, attribute)) + if err != nil { + return "", err + } + valueStr := strings.TrimRight(string(value), "\r\n") + return valueStr, nil +} + +// TODO Refactor into udev utils module +// getUsbRootDevice +// converts something like /sys/bus/usb/devices/1-2.5/ into 1-2.5 +func getUsbRootDevice(path string) string { + parts := strings.Split(path, "/") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} diff --git a/pkg/harness/device.go b/pkg/harness/device.go new file mode 100644 index 0000000..8828ddf --- /dev/null +++ b/pkg/harness/device.go @@ -0,0 +1,34 @@ +package harness + +import ( + "io" + "time" +) + +type ConsoleInterface interface { + io.ReadWriteCloser + SetReadTimeout(t time.Duration) error +} + +type Device interface { + Driver() HarnessDriver + Power(action string) error + Console() (ConsoleInterface, error) + SetConsoleSpeed(bps int) error + Version() (string, error) + Name() string // name of the device, can be assigned by the user + Tags() []string // tags assigned to the device, can be assigned by the user + SetName(name string) error // set the name of the device, should be stored in config or flashed to device + SetUsbConsole(name string) error // set the substring of an out of band console name for this device + SetTags(tags []string) error + SetConfig(k, v string) error + GetConfig() (map[string]string, error) + Serial() (string, error) + SetDiskImage(path string, offset uint64) error + AttachStorage(connect bool) error + SetControl(key string, value string) error + Device() (string, error) + IsBusy() (bool, error) + Lock() error // open/lock the device so other instances cannot use it + Unlock() error // close the locked device so other instances can use it +} diff --git a/pkg/harness/driver.go b/pkg/harness/driver.go new file mode 100644 index 0000000..e97e5b6 --- /dev/null +++ b/pkg/harness/driver.go @@ -0,0 +1,99 @@ +package harness + +import ( + "fmt" + "strings" +) + +type HarnessDriver interface { + Name() string + Description() string + FindDevices() ([]Device, error) +} + +// The initial implementation has only one type of driver, the dutlink-board driver +// but this could be extended to support other types of drivers for HIL devices. + +var drivers = []HarnessDriver{} + +func RegisterDriver(driver HarnessDriver) { + drivers = append(drivers, driver) +} + +func GetDrivers() []HarnessDriver { + return drivers +} + +// FindDevices iterates over the available drivers and gets a list of devices. +// If a driver is specified, only devices for that driver are returned. + +func FindDevices(driverName string, tags []string) ([]Device, int, error) { + var devices []Device + busyCount := 0 + for _, driver := range drivers { + if driverName != "" && driverName != driver.Name() { + continue // skip this driver + } + + d, err := driver.FindDevices() + if err != nil { + return nil, 0, fmt.Errorf("(%q).FindDevices: %w", driver.Name(), err) + } + for _, device := range d { + if busy, _ := device.IsBusy(); busy { + busyCount++ + } + if len(tags) != 0 { + deviceTags := device.Tags() + if contains_tags(deviceTags, tags) { + devices = append(devices, device) + } + } else { + devices = append(devices, device) + } + } + + } + return devices, busyCount, nil +} + +func contains_tags(slice []string, tags []string) bool { + for _, tag := range tags { + if !contains_tag(slice, tag) { + return false + } + } + return true +} + +func contains_tag(slice []string, str string) bool { + for _, s := range slice { + + if strings.ToLower(s) == strings.ToLower(str) { + return true + } + } + return false +} + +// FindDevice iterates over the available drivers and return a specific Device. +// If a driver is specified, only devices for that driver are returned. +func FindDevice(driverName string, deviceId string) (Device, error) { + devices, _, err := FindDevices(driverName, []string{}) + if err != nil { + return nil, fmt.Errorf("FindDevices: %w", err) + } + + for _, device := range devices { + name := device.Name() + + serialNumber, err := device.Serial() + if err != nil { + return nil, fmt.Errorf("FindDevice (%q).SerialNumber: %w", device.Driver().Name(), err) + } + if name == deviceId || serialNumber == deviceId { + return device, nil + } + } + return nil, fmt.Errorf("FindDevice: %q not found", deviceId) +} diff --git a/pkg/harness/errors.go b/pkg/harness/errors.go new file mode 100644 index 0000000..0753d0b --- /dev/null +++ b/pkg/harness/errors.go @@ -0,0 +1,11 @@ +package harness + +import "errors" + +// basic errors +var ErrDeviceInUse = errors.New("device is in use") +var ErrCannotOpenDevice = errors.New("unable to open device tty") +var ErrNotImplemented = errors.New("not implemented") +var ErrCannotSetBaudRate = errors.New("unable to set baud rate") +var ErrCannotSetDiskImage = errors.New("unable to set disk image") +var ErrDeviceNotResponding = errors.New("device not responding") diff --git a/pkg/locking/locking.go b/pkg/locking/locking.go new file mode 100644 index 0000000..f8fcef6 --- /dev/null +++ b/pkg/locking/locking.go @@ -0,0 +1,71 @@ +package locking + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +const JUMPSTARTER_LOCK_BASE = "/tmp/jumpstarter-locks/" + +func init() { + if _, err := os.Stat(JUMPSTARTER_LOCK_BASE); os.IsNotExist(err) { + umask := syscall.Umask(0) + err := os.Mkdir(JUMPSTARTER_LOCK_BASE, 0777) + syscall.Umask(umask) + + if err != nil { + fmt.Printf("Error creating %s\n", JUMPSTARTER_LOCK_BASE) + os.Exit(1) + } + } +} + +type Lock struct { + fd int + fileName string +} + +func TryLock(devicePath string) (Lock, error) { + baseName := filepath.Base(devicePath) + fileLock := JUMPSTARTER_LOCK_BASE + "LCK.." + baseName + + umask := syscall.Umask(0) + fd, err := syscall.Open(fileLock, syscall.O_CREAT|syscall.O_RDONLY, 0666) + syscall.Umask(umask) + if err != nil { + return Lock{}, fmt.Errorf("TryLock: opening %q: %w", fileLock, err) + } + + err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) + + if err == syscall.EWOULDBLOCK { + syscall.Close(fd) + return Lock{}, harness.ErrDeviceInUse + } + + if err != nil { + syscall.Close(fd) + } + + return Lock{fd, fileLock}, nil +} + +func (l *Lock) Unlock() error { + err := syscall.Flock(l.fd, syscall.LOCK_UN) + if err != nil { + return fmt.Errorf("Unlock: %w", err) + } + err = syscall.Close(l.fd) + if err != nil { + return fmt.Errorf("Unlock: %w", err) + } + err = os.Remove(l.fileName) + if err != nil { + return fmt.Errorf("Unlock: %w", err) + } + return nil +} diff --git a/pkg/runner/errors.go b/pkg/runner/errors.go new file mode 100644 index 0000000..0f1f12f --- /dev/null +++ b/pkg/runner/errors.go @@ -0,0 +1,8 @@ +package runner + +import "errors" + +// basic errors +var ErrNoDevices = errors.New("no suitable devices were found") +var ErrAllDevicesBusy = errors.New("no available devices found, possible runners but busy") +var ErrAllDevicesBusyTimeout = errors.New("timed out waiting for devices to become available") diff --git a/pkg/runner/run.go b/pkg/runner/run.go new file mode 100644 index 0000000..33c6c69 --- /dev/null +++ b/pkg/runner/run.go @@ -0,0 +1,245 @@ +package runner + +import ( + "errors" + "fmt" + "math/rand" + "os" + "time" + + "github.com/fatih/color" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "gopkg.in/yaml.v3" +) + +func RunScript(device_id, driver, yaml_file string, disableCleanup bool) error { + + // parse yaml file into a JumpstarterScript struct + script := JumpstarterScript{} + + // read yaml file + if err := readScript(yaml_file, &script); err != nil { + return fmt.Errorf("RunScript: %w", err) + } + // TODO: check if the yaml contents are consistent + + var device harness.Device + + // get current time so we can check for timeout later on the loop + startTime := time.Now() + + firstAttempt := true + for device == nil && time.Since(startTime) < time.Duration(script.Timeout)*time.Second { + var err error + device, err = script.getDevice(device_id, driver) + switch { + case errors.Is(err, ErrAllDevicesBusy): + if firstAttempt { + fmt.Print("⌛ Waiting for available devices: ") + firstAttempt = false + } + fmt.Print("·") + time.Sleep(15 * time.Second) + + case err != nil: + return fmt.Errorf("RunScript: %w", err) + } + } + + if device == nil { + fmt.Println("❌") + return ErrAllDevicesBusyTimeout + } + + // if we wrote the line "Waiting for available devices" before, we need to confirm what happened + if !firstAttempt { + fmt.Println(" a device became available ✅") + } + + color.Set(color.FgHiYellow) + fmt.Printf("⚙ Using device %q with tags %v\n", device.Name(), device.Tags()) + color.Unset() + + return script.run(device, disableCleanup) +} + +func (p *JumpstarterStep) run(device harness.Device) StepResult { + if p.Comment == nil { + printHeader("Step", p.getName()) + } + // we must also close the Comment.run output when necessary + defer endHeader() + + switch { + case p.Comment != nil: + return p.Comment.run(device) + + case p.SetDiskImage != nil: + return p.SetDiskImage.run(device) + + case p.Expect != nil: + if p.Expect.Timeout == 0 { + p.Expect.Timeout = uint(p.parent.ExpectTimeout) + } + return p.Expect.run(device) + + case p.Send != nil: + return p.Send.run(device) + + case p.Storage != nil: + return p.Storage.run(device) + + case p.Power != nil: + return p.Power.run(device) + + case p.Reset != nil: + return p.Reset.run(device) + + case p.Pause != nil: + return p.Pause.run(device) + + case p.WriteAnsibleInventory != nil: + return p.WriteAnsibleInventory.run(device) + + case p.LocalShell != nil: + return p.LocalShell.run(device) + } + + return StepResult{ + status: Fatal, + err: fmt.Errorf("invalid task: %s", p.getName()), + } +} + +func (p *JumpstarterScript) getDevice(device_id string, driver string) (harness.Device, error) { + if device_id != "" { + device, err := harness.FindDevice(driver, device_id) + if err != nil { + return nil, fmt.Errorf("getDevice: %w", err) + } + return device, nil + } else { + + devices, busyCount, err := harness.FindDevices(driver, p.Selector) + if err != nil { + return nil, fmt.Errorf("getDevice: %w", err) + } + + nonBusy := filterOutBusy(devices) + + if len(devices) == 0 && busyCount == 0 { + return nil, ErrNoDevices + } + + if len(devices) == 0 && busyCount > 0 { + // TODO: the dutlink driver cannot really read tags while busy yet + return nil, ErrAllDevicesBusy + } + + if len(nonBusy) == 0 { + return nil, ErrAllDevicesBusy + } + + device := nonBusy[rand.Intn(len(nonBusy))] + if err := device.Lock(); err != nil { + + return nil, fmt.Errorf("getDevice: tried to open a device: %w", err) + } + return device, nil + } +} + +func (p *JumpstarterScript) runScriptSteps(device harness.Device) error { + return p.runTasks(&(p.Steps), device) +} + +func (p *JumpstarterScript) runScriptCleanup(device harness.Device) error { + printHeader("Cleanup", p.Name) + defer endHeader() + return p.runTasks(&(p.Cleanup), device) + +} + +func (p *JumpstarterScript) run(device harness.Device, disableCleanup bool) error { + var errCleanup error + errTasks := p.runScriptSteps(device) + + if disableCleanup { + color.Set(color.FgHiYellow) + fmt.Printf("⚠ Cleaning phase has been skipped based on the request") + color.Unset() + } else { + errCleanup = p.runScriptCleanup(device) + } + if errCleanup != nil { + if errTasks != nil { + return fmt.Errorf("errors during script run %w and cleanup: %w", errTasks, errCleanup) + } else { + return fmt.Errorf("errors during script cleanup: %w", errCleanup) + } + } + if errTasks != nil { + return fmt.Errorf("errors during script run: %w", errTasks) + } + return nil +} + +func (p *JumpstarterScript) runTasks(steps *[]JumpstarterStep, device harness.Device) error { + + for _, task := range *steps { + task.parent = p // The yaml parser does not do this, but we do it here + res := task.run(device) + switch res.status { + case SilentOk: + + case Done: + color.Set(color.FgHiGreen) + fmt.Printf("[✓] done\n\n") + color.Unset() + case Fatal: + color.Set(color.FgHiRed) + fmt.Printf("[x] failed\n\n") + color.Unset() + return fmt.Errorf("runTasks: %w", res.err) + } + } + return nil +} + +func filterOutBusy(devices []harness.Device) []harness.Device { + var freeDevices []harness.Device + for _, device := range devices { + if busy, _ := device.IsBusy(); !busy { + freeDevices = append(freeDevices, device) + } + } + return freeDevices +} + +func readScript(yaml_file string, script *JumpstarterScript) error { + script_data, err := os.ReadFile(yaml_file) + if err != nil { + return fmt.Errorf("readScript(%q): Error reading yaml file: %w", yaml_file, err) + } + + if err := yaml.Unmarshal([]byte(script_data), &script); err != nil { + return fmt.Errorf("readScript(%q): %w", yaml_file, err) + } + return nil +} + +func printHeader(header, name string) { + fmt.Println(getHeader(header, name)) +} + +func endHeader() { + if os.Getenv("GITHUB_ACTIONS") == "true" { + fmt.Println("::endgroup::") + } +} +func getHeader(header, name string) string { + if os.Getenv("GITHUB_ACTIONS") == "true" { + return fmt.Sprintf("::group:: %s ➤ %s", header, name) + } + return fmt.Sprintf("➤ %s ➤ %s", header, name) +} diff --git a/pkg/runner/script.go b/pkg/runner/script.go new file mode 100644 index 0000000..0e468e7 --- /dev/null +++ b/pkg/runner/script.go @@ -0,0 +1,162 @@ +package runner + +import ( + "fmt" + "strings" + + "github.com/creasty/defaults" +) + +// yaml parser + +type JumpstarterScript struct { + Name string `yaml:"name"` + Selector []string `yaml:"selector"` + Timeout uint `default:"1800" yaml:"timeout"` + Drivers []string `yaml:"drivers"` + ExpectTimeout uint `default:"120" yaml:"expect-timeout"` + Steps []JumpstarterStep `yaml:"steps"` + Cleanup []JumpstarterStep `yaml:"cleanup"` +} + +func (e *JumpstarterScript) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Set the defaults defined in struct metadata + defaults.Set(e) + type plain JumpstarterScript + if err := unmarshal((*plain)(e)); err != nil { + return err + } + return nil +} + +type JumpstarterStep struct { + // name of the task + Name string `yaml:"name"` + SetDiskImage *SetDiskImageStep `yaml:"set-disk-image,omitempty"` + Expect *ExpectStep `yaml:"expect,omitempty"` + Send *SendStep `yaml:"send,omitempty"` + Storage *StorageStep `yaml:"storage,omitempty"` + Power *PowerStep `yaml:"power,omitempty"` + Reset *ResetStep `yaml:"reset,omitempty"` + Pause *PauseStep `yaml:"pause,omitempty"` + Comment *CommentStep `yaml:"comment,omitempty"` + WriteAnsibleInventory *WriteAnsibleInventoryStep `yaml:"write-ansible-inventory,omitempty"` + LocalShell *LocalShellStep `yaml:"local-shell,omitempty"` + parent *JumpstarterScript +} + +type SetDiskImageStep struct { + Image string `yaml:"image"` + AttachStorage bool `yaml:"attach_storage"` + OffsetGB uint `yaml:"offset-gb"` +} + +type ExpectStep struct { + This string `yaml:"this"` + Fatal string `yaml:"fatal"` + Echo bool `default:"true" yaml:"echo"` + DebugEscapes bool `default:"false" yaml:"debug_escapes"` + Timeout uint `yaml:"timeout"` +} + +func (e *ExpectStep) UnmarshalYAML(unmarshal func(interface{}) error) error { + defaults.Set(e) + type plain ExpectStep + if err := unmarshal((*plain)(e)); err != nil { + return err + } + + return nil +} + +type ResetStep struct { + TimeMs uint `yaml:"time_ms"` +} + +type PauseStep uint + +type WriteAnsibleInventoryStep struct { + Filename string `default:"inventory" yaml:"filename"` + User string `default:"root" yaml:"user"` + SshKey string `yaml:"ssh_key"` +} + +func (e *WriteAnsibleInventoryStep) UnmarshalYAML(unmarshal func(interface{}) error) error { + defaults.Set(e) + type plain WriteAnsibleInventoryStep + if err := unmarshal((*plain)(e)); err != nil { + return err + } + + return nil +} + +type LocalShellStep struct { + Script string `yaml:"script"` +} + +type SendStep struct { + This []string `yaml:"this"` + DelayMs uint `default:"100" yaml:"delay_ms"` + Echo bool `default:"true" yaml:"echo"` + DebugEscapes bool `default:"false" yaml:"debug_escapes"` +} + +func (s *SendStep) UnmarshalYAML(unmarshal func(interface{}) error) error { + defaults.Set(s) + type plain SendStep + if err := unmarshal((*plain)(s)); err != nil { + return err + } + + return nil +} + +type StorageStep string +type PowerStep string +type CommentStep string + +// a type enum with changed, ok, error +type TaskStatus int + +const ( + Done TaskStatus = iota + SilentOk + Fatal +) + +type StepResult struct { + status TaskStatus + err error +} + +func (p *JumpstarterStep) getName() string { + if p.Name != "" { + return p.Name + } + + switch { + case p.SetDiskImage != nil: + return fmt.Sprintf("set-disk-image: %v", p.SetDiskImage.Image) + case p.Expect != nil: + return fmt.Sprintf("expect: %q", p.Expect.This) // we should add a getName method instead + case p.Send != nil: + return fmt.Sprintf("send: %v", strings.Replace(strings.Join(p.Send.This, ", "), "\n", "", -1)) + case p.Storage != nil: + return fmt.Sprintf("storage: %q", string(*p.Storage)) + case p.Power != nil: + return fmt.Sprintf("power: %q", string(*p.Power)) + case p.Reset != nil: + return "reset" + case p.Pause != nil: + return fmt.Sprintf("pause: %d", *p.Pause) + case p.WriteAnsibleInventory != nil: + return "write-ansible-inventory" + case p.LocalShell != nil: + return "local-shell" + case p.Comment != nil: + return string(*p.Comment) + default: + return "unknown" + } +} diff --git a/pkg/runner/script_test.go b/pkg/runner/script_test.go new file mode 100644 index 0000000..89ab5a4 --- /dev/null +++ b/pkg/runner/script_test.go @@ -0,0 +1,141 @@ +package runner + +import ( + "log" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestScriptParsing(t *testing.T) { + var playbook = ` + name: "Test Jetson kmods" + selector: + - orin + + expect-timeout: 60 + + steps: + - comment: "Powering off and writing the image to disk" + - power: off + + - set-disk-image: + image: "rhel-guest-image.raw" + + - storage: attach + + - power: on + + - expect: + this: "login: " + debug_escapes: false + timeout: 300 + - send: + this: + - "root\n" + - "redhat\n" + + - pause: 3 + + - expect: + this: "[root@localhost ~]#" + debug_escapes: false + + - send: + echo: true + this: + - "sudo dnf update -y\n" + + - expect: + timeout: 120 + echo: true + debug_escapes: false + this: "Complete" + + - expect: + debug_escapes: false + this: "[root@localhost ~]#" + + - send: + echo: true + this: + - "sudo dnf install -y nvidia-jetpack-kmod\n" + + - expect: + timeout: 120 + echo: true + debug_escapes: false + this: "Complete" + + - send: + debug_escapes: false + this: + - "reboot\n" + + - expect: + this: "login: " + debug_escapes: false + timeout: 500 # the kmod boot takes very long because of some issues with the crypto modules from nvidia + + - send: + this: + - "root\n" + - "redhat\n" + + - send: + echo: false # we dont want to capture any of the output so expect will catch it later + this: + - "\n" + - "\n" + + - expect: + debug_escapes: false + echo: true + this: "[root@localhost ~]#" + + - send: + echo: false # we dont want to capture any of the output so expect will catch it + this: + - "lsmod | grep --color=never nv\n" + + - expect: + echo: true + this: "nvgpu" + + - write-ansible-inventory: + filename: "inventory.yaml" + ssh_key: ~/.ssh/id_rsa + + - local-shell: + script: | + ansible -m ping -i inventory.yaml all + + cleanup: + - send: + debug_escapes: false + this: + - "poweroff\n" + - pause: 5 + + - power: off + + - storage: detach + + +` + // parse yaml file into a JumpstarterPlaybook struct + script := JumpstarterScript{} + + err := yaml.Unmarshal([]byte(playbook), &script) + if err != nil { + log.Fatalf("Unmarshal: %v", err) + } + + if err != nil { + t.Errorf("Expected no parsing error, got %v", err) + } + + if len(script.Steps) != 23 { + t.Errorf("Expected 12 steps, got %d", len(script.Steps)) + } +} diff --git a/pkg/runner/step_comment.go b/pkg/runner/step_comment.go new file mode 100644 index 0000000..8df787a --- /dev/null +++ b/pkg/runner/step_comment.go @@ -0,0 +1,20 @@ +package runner + +import ( + "fmt" + + "github.com/fatih/color" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *CommentStep) run(device harness.Device) StepResult { + + color.Set(color.FgHiYellow) + fmt.Printf("\n➤ %s\n", string(*t)) + color.Unset() + return StepResult{ + status: SilentOk, + err: nil, + } +} diff --git a/pkg/runner/step_expect.go b/pkg/runner/step_expect.go new file mode 100644 index 0000000..655c9b9 --- /dev/null +++ b/pkg/runner/step_expect.go @@ -0,0 +1,78 @@ +package runner + +import ( + "fmt" + "time" + + "github.com/fatih/color" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *ExpectStep) run(device harness.Device) StepResult { + + console, err := device.Console() + if err != nil { + return StepResult{ + status: Fatal, + err: fmt.Errorf("Expect:run(%q) opening console: %w", t.This, err), + } + } + + console.SetReadTimeout(time.Second) + + startTime := time.Now() + expected := t.This + timeout := float64(t.Timeout) + p := 0 + received := "" + buf := make([]byte, 1) + + for p < len(expected) { + n, err := console.Read(buf) + if err != nil { + return StepResult{ + status: Fatal, + err: fmt.Errorf("Expect:run(%q) reading %w", expected, err), + } + } + if n == 0 { + if time.Since(startTime).Seconds() > timeout { + color.Set(color.FgRed) + fmt.Printf("\n\nexpecting %q and timed out after %d seconds.\n", expected, t.Timeout) + color.Unset() + + return StepResult{ + status: Fatal, + err: fmt.Errorf("Expect:run(%q) timeout", expected), + } + } + continue + } + c := buf[0] + if t.Echo { + if c != '\x1b' || !t.DebugEscapes { + fmt.Print(string(c)) + } else { + fmt.Print("\n") + } + + } + received += string(c) + if c == expected[p] { + p++ + } else { + if c != expected[0] { + p = 0 + } else { + p = 1 + } + } + } + + fmt.Println("") + + return StepResult{ + status: SilentOk, + err: nil, + } +} diff --git a/pkg/runner/step_local_shell.go b/pkg/runner/step_local_shell.go new file mode 100644 index 0000000..54b3830 --- /dev/null +++ b/pkg/runner/step_local_shell.go @@ -0,0 +1,49 @@ +package runner + +import ( + "os" + "os/exec" + "strings" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *LocalShellStep) run(device harness.Device) StepResult { + /* + // create a temporary file in /tmp + file, err := os.CreateTemp("/tmp", "jumpstarter-*-localshell.sh") + if err != nil { + return TaskResult{ + status: Fatal, + err: err, + } + } + file.WriteString(t.Script) + file.Close() + + defer os.Remove(file.Name()) + + // run the script + cmd := "bash " + file.Name() + // execute in system + + */ + + cmd := exec.Command("bash") + cmd.Stdin = strings.NewReader("set -x\n" + t.Script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + err := cmd.Run() + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + + return StepResult{ + status: SilentOk, + err: nil, + } + +} diff --git a/pkg/runner/step_pause.go b/pkg/runner/step_pause.go new file mode 100644 index 0000000..460f26f --- /dev/null +++ b/pkg/runner/step_pause.go @@ -0,0 +1,20 @@ +package runner + +import ( + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *PauseStep) run(device harness.Device) StepResult { + s := *t + if s < 1 { + s = 1 + } + time.Sleep(time.Duration(s) * time.Second) + + return StepResult{ + status: Done, + err: nil, + } +} diff --git a/pkg/runner/step_power.go b/pkg/runner/step_power.go new file mode 100644 index 0000000..acef84f --- /dev/null +++ b/pkg/runner/step_power.go @@ -0,0 +1,43 @@ +package runner + +import ( + "strings" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *PowerStep) run(device harness.Device) StepResult { + action := strings.ToLower(string(*t)) + switch action { + case "cycle": + err := device.Power("off") + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + time.Sleep(2 * time.Second) + err = device.Power("on") + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + default: + err := device.Power(action) + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + } + + return StepResult{ + status: Done, + err: nil, + } +} diff --git a/pkg/runner/step_reset.go b/pkg/runner/step_reset.go new file mode 100644 index 0000000..cc307f9 --- /dev/null +++ b/pkg/runner/step_reset.go @@ -0,0 +1,40 @@ +package runner + +import ( + "fmt" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *ResetStep) run(device harness.Device) StepResult { + + fmt.Println("Resetting device...") + err := device.SetControl("r", "l") + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + + ms := t.TimeMs + if ms < 1000 { + ms = 1000 + } + + time.Sleep(time.Duration(ms) * time.Millisecond) + + err = device.SetControl("r", "z") + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + + return StepResult{ + status: Done, + err: nil, + } +} diff --git a/pkg/runner/step_send.go b/pkg/runner/step_send.go new file mode 100644 index 0000000..688be33 --- /dev/null +++ b/pkg/runner/step_send.go @@ -0,0 +1,95 @@ +package runner + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/fatih/color" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *SendStep) run(device harness.Device) StepResult { + + console, err := device.Console() + if err != nil { + return StepResult{ + status: Fatal, + err: fmt.Errorf("Expect:run(%q) opening console: %w", t.This, err), + } + } + + for _, send := range t.This { + time.Sleep(time.Duration(t.DelayMs) * time.Millisecond) + converted := sendStringParser(send) + console.Write([]byte(converted)) + color.Set(color.FgYellow) + fmt.Println("\n\nsent:", send) + color.Unset() + + if t.Echo { + monitorOutput(console, t.DebugEscapes) + } + } + + fmt.Println("") + + return StepResult{ + status: SilentOk, + err: nil, + } +} + +func monitorOutput(console harness.ConsoleInterface, debugEscapes bool) { + console.SetReadTimeout(time.Millisecond * 100) + buf := make([]byte, 512) + for { + n, err := console.Read(buf) + if err != nil || n == 0 { + return + } + bufStr := string(buf[:n]) + + if debugEscapes { + bufStr = strings.ReplaceAll(bufStr, "\x1b", "\n") + } + fmt.Print(bufStr) + } +} + +var replaceMap map[string]string = map[string]string{ + "": "\x1b", + "": "\x1bOP", + "": "\x1bOQ", + "": "\x1bOR", + "": "\x1bOS", + "": "\x1b[15~", + "": "\x1b[17~", + "": "\x1b[18~", + "": "\x1b[19~", + "": "\x1b[20~", + "": "\x1b[21~", + "": "\x1b[23~", + "": "\x1b[A", + "": "\x1b[B", + "": "\x1b[D", + "": "\x1b[C", + "": "\r\n", + "": "\t", + "": "\x7f", + "": "\x1b[3~", + "": "\x01", + "": "\x02", + "": "\x03", + "": "\x04", + "": "\x05", + "": "\x18"} + +func sendStringParser(send string) string { + for k, v := range replaceMap { + replace_insensitive := regexp.MustCompile("(?i)" + k) + send = replace_insensitive.ReplaceAllString(send, v) + } + return send +} diff --git a/pkg/runner/step_set_disk_image.go b/pkg/runner/step_set_disk_image.go new file mode 100644 index 0000000..b1a0da2 --- /dev/null +++ b/pkg/runner/step_set_disk_image.go @@ -0,0 +1,21 @@ +package runner + +import ( + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *SetDiskImageStep) run(device harness.Device) StepResult { + + err := device.SetDiskImage(t.Image, uint64(t.OffsetGB)*1024*1024*1024) + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + + return StepResult{ + status: Done, + err: nil, + } +} diff --git a/pkg/runner/step_storage.go b/pkg/runner/step_storage.go new file mode 100644 index 0000000..1e6eb7c --- /dev/null +++ b/pkg/runner/step_storage.go @@ -0,0 +1,24 @@ +package runner + +import ( + "strings" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func (t *StorageStep) run(device harness.Device) StepResult { + + attach := strings.ToLower(string(*t)) == "attach" + err := device.AttachStorage(attach) + if err != nil { + return StepResult{ + status: Fatal, + err: err, + } + } + + return StepResult{ + status: Done, + err: nil, + } +} diff --git a/pkg/runner/step_write_ansible_inventory.go b/pkg/runner/step_write_ansible_inventory.go new file mode 100644 index 0000000..3c02880 --- /dev/null +++ b/pkg/runner/step_write_ansible_inventory.go @@ -0,0 +1,42 @@ +package runner + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" + "github.com/jumpstarter-dev/jumpstarter/pkg/tools" +) + +func (t *WriteAnsibleInventoryStep) run(device harness.Device) StepResult { + + // Open the inventory file t.Filename for writing + // If the file doesn't exist, create it or append to the file + inventoryFile, err := os.OpenFile(t.Filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return StepResult{ + status: Fatal, + err: fmt.Errorf("WriteAnsibleInventory:run(%q) opening file for write: %w", t.Filename, err), + } + } + defer inventoryFile.Close() + + if err := tools.CreateAnsibleInventory(device, inventoryFile, t.User, t.SshKey); err != nil { + return StepResult{ + status: Fatal, + err: fmt.Errorf("WriteAnsibleInventory:run(%q) writing inventory file: %w", t.Filename, err), + } + } + + color.Set(color.FgYellow) + fmt.Println("\n\rwritten :", t.Filename) + color.Unset() + + fmt.Println("") + + return StepResult{ + status: SilentOk, + err: nil, + } +} diff --git a/pkg/storage/writer.go b/pkg/storage/writer.go new file mode 100644 index 0000000..bb0b5fd --- /dev/null +++ b/pkg/storage/writer.go @@ -0,0 +1,81 @@ +package storage + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "time" + + "github.com/fatih/color" + "github.com/schollz/progressbar/v3" +) + +const WAIT_TIME_USB_STORAGE = 12 * time.Second +const WAIT_TIME_USB_STORAGE_DISCONNECT = 6 * time.Second + +func WriteImageToDisk(imagePath string, diskPath string, offset uint64, blockSize uint64, eject bool) error { + inputFile, err := os.OpenFile(imagePath, os.O_RDONLY, 0666) + if err != nil { + return fmt.Errorf("WriteImageToDisk: %w", err) + } + defer inputFile.Close() + + fi, err := inputFile.Stat() + if err != nil { + return fmt.Errorf("WriteImageToDisk: reading input file info %w", err) + } + inputSize := fi.Size() + + outputFile, err := os.OpenFile(diskPath, os.O_WRONLY|os.O_SYNC, 0666) + if err != nil { + return fmt.Errorf("WriteImageToDisk: %w", err) + } + + if _, err := outputFile.Seek(int64(offset), 0); err != nil { + outputFile.Close() + return fmt.Errorf("writeImageToDisk:Seek to 0x%x %w", offset, err) + } + + buffer := make([]byte, blockSize) + + bar := progressbar.DefaultBytes(inputSize, "💾 writing") + for { + n, err := inputFile.Read(buffer) + if err != nil && err != io.EOF { + outputFile.Close() + return fmt.Errorf("WriteImageToDisk: %w", err) + } + if n == 0 { + break + } + if _, err := outputFile.Write(buffer[:n]); err != nil { + outputFile.Close() + return fmt.Errorf("WriteImageToDisk: %w", err) + } + bar.Add(n) + } + outputFile.Close() + fmt.Println() + + if err := exec.Command("sync").Run(); err != nil { + return fmt.Errorf("WriteImageToDisk: sync %w", err) + } + if eject { + fmt.Println("⏏ Requesting disk ejection ....") + time.Sleep(WAIT_TIME_USB_STORAGE) + cmd := exec.Command("udisksctl", "power-off", "-b", diskPath) + var errb bytes.Buffer + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + // udiskctl doesn't work in the container workflows, so we ignore the error and write a warning + color.Set(color.FgYellow) + fmt.Printf("warning: udisksctl power-off failed: %s\n", errb.String()) + color.Unset() + } + } + fmt.Println("🕐 Waiting before disconnecting disk ....") + time.Sleep(WAIT_TIME_USB_STORAGE_DISCONNECT) + return nil +} diff --git a/pkg/tools/ansible_inventory.go b/pkg/tools/ansible_inventory.go new file mode 100644 index 0000000..2fbebbc --- /dev/null +++ b/pkg/tools/ansible_inventory.go @@ -0,0 +1,47 @@ +package tools + +import ( + "fmt" + "os" + "regexp" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func CreateAnsibleInventory(device harness.Device, output *os.File, user string, ssh_key string) error { + serial, err := device.Console() + if err != nil { + return fmt.Errorf("CreateAnsibleInventory: error getting console: %w", err) + } + + result, err := RunCommand(serial, "ip a show dev eth0", 1) + if err != nil { + return fmt.Errorf("CreateAnsibleInventory: error requesting IP address: %w", err) + } + + ip, err := extractSrcIPAddress(result) + if err != nil { + return fmt.Errorf("CreateAnsibleInventory: error parsing IP address: %w", err) + } + + fmt.Fprint(output, "---\nboards:\n hosts:\n") + fmt.Fprintf(output, " %s:\n", device.Name()) + fmt.Fprintf(output, " ansible_host: %s\n", ip) + fmt.Fprintf(output, " ansible_user: %s\n", user) + fmt.Fprintf(output, " ansible_become: yes\n") + fmt.Fprintf(output, " ansible_ssh_common_args: '-o StrictHostKeyChecking=no'\n") + if ssh_key != "" { + fmt.Fprintf(output, " ansible_ssh_private_key_file: %s\n", ssh_key) + } + return nil +} + +func extractSrcIPAddress(input string) (string, error) { + re := regexp.MustCompile(`inet (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})`) + matches := re.FindStringSubmatch(input) + if len(matches) > 1 { + return matches[1], nil + } else { + return "", fmt.Errorf("No src IP address found in %q", input) + } +} diff --git a/pkg/tools/run_command.go b/pkg/tools/run_command.go new file mode 100644 index 0000000..4ef2334 --- /dev/null +++ b/pkg/tools/run_command.go @@ -0,0 +1,59 @@ +package tools + +import ( + "bytes" + "fmt" + "strings" + "time" + + "github.com/jumpstarter-dev/jumpstarter/pkg/harness" +) + +func readUntil(console harness.ConsoleInterface) ([]byte, error) { + // Read the console in a loop until no more data is produced within the read timeout. + // Because some devices will only provide a few bytes at a time (i.e. 64), + // and a single Read call cannot drain the whole command output. + all := bytes.NewBuffer(nil) + buf := make([]byte, 1024) + for { + n, err := console.Read(buf) + if err != nil { + return nil, err + } + if n == 0 { + break + } + // not checking as err is always nil for Buffer.Write + _, _ = all.Write(buf[:n]) + } + return all.Bytes(), nil +} + +func RunCommand(console harness.ConsoleInterface, cmd string, wait int) (string, error) { + console.SetReadTimeout(100 * time.Millisecond) + + // clear the input buffer first + _, err := readUntil(console) + if err != nil { + return "", fmt.Errorf("runCommand %s, clearing input buffer: %w", cmd, err) + } + + if _, err := console.Write([]byte(cmd + "\r\n")); err != nil { + return "", fmt.Errorf("runCommand %s, sending command: %w", cmd, err) + } + + time.Sleep(time.Duration(wait) * time.Second) + + all, err := readUntil(console) + if err != nil { + return "", fmt.Errorf("runCommand %s, reading response: %w", cmd, err) + } + + lines := strings.Split(string(all), "\n") + if len(lines) > 1 { + // the first line is the command we just sent, so we skip it + return strings.Join(lines[1:], "\n"), nil + } else { + return "", nil + } +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go new file mode 100644 index 0000000..8b643a2 --- /dev/null +++ b/pkg/tools/shell.go @@ -0,0 +1,15 @@ +package tools + +import ( + "io" + "os/exec" + "strings" +) + +func RunBash(script string, stdout, stderr io.Writer) error { + cmd := exec.Command("sh") + cmd.Stdin = strings.NewReader(script + "\n") + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} diff --git a/script-examples/orin-agx.yaml b/script-examples/orin-agx.yaml new file mode 100644 index 0000000..973e0d2 --- /dev/null +++ b/script-examples/orin-agx.yaml @@ -0,0 +1,118 @@ + +name: "Test Jetson kmods" +selector: + - orin +expect-timeout: 60 + +steps: + - comment: "Powering off and writing the image to disk" + - power: off + + - set-disk-image: + image: "rhel-guest-image.raw" + + - storage: attach + + - comment: "Booting up and waiting for login prompt" + - power: on + + - expect: + this: "login: " + debug_escapes: true + timeout: 300 + - send: + this: + - "root\n" + - "redhat\n" + + - pause: 3 + + - expect: + this: "[root@localhost ~]#" + debug_escapes: false + + - comment: "Updating kernel if necessary and installing the jetpack kmods" + - send: + echo: true + this: + - "sudo dnf update -y\n" + + - expect: + timeout: 120 + echo: true + debug_escapes: false + this: "Complete" + + - expect: + debug_escapes: false + this: "[root@localhost ~]#" + + - send: + echo: true + this: + - "sudo dnf install -y nvidia-jetpack-kmod\n" + + - expect: + timeout: 120 + echo: true + debug_escapes: false + this: "Complete" + + - comment: "Rebooting to get latest kernel and kmods" + - send: + debug_escapes: false + this: + - "reboot\n" + + - expect: + this: "login: " + debug_escapes: false + timeout: 500 # the kmod boot takes very long because of some issues with the crypto modules from nvidia + + - send: + this: + - "root\n" + - "redhat\n" + + - send: + echo: false # we dont want to capture any of the output so expect will catch it later + this: + - "\n" + - "\n" + + - expect: + debug_escapes: false + echo: true + this: "[root@localhost ~]#" + + - comment: "verifying that the kmods are loaded" + - send: + echo: false # we dont want to capture any of the output so expect will catch it + this: + - "lsmod | grep --color=never nv\n" + + - expect: + echo: true + this: "nvgpu" + + - comment: "Creating an inventory for this device and continuing with ansible" + - write-ansible-inventory: + filename: "inventory.yaml" + ssh_key: ~/.ssh/id_rsa + + - local-shell: + script: | + ansible -m ping -i inventory.yaml all + +cleanup: + - comment: "Powering off and detaching the disk" + - send: + debug_escapes: false + this: + - "poweroff\n" + - pause: 5 + + - power: off + + - storage: detach + diff --git a/script-examples/xavier-nx-test.yaml b/script-examples/xavier-nx-test.yaml new file mode 100644 index 0000000..23a8d4e --- /dev/null +++ b/script-examples/xavier-nx-test.yaml @@ -0,0 +1,69 @@ +name: "Test Jetson kmods" +selector: + - xavier-nx + - 8gb +expect-timeout: 60 +steps: + - power: off + - set-disk-image: + image: "isos/RHEL-9.3.0-20230809.27-aarch64-boot.iso" + + - name: "Attach storage" + storage: + attached: true + + - name: "Power on" + power: + action: on + + - expect: + this: "Press ESCAPE for boot options" + + - send: + this: + - "" + + - expect: + this: "GRUB version" + + - send: + this: + - "" # select the main grub entry, no disk test + - "e" # edit it + - "" # go down to the kernel line, and end of line + - " inst.vnc console=ttyS0,115200" # our addition + - "" #boot + + - expect: + this: "forever" + debug_escapes: false + + - expect: + this: "Install finished" + + - storage: detach + + - expect: + this: "login: " + debug_escapes: false + timeout: 500 # the kmod boot takes very long because of some issues with the crypto modules from nvidia + + - send: + this: + - "root\n" + - "redhat\n" + + - comment: "Creating an inventory for this device and continuing with ansible" + - write-ansible-inventory: + filename: "inventory.yaml" + ssh_key: ~/.ssh/id_rsa + + - local-shell: + script: | + ansible -m ping -i inventory.yaml all + + + cleanup: + - comment: "Cleanup and power off" + - power: off + - storage: detach diff --git a/tekton/image/copy-to-cluster.sh b/tekton/image/copy-to-cluster.sh new file mode 100755 index 0000000..100025f --- /dev/null +++ b/tekton/image/copy-to-cluster.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set +x -e +oc apply -f pvc-image.yaml +oc apply -f dummy.yaml +oc wait --for=condition=ready pod/dummy +oc rsync ./files/ dummy:/mnt/ --delete=true --strategy=tar +oc delete pod dummy diff --git a/tekton/image/dummy.yaml b/tekton/image/dummy.yaml new file mode 100644 index 0000000..112d8d3 --- /dev/null +++ b/tekton/image/dummy.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Pod +metadata: + labels: + run: dummy + name: dummy +spec: + volumes: + - name: image + persistentVolumeClaim: + claimName: lvm-rhel-image + + containers: + - name: dummy + args: + - sleep + - "3600" + image: fedora:latest + imagePullPolicy: Always + volumeMounts: + - mountPath: "/mnt" + name: image + + diff --git a/tekton/image/files/.gitkeep b/tekton/image/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tekton/image/files/your-image-would-go-here b/tekton/image/files/your-image-would-go-here new file mode 100644 index 0000000..e69de29 diff --git a/tekton/image/pvc-image.yaml b/tekton/image/pvc-image.yaml new file mode 100644 index 0000000..70ca483 --- /dev/null +++ b/tekton/image/pvc-image.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: lvm-rhel-image +spec: + accessModes: + - ReadWriteOnce + volumeMode: Filesystem + resources: + requests: + storage: 10Gi + storageClassName: lvms-vg1 diff --git a/tekton/jumpstarter-pipelines-scc.yaml b/tekton/jumpstarter-pipelines-scc.yaml new file mode 100644 index 0000000..9b3f589 --- /dev/null +++ b/tekton/jumpstarter-pipelines-scc.yaml @@ -0,0 +1,37 @@ +apiVersion: security.openshift.io/v1 +kind: SecurityContextConstraints +metadata: + annotations: + kubernetes.io/description: 'jumpstarter SCC to use jumpstarter tasks on OpenShift pipelines' + name: jumpstarter-pipelines-scc +allowHostDirVolumePlugin: true +allowHostIPC: false +allowHostNetwork: false +allowHostPID: false +allowHostPorts: false +allowPrivilegeEscalation: true +allowPrivilegedContainer: true +allowedCapabilities: +- CHOWN +- SYS_CHROOT +allowedUnsafeSysctls: null +defaultAddCapabilities: null +fsGroup: + type: RunAsAny +groups: +- system:cluster-admins +- system:nodes +- system:masters +priority: null +readOnlyRootFilesystem: false +requiredDropCapabilities: null +runAsUser: + type: RunAsAny +seLinuxContext: + type: RunAsAny +seccompProfiles: +- '*' +supplementalGroups: + type: RunAsAny +volumes: +- '*' diff --git a/tekton/pipelines/pipeline-jumpstarter-orin-nx.yaml b/tekton/pipelines/pipeline-jumpstarter-orin-nx.yaml new file mode 100644 index 0000000..0919421 --- /dev/null +++ b/tekton/pipelines/pipeline-jumpstarter-orin-nx.yaml @@ -0,0 +1,106 @@ +apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: jumpstarter-orin-nx +spec: + tasks: + - name: git-clone + params: + - name: url + value: 'https://github.com/mangelajo/jumpstarter-on-tekton.git' + - name: revision + value: main + - name: refspec + value: '' + - name: sslVerify + value: 'false' + - name: crtFileName + value: ca-bundle.crt + - name: verbose + value: 'true' + - name: gitInitImage + value: >- + gcr.io/tekton-releases/github.com/tektoncd/pipeline/cmd/git-init:v0.40.2 + - name: userHome + value: /home/git + - name: submodules + value: 'false' + taskRef: + kind: Task + name: git-clone + workspaces: + - name: output + workspace: checkout-files + - name: ssh-directory + workspace: ssh-credentials + - name: prepare-image + params: + - name: imageFile + value: rhel-guest-image.raw.xz + - name: imageOutputFile + value: rhel-guest-image.raw + taskRef: + kind: Task + name: prepare-image + workspaces: + - name: images + workspace: image-input + - name: image-out + workspace: image-files + - name: ssh-auth-out + workspace: image-ssh-creds + - name: run-jumpstarter-script + params: + - name: imgName + value: test + - name: scriptFile + value: jumpstarter-script/orin-kmods-jumpstarter.yaml + - name: imageFile + value: rhel-guest-image.raw + - name: imageSshKey + value: $(tasks.prepare-image.results.sshKey) + runAfter: + - prepare-image + - git-clone + taskRef: + kind: Task + name: run-jumpstarter-script + workspaces: + - name: scripts + workspace: checkout-files + - name: images + workspace: image-files + - name: artifacts + workspace: artifacts + workspaces: + - description: > + This worksplace will contain the downloaded jumpstarter scripts from the + git task, passed to the jumpstarter script task. i.e. use a + VolumeClaimTemplate here. + name: checkout-files + - description: > + Image source used by image-prepare, we use a PVC with the image in it as + an example for this pipeline. + name: image-input + - description: > + This workspace is used to store the raw image that will be passed down + to the jumpstarter run task, it's populated by image-prepare. i.e. use a + VolumeClaimTemplate here. + name: image-files + - description: > + This workspace is used to store the raw image that will be passed down + to the jumpstarter run task, it's populated by image-prepare. i.e. use a + VolumeClaimTemplate here. + name: artifacts + - description: > + SSH Credentials provided for the git task, attach a secret here with the + .ssh directory contents. + name: ssh-credentials + optional: true + - description: > + This is an optional workspace for image-prepare to copy ssh credentials + injected in to the image as authorized_keys for later interactions via + ssh or ansible with the device. It's alternatively provided as a sshKey + result from image-prepare. + name: image-ssh-creds + optional: true diff --git a/tekton/pipelines/task-git-clone.yaml b/tekton/pipelines/task-git-clone.yaml new file mode 100644 index 0000000..df5cb5e --- /dev/null +++ b/tekton/pipelines/task-git-clone.yaml @@ -0,0 +1,274 @@ +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: git-clone + labels: + app.kubernetes.io/version: '0.9' +spec: + description: >- + These Tasks are Git tasks to work with repositories used by other tasks in + your Pipeline. + + The git-clone Task will clone a repo from the provided url into the output + Workspace. By default the repo will be cloned into the root of your + Workspace. You can clone into a subdirectory by setting this Task's + subdirectory param. This Task also supports sparse checkouts. To perform a + sparse checkout, pass a list of comma separated directory patterns to this + Task's sparseCheckoutDirectories param. + params: + - description: Repository URL to clone from. + name: url + type: string + - default: '' + description: 'Revision to checkout. (branch, tag, sha, ref, etc...)' + name: revision + type: string + - default: '' + description: Refspec to fetch before checking out revision. + name: refspec + type: string + - default: 'true' + description: Initialize and fetch git submodules. + name: submodules + type: string + - default: '1' + description: 'Perform a shallow clone, fetching only the most recent N commits.' + name: depth + type: string + - default: 'true' + description: >- + Set the `http.sslVerify` global git config. Setting this to `false` is + not advised unless you are sure that you trust your git remote. + name: sslVerify + type: string + - default: ca-bundle.crt + description: >- + file name of mounted crt using ssl-ca-directory workspace. default value + is ca-bundle.crt. + name: crtFileName + type: string + - default: '' + description: Subdirectory inside the `output` Workspace to clone the repo into. + name: subdirectory + type: string + - default: '' + description: >- + Define the directory patterns to match or exclude when performing a + sparse checkout. + name: sparseCheckoutDirectories + type: string + - default: 'true' + description: >- + Clean out the contents of the destination directory if it already exists + before cloning. + name: deleteExisting + type: string + - default: '' + description: HTTP proxy server for non-SSL requests. + name: httpProxy + type: string + - default: '' + description: HTTPS proxy server for SSL requests. + name: httpsProxy + type: string + - default: '' + description: Opt out of proxying HTTP/HTTPS requests. + name: noProxy + type: string + - default: 'true' + description: Log the commands that are executed during `git-clone`'s operation. + name: verbose + type: string + - default: 'gcr.io/tekton-releases/github.com/tektoncd/pipeline/cmd/git-init:v0.40.2' + description: The image providing the git-init binary that this Task runs. + name: gitInitImage + type: string + - default: /home/git + description: | + Absolute path to the user's home directory. + name: userHome + type: string + results: + - description: The precise commit SHA that was fetched by this Task. + name: commit + type: string + - description: The precise URL that was fetched by this Task. + name: url + type: string + - description: The epoch timestamp of the commit that was fetched by this Task. + name: committer-date + type: string + steps: + - env: + - name: HOME + value: $(params.userHome) + - name: PARAM_URL + value: $(params.url) + - name: PARAM_REVISION + value: $(params.revision) + - name: PARAM_REFSPEC + value: $(params.refspec) + - name: PARAM_SUBMODULES + value: $(params.submodules) + - name: PARAM_DEPTH + value: $(params.depth) + - name: PARAM_SSL_VERIFY + value: $(params.sslVerify) + - name: PARAM_CRT_FILENAME + value: $(params.crtFileName) + - name: PARAM_SUBDIRECTORY + value: $(params.subdirectory) + - name: PARAM_DELETE_EXISTING + value: $(params.deleteExisting) + - name: PARAM_HTTP_PROXY + value: $(params.httpProxy) + - name: PARAM_HTTPS_PROXY + value: $(params.httpsProxy) + - name: PARAM_NO_PROXY + value: $(params.noProxy) + - name: PARAM_VERBOSE + value: $(params.verbose) + - name: PARAM_SPARSE_CHECKOUT_DIRECTORIES + value: $(params.sparseCheckoutDirectories) + - name: PARAM_USER_HOME + value: $(params.userHome) + - name: WORKSPACE_OUTPUT_PATH + value: $(workspaces.output.path) + - name: WORKSPACE_SSH_DIRECTORY_BOUND + value: $(workspaces.ssh-directory.bound) + - name: WORKSPACE_SSH_DIRECTORY_PATH + value: $(workspaces.ssh-directory.path) + - name: WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND + value: $(workspaces.basic-auth.bound) + - name: WORKSPACE_BASIC_AUTH_DIRECTORY_PATH + value: $(workspaces.basic-auth.path) + - name: WORKSPACE_SSL_CA_DIRECTORY_BOUND + value: $(workspaces.ssl-ca-directory.bound) + - name: WORKSPACE_SSL_CA_DIRECTORY_PATH + value: $(workspaces.ssl-ca-directory.path) + image: $(params.gitInitImage) + name: clone + resources: {} + script: > + #!/usr/bin/env sh + + set -eu + + rm -rf ~/.ssh + + echo HI! + + if [ "${PARAM_VERBOSE}" = "true" ] ; then + set -x + fi + + + if [ "${WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND}" = "true" ] ; then + cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.git-credentials" "${PARAM_USER_HOME}/.git-credentials" + cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.gitconfig" "${PARAM_USER_HOME}/.gitconfig" + chmod 400 "${PARAM_USER_HOME}/.git-credentials" + chmod 400 "${PARAM_USER_HOME}/.gitconfig" + fi + + + if [ "${WORKSPACE_SSH_DIRECTORY_BOUND}" = "true" ] ; then + cp -R "${WORKSPACE_SSH_DIRECTORY_PATH}" "${PARAM_USER_HOME}"/.ssh + chmod 700 "${PARAM_USER_HOME}"/.ssh + chmod -R 400 "${PARAM_USER_HOME}"/.ssh/* + fi + + + if [ "${WORKSPACE_SSL_CA_DIRECTORY_BOUND}" = "true" ] ; then + export GIT_SSL_CAPATH="${WORKSPACE_SSL_CA_DIRECTORY_PATH}" + if [ "${PARAM_CRT_FILENAME}" != "" ] ; then + export GIT_SSL_CAINFO="${WORKSPACE_SSL_CA_DIRECTORY_PATH}/${PARAM_CRT_FILENAME}" + fi + fi + + CHECKOUT_DIR="${WORKSPACE_OUTPUT_PATH}/${PARAM_SUBDIRECTORY}" + + + cleandir() { + # Delete any existing contents of the repo directory if it exists. + # + # We don't just "rm -rf ${CHECKOUT_DIR}" because ${CHECKOUT_DIR} might be "/" + # or the root of a mounted volume. + if [ -d "${CHECKOUT_DIR}" ] ; then + # Delete non-hidden files and directories + rm -rf "${CHECKOUT_DIR:?}"/* + # Delete files and directories starting with . but excluding .. + rm -rf "${CHECKOUT_DIR}"/.[!.]* + # Delete files and directories starting with .. plus any other character + rm -rf "${CHECKOUT_DIR}"/..?* + fi + } + + + if [ "${PARAM_DELETE_EXISTING}" = "true" ] ; then + cleandir || true + fi + + + test -z "${PARAM_HTTP_PROXY}" || export HTTP_PROXY="${PARAM_HTTP_PROXY}" + + test -z "${PARAM_HTTPS_PROXY}" || export + HTTPS_PROXY="${PARAM_HTTPS_PROXY}" + + test -z "${PARAM_NO_PROXY}" || export NO_PROXY="${PARAM_NO_PROXY}" + + + git config --global --add safe.directory "${WORKSPACE_OUTPUT_PATH}" + + /ko-app/git-init \ + -url="${PARAM_URL}" \ + -revision="${PARAM_REVISION}" \ + -refspec="${PARAM_REFSPEC}" \ + -path="${CHECKOUT_DIR}" \ + -sslVerify="${PARAM_SSL_VERIFY}" \ + -submodules="${PARAM_SUBMODULES}" \ + -depth="${PARAM_DEPTH}" \ + -sparseCheckoutDirectories="${PARAM_SPARSE_CHECKOUT_DIRECTORIES}" + cd "${CHECKOUT_DIR}" + + RESULT_SHA="$(git rev-parse HEAD)" + + EXIT_CODE="$?" + + if [ "${EXIT_CODE}" != 0 ] ; then + exit "${EXIT_CODE}" + fi + + RESULT_COMMITTER_DATE="$(git log -1 --pretty=%ct)" + + printf "%s" "${RESULT_COMMITTER_DATE}" > + "$(results.committer-date.path)" + + printf "%s" "${RESULT_SHA}" > "$(results.commit.path)" + + printf "%s" "${PARAM_URL}" > "$(results.url.path)" + securityContext: + runAsNonRoot: true + runAsUser: 65532 + workspaces: + - description: The git repo will be cloned onto the volume backing this Workspace. + name: output + - description: | + A .ssh directory with private key, known_hosts, config, etc. Copied to + the user's home before git commands are executed. Used to authenticate + with the git remote when performing the clone. Binding a Secret to this + Workspace is strongly recommended over other volume types. + name: ssh-directory + optional: true + - description: | + A Workspace containing a .gitconfig and .git-credentials file. These + will be copied to the user's home before any git commands are run. Any + other files in this Workspace are ignored. It is strongly recommended + to use ssh-directory over basic-auth whenever possible and to bind a + Secret to this Workspace over other volume types. + name: basic-auth + optional: true + - description: | + A workspace containing CA certificates, this will be used by Git to + verify the peer with when fetching or pushing over HTTPS. + name: ssl-ca-directory + optional: true diff --git a/tekton/pipelines/task-jumpstarter-script.yaml b/tekton/pipelines/task-jumpstarter-script.yaml new file mode 100644 index 0000000..d634608 --- /dev/null +++ b/tekton/pipelines/task-jumpstarter-script.yaml @@ -0,0 +1,81 @@ +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: run-jumpstarter-script +spec: + params: + - name: scriptFile + type: string + - name: imageFile + type: string + - description: base64 encoded ssh key for accessing a device with this image. + name: imageSshKey + type: string + - default: 'false' + description: >- + Disables jumpstarter cleanup at the end, or in case of errors, this is + helpful for debugging purposes. + name: disableCleanup + type: string + steps: + - image: 'quay.io/mangelajo/jumpstarter:0.3.2' + name: info + resources: {} + script: | + #!/usr/bin/env sh + set -x + + jumpstarter list-devices + + ls $(workspaces.images.path) + ls $(workspaces.scripts.path) + ls $(workspaces.artifacts.path) + securityContext: + privileged: true + volumeMounts: + - mountPath: /dev + name: dynamic-devices + - image: 'quay.io/mangelajo/jumpstarter:0.3.2' + name: run-script + resources: {} + script: | + #!/usr/bin/env sh + + + if [[ "$(params.imageSshKey)" != "" ]]; then + echo Using imageSshKey parameter as ~/.ssh/id_rsa + mkdir -p ~/.ssh + echo "$(params.imageSshKey)" | base64 -d > ~/.ssh/id_rsa + chmod 700 ~/.ssh + chmod -R 400 ~/.ssh/* + fi + + set -x + + ln -s $(workspaces.images.path)/$(params.imageFile) . + cp -rfv $(workspaces.scripts.path)/* . + + PARAMS= + + if [[ "$(params.disableCleanup)" == "true" ]]; then + PARAMS="${PARAMS} --disable-cleanup" + fi + jumpstarter run-script $PARAMS $(params.scriptFile) + securityContext: + privileged: true + volumeMounts: + - mountPath: /dev + name: dynamic-devices + volumes: + - hostPath: + path: /dev + name: dynamic-devices + workspaces: + - description: The script sources to be ran with jumpstarter + name: scripts + - description: The images to be used by the jumpstarter script + name: images + optional: true + - description: The output directory for artifacts + name: artifacts + optional: true diff --git a/tekton/pipelines/task-prepare-image.yaml b/tekton/pipelines/task-prepare-image.yaml new file mode 100644 index 0000000..5e2a60a --- /dev/null +++ b/tekton/pipelines/task-prepare-image.yaml @@ -0,0 +1,116 @@ +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: prepare-image +spec: + params: + - name: imageFile + type: string + - name: imageOutputFile + type: string + results: + - description: The login username + name: user + type: string + - description: The login password + name: password + type: string + - description: The SSH private key for acessing the image + name: sshKey + type: string + steps: + - image: 'quay.io/mangelajo/guestfs-tools:latest' + imagePullPolicy: Always + name: prepare-image + resources: {} + script: | + #!/usr/bin/env sh + set -x + + + OUT_FILE=$(workspaces.image-out.path)/$(params.imageOutputFile) + + + xz -d -T0 -v \ + $(workspaces.images.path)/$(params.imageFile) -c \ + > $(params.imageOutputFile) + + # create and inject an authorized key + + + mkdir -p ssh + + ssh-keygen -b 2048 -t rsa -f ssh/id_rsa -q -N "" + + mkdir -p mount + + # guestmount the locally copied image, if we do it over the volume mount + # it doesn't work for some reason. + + LIBGUESTFS_BACKEND=direct guestmount -a $(params.imageOutputFile) \ + -m /dev/sda3:/ -m /dev/sda2:/boot \ + -o allow_other -o nonempty --rw mount + + trap "guestunmount ${PWD}/mount" ERR SIGINT + + + cp ssh/id_rsa.pub mount/root/.ssh/authorized_keys + chmod 700 mount/root/.ssh + chmod 600 mount/root/.ssh/authorized_keys + + ls -la mount/ + ls -la mount/root/.ssh/ + cat mount/root/.ssh/authorized_keys + cat ssh/id_rsa.pub + + # TODO: make sure we patch the password properly + cat mount/etc/passwd | grep root + cat mount/etc/shadow | grep root + + echo PermitRootLogin yes >> mount/etc/ssh/sshd_config + + sed -i s/ttyS0/ttyTCU0/g \ + mount/boot/loader/entries/* \ + mount/etc/default/grub \ + mount/etc/kernel/cmdline + + # unmount the image + + guestunmount mount && trap - ERR SIGINT + + sleep 2 + + sync + + sleep 2 + + sync + + + cp $(params.imageOutputFile) "${OUT_FILE}" + + if [[ "$(workspaces.ssh-auth-out.bound)" == "true" ]] ; then + cp ssh/id_rsa $(workspaces.ssh-auth-out.path)/ + cp ssh/id_rsa.pub $(workspaces.ssh-auth-out.path)/ + fi + + cat ssh/id_rsa | base64 -w0 > "$(results.sshKey.path)" + echo root > "$(results.user.path)" + echo redhat > "$(results.password.path)" + securityContext: + privileged: true + volumeMounts: + - mountPath: /dev + name: dynamic-devices + volumes: + - hostPath: + path: /dev + name: dynamic-devices + workspaces: + - description: The images to be prepared + name: images + - description: The image output + name: image-out + - description: SSH Authentication details to access the image + name: ssh-auth-out + optional: true diff --git a/tekton/run-pipeline.sh b/tekton/run-pipeline.sh new file mode 100755 index 0000000..9f12fb9 --- /dev/null +++ b/tekton/run-pipeline.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +tkn pipeline start jumpstarter-orin-nx \ + --workspace name=checkout-files,volumeClaimTemplateFile=workspace-template.yaml \ + --workspace name=image-input,claimName=lvm-rhel-image \ + --workspace name=image-files,volumeClaimTemplateFile=workspace-templates/image-workspace-template.yaml \ + --workspace name=artifacts,volumeClaimTemplateFile=workspace-templates/workspace-template.yaml \ + --showlog diff --git a/tekton/setup.sh b/tekton/setup.sh new file mode 100755 index 0000000..32733c4 --- /dev/null +++ b/tekton/setup.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -x -e +oc new-project jumpstarter-pipelines + +oc apply -f jumpstarter-pipelines-scc.yaml +oc adm policy add-scc-to-user jumpstarter-pipelines-scc -z pipeline + +cd image +./copy-to-cluster.sh +cd .. +oc apply -f pipelines/task-git-clone.yaml +oc apply -f pipelines/task-prepare-image.yaml +oc apply -f pipelines/task-jumpstarter-script.yaml +oc apply -f pipelines/pipeline-jumpstarter-orin-nx.yaml + +echo you can now run the pipeline with: +echo ./run-pipeline.sh + + diff --git a/tekton/workspace-templates/image-workspace-template.yaml b/tekton/workspace-templates/image-workspace-template.yaml new file mode 100644 index 0000000..2d061ae --- /dev/null +++ b/tekton/workspace-templates/image-workspace-template.yaml @@ -0,0 +1,6 @@ +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 12Gi diff --git a/tekton/workspace-templates/workspace-template.yaml b/tekton/workspace-templates/workspace-template.yaml new file mode 100644 index 0000000..32d37cf --- /dev/null +++ b/tekton/workspace-templates/workspace-template.yaml @@ -0,0 +1,6 @@ +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi