-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathportal.go
More file actions
456 lines (383 loc) · 13 KB
/
portal.go
File metadata and controls
456 lines (383 loc) · 13 KB
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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
// Copyright (c) Codesphere Inc.
// SPDX-License-Identifier: Apache-2.0
package portal
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/codesphere-cloud/oms/internal/env"
)
type Portal interface {
ListBuilds(product Product) (availablePackages Builds, err error)
GetBuild(product Product, version string, hash string) (Build, error)
DownloadBuildArtifact(product Product, build Build, file io.Writer, startByte int, quiet bool) error
VerifyBuildArtifactDownload(file io.Reader, download Build) error
RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error)
RevokeAPIKey(key string) error
UpdateAPIKey(key string, expiresAt time.Time) error
ListAPIKeys() ([]ApiKey, error)
GetApiKeyId(oldKey string) (string, error)
}
type PortalClient struct {
Env env.Env
HttpClient HttpClient
}
type HttpClient interface {
Do(*http.Request) (*http.Response, error)
}
func NewPortalClient() *PortalClient {
return &PortalClient{
Env: env.NewEnv(),
HttpClient: NewConfiguredHttpClient(),
}
}
// NewConfiguredHttpClient creates an HTTP client with proper timeouts
func NewConfiguredHttpClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 2 * time.Minute,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
},
}
}
type Product string
const (
CodesphereProduct Product = "codesphere"
OmsProduct Product = "oms"
)
// TruncateHTMLResponse detects HTML responses and truncates them to avoid verbose error messages.
func TruncateHTMLResponse(body string) string {
// Check if response looks like HTML
if strings.HasPrefix(strings.TrimSpace(body), "<!DOCTYPE") || strings.HasPrefix(strings.TrimSpace(body), "<html") {
// Extract title if present
if idx := strings.Index(body, "<title>"); idx != -1 {
endIdx := strings.Index(body[idx:], "</title>")
if endIdx != -1 {
title := body[idx+7 : idx+endIdx]
return fmt.Sprintf("Server says: %s", title)
}
}
return "Received HTML response instead of JSON"
}
if len(body) <= 500 {
return body
}
return body[:500] + "... (truncated)"
}
// AuthorizedHttpRequest sends a HTTP request with the necessary authorization headers.
func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Response, err error) {
apiKey, err := c.Env.GetOmsPortalApiKey()
if err != nil {
err = fmt.Errorf("failed to get API Key: %w", err)
return
}
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Accept", "application/json")
resp, err = c.HttpClient.Do(req)
if err != nil {
err = fmt.Errorf("failed to send request: %w", err)
return
}
if resp.StatusCode == http.StatusUnauthorized {
log.Println("You need a valid OMS API Key, please reach out to the Codesphere support at support@codesphere.com to request a new API Key.")
log.Println("If you already have an API Key, make sure to set it using the environment variable OMS_PORTAL_API_KEY")
}
var respBody []byte
if resp.StatusCode >= 300 {
if resp.Body != nil {
respBody, _ = io.ReadAll(resp.Body)
}
truncatedBody := TruncateHTMLResponse(string(respBody))
log.Printf("Non-2xx response received - Status: %d", resp.StatusCode)
log.Printf("%s", truncatedBody)
err = fmt.Errorf("unexpected response status: %d - %s (%s)", resp.StatusCode, http.StatusText(resp.StatusCode), truncatedBody)
return
}
return
}
// HttpRequest sends an unauthorized HTTP request to the portal API with the specified method, path, and body.
func (c *PortalClient) HttpRequest(method string, path string, body []byte) (resp *http.Response, err error) {
requestBody := bytes.NewBuffer(body)
url, err := url.JoinPath(c.Env.GetOmsPortalApi(), path)
if err != nil {
err = fmt.Errorf("failed to get generate URL: %w", err)
return
}
req, err := http.NewRequest(method, url, requestBody)
if err != nil {
log.Fatalf("Error creating request: %v", err)
return
}
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
return c.AuthorizedHttpRequest(req)
}
// GetBody sends a GET request to the specified path and returns the response body and status code.
func (c *PortalClient) GetBody(path string) (body []byte, status int, err error) {
resp, err := c.HttpRequest(http.MethodGet, path, []byte{})
if err != nil || resp == nil {
err = fmt.Errorf("GET failed: %w", err)
return
}
defer func() { _ = resp.Body.Close() }()
status = resp.StatusCode
body, err = io.ReadAll(resp.Body)
if err != nil {
err = fmt.Errorf("failed to read response body: %w", err)
return
}
return
}
// ListBuilds retrieves the list of available builds for the specified product.
func (c *PortalClient) ListBuilds(product Product) (availablePackages Builds, err error) {
res, _, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
if err != nil {
err = fmt.Errorf("failed to list packages: %w", err)
return
}
err = json.Unmarshal(res, &availablePackages)
if err != nil {
err = fmt.Errorf("failed to parse list packages response: %w", err)
return
}
compareBuilds := func(l, r Build) int {
if l.Date.Before(r.Date) {
return -1
}
if l.Date.Equal(r.Date) && l.Internal == r.Internal {
return 0
}
return 1
}
slices.SortFunc(availablePackages.Builds, compareBuilds)
return
}
// GetBuild retrieves a specific build for the given product, version, and hash.
func (c *PortalClient) GetBuild(product Product, version string, hash string) (Build, error) {
packages, err := c.ListBuilds(product)
if err != nil {
return Build{}, fmt.Errorf("failed to list %s packages: %w", product, err)
}
if len(packages.Builds) == 0 {
return Build{}, errors.New("no builds returned")
}
if version == "" || version == "latest" {
// Builds are always ordered by date, newest build is latest version
return packages.Builds[len(packages.Builds)-1], nil
}
matchingPackages := []Build{}
for _, build := range packages.Builds {
if build.Version == version {
if len(hash) == 0 || strings.HasPrefix(hash, build.Hash) {
matchingPackages = append(matchingPackages, build)
}
}
}
if len(matchingPackages) == 0 {
return Build{}, fmt.Errorf("version '%s' with hash '%s' not found", version, hash)
}
// Builds are always ordered by date, return newest build
return matchingPackages[len(matchingPackages)-1], nil
}
// DownloadBuildArtifact downloads the build artifact for the specified product and build.
func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, startByte int, quiet bool) error {
reqBody, err := json.Marshal(build)
if err != nil {
return fmt.Errorf("failed to generate request body: %w", err)
}
url, err := url.JoinPath(c.Env.GetOmsPortalApi(), fmt.Sprintf("/packages/%s/download", product))
if err != nil {
return fmt.Errorf("failed to get generate URL: %w", err)
}
bodyReader := bytes.NewBuffer(reqBody)
req, err := http.NewRequest(http.MethodGet, url, bodyReader)
if err != nil {
return fmt.Errorf("failed to create GET request to download build: %w", err)
}
if startByte > 0 {
log.Printf("Resuming download of existing file at byte %d\n", startByte)
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startByte))
}
// Download the file from startByte to allow resuming
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.AuthorizedHttpRequest(req)
if err != nil {
return fmt.Errorf("GET request to download build failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if !quiet && resp.ContentLength > 0 {
log.Printf("Starting download of %s...", ByteCountToHumanReadable(resp.ContentLength))
}
// Create a WriteCounter to wrap the output file and report progress, unless quiet is requested.
// Default behavior: report progress. Quiet callers should pass true for quiet.
counter := file
if !quiet {
totalSize := resp.ContentLength
if startByte > 0 && totalSize > 0 {
totalSize = totalSize + int64(startByte)
}
counter = NewWriteCounterWithTotal(file, totalSize, int64(startByte))
}
_, err = io.Copy(counter, resp.Body)
if err != nil {
return fmt.Errorf("failed to copy response body to file: %w", err)
}
log.Println("Download finished successfully.")
return nil
}
func (c *PortalClient) VerifyBuildArtifactDownload(file io.Reader, download Build) error {
// skip if oms-portal does not provide MD5Sum (older builds)
if download.Artifacts[0].Md5Sum == "" {
return nil
}
log.Println("Calculating MD5 checksum to verify download integrity...")
hash := md5.New()
_, err := io.Copy(hash, file)
if err != nil {
return fmt.Errorf("failed to compute checksum: %w", err)
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
if !strings.EqualFold(download.Artifacts[0].Md5Sum, md5Sum) {
return fmt.Errorf("invalid md5Sum: expected %s, but got %s", download.Artifacts[0].Md5Sum, md5Sum)
}
log.Println("File checksum verified successfully.")
return nil
}
// RegisterAPIKey registers a new API key with the specified parameters.
func (c *PortalClient) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) {
req := struct {
Owner string `json:"owner"`
Organization string `json:"organization"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
}{
Owner: owner,
Organization: organization,
Role: role,
ExpiresAt: expiresAt,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to generate request body: %w", err)
}
resp, err := c.HttpRequest(http.MethodPost, "/key/register", reqBody)
if err != nil {
return nil, fmt.Errorf("POST request to register API key failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}
newKey := &ApiKey{}
err = json.Unmarshal(responseBody, newKey)
if err != nil {
return nil, fmt.Errorf("failed to decode response body: %w", err)
}
return newKey, nil
}
// RevokeAPIKey revokes the API key with the specified key ID.
func (c *PortalClient) RevokeAPIKey(keyId string) error {
req := struct {
KeyID string `json:"keyId"`
}{
KeyID: keyId,
}
reqBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to generate request body: %w", err)
}
resp, err := c.HttpRequest(http.MethodPost, "/key/revoke", reqBody)
if err != nil {
return fmt.Errorf("POST request to revoke API key failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
log.Println("API key revoked successfully!")
return nil
}
// UpdateAPIKey updates the expiration date of the specified API key.
func (c *PortalClient) UpdateAPIKey(key string, expiresAt time.Time) error {
req := struct {
Key string `json:"keyId"`
ExpiresAt time.Time `json:"expiresAt"`
}{
Key: key,
ExpiresAt: expiresAt,
}
reqBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to generate request body: %w", err)
}
resp, err := c.HttpRequest(http.MethodPost, "/key/update", reqBody)
if err != nil {
return fmt.Errorf("POST request to update API key failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
log.Println("API key updated successfully")
return nil
}
// ListAPIKeys retrieves the list of API keys.
func (c *PortalClient) ListAPIKeys() ([]ApiKey, error) {
res, _, err := c.GetBody("/keys")
if err != nil {
return nil, fmt.Errorf("failed to list api keys: %w", err)
}
var keys []ApiKey
if err := json.Unmarshal(res, &keys); err != nil {
return nil, fmt.Errorf("failed to parse api keys response: %w", err)
}
return keys, nil
}
// GetApiKeyId retrieves the key ID by sending the old key in the request header.
func (c *PortalClient) GetApiKeyId(oldKey string) (string, error) {
url, err := url.JoinPath(c.Env.GetOmsPortalApi(), "/key")
if err != nil {
return "", fmt.Errorf("failed to generate URL: %w", err)
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Key", oldKey)
req.Header.Set("Accept", "application/json")
resp, err := c.HttpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
truncatedBody := TruncateHTMLResponse(string(respBody))
return "", fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), truncatedBody)
}
var result struct {
KeyID string `json:"keyId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
return result.KeyID, nil
}