Skip to content
This repository was archived by the owner on Jan 30, 2020. It is now read-only.

Commit 70a739d

Browse files
committed
api: allow machine metadata to be updated via the http api
1 parent e10c6b7 commit 70a739d

8 files changed

Lines changed: 325 additions & 21 deletions

File tree

api/machines.go

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,22 @@
1515
package api
1616

1717
import (
18-
"fmt"
18+
"encoding/json"
19+
"errors"
1920
"net/http"
2021
"path"
22+
"regexp"
2123

2224
"github.com/coreos/fleet/client"
2325
"github.com/coreos/fleet/log"
2426
"github.com/coreos/fleet/machine"
2527
"github.com/coreos/fleet/schema"
2628
)
2729

30+
var (
31+
metadataPathRegex = regexp.MustCompile("^/([^/]+)/metadata/([A-Za-z0-9_.-]+$)")
32+
)
33+
2834
func wireUpMachinesResource(mux *http.ServeMux, prefix string, cAPI client.API) {
2935
res := path.Join(prefix, "machines")
3036
mr := machinesResource{cAPI}
@@ -35,12 +41,24 @@ type machinesResource struct {
3541
cAPI client.API
3642
}
3743

44+
type machineMetadataOp struct {
45+
Operation string `json:"op"`
46+
Path string
47+
Value string
48+
}
49+
3850
func (mr *machinesResource) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
39-
if req.Method != "GET" {
40-
sendError(rw, http.StatusBadRequest, fmt.Errorf("only HTTP GET supported against this resource"))
41-
return
51+
switch req.Method {
52+
case "GET":
53+
mr.list(rw, req)
54+
case "PATCH":
55+
mr.patch(rw, req)
56+
default:
57+
sendError(rw, http.StatusMethodNotAllowed, errors.New("only GET and PATCH supported against this resource"))
4258
}
59+
}
4360

61+
func (mr *machinesResource) list(rw http.ResponseWriter, req *http.Request) {
4462
token, err := findNextPageToken(req.URL)
4563
if err != nil {
4664
sendError(rw, http.StatusBadRequest, err)
@@ -62,6 +80,54 @@ func (mr *machinesResource) ServeHTTP(rw http.ResponseWriter, req *http.Request)
6280
sendResponse(rw, http.StatusOK, page)
6381
}
6482

83+
func (mr *machinesResource) patch(rw http.ResponseWriter, req *http.Request) {
84+
ops := make([]machineMetadataOp, 0)
85+
dec := json.NewDecoder(req.Body)
86+
if err := dec.Decode(&ops); err != nil {
87+
sendError(rw, http.StatusBadRequest, err)
88+
return
89+
}
90+
91+
for _, op := range ops {
92+
if op.Operation != "add" && op.Operation != "remove" && op.Operation != "replace" {
93+
sendError(rw, http.StatusBadRequest, errors.New("invalid op: expect add, remove, or replace"))
94+
return
95+
}
96+
97+
if metadataPathRegex.FindStringSubmatch(op.Path) == nil {
98+
sendError(rw, http.StatusBadRequest, errors.New("machine metadata path invalid"))
99+
return
100+
}
101+
102+
if op.Operation != "remove" && len(op.Value) == 0 {
103+
sendError(rw, http.StatusBadRequest, errors.New("invalid value: add and replace require a value"))
104+
return
105+
}
106+
}
107+
108+
for _, op := range ops {
109+
// regex already validated above
110+
s := metadataPathRegex.FindStringSubmatch(op.Path)
111+
machID := s[1]
112+
key := s[2]
113+
114+
if op.Operation == "remove" {
115+
err := mr.cAPI.DeleteMachineMetadata(machID, key)
116+
if err != nil {
117+
sendError(rw, http.StatusInternalServerError, err)
118+
return
119+
}
120+
} else {
121+
err := mr.cAPI.SetMachineMetadata(machID, key, op.Value)
122+
if err != nil {
123+
sendError(rw, http.StatusInternalServerError, err)
124+
return
125+
}
126+
}
127+
}
128+
sendResponse(rw, http.StatusNoContent, "")
129+
}
130+
65131
func getMachinePage(cAPI client.API, tok PageToken) (*schema.MachinePage, error) {
66132
all, err := cAPI.Machines()
67133
if err != nil {

api/machines_test.go

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,30 @@ import (
1919
"net/http/httptest"
2020
"reflect"
2121
"strconv"
22+
"strings"
2223
"testing"
2324

2425
"github.com/coreos/fleet/client"
2526
"github.com/coreos/fleet/machine"
2627
"github.com/coreos/fleet/registry"
2728
)
2829

29-
func TestMachinesList(t *testing.T) {
30+
func fakeMachinesSetup() (*machinesResource, *httptest.ResponseRecorder) {
3031
fr := registry.NewFakeRegistry()
3132
fr.SetMachines([]machine.MachineState{
32-
{ID: "XXX", PublicIP: "", Metadata: nil},
33+
{ID: "XXX", PublicIP: "", Metadata: map[string]string{}},
3334
{ID: "YYY", PublicIP: "1.2.3.4", Metadata: map[string]string{"ping": "pong"}},
3435
})
3536
fAPI := &client.RegistryClient{Registry: fr}
3637
resource := &machinesResource{cAPI: fAPI}
3738
rw := httptest.NewRecorder()
38-
req, err := http.NewRequest("GET", "http://example.com", nil)
39+
40+
return resource, rw
41+
}
42+
43+
func TestMachinesList(t *testing.T) {
44+
resource, rw := fakeMachinesSetup()
45+
req, err := http.NewRequest("GET", "http://example.com/machines", nil)
3946
if err != nil {
4047
t.Fatalf("Failed creating http.Request: %v", err)
4148
}
@@ -63,11 +70,23 @@ func TestMachinesList(t *testing.T) {
6370
}
6471
}
6572

73+
func TestMachinesListBadMethod(t *testing.T) {
74+
resource, rw := fakeMachinesSetup()
75+
req, err := http.NewRequest("POST", "http://example.com/machines", nil)
76+
if err != nil {
77+
t.Fatalf("Failed creating http.Request: %v", err)
78+
}
79+
80+
resource.ServeHTTP(rw, req)
81+
82+
err = assertErrorResponse(rw, http.StatusMethodNotAllowed)
83+
if err != nil {
84+
t.Error(err.Error())
85+
}
86+
}
87+
6688
func TestMachinesListBadNextPageToken(t *testing.T) {
67-
fr := registry.NewFakeRegistry()
68-
fAPI := &client.RegistryClient{Registry: fr}
69-
resource := &machinesResource{fAPI}
70-
rw := httptest.NewRecorder()
89+
resource, rw := fakeMachinesSetup()
7190
req, err := http.NewRequest("GET", "http://example.com/machines?nextPageToken=EwBMLg==", nil)
7291
if err != nil {
7392
t.Fatalf("Failed creating http.Request: %v", err)
@@ -136,3 +155,126 @@ func TestExtractMachinePage(t *testing.T) {
136155
}
137156
}
138157
}
158+
159+
func TestMachinesPatchAddModify(t *testing.T) {
160+
reqBody := `
161+
[{"op": "add", "path": "/XXX/metadata/foo", "value": "bar"},
162+
{"op": "replace", "path": "/YYY/metadata/ping", "value": "splat"}]
163+
`
164+
165+
resource, rw := fakeMachinesSetup()
166+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
167+
if err != nil {
168+
t.Fatalf("Failed creating http.Request: %v", err)
169+
}
170+
171+
resource.ServeHTTP(rw, req)
172+
if rw.Code != http.StatusNoContent {
173+
t.Errorf("Expected 204, got %d", rw.Code)
174+
}
175+
176+
// fetch machine to make sure data has been added
177+
req, err = http.NewRequest("GET", "http://example.com/machines", nil)
178+
if err != nil {
179+
t.Fatalf("Failed creating http.Request: %v", err)
180+
}
181+
rw.Body.Reset()
182+
resource.ServeHTTP(rw, req)
183+
184+
if rw.Body == nil {
185+
t.Error("Received nil response body")
186+
} else {
187+
body := rw.Body.String()
188+
expected := `{"machines":[{"id":"XXX","metadata":{"foo":"bar"}},{"id":"YYY","metadata":{"ping":"splat"},"primaryIP":"1.2.3.4"}]}`
189+
if body != expected {
190+
t.Errorf("Expected body:\n%s\n\nReceived body:\n%s\n", expected, body)
191+
}
192+
}
193+
}
194+
195+
func TestMachinesPatchDelete(t *testing.T) {
196+
reqBody := `
197+
[{"op": "remove", "path": "/XXX/metadata/foo"},
198+
{"op": "remove", "path": "/YYY/metadata/ping"}]
199+
`
200+
201+
resource, rw := fakeMachinesSetup()
202+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
203+
if err != nil {
204+
t.Fatalf("Failed creating http.Request: %v", err)
205+
}
206+
207+
resource.ServeHTTP(rw, req)
208+
if rw.Code != http.StatusNoContent {
209+
t.Errorf("Expected 204, got %d", rw.Code)
210+
}
211+
212+
// fetch machine to make sure data has been added
213+
req, err = http.NewRequest("GET", "http://example.com/machines", nil)
214+
if err != nil {
215+
t.Fatalf("Failed creating http.Request: %v", err)
216+
}
217+
rw.Body.Reset()
218+
resource.ServeHTTP(rw, req)
219+
220+
if rw.Body == nil {
221+
t.Error("Received nil response body")
222+
} else {
223+
body := rw.Body.String()
224+
expected := `{"machines":[{"id":"XXX"},{"id":"YYY","primaryIP":"1.2.3.4"}]}`
225+
if body != expected {
226+
t.Errorf("Expected body:\n%s\n\nReceived body:\n%s\n", expected, body)
227+
}
228+
}
229+
}
230+
231+
func TestMachinesPatchBadOp(t *testing.T) {
232+
reqBody := `
233+
[{"op": "noop", "path": "/XXX/metadata/foo", "value": "bar"}]
234+
`
235+
236+
resource, rw := fakeMachinesSetup()
237+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
238+
if err != nil {
239+
t.Fatalf("Failed creating http.Request: %v", err)
240+
}
241+
242+
resource.ServeHTTP(rw, req)
243+
if rw.Code != http.StatusBadRequest {
244+
t.Errorf("Expected 400, got %d", rw.Code)
245+
}
246+
}
247+
248+
func TestMachinesPatchBadPath(t *testing.T) {
249+
reqBody := `
250+
[{"op": "add", "path": "/XXX/foo", "value": "bar"}]
251+
`
252+
253+
resource, rw := fakeMachinesSetup()
254+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
255+
if err != nil {
256+
t.Fatalf("Failed creating http.Request: %v", err)
257+
}
258+
259+
resource.ServeHTTP(rw, req)
260+
if rw.Code != http.StatusBadRequest {
261+
t.Errorf("Expected 400, got %d", rw.Code)
262+
}
263+
}
264+
265+
func TestMachinesPatchBadValue(t *testing.T) {
266+
reqBody := `
267+
[{"op": "add", "path": "/XXX/foo"}]
268+
`
269+
270+
resource, rw := fakeMachinesSetup()
271+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
272+
if err != nil {
273+
t.Fatalf("Failed creating http.Request: %v", err)
274+
}
275+
276+
resource.ServeHTTP(rw, req)
277+
if rw.Code != http.StatusBadRequest {
278+
t.Errorf("Expected 400, got %d", rw.Code)
279+
}
280+
}

client/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121

2222
type API interface {
2323
Machines() ([]machine.MachineState, error)
24+
SetMachineMetadata(machID string, key string, value string) error
25+
DeleteMachineMetadata(machID string, key string) error
2426

2527
Unit(string) (*schema.Unit, error)
2628
Units() ([]*schema.Unit, error)

machine/state.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const (
2323
type MachineState struct {
2424
ID string
2525
PublicIP string
26-
Metadata map[string]string
26+
Metadata map[string]string `json:"-"`
2727
Version string
2828
}
2929

registry/fake.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,24 @@ func (f *FakeRegistry) UnitHeartbeat(name, machID string, ttl time.Duration) err
279279

280280
func (f *FakeRegistry) ClearUnitHeartbeat(string) {}
281281

282+
func (f *FakeRegistry) SetMachineMetadata(machID string, key string, value string) error {
283+
for _, mach := range f.machines {
284+
if mach.ID == machID {
285+
mach.Metadata[key] = value
286+
}
287+
}
288+
return nil
289+
}
290+
291+
func (f *FakeRegistry) DeleteMachineMetadata(machID string, key string) error {
292+
for _, mach := range f.machines {
293+
if mach.ID == machID {
294+
delete(mach.Metadata, key)
295+
}
296+
}
297+
return nil
298+
}
299+
282300
func NewFakeClusterRegistry(dVersion *semver.Version, eVersion int) *FakeClusterRegistry {
283301
return &FakeClusterRegistry{
284302
dVersion: dVersion,

registry/interface.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type Registry interface {
3737
SetUnitTargetState(name string, state job.JobState) error
3838
SetMachineState(ms machine.MachineState, ttl time.Duration) (uint64, error)
3939
UnscheduleUnit(name, machID string) error
40+
SetMachineMetadata(machID, key, value string) error
41+
DeleteMachineMetadata(machID, key string) error
4042

4143
UnitRegistry
4244
}

0 commit comments

Comments
 (0)