Merge pull request #24 from marineam/client
Begin generic omaha client implementation
This commit is contained in:
commit
f8acb2d7b7
10 changed files with 997 additions and 8 deletions
23
README.md
23
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.
|
||||
|
|
274
omaha/client/client.go
Normal file
274
omaha/client/client.go
Normal file
|
@ -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
|
||||
}
|
181
omaha/client/client_test.go
Normal file
181
omaha/client/client_test.go
Normal file
|
@ -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])
|
||||
}
|
||||
}
|
73
omaha/client/error.go
Normal file
73
omaha/client/error.go
Normal file
|
@ -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
|
||||
}
|
||||
}
|
95
omaha/client/http.go
Normal file
95
omaha/client/http.go
Normal file
|
@ -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
|
||||
}
|
212
omaha/client/http_test.go
Normal file
212
omaha/client/http_test.go
Normal file
|
@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<request protocol="3.0" version="ChromeOSUpdateEngine-0.1.0.0" updaterversion="ChromeOSUpdateEngine-0.1.0.0" installsource="ondemandupdate" ismachine="1">
|
||||
<os version="Indy" platform="Chrome OS" sp="ForcedUpdate_x86_64"></os>
|
||||
<app appid="{87efface-864d-49a5-9bb3-4b050a7c227a}" bootid="{7D52A1CC-7066-40F0-91C7-7CB6A871BFDE}" machineid="{8BDE4C4D-9083-4D61-B41C-3253212C0C37}" oem="ec3000" version="ForcedUpdate" track="dev-channel" from_track="developer-build" lang="en-US" board="amd64-generic" hardware_class="" delta_okay="false" >
|
||||
<ping active="1" a="-1" r="-1"></ping>
|
||||
<updatecheck targetversionprefix=""></updatecheck>
|
||||
<event eventtype="3" eventresult="2" previousversion=""></event>
|
||||
</app>
|
||||
</request>
|
||||
`
|
||||
)
|
||||
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?><response protocol="3.0">`))
|
||||
w.Write(bytes.Repeat([]byte{' '}, 2*1024*1024))
|
||||
w.Write([]byte(`</response>`))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
75
omaha/client/machine_linux.go
Normal file
75
omaha/client/machine_linux.go
Normal file
|
@ -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
|
||||
}
|
48
omaha/client/machine_linux_test.go
Normal file
48
omaha/client/machine_linux_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
Loading…
Reference in a new issue