Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 25 additions & 44 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,38 @@ on:
pull_request:

jobs:
build:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ '1.15' ]
name: Go ${{ matrix.go }} sample
# The version the module declares, and the newest release. They are the
# same toolchain until Go moves on, and no version is named here, so the
# floor is stated once, in go.mod.
include:
- name: declared
versionFile: go.mod
- name: latest
version: stable
name: Go ${{ matrix.name }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Setup go
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
go-version: ${{ matrix.version }}
go-version-file: ${{ matrix.versionFile }}

- name: Install Dependencies
run: |
sudo apt-get update; sudo apt-get install socat
- name: Run API test
run: |
wget https://raw.githubusercontent.com/UpdateHub/updatehub/master/doc/agent-http.yaml \
-O agent-http.yaml
docker run \
--rm \
--detach=true \
--name agent-sdk-go-mock \
-p 8080:8000 \
-v $PWD/agent-http.yaml:/api.yaml \
danielgtaylor/apisprout@sha256:6c07143937e57095d8478efc8ab7eab52b44e67c7673285f8c0a2bf4a7b137ad \
/api.yaml --validate-request
go run examples/api/main.go
- name: Run listener test
run: |
export UH_LISTENER_TEST=updatehub-statechange.sock
go run examples/listener/main.go &

while [ ! -S "$UH_LISTENER_TEST" ]; do
sleep 1
done
- name: Build
run: go build ./...

if [[ "$(echo "download" | socat - UNIX-CONNECT:updatehub-statechange.sock)" != "cancel" ]]; then
echo "Unexpected download response"
- name: Check the formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt reports:"
echo "$unformatted"
exit 1
fi
if [[ "$(echo "install" | socat - UNIX-CONNECT:updatehub-statechange.sock)" != "" ]]; then
echo "Unexpected install response"
exit 2
fi
if [[ "$(echo "error" | socat - UNIX-CONNECT:updatehub-statechange.sock)" != "" ]]; then
echo "Unexpected error response"
exit 3
fi
if [[ "$(echo "reboot" | socat - UNIX-CONNECT:updatehub-statechange.sock)" != "" ]]; then
echo "Unexpected reboot response"
exit 4
fi

- name: Test
run: go test -race ./...
9 changes: 6 additions & 3 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
uses: golangci/golangci-lint-action@v8
with:
version: v1.29
version: latest
142 changes: 141 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,143 @@
# updatehub agent SDK for Go

[![godoc](https://godoc.org/github.com/UpdateHub/agent-sdk-go?status.svg)](https://godoc.org/github.com/UpdateHub/agent-sdk-go)
[![godoc](https://pkg.go.dev/badge/github.com/UpdateHub/agent-sdk-go/v2.svg)](https://pkg.go.dev/github.com/UpdateHub/agent-sdk-go/v2)

A Go client for the [UpdateHub](https://updatehub.io) agent's local API. It
speaks the agent's two transports:

- `Client` calls the HTTP API the agent serves on `localhost:8080`.
- `StateChange` serves the Unix socket the agent connects to before every state
transition, so your program can veto one.

This release is validated against UpdateHub agent **2.1.6**.

## Install

```sh
go get github.com/UpdateHub/agent-sdk-go/v2
```

It needs Go 1.26 or later, and nothing outside the standard library.

## Ask the agent to search for an update

```go
client, err := updatehub.NewClient(updatehub.DefaultBaseURL, 30*time.Second, 5*time.Minute)
if err != nil {
return err
}

response, err := client.Probe(ctx, "")
if err != nil {
return err
}

switch response.Outcome {
case updatehub.ProbeUpdating:
// an update is available
case updatehub.ProbeNoUpdate:
// the server offers nothing new
case updatehub.ProbeTryAgain:
// the server asked for a back-off of response.TryAgainIn
case updatehub.ProbeBusy:
// the agent did not probe; it is in the response.BusyState state
}
```

Pass an empty custom server to probe the address the agent is configured with.
`ProbeBusy` is not a failure: the agent answers it, without reaching the server,
whenever it is in a state that does not accept a probe. One of those states is
named `error`, so read `Outcome` rather than the state name alone.

**One turn per agent.** A `Client` serialises its calls, because agent 2.1.6
panics a worker thread when several requests arrive together and then answers
nothing more until it restarts. The turn is held per agent rather than per
`Client`, so a second client built for the same base URL waits rather than
arriving beside the first — but the key is the base URL as written, so spell the
address the same way everywhere.

A context bounds the wait, never the request. When a caller gives up, the
request keeps the turn until the agent answers it, until the connection breaks,
or until the hold runs out — so a call that gave up cannot leave a second one
arriving beside it. A call that then gives up waiting for its turn reports
`ErrCallOutstanding`, which tells you the agent is busy with an earlier request
of yours rather than slow to answer this one.

The hold is the third argument to `NewClient`, and long is safer than short: the
agent may still be working on the abandoned request. It exists because an agent
can also stay alive and answer nothing, and something must guarantee the turn
comes back from that.

## Veto a state transition

```go
listener := updatehub.NewStateChange(updatehub.DefaultSocketPath)

listener.OnError(func(err error) {
// one connection failed; the listener keeps serving
})

listener.OnState(updatehub.StateDownload, func(ctx context.Context, handler *updatehub.Handler) error {
return handler.Cancel()
})

if err := listener.Bind(); err != nil {
if errors.Is(err, syscall.EADDRINUSE) {
// somebody else holds the socket, or a dead process left the path
}

return err
}
defer listener.Close()

return listener.Serve(ctx)
```

A connection that closes with nothing written lets the agent proceed, so an
unbound socket is not a degraded veto: it is consent. The same is true when the
agent's trigger script is missing, because the agent then never consults the
socket at all. `TriggerInstalled(updatehub.TriggerPath)` reports whether the script is
there; what to do about its absence is your program's decision, not this
package's.

## What changed in v2.0.0

The module path is now `github.com/UpdateHub/agent-sdk-go/v2`, and the API is
not compatible with what came before it. The rewrite is driven by one intended
consumer: a single-process firmware daemon on a device that has no way back if
the update channel breaks.

- **Nothing exits the process.** `log.Fatal` is gone from every error path.
- **No method panics.** Each one used to end in a single-value type assertion on
a value that is nil on the error path, so an unreachable agent crashed the
caller.
- **Every call reads the HTTP status code**, and reports an unexpected one as a
`*StatusError` carrying the code and the body.
- **`Probe` sends no body when no custom server is given.** It used to send
`{"custom_server": ""}`, which agent 2.1.6 answers with a 500 — and keeps
answering with a 500 until it restarts.
- **`ProbeResponse` is a type**, not `interface{}`, and it carries all four
replies the agent can send.
- **`LocalInstall`, `RemoteInstall` and `AbortDownload` report a refusal** as a
result rather than as an error.
- **Calls to one agent are serialised**, through a turn held per base URL, and
the turn lasts until the request really ends rather than until the caller
stops waiting for it.
- **Configuration is a constructor parameter.** The base URL and the timeout go
to `NewClient`, the socket path goes to `NewStateChange`, and the
`UH_LISTENER_TEST` environment variable is deleted.
- **The listener binds without unlinking**, so a path that is already there
fails with `EADDRINUSE` instead of being taken silently.
- **The listener has a lifetime.** `Bind`, `Serve(ctx)` and `Close` replace a
`Listen` that blocked for ever, and it serves each connection in its own
goroutine.
- **A callback takes a context and returns an error**, and errors reach the sink
`OnError` registers. A callback that panics is reported there too, rather than
taking down a process that could not have recovered it.
- **The package writes nothing to stdout and nothing through `log`.**
- **`gorequest` is gone**, with the `goproxy` and `pkg/errors` dependencies it
pulled in. The package now needs the standard library alone.

## License

Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE).
Loading
Loading