Skip to content

Commit 9f7b2c7

Browse files
committed
sdk: add SetManifest
1 parent 1605956 commit 9f7b2c7

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

sdk/apiv1/manifest.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2026 Edgeless Systems GmbH
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
//go:build contrast_unstable_api
5+
6+
package apiv1
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"errors"
12+
"fmt"
13+
"net/http"
14+
15+
"github.com/edgelesssys/contrast/apitypes"
16+
)
17+
18+
// ManifestPath is the path of the manifest endpoint, relative to the API's base URL.
19+
const ManifestPath = "/v1/manifest"
20+
21+
// SetManifest sets a manifest at the Coordinator and returns the deployment's CA certificates.
22+
//
23+
// The returned SeedSharesDoc is only set if this call set the initial manifest. It holds the
24+
// secret seed, encrypted for each seedshare owner, and is required to recover the deployment.
25+
//
26+
// Note: this function does not verify that the Coordinator is trustworthy! Callers should
27+
// verify it via the SDK's GetAttestation and ValidateAttestation before relying on the response.
28+
func (a *API) SetManifest(ctx context.Context, req *apitypes.SetManifestRequest) (*apitypes.SetManifestResponse, error) {
29+
if req == nil {
30+
return nil, errors.New("request must not be nil")
31+
}
32+
33+
body, err := a.transport.DoJSON(ctx, http.MethodPost, ManifestPath, req)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
var resp apitypes.SetManifestResponse
39+
if err := json.Unmarshal(body, &resp); err != nil {
40+
return nil, fmt.Errorf("unmarshalling response: %w", err)
41+
}
42+
return &resp, nil
43+
}

sdk/manifest.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
"fmt"
11+
12+
"github.com/edgelesssys/contrast/apitypes"
13+
"github.com/edgelesssys/contrast/sdk/apiv1"
14+
)
15+
16+
// SetManifest sets a manifest at the Coordinator and returns the deployment's CA certificates.
17+
//
18+
// It speaks the newest API version supported by both this SDK and the Coordinator, determined
19+
// once per Client via [Client.NegotiateAPIVersion]. To pin a version instead, call the
20+
// corresponding versioned API, e.g. c.V1().SetManifest.
21+
//
22+
// The returned SeedSharesDoc is only set if this call set the initial manifest. It holds the
23+
// secret seed, encrypted for each seedshare owner, and is required to recover the deployment.
24+
//
25+
// Note: this function does not verify that the Coordinator is trustworthy! Callers should
26+
// verify it via [Client.GetAttestation] and [Client.ValidateAttestation] before relying on the
27+
// response.
28+
func (c Client) SetManifest(ctx context.Context, req *apitypes.SetManifestRequest) (*apitypes.SetManifestResponse, error) {
29+
version, err := c.NegotiateAPIVersion(ctx)
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
switch version {
35+
case apiv1.Version:
36+
return c.V1().SetManifest(ctx, req)
37+
default:
38+
return nil, fmt.Errorf("SetManifest is not implemented for API version %q", version)
39+
}
40+
}

