From f254b196e1253c30239f5b75a6db6ebe864a6579 Mon Sep 17 00:00:00 2001 From: Gabriela Tsvetomirova Yoncheva Date: Thu, 8 Feb 2024 15:41:03 +0200 Subject: [PATCH] Add update agent example application (#8) [#4] Add update agent example application Created update agent example application similar to the containers update agent: - uses the update agent API - manages files in a directory, message to feature contains filenames and URLs - has Dockerfile similar to the vehicle-simulator application - mount points are used both for adding needed certificates and for specifying the directory to be managed by the update agent - backup functionality for rollback - deployment.json provided and tested with the -f flag used by kanto-cm - readme.md provided with basic information about the update agent and instruction on how to install it (standard/containerized) Signed-off-by: Gabriela Yoncheva Signed-off-by: Kristiyan Gostev Co-authored-by: Kristiyan Gostev --- .github/workflows/validation.yaml | 10 +- custom-update-agent/Dockerfile | 17 + custom-update-agent/README.md | 138 +++++ .../custom-update-agent.service | 11 + custom-update-agent/deployment.json | 21 + custom-update-agent/go.mod | 24 + custom-update-agent/go.sum | 75 +++ custom-update-agent/install.sh | 19 + custom-update-agent/main.go | 54 ++ .../updateagent/internal_desired_state.go | 55 ++ .../updateagent/update_agent_init.go | 36 ++ .../updateagent/update_manager.go | 210 +++++++ .../updateagent/update_operation.go | 568 ++++++++++++++++++ custom-update-agent/util/file.go | 30 + custom-update-agent/util/files_compare.go | 57 ++ custom-update-agent/util/from_files.go | 40 ++ custom-update-agent/util/logger.go | 30 + custom-update-agent/util/to_files.go | 44 ++ 18 files changed, 1437 insertions(+), 2 deletions(-) create mode 100644 custom-update-agent/Dockerfile create mode 100644 custom-update-agent/README.md create mode 100644 custom-update-agent/custom-update-agent.service create mode 100644 custom-update-agent/deployment.json create mode 100644 custom-update-agent/go.mod create mode 100644 custom-update-agent/go.sum create mode 100755 custom-update-agent/install.sh create mode 100644 custom-update-agent/main.go create mode 100644 custom-update-agent/updateagent/internal_desired_state.go create mode 100644 custom-update-agent/updateagent/update_agent_init.go create mode 100644 custom-update-agent/updateagent/update_manager.go create mode 100644 custom-update-agent/updateagent/update_operation.go create mode 100644 custom-update-agent/util/file.go create mode 100644 custom-update-agent/util/files_compare.go create mode 100644 custom-update-agent/util/from_files.go create mode 100644 custom-update-agent/util/logger.go create mode 100644 custom-update-agent/util/to_files.go diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 632ed95..ec1b2d6 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -7,14 +7,20 @@ on: - "main" paths: - vehicle-simulator/**/*.go + - custom-update-agent/**/*.go - .github/**/*.yaml pull_request: paths: - vehicle-simulator/**/*.go + - custom-update-agent/**/*.go - .github/**/*.yaml jobs: - call-go-validation: + call-go-validation-vs: uses: eclipse-kanto/example-applications/.github/workflows/go-validation.yaml@main with: - work-dir-path: ./vehicle-simulator \ No newline at end of file + work-dir-path: ./vehicle-simulator + call-go-validation-cua: + uses: eclipse-kanto/example-applications/.github/workflows/go-validation.yaml@main + with: + work-dir-path: ./custom-update-agent \ No newline at end of file diff --git a/custom-update-agent/Dockerfile b/custom-update-agent/Dockerfile new file mode 100644 index 0000000..285338f --- /dev/null +++ b/custom-update-agent/Dockerfile @@ -0,0 +1,17 @@ +FROM --platform=$BUILDPLATFORM golang:alpine AS builder +RUN apk update && apk add --no-cache git +WORKDIR /build +COPY . . +RUN go mod download + +ARG TARGETOS TARGETARCH +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o app . +RUN mkdir ./fileagent + +FROM scratch +WORKDIR /tmp +COPY --from=builder /tmp . +WORKDIR /bin +COPY --from=builder /build/fileagent . +COPY --from=builder /build/app . +CMD ["/bin/app"] \ No newline at end of file diff --git a/custom-update-agent/README.md b/custom-update-agent/README.md new file mode 100644 index 0000000..e8bf93b --- /dev/null +++ b/custom-update-agent/README.md @@ -0,0 +1,138 @@ +![Kanto logo](https://github.com/eclipse-kanto/kanto/raw/main/logo/kanto.svg) + +# Eclipse Kanto - Files Update Agent + +# Introduction + +This is an example application for demonstrating how an update agent works in implementing the Update Agent API to update a certain domain inside the target device. In the case of the Files Update Agent application it is used to manage the files inside of a directory on the target device. + +# Desired state + +The update process is initiated by sending the desired state specification as an MQTT message towards the device, which is handled by the Update Manager component. + +The desired state specification in the scope of the Update Manager is a JSON-based document, which consists of multiple component definitions per domain. The Files Update Agent is responsible for the files domain. Below is an example desired state that contains the Eclipse Kanto logo. + +```json +{ + "desiredState": { + "domains": [ + { + "id": "files", + "config": [], + "components": [ + { + "config": [ + { + "key": "file_name", + "value": "kantoLogo.svg" + }, + { + "key": "download_url", + "value": "https://github.com/eclipse-kanto/kanto/raw/main/logo/kanto.svg" + } + ] + } + ] + } + ] + } +} +``` + +# Commands + +Based on the received desired state the update agent can do the following changes to the provided directory: + +- Download file +- Remove file +- Replace file + +# Installation + +## Prerequisites +You must have an installed and working instance of: +* Eclipse Kanto Update Manager +* Eclipse Kanto Container Management + +Regardless if you are running the update agent as a standard or containerized application, you will need to add the following to the update manager configuration, located at `/etc/kanto-update-manager/config.json`: + +```json +{ + "domain": "device", + "agents": { + "files": { + "rebootRequired": false, + "readTimeout": "20s" + } + } +} +``` +After that reboot the service by executing: + +``` +$ sudo systemctl restart kanto-update-manager.service +``` +## Standard service +Replace the directory provided with `-dir` flag in `custom-update-agent.service` with the desired file directory. +``` Ini +[Unit] +Description=Eclipse Kanto - Files Update Agent +[Service] +Type=simple +ExecStart=/usr/bin/custom-update-agent --dir "" +Restart=always +TimeoutSec=300 + +[Install] +WantedBy=multi-user.target +``` +After that execute the provided `install.sh` script: +``` +$ sudo ./install.sh +``` +To check the status of the `custom-update-agent.service` execute: +``` +$ systemctl status custom-update-agent.service +``` +## Containerized application +A containerized instance of the Files Update Agent can be built using Eclipse Kanto Container Management. +Replace the fields marked with <> in `deployment.json` accordingly +```json +{ + "container_name": "files-update-agent", + "image": { + "name": ":/:" + }, + "host_config": { + "network_mode": "host" + }, + "mount_points": [ + { + "destination": "", + "source": "", + "propagation_mode": "rprivate" + }, + { + "destination": "/bin/fileagent", + "source": "", + "propagation_mode": "shared" + } + ] +} +``` +Create the container by executing: +``` +$ sudo kanto-cm create -f deployment.json +``` +You can check the status of the container by executing: +``` +$ sudo kanto-cm list +``` +Start the containerized application by executing: +``` +$ sudo kanto-cm start +``` +You can check the logs of the container by executing: +``` +$ sudo kanto-cm logs --debug +``` \ No newline at end of file diff --git a/custom-update-agent/custom-update-agent.service b/custom-update-agent/custom-update-agent.service new file mode 100644 index 0000000..866cb01 --- /dev/null +++ b/custom-update-agent/custom-update-agent.service @@ -0,0 +1,11 @@ +[Unit] +Description=Eclipse Kanto - Files Update Agent + +[Service] +Type=simple +ExecStart=/usr/bin/custom-update-agent --dir "" +Restart=always +TimeoutSec=300 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/custom-update-agent/deployment.json b/custom-update-agent/deployment.json new file mode 100644 index 0000000..4bb9354 --- /dev/null +++ b/custom-update-agent/deployment.json @@ -0,0 +1,21 @@ +{ + "container_name": "files-update-agent", + "image": { + "name": ":/:" + }, + "host_config": { + "network_mode": "host" + }, + "mount_points": [ + { + "destination": "", + "source": "", + "propagation_mode": "rprivate" + }, + { + "destination": "/bin/fileagent", + "source": "", + "propagation_mode": "shared" + } + ] +} \ No newline at end of file diff --git a/custom-update-agent/go.mod b/custom-update-agent/go.mod new file mode 100644 index 0000000..28eb43c --- /dev/null +++ b/custom-update-agent/go.mod @@ -0,0 +1,24 @@ +module github.com/eclipse-kanto/example-applications/custom-update-agent + +go 1.21 + +replace github.com/docker/docker => github.com/moby/moby v23.0.3+incompatible + +require ( + github.com/eclipse-kanto/container-management v0.1.0-M4 + github.com/eclipse-kanto/update-manager v0.1.0-M4.0.20240112143913-bbeef46051af + github.com/pkg/errors v0.9.1 + github.com/rickar/props v1.0.0 +) + +require ( + github.com/eclipse/ditto-clients-golang v0.0.0-20230504175246-3e6e17510ac4 // indirect + github.com/eclipse/paho.mqtt.golang v1.4.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect +) diff --git a/custom-update-agent/go.sum b/custom-update-agent/go.sum new file mode 100644 index 0000000..3344bf4 --- /dev/null +++ b/custom-update-agent/go.sum @@ -0,0 +1,75 @@ +github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= +github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse-kanto/container-management v0.1.0-M4 h1:061GDDWH+3pW7rn5OHiJ5VT9fodjz3pVnGW59j1qcnQ= +github.com/eclipse-kanto/container-management v0.1.0-M4/go.mod h1:CpoavUZrXKGNYrRNGnvSLZEBkx/K3gl4DpY61YSpAdU= +github.com/eclipse-kanto/update-manager v0.1.0-M4.0.20240112143913-bbeef46051af h1:ChZkNmAMM0kT6D6Y+BRodzAdaMJqqKq6tKZC4rLFUXQ= +github.com/eclipse-kanto/update-manager v0.1.0-M4.0.20240112143913-bbeef46051af/go.mod h1:JtTUDoVwEHSI1QYlzn1AZSVNfUazMo+um8LM/rbRbbI= +github.com/eclipse/ditto-clients-golang v0.0.0-20230504175246-3e6e17510ac4 h1:Z3jNhQFfkUmwyFv8JRnGRn3WCJ9+teLeFhh7rGHYtUo= +github.com/eclipse/ditto-clients-golang v0.0.0-20230504175246-3e6e17510ac4/go.mod h1:ey7YwfHSQJsinGkGbgeEgqZA7qJnoB0YiFVTFEY50Jg= +github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/eclipse/paho.mqtt.golang v1.4.1 h1:tUSpviiL5G3P9SZZJPC4ZULZJsxQKXxfENpMvdbAXAI= +github.com/eclipse/paho.mqtt.golang v1.4.1/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rickar/props v1.0.0 h1:3C3j+wF2/XbQ/sCGRK8DkCLwuRvzqToMvDzmdxHwCsg= +github.com/rickar/props v1.0.0/go.mod h1:VVywBJXdOY3IwDtBmgAMIZs/XM/CtMKSJzu5dsHYwEY= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/custom-update-agent/install.sh b/custom-update-agent/install.sh new file mode 100755 index 0000000..d2f55b6 --- /dev/null +++ b/custom-update-agent/install.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# +# Copyright (c) 2024 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +go build +cp custom-update-agent /usr/bin/custom-update-agent +cp custom-update-agent.service /etc/systemd/system/custom-update-agent.service +systemctl daemon-reload +systemctl restart custom-update-agent.service \ No newline at end of file diff --git a/custom-update-agent/main.go b/custom-update-agent/main.go new file mode 100644 index 0000000..f887ae3 --- /dev/null +++ b/custom-update-agent/main.go @@ -0,0 +1,54 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/eclipse-kanto/example-applications/custom-update-agent/updateagent" + "github.com/eclipse-kanto/example-applications/custom-update-agent/util" + + "github.com/eclipse-kanto/update-manager/api" + "github.com/eclipse-kanto/update-manager/mqtt" +) + +func main() { + logger := util.ConfigLogger(slog.LevelDebug, os.Stdout) + slog.SetDefault(&logger) + + flag.StringVar(&updateagent.FileDirectory, "dir", "./fileagent", "the path to the directory where file agent will manage files") + flag.Parse() + + updateAgent, err := updateagent.Init(mqtt.NewDefaultConfig(), "files") + if err != nil { + slog.Error("could not initialize an Update Agent service! got", "error", err) + os.Exit(1) + } + if err := updateAgent.(api.UpdateAgent).Start(context.Background()); err != nil { + slog.Error("could not start Update Agent service! got", "error", err) + os.Exit(2) + } + slog.Info("successfully started Update Agent service") + + var signalChan = make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP) + + sig := <-signalChan + slog.Info("Exiting!, received", "signal", sig) + updateAgent.(api.UpdateAgent).Stop() +} diff --git a/custom-update-agent/updateagent/internal_desired_state.go b/custom-update-agent/updateagent/internal_desired_state.go new file mode 100644 index 0000000..0b7da88 --- /dev/null +++ b/custom-update-agent/updateagent/internal_desired_state.go @@ -0,0 +1,55 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "fmt" + + "github.com/eclipse-kanto/example-applications/custom-update-agent/util" + + "github.com/eclipse-kanto/update-manager/api/types" + "github.com/pkg/errors" +) + +type internalDesiredState struct { + desiredState *types.DesiredState + files []*util.File +} + +func (ds *internalDesiredState) findComponent(name string) types.Component { + for _, component := range ds.desiredState.Domains[0].Components { + if component.ID == name { + return component.Component + } + } + return types.Component{} +} + +// toInternalDesiredState converts incoming desired state into an internal desired state structure +func toInternalDesiredState(desiredState *types.DesiredState, domainName string) (*internalDesiredState, error) { + if len(desiredState.Domains) != 1 { + return nil, fmt.Errorf("one domain expected in desired state specification, but got %d", len(desiredState.Domains)) + } + if desiredState.Domains[0].ID != domainName { + return nil, fmt.Errorf("domain id mismatch - expecting %s, received %s", domainName, desiredState.Domains[0].ID) + } + files, err := util.ToFiles(desiredState.Domains[0].Components) + if err != nil { + return nil, errors.Wrap(err, "cannot convert desired state components to container configurations") + } + + return &internalDesiredState{ + desiredState: desiredState, + files: files, + }, nil +} diff --git a/custom-update-agent/updateagent/update_agent_init.go b/custom-update-agent/updateagent/update_agent_init.go new file mode 100644 index 0000000..57e2809 --- /dev/null +++ b/custom-update-agent/updateagent/update_agent_init.go @@ -0,0 +1,36 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "github.com/eclipse-kanto/update-manager/api" + "github.com/eclipse-kanto/update-manager/api/agent" + "github.com/eclipse-kanto/update-manager/mqtt" +) + +// newUpdateManager instantiates a new update manager instance +func newUpdateManager(domainName string) api.UpdateManager { + return &fileUpdateManager{ + domainName: domainName, + createUpdateOperation: newOperation, + } +} + +// Init initializes a new Update Agent instance using given configuration and domain +func Init(config *mqtt.ConnectionConfig, domainName string) (interface{}, error) { + mqttClient, err := mqtt.NewUpdateAgentClient(domainName, config) + if err != nil { + return nil, err + } + return agent.NewUpdateAgent(mqttClient, newUpdateManager(domainName)), nil +} diff --git a/custom-update-agent/updateagent/update_manager.go b/custom-update-agent/updateagent/update_manager.go new file mode 100644 index 0000000..9bd3987 --- /dev/null +++ b/custom-update-agent/updateagent/update_manager.go @@ -0,0 +1,210 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sync" + + "github.com/eclipse-kanto/container-management/containerm/log" + "github.com/eclipse-kanto/container-management/containerm/version" + "github.com/eclipse-kanto/example-applications/custom-update-agent/util" + + "github.com/eclipse-kanto/update-manager/api" + "github.com/eclipse-kanto/update-manager/api/types" + + "github.com/rickar/props" +) + +const ( + updateManagerName = "Eclipse Kanto File Update Agent" + parameterDomain = "domain" +) + +// FileDirectory points to the directory managed by the Files Update Agent +var FileDirectory = "" + +type fileUpdateManager struct { + domainName string + + applyLock sync.Mutex + eventCallback api.UpdateManagerCallback + createUpdateOperation createUpdateOperation + operation UpdateOperation +} + +// Name returns the name of this update manager, e.g. "files". +func (updMgr *fileUpdateManager) Name() string { + return updMgr.domainName +} + +// Apply triggers the update operation with the given activity ID and desired state with files. +// First, it validates the received desired state specification and identifies the actions to be applied. +// If errors are detected, then IDENTIFICATION_FAILED feedback status is reported and operation finishes unsuccessfully. +// Otherwise, IDENTIFIED feedback status with identified actions is reported and it will wait for further commands to proceed. +func (updMgr *fileUpdateManager) Apply(ctx context.Context, activityID string, desiredState *types.DesiredState) { + updMgr.applyLock.Lock() + defer updMgr.applyLock.Unlock() + + log.Debug("processing desired state - start") + // create operation instance + internalDesiredState, err := toInternalDesiredState(desiredState, updMgr.domainName) + if err != nil { + log.ErrorErr(err, "could not parse desired state components as file configurations") + updMgr.eventCallback.HandleDesiredStateFeedbackEvent(updMgr.Name(), activityID, "", types.StatusIdentificationFailed, err.Error(), []*types.Action{}) + return + } + newOperation := updMgr.createUpdateOperation(updMgr, activityID, internalDesiredState) + + // identification phase + newOperation.Feedback(types.StatusIdentifying, "", "") + hasActions, err := newOperation.Identify() + if err != nil { + newOperation.Feedback(types.StatusIdentificationFailed, err.Error(), "") + log.ErrorErr(err, "processing desired state - identification phase failed") + return + } + newOperation.Feedback(types.StatusIdentified, "", "") + if !hasActions { + log.Debug("processing desired state - identification phase completed, no actions identified, sending COMPLETE status") + newOperation.Feedback(types.StatusCompleted, "", "") + return + } + updMgr.operation = newOperation + log.Debug("processing desired state - identification phase completed, waiting for commands...") +} + +// Command processes received desired state command. +func (updMgr *fileUpdateManager) Command(ctx context.Context, activityID string, command *types.DesiredStateCommand) { + if command == nil { + log.Error("Skipping received command for activityId %s, but no payload.", activityID) + return + } + updMgr.applyLock.Lock() + defer updMgr.applyLock.Unlock() + + operation := updMgr.operation + if operation == nil { + log.Warn("Ignoring received command %s for baseline %s and activityId %s, but no operation in progress.", command.Command, command.Baseline, activityID) + return + } + if operation.GetActivityID() != activityID { + log.Warn("Ignoring received command %s for baseline %s and activityId %s, but not matching operation in progress [%s].", + command.Command, command.Baseline, activityID, operation.GetActivityID()) + return + } + operation.Execute(command.Command, command.Baseline) +} + +// Get returns the current state as an inventory graph. +// The inventory graph includes a root software node (type APPLICATION) representing the update agent itself and a list of software nodes (type DATA) representing the available files. +func (updMgr *fileUpdateManager) Get(ctx context.Context, activityID string) (*types.Inventory, error) { + return toInventory(updMgr.asSoftwareNode(), updMgr.getCurrentFiles()), nil +} + +func toInventory(swNodeAgent *types.SoftwareNode, swNodeFiles []*types.SoftwareNode) *types.Inventory { + swNodes := []*types.SoftwareNode{swNodeAgent} + associations := []*types.Association{} + if len(swNodeFiles) > 0 { + swNodes = append(swNodes, swNodeFiles...) + for _, swNodeContainer := range swNodeFiles { + swNodeContainer.ID = swNodeAgent.Parameters[0].Value + ":" + swNodeContainer.ID + + associations = append(associations, &types.Association{ + SourceID: swNodeAgent.ID, + TargetID: swNodeContainer.ID, + }) + } + } + return &types.Inventory{ + SoftwareNodes: swNodes, + Associations: associations, + } +} + +func (updMgr *fileUpdateManager) asSoftwareNode() *types.SoftwareNode { + return &types.SoftwareNode{ + InventoryNode: types.InventoryNode{ + ID: updMgr.Name() + "-update-agent", + Version: version.ProjectVersion, + Name: updateManagerName, + Parameters: []*types.KeyValuePair{ + { + Key: parameterDomain, + Value: updMgr.Name(), + }, + }, + }, + Type: types.SoftwareTypeApplication, + } +} + +func (updMgr *fileUpdateManager) getCurrentFiles() []*types.SoftwareNode { + files := []*util.File{} + propsFilePath := FileDirectory + "/state.props" + + _, err := os.Stat(propsFilePath) + + if errors.Is(err, os.ErrNotExist) { + entries, err := os.ReadDir(FileDirectory) + if err != nil { + slog.Error("got error checking current files", "error", err) + return nil + } + _, err = os.Create(propsFilePath) + if err != nil { + slog.Error(fmt.Sprintf("got error creating file [%s]", "state.props"), "error", err) + } + for _, entry := range entries { + addProperty(entry.Name(), "unknown") + } + } + propsFile, err := os.Open(propsFilePath) + if err != nil { + slog.Error("got error checking current files", "error", err) + return nil + } + + properties, err := props.Read(propsFile) + if err != nil { + slog.Error("got error when reading state.props file", "error", err) + return nil + } + + for _, filename := range properties.Names() { + url, _ := properties.Get(filename) + files = append(files, &util.File{Name: filename, DownloadURL: url}) + } + + return util.FromFiles(files) +} + +// Dispose releases all resources used by this instance +func (updMgr *fileUpdateManager) Dispose() error { + return nil +} + +// WatchEvents subscribes for events that update the current state inventory +func (updMgr *fileUpdateManager) WatchEvents(ctx context.Context) { + // no events handled yet - current state inventory reported only on initial start or explicit get request +} + +// SetCallback sets the callback instance that is used for desired state feedback / current state notifications. +// It is set when the update agent instance is started +func (updMgr *fileUpdateManager) SetCallback(callback api.UpdateManagerCallback) { + updMgr.eventCallback = callback +} diff --git a/custom-update-agent/updateagent/update_operation.go b/custom-update-agent/updateagent/update_operation.go new file mode 100644 index 0000000..a7ff805 --- /dev/null +++ b/custom-update-agent/updateagent/update_operation.go @@ -0,0 +1,568 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package updateagent + +import ( + "bufio" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "reflect" + "strings" + + "github.com/eclipse-kanto/example-applications/custom-update-agent/util" + "github.com/eclipse-kanto/update-manager/api/types" + + "github.com/rickar/props" +) + +type fileAction struct { + desired *util.File + current *util.File + + feedbackAction *types.Action + actionType util.ActionType +} + +type action struct { + status types.StatusType + actions []*fileAction +} + +type operation struct { + temporaryDirectory string + downloadDirectory string + backupDirectory string + + updateManager *fileUpdateManager + activityID string + desiredState *internalDesiredState + + allActions *action +} + +// UpdateOperation defines an interface for an update operation process +type UpdateOperation interface { + GetActivityID() string + Identify() (bool, error) + Execute(command types.CommandType, baseline string) + Feedback(status types.StatusType, message string, baseline string) +} + +type createUpdateOperation func(*fileUpdateManager, string, *internalDesiredState) UpdateOperation + +func newOperation(updMgr *fileUpdateManager, activityID string, desiredState *internalDesiredState) UpdateOperation { + return &operation{ + updateManager: updMgr, + activityID: activityID, + desiredState: desiredState, + } +} + +// GetActivityID returns the activity ID associated with this operation +func (o *operation) GetActivityID() string { + return o.activityID +} + +// Identify executes the IDENTIFYING phase, triggered with the full desired state for the domain +func (o *operation) Identify() (bool, error) { + var err error + o.temporaryDirectory, err = os.MkdirTemp("", "file_agent") + if err != nil { + slog.Error("got error creating temporary directory", "error", err) + return false, err + } + o.downloadDirectory, err = os.MkdirTemp(o.temporaryDirectory, "file_agent_download") + if err != nil { + slog.Error("got error creating download directory", "error", err) + return false, err + } + o.backupDirectory, err = os.MkdirTemp(o.temporaryDirectory, "file_agent_backup") + if err != nil { + slog.Error("got error creating backup directory", "error", err) + return false, err + } + + files, err := os.ReadDir(FileDirectory) + for _, f := range files { + filename := f.Name() + o.copyFile(filename, FileDirectory, o.backupDirectory) + } + if err != nil { + slog.Error("got error reading files directory", "error", err) + return false, err + } + + currentFiles := []*util.File{} + propsFile, err := os.Open(FileDirectory + "/state.props") + if err != nil { + slog.Error("got error opening state.props file", "error", err) + return false, err + } + + properties, err := props.Read(propsFile) + if err != nil { + slog.Error("got error reading state.props file", "error", err) + return false, err + } + + for _, filename := range properties.Names() { + url, _ := properties.Get(filename) + currentFiles = append(currentFiles, &util.File{Name: filename, DownloadURL: url}) + } + + currentFilesMap := util.AsNamedMap(currentFiles) + allActions := []*fileAction{} + + slog.Debug("checking desired vs current files") + + for _, desired := range o.desiredState.files { + filename := desired.Name + current := currentFilesMap[filename] + if current != nil { + delete(currentFilesMap, filename) + } + allActions = append(allActions, o.newFileAction(current, desired)) + } + + destroyActions := o.newRemoveActions(currentFilesMap) + allActions = append(allActions, destroyActions...) + + o.allActions = &action{ + status: types.StatusIdentified, + actions: allActions, + } + propsFile.Close() + + return len(allActions) > 0, nil +} + +func (o *operation) newFileAction(current *util.File, desired *util.File) *fileAction { + actionType := util.DetermineUpdateAction(current, desired) + message := util.GetActionMessage(actionType) + + slog.Debug(fmt.Sprintf("[%s] %s", desired.Name, message)) + + return &fileAction{ + desired: desired, + current: current, + feedbackAction: &types.Action{ + Component: &types.Component{ + ID: o.updateManager.domainName + ":" + desired.Name, + Version: o.desiredState.findComponent(desired.Name).Version, + }, + Status: types.ActionStatusIdentified, + Message: message, + }, + actionType: actionType, + } +} + +func (o *operation) newRemoveActions(toBeRemoved map[string]*util.File) []*fileAction { + removeActions := []*fileAction{} + message := util.GetActionMessage(util.ActionRemove) + for _, current := range toBeRemoved { + slog.Debug(fmt.Sprintf("[%s] %s", current.Name, message)) + removeActions = append(removeActions, &fileAction{ + desired: nil, + current: current, + feedbackAction: &types.Action{ + Component: &types.Component{ + ID: o.updateManager.domainName + ":" + current.Name, + }, + Status: types.ActionStatusIdentified, + Message: message, + }, + actionType: util.ActionRemove, + }) + } + return removeActions +} + +// Execute executes each COMMAND (download, update, activate, etc) phase, triggered per baseline or for all the identified actions +func (o *operation) Execute(command types.CommandType, baseline string) { + commandHandler, action := o.getCommandHandler(baseline, command) + if action == nil { + return + } + commandHandler(o, action) +} + +type commandHandler func(*operation, *action) + +var commandHandlers = map[types.CommandType]struct { + expectedBaselineStatus []types.StatusType + baselineFailureStatus types.StatusType + commandHandler commandHandler +}{ + types.CommandDownload: { + expectedBaselineStatus: []types.StatusType{types.StatusIdentified}, + baselineFailureStatus: types.BaselineStatusDownloadFailure, + commandHandler: download, + }, + types.CommandUpdate: { + expectedBaselineStatus: []types.StatusType{types.BaselineStatusDownloadSuccess}, + baselineFailureStatus: types.BaselineStatusUpdateFailure, + commandHandler: update, + }, + types.CommandActivate: { + expectedBaselineStatus: []types.StatusType{types.BaselineStatusUpdateSuccess}, + baselineFailureStatus: types.BaselineStatusActivationFailure, + commandHandler: activate, + }, + types.CommandCleanup: { + baselineFailureStatus: types.BaselineStatusCleanupFailure, + commandHandler: cleanup, + }, +} + +func (o *operation) getCommandHandler(baseline string, command types.CommandType) (commandHandler, *action) { + handler, ok := commandHandlers[command] + + if !ok { + slog.Warn("Ignoring unknown", "command", command) + return nil, nil + } + if o.allActions == nil { + o.Feedback(handler.baselineFailureStatus, "Unknown baseline "+baseline, baseline) + return nil, nil + } + if len(handler.expectedBaselineStatus) > 0 && !hasStatus(handler.expectedBaselineStatus, o.allActions.status) { + o.Feedback(handler.baselineFailureStatus, fmt.Sprintf("%s is possible only after status %s is reported", command, asStatusString(handler.expectedBaselineStatus)), baseline) + return nil, nil + } + return handler.commandHandler, o.allActions +} + +// ActionAdd and ActionReplace: download file from defined url to temporary file directory. +func download(o *operation, baselineAction *action) { + var lastAction *fileAction + var lastActionErr error + lastActionMessage := "" + + slog.Debug("downloading - starting...") + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloadSuccess, lastAction, types.ActionStatusDownloadSuccess, lastActionMessage) + + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloadFailure, lastAction, types.ActionStatusDownloadFailure, lastActionErr.Error()) + rollback(o, baselineAction) + } + + slog.Debug("downloading - done.") + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloading, lastAction, types.ActionStatusDownloadSuccess, lastActionMessage) + } + lastAction = action + + if action.actionType == util.ActionAdd || action.actionType == util.ActionReplace { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusDownloading, action, types.ActionStatusDownloading, action.feedbackAction.Message) + + if err := o.downloadFile(action.desired); err != nil { + lastActionErr = err + return + } + lastActionMessage = "New file added." + } else { + lastAction = nil + } + } +} + +// ActionAdd, ActionNone and ActionReplace: update the state.props file with the new file-dowload url pairs. +func activate(o *operation, baselineAction *action) { + var lastAction *fileAction + var lastActionErr error + + lastActionMessage := "" + + propsFile, err := os.OpenFile(FileDirectory+"/state.props", os.O_RDWR, 0666) + + if err != nil { + slog.Error("got error opening state.props file", "error", err) + return + } + + propsFile.Truncate(0) + defer propsFile.Close() + + slog.Debug("activating - starting...") + + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivationSuccess, lastAction, types.ActionStatusActivationSuccess, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivationFailure, lastAction, types.ActionStatusActivationFailure, lastActionErr.Error()) + rollback(o, baselineAction) + } + + slog.Debug("activating - done.") + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivating, lastAction, types.ActionStatusActivationSuccess, lastActionMessage) + } + + lastAction = action + if action.actionType == util.ActionAdd || action.actionType == util.ActionReplace || action.actionType == util.ActionNone { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusActivating, action, types.ActionStatusActivating, action.feedbackAction.Message) + lastActionMessage = "Desired file added to state.props file." + if err := addProperty(action.desired.Name, action.desired.DownloadURL); err != nil { + lastActionErr = err + slog.Error("got error updating state.props file", "error", err) + return + } + } else { + lastAction = nil + } + } +} + +// ActionAdd, ActionReplace: move file from temporary directory to fileagent directory. +func update(o *operation, baselineAction *action) { + var lastAction *fileAction + var lastActionErr error + lastActionMessage := "" + + slog.Debug("updating - starting...") + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdateSuccess, lastAction, types.ActionStatusUpdateSuccess, lastActionMessage) + } else { + slog.Debug("last action error") + slog.Debug(lastActionErr.Error()) + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdateFailure, lastAction, types.ActionStatusUpdateFailure, lastActionErr.Error()) + rollback(o, baselineAction) + } + + slog.Debug("updating - done.") + }() + + actions := baselineAction.actions + for _, action := range actions { + if lastAction != nil { + lastAction.feedbackAction.Status = types.ActionStatusUpdateSuccess + lastAction.feedbackAction.Message = lastActionMessage + } + lastAction = action + if action.actionType == util.ActionAdd || action.actionType == util.ActionReplace { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusUpdating, action, types.ActionStatusUpdating, action.feedbackAction.Message) + if err := o.copyFile(action.desired.Name, o.downloadDirectory, FileDirectory); err != nil { + lastActionErr = err + return + } + lastActionMessage = "File added to directory." + } else if action.actionType == util.ActionRemove { + if err := o.removeFile(action.current); err != nil { + lastActionErr = err + return + } + lastActionMessage = "File removed from directory." + } else { + lastActionMessage = action.feedbackAction.Message + } + } +} + +// Restores fileagent directory backup and removes all files in temporary directory +func rollback(o *operation, baselineAction *action) { + var lastAction *fileAction + var lastActionMessage string + var lastActionErr error + + slog.Debug("rollback - starting...") + + defer func() { + if lastActionErr == nil { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollbackSuccess, lastAction, types.ActionStatusUpdateFailure, lastActionMessage) + } else { + o.updateBaselineActionStatus(baselineAction, types.BaselineStatusRollbackFailure, lastAction, types.ActionStatusUpdateFailure, lastActionMessage) + } + slog.Debug("rollback - done.") + }() + + files, err := os.ReadDir(FileDirectory) + if err != nil { + slog.Error("got error reading directory", "error", err) + lastActionErr = err + return + } + backupFiles, err := os.ReadDir(o.backupDirectory) + if err != nil { + slog.Error("got error opening directory", "error", err) + lastActionErr = err + return + } + if !reflect.DeepEqual(files, backupFiles) { + for _, entry := range files { + err = os.Remove(FileDirectory + "/" + entry.Name()) + if err != nil { + slog.Error("got error removing file", "error", err) + lastActionErr = err + return + } + } + + for _, f := range files { + filename := f.Name() + o.copyFile(filename, o.backupDirectory, FileDirectory) + } + } +} + +// ActionRemove: removes the old file from fileagent directory. +// ActionAdd and ActionReplace: removes temporary download directory. +func cleanup(o *operation, baselineAction *action) { + slog.Debug("cleanup - starting...") + + o.cleanupTemporaryFolders() + o.Feedback(types.BaselineStatusCleanupSuccess, "", "") + + slog.Debug("cleanup - done.") +} + +// Feedback sends desired state feedback responses, baseline parameter is optional +func (o *operation) Feedback(status types.StatusType, message string, baseline string) { + o.updateManager.eventCallback.HandleDesiredStateFeedbackEvent(o.updateManager.domainName, o.activityID, baseline, status, message, o.toFeedbackActions()) +} + +func (o *operation) updateBaselineActionStatus(baseline *action, baselineStatus types.StatusType, + action *fileAction, actionStatus types.ActionStatusType, message string) { + if action != nil { + action.feedbackAction.Status = actionStatus + action.feedbackAction.Message = message + } + baseline.status = baselineStatus + o.Feedback(baselineStatus, "", "") +} + +func (o *operation) toFeedbackActions() []*types.Action { + if o.allActions == nil { + return nil + } + result := make([]*types.Action, len(o.allActions.actions)) + for i, action := range o.allActions.actions { + result[i] = action.feedbackAction + } + return result +} + +func hasStatus(where []types.StatusType, what types.StatusType) bool { + for _, status := range where { + if status == what { + return true + } + } + return false +} + +func (o *operation) copyFile(filename string, sourcePath string, destinationPath string) error { + sourceFile, err := os.Open(sourcePath + "/" + filename) + if err != nil { + slog.Error(fmt.Sprintf("got error opening file [%s]", filename), "error", err) + return err + } + defer sourceFile.Close() + destinationFile, err := os.Create(destinationPath + "/" + filename) + if err != nil { + slog.Error(fmt.Sprintf("got error creating file [%s]", filename), "error", err) + return err + } + defer destinationFile.Close() + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + slog.Error(fmt.Sprintf("got error copying file [%s] to [%s]", filename, FileDirectory), "error", err) + return err + + } + return err +} + +func (o *operation) cleanupTemporaryFolders() error { + err := os.RemoveAll(o.temporaryDirectory) + if err != nil { + slog.Error("got error removing temporary folders", "error", err) + } + return err +} + +func (o *operation) removeFile(desired *util.File) error { + err := os.Remove(FileDirectory + "/" + desired.Name) + if err != nil { + slog.Error(fmt.Sprintf("got error removing file [%s]", desired.Name), "error", err) + } + return err +} + +func (o *operation) downloadFile(desired *util.File) error { + resp, err := http.Get(desired.DownloadURL) + if err != nil { + slog.Debug(fmt.Sprintf("could not download file from url [%s]", desired.DownloadURL), "error", err) + return err + } + defer resp.Body.Close() + out, err := os.Create(o.downloadDirectory + "/" + desired.Name) + + if err != nil { + slog.Debug(fmt.Sprintf("could not create file [%s]", desired.Name), "error", err) + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + slog.Debug(fmt.Sprintf("could not copy contents from [%s] to file [%s]", desired.DownloadURL, desired.Name), "error", err) + return err + } + + return nil +} + +func addProperty(key string, value string) error { + propsFilePath := FileDirectory + "/state.props" + propsFile, err := os.OpenFile(propsFilePath, os.O_APPEND|os.O_WRONLY, os.ModeAppend) + if err != nil { + slog.Debug(fmt.Sprintf("could not open file [%s]", propsFilePath), "error", err) + return err + } + defer propsFile.Close() + + w := bufio.NewWriter(propsFile) + + newProperties := props.NewProperties() + newProperties.Set(key, value) + newProperties.Write(w) + + err = w.Flush() + return err +} + +func asStatusString(what []types.StatusType) string { + var sb strings.Builder + for _, status := range what { + if sb.Len() > 0 { + sb.WriteRune('|') + } + sb.WriteString(string(status)) + } + return sb.String() +} diff --git a/custom-update-agent/util/file.go b/custom-update-agent/util/file.go new file mode 100644 index 0000000..3723036 --- /dev/null +++ b/custom-update-agent/util/file.go @@ -0,0 +1,30 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +// File represents the file instance in directory +type File struct { + Name string `json:"file_name"` + DownloadURL string `json:"download_url"` +} + +// AsNamedMap returns a map of file where key is the file's name +func AsNamedMap(filesList []*File) map[string]*File { + result := map[string]*File{} + for _, file := range filesList { + if file != nil { + result[file.Name] = file + } + } + return result +} diff --git a/custom-update-agent/util/files_compare.go b/custom-update-agent/util/files_compare.go new file mode 100644 index 0000000..a2be2f8 --- /dev/null +++ b/custom-update-agent/util/files_compare.go @@ -0,0 +1,57 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "fmt" +) + +// ActionType defines a type for an action to achieve desired file state +type ActionType int + +const ( + // ActionNone denotes that the current file already has the desired configuration, no action is required + ActionNone ActionType = iota + // ActionAdd denotes that a new file with desired configurtion shall be downloaded and added to directory + ActionAdd + // ActionReplace denotes the existing file shall be replaced by a new file with desired configurtionadded in directory + ActionReplace + // ActionRemove denotes that the existing file shall be removed from directory + ActionRemove +) + +// DetermineUpdateAction compares the current file with the desired one and determines what action shall be done to achieve desired state +func DetermineUpdateAction(current *File, desired *File) ActionType { + if current == nil { + return ActionAdd + } + if current.DownloadURL != desired.DownloadURL && current.Name == desired.Name { + return ActionReplace + } + return ActionNone +} + +// GetActionMessage returns a text message describing the given action type +func GetActionMessage(actionType ActionType) string { + switch actionType { + case ActionNone: + return "No changes detected, file will remain in directory with current state." + case ActionAdd: + return "New file will be downloaded and added in direcotry." + case ActionReplace: + return "Existing file will be replaced by a new one." + case ActionRemove: + return "Existing file will be removed, no longer needed." + } + return "Unknown action type: " + fmt.Sprint(actionType) +} diff --git a/custom-update-agent/util/from_files.go b/custom-update-agent/util/from_files.go new file mode 100644 index 0000000..a1d057b --- /dev/null +++ b/custom-update-agent/util/from_files.go @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "github.com/eclipse-kanto/update-manager/api/types" +) + +// FromFiles turns a list of files into a list of software nodes +func FromFiles(files []*File) []*types.SoftwareNode { + softwareNodes := make([]*types.SoftwareNode, len(files)) + for i, file := range files { + softwareNodes[i] = fromFile(file) + } + return softwareNodes +} + +func fromFile(file *File) *types.SoftwareNode { + params := []*types.KeyValuePair{} + + params = append(params, &types.KeyValuePair{Key: "download_url", Value: file.DownloadURL}) + + return &types.SoftwareNode{ + InventoryNode: types.InventoryNode{ + ID: file.Name, + Parameters: params, + }, + Type: types.SoftwareTypeData, + } +} diff --git a/custom-update-agent/util/logger.go b/custom-update-agent/util/logger.go new file mode 100644 index 0000000..06a78e9 --- /dev/null +++ b/custom-update-agent/util/logger.go @@ -0,0 +1,30 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "log/slog" + "os" +) + +// ConfigLogger is used for configuring the slog logger log level +func ConfigLogger(level slog.Level, output *os.File) slog.Logger { + opts := slog.HandlerOptions{ + Level: level, + } + + textHandler := slog.NewTextHandler(output, &opts) + logger := slog.New(textHandler) + + return *logger +} diff --git a/custom-update-agent/util/to_files.go b/custom-update-agent/util/to_files.go new file mode 100644 index 0000000..a054693 --- /dev/null +++ b/custom-update-agent/util/to_files.go @@ -0,0 +1,44 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + +package util + +import ( + "github.com/eclipse-kanto/update-manager/api/types" + "github.com/pkg/errors" +) + +// ToFiles converts a list of components into a list of files +func ToFiles(components []*types.ComponentWithConfig) ([]*File, error) { + files := []*File{} + for _, component := range components { + file, err := toFile(component) + if err != nil { + return nil, errors.Wrapf(err, "invalid configuration for container %s", component.ID) + } + files = append(files, file) + } + return files, nil +} + +func toFile(component *types.ComponentWithConfig) (*File, error) { + file := &File{} + for _, kvPair := range component.Config { + if kvPair.Key == "file_name" { + file.Name = kvPair.Value + } + if kvPair.Key == "download_url" { + file.DownloadURL = kvPair.Value + } + } + return file, nil +}