├── NOTICE ├── files └── overlay_root_fs │ ├── etc │ ├── systemd │ │ └── system │ │ │ └── multi-user.target.wants │ │ │ ├── ssh.service │ │ │ └── goagent.service │ ├── wpa_supplicant │ │ └── wpa_supplicant.conf │ └── goagent │ │ └── goagent.conf │ └── lib │ └── systemd │ └── system │ └── goagent.service ├── awsiotjobs ├── NOTICE ├── mender │ ├── mender.go │ └── mender_test.go ├── LICENSE └── awsiotjobs.go ├── img ├── update-process.png ├── high-level-arch.png └── goagent-service-status.png ├── .gitignore ├── cmd └── cmd.go ├── go.mod ├── CODE_OF_CONDUCT.md ├── Config ├── mendercmd ├── LICENSE └── mender_cmd.go ├── goagent.go ├── CONTRIBUTING.md ├── go.sum ├── LICENSE └── README.md /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /files/overlay_root_fs/etc/systemd/system/multi-user.target.wants/ssh.service: -------------------------------------------------------------------------------- 1 | /lib/systemd/system/ssh.service -------------------------------------------------------------------------------- /files/overlay_root_fs/etc/systemd/system/multi-user.target.wants/goagent.service: -------------------------------------------------------------------------------- 1 | /lib/systemd/system/goagent.service -------------------------------------------------------------------------------- /awsiotjobs/NOTICE: -------------------------------------------------------------------------------- 1 | AWS IoT Jobs Go library 2 | Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /img/update-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-iot-jobs-full-system-update/HEAD/img/update-process.png -------------------------------------------------------------------------------- /img/high-level-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-iot-jobs-full-system-update/HEAD/img/high-level-arch.png -------------------------------------------------------------------------------- /img/goagent-service-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-iot-jobs-full-system-update/HEAD/img/goagent-service-status.png -------------------------------------------------------------------------------- /files/overlay_root_fs/etc/wpa_supplicant/wpa_supplicant.conf: -------------------------------------------------------------------------------- 1 | ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 2 | update_config=1 3 | 4 | country= 5 | network={ 6 | ssid="" 7 | psk="" 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | ) 8 | 9 | func main() { 10 | fmt.Println("Mender mock") 11 | for i := 0; i < 3; i++ { 12 | fmt.Println(i) 13 | time.Sleep(1 * time.Second) 14 | } 15 | os.Exit(0) 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aws-samples/aws-iot-jobs-full-system-update/goagent 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/eclipse/paho.mqtt.golang v1.2.0 7 | github.com/stretchr/testify v1.7.0 8 | golang.org/x/tools/gopls v0.7.1 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /files/overlay_root_fs/etc/goagent/goagent.conf: -------------------------------------------------------------------------------- 1 | { 2 | "Port": 8883, 3 | "CaCertPath": "/etc/goagent/rootCA.pem", 4 | "CertificatePath":"/etc/goagent/cert.pem", 5 | "PrivateKeyPath": "/etc/goagent/private.key", 6 | "Endpoint": "", 7 | "ThingName": "" 9 | } 10 | -------------------------------------------------------------------------------- /files/overlay_root_fs/lib/systemd/system/goagent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AWS IoT Job agent with Mender integration 3 | After=network.target systemd-timesyncd.service 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | Group=root 9 | ExecStart=/usr/sbin/goagent 10 | Restart=always 11 | RestartSec=5 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /Config: -------------------------------------------------------------------------------- 1 | # -*-perl-*- 2 | 3 | package.Go-JobAgent = { 4 | interfaces = (1.0); 5 | 6 | deploy = { 7 | generic = true; 8 | }; 9 | 10 | build-environment = { 11 | chroot = basic; 12 | network-access = blocked; 13 | }; 14 | 15 | # Use NoOpBuild. See https://w.amazon.com/index.php/BrazilBuildSystem/NoOpBuild 16 | build-system = no-op; 17 | build-tools = { 18 | 1.0 = { 19 | NoOpBuild = 1.0; 20 | }; 21 | }; 22 | 23 | # Use runtime-dependencies for when you want to bring in additional 24 | # packages when deploying. 25 | # Use dependencies instead if you intend for these dependencies to 26 | # be exported to other packages that build against you. 27 | dependencies = { 28 | 1.0 = { 29 | }; 30 | }; 31 | 32 | runtime-dependencies = { 33 | 1.0 = { 34 | }; 35 | }; 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /mendercmd/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /mendercmd/mender_cmd.go: -------------------------------------------------------------------------------- 1 | package mendercmd 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | ) 8 | 9 | // Commander interface represents a generic tool interface 10 | type Commander interface { 11 | Commit() error 12 | Install(url string, done chan error, progress chan string) error 13 | Rollback() error 14 | } 15 | 16 | // MenderCommand serves as the implementation of the commander interface 17 | type MenderCommand struct { 18 | } 19 | 20 | func execMender(done chan error, progress chan string, args ...string) error { 21 | cmd := exec.Command("mender", args...) 22 | stdout, _ := cmd.StdoutPipe() 23 | cmd.Start() 24 | scanner := bufio.NewScanner(stdout) 25 | for scanner.Scan() { 26 | m := scanner.Text() 27 | if progress != nil { 28 | progress <- m 29 | } 30 | fmt.Println(m) 31 | } 32 | err := cmd.Wait() 33 | if done != nil { 34 | done <- err 35 | } 36 | return err 37 | } 38 | 39 | // Install runs the mender install 40 | func (m *MenderCommand) Install(url string, done chan error, progress chan string) error { 41 | return execMender(done, progress, "-install", url) 42 | } 43 | 44 | // Commit runs mender commit 45 | func (m *MenderCommand) Commit() error { 46 | return execMender(nil, nil, "-commit") 47 | } 48 | 49 | // Rollback runs mender rollback 50 | func (m *MenderCommand) Rollback() error { 51 | return execMender(nil, nil, "-rollback") 52 | } 53 | -------------------------------------------------------------------------------- /goagent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/aws-samples/aws-iot-jobs-full-system-update/goagent/awsiotjobs" 9 | "github.com/aws-samples/aws-iot-jobs-full-system-update/goagent/awsiotjobs/mender" 10 | ) 11 | 12 | func main() { 13 | c := awsiotjobs.NewConfig() 14 | configFile := "" 15 | flag.IntVar(&c.Port, "port", 8883, "the port to use to connect") 16 | flag.StringVar(&c.CaCertPath, "cacert", "rootCA.pem", "the CA cert path") 17 | flag.StringVar(&c.CertificatePath, "cert", "cert.pem", "the device certificate path") 18 | flag.StringVar(&c.PrivateKeyPath, "key", "private.key", "the private key path") 19 | flag.StringVar(&c.Endpoint, "endpoint", "", "the endpoint path") 20 | flag.StringVar(&c.ThingName, "thingName", "", "the thing name") 21 | flag.StringVar(&c.ClientID, "clientId", "", "the client Id for the MQTT connection") 22 | flag.StringVar(&configFile, "config", "/etc/goagent/goagent.conf", "the configuration file. Inline properties will override config file settings") 23 | flag.Parse() 24 | 25 | if len(configFile) > 0 { 26 | c.FromFile(configFile) 27 | flag.Parse() // We execute this to override the settings read from the config file 28 | } 29 | c.Handler = mender.Process 30 | awsJobsClient := awsiotjobs.NewClient(c) 31 | fmt.Println("MenderAgent started") 32 | awsJobsClient.ConnectAndSubscribe() 33 | 34 | var wg sync.WaitGroup 35 | wg.Add(1) 36 | wg.Wait() 37 | 38 | } 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /awsiotjobs/mender/mender.go: -------------------------------------------------------------------------------- 1 | package mender 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os/exec" 8 | "time" 9 | 10 | "github.com/aws-samples/aws-iot-jobs-full-system-update/goagent/awsiotjobs" 11 | "github.com/aws-samples/aws-iot-jobs-full-system-update/goagent/mendercmd" 12 | ) 13 | 14 | var timeout = 10 * time.Minute 15 | 16 | type nextJobPayload struct { 17 | clientToken string 18 | } 19 | 20 | type jobDocument struct { 21 | operation string 22 | parameters string 23 | } 24 | 25 | type menderInstall struct { 26 | url string 27 | } 28 | 29 | // Job represents the job document received via AWS IoT jobs 30 | type Job struct { 31 | Operation string `json:"operation"` 32 | URL string `json:"url"` 33 | menderState State 34 | execution awsiotjobs.JobExecutioner 35 | } 36 | 37 | // State reports the state of the job 38 | type State struct { 39 | Step string `json:"step"` 40 | } 41 | 42 | func (mj *Job) progress(step string) { 43 | mj.menderState.Step = step // should wrap with a mutex 44 | err := mj.execution.InProgress(awsiotjobs.StatusDetails{"step": step}) 45 | if err != nil { 46 | log.Printf("Failed to execute InProgress on the Job, got error: %s", err.Error()) 47 | } 48 | } 49 | 50 | func (mj *Job) success(step string) { 51 | mj.menderState.Step = step // should wrap with a mutex 52 | err := mj.execution.Success(awsiotjobs.StatusDetails{"step": step}) 53 | if err != nil { 54 | log.Printf("Failed to execute Success on the Job, got error: %s", err.Error()) 55 | } 56 | } 57 | 58 | func (mj *Job) fail(err awsiotjobs.JobError) { 59 | e := mj.execution.Fail(err) 60 | if e != nil { 61 | log.Printf("Failed to execute Fail on the Job, got error: %s", err.Error()) 62 | } 63 | } 64 | 65 | func (mj *Job) reject(err awsiotjobs.JobError) { 66 | e := mj.execution.Reject(err) 67 | if e != nil { 68 | log.Printf("Failed to execute Reject on the Job, got error: %s", err.Error()) 69 | } 70 | } 71 | 72 | // This function implements the logic for the execution of the Mender job 73 | func (mj *Job) exec(cmd mendercmd.Commander, timeout time.Duration) error { 74 | switch mj.Operation { 75 | case "mender_install": 76 | // check if we are back after rebooting 77 | switch mj.menderState.Step { 78 | case "rebooting": 79 | mj.reportProgress("rebooted") 80 | // This is a naive implementation. Before committing one would probably check the system is working fine 81 | // and then issue the Commit, otherwise Rollback. 82 | // For example, in case Greengrass was installed, one could check that Greengrass service is up 83 | // and running. 84 | // On the other hand, to come to this stage, we know that we have network, time and date and we can connect 85 | // to AWS. 86 | err := cmd.Commit() // commit 87 | if err != nil { 88 | jobErr := awsiotjobs.JobError{ErrCode: "ERR_MENDER_COMMIT", ErrMessage: "error committing"} 89 | mj.fail(jobErr) 90 | return jobErr 91 | } 92 | mj.success("committed") 93 | default: 94 | // If the step is "installing" it could be for different cases 95 | // 1- the system rebooted/lost connection and the installation was not completed. 96 | // 2- Installation was completed, system rebooted, but the state update was not performed 97 | // In case 1 we should restart the installation process 98 | // In case 2 we should either make sure this does not happen - ie make the reboot conditional to the 99 | // correct persistance of the "rebooting" state; or rely on some other mechanism to detect that the 100 | // firmware has been successfully updated and the system has rebooted and is working correctly 101 | 102 | ch := make(chan string) 103 | done := make(chan error) 104 | mj.progress("installing") 105 | go cmd.Install(mj.URL, done, ch) 106 | for { 107 | select { 108 | case progress := <-ch: 109 | log.Printf("%s", progress) 110 | mj.reportProgress(progress) // report progress via MQTT 111 | case err := <-done: 112 | if err != nil { 113 | jobErr := awsiotjobs.JobError{ErrCode: "ERR_MENDER_INSTALL_FAILED", ErrMessage: err.Error()} 114 | mj.fail(jobErr) 115 | return jobErr 116 | } 117 | // This should be changed - setting the rebooting state might fail 118 | // and when the system startsup will find a wrong state and will start installing the software again 119 | // Must find a way to make this deterministic - maybe relying on mender local state? 120 | mj.progress("rebooting") 121 | go func() { 122 | cmd := exec.Command("shutdown", "-r", "now") 123 | cmd.Start() 124 | err := cmd.Wait() 125 | if err != nil { 126 | fmt.Println("Could not reboot the system") 127 | mj.fail(awsiotjobs.JobError{ErrCode: "ERROR_UNABLE_TO_REBOOT", ErrMessage: err.Error()}) 128 | return 129 | } 130 | fmt.Println("rebooting...") 131 | mj.execution.Terminate() //Should be called by the agent code and not the library - based on signalling from the OS when shutting down 132 | }() 133 | return nil 134 | case <-time.After(timeout): // timeout value can be in doc 135 | fmt.Printf("install timeout") 136 | jobErr := awsiotjobs.JobError{ErrCode: "ERR_MENDER_INSTALL_TIMEOUT", ErrMessage: "mender timed out"} 137 | mj.fail(jobErr) 138 | return jobErr 139 | } 140 | } 141 | } 142 | 143 | case "mender_rollback": 144 | err := cmd.Rollback() 145 | if err != nil { 146 | mj.fail(awsiotjobs.JobError{ErrCode: "ERR_MENDER_ROLLBACK_FAIL", ErrMessage: "unable to run rollback"}) 147 | return err 148 | } 149 | mj.success("rolled_back") 150 | } 151 | return nil 152 | } 153 | 154 | func (mj *Job) reportProgress(p string) { 155 | payload := map[string]interface{}{ 156 | "progress": p, 157 | "ts": time.Now().Unix(), 158 | } 159 | topic := fmt.Sprintf("mender/%s/job/%s/progress", mj.execution.GetThingName(), mj.execution.GetJobID()) 160 | jsonPayload, _ := json.Marshal(payload) 161 | mj.execution.Publish(topic, 0, jsonPayload) 162 | } 163 | 164 | func parseJobDocument(jobExecution awsiotjobs.JobExecutioner) (Job, error) { 165 | jobDocument, _ := json.Marshal(jobExecution.GetJobDocument()) 166 | job := Job{execution: jobExecution} 167 | json.Unmarshal(jobDocument, &job) 168 | switch job.Operation { 169 | case "mender_install": 170 | if len(job.URL) == 0 { 171 | return job, awsiotjobs.JobError{ErrCode: "ERR_MENDER_MISSING_URL", ErrMessage: "missing url parameter"} 172 | } 173 | case "mender_rollback": 174 | default: 175 | return job, awsiotjobs.JobError{ErrCode: "ERR_JOB_INVALID_OPERATION", ErrMessage: "unrecognized or missing operation"} 176 | } 177 | var menderState State 178 | statusDetails, _ := json.Marshal(jobExecution.GetStatusDetails()) 179 | json.Unmarshal(statusDetails, &menderState) 180 | job.menderState = menderState 181 | return job, nil 182 | } 183 | 184 | // Process is the JobExecution handler 185 | func Process(jobExecution awsiotjobs.JobExecutioner) { 186 | job, err := parseJobDocument(jobExecution) 187 | if err != nil { 188 | jobError, ok := err.(awsiotjobs.JobError) 189 | if ok { 190 | switch jobError.ErrCode { 191 | case "ERR_MENDER_MISSING_URL": 192 | case "ERR_JOB_INVALID_OPERATION": 193 | fmt.Printf("Invalid job document - Rejecting\n") 194 | job.reject(err.(awsiotjobs.JobError)) 195 | default: 196 | fmt.Printf("Unknown - Ignoring") 197 | } 198 | } else { 199 | fmt.Printf("Unknown error %s - Ignoring\n", err.Error()) 200 | } 201 | } else { 202 | go func() { 203 | job.exec(&mendercmd.MenderCommand{}, timeout) 204 | }() 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /awsiotjobs/mender/mender_test.go: -------------------------------------------------------------------------------- 1 | package mender 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/aws-samples/aws-iot-jobs-full-system-update/goagent/awsiotjobs" 10 | "github.com/stretchr/testify/mock" 11 | ) 12 | 13 | const testTimeout = 500 * time.Millisecond 14 | 15 | type JobExecutionMock struct { 16 | mock.Mock 17 | jobExecution awsiotjobs.JobExecution 18 | } 19 | 20 | func (j *JobExecutionMock) GetStatusDetails() awsiotjobs.StatusDetails { 21 | j.On("GetStatusDetails").Return(j.jobExecution.StatusDetails) 22 | j.Called() 23 | return j.jobExecution.StatusDetails 24 | } 25 | 26 | func (j *JobExecutionMock) GetJobDocument() awsiotjobs.JobDocument { 27 | j.On("GetJobDocument").Return(j.jobExecution.JobDocument) 28 | return j.jobExecution.JobDocument 29 | } 30 | 31 | func (j *JobExecutionMock) GetJobID() string { 32 | j.On("GetJobID").Return("AA") 33 | j.Called() 34 | return "AA" 35 | } 36 | 37 | func (j *JobExecutionMock) GetThingName() string { 38 | j.On("GetThingName").Return("thingName") 39 | j.Called() 40 | return "thingName" 41 | } 42 | 43 | func (j *JobExecutionMock) Publish(t string, q byte, p interface{}) { 44 | j.On("Publish").Return() 45 | j.Called() 46 | } 47 | 48 | func (j *JobExecutionMock) Success(s awsiotjobs.StatusDetails) error { 49 | j.On("Success").Return() 50 | j.Called() 51 | return nil 52 | } 53 | 54 | func (j *JobExecutionMock) InProgress(s awsiotjobs.StatusDetails) error { 55 | j.On("InProgress").Return() 56 | j.Called() 57 | return nil 58 | } 59 | 60 | func (j *JobExecutionMock) Fail(e awsiotjobs.JobError) error { 61 | j.On("Fail").Return() 62 | j.Called() 63 | return nil 64 | } 65 | 66 | func (j *JobExecutionMock) Reject(e awsiotjobs.JobError) error { 67 | j.On("Reject").Return() 68 | j.Called() 69 | return nil 70 | } 71 | 72 | func (j *JobExecutionMock) Terminate() { 73 | j.On("Terminate").Return() 74 | j.Called() 75 | } 76 | 77 | func TestParseJobMessageInstall(t *testing.T) { 78 | doc := awsiotjobs.JobExecution{ 79 | JobDocument: map[string]interface{}{ 80 | "operation": "mender_install", 81 | "url": "http://test", 82 | }, 83 | Status: "QUEUED", 84 | StatusDetails: map[string]interface{}{}, 85 | } 86 | amock := JobExecutionMock{jobExecution: doc} 87 | job, _ := parseJobDocument(&amock) 88 | wanted := Job{ 89 | "mender_install", 90 | "http://test", 91 | State{}, 92 | &amock, 93 | } 94 | 95 | if !reflect.DeepEqual(job, wanted) { 96 | t.Errorf("\nwanted: %v,\ngot %v", wanted, job) 97 | } 98 | 99 | } 100 | 101 | func TestParseJobMessageRollback(t *testing.T) { 102 | doc := awsiotjobs.JobExecution{ 103 | JobID: "job", 104 | ThingName: "thing", 105 | JobDocument: map[string]interface{}{ 106 | "operation": "mender_rollback", 107 | }, 108 | Status: "QUEUED", 109 | StatusDetails: map[string]interface{}{}, 110 | QueuedAt: 1244423223, 111 | StartedAt: 1244423223, 112 | LastUpdatedAt: 1244423223, 113 | VersionNumber: 1, 114 | ExecutionNumber: 1000, 115 | } 116 | amock := JobExecutionMock{jobExecution: doc} 117 | job, _ := parseJobDocument(&amock) 118 | wanted := Job{ 119 | "mender_rollback", 120 | "", 121 | State{}, 122 | &amock, 123 | } 124 | 125 | if !reflect.DeepEqual(job, wanted) { 126 | t.Errorf("\nwanted: %v,\ngot %v", wanted, job) 127 | } 128 | 129 | } 130 | 131 | func TestParseJobMessageInstallMissingUrl(t *testing.T) { 132 | doc := awsiotjobs.JobExecution{ 133 | JobID: "job", 134 | ThingName: "thing", 135 | JobDocument: map[string]interface{}{ 136 | "operation": "mender_install", 137 | }, 138 | Status: "QUEUED", 139 | StatusDetails: map[string]interface{}{}, 140 | QueuedAt: 1244423223, 141 | StartedAt: 1244423223, 142 | LastUpdatedAt: 1244423223, 143 | VersionNumber: 1, 144 | ExecutionNumber: 1000, 145 | } 146 | 147 | amock := JobExecutionMock{jobExecution: doc} 148 | _, err := parseJobDocument(&amock) 149 | wanted := awsiotjobs.JobError{ErrCode: "ERR_MENDER_MISSING_URL", ErrMessage: "missing url parameter"} 150 | if err != wanted { 151 | t.Errorf("wanted %v got %v", wanted, err) 152 | } 153 | } 154 | 155 | func TestProcessMissingOperationFail(t *testing.T) { 156 | doc := awsiotjobs.JobExecution{ 157 | JobDocument: map[string]interface{}{ 158 | "operation": "mender_insll", 159 | }, 160 | Status: "QUEUED", 161 | StatusDetails: map[string]interface{}{}, 162 | } 163 | amock := JobExecutionMock{jobExecution: doc} 164 | Process(&amock) 165 | amock.AssertCalled(t, "Reject") 166 | } 167 | 168 | func TestProcessMissingUrlFail(t *testing.T) { 169 | doc := awsiotjobs.JobExecution{ 170 | JobDocument: map[string]interface{}{ 171 | "operation": "mender_installl", 172 | }, 173 | Status: "QUEUED", 174 | StatusDetails: map[string]interface{}{}, 175 | } 176 | amock := JobExecutionMock{jobExecution: doc} 177 | Process(&amock) 178 | amock.AssertCalled(t, "Reject") 179 | } 180 | 181 | type CommandFail struct { 182 | mock.Mock 183 | } 184 | 185 | func (c *CommandFail) Install(url string, done chan error, progress chan string) error { 186 | done <- errors.New("install error") 187 | return errors.New("install error") 188 | } 189 | 190 | func (c *CommandFail) Commit() error { 191 | //ret := c.Called() 192 | return errors.New("commit error") 193 | } 194 | 195 | func (c *CommandFail) Rollback() error { 196 | //ret := c.Called() 197 | return errors.New("rollback error") 198 | } 199 | 200 | func TestExecInstallFail(t *testing.T) { 201 | doc := awsiotjobs.JobExecution{ 202 | JobDocument: map[string]interface{}{ 203 | "operation": "mender_install", 204 | }, 205 | Status: "QUEUED", 206 | StatusDetails: map[string]interface{}{}, 207 | VersionNumber: 1, 208 | } 209 | 210 | amock := JobExecutionMock{jobExecution: doc} 211 | 212 | job, _ := parseJobDocument(&amock) 213 | cmd := &CommandFail{} 214 | err := job.exec(cmd, testTimeout) 215 | time.Sleep(1 * time.Second) 216 | jobError, ok := err.(awsiotjobs.JobError) 217 | if !ok { 218 | t.Errorf("Expected JobError got %v", err) 219 | } 220 | wanted := "ERR_MENDER_INSTALL_FAILED" 221 | if jobError.ErrCode != wanted { 222 | t.Errorf("Expected \"%s\", got \"%s\"", wanted, jobError.ErrCode) 223 | } 224 | amock.AssertCalled(t, "Fail") 225 | } 226 | 227 | func TestExecCommitFail(t *testing.T) { 228 | doc := awsiotjobs.JobExecution{ 229 | JobDocument: awsiotjobs.JobDocument{ 230 | "operation": "mender_install", 231 | "url": "http://test", 232 | }, 233 | Status: "QUEUED", 234 | StatusDetails: awsiotjobs.StatusDetails{"step": "rebooting"}, 235 | VersionNumber: 1, 236 | } 237 | 238 | amock := JobExecutionMock{jobExecution: doc} 239 | 240 | job, _ := parseJobDocument(&amock) 241 | cmd := &CommandFail{} 242 | err := job.exec(cmd, testTimeout) 243 | time.Sleep(1 * time.Second) 244 | jobError, ok := err.(awsiotjobs.JobError) 245 | if !ok { 246 | t.Errorf("Expected JobError got %v", err) 247 | } 248 | wanted := "ERR_MENDER_COMMIT" 249 | if jobError.ErrCode != wanted { 250 | t.Errorf("Expected \"%s\", got \"%s\"", wanted, jobError.ErrCode) 251 | } 252 | amock.AssertCalled(t, "Fail") 253 | } 254 | 255 | type CommandTimeout struct { 256 | mock.Mock 257 | } 258 | 259 | func (c *CommandTimeout) Install(url string, done chan error, progress chan string) error { 260 | time.Sleep(testTimeout * 2) 261 | return nil 262 | } 263 | 264 | func (c *CommandTimeout) Commit() error { 265 | //ret := c.Called() 266 | return errors.New("commit error") 267 | } 268 | 269 | func (c *CommandTimeout) Rollback() error { 270 | //ret := c.Called() 271 | return errors.New("rollback error") 272 | } 273 | func TestExecTimeoutFail(t *testing.T) { 274 | doc := awsiotjobs.JobExecution{ 275 | JobDocument: awsiotjobs.JobDocument{ 276 | "operation": "mender_install", 277 | "url": "http://test", 278 | }, 279 | Status: "QUEUED", 280 | StatusDetails: awsiotjobs.StatusDetails{}, 281 | VersionNumber: 1, 282 | } 283 | 284 | amock := JobExecutionMock{jobExecution: doc} 285 | 286 | job, _ := parseJobDocument(&amock) 287 | cmd := &CommandTimeout{} 288 | err := job.exec(cmd, testTimeout) 289 | time.Sleep(1 * time.Second) 290 | jobError, ok := err.(awsiotjobs.JobError) 291 | if !ok { 292 | t.Errorf("Expected JobError got %v", err) 293 | } 294 | wanted := "ERR_MENDER_INSTALL_TIMEOUT" 295 | if jobError.ErrCode != wanted { 296 | t.Errorf("Expected \"%s\", got \"%s\"", wanted, jobError.ErrCode) 297 | } 298 | amock.AssertCalled(t, "Fail") 299 | } 300 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0= 9 | github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= 10 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 11 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 12 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 13 | github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= 14 | github.com/jba/templatecheck v0.6.0/go.mod h1:/1k7EajoSErFI9GLHAsiIJEaNLt3ALKNw2TV7z2SYv4= 15 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 16 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 17 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 18 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 22 | github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 23 | github.com/sanity-io/litter v1.5.0/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0= 24 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 25 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 26 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 27 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 28 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 29 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 30 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 31 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 32 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 33 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 35 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 36 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 37 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 38 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 39 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 40 | golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 41 | golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= 42 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 43 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 44 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/net v0.0.0-20200925080053-05aa5d4ee321 h1:lleNcKRbcaC8MqgLwghIkzZ2JBQAb7QQ9MiwRt1BisA= 46 | golang.org/x/net v0.0.0-20200925080053-05aa5d4ee321/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 47 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 48 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= 49 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 53 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 54 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 59 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 60 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 61 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= 62 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 64 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 65 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 66 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 67 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 68 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 69 | golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 70 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 71 | golang.org/x/tools v0.1.6-0.20210802203754-9b21a8868e16 h1:ZC/gVBZl8poJyKzWLxxlsmhayVGosF4mohR35szD5Bg= 72 | golang.org/x/tools v0.1.6-0.20210802203754-9b21a8868e16/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 73 | golang.org/x/tools/gopls v0.7.1 h1:Mh3Z8Xcoq3Zy7ksSlwDV/nzQSbjFf06A+L+F8YHq55U= 74 | golang.org/x/tools/gopls v0.7.1/go.mod h1:keTmqBxKJRaTbzntq7EG7w8Pa7OlOXVqm4rc6Z09gMU= 75 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 76 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 77 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 78 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 79 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 80 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 82 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 85 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 86 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 87 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 88 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | honnef.co/go/tools v0.2.0 h1:ws8AfbgTX3oIczLPNPCu5166oBg9ST2vNs0rcht+mDE= 90 | honnef.co/go/tools v0.2.0/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY= 91 | mvdan.cc/gofumpt v0.1.1 h1:bi/1aS/5W00E2ny5q65w9SnKpWEF/UIOqDYBILpo9rA= 92 | mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48= 93 | mvdan.cc/xurls/v2 v2.2.0 h1:NSZPykBXJFCetGZykLAxaL6SIpvbVy/UFEniIfHAa8A= 94 | mvdan.cc/xurls/v2 v2.2.0/go.mod h1:EV1RMtya9D6G5DMYPGD8zTQzaHet6Jh8gFlRgGRJeO8= 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /awsiotjobs/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /awsiotjobs/awsiotjobs.go: -------------------------------------------------------------------------------- 1 | package awsiotjobs 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "sync" 11 | "time" 12 | 13 | mqtt "github.com/eclipse/paho.mqtt.golang" 14 | ) 15 | 16 | const jobBaseTopic = "$aws/things/%s/jobs/%s" 17 | const publishTimeout = 2 * time.Second 18 | 19 | // Config is the configuration to connect to AWS IoT 20 | type Config struct { 21 | Port int 22 | CaCertPath string 23 | CertificatePath string 24 | PrivateKeyPath string 25 | Endpoint string 26 | ThingName string 27 | ClientID string 28 | Handler func(je JobExecutioner) 29 | } 30 | 31 | // FromFile reads the configuration from a JSON file 32 | // { 33 | // "Port": 88, 34 | // "CaCertPath": "ca", 35 | // "CertificatePath":"cert", 36 | // "PrivateKeyPath": "key", 37 | // "Endpoint": "ep", 38 | // "ThingName": "tn", 39 | // "ClientID": "cid" 40 | // } 41 | func (c *Config) FromFile(file string) error { 42 | s, err := ioutil.ReadFile(file) 43 | if err != nil { 44 | fmt.Printf("Invalid config file - ignoring\n") 45 | return err 46 | } 47 | json.Unmarshal(s, &c) 48 | return nil 49 | } 50 | 51 | type nextJobPayload struct { 52 | clientToken string 53 | } 54 | 55 | // NewTLSConfig creates a new TLS config 56 | func NewTLSConfig(caCertPath, certPath, privKeyPath string) *tls.Config { 57 | certpool := x509.NewCertPool() 58 | caCert, err := ioutil.ReadFile(caCertPath) 59 | if err == nil { 60 | certpool.AppendCertsFromPEM(caCert) 61 | } else { 62 | panic(err) 63 | } 64 | 65 | cert, err := tls.LoadX509KeyPair(certPath, privKeyPath) 66 | if err != nil { 67 | panic(err) 68 | } 69 | 70 | cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) 71 | 72 | if err != nil { 73 | panic(err) 74 | } 75 | 76 | return &tls.Config{ 77 | RootCAs: certpool, 78 | ClientAuth: tls.NoClientCert, 79 | ClientCAs: nil, 80 | InsecureSkipVerify: false, 81 | Certificates: []tls.Certificate{cert}, 82 | } 83 | } 84 | 85 | // JobError contains the error code and message for a Job error 86 | type JobError struct { 87 | ErrCode string 88 | ErrMessage string 89 | } 90 | 91 | func (err JobError) Error() string { 92 | return fmt.Sprintf("code %s, msg: %s", err.ErrCode, err.ErrMessage) 93 | } 94 | 95 | // JobDocument represent the Job Document passed by AWS IoT Jobs. 96 | // We use a generic map[string]interface{} since the documents are JSON based 97 | type JobDocument map[string]interface{} 98 | 99 | // StatusDetails represents the Status Details passed by AWS IoT Jobs 100 | // We use a generic map[string]interface{} since the documents are JSON based 101 | type StatusDetails map[string]interface{} 102 | 103 | // JobExecutioner is an interface allowig the concrete Job handler to interact with the 104 | // JobExecution logic 105 | type JobExecutioner interface { 106 | GetJobDocument() JobDocument 107 | GetStatusDetails() StatusDetails 108 | Publish(string, byte, interface{}) 109 | Success(StatusDetails) error 110 | Fail(JobError) error 111 | Reject(JobError) error 112 | InProgress(StatusDetails) error 113 | Terminate() 114 | GetThingName() string 115 | GetJobID() string 116 | } 117 | 118 | // JobExecution represents the AWS IoT job execution document 119 | // JOB MESSAGE SAMPLE 120 | // { 121 | // "timestamp":1573561673, 122 | // "execution":{ 123 | // "jobId":"mender_install-7cf96d", 124 | // "status":"IN_PROGRESS", 125 | // "queuedAt":1573560519, 126 | // "startedAt":1573560656, 127 | // "lastUpdatedAt":1573560656, 128 | // "versionNumber":2, 129 | // "executionNumber":1, 130 | // "jobDocument": { 131 | // "operation":"mender_install", 132 | // "url":"https://fwupdate-demo" 133 | // } 134 | // } 135 | // } 136 | type JobExecution struct { 137 | JobID string `json:"jobId"` 138 | ThingName string `json:"thingName"` 139 | JobDocument JobDocument `json:"jobDocument"` 140 | Status string `json:"status"` 141 | StatusDetails StatusDetails `json:"statusDetails"` 142 | QueuedAt int64 `json:"queuedAt"` 143 | StartedAt int64 `json:"startedAt"` 144 | LastUpdatedAt int64 `json:"lastUpdatedAt"` 145 | VersionNumber int64 `json:"versionNumber"` 146 | ExecutionNumber int64 `json:"executionNumber"` 147 | client *Client 148 | mux sync.Mutex 149 | } 150 | 151 | // GetJobDocument is the accessor to the JobDocument 152 | func (je *JobExecution) GetJobDocument() JobDocument { 153 | return je.JobDocument 154 | } 155 | 156 | // GetStatusDetails is the accessor to the StatusDetails 157 | func (je *JobExecution) GetStatusDetails() StatusDetails { 158 | return je.StatusDetails 159 | } 160 | 161 | // GetThingName is the accessor to the ThingName 162 | func (je *JobExecution) GetThingName() string { 163 | return je.client.config.ThingName 164 | } 165 | 166 | // GetJobID is the accessor to JobID 167 | func (je *JobExecution) GetJobID() string { 168 | return je.JobID 169 | } 170 | 171 | func (je *JobExecution) getUpdatePayload() interface{} { 172 | payload := make(map[string]interface{}) 173 | payload["status"] = je.Status 174 | payload["statusDetails"] = je.StatusDetails 175 | payload["expectedVersion"] = je.VersionNumber 176 | payload["executionNumber"] = je.ExecutionNumber 177 | payload["includeJobExecutionState"] = true 178 | payload["clientToken"] = "client-token" 179 | jsonPayload, _ := json.Marshal(payload) 180 | return jsonPayload 181 | } 182 | 183 | func (je *JobExecution) sendUpdate() error { 184 | if je.client.Iot == nil { 185 | log.Panic("Iot client not set") 186 | } 187 | payload := je.getUpdatePayload() 188 | topic := fmt.Sprintf("%s/update", fmt.Sprintf(jobBaseTopic, je.client.config.ThingName, je.JobID)) 189 | log.Printf("Updating status with %s\non topic %s\n", string(payload.([]byte)), topic) 190 | token := je.client.Iot.Publish(topic, 1, false, payload) // Send syncronously 191 | if token.WaitTimeout(publishTimeout) && token.Error() != nil { 192 | return token.Error() 193 | } 194 | return nil 195 | } 196 | 197 | /* 198 | InProgress reports the execution in progress to AWS IoT Device Management 199 | The argument which is passed to the function is reported in the StatusDetails field of the job. 200 | You can use InProgress in case the execution of your job will take some time or needs multiple steps and 201 | you need to be able to recover from an interruption. 202 | The next time you access the Jobs API, you'll get the pending job execution and the correspondin state. 203 | */ 204 | func (je *JobExecution) InProgress(statusDetails StatusDetails) error { 205 | log.Printf("JOB IN_PROGRESS: %v\n", statusDetails) 206 | je.mux.Lock() 207 | je.StatusDetails = statusDetails 208 | je.Status = "IN_PROGRESS" 209 | je.mux.Unlock() 210 | return je.sendUpdate() 211 | } 212 | 213 | /* 214 | Success reports a successfull job execution to AWS IoT Device Management 215 | By passing a StatusDetails structure to the function you can store some additional information regarding 216 | the execution. 217 | This function should be called to notify Device Management that the job was successfully performed. 218 | If there are other jobs pending, they will be immediately notified to the client. 219 | */ 220 | func (je *JobExecution) Success(statusDetails StatusDetails) error { 221 | log.Printf("JOB SUCCEEDED: %v\n", statusDetails) 222 | je.mux.Lock() 223 | je.StatusDetails = statusDetails 224 | je.Status = "SUCCEEDED" 225 | je.mux.Unlock() 226 | err := je.sendUpdate() 227 | if err != nil { 228 | return err 229 | } 230 | je.unsubscribeFromUpdates() 231 | return nil 232 | } 233 | 234 | /* 235 | Success reports a fialed job execution to AWS IoT Device Management 236 | By passing a StatusDetails structure to the function you can store some additional information regarding 237 | the reason of the failure. 238 | This function should be called to notify Device Management that the job failed. 239 | If there are other jobs pending, they will be immediately notified to the client. 240 | */ 241 | func (je *JobExecution) Fail(err JobError) error { 242 | log.Printf("JOB FAIL: %v\n", err) 243 | je.mux.Lock() 244 | je.StatusDetails = map[string]interface{}{ 245 | "error": err.Error(), 246 | } 247 | je.Status = "FAILED" 248 | je.mux.Unlock() 249 | e := je.sendUpdate() 250 | if e != nil { 251 | return err 252 | } 253 | je.unsubscribeFromUpdates() 254 | return nil 255 | } 256 | 257 | /* 258 | Reject reports that the client could not handle the job execution, for example because the document was not understood or missing 259 | compulsory information. 260 | By passing a StatusDetails structure to the function you can store some additional information regarding 261 | the reason of the rejection. 262 | If there are other jobs pending, they will be immediately notified to the client. 263 | */ 264 | func (je *JobExecution) Reject(err JobError) error { 265 | log.Printf("JOB REJECTED: %v\n", err) 266 | je.mux.Lock() 267 | je.StatusDetails = map[string]interface{}{ 268 | "error": err.Error(), 269 | } 270 | je.Status = "REJECTED" 271 | je.mux.Unlock() 272 | e := je.sendUpdate() 273 | if e != nil { 274 | return err 275 | } 276 | je.unsubscribeFromUpdates() 277 | return nil 278 | } 279 | 280 | // Terminate the job execution if the process has to stop 281 | func (je *JobExecution) Terminate() { 282 | je.client.unsubscribe() 283 | } 284 | 285 | // Publish is a wrapper on the mqtt Publish 286 | func (je *JobExecution) Publish(topic string, qos byte, payload interface{}) { 287 | je.client.Iot.Publish(topic, qos, false, payload) 288 | } 289 | 290 | // Internal types used to decode the updates 291 | type executionStateType struct { 292 | Status string `json:"status"` 293 | StatusDetails StatusDetails `json:"statusDetails"` 294 | VersionNumber int64 `json:"versionNumber"` 295 | } 296 | 297 | type updatePayload struct { 298 | ExecutionState executionStateType `json:"executionState"` 299 | } 300 | 301 | func (je *JobExecution) updateHandler(client mqtt.Client, msg mqtt.Message) { 302 | payload := updatePayload{} 303 | json.Unmarshal(msg.Payload(), &payload) 304 | log.Printf("%v\n", payload) 305 | je.mux.Lock() 306 | je.VersionNumber = payload.ExecutionState.VersionNumber 307 | je.StatusDetails = payload.ExecutionState.StatusDetails 308 | je.mux.Unlock() 309 | } 310 | 311 | func (je *JobExecution) subscribeToUpdates() { 312 | updateTopic := fmt.Sprintf(jobBaseTopic, je.client.config.ThingName, "+/update/accepted") 313 | je.client.Iot.Subscribe(updateTopic, 0, je.updateHandler) 314 | } 315 | 316 | func (je *JobExecution) unsubscribeFromUpdates() { 317 | updateTopic := fmt.Sprintf(jobBaseTopic, je.client.config.ThingName, "+/update/accepted") 318 | je.client.Iot.Unsubscribe(updateTopic) 319 | } 320 | 321 | var defaultHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) { 322 | log.Printf("Topic: %s\n", msg.Topic()) 323 | log.Printf("Msg: %s\n", msg.Payload()) 324 | } 325 | 326 | func parseJobMessage(msg []byte) (*JobExecution, error) { 327 | var jobExecution JobExecution 328 | var doc map[string]interface{} 329 | json.Unmarshal(msg, &doc) 330 | execution, ok := doc["execution"] 331 | if !ok { 332 | return &jobExecution, JobError{"ERR_INVALID_JOB", fmt.Sprintf("missing \"execution\" from payload: %s", msg)} 333 | } 334 | executionJSON, _ := json.Marshal(execution) 335 | json.Unmarshal(executionJSON, &jobExecution) 336 | return &jobExecution, nil 337 | } 338 | 339 | func (client *Client) jobHandler(mqttClient mqtt.Client, msg mqtt.Message) { 340 | job, err := parseJobMessage(msg.Payload()) 341 | if err != nil { 342 | fmt.Printf("Not a job - Ignoring, %s\n", err.Error()) 343 | return 344 | } 345 | job.client = client 346 | job.ThingName = client.config.ThingName // This is so the specialized jobs can access the property 347 | job.subscribeToUpdates() 348 | go job.client.config.Handler(job) 349 | } 350 | 351 | func (client *Client) subscribe() { 352 | thingName := client.config.ThingName 353 | client.Iot.Subscribe(fmt.Sprintf(jobBaseTopic, thingName, "notify-next"), 0, client.jobHandler) 354 | client.Iot.Subscribe(fmt.Sprintf(jobBaseTopic, thingName, "+/get/accepted"), 0, client.jobHandler) 355 | client.Iot.Subscribe(fmt.Sprintf(jobBaseTopic, thingName, "+/get/rejected"), 0, defaultHandler) 356 | client.Iot.Subscribe(fmt.Sprintf(jobBaseTopic, thingName, "start-next/accepted"), 0, client.jobHandler) 357 | client.Iot.Subscribe(fmt.Sprintf(jobBaseTopic, thingName, "start-next/rejected"), 0, defaultHandler) 358 | } 359 | 360 | func (client *Client) unsubscribe() { 361 | thingName := client.config.ThingName 362 | client.Iot.Unsubscribe(fmt.Sprintf(jobBaseTopic, thingName, "notify-next")) 363 | client.Iot.Unsubscribe(fmt.Sprintf(jobBaseTopic, thingName, "+/get/accepted")) 364 | client.Iot.Unsubscribe(fmt.Sprintf(jobBaseTopic, thingName, "+/get/rejected")) 365 | client.Iot.Unsubscribe(fmt.Sprintf(jobBaseTopic, thingName, "start-next/accepted")) 366 | client.Iot.Unsubscribe(fmt.Sprintf(jobBaseTopic, thingName, "start-next/rejected")) 367 | } 368 | 369 | // NewConfig return a new config object with the default paramters 370 | func NewConfig() Config { 371 | return Config{} 372 | } 373 | 374 | // IMqttClient represents the Mqtt client interface used by this library, allows also for better testability 375 | type IMqttClient interface { 376 | Publish(string, byte, bool, interface{}) mqtt.Token 377 | Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token 378 | Unsubscribe(...string) mqtt.Token 379 | Connect() mqtt.Token 380 | } 381 | 382 | // Client defines the client for connecting to AWSIoTJobs. 383 | type Client struct { 384 | Iot IMqttClient //mqtt.Client 385 | config Config 386 | } 387 | 388 | func (client *Client) init(c Config) { 389 | client.config = c 390 | opts := mqtt.NewClientOptions() 391 | opts.AddBroker(fmt.Sprintf("ssl://%s:%d", c.Endpoint, c.Port)) 392 | opts.SetClientID(c.ClientID).SetTLSConfig(NewTLSConfig(c.CaCertPath, c.CertificatePath, c.PrivateKeyPath)) 393 | opts.SetDefaultPublishHandler(defaultHandler) 394 | opts.SetAutoReconnect(true) 395 | opts.SetMaxReconnectInterval(10 * time.Minute) 396 | client.Iot = mqtt.NewClient(opts) 397 | } 398 | 399 | // NewClient returns a new AWSIoTJobsClient using the configuration 400 | func NewClient(config Config) Client { 401 | client := Client{} 402 | client.init(config) 403 | return client 404 | } 405 | 406 | // ConnectAndSubscribe connects to AWS IoT Core and subscribed to the job topics 407 | func (client *Client) ConnectAndSubscribe() { 408 | fmt.Println("ConnectAndSubscribe - Connecting") 409 | if token := client.Iot.Connect(); token.Wait() && token.Error() != nil { 410 | panic(token.Error()) 411 | } 412 | client.subscribe() 413 | fmt.Println("ConnectAndSubscribe - Checking for jobs") 414 | client.Iot.Publish(fmt.Sprintf(jobBaseTopic, client.config.ThingName, "start-next"), 1, false, "") 415 | log.Println("ConnectAndSubscribe - Done") 416 | } 417 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS IoT Jobs and Mender integration demo 2 | 3 | This demo show show to integrate AWS IoT Device Management Jobs with the mender client in order to perform safe system OTA upgrades. 4 | 5 | ## Architecture 6 | 7 | The following diagram depict the architecture of the solution we are going to build. 8 | 9 | ![](img/high-level-arch.png) 10 | 11 | ## Process 12 | 13 | The process that the system we are building implements is the following: 14 | 15 | ![](img/update-process.png) 16 | 17 | The steps in the blue boxes are performed in the cloud, the red boxes on the device. 18 | 19 | ## Prerequisites 20 | 21 | In order to build and run this demo, you need the following: 22 | 23 | * A Linux enviroment 24 | * A Raspberry Pi board. This demo has been tested with a Raspberry Pi 3 B+ board, but any other board supported by the mender tool would do. If using other boards, you might need to adapt the cross compiling option for Go and change some settings in the `mender-env.sh` file 25 | * An SD card with at least 8Gb of space 26 | * A computer with an SD Card reader 27 | * The [Etcher](https://www.balena.io/etcher/) tool to write the image to the SD Card. Any other tool you are familiar with would also do, including `dd`. 28 | * The [Golang tools](https://golang.org/dl/) for the platform on which you will develop. Ensure you have go version 1.12 or above (`go version`). 29 | 30 | In this document I am assuming you'll be using an AWS Cloud9 environment. If nevertheless you are running this in another environemnt I'll assume you know what you are doing and will be able to adapt the commands as needed. 31 | 32 | ## Create an AWS Cloud9 instance 33 | 34 | For this demo, I recommend to use an [AWS Cloud9](console.aws.amazon.com/cloud9) instance to install and run the tools, especially if you have a Windows laptop. 35 | 36 | Create a new AWS Cloud9 instance using the console. 37 | * Click on this [link](https://console.aws.amazon.com/cloud9/home/create) to open the console 38 | * Enter a name and click `Next step` 39 | * Leave all settings as-is but change Platform to **Ubuntu** 40 | * Click `Next step` 41 | * Click `Create Environment` 42 | 43 | Wait for the instance to be initialized. 44 | 45 | 46 | ### Resize the EBS volume 47 | 48 | Cloud9 comes with a default 8Gib volume, which is too small for building a full Raspbberry Pi image. 49 | Once the instance is up and running, select the top folder in the explorer on the left and then **File | New File**. Name the file `resize.sh` and copy the following script: 50 | 51 | ```bash 52 | #!/bin/bash 53 | 54 | # Specify the desired volume size in GiB as a command-line argument. If not specified, default to 20 GiB. 55 | SIZE=${1:-20} 56 | 57 | # Install the jq command-line JSON processor. 58 | sudo apt install -y jq 59 | 60 | # Get the ID of the envrionment host Amazon EC2 instance. 61 | INSTANCEID=$(curl http://169.254.169.254/latest/meta-data//instance-id) 62 | 63 | # Get the ID of the Amazon EBS volume associated with the instance. 64 | VOLUMEID=$(aws ec2 describe-instances --instance-id $INSTANCEID | jq -r .Reservations[0].Instances[0].BlockDeviceMappings[0].Ebs.VolumeId) 65 | 66 | # Resize the EBS volume. 67 | aws ec2 modify-volume --volume-id $VOLUMEID --size $SIZE 68 | 69 | # Wait for the resize to finish. 70 | while [ "$(aws ec2 describe-volumes-modifications --volume-id $VOLUMEID --filters Name=modification-state,Values="optimizing","completed" | jq '.VolumesModifications | length')" != "1" ]; do 71 | sleep 1 72 | done 73 | 74 | # Rewrite the partition table so that the partition takes up all the space that it can. 75 | sudo growpart /dev/xvda 1 76 | 77 | # Expand the size of the file system. 78 | sudo resize2fs /dev/xvda1 79 | ``` 80 | 81 | Save the file. In the terminal window at the bottom of the IDE, execute the following: 82 | 83 | ``` 84 | chmod +x resize.sh 85 | sudo ./resize.sh 86 | ``` 87 | 88 | ## Build the image and the mender artifact 89 | 90 | ### Install mender-convert 91 | 92 | `mender-convert` is a tool provided by the mender.io project. 93 | You can read more about mender convert tool https://github.com/mendersoftware/mender-convert, and you can get an overview on how to use it by following this blog post https://hub.mender.io/t/raspberry-pi-3-model-b-b-raspbian/140. 94 | 95 | This project has been built for v2.2.0. 96 | 97 | To install mender do the following: 98 | 99 | ```bash 100 | cd ~/environment 101 | git clone -b 2.2.0 https://github.com/mendersoftware/mender-convert.git 102 | cd mender-convert 103 | ./docker-build 104 | ``` 105 | 106 | NOTE: if you get a warning about `Could not get lock...`, wait another 60sec os so. There are some background scripts finalizing the configuration of the AWS Cloud9 instance. 107 | 108 | ### Obtaining and building the goagent 109 | 110 | To build the `goagent` run: 111 | 112 | ```bash 113 | cd ~/environment 114 | git clone https://github.com/aws-samples/aws-iot-jobs-full-system-update 115 | cd aws-iot-jobs-full-system-update/files 116 | env GOOS=linux GOARCH=arm GOARM=7 go build ../goagent.go 117 | mkdir -p overlay_root_fs/usr/sbin 118 | install -m 755 goagent overlay_root_fs/usr/sbin/goagent 119 | ``` 120 | 121 | ### Raspbian 122 | 123 | Download the Raspbian image and extract it: 124 | 125 | ```bash 126 | cd .. 127 | mkdir -p input 128 | cd input 129 | wget http://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2019-04-09/2019-04-08-raspbian-stretch-lite.zip 130 | unzip raspbian_lite-2019-04-09/2019-04-08-raspbian-stretch-lite.zip 131 | ``` 132 | 133 | ### Modify the Wifi configuration 134 | 135 | Open `files/overlay_root_fs/etc/wpa_supplicant/wpa_supplicant.conf` and provide the values for `` and ``. 136 | 137 | 138 | ### Enable the connection to AWS IoT 139 | 140 | The goagent connects to AWS IoT over MQTT in order to receive the commands sent by the AWS IoT Jobs service. MQTT connections are encrypted via TLS and secured via mutual TLS authentication. For this we need to have a private key and a device certificate to identify and authenticate the device. In a real production environment the private key would be generated in a secure way on the actual device and be accessible to the agent, for example using a secure element. The device certificate would then be obtained by issuing a CSR (certificate signing request) that would be signed by a CA recognized by AWS IoT. You can find an implementation of this process in the following repo https://github.com/aws-samples/iot-provisioning-secretfree. 141 | 142 | For this demonstration, we are going to generate the private key and the certificate using the AWS IoT console and then transfer them to the image via the `mender-convert` tool. 143 | 144 | * Go to the [AWS IoT Console](https://console.aws.amazon.com/iot/home?#/create/provisioning) 145 | * Click on **Create a single thing** 146 | * Enter a Name, eg "rpi-mender-demo" and click **Next** at the bottom of the page 147 | * Click on **Create certificate** 148 | * Download the certificate for this thing and the private key. 149 | * Click on **Activate** 150 | * Click on Done 151 | 152 | Select **Secure | Policies** and click on Create (or click on this [quick link](https://console.aws.amazon.com/iot/home#/create/policy)) 153 | 154 | Enter a name for the policy, eg "agent-policy" and click on **Advanced mode**. 155 | 156 | In the editor, delete all the text and copy paste the following: 157 | 158 | ```json 159 | { 160 | "Version": "2012-10-17", 161 | "Statement": [ 162 | { 163 | "Effect": "Allow", 164 | "Action": "iot:*", 165 | "Resource": "*" 166 | } 167 | ] 168 | } 169 | ``` 170 | 171 | Click on **Create** 172 | 173 | Select the **Manage | Things** menu, click on the thing you just created (eg "rpi-mender-demo"), and then on **Security**. Click on Actions|Attach Policy and select the policy you created in the previous step. 174 | 175 | ### Transfer the certificates 176 | 177 | Back in the Cloud9 instance, select the `aws-iot-jobs-full-system-update/files/overlay_root_fs/etc/goagent` folder in the navigation pane on the left. Then, select **File** in the top menu bar and **Upload local files...**. Select the certificate and private key you downloaded before or drag&drop them on the dialog box. 178 | 179 | Close the dialog box. 180 | 181 | Using the file explorer or the terminal, rename the files to `cert.pem` and `private.key` respectively. 182 | 183 | For the mutual TLS authentication to work we also need the server certificate. Run the following command in the terminal window to save the server certificate locally. 184 | 185 | ```bash 186 | cd ~/environment/aws-iot-jobs-full-system-update/files/overlay_root_fs/etc/goagent 187 | curl -o rootCA.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem 188 | ``` 189 | 190 | ### Update the goagent configuration file 191 | 192 | `goagent` uses a configuration file to get the parameters needed to connect to AWS IoT. The file can be found in the `aws-iot-jobs-full-system-update/files/overlay_root_fs/etc/goagent` folder. 193 | 194 | Open the file in the Cloud9 editor and provide the following information: 195 | 196 | * `endpoint` - it can be found [here](https://console.aws.amazon.com/iot/home#/settings) 197 | * `thingId` - the name of the Thing you have created 198 | * `clientId` - use the same name as for the thingId 199 | 200 | Save the modifications 201 | 202 | ### Build the SD card image 203 | 204 | Now we have all the necessary bits and pieces to build the image. 205 | Run the following to generate the image and the mender artifact 206 | 207 | ```bash 208 | cd ~/environment/mender-convert 209 | rm -rf overlay_root_fs 210 | sudo cp -R ~/environment/aws-iot-jobs-full-system-update/files/overlay_root_fs overlay_root_fs 211 | sudo chown -R root.root overlay_root_fs 212 | MENDER_ARTIFACT_NAME=release-1 MENDER_ENABLE_SYSTEMD=n ./docker-mender-convert \ 213 | --disk-image input/2019-04-08-raspbian-stretch-lite.img \ 214 | --config configs/raspberrypi3_config \ 215 | --overlay ./overlay_root_fs 216 | ``` 217 | 218 | > NOTE: `MENDER_ENABLE_SYSTEMD=n` disables `mender-client` service as we want to use the client in standalone mode. 219 | 220 | ### Transfer the image 221 | 222 | Once the above process is finished you'll end up with two relevant files in the `mender-convert/deploy` folder: 223 | * an `img.gz` file - this is the full image that need to be transferred to the SD card 224 | * a `mender` file - this is the mender artifact which is used by the mender client to upgrade the system 225 | 226 | The img file must be transferred to your local machine and copied onto the SD card. To do this navigate to the `deploy` folder in the explorer tab on the left, right-click on the img file and click on Download. Depending on your internet connection it might take some time. 227 | 228 | The mender artifact needs to be copied to an S3 bucket where it can later be accessed by the mender client via a pre-signed URL. 229 | 230 | Let's create the bucket. You can either use the [Amazon S3 console](https://console.aws.amazon.com/s3) or the aws cli in the Cloud9 terminal: 231 | 232 | ```bash 233 | aws s3 mb s3:// 234 | ``` 235 | 236 | Once you have created the bucket, copy the mender file to it. 237 | 238 | ```bash 239 | aws s3 cp ~/environment/mender-convert/deploy/2019-04-08-raspbian-stretch-lite-raspberrypi3-mender.mender s3:// 240 | ``` 241 | 242 | This bucket and the file are private and cannot be accessed by unauthorized parties. 243 | 244 | ### Verify the system 245 | 246 | Once the image has finished downloading, you copy it to the SD card. 247 | You can use [Etcher](https://etcher.io) or any other command you are familiar with on your system. 248 | 249 | Insert the SD card in your Raspberry Pi and connect the power. It is advisable to have a monitor connected to the Raspberry Pi to check that the boot process executes correctly and to capture the IP address assigned to the Pi depending on the chosen network connection. 250 | 251 | If everything is fine you should be able to login to the Raspberry Pi using the default username and password (`pi/raspberry`). 252 | 253 | NOTE: in a production setup you would likely disable SSH or at a minimum use stronger passwords or public keys. An more secure method would be to use [AWS IoT Secure Tunneling](https://docs.aws.amazon.com/iot/latest/developerguide/secure-tunneling.html) which can be integrated with the job agent. 254 | 255 | You can then verify that the agent is running by executing: 256 | 257 | ```bash 258 | systemctl status goagent 259 | ``` 260 | 261 | The result should look like the following: 262 | 263 | ![](img/goagent-service-status.png) 264 | 265 | 266 | ## AWS IoT Jobs 267 | 268 | It is now time to test the agent. As we are going to use URL signing, we first have to create a Role that AWS IoT Device Management can assume to create the pre-signed URL for the mender artifact. 269 | 270 | ### Create a Job Document 271 | 272 | First we have to create a Job Document and upload it to an S3 bucket. 273 | 274 | In the Cloud9 IDE, create a new file in the top folder with the following content. Let's call it `menderjob.json`. Replace \ with the name of the bucket where you placed the mender artifact. 275 | 276 | ```json 277 | { 278 | "operation": "mender_install", 279 | "url": "${aws:iot:s3-presigned-url:https://s3.amazonaws.com//2019-04-08-raspbian-stretch-lite.mender}" 280 | } 281 | ``` 282 | 283 | Copy the file to the S3 bucket with the following command. For BUCKET we are going to use the same bucket we created for storing the mender artifact: 284 | 285 | ```bash 286 | aws s3 cp ~/environment/menderjob.json s3:// 287 | ``` 288 | 289 | ### Create an AWS IoT Job 290 | 291 | It is time now to create AWS IoT Job containing the information necessary for the de 292 | Go the AWS IoT Console and select **Manage|Jobs**. Click on **Create Job**. You can also use this [quick link](https://console.aws.amazon.com/iot/home#/create/job). 293 | 294 | Select **Create a custom job**, then enter a Job ID, for example "mender-update-1". 295 | 296 | * Under **Select devices to update** select the Thing you have created earlier. 297 | 298 | * Under **Add a job file** select the bucket and then the `menderjob.json` file. 299 | 300 | * Under **Pre-sign resource URLs** select **I want to...** and select **Create Role**. Enter a name and then click on **Create role**. In **URL will expire at this time** select **1 hour**. 301 | 302 | Leave the rest as-is and click **Next** and then click **Create**. 303 | 304 | 305 | ## Monitoring the progress 306 | 307 | AWS IoT Jobs has a rather coarse reporting for job progress avaiable to other subscriber than the device itself. 308 | 309 | To overcome this limitiation, the goagent publishes the output of the mender command to a dedicated topic, so that a montioring application can easily follow the progress. 310 | 311 | In the AWS IoT Console, select **Test** and subscribe to `mender/#`. 312 | 313 | You should start seeing messages like the following appearing: 314 | 315 | 316 | ```json 317 | { 318 | "progress": "................................ 5% 12288 KiB", 319 | "ts": 1574763274 320 | } 321 | ``` 322 | 323 | ## How does this work? 324 | 325 | The goagent has received the new job, and accepted it by reporting back to the AWS Job service an IN_PROGRESS status. It also reports back the current step of the installation progress, in this case "downloading". This information is not available in the console but can be queried via the API prior knowing the jobId and the thingName. 326 | 327 | At this stage the mender client is downloading the artifact from S3 via the pre-signed URL and copying it to the inactive partition (`mender -install `) 328 | 329 | Once the installation is completed and the mender client exits, the goagent reports back to the AWS Jobs service a status of IN_PROGRESS with step "rebooting" and issues a reboot command. 330 | When the system comes up again, the goagent will retrieve the current job as pending. Since the stage is "rebooting" it determines that the Raspberry has rebooted and commit the update using the mender client (`mender -commit`), and reports back a successful job. If the commit command fails it means that the system has rebooted to the old partition, and the goagent issues a rollback command (`mender -rollback`) and reports back a failed job. 331 | 332 | If the network connection is interrupted during the download or the device reboots for any other reason before the update is completed, the goagent invokes the `mender install` command which in turn download the firmware again. 333 | 334 | It is also possible to add a counter to the job reported state to keep track of how many time a download has been attempted and fail the job after N attempts. 335 | 336 | ## Troubleshooting 337 | 338 | ### The goagent is not active 339 | On the Raspberry Pi 340 | 341 | ``` 342 | sudo journalctl -u goagent 343 | ``` 344 | 345 | to check the logs 346 | 347 | ### The job never shows completed 348 | 349 | It might take some time for the mender client to finish the upgrade even after the logs have been showing 99% completion. Be patient. 350 | If you have an ssh connection to the Raspberry Pi open during the upgrade, it will disconnect on reboot, indicating that the mender command has successfully terminated. 351 | If after this the job still does not show completed, ssh to the Raspberry Pi and check if the goagent is running 352 | 353 | ``` 354 | systemctl status goagent 355 | ``` 356 | 357 | ## Improvements 358 | 359 | If you feel brave enough feel free to take this code and: 360 | 361 | * Add status reporting (startup time, heartbeat, local IP address) to using thing shadow 362 | * Perform graceful shutdown of other components running on the system before rebooting 363 | * Perform additional system verifications upon reboot before committing 364 | 365 | # License 366 | 367 | This project is licensed under the Apache-2.0 License. 368 | --------------------------------------------------------------------------------