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

Commit 868a18f

Browse files
author
Dongsu Park
authored
Merge pull request #1642 from dalbani/dynamic-metadata
fleetd: support dynamic metadata
2 parents 3aaa1ab + 5378558 commit 868a18f

12 files changed

+380
-18
lines changed

Documentation/api-v1.md

+24
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,30 @@ The request must not have a body.
202202

203203
A successful response will contain a page of zero or more Machine entities.
204204

205+
### Edit Machine Metadata
206+
207+
Add, change, or remove metadata from one or more machines.
208+
209+
#### Request
210+
211+
```
212+
PATCH /machines HTTP/1.1
213+
214+
[
215+
{ "op": "add", "path": "/<machine_id>/metadata/<name>", "value": <new value> },
216+
{ "op": "remove", "path": "/<machine_id>/metadata/<name>" },
217+
{ "op": "replace", "path": "/<machine_id>/metadata/<name>", "value": <new value> }
218+
]
219+
```
220+
221+
The request body must contain a JSON document in [JSONPatch](http://jsonpatch.com) format. Supported operations are "add", "remove" and "replace". Any number of operations for any number of machines, including machines not currently registered with the cluster, may be included in a single request. All operations will be processed in-order, top to bottom after validation. Modified metadata will persist across a machine leaving and rejoining the cluster.
222+
223+
224+
#### Response
225+
226+
A success in indicated by a `204 No Content`.
227+
Invalid operations, missing values, or improperly formatted paths will result in a `400 Bad Request`.
228+
205229
## Capability Discovery
206230

207231
The v1 fleet API is described by a [discovery document][disco]. Users should generate their client bindings from this document using the appropriate language generator.

Documentation/unit-files-and-scheduling.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ app.service fd1d3e94.../10.0.0.1 active running
210210
```
211211

212212
A machine is not automatically configured with metadata.
213-
A deployer may define machine metadata using the `metadata` [config option][config-option].
213+
A deployer may define machine metadata using the `metadata` [config option][config-option] or via the [HTTP api][http-api].
214214

215215
## Schedule unit next to another unit
216216

@@ -244,6 +244,7 @@ MachineOf=%p.socket
244244
would result in an effective `MachineOf` of `foo.socket`. Using the same unit snippet with a Unit called `bar.service`, on the other hand, would result in an effective `MachineOf` of `bar.socket`.
245245

246246
[config-option]: deployment-and-configuration.md#metadata
247+
[http-api]: api-v1.md#edit-machine-metadata
247248
[systemd-guide]: https://github.com/coreos/docs/blob/master/os/getting-started-with-systemd.md
248249
[systemd instances]: http://0pointer.de/blog/projects/instances.html
249250
[systemd specifiers]: http://www.freedesktop.org/software/systemd/man/systemd.unit.html#Specifiers

agent/reconcile.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,13 @@ func desiredAgentState(a *Agent, reg registry.Registry) (*AgentState, error) {
127127
return nil, err
128128
}
129129

130-
ms := a.Machine.State()
130+
// fetch full machine state from registry instead of
131+
// using the local version to allow for dynamic metadata
132+
ms, err := reg.MachineState(a.Machine.State().ID)
133+
if err != nil {
134+
log.Errorf("Failed fetching machine state from Registry: %v", err)
135+
return nil, err
136+
}
131137
as := AgentState{
132138
MState: &ms,
133139
Units: make(map[string]*job.Unit),

agent/reconcile_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ MachineMetadata=dog=woof`),
254254
reg := registry.NewFakeRegistry()
255255
reg.SetJobs(tt.regJobs)
256256
a := makeAgentWithMetadata(tt.metadata)
257+
reg.SetMachines([]machine.MachineState{a.Machine.State()})
257258
as, err := desiredAgentState(a, reg)
258259
if err != nil {
259260
t.Errorf("case %d: unexpected error: %v", i, err)

api/machines.go

+70-4
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, tokenLimit int, cAPI client.API) {
2935
res := path.Join(prefix, "machines")
3036
mr := machinesResource{cAPI, uint16(tokenLimit)}
@@ -36,12 +42,24 @@ type machinesResource struct {
3642
tokenLimit uint16
3743
}
3844

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

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

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

api/machines_test.go

+132-2
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,29 @@ 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, tokenLimit: testTokenLimit}
3738
rw := httptest.NewRecorder()
39+
40+
return resource, rw
41+
}
42+
43+
func TestMachinesList(t *testing.T) {
44+
resource, rw := fakeMachinesSetup()
3845
req, err := http.NewRequest("GET", "http://example.com", nil)
3946
if err != nil {
4047
t.Fatalf("Failed creating http.Request: %v", err)
@@ -136,3 +143,126 @@ func TestExtractMachinePage(t *testing.T) {
136143
}
137144
}
138145
}
146+
147+
func TestMachinesPatchAddModify(t *testing.T) {
148+
reqBody := `
149+
[{"op": "add", "path": "/XXX/metadata/foo", "value": "bar"},
150+
{"op": "replace", "path": "/YYY/metadata/ping", "value": "splat"}]
151+
`
152+
153+
resource, rw := fakeMachinesSetup()
154+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
155+
if err != nil {
156+
t.Fatalf("Failed creating http.Request: %v", err)
157+
}
158+
159+
resource.ServeHTTP(rw, req)
160+
if rw.Code != http.StatusNoContent {
161+
t.Errorf("Expected 204, got %d", rw.Code)
162+
}
163+
164+
// fetch machine to make sure data has been added
165+
req, err = http.NewRequest("GET", "http://example.com/machines", nil)
166+
if err != nil {
167+
t.Fatalf("Failed creating http.Request: %v", err)
168+
}
169+
rw.Body.Reset()
170+
resource.ServeHTTP(rw, req)
171+
172+
if rw.Body == nil {
173+
t.Error("Received nil response body")
174+
} else {
175+
body := rw.Body.String()
176+
expected := `{"machines":[{"id":"XXX","metadata":{"foo":"bar"}},{"id":"YYY","metadata":{"ping":"splat"},"primaryIP":"1.2.3.4"}]}`
177+
if body != expected {
178+
t.Errorf("Expected body:\n%s\n\nReceived body:\n%s\n", expected, body)
179+
}
180+
}
181+
}
182+
183+
func TestMachinesPatchDelete(t *testing.T) {
184+
reqBody := `
185+
[{"op": "remove", "path": "/XXX/metadata/foo"},
186+
{"op": "remove", "path": "/YYY/metadata/ping"}]
187+
`
188+
189+
resource, rw := fakeMachinesSetup()
190+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
191+
if err != nil {
192+
t.Fatalf("Failed creating http.Request: %v", err)
193+
}
194+
195+
resource.ServeHTTP(rw, req)
196+
if rw.Code != http.StatusNoContent {
197+
t.Errorf("Expected 204, got %d", rw.Code)
198+
}
199+
200+
// fetch machine to make sure data has been added
201+
req, err = http.NewRequest("GET", "http://example.com/machines", nil)
202+
if err != nil {
203+
t.Fatalf("Failed creating http.Request: %v", err)
204+
}
205+
rw.Body.Reset()
206+
resource.ServeHTTP(rw, req)
207+
208+
if rw.Body == nil {
209+
t.Error("Received nil response body")
210+
} else {
211+
body := rw.Body.String()
212+
expected := `{"machines":[{"id":"XXX"},{"id":"YYY","primaryIP":"1.2.3.4"}]}`
213+
if body != expected {
214+
t.Errorf("Expected body:\n%s\n\nReceived body:\n%s\n", expected, body)
215+
}
216+
}
217+
}
218+
219+
func TestMachinesPatchBadOp(t *testing.T) {
220+
reqBody := `
221+
[{"op": "noop", "path": "/XXX/metadata/foo", "value": "bar"}]
222+
`
223+
224+
resource, rw := fakeMachinesSetup()
225+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
226+
if err != nil {
227+
t.Fatalf("Failed creating http.Request: %v", err)
228+
}
229+
230+
resource.ServeHTTP(rw, req)
231+
if rw.Code != http.StatusBadRequest {
232+
t.Errorf("Expected 400, got %d", rw.Code)
233+
}
234+
}
235+
236+
func TestMachinesPatchBadPath(t *testing.T) {
237+
reqBody := `
238+
[{"op": "add", "path": "/XXX/foo", "value": "bar"}]
239+
`
240+
241+
resource, rw := fakeMachinesSetup()
242+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
243+
if err != nil {
244+
t.Fatalf("Failed creating http.Request: %v", err)
245+
}
246+
247+
resource.ServeHTTP(rw, req)
248+
if rw.Code != http.StatusBadRequest {
249+
t.Errorf("Expected 400, got %d", rw.Code)
250+
}
251+
}
252+
253+
func TestMachinesPatchBadValue(t *testing.T) {
254+
reqBody := `
255+
[{"op": "add", "path": "/XXX/foo"}]
256+
`
257+
258+
resource, rw := fakeMachinesSetup()
259+
req, err := http.NewRequest("PATCH", "http://example.com/machines", strings.NewReader(reqBody))
260+
if err != nil {
261+
t.Fatalf("Failed creating http.Request: %v", err)
262+
}
263+
264+
resource.ServeHTTP(rw, req)
265+
if rw.Code != http.StatusBadRequest {
266+
t.Errorf("Expected 400, got %d", rw.Code)
267+
}
268+
}

