diff --git a/README.md b/README.md
index c711287..573622a 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,24 @@
# Go Omaha
-Implementation of the omaha protocol in Go.
+[![Build Status](https://travis-ci.org/coreos/go-omaha.svg?branch=master)](https://travis-ci.org/coreos/go-omaha)
+[![GoDoc](https://godoc.org/github.com/coreos/go-omaha/omaha?status.svg)](https://godoc.org/github.com/coreos/go-omaha/omaha)
-https://github.com/google/omaha
+Implementation of the [omaha update protocol](https://github.com/google/omaha) in Go.
-## Docs
+## Status
-http://godoc.org/github.com/coreos/go-omaha/omaha
+This code is targeted for use with CoreOS's [CoreUpdate](https://coreos.com/products/coreupdate/) product and the Container Linux [update_engine](https://github.com/coreos/update_engine).
+As a result this is not a complete implementation of the [protocol](https://github.com/google/omaha/blob/master/doc/ServerProtocolV3.md) and inherits a number of quirks from update_engine.
+These differences include:
-[![Build Status](https://travis-ci.org/coreos/go-omaha.png)](https://travis-ci.org/coreos/go-omaha)
+ - No offline activity tracking.
+ The protocol's ping mechanism allows for tracking application usage, reporting the number of days since the last ping and how many of those days saw active usage.
+ CoreUpdate does not use this, instead assuming update clients are always online and checking in once every ~45-50 minutes.
+ Each check in should include a ping and optionally an update check.
+
+ - Various protocol extensions/abuses.
+ update_engine, likely due to earlier limitations of the protocol and Google's server implementation, uses a number of non-standard fields.
+ For example, packing a lot of extra attributes such as the package's SHA-256 hash into a "postinstall" action.
+ As much as possible the code includes comments about these extensions.
+
+ - Many data fields not used by CoreUpdate are omitted.
diff --git a/omaha/client/client.go b/omaha/client/client.go
new file mode 100644
index 0000000..3be8c44
--- /dev/null
+++ b/omaha/client/client.go
@@ -0,0 +1,274 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package client provides a general purpose Omaha update client implementation.
+package client
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/satori/go.uuid"
+
+ "github.com/coreos/go-omaha/omaha"
+)
+
+// Client supports managing multiple apps using a single server.
+type Client struct {
+ apiClient *httpClient
+ apiEndpoint string
+ clientVersion string
+ userID string
+ sessionID string
+ isMachine bool
+ apps map[string]*AppClient
+}
+
+// AppClient supports managing a single application.
+type AppClient struct {
+ *Client
+ appID string
+ track string
+ version string
+}
+
+// New creates an omaha client for updating one or more applications.
+// userID must be a persistent unique identifier of this update client.
+func New(serverURL, userID string) (*Client, error) {
+ if userID == "" {
+ return nil, errors.New("omaha: empty user identifier")
+ }
+
+ c := &Client{
+ apiClient: newHTTPClient(),
+ clientVersion: "go-omaha",
+ userID: userID,
+ sessionID: uuid.NewV4().String(),
+ apps: make(map[string]*AppClient),
+ }
+
+ if err := c.SetServerURL(serverURL); err != nil {
+ return nil, err
+ }
+
+ return c, nil
+}
+
+// SetServerURL changes the Omaha server this client talks to.
+// If the URL does not include a path component /v1/update/ is assumed.
+func (c *Client) SetServerURL(serverURL string) error {
+ u, err := url.Parse(serverURL)
+ if err != nil {
+ return fmt.Errorf("omaha: invalid server URL: %v", err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("omaha: invalid server protocol: %s", u)
+ }
+ if u.Host == "" {
+ return fmt.Errorf("omaha: invalid server host: %s", u)
+ }
+ if u.Path == "" || u.Path == "/" {
+ u.Path = "/v1/update/"
+ }
+
+ c.apiEndpoint = u.String()
+ return nil
+}
+
+// SetClientVersion sets the identifier of this updater application.
+// e.g. "update_engine-0.1.0". Default is "go-omaha".
+func (c *Client) SetClientVersion(clientVersion string) {
+ c.clientVersion = clientVersion
+}
+
+// AppClient gets the application client for the given application ID.
+func (c *Client) AppClient(appID string) (*AppClient, error) {
+ if app, ok := c.apps[appID]; ok {
+ return app, nil
+ }
+
+ return nil, fmt.Errorf("omaha: missing app client %q", appID)
+}
+
+// NewAppClient creates a new application client.
+func (c *Client) NewAppClient(appID, appVersion string) (*AppClient, error) {
+ if _, ok := c.apps[appID]; ok {
+ return nil, fmt.Errorf("omaha: duplicate app client %q", appID)
+ }
+
+ ac := &AppClient{
+ Client: c,
+ appID: appID,
+ }
+ c.apps[appID] = ac
+
+ return ac, nil
+}
+
+// NewAppClient creates a single application client.
+// Shorthand for New(serverURL, userID).NewAppClient(appID, appVersion).
+func NewAppClient(serverURL, userID, appID, appVersion string) (*AppClient, error) {
+ c, err := New(serverURL, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ ac, err := c.NewAppClient(appID, appVersion)
+ if err := ac.SetVersion(appVersion); err != nil {
+ return nil, err
+ }
+
+ return ac, nil
+}
+
+// SetVersion changes the application version.
+func (ac *AppClient) SetVersion(version string) error {
+ if version == "" {
+ return errors.New("omaha: empty application version")
+ }
+
+ ac.version = version
+ return nil
+}
+
+// SetTrack sets the application update track or group.
+// This is a update_engine/Core Update protocol extension.
+func (ac *AppClient) SetTrack(track string) error {
+ // Although track is an omaha extension and theoretically not required
+ // our Core Update server requires track to be set to a valid id/name.
+ // TODO: deprecate track and use the standard cohort protocol fields.
+ if track == "" {
+ return errors.New("omaha: empty application update track/group")
+ }
+
+ ac.track = track
+ return nil
+}
+
+func (ac *AppClient) UpdateCheck() (*omaha.UpdateResponse, error) {
+ req := ac.newReq()
+ app := req.Apps[0]
+ app.AddPing()
+ app.AddUpdateCheck()
+
+ appResp, err := ac.doReq(ac.apiEndpoint, req)
+ if err != nil {
+ return nil, err
+ }
+
+ if appResp.Ping == nil {
+ return nil, fmt.Errorf("omaha: ping status missing from response")
+ }
+
+ if appResp.Ping.Status != "ok" {
+ return nil, fmt.Errorf("omaha: ping status %s", appResp.Ping.Status)
+ }
+
+ if appResp.UpdateCheck == nil {
+ return nil, fmt.Errorf("omaha: update check missing from response")
+ }
+
+ if appResp.UpdateCheck.Status != omaha.UpdateOK {
+ return nil, appResp.UpdateCheck.Status
+ }
+
+ return appResp.UpdateCheck, nil
+}
+
+func (ac *AppClient) Ping() error {
+ req := ac.newReq()
+ app := req.Apps[0]
+ app.AddPing()
+
+ appResp, err := ac.doReq(ac.apiEndpoint, req)
+ if err != nil {
+ return err
+ }
+
+ if appResp.Ping == nil {
+ return fmt.Errorf("omaha: ping status missing from response")
+ }
+
+ if appResp.Ping.Status != "ok" {
+ return fmt.Errorf("omaha: ping status %s", appResp.Ping.Status)
+ }
+
+ return nil
+}
+
+func (ac *AppClient) Event(event *omaha.EventRequest) error {
+ req := ac.newReq()
+ app := req.Apps[0]
+ app.Events = append(app.Events, event)
+
+ appResp, err := ac.doReq(ac.apiEndpoint, req)
+ if err != nil {
+ return err
+ }
+
+ if len(appResp.Events) == 0 {
+ return fmt.Errorf("omaha: event status missing from response")
+ }
+
+ if appResp.Events[0].Status != "ok" {
+ return fmt.Errorf("omaha: event status %s", appResp.Events[0].Status)
+ }
+
+ return nil
+}
+
+func (ac *AppClient) newReq() *omaha.Request {
+ req := omaha.NewRequest()
+ req.Version = ac.clientVersion
+ req.UserID = ac.userID
+ req.SessionID = ac.sessionID
+ if ac.isMachine {
+ req.IsMachine = 1
+ }
+
+ app := req.AddApp(ac.appID, ac.version)
+ app.Track = ac.track
+
+ // MachineID and BootID are non-standard fields used by CoreOS'
+ // update_engine and Core Update. Copy their values from the
+ // standard UserID and SessionID. Eventually the non-standard
+ // fields should be deprecated.
+ app.MachineID = req.UserID
+ app.BootID = req.SessionID
+
+ return req
+}
+
+func (ac *AppClient) doReq(url string, req *omaha.Request) (*omaha.AppResponse, error) {
+ if len(req.Apps) != 1 {
+ panic(fmt.Errorf("unexpected number of apps: %d", len(req.Apps)))
+ }
+ appID := req.Apps[0].ID
+ resp, err := ac.apiClient.Omaha(url, req)
+ if err != nil {
+ return nil, err
+ }
+
+ appResp := resp.GetApp(appID)
+ if appResp == nil {
+ return nil, fmt.Errorf("omaha: app %s missing from response", appID)
+ }
+
+ if appResp.Status != omaha.AppOK {
+ return nil, appResp.Status
+ }
+
+ return appResp, nil
+}
diff --git a/omaha/client/client_test.go b/omaha/client/client_test.go
new file mode 100644
index 0000000..05ee0d0
--- /dev/null
+++ b/omaha/client/client_test.go
@@ -0,0 +1,181 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package client
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/coreos/go-omaha/omaha"
+)
+
+// implements omaha.Updater
+type recorder struct {
+ t *testing.T
+ update *omaha.Update
+ checks []*omaha.UpdateRequest
+ events []*omaha.EventRequest
+ pings []*omaha.PingRequest
+}
+
+func newRecordingServer(t *testing.T, u *omaha.Update) (*recorder, *omaha.Server) {
+ r := &recorder{t: t, update: u}
+ s, err := omaha.NewServer("127.0.0.1:0", r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ go s.Serve()
+ return r, s
+}
+
+func (r *recorder) CheckApp(req *omaha.Request, app *omaha.AppRequest) error {
+ // CheckApp is meant for checking if app.ID is valid but we don't
+ // care and accept any ID. Instead this is just a convenient place
+ // to check that all requests are well formed.
+ if len(req.SessionID) != 36 {
+ r.t.Errorf("SessionID %q is not a UUID", req.SessionID)
+ }
+ if app.BootID != req.SessionID {
+ r.t.Errorf("BootID %q != SessionID %q", app.BootID, req.SessionID)
+ }
+ if req.UserID == "" {
+ r.t.Error("UserID is blank")
+ }
+ if app.MachineID != req.UserID {
+ r.t.Errorf("MachineID %q != UserID %q", app.MachineID, req.UserID)
+ }
+ if app.Version == "" {
+ r.t.Error("App Version is blank")
+ }
+ return nil
+}
+
+func (r *recorder) CheckUpdate(req *omaha.Request, app *omaha.AppRequest) (*omaha.Update, error) {
+ r.checks = append(r.checks, app.UpdateCheck)
+ if r.update == nil {
+ return nil, omaha.NoUpdate
+ } else {
+ return r.update, nil
+ }
+}
+
+func (r *recorder) Event(req *omaha.Request, app *omaha.AppRequest, event *omaha.EventRequest) {
+ r.events = append(r.events, event)
+}
+
+func (r *recorder) Ping(req *omaha.Request, app *omaha.AppRequest) {
+ r.pings = append(r.pings, app.Ping)
+}
+
+func TestClientNoUpdate(t *testing.T) {
+ r, s := newRecordingServer(t, nil)
+ defer s.Destroy()
+
+ url := "http://" + s.Addr().String()
+ ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := ac.UpdateCheck(); err != omaha.NoUpdate {
+ t.Fatalf("UpdateCheck id not return NoUpdate: %v", err)
+ }
+
+ if len(r.pings) != 1 {
+ t.Fatalf("expected 1 ping, not %d", len(r.pings))
+ }
+
+ if len(r.checks) != 1 {
+ t.Fatalf("expected 1 update check, not %d", len(r.checks))
+ }
+}
+
+func TestClientWithUpdate(t *testing.T) {
+ r, s := newRecordingServer(t, &omaha.Update{
+ Manifest: omaha.Manifest{
+ Version: "1.1.1",
+ },
+ })
+ defer s.Destroy()
+
+ url := "http://" + s.Addr().String()
+ ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ update, err := ac.UpdateCheck()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if update.Manifest.Version != "1.1.1" {
+ t.Fatalf("expected version 1.1.1, not %s", update.Manifest.Version)
+ }
+
+ if len(r.pings) != 1 {
+ t.Fatalf("expected 1 ping, not %d", len(r.pings))
+ }
+
+ if len(r.checks) != 1 {
+ t.Fatalf("expected 1 update check, not %d", len(r.checks))
+ }
+}
+
+func TestClientPing(t *testing.T) {
+ r, s := newRecordingServer(t, nil)
+ defer s.Destroy()
+
+ url := "http://" + s.Addr().String()
+ ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := ac.Ping(); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(r.pings) != 1 {
+ t.Fatalf("expected 1 ping, not %d", len(r.pings))
+ }
+}
+
+func TestClientEvent(t *testing.T) {
+ r, s := newRecordingServer(t, nil)
+ defer s.Destroy()
+
+ url := "http://" + s.Addr().String()
+ ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ event := &omaha.EventRequest{
+ Type: omaha.EventTypeDownloadComplete,
+ Result: omaha.EventResultSuccess,
+ }
+ if err := ac.Event(event); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(r.events) != 1 {
+ t.Fatalf("expected 1 event, not %d", len(r.events))
+ }
+
+ if !reflect.DeepEqual(event, r.events[0]) {
+ t.Fatalf("sent != received:\n%#v\n%#v", event, r.events[0])
+ }
+}
diff --git a/omaha/client/error.go b/omaha/client/error.go
new file mode 100644
index 0000000..2132295
--- /dev/null
+++ b/omaha/client/error.go
@@ -0,0 +1,73 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package client
+
+import (
+ "encoding/xml"
+ "errors"
+ "io"
+ "net/http"
+)
+
+var (
+ bodySizeError = errors.New("http response exceeded 1MB")
+ bodyEmptyError = errors.New("http response was empty")
+)
+
+// xml doesn't return the standard io.ErrUnexpectedEOF so check for both.
+func isUnexpectedEOF(err error) bool {
+ if xerr, ok := err.(*xml.SyntaxError); ok {
+ return xerr.Msg == "unexpected EOF"
+ }
+ return err == io.ErrUnexpectedEOF
+}
+
+// httpError implements error and net.Error for http responses.
+type httpError struct {
+ *http.Response
+}
+
+func (he *httpError) Error() string {
+ return "http error: " + he.Status
+}
+
+func (he *httpError) Timeout() bool {
+ switch he.StatusCode {
+ case http.StatusRequestTimeout: // 408
+ return true
+ case http.StatusGatewayTimeout: // 504
+ return true
+ default:
+ return false
+ }
+}
+
+func (he *httpError) Temporary() bool {
+ if he.Timeout() {
+ return true
+ }
+ switch he.StatusCode {
+ case http.StatusTooManyRequests: // 429
+ return true
+ case http.StatusInternalServerError: // 500
+ return true
+ case http.StatusBadGateway: // 502
+ return true
+ case http.StatusServiceUnavailable: // 503
+ return true
+ default:
+ return false
+ }
+}
diff --git a/omaha/client/http.go b/omaha/client/http.go
new file mode 100644
index 0000000..2db32f4
--- /dev/null
+++ b/omaha/client/http.go
@@ -0,0 +1,95 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package client
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/coreos/go-omaha/omaha"
+)
+
+const (
+ defaultTimeout = 90 * time.Second
+ defaultTries = 7
+)
+
+// httpClient extends the standard http.Client to support xml encoding
+// and decoding as well as automatic retries on transient failures.
+type httpClient struct {
+ http.Client
+}
+
+func newHTTPClient() *httpClient {
+ return &httpClient{http.Client{
+ Timeout: defaultTimeout,
+ }}
+}
+
+// doPost sends a single HTTP POST, returning a parsed omaha response.
+func (hc *httpClient) doPost(url string, reqBody []byte) (*omaha.Response, error) {
+ resp, err := hc.Post(url, "text/xml; charset=utf-8", bytes.NewReader(reqBody))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // A response over 1M in size is certainly bogus.
+ respBody := &io.LimitedReader{R: resp.Body, N: 1024 * 1024}
+ contentType := resp.Header.Get("Content-Type")
+ omahaResp, err := omaha.ParseResponse(contentType, respBody)
+
+ // Report a more sensible error if we truncated the body.
+ if isUnexpectedEOF(err) && respBody.N <= 0 {
+ err = bodySizeError
+ } else if err == io.EOF {
+ err = bodyEmptyError
+ }
+
+ // Prefer reporting HTTP errors over XML parsing errors.
+ if resp.StatusCode != http.StatusOK {
+ err = &httpError{resp}
+ }
+
+ return omahaResp, err
+}
+
+// Omaha encodes and sends an omaha request, retrying on any transient errors.
+func (hc *httpClient) Omaha(url string, req *omaha.Request) (resp *omaha.Response, err error) {
+ buf := bytes.NewBufferString(xml.Header)
+ enc := xml.NewEncoder(buf)
+ if err := enc.Encode(req); err != nil {
+ return nil, fmt.Errorf("omaha: failed to encode request: %v", err)
+ }
+
+ for i := 0; i < defaultTries; i++ {
+ resp, err = hc.doPost(url, buf.Bytes())
+ if neterr, ok := err.(net.Error); ok && neterr.Temporary() {
+ // TODO(marineam): add exponential backoff
+ continue
+ }
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("omaha: request failed: %v", err)
+ }
+
+ return resp, nil
+}
diff --git a/omaha/client/http_test.go b/omaha/client/http_test.go
new file mode 100644
index 0000000..9d9c470
--- /dev/null
+++ b/omaha/client/http_test.go
@@ -0,0 +1,212 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package client
+
+import (
+ "bytes"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/coreos/go-omaha/omaha"
+)
+
+const (
+ sampleRequest = `
+
+
+
+
+
+
+
+
+`
+)
+
+func TestHTTPClientDoPost(t *testing.T) {
+ s, err := omaha.NewTrivialServer("127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Destroy()
+ go s.Serve()
+
+ c := newHTTPClient()
+ url := "http://" + s.Addr().String() + "/v1/update/"
+
+ resp, err := c.doPost(url, []byte(sampleRequest))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Apps) != 1 {
+ t.Fatalf("Should be 1 app, not %d", len(resp.Apps))
+ }
+ if resp.Apps[0].Status != omaha.AppOK {
+ t.Fatalf("Bad apps status: %q", resp.Apps[0].Status)
+ }
+}
+
+type flakyHandler struct {
+ omaha.OmahaHandler
+ flakes int
+ reqs int
+}
+
+func (f *flakyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ f.reqs++
+ if f.flakes > 0 {
+ f.flakes--
+ http.Error(w, "Flake!", http.StatusInternalServerError)
+ return
+ }
+ f.OmahaHandler.ServeHTTP(w, r)
+}
+
+type flakyServer struct {
+ l net.Listener
+ s *http.Server
+ h *flakyHandler
+}
+
+func newFlakyServer() (*flakyServer, error) {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+
+ f := &flakyServer{
+ l: l,
+ s: &http.Server{},
+ h: &flakyHandler{
+ OmahaHandler: omaha.OmahaHandler{
+ Updater: omaha.UpdaterStub{},
+ },
+ flakes: 1,
+ },
+ }
+ f.s.Handler = f.h
+
+ go f.s.Serve(l)
+ return f, nil
+}
+
+func TestHTTPClientError(t *testing.T) {
+ f, err := newFlakyServer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.l.Close()
+
+ c := newHTTPClient()
+ url := "http://" + f.l.Addr().String()
+
+ _, err = c.doPost(url, []byte(sampleRequest))
+ switch err := err.(type) {
+ case nil:
+ t.Fatal("doPost succeeded but should have failed")
+ case *httpError:
+ if err.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("Unexpected http error: %v", err)
+ }
+ if err.Timeout() {
+ t.Fatal("http 500 error reported as timeout")
+ }
+ if !err.Temporary() {
+ t.Fatal("http 500 error not reported as temporary")
+ }
+ default:
+ t.Fatalf("Unexpected error: %v", err)
+ }
+}
+
+func TestHTTPClientRetry(t *testing.T) {
+ f, err := newFlakyServer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.l.Close()
+
+ req, err := omaha.ParseRequest("", strings.NewReader(sampleRequest))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c := newHTTPClient()
+ url := "http://" + f.l.Addr().String()
+
+ resp, err := c.Omaha(url, req)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(resp.Apps) != 1 {
+ t.Fatalf("Should be 1 app, not %d", len(resp.Apps))
+ }
+
+ if resp.Apps[0].Status != omaha.AppOK {
+ t.Fatalf("Bad apps status: %q", resp.Apps[0].Status)
+ }
+
+ if f.h.reqs != 2 {
+ t.Fatalf("Server received %d requests, not 2", f.h.reqs)
+ }
+}
+
+// should result in an unexected EOF
+func largeHandler1(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(``))
+ w.Write(bytes.Repeat([]byte{' '}, 2*1024*1024))
+ w.Write([]byte(``))
+}
+
+// should result in an EOF
+func largeHandler2(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+ w.Write(bytes.Repeat([]byte{' '}, 2*1024*1024))
+}
+
+func TestHTTPClientLarge(t *testing.T) {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+
+ s := &http.Server{
+ Handler: http.HandlerFunc(largeHandler1),
+ }
+ go s.Serve(l)
+
+ c := newHTTPClient()
+ url := "http://" + l.Addr().String()
+
+ _, err = c.doPost(url, []byte(sampleRequest))
+ if err != bodySizeError {
+ t.Errorf("Unexpected error: %v", err)
+ }
+
+ // switch to failing before XML is read instead of half-way
+ // through (which results in a different error internally)
+ s.Handler = http.HandlerFunc(largeHandler2)
+
+ _, err = c.doPost(url, []byte(sampleRequest))
+ if err != bodyEmptyError {
+ t.Errorf("Unexpected error: %v", err)
+ }
+}
diff --git a/omaha/client/machine_linux.go b/omaha/client/machine_linux.go
new file mode 100644
index 0000000..3661318
--- /dev/null
+++ b/omaha/client/machine_linux.go
@@ -0,0 +1,75 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// +build linux
+
+package client
+
+import (
+ "bytes"
+ "fmt"
+ "io/ioutil"
+)
+
+const (
+ machineIDPath = "/etc/machine-id"
+ bootIDPath = "/proc/sys/kernel/random/boot_id"
+)
+
+// NewMachineClient creates a machine-wide client, updating applications
+// that may be used by multiple users. On Linux the system's machine id
+// is used as the user id, and boot id is used as the omaha session id.
+func NewMachineClient(serverURL string) (*Client, error) {
+ machineID, err := ioutil.ReadFile(machineIDPath)
+ if err != nil {
+ fmt.Errorf("omaha: failed to read machine id: %v", err)
+ }
+
+ machineID = bytes.TrimSpace(machineID)
+ // Although machineID should be a UUID, it is formatted as a
+ // plain hex string, omitting the normal '-' separators, so it
+ // should be 32 bytes long. It would be nice to reformat it to
+ // add the '-' chars but update_engine doesn't so stick with its
+ // behavior for now.
+ if len(machineID) < 32 {
+ fmt.Errorf("omaha: incomplete machine id: %q",
+ machineID)
+ }
+
+ bootID, err := ioutil.ReadFile(bootIDPath)
+ if err != nil {
+ fmt.Errorf("omaha: failed to read boot id: %v", err)
+ }
+
+ bootID = bytes.TrimSpace(bootID)
+ // unlike machineID, bootID *does* include '-' chars.
+ if len(bootID) < 36 {
+ fmt.Errorf("omaha: incomplete boot id: %q", bootID)
+ }
+
+ c := &Client{
+ apiClient: newHTTPClient(),
+ clientVersion: "go-omaha",
+ userID: string(machineID),
+ sessionID: string(bootID),
+ isMachine: true,
+ apps: make(map[string]*AppClient),
+ }
+
+ if err := c.SetServerURL(serverURL); err != nil {
+ return nil, err
+ }
+
+ return c, nil
+}
diff --git a/omaha/client/machine_linux_test.go b/omaha/client/machine_linux_test.go
new file mode 100644
index 0000000..022d6b8
--- /dev/null
+++ b/omaha/client/machine_linux_test.go
@@ -0,0 +1,48 @@
+// Copyright 2017 CoreOS, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// +build linux
+
+package client
+
+import (
+ "bytes"
+ "io/ioutil"
+ "testing"
+)
+
+// skip test if external file isn't readable
+func readOrSkip(t *testing.T, name string) string {
+ data, err := ioutil.ReadFile(name)
+ if err != nil {
+ t.Skip(err)
+ }
+ return string(bytes.TrimSpace(data))
+}
+
+func TestNewMachine(t *testing.T) {
+ userID := readOrSkip(t, machineIDPath)
+ sessionID := readOrSkip(t, bootIDPath)
+
+ c, err := NewMachineClient("https://example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.userID != userID {
+ t.Errorf("%q != %q", c.userID, userID)
+ }
+ if c.sessionID != sessionID {
+ t.Errorf("%q != %q", c.sessionID, sessionID)
+ }
+}
diff --git a/omaha/codes.go b/omaha/codes.go
index 822f4b1..775b95b 100644
--- a/omaha/codes.go
+++ b/omaha/codes.go
@@ -160,7 +160,7 @@ const (
// Make AppStatus easy to use as an error
func (a AppStatus) Error() string {
- return string(a)
+ return "omaha: app status " + string(a)
}
type UpdateStatus string
@@ -177,5 +177,5 @@ const (
// Make UpdateStatus easy to use as an error
func (u UpdateStatus) Error() string {
- return string(u)
+ return "omaha: update status " + string(u)
}
diff --git a/omaha/protocol.go b/omaha/protocol.go
index 98cce51..8a2d411 100644
--- a/omaha/protocol.go
+++ b/omaha/protocol.go
@@ -34,7 +34,7 @@ type Request struct {
Apps []*AppRequest `xml:"app"`
Protocol string `xml:"protocol,attr"`
InstallSource string `xml:"installsource,attr,omitempty"`
- IsMachine string `xml:"ismachine,attr,omitempty"`
+ IsMachine int `xml:"ismachine,attr,omitempty"`
RequestID string `xml:"requestid,attr,omitempty"`
SessionID string `xml:"sessionid,attr,omitempty"`
TestSource string `xml:"testsource,attr,omitempty"`
@@ -79,6 +79,15 @@ func (r *Request) AddApp(id, version string) *AppRequest {
return a
}
+func (r *Request) GetApp(id string) *AppRequest {
+ for _, app := range r.Apps {
+ if app.ID == id {
+ return app
+ }
+ }
+ return nil
+}
+
type AppRequest struct {
Ping *PingRequest `xml:"ping"`
UpdateCheck *UpdateRequest `xml:"updatecheck"`
@@ -181,6 +190,15 @@ func (r *Response) AddApp(id string, status AppStatus) *AppResponse {
return a
}
+func (r *Response) GetApp(id string) *AppResponse {
+ for _, app := range r.Apps {
+ if app.ID == id {
+ return app
+ }
+ }
+ return nil
+}
+
type AppResponse struct {
Ping *PingResponse `xml:"ping"`
UpdateCheck *UpdateResponse `xml:"updatecheck"`