sdk/manifest_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
"testing"
13+
14+
"github.com/edgelesssys/contrast/apitypes"
15+
"github.com/edgelesssys/contrast/internal/constants"
16+
"github.com/edgelesssys/contrast/sdk/apiv1"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
func TestSetManifest(t *testing.T) {
22+
request := &apitypes.SetManifestRequest{
23+
Manifest: []byte(`{"foo":"bar"}`),
24+
Policies: [][]byte{[]byte("policy1")},
25+
Signature: []byte("signature"),
26+
}
27+
28+
for name, tc := range map[string]struct {
29+
request *apitypes.SetManifestRequest
30+
coordinatorVersions []string
31+
// pinned selects an explicitly versioned call instead of the negotiating one.
32+
pinned bool
33+
34+
wantErr string
35+
}{
36+
"negotiated": {
37+
request: request,
38+
coordinatorVersions: []string{apiv1.Version},
39+
},
40+
"pinned to v1": {
41+
request: request,
42+
pinned: true,
43+
},
44+
"nil request": {
45+
coordinatorVersions: []string{apiv1.Version},
46+
wantErr: "request must not be nil",
47+
},
48+
"no common API version": {
49+
request: request,
50+
coordinatorVersions: []string{"v99"},
51+
wantErr: "no common API version",
52+
},
53+
} {
54+
t.Run(name, func(t *testing.T) {
55+
require := require.New(t)
56+
assert := assert.New(t)
57+
58+
srv := httptest.NewServer(coordinatorHandler(tc.coordinatorVersions))
59+
t.Cleanup(srv.Close)
60+
61+
client := New().WithBaseURL(srv.URL)
62+
63+
var resp *apitypes.SetManifestResponse
64+
var err error
65+
if tc.pinned {
66+
resp, err = client.V1().SetManifest(t.Context(), tc.request)
67+
} else {
68+
resp, err = client.SetManifest(t.Context(), tc.request)
69+
}
70+
71+
if tc.wantErr != "" {
72+
require.Error(err)
73+
assert.Contains(err.Error(), tc.wantErr)
74+
assert.Nil(resp)
75+
return
76+
}
77+
78+
require.NoError(err)
79+
require.NotNil(resp)
80+
assert.Equal(constants.Version, resp.Version)
81+
assert.Equal([]byte("root-ca"), resp.RootCA)
82+
assert.Equal([]byte("mesh-ca"), resp.MeshCA)
83+
require.NotNil(resp.SeedSharesDoc)
84+
require.Len(resp.SeedSharesDoc.SeedShares, 1)
85+
assert.Equal("public-key", resp.SeedSharesDoc.SeedShares[0].PublicKey)
86+
})
87+
}
88+
}
89+
90+
// TestSetManifestRequestEncoding ensures the request reaches the Coordinator unchanged,
91+
// at the versioned path.
92+
func TestSetManifestRequestEncoding(t *testing.T) {
93+
require := require.New(t)
94+
assert := assert.New(t)
95+
96+
want := &apitypes.SetManifestRequest{
97+
Manifest: []byte(`{"foo":"bar"}`),
98+
Policies: [][]byte{[]byte("policy1"), []byte("policy2")},
99+
PreviousTransitionHash: []byte("previous-transition-hash"),
100+
Signature: []byte("signature"),
101+
}
102+
103+
var got apitypes.SetManifestRequest
104+
var gotPath, gotMethod, gotContentType string
105+
mux := http.NewServeMux()
106+
mux.Handle(capabilitiesPath, capabilitiesHandler([]string{apiv1.Version}))
107+
mux.HandleFunc(apiv1.ManifestPath, func(w http.ResponseWriter, r *http.Request) {
108+
defer r.Body.Close()
109+
gotPath, gotMethod, gotContentType = r.URL.Path, r.Method, r.Header.Get("Content-Type")
110+
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
111+
w.WriteHeader(http.StatusBadRequest)
112+
return
113+
}
114+
setManifestResponse(w)
115+
})
116+
srv := httptest.NewServer(mux)
117+
t.Cleanup(srv.Close)
118+
119+
_, err := New().WithBaseURL(srv.URL).SetManifest(t.Context(), want)
120+
require.NoError(err)
121+
122+
assert.Equal(apiv1.ManifestPath, gotPath)
123+
assert.Equal(http.MethodPost, gotMethod)
124+
assert.Equal("application/json", gotContentType)
125+
assert.Equal(*want, got)
126+
}
127+
128+
// TestSetManifestBaseURLWithPathPrefix ensures a reverse-proxied base URL keeps its prefix.
129+
func TestSetManifestBaseURLWithPathPrefix(t *testing.T) {
130+
require := require.New(t)
131+
132+
var gotPath string
133+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
gotPath = r.URL.Path
135+
setManifestResponse(w)
136+
}))
137+
t.Cleanup(srv.Close)
138+
139+
_, err := New().WithBaseURL(srv.URL+"/contrast").WithAPIVersion(apiv1.Version).
140+
SetManifest(t.Context(), &apitypes.SetManifestRequest{Manifest: []byte("{}")})
141+
require.NoError(err)
142+
require.Equal("/contrast"+apiv1.ManifestPath, gotPath)
143+
}
144+
145+
// TestNoBaseURL ensures calls fail with a helpful error if no base URL was set.
146+
func TestNoBaseURL(t *testing.T) {
147+
_, err := New().SetManifest(t.Context(), &apitypes.SetManifestRequest{})
148+
require.ErrorContains(t, err, "no base URL set")
149+
}
150+
151+
// coordinatorHandler serves both the capabilities and the v1 manifest endpoint.
152+
func coordinatorHandler(versions []string) http.Handler {
153+
mux := http.NewServeMux()
154+
mux.Handle(capabilitiesPath, capabilitiesHandler(versions))
155+
mux.HandleFunc(apiv1.ManifestPath, func(w http.ResponseWriter, r *http.Request) {
156+
defer r.Body.Close()
157+
setManifestResponse(w)
158+
})
159+
return mux
160+
}
161+
162+
func setManifestResponse(w http.ResponseWriter) {
163+
resp := &apitypes.SetManifestResponse{
164+
Version: constants.Version,
165+
RootCA: []byte("root-ca"),
166+
MeshCA: []byte("mesh-ca"),
167+
SeedSharesDoc: &apitypes.SeedShareDocument{
168+
Salt: []byte("salt"),
169+
SeedShares: []apitypes.SeedShare{{
170+
PublicKey: "public-key",
171+
EncryptedSeed: []byte("encrypted-seed"),
172+
}},
173+
},
174+
}
175+
if err := json.NewEncoder(w).Encode(resp); err != nil {
176+
panic(err)
177+
}
178+
}

0 commit comments

Comments
 (0)