client/api.go

+2
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, key, value string) error
25+
DeleteMachineMetadata(machID, key string) error
2426

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

registry/fake.go

+31
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,37 @@ func (f *FakeRegistry) UnitHeartbeat(name, machID string, ttl time.Duration) err
314314

315315
func (f *FakeRegistry) ClearUnitHeartbeat(string) {}
316316

317+
func (f *FakeRegistry) SetMachineMetadata(machID string, key string, value string) error {
318+
for _, mach := range f.machines {
319+
if mach.ID == machID {
320+
mach.Metadata[key] = value
321+
}
322+
}
323+
return nil
324+
}
325+
326+
func (f *FakeRegistry) DeleteMachineMetadata(machID string, key string) error {
327+
for _, mach := range f.machines {
328+
if mach.ID == machID {
329+
delete(mach.Metadata, key)
330+
}
331+
}
332+
return nil
333+
}
334+
335+
func (f *FakeRegistry) MachineState(machID string) (machine.MachineState, error) {
336+
f.RLock()
337+
defer f.RUnlock()
338+
339+
for _, mach := range f.machines {
340+
if mach.ID == machID {
341+
return mach, nil
342+
}
343+
}
344+
345+
return machine.MachineState{}, errors.New("Machine state not found")
346+
}
347+
317348
func NewFakeClusterRegistry(dVersion *semver.Version, eVersion int) *FakeClusterRegistry {
318349
return &FakeClusterRegistry{
319350
dVersion: dVersion,

registry/interface.go

+3
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ type Registry interface {
3737
ScheduleUnit(name, machID string) error
3838
SetUnitTargetState(name string, state job.JobState) error
3939
SetMachineState(ms machine.MachineState, ttl time.Duration) (uint64, error)
40+
MachineState(machID string) (machine.MachineState, error)
4041
UnscheduleUnit(name, machID string) error
42+
SetMachineMetadata(machID string, key string, value string) error
43+
DeleteMachineMetadata(machID string, key string) error
4144

4245
IsRegistryReady() bool
4346
UseEtcdRegistry() bool

0 commit comments

Comments
 (0)