Skip to content

Commit cd4bc47

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

13 files changed

Lines changed: 690 additions & 116 deletions

File tree

apitypes/apitypes.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,33 @@
66
// The types here define the request/response bodies exchanged with the Coordinator.
77
package apitypes
88

9+
import (
10+
"fmt"
11+
"net/http"
12+
)
13+
914
// Port is the listening port of the HTTP API server.
1015
const Port = "1314"
1116

1217
// APIError is the body returned by the Contrast HTTP API if a request was not successful.
18+
//
19+
// It implements [error], so clients can inspect a failed API call with [errors.As]:
20+
//
21+
// _, err := client.GetAttestation(ctx, nonce)
22+
// var apiErr *apitypes.APIError
23+
// if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusPreconditionFailed {
24+
// // The Coordinator has no manifest yet.
25+
// }
1326
type APIError struct {
1427
// Version is the Coordinator version.
15-
Version string `json:"version"`
16-
StatusCode int `json:"status_code"`
17-
Err string `json:"error"`
28+
Version string `json:"version"`
29+
// StatusCode is the HTTP status code of the response.
30+
StatusCode int `json:"status_code"`
31+
// Err is the error message.
32+
Err string `json:"error"`
33+
}
34+
35+
// Error implements the error interface.
36+
func (e *APIError) Error() string {
37+
return fmt.Sprintf("HTTP API call failed with %d (%s): %s", e.StatusCode, http.StatusText(e.StatusCode), e.Err)
1838
}

coordinator/internal/httpapi/attest.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,9 @@ func writeJSONError(w http.ResponseWriter, status int, err error) {
149149
w.WriteHeader(status)
150150

151151
apiErr := &apitypes.AttestationError{
152-
Version: constants.Version,
153-
Err: err.Error(),
152+
Version: constants.Version,
153+
StatusCode: status,
154+
Err: err.Error(),
154155
}
155156
if errEncode := json.NewEncoder(w).Encode(apiErr); errEncode != nil {
156157
log.Printf("encoding error response %v failed: %v", err, errEncode)

coordinator/internal/httpapi/attest_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ func TestAttestationHandler(t *testing.T) {
169169
var apiErr apitypes.AttestationError
170170
require.NoError(json.NewDecoder(res.Body).Decode(&apiErr))
171171
require.Contains(apiErr.Err, tc.expErr.Error())
172+
require.Equal(tc.expStatus, apiErr.StatusCode)
172173
} else if res.StatusCode == http.StatusOK {
173174
var resp apitypes.AttestationResponse
174175
require.NoError(json.NewDecoder(res.Body).Decode(&resp))

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/httpapi"
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+
httpapi *httpapi.Client
24+
}
25+
26+
// New returns an [API] issuing its requests through the given HTTP API client.
27+
func New(c *httpapi.Client) *API {
28+
return &API{httpapi: c}
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 first successful result is cached, so this costs at most one successful 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.httpapi.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.httpapi)
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+
}

0 commit comments

Comments
 (0)