Skip to content

Commit 67ac4ad

Browse files
committed
sdk: negotiate API version
1 parent 546c191 commit 67ac4ad

8 files changed

Lines changed: 406 additions & 49 deletions

File tree

e2e/coordinator/coordinator_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ func TestCoordinator(t *testing.T) {
205205
nonce := [32]byte{}
206206
var report []byte
207207
require.NoError(ct.Kubeclient.WithForwardedPort(ctx, ct.Namespace, "port-forwarder-coordinator-ready", apitypes.Port, func(addr string) error {
208-
r, err := client.GetAttestation(ctx, fmt.Sprintf("http://%s/attest", addr), nonce[:])
208+
r, err := client.WithBaseURL(fmt.Sprintf("http://%s", addr)).GetAttestation(ctx, nonce[:])
209209
if err != nil {
210210
return err
211211
}

e2e/internal/contrasttest/contrasttest.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,7 @@ func (ct *ContrastTest) RunVerify(ctx context.Context) error {
400400
}
401401
var serializedAttestation []byte
402402
err = ct.Kubeclient.WithForwardedPort(ctx, ct.Namespace, "port-forwarder-coordinator", apitypes.Port, func(addr string) error {
403-
url := fmt.Sprintf("http://%s/attest", addr)
404-
resp, err := client.GetAttestation(ctx, url, nonce)
403+
resp, err := client.WithBaseURL(fmt.Sprintf("http://%s", addr)).GetAttestation(ctx, nonce)
405404
if err != nil {
406405
return fmt.Errorf("getting attestation: %w", err)
407406
}

sdk/apiv1/apiv1.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build contrast_unstable_api
5+
6+
// Package apiv1 implements version v1 of the Contrast HTTP API.
7+
//
8+
// Obtain an [API] from the SDK client via its V1 method, rather than constructing one
9+
// directly. Use this package to pin calls to v1; the SDK's top-level methods always speak
10+
// the newest API version the Coordinator supports.
11+
package apiv1
12+
13+
import (
14+
"github.com/edgelesssys/contrast/apitypes"
15+
"github.com/edgelesssys/contrast/sdk/internal/transport"
16+
)
17+
18+
// Version is the API version implemented by this package.
19+
const Version = apitypes.APIVersionV1
20+
21+
// API calls version v1 of the Coordinator's HTTP API.
22+
type API struct {
23+
transport *transport.Client
24+
}
25+
26+
// New returns an [API] using the given transport.
27+
func New(t *transport.Client) *API {
28+
return &API{transport: t}
29+
}

sdk/capabilities.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build contrast_unstable_api
5+
6+
package sdk
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"net/http"
13+
"slices"
14+
"sync"
15+
16+
"github.com/edgelesssys/contrast/apitypes"
17+
"github.com/edgelesssys/contrast/sdk/apiv1"
18+
)
19+
20+
// capabilitiesPath is the path of the Coordinator's capabilities endpoint.
21+
//
22+
// This endpoint is deliberately unversioned. It's how clients discover which versions
23+
// exist, so it must be reachable without knowing a version first.
24+
const capabilitiesPath = "/capabilities"
25+
26+
// supportedAPIVersions are the API versions this SDK can speak, newest first.
27+
var supportedAPIVersions = []string{apiv1.Version}
28+
29+
// negotiation caches the result of API version negotiation.
30+
//
31+
// It's held behind a pointer so that copying a [Client] doesn't copy the mutex.
32+
type negotiation struct {
33+
mu sync.Mutex
34+
version string
35+
}
36+
37+
// NegotiateAPIVersion returns the newest API version supported by both this SDK and the Coordinator.
38+
//
39+
// The result is cached, so this costs at most one request per [Client].
40+
func (c Client) NegotiateAPIVersion(ctx context.Context) (string, error) {
41+
c.negotiated.mu.Lock()
42+
defer c.negotiated.mu.Unlock()
43+
if c.negotiated.version != "" {
44+
return c.negotiated.version, nil
45+
}
46+
47+
body, err := c.transport.DoJSON(ctx, http.MethodGet, capabilitiesPath, nil)
48+
if err != nil {
49+
return "", fmt.Errorf("getting capabilities: %w", err)
50+
}
51+
var caps apitypes.CapabilitiesResponse
52+
if err := json.Unmarshal(body, &caps); err != nil {
53+
return "", fmt.Errorf("unmarshalling capabilities: %w", err)
54+
}
55+
56+
// supportedAPIVersions is ordered newest first, so the first match is the best one.
57+
for _, version := range supportedAPIVersions {
58+
if slices.Contains(caps.APIVersions, version) {
59+
c.negotiated.version = version
60+
return version, nil
61+
}
62+
}
63+
return "", fmt.Errorf("no common API version: Coordinator supports %v, SDK supports %v", caps.APIVersions, supportedAPIVersions)
64+
}
65+
66+
// V1 returns a client for version v1 of the Coordinator's HTTP API.
67+
func (c Client) V1() *apiv1.API {
68+
return apiv1.New(c.transport)
69+
}

sdk/capabilities_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build contrast_unstable_api
5+
6+
package sdk
7+
8+
import (
9+
"encoding/json"
10+
"net/http"
11+
"net/http/httptest"
12+
"sync/atomic"
13+
"testing"
14+
15+
"github.com/edgelesssys/contrast/apitypes"
16+
"github.com/edgelesssys/contrast/sdk/apiv1"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
func TestNegotiateAPIVersion(t *testing.T) {
22+
for name, tc := range map[string]struct {
23+
coordinatorVersions []string
24+
handler http.Handler
25+
26+
wantVersion string
27+
wantErr string
28+
}{
29+
"coordinator supports v1": {
30+
coordinatorVersions: []string{apiv1.Version},
31+
wantVersion: apiv1.Version,
32+
},
33+
"coordinator supports a newer version, too": {
34+
coordinatorVersions: []string{apiv1.Version, "v2"},
35+
wantVersion: apiv1.Version,
36+
},
37+
"no common version": {
38+
coordinatorVersions: []string{"v99"},
39+
wantErr: "no common API version",
40+
},
41+
"coordinator has no capabilities endpoint": {
42+
handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
43+
w.WriteHeader(http.StatusNotFound)
44+
}),
45+
wantErr: "getting capabilities",
46+
},
47+
"capabilities response is malformed": {
48+
handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
49+
_, _ = w.Write([]byte("not json"))
50+
}),
51+
wantErr: "unmarshalling capabilities",
52+
},
53+
} {
54+
t.Run(name, func(t *testing.T) {
55+
require := require.New(t)
56+
assert := assert.New(t)
57+
58+
handler := tc.handler
59+
if handler == nil {
60+
handler = capabilitiesHandler(tc.coordinatorVersions)
61+
}
62+
srv := httptest.NewServer(handler)
63+
t.Cleanup(srv.Close)
64+
65+
version, err := New().WithBaseURL(srv.URL).NegotiateAPIVersion(t.Context())
66+
67+
if tc.wantErr != "" {
68+
require.Error(err)
69+
assert.Contains(err.Error(), tc.wantErr)
70+
return
71+
}
72+
require.NoError(err)
73+
assert.Equal(tc.wantVersion, version)
74+
})
75+
}
76+
}
77+
78+
// TestNegotiateAPIVersionCaching ensures the Coordinator is only asked once per Client.
79+
func TestNegotiateAPIVersionCaching(t *testing.T) {
80+
require := require.New(t)
81+
assert := assert.New(t)
82+
83+
var calls atomic.Int32
84+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85+
calls.Add(1)
86+
capabilitiesHandler([]string{apiv1.Version}).ServeHTTP(w, r)
87+
}))
88+
t.Cleanup(srv.Close)
89+
90+
client := New().WithBaseURL(srv.URL)
91+
for range 3 {
92+
version, err := client.NegotiateAPIVersion(t.Context())
93+
require.NoError(err)
94+
assert.Equal(apiv1.Version, version)
95+
}
96+
assert.Equal(int32(1), calls.Load())
97+
}
98+
99+
// TestNegotiateAPIVersionErrorNotCached ensures a transient failure doesn't poison the Client.
100+
func TestNegotiateAPIVersionErrorNotCached(t *testing.T) {
101+
require := require.New(t)
102+
103+
var fail atomic.Bool
104+
fail.Store(true)
105+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
if fail.Load() {
107+
w.WriteHeader(http.StatusServiceUnavailable)
108+
return
109+
}
110+
capabilitiesHandler([]string{apiv1.Version}).ServeHTTP(w, r)
111+
}))
112+
t.Cleanup(srv.Close)
113+
114+
client := New().WithBaseURL(srv.URL)
115+
_, err := client.NegotiateAPIVersion(t.Context())
116+
require.Error(err)
117+
118+
fail.Store(false)
119+
version, err := client.NegotiateAPIVersion(t.Context())
120+
require.NoError(err)
121+
require.Equal(apiv1.Version, version)
122+
}
123+
124+
// TestWithAPIVersionSkipsNegotiation ensures a pinned version doesn't contact the Coordinator.
125+
func TestWithAPIVersionSkipsNegotiation(t *testing.T) {
126+
require := require.New(t)
127+
128+
var contacted atomic.Bool
129+
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
130+
contacted.Store(true)
131+
}))
132+
t.Cleanup(srv.Close)
133+
134+
version, err := New().WithBaseURL(srv.URL).WithAPIVersion(apiv1.Version).NegotiateAPIVersion(t.Context())
135+
require.NoError(err)
136+
require.Equal(apiv1.Version, version)
137+
require.False(contacted.Load(), "Coordinator must not be contacted for a pinned API version")
138+
}
139+
140+
func capabilitiesHandler(versions []string) http.Handler {
141+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142+
if r.URL.Path != capabilitiesPath || r.Method != http.MethodGet {
143+
w.WriteHeader(http.StatusNotFound)
144+
return
145+
}
146+
if err := json.NewEncoder(w).Encode(apitypes.CapabilitiesResponse{APIVersions: versions}); err != nil {
147+
panic(err)
148+
}
149+
})
150+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build contrast_unstable_api
5+
6+
// Package transport implements the HTTP plumbing shared by the SDK's API versions.
7+
//
8+
// It's internal so that the versioned API packages can share one connection setup
9+
// without exposing it as part of the SDK's public surface.
10+
package transport
11+
12+
import (
13+
"bytes"
14+
"context"
15+
"encoding/json"
16+
"errors"
17+
"fmt"
18+
"io"
19+
"log/slog"
20+
"net/http"
21+
"net/url"
22+
"strings"
23+
24+
"github.com/edgelesssys/contrast/apitypes"
25+
)
26+
27+
// ErrBaseURLUnset is returned when a request is attempted without a base URL.
28+
var ErrBaseURLUnset = errors.New("no base URL set, use WithBaseURL")
29+
30+
// Client performs JSON requests against the Coordinator's HTTP API.
31+
type Client struct {
32+
// HTTPClient is used to contact the Coordinator.
33+
HTTPClient *http.Client
34+
// BaseURL is the Coordinator's HTTP API root, e.g. "http://coordinator:1314".
35+
BaseURL string
36+
// Log receives diagnostics that aren't part of a returned error.
37+
Log *slog.Logger
38+
}
39+
40+
// URL resolves an API path like "/v1/manifest" against the client's base URL.
41+
func (c *Client) URL(path string) (string, error) {
42+
if c.BaseURL == "" {
43+
return "", ErrBaseURLUnset
44+
}
45+
base, err := url.Parse(c.BaseURL)
46+
if err != nil {
47+
return "", fmt.Errorf("parsing base URL %q: %w", c.BaseURL, err)
48+
}
49+
ref, err := url.Parse(path)
50+
if err != nil {
51+
return "", fmt.Errorf("parsing path %q: %w", path, err)
52+
}
53+
// Ensure a base URL with a path prefix, as used by reverse proxies, keeps that
54+
// prefix: "https://proxy/contrast" + "/v1/manifest" resolves to
55+
// "https://proxy/contrast/v1/manifest".
56+
base.Path = strings.TrimSuffix(base.Path, "/") + ref.EscapedPath()
57+
base.RawQuery = ref.RawQuery
58+
return base.String(), nil
59+
}
60+
61+
// DoJSON sends reqBody JSON-encoded to the given API path and returns the raw response body.
62+
//
63+
// If the Coordinator responds with a non-OK status, the body is parsed as an
64+
// [apitypes.APIError] and returned as an error.
65+
func (c *Client) DoJSON(ctx context.Context, method, path string, reqBody any) ([]byte, error) {
66+
url, err := c.URL(path)
67+
if err != nil {
68+
return nil, err
69+
}
70+
71+
var bodyReader io.Reader
72+
if reqBody != nil {
73+
body, err := json.Marshal(reqBody)
74+
if err != nil {
75+
return nil, fmt.Errorf("creating request body: %w", err)
76+
}
77+
bodyReader = bytes.NewBuffer(body)
78+
}
79+
80+
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
81+
if err != nil {
82+
return nil, fmt.Errorf("constructing HTTP request: %w", err)
83+
}
84+
if reqBody != nil {
85+
req.Header.Set("Content-Type", "application/json")
86+
}
87+
88+
httpResp, err := c.HTTPClient.Do(req)
89+
if err != nil {
90+
return nil, fmt.Errorf("http request failed: %w", err)
91+
}
92+
defer httpResp.Body.Close()
93+
94+
if httpResp.StatusCode != http.StatusOK {
95+
errBody, err := io.ReadAll(httpResp.Body)
96+
if err != nil {
97+
return nil, fmt.Errorf("reading response (status code %d): %w", httpResp.StatusCode, err)
98+
}
99+
details := httpResp.Status
100+
var apiErr apitypes.APIError
101+
if err := json.Unmarshal(errBody, &apiErr); err == nil {
102+
details = apiErr.Err
103+
} else {
104+
c.Log.Error("parsing error response", "err", err, "response", string(errBody))
105+
}
106+
return nil, fmt.Errorf("HTTP API call failed with %d (%s): %s", httpResp.StatusCode, http.StatusText(httpResp.StatusCode), details)
107+
}
108+
109+
resp, err := io.ReadAll(httpResp.Body)
110+
if err != nil {
111+
return nil, fmt.Errorf("reading HTTP response body: %w", err)
112+
}
113+
return resp, nil
114+
}

0 commit comments

Comments
 (0)