aboutsummaryrefslogtreecommitdiffstats
path: root/client/go/internal/vespa/target.go
blob: 8c3f5c9b7c30eab0ee79d97492e938bdedd7ae5b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.

package vespa

import (
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/vespa-engine/vespa/client/go/internal/util"
	"github.com/vespa-engine/vespa/client/go/internal/version"
)

const (
	// A target for a local Vespa service
	TargetLocal = "local"

	// A target for a Vespa service at a custom URL
	TargetCustom = "custom"

	// A Vespa Cloud target
	TargetCloud = "cloud"

	// A hosted Vespa target
	TargetHosted = "hosted"

	// LatestDeployment waits for a deployment to converge to latest generation
	LatestDeployment int64 = -1

	// AnyDeployment waits for a deployment to converge on any generation
	AnyDeployment int64 = -2
)

var errWaitTimeout = errors.New("wait timed out")
var errAuth = errors.New("auth failed")

// Authenticator authenticates the given HTTP request.
type Authenticator interface {
	Authenticate(request *http.Request) error
}

// Service represents a Vespa service.
type Service struct {
	BaseURL    string
	Name       string
	TLSOptions TLSOptions

	deployAPI     bool
	once          sync.Once
	auth          Authenticator
	httpClient    util.HTTPClient
	retryInterval time.Duration
}

// Target represents a Vespa platform, running named Vespa services.
type Target interface {
	// Type returns this target's type, e.g. local or cloud.
	Type() string

	// IsCloud returns whether this target is Vespa Cloud or hosted Vespa
	IsCloud() bool

	// Deployment returns the deployment managed by this target.
	Deployment() Deployment

	// DeployService returns the service providing the deploy API on this target.
	DeployService() (*Service, error)

	// ContainerServices returns all container services of the current deployment. If timeout is positive, wait for
	// services to be discovered.
	ContainerServices(timeout time.Duration) ([]*Service, error)

	// AwaitDeployment waits for a deployment identified by id to succeed. It returns the id that succeeded, or an
	// error. The exact meaning of id depends on the implementation.
	AwaitDeployment(id int64, timeout time.Duration) (int64, error)

	// PrintLog writes the logs of this deployment using given options to control output.
	PrintLog(options LogOptions) error

	// CheckVersion verifies whether clientVersion is compatible with this target.
	CheckVersion(clientVersion version.Version) error
}

// TLSOptions holds the client certificate to use for cloud API or service requests.
type TLSOptions struct {
	CACertificate []byte
	KeyPair       []tls.Certificate
	TrustAll      bool

	CACertificateFile string
	CertificateFile   string
	PrivateKeyFile    string
}

// LogOptions configures the log output to produce when writing log messages.
type LogOptions struct {
	From    time.Time
	To      time.Time
	Follow  bool
	Dequote bool
	Writer  io.Writer
	Level   int
}

// Do sends request to this service. Authentication of the request happens automatically.
func (s *Service) Do(request *http.Request, timeout time.Duration) (*http.Response, error) {
	util.ConfigureTLS(s.httpClient, s.TLSOptions.KeyPair, s.TLSOptions.CACertificate, s.TLSOptions.TrustAll)
	if s.auth != nil {
		if err := s.auth.Authenticate(request); err != nil {
			return nil, fmt.Errorf("%w: %s", errAuth, err)
		}
	}
	return s.httpClient.Do(request, timeout)
}

// SetClient sets the HTTP client that this service should use.
func (s *Service) SetClient(client util.HTTPClient) { s.httpClient = client }

// Wait polls the health check of this service until it succeeds or timeout passes.
func (s *Service) Wait(timeout time.Duration) error {
	// A path that does not need authentication, on any target
	url := strings.TrimRight(s.BaseURL, "/") + "/status.html"
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return err
	}
	okFunc := func(status int, response []byte) (bool, error) { return isOK(status) }
	status, err := wait(s, okFunc, func() *http.Request { return req }, timeout, s.retryInterval)
	if err != nil {
		statusDesc := ""
		if status > 0 {
			statusDesc = fmt.Sprintf(": status %d", status)
		}
		return fmt.Errorf("unhealthy %s%s%s at %s: %w", s.Description(), waitDescription(timeout), statusDesc, url, err)
	}
	return nil
}

func (s *Service) Description() string {
	if s.deployAPI {
		return "deploy API"
	}
	if s.Name == "" {
		return "container"
	}
	return "container " + s.Name
}

// FindService returns the service of given name, found among services, if any.
func FindService(name string, services []*Service) (*Service, error) {
	if name == "" && len(services) == 1 {
		return services[0], nil
	}
	names := make([]string, len(services))
	for i, s := range services {
		if name == s.Name {
			return s, nil
		}
		names[i] = s.Name
	}
	found := "no services found"
	if len(names) > 0 {
		found = "known services: " + strings.Join(names, ", ")
	}
	if name != "" {
		return nil, fmt.Errorf("no such service: %q: %s", name, found)
	}
	return nil, fmt.Errorf("no service specified: %s", found)
}

func waitDescription(d time.Duration) string {
	if d > 0 {
		return " after waiting up to " + d.String()
	}
	return ""
}

func isOK(status int) (bool, error) {
	class := status / 100
	switch class {
	case 2: // success
		return true, nil
	case 4: // client error
		return false, fmt.Errorf("got status %d", status)
	default: // retry on everything else
		return false, nil
	}
}

// responseFunc returns whether a HTTP request is considered successful, based on its status and response data.
// Returning false indicates that the operation should be retried. An error is returned if the response is considered
// terminal and that the request should not be retried.
type responseFunc func(status int, response []byte) (ok bool, err error)

type requestFunc func() *http.Request

// wait queries service until one of the following conditions are satisfied:
//
// 1. okFn returns true or a non-nil error
// 2. timeout is exceeded
//
// It returns the last received HTTP status code and error, if any.
func wait(service *Service, okFn responseFunc, reqFn requestFunc, timeout, retryInterval time.Duration) (int, error) {
	var (
		status   int
		response *http.Response
		err      error
	)
	deadline := time.Now().Add(timeout)
	loopOnce := timeout == 0
	for time.Now().Before(deadline) || loopOnce {
		response, err = service.Do(reqFn(), 10*time.Second)
		if errors.Is(err, errAuth) {
			return status, fmt.Errorf("aborting wait: %w", err)
		} else if err == nil {
			status = response.StatusCode
			body, err := io.ReadAll(response.Body)
			if err != nil {
				return 0, err
			}
			response.Body.Close()
			ok, err := okFn(status, body)
			if err != nil {
				return status, fmt.Errorf("aborting wait: %w", err)
			}
			if ok {
				return status, nil
			}
		}
		timeLeft := time.Until(deadline)
		if loopOnce || timeLeft < retryInterval {
			break
		}
		time.Sleep(retryInterval)
	}
	if err == nil {
		return status, errWaitTimeout
	}
	return status, err
}