diff --git a/cli/device/list.go b/cli/device/list.go index a76a7412..a853dfc7 100644 --- a/cli/device/list.go +++ b/cli/device/list.go @@ -33,7 +33,8 @@ import ( ) type listFlags struct { - tags map[string]string + tags map[string]string + deviceIds string } func initListCommand() *cobra.Command { @@ -56,6 +57,7 @@ func initListCommand() *cobra.Command { "Comma-separated list of tags with format =.\n"+ "List only devices that match the provided tags.", ) + listCommand.Flags().StringVarP(&flags.deviceIds, "device-ids", "d", "", "Comma separated list of Device IDs") return listCommand } @@ -67,7 +69,7 @@ func runListCommand(flags *listFlags) error { return fmt.Errorf("retrieving credentials: %w", err) } - params := &device.ListParams{Tags: flags.tags} + params := &device.ListParams{Tags: flags.tags, DeviceIds: flags.deviceIds} devs, err := device.List(context.TODO(), params, cred) if err != nil { return err diff --git a/cli/device/tag/create.go b/cli/device/tag/create.go index 22b62a30..6303a6ea 100644 --- a/cli/device/tag/create.go +++ b/cli/device/tag/create.go @@ -21,6 +21,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/arduino/arduino-cli/cli/errorcodes" "github.com/arduino/arduino-cli/cli/feedback" @@ -32,6 +33,7 @@ import ( type createTagsFlags struct { id string + ids string tags map[string]string } @@ -49,23 +51,45 @@ func InitCreateTagsCommand() *cobra.Command { }, } createTagsCommand.Flags().StringVarP(&flags.id, "id", "i", "", "Device ID") + createTagsCommand.Flags().StringVarP(&flags.ids, "ids", "", "", "Comma-separated list of Device IDs") createTagsCommand.Flags().StringToStringVar( &flags.tags, "tags", nil, "Comma-separated list of tags with format =.", ) - createTagsCommand.MarkFlagRequired("id") createTagsCommand.MarkFlagRequired("tags") return createTagsCommand } func runCreateTagsCommand(flags *createTagsFlags) error { - logrus.Infof("Creating tags on device %s", flags.id) + if flags.id == "" && flags.ids == "" { + return fmt.Errorf("missing required flag(s) \"id\" or \"ids\"") + } + + if flags.id != "" { + if err := creteTag(flags.id, flags.tags); err != nil { + return err + } + } + if flags.ids != "" { + idsArray := strings.Split(flags.ids, ",") + for _, id := range idsArray { + id = strings.TrimSpace(id) + if err := creteTag(id, flags.tags); err != nil { + return err + } + } + } + return nil +} + +func creteTag(id string, tags map[string]string) error { + logrus.Infof("Creating tags on device %s", id) params := &tag.CreateTagsParams{ - ID: flags.id, - Tags: flags.tags, + ID: id, + Tags: tags, Resource: tag.Device, } diff --git a/cli/device/tag/delete.go b/cli/device/tag/delete.go index 0779f2d1..1721a688 100644 --- a/cli/device/tag/delete.go +++ b/cli/device/tag/delete.go @@ -21,6 +21,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/arduino/arduino-cli/cli/errorcodes" "github.com/arduino/arduino-cli/cli/feedback" @@ -32,6 +33,7 @@ import ( type deleteTagsFlags struct { id string + ids string keys []string } @@ -49,14 +51,39 @@ func InitDeleteTagsCommand() *cobra.Command { }, } deleteTagsCommand.Flags().StringVarP(&flags.id, "id", "i", "", "Device ID") + deleteTagsCommand.Flags().StringVarP(&flags.id, "ids", "", "", "Comma-separated list of Device IDs") deleteTagsCommand.Flags().StringSliceVarP(&flags.keys, "keys", "k", nil, "Comma-separated list of keys of tags to delete") - deleteTagsCommand.MarkFlagRequired("id") deleteTagsCommand.MarkFlagRequired("keys") return deleteTagsCommand } func runDeleteTagsCommand(flags *deleteTagsFlags) error { - logrus.Infof("Deleting tags with keys %s", flags.keys) + if flags.id == "" && flags.ids == "" { + return fmt.Errorf("missing required flag(s) \"id\" or \"ids\"") + } + + if flags.id != "" { + err := deleteTags(flags.id, flags.keys) + if err != nil { + return err + } + } + if flags.ids != "" { + ids := strings.Split(flags.ids, ",") + for _, id := range ids { + id = strings.TrimSpace(id) + err := deleteTags(id, flags.keys) + if err != nil { + return err + } + } + } + + return nil +} + +func deleteTags(id string, keys []string) error { + logrus.Infof("Deleting tags with keys %s", keys) cred, err := config.RetrieveCredentials() if err != nil { @@ -64,8 +91,8 @@ func runDeleteTagsCommand(flags *deleteTagsFlags) error { } params := &tag.DeleteTagsParams{ - ID: flags.id, - Keys: flags.keys, + ID: id, + Keys: keys, Resource: tag.Device, } diff --git a/cli/ota/massupload.go b/cli/ota/massupload.go index 6bf5d8b9..02f91e27 100644 --- a/cli/ota/massupload.go +++ b/cli/ota/massupload.go @@ -22,7 +22,6 @@ import ( "fmt" "os" "sort" - "strings" "github.com/arduino/arduino-cli/cli/errorcodes" "github.com/arduino/arduino-cli/cli/feedback" @@ -99,22 +98,6 @@ func runMassUploadCommand(flags *massUploadFlags) error { }) feedback.PrintResult(massUploadResult{resp}) - - var failed []string - for _, r := range resp { - if r.Err != nil { - failed = append(failed, r.ID) - } - } - if len(failed) == 0 { - return nil - } - failDevs := strings.Join(failed, ",") - feedback.Printf( - "You can try to perform the OTA again on the failed devices using the following command:\n"+ - "$ arduino-cloud-cli ota mass-upload --file %s --fqbn %s -d %s", - params.File, params.FQBN, failDevs, - ) return nil } @@ -131,17 +114,35 @@ func (r massUploadResult) String() string { return "No OTA done." } t := table.New() - t.SetHeader("ID", "Result") + hasErrorReason := false + for _, r := range r.res { + if r.OtaStatus.ErrorReason != "" { + hasErrorReason = true + break + } + } + + if hasErrorReason { + t.SetHeader("Device ID", "Ota ID", "Result", "Error Reason") + } else { + t.SetHeader("Device ID", "Ota ID", "Result") + } + + // Now print the table for _, r := range r.res { outcome := "Success" if r.Err != nil { outcome = fmt.Sprintf("Fail: %s", r.Err.Error()) } + if r.OtaStatus.Status != "" { + outcome = r.OtaStatus.MapStatus() + } - t.AddRow( - r.ID, - outcome, - ) + line := []interface{}{r.ID, r.OtaStatus.ID, outcome} + if hasErrorReason { + line = append(line, r.OtaStatus.ErrorReason) + } + t.AddRow(line...) } return t.Render() } diff --git a/cli/ota/ota.go b/cli/ota/ota.go index 7b6f63a6..b7a683a8 100644 --- a/cli/ota/ota.go +++ b/cli/ota/ota.go @@ -30,6 +30,7 @@ func NewCommand() *cobra.Command { otaCommand.AddCommand(initUploadCommand()) otaCommand.AddCommand(initMassUploadCommand()) + otaCommand.AddCommand(initOtaStatusCommand()) otaCommand.AddCommand(initEncodeBinaryCommand()) otaCommand.AddCommand(initDecodeHeaderCommand()) diff --git a/cli/ota/status.go b/cli/ota/status.go new file mode 100644 index 00000000..ebcce353 --- /dev/null +++ b/cli/ota/status.go @@ -0,0 +1,72 @@ +// This file is part of arduino-cloud-cli. +// +// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ota + +import ( + "fmt" + "os" + + "github.com/arduino/arduino-cli/cli/errorcodes" + "github.com/arduino/arduino-cli/cli/feedback" + "github.com/arduino/arduino-cloud-cli/command/ota" + "github.com/arduino/arduino-cloud-cli/config" + "github.com/spf13/cobra" +) + +type statusFlags struct { + otaID string + otaIDs string + deviceId string + limit int16 + sort string +} + +func initOtaStatusCommand() *cobra.Command { + flags := &statusFlags{} + uploadCommand := &cobra.Command{ + Use: "status", + Short: "OTA status", + Long: "Get OTA status by OTA or device ID", + Run: func(cmd *cobra.Command, args []string) { + if err := runOtaStatusCommand(flags); err != nil { + feedback.Errorf("Error during ota get status: %v", err) + os.Exit(errorcodes.ErrGeneric) + } + }, + } + uploadCommand.Flags().StringVarP(&flags.otaID, "ota-id", "o", "", "OTA ID") + uploadCommand.Flags().StringVarP(&flags.otaIDs, "ota-ids", "", "", "OTA IDs (comma separated)") + uploadCommand.Flags().StringVarP(&flags.deviceId, "device-id", "d", "", "Device ID") + uploadCommand.Flags().Int16VarP(&flags.limit, "limit", "l", 10, "Output limit (default: 10)") + uploadCommand.Flags().StringVarP(&flags.sort, "sort", "s", "desc", "Sorting (default: desc)") + + return uploadCommand +} + +func runOtaStatusCommand(flags *statusFlags) error { + if flags.otaID == "" && flags.deviceId == "" && flags.otaIDs == "" { + return fmt.Errorf("required flag(s) \"ota-id\" or \"device-id\" or \"ota-ids\" not set") + } + + cred, err := config.RetrieveCredentials() + if err != nil { + return fmt.Errorf("retrieving credentials: %w", err) + } + + return ota.PrintOtaStatus(flags.otaID, flags.otaIDs, flags.deviceId, cred, int(flags.limit), flags.sort) +} diff --git a/command/device/list.go b/command/device/list.go index d1f6af0f..ba1ffd43 100644 --- a/command/device/list.go +++ b/command/device/list.go @@ -20,6 +20,7 @@ package device import ( "context" "fmt" + "strings" "github.com/arduino/arduino-cloud-cli/config" "github.com/arduino/arduino-cloud-cli/internal/iot" @@ -28,7 +29,8 @@ import ( // ListParams contains the optional parameters needed // to filter the devices to be listed. type ListParams struct { - Tags map[string]string // If tags are provided, only devices that have all these tags are listed. + Tags map[string]string // If tags are provided, only devices that have all these tags are listed. + DeviceIds string // If ids are provided, only devices with these ids are listed. } // List command is used to list @@ -43,9 +45,19 @@ func List(ctx context.Context, params *ListParams, cred *config.Credentials) ([] if err != nil { return nil, err } + var deviceIdFilter []string + if params.DeviceIds != "" { + deviceIdFilter = strings.Split(params.DeviceIds, ",") + for i := range deviceIdFilter { + deviceIdFilter[i] = strings.TrimSpace(deviceIdFilter[i]) + } + } var devices []DeviceInfo for _, foundDev := range foundDevices { + if len(deviceIdFilter) > 0 && !sliceContains(deviceIdFilter, foundDev.Id) { + continue + } dev, err := getDeviceInfo(&foundDev) if err != nil { return nil, fmt.Errorf("parsing device %s from cloud: %w", foundDev.Id, err) @@ -55,3 +67,12 @@ func List(ctx context.Context, params *ListParams, cred *config.Credentials) ([] return devices, nil } + +func sliceContains(s []string, v string) bool { + for i := range s { + if v == s[i] { + return true + } + } + return false +} diff --git a/command/ota/massupload.go b/command/ota/massupload.go index 9613ad84..0d81ae2e 100644 --- a/command/ota/massupload.go +++ b/command/ota/massupload.go @@ -27,6 +27,8 @@ import ( "github.com/arduino/arduino-cloud-cli/config" "github.com/arduino/arduino-cloud-cli/internal/iot" "github.com/arduino/arduino-cloud-cli/internal/ota" + otaapi "github.com/arduino/arduino-cloud-cli/internal/ota-api" + iotclient "github.com/arduino/iot-client-go" ) @@ -47,8 +49,9 @@ type MassUploadParams struct { // Result of an ota upload on a device. type Result struct { - ID string - Err error + ID string + Err error + OtaStatus otaapi.Ota } // MassUpload command is used to mass upload a firmware OTA, @@ -60,6 +63,7 @@ func MassUpload(ctx context.Context, params *MassUploadParams, cred *config.Cred return nil, errors.New("cannot use both DeviceIDs and Tags. only one of them should be not nil") } + // Generate .ota file _, err := os.Stat(params.File) if err != nil { return nil, fmt.Errorf("file %s does not exists: %w", params.File, err) @@ -95,6 +99,7 @@ func MassUpload(ctx context.Context, params *MassUploadParams, cred *config.Cred if err != nil { return nil, err } + otapi := otaapi.NewClient(cred) // Prepare the list of device-ids to update d, err := idsGivenTags(ctx, iotClient, params.Tags) @@ -115,7 +120,7 @@ func MassUpload(ctx context.Context, params *MassUploadParams, cred *config.Cred expiration = otaDeferredExpirationMins } - res := run(ctx, iotClient, valid, otaFile, expiration) + res := run(ctx, iotClient, otapi, valid, otaFile, expiration) res = append(res, invalid...) return res, nil } @@ -174,7 +179,11 @@ type otaUploader interface { DeviceOTA(ctx context.Context, id string, file *os.File, expireMins int) error } -func run(ctx context.Context, uploader otaUploader, ids []string, otaFile string, expiration int) []Result { +type otaStatusGetter interface { + GetOtaLastStatusByDeviceID(deviceID string) (*otaapi.OtaStatusList, error) +} + +func run(ctx context.Context, uploader otaUploader, otapi otaStatusGetter, ids []string, otaFile string, expiration int) []Result { type job struct { id string file *os.File @@ -200,7 +209,14 @@ func run(ctx context.Context, uploader otaUploader, ids []string, otaFile string go func() { for job := range jobs { err := uploader.DeviceOTA(ctx, job.id, job.file, expiration) - resCh <- Result{ID: job.id, Err: err} + otaResult := Result{ID: job.id, Err: err} + + otaID, otaapierr := otapi.GetOtaLastStatusByDeviceID(job.id) + if otaapierr == nil && otaID != nil && len(otaID.Ota) > 0 { + otaResult.OtaStatus = otaID.Ota[0] + } + + resCh <- otaResult } }() } diff --git a/command/ota/massupload_test.go b/command/ota/massupload_test.go index f3cec3c3..bc2856da 100644 --- a/command/ota/massupload_test.go +++ b/command/ota/massupload_test.go @@ -7,7 +7,9 @@ import ( "strings" "testing" + otaapi "github.com/arduino/arduino-cloud-cli/internal/ota-api" iotclient "github.com/arduino/iot-client-go" + "github.com/gofrs/uuid" ) const testFilename = "testdata/empty.bin" @@ -20,6 +22,20 @@ func (d *deviceUploaderTest) DeviceOTA(ctx context.Context, id string, file *os. return d.deviceOTA(ctx, id, file, expireMins) } +type otaStatusGetterTest struct{} + +func (s *otaStatusGetterTest) GetOtaLastStatusByDeviceID(deviceID string) (*otaapi.OtaStatusList, error) { + ota := otaapi.Ota{ + ID: uuid.Must(uuid.NewV4()).String(), + Status: "in_progress", + StartedAt: "2021-09-01T12:00:00Z", + } + response := &otaapi.OtaStatusList{ + Ota: []otaapi.Ota{ota}, + } + return response, nil +} + func TestRun(t *testing.T) { var ( failPrefix = "00000000" @@ -38,9 +54,10 @@ func TestRun(t *testing.T) { return nil }, } + mockStatusClient := &otaStatusGetterTest{} devs := []string{okID1, failID1, okID2, failID2, okID3} - res := run(context.TODO(), mockClient, devs, testFilename, 0) + res := run(context.TODO(), mockClient, mockStatusClient, devs, testFilename, 0) if len(res) != len(devs) { t.Errorf("expected %d results, got %d", len(devs), len(res)) } diff --git a/command/ota/status.go b/command/ota/status.go new file mode 100644 index 00000000..6913209d --- /dev/null +++ b/command/ota/status.go @@ -0,0 +1,46 @@ +package ota + +import ( + "fmt" + + "github.com/arduino/arduino-cli/cli/feedback" + "github.com/arduino/arduino-cloud-cli/config" + otaapi "github.com/arduino/arduino-cloud-cli/internal/ota-api" +) + +func PrintOtaStatus(otaid, otaids, device string, cred *config.Credentials, limit int, order string) error { + + if feedback.GetFormat() == feedback.JSONMini { + return fmt.Errorf("jsonmini format is not supported for this command") + } + + otapi := otaapi.NewClient(cred) + + if otaids != "" { + res, err := otapi.GetOtaStatusByOtaIDs(otaids) + if err == nil && res != nil { + feedback.PrintResult(res) + } else if err != nil { + return err + } + } else if otaid != "" { + res, err := otapi.GetOtaStatusByOtaID(otaid, limit, order) + if err == nil && res != nil { + feedback.PrintResult(otaapi.OtaStatusDetail{ + Ota: res.Ota, + Details: res.States, + }) + } else if err != nil { + return err + } + } else if device != "" { + res, err := otapi.GetOtaStatusByDeviceID(device, limit, order) + if err == nil && res != nil { + feedback.PrintResult(res) + } else if err != nil { + return err + } + } + + return nil +} diff --git a/command/ota/upload.go b/command/ota/upload.go index 16d138e4..fcb32267 100644 --- a/command/ota/upload.go +++ b/command/ota/upload.go @@ -23,9 +23,11 @@ import ( "os" "path/filepath" + "github.com/arduino/arduino-cli/cli/feedback" "github.com/arduino/arduino-cloud-cli/config" "github.com/arduino/arduino-cloud-cli/internal/iot" "github.com/arduino/arduino-cloud-cli/internal/ota" + otaapi "github.com/arduino/arduino-cloud-cli/internal/ota-api" ) const ( @@ -56,6 +58,7 @@ func Upload(ctx context.Context, params *UploadParams, cred *config.Credentials) if err != nil { return err } + otapi := otaapi.NewClient(cred) dev, err := iotClient.DeviceShow(ctx, params.DeviceID) if err != nil { @@ -102,6 +105,14 @@ func Upload(ctx context.Context, params *UploadParams, cred *config.Credentials) if err != nil { return err } + // Try to get ota-id from API + otaID, err := otapi.GetOtaLastStatusByDeviceID(params.DeviceID) + if err != nil { + return err + } + if otaID != nil && len(otaID.Ota) > 0 { + feedback.PrintResult(otaID.Ota[0]) + } return nil } diff --git a/example/tools/ota/ota_mass_upload.sh b/example/tools/ota/ota_mass_upload.sh new file mode 100755 index 00000000..77127235 --- /dev/null +++ b/example/tools/ota/ota_mass_upload.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# This script is used to upload the firmware to the device using the OTA service. + +export PATH=$PATH:. + +checkExecutable () { + if ! command -v $1 &> /dev/null + then + echo "$1 could not be found in PATH" + exit 1 + fi +} + +printHelp () { + echo "" + echo "Usage: $0 [-t | -d ] -f [-o ] [-v ]" + echo "" + echo "Examples -----------------" + echo " perform ota on devices with firmware=v1 tag" + echo " $0 -t firmware=v1 -f myfirmware.bin" + echo " perform ota on devices with firmware=v1 tag and apply new tag firmware=v2 to updated devices, waiting for 1200 seconds" + echo " $0 -t firmware=v1 -f myfirmware.bin -v firmware=v2 -o 1200" + echo " perform ota on two specified devices" + echo " $0 -d 261ec96a-38ba-4520-96e6-2447c4163e9b,8b10acdb-b722-4068-8e4d-d1c1b7302df4 -f myfirmware.bin" + echo "" + exit 1 +} + +# Check dependencies... +checkExecutable "arduino-cloud-cli" +checkExecutable "jq" +checkExecutable "sort" +checkExecutable "uniq" +checkExecutable "paste" + +# Default wait time for OTA process to complete +waittime=900 +newtagversion="" + +while getopts t:v:f:o:d: flag +do + case "${flag}" in + t) tag=${OPTARG};; + v) newtagversion=${OPTARG};; + f) firmwarefile=${OPTARG};; + o) waittime=${OPTARG};; + d) deviceids=${OPTARG};; + esac +done + +if [[ "$firmwarefile" == "" || "$waittime" == "" ]]; then + printHelp +fi +if [[ "$tag" == "" && "$deviceids" == "" ]]; then + printHelp +fi + +if [[ "$deviceids" == "" ]]; then + echo "Starting OTA process for devices with tag \"$tag\" using firmware \"$firmwarefile\"" + echo "" + + devicelistjson=$(arduino-cloud-cli device list --tags $tag --format json) +else + echo "Starting OTA process for devices \"$deviceids\" using firmware \"$firmwarefile\"" + echo "" + + devicelistjson=$(arduino-cloud-cli device list -d $deviceids --format json) +fi + +if [[ "$devicelistjson" == "" || "$devicelistjson" == "null" ]]; then + echo "No device found" + exit 1 +fi + +devicecount=$(echo $devicelistjson | jq '.[] | .id' | wc -l) + +if [ "$devicecount" -gt 0 ]; then + echo "Found $devicecount devices" + echo "" + if [[ "$deviceids" == "" ]]; then + arduino-cloud-cli device list --tags $tag + else + arduino-cloud-cli device list -d $deviceids + fi +else + echo "No device found" + exit 1 +fi + +fqbncount=$(echo $devicelistjson | jq '.[] | .fqbn' | sort | uniq | wc -l) + +if [ "$fqbncount" -gt 1 ]; then + echo "Mixed FQBNs detected. Please ensure all devices have the same FQBN." + fqbns=$(echo $devicelistjson | jq '.[] | .fqbn' | sort | uniq) + echo "Detected FQBNs:" + echo "$fqbns" + exit 1 +fi + +fqbn=$(echo $devicelistjson | jq -r '.[] | .fqbn' | sort | uniq | head -n 1) + +echo "Sending OTA request to detected boards of type $fqbn..." +if [[ "$deviceids" == "" ]]; then + otastartedout=$(arduino-cloud-cli ota mass-upload --device-tags $tag --file $firmwarefile -b $fqbn --format json) +else + otastartedout=$(arduino-cloud-cli ota mass-upload -d $deviceids --file $firmwarefile -b $fqbn --format json) +fi +if [ $? -ne 0 ]; then + echo "Detected error during OTA process. Exiting..." + exit 1 +fi + +otaids=$(echo $otastartedout | jq -r '.[] | .OtaStatus | .id' | uniq | paste -sd "," -) + +if [ $otaids == "null" ]; then + echo "No OTA processes to monitor. This could be due to an upgrade from previous ArduinoIotLibrary versions. Exiting..." + if [ "$newtagversion" != "" ]; then + otasucceeded=$(echo $devicelistjson | jq -r '.[] | .id' | uniq | paste -sd "," -) + echo "" + echo "Tag updated devices as \"$newtagversion\"" + arduino-cloud-cli device create-tags --ids $otasucceeded --tags $newtagversion + echo "" + arduino-cloud-cli device list --tags $newtagversion + fi + exit 0 +fi + +correctlyfinished=0 +while [ $waittime -gt 0 ]; do + echo "Waiting for $waittime seconds for OTA process to complete..." + sleep 15 + waittime=$((waittime-15)) + # Check status of running processess... + otastatuslines=$(arduino-cloud-cli ota status --ota-ids $otaids --format json) + otastatusinpcnt=$(echo $otastatuslines | grep in_progress | wc -l) + otastatuspencnt=$(echo $otastatuslines | grep pending | wc -l) + otasucceeded=$(echo $otastatuslines | jq -r '.[] | select (.status | contains("succeeded")) | .device_id' | uniq | paste -sd "," -) + if [[ $otastatusinpcnt -eq 0 && $otastatuspencnt -eq 0 ]]; then + correctlyfinished=1 + break + fi +done + +echo "" +echo "Status report:" +arduino-cloud-cli ota status --ota-ids $otaids + +exitcode=0 +if [ $correctlyfinished -eq 0 ]; then + echo "OTA process did not complete within the specified time for some boards" + exitcode=1 +else + echo "OTA process completed successfully for all boards" +fi + +if [ "$newtagversion" != "" ]; then + echo "" + echo "Tag updated devices as \"$newtagversion\"" + arduino-cloud-cli device create-tags --ids $otasucceeded --tags $newtagversion + echo "" + arduino-cloud-cli device list --tags $newtagversion +fi + +exit $exitcode diff --git a/internal/iot/client.go b/internal/iot/client.go index 3a438b27..55170caa 100644 --- a/internal/iot/client.go +++ b/internal/iot/client.go @@ -195,8 +195,8 @@ func (cl *Client) DeviceOTA(ctx context.Context, id string, file *os.File, expir ExpireInMins: optional.NewInt32(int32(expireMins)), Async: optional.NewBool(true), } - _, err = cl.api.DevicesV2OtaApi.DevicesV2OtaUpload(ctx, id, file, opt) - if err != nil { + resp, err := cl.api.DevicesV2OtaApi.DevicesV2OtaUpload(ctx, id, file, opt) + if err != nil && resp.StatusCode != 409 { // 409 (Conflict) is the status code for an already existing OTA for the same SHA/device, so ignoring it. err = fmt.Errorf("uploading device ota: %w", errorDetail(err)) return err } @@ -471,13 +471,10 @@ func (cl *Client) DashboardDelete(ctx context.Context, id string) error { } func (cl *Client) setup(client, secret, organization string) error { - baseURL := "https://api2.arduino.cc" - if url := os.Getenv("IOT_API_URL"); url != "" { - baseURL = url - } + baseURL := GetArduinoAPIBaseURL() // Configure a token source given the user's credentials. - cl.token = token(client, secret, baseURL) + cl.token = NewUserTokenSource(client, secret, baseURL) config := iotclient.NewConfiguration() if organization != "" { diff --git a/internal/iot/token.go b/internal/iot/token.go index 2f4b1ead..717ef342 100644 --- a/internal/iot/token.go +++ b/internal/iot/token.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "net/url" + "os" "strings" iotclient "github.com/arduino/iot-client-go" @@ -29,7 +30,16 @@ import ( cc "golang.org/x/oauth2/clientcredentials" ) -func token(client, secret, baseURL string) oauth2.TokenSource { +func GetArduinoAPIBaseURL() string { + baseURL := "https://api2.arduino.cc" + if url := os.Getenv("IOT_API_URL"); url != "" { + baseURL = url + } + return baseURL +} + +// Build a new token source to forge api JWT tokens based on provided credentials +func NewUserTokenSource(client, secret, baseURL string) oauth2.TokenSource { // We need to pass the additional "audience" var to request an access token. additionalValues := url.Values{} additionalValues.Add("audience", "https://api2.arduino.cc/iot") diff --git a/internal/ota-api/client.go b/internal/ota-api/client.go new file mode 100644 index 00000000..dc647406 --- /dev/null +++ b/internal/ota-api/client.go @@ -0,0 +1,207 @@ +// This file is part of arduino-cloud-cli. +// +// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package otaapi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/arduino/arduino-cloud-cli/config" + "github.com/arduino/arduino-cloud-cli/internal/iot" + "golang.org/x/oauth2" +) + +const ( + OrderDesc = "desc" + OrderAsc = "asc" +) + +var ErrAlreadyInProgress = fmt.Errorf("already in progress") + +type OtaApiClient struct { + client *http.Client + host string + src oauth2.TokenSource + organization string +} + +func NewClient(credentials *config.Credentials) *OtaApiClient { + host := iot.GetArduinoAPIBaseURL() + tokenSource := iot.NewUserTokenSource(credentials.Client, credentials.Secret, host) + return &OtaApiClient{ + client: &http.Client{}, + src: tokenSource, + host: host, + organization: credentials.Organization, + } +} + +func (c *OtaApiClient) performGetRequest(endpoint, token string) (*http.Response, error) { + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Content-Type", "application/json") + if c.organization != "" { + req.Header.Add("X-Organization", c.organization) + } + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + return res, nil +} + +func (c *OtaApiClient) GetOtaStatusByOtaID(otaid string, limit int, order string) (*OtaStatusResponse, error) { + + if otaid == "" { + return nil, fmt.Errorf("invalid ota-id: empty") + } + + userRequestToken, err := c.src.Token() + if err != nil { + if strings.Contains(err.Error(), "401") { + return nil, errors.New("wrong credentials") + } + return nil, fmt.Errorf("cannot retrieve a valid token: %w", err) + } + + endpoint := c.host + "/ota/v1/ota/" + otaid + res, err := c.performGetRequest(endpoint, userRequestToken.AccessToken) + if err != nil { + return nil, err + } + defer res.Body.Close() + bodyb, err := io.ReadAll(res.Body) + + if res.StatusCode == 200 { + var otaResponse OtaStatusResponse + if err == nil && bodyb != nil { + err = json.Unmarshal(bodyb, &otaResponse) + if err != nil { + return nil, err + } + } + + if len(otaResponse.States) > 0 { + // Sort output by StartedAt + sort.Slice(otaResponse.States, func(i, j int) bool { + t1, err := time.Parse(time.RFC3339, otaResponse.States[i].Timestamp) + if err != nil { + return false + } + t2, err := time.Parse(time.RFC3339, otaResponse.States[j].Timestamp) + if err != nil { + return false + } + if order == "asc" { + return t1.Before(t2) + } + return t1.After(t2) + }) + if limit > 0 && len(otaResponse.States) > limit { + otaResponse.States = otaResponse.States[:limit] + } + } + + return &otaResponse, nil + } else if res.StatusCode == 404 || res.StatusCode == 400 { + return nil, fmt.Errorf("ota-id %s not found", otaid) + } + + return nil, err +} + +func (c *OtaApiClient) GetOtaStatusByOtaIDs(otaids string) (*OtaStatusList, error) { + + ids := strings.Split(otaids, ",") + if len(ids) == 0 { + return nil, fmt.Errorf("invalid ota-ids: empty") + } + + returnStatus := OtaStatusList{} + for _, id := range ids { + if id != "" { + resp, err := c.GetOtaStatusByOtaID(id, 1, OrderDesc) + if err != nil { + return nil, err + } + returnStatus.Ota = append(returnStatus.Ota, resp.Ota) + } + + } + + return &returnStatus, nil +} + +func (c *OtaApiClient) GetOtaLastStatusByDeviceID(deviceID string) (*OtaStatusList, error) { + return c.GetOtaStatusByDeviceID(deviceID, 1, OrderDesc) +} + +func (c *OtaApiClient) GetOtaStatusByDeviceID(deviceID string, limit int, order string) (*OtaStatusList, error) { + + if deviceID == "" { + return nil, fmt.Errorf("invalid device-id: empty") + } + + userRequestToken, err := c.src.Token() + if err != nil { + if strings.Contains(err.Error(), "401") { + return nil, errors.New("wrong credentials") + } + return nil, fmt.Errorf("cannot retrieve a valid token: %w", err) + } + + endpoint := c.host + "/ota/v1/ota?device_id=" + deviceID + if limit > 0 { + endpoint += "&limit=" + fmt.Sprintf("%d", limit) + } + if order != "" && (order == "asc" || order == "desc") { + endpoint += "&order=" + order + } + res, err := c.performGetRequest(endpoint, userRequestToken.AccessToken) + if err != nil { + return nil, err + } + defer res.Body.Close() + bodyb, err := io.ReadAll(res.Body) + + if res.StatusCode == 200 { + var otaResponse OtaStatusList + if err == nil && bodyb != nil { + err = json.Unmarshal(bodyb, &otaResponse) + if err != nil { + return nil, err + } + } + return &otaResponse, nil + } else if res.StatusCode == 404 || res.StatusCode == 400 { + return nil, fmt.Errorf("device-id %s not found", deviceID) + } else if res.StatusCode == 409 { + return nil, ErrAlreadyInProgress + } + + return nil, err +} diff --git a/internal/ota-api/dto.go b/internal/ota-api/dto.go new file mode 100644 index 00000000..eaf09424 --- /dev/null +++ b/internal/ota-api/dto.go @@ -0,0 +1,185 @@ +// This file is part of arduino-cloud-cli. +// +// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package otaapi + +import ( + "strings" + "time" + + "unicode" + + "github.com/arduino/arduino-cli/table" +) + +type ( + OtaStatusResponse struct { + Ota Ota `json:"ota"` + States []State `json:"states,omitempty"` + } + + OtaStatusList struct { + Ota []Ota `json:"ota"` + } + + Ota struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + DeviceID string `json:"device_id,omitempty" yaml:"device_id,omitempty"` + Status string `json:"status" yaml:"status"` + StartedAt string `json:"started_at" yaml:"started_at"` + EndedAt string `json:"ended_at,omitempty" yaml:"ended_at,omitempty"` + ErrorReason string `json:"error_reason,omitempty" yaml:"error_reason,omitempty"` + } + + State struct { + OtaID string `json:"ota_id"` + State string `json:"state"` + StateData string `json:"state_data,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + } + + OtaStatusDetail struct { + Ota Ota `json:"ota"` + Details []State `json:"details,omitempty"` + } +) + +func (r OtaStatusList) Data() interface{} { + return r.Ota +} + +func (r OtaStatusList) String() string { + if len(r.Ota) == 0 { + return "" + } + t := table.New() + hasErrorReason := false + for _, r := range r.Ota { + if r.ErrorReason != "" { + hasErrorReason = true + break + } + } + + if hasErrorReason { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At", "Error Reason") + } else { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At") + } + + // Now print the table + for _, r := range r.Ota { + line := []any{r.DeviceID, r.ID, r.MapStatus(), formatHumanReadableTs(r.StartedAt), formatHumanReadableTs(r.EndedAt)} + if hasErrorReason { + line = append(line, r.ErrorReason) + } + t.AddRow(line...) + } + + return t.Render() +} + +func (o Ota) MapStatus() string { + return upperCaseFirst(o.Status) +} + +func (r Ota) Data() interface{} { + return r +} + +func (r Ota) String() string { + if len(r.ID) == 0 { + return "" + } + t := table.New() + hasErrorReason := r.ErrorReason != "" + + if hasErrorReason { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At", "Error Reason") + } else { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At") + } + + // Now print the table + line := []any{r.DeviceID, r.ID, r.MapStatus(), formatHumanReadableTs(r.StartedAt), formatHumanReadableTs(r.EndedAt)} + if hasErrorReason { + line = append(line, r.ErrorReason) + } + t.AddRow(line...) + + return t.Render() +} + +func (r OtaStatusDetail) Data() interface{} { + return r.Ota +} + +func (r OtaStatusDetail) String() string { + if r.Ota.ID == "" { + return "No OTA found" + } + t := table.New() + hasErrorReason := r.Ota.ErrorReason != "" + + if hasErrorReason { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At", "Error Reason") + } else { + t.SetHeader("Device ID", "Ota ID", "Status", "Started At", "Ended At") + } + + // Now print the table + line := []any{r.Ota.DeviceID, r.Ota.ID, r.Ota.MapStatus(), formatHumanReadableTs(r.Ota.StartedAt), formatHumanReadableTs(r.Ota.EndedAt)} + if hasErrorReason { + line = append(line, r.Ota.ErrorReason) + } + t.AddRow(line...) + + output := t.Render() + + // Add details + if len(r.Details) > 0 { + t = table.New() + t.SetHeader("Time", "Status", "Detail") + for _, s := range r.Details { + t.AddRow(formatHumanReadableTs(s.Timestamp), upperCaseFirst(s.State), s.StateData) + } + output += "\nDetails:\n" + t.Render() + } + + return output +} + +func upperCaseFirst(s string) string { + if len(s) > 0 { + s = strings.ReplaceAll(s, "_", " ") + for i, v := range s { + return string(unicode.ToUpper(v)) + s[i+1:] + } + } + return "" +} + +func formatHumanReadableTs(ts string) string { + if ts == "" { + return "" + } + parsed, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + return ts + } + return parsed.Format(time.RFC3339) +}