bootcfg/api: Switch from api types to storagepb types

* Remove api.Store and use storagepb.Store instead
* Remove api.Spec and use storagepb.Profile instead
* Switch from api.Group to storagepb.Group
* Move api.Group to config for YAML config decoding only
This commit is contained in:
Dalton Hubble
2016-03-10 18:07:10 -08:00
parent c98a0b74de
commit 0d148581b9
34 changed files with 389 additions and 701 deletions

View File

@@ -2,6 +2,7 @@
## Latest
* Remove HTTP `/spec/id` JSON endpoint
* Add detached OpenPGP signature endpoints (`.sig`)
## v0.2.0 (2016-02-09)

View File

@@ -1,11 +0,0 @@
package api
// BootConfig defines a kernel image, kernel options, and initrds to boot.
type BootConfig struct {
// the URL of the kernel image
Kernel string `json:"kernel"`
// the init RAM filesystem URLs
Initrd []string `json:"initrd"`
// command line kernel options
Cmdline map[string]interface{} `json:"cmdline"`
}

View File

@@ -2,10 +2,12 @@ package api
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
cloudinit "github.com/coreos/coreos-cloudinit/config"
"golang.org/x/net/context"
)
@@ -17,28 +19,31 @@ type CloudConfig struct {
// cloudHandler returns a handler that responds with the cloud config for the
// requester.
func cloudHandler(store Store) ContextHandler {
func cloudHandler(store storage.Store) ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
group, err := groupFromContext(ctx)
if err != nil || group.Spec == "" {
if err != nil || group.Profile == "" {
http.NotFound(w, req)
return
}
spec, err := store.Spec(group.Spec)
if err != nil || spec.CloudConfig == "" {
profile, err := store.ProfileGet(group.Profile)
if err != nil || profile.CloudId == "" {
http.NotFound(w, req)
return
}
contents, err := store.CloudConfig(spec.CloudConfig)
contents, err := store.CloudGet(profile.CloudId)
if err != nil {
http.NotFound(w, req)
return
}
// collect data for rendering
data := make(map[string]interface{})
for k := range group.Metadata {
data[k] = group.Metadata[k]
var data map[string]interface{}
err = json.Unmarshal(group.Metadata, &data)
if err != nil {
log.Error("error unmarshalling metadata")
http.NotFound(w, req)
return
}
// render the template of a cloud config with data

View File

@@ -5,28 +5,29 @@ import (
"net/http/httptest"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
"golang.org/x/net/context"
)
func TestCloudHandler(t *testing.T) {
cloudContent := "#cloud-config"
content := "#cloud-config"
store := &fixedStore{
Specs: map[string]*Spec{testGroup.Spec: testSpec},
CloudConfigs: map[string]string{testSpec.CloudConfig: cloudContent},
Profiles: map[string]*storagepb.Profile{testGroup.Profile: testProfile},
CloudConfigs: map[string]string{testProfile.CloudId: content},
}
h := cloudHandler(store)
ctx := withGroup(context.Background(), &testGroup)
ctx := withGroup(context.Background(), testGroup)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)
// assert that:
// - the Spec's cloud config is served
// - Cloud config is rendered with Group metadata
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, cloudContent, w.Body.String())
assert.Equal(t, content, w.Body.String())
}
func TestCloudHandler_MissingCtxSpec(t *testing.T) {
func TestCloudHandler_MissingCtxProfile(t *testing.T) {
h := cloudHandler(&emptyStore{})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
@@ -36,7 +37,7 @@ func TestCloudHandler_MissingCtxSpec(t *testing.T) {
func TestCloudHandler_MissingCloudConfig(t *testing.T) {
h := cloudHandler(&emptyStore{})
ctx := withSpec(context.Background(), testSpec)
ctx := withProfile(context.Background(), testProfile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)

View File

@@ -3,6 +3,7 @@ package api
import (
"errors"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"golang.org/x/net/context"
)
@@ -10,37 +11,37 @@ import (
type key int
const (
specKey key = iota
profileKey key = iota
groupKey
)
var (
errNoSpecFromContext = errors.New("api: Context missing a Spec")
errNoGroupFromContext = errors.New("api: Context missing a Group")
errNoProfileFromContext = errors.New("api: Context missing a Profile")
errNoGroupFromContext = errors.New("api: Context missing a Group")
)
// withSpec returns a copy of ctx that stores the given Spec.
func withSpec(ctx context.Context, spec *Spec) context.Context {
return context.WithValue(ctx, specKey, spec)
// withProfile returns a copy of ctx that stores the given Profile.
func withProfile(ctx context.Context, profile *storagepb.Profile) context.Context {
return context.WithValue(ctx, profileKey, profile)
}
// specFromContext returns the Spec from the ctx.
func specFromContext(ctx context.Context) (*Spec, error) {
spec, ok := ctx.Value(specKey).(*Spec)
// profileFromContext returns the Profile from the ctx.
func profileFromContext(ctx context.Context) (*storagepb.Profile, error) {
profile, ok := ctx.Value(profileKey).(*storagepb.Profile)
if !ok {
return nil, errNoSpecFromContext
return nil, errNoProfileFromContext
}
return spec, nil
return profile, nil
}
// withGroup returns a copy of ctx that stores the given Group.
func withGroup(ctx context.Context, group *Group) context.Context {
func withGroup(ctx context.Context, group *storagepb.Group) context.Context {
return context.WithValue(ctx, groupKey, group)
}
// groupFromContext returns the Group from the ctx.
func groupFromContext(ctx context.Context) (*Group, error) {
group, ok := ctx.Value(groupKey).(*Group)
func groupFromContext(ctx context.Context) (*storagepb.Group, error) {
group, ok := ctx.Value(groupKey).(*storagepb.Group)
if !ok {
return nil, errNoGroupFromContext
}

View File

@@ -3,35 +3,36 @@ package api
import (
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
"golang.org/x/net/context"
)
func TestContextSpec(t *testing.T) {
expectedSpec := &Spec{ID: "g1h2i3j4"}
ctx := withSpec(context.Background(), expectedSpec)
spec, err := specFromContext(ctx)
func TestContextProfile(t *testing.T) {
expectedProfile := &storagepb.Profile{Id: "g1h2i3j4"}
ctx := withProfile(context.Background(), expectedProfile)
profile, err := profileFromContext(ctx)
assert.Nil(t, err)
assert.Equal(t, expectedSpec, spec)
assert.Equal(t, expectedProfile, profile)
}
func TestContextSpec_Error(t *testing.T) {
spec, err := specFromContext(context.Background())
assert.Nil(t, spec)
func TestContextProfile_Error(t *testing.T) {
profile, err := profileFromContext(context.Background())
assert.Nil(t, profile)
if assert.NotNil(t, err) {
assert.Equal(t, errNoSpecFromContext, err)
assert.Equal(t, errNoProfileFromContext, err)
}
}
func TestGroupSpec(t *testing.T) {
expectedGroup := &Group{Name: "test group"}
func TestContextGroup(t *testing.T) {
expectedGroup := &storagepb.Group{Name: "test group"}
ctx := withGroup(context.Background(), expectedGroup)
group, err := groupFromContext(ctx)
assert.Nil(t, err)
assert.Equal(t, expectedGroup, group)
}
func TestGroupSpec_Error(t *testing.T) {
func TestContextGroup_Error(t *testing.T) {
group, err := groupFromContext(context.Background())
assert.Nil(t, group)
if assert.NotNil(t, err) {

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"sort"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"golang.org/x/net/context"
)
@@ -12,76 +14,19 @@ var (
errNoMatchingGroup = errors.New("api: No matching Group")
)
// Group associates matcher conditions with a Specification identifier. The
// Matcher conditions may be satisfied by zero or more machines.
type Group struct {
// Human readable name (optional)
Name string `yaml:"name"`
// Spec identifier
Spec string `yaml:"spec"`
// Custom Metadata
Metadata map[string]interface{} `yaml:"metadata"`
// matcher conditions
Matcher RequirementSet `yaml:"require"`
}
// byMatcher defines a collection of Group structs which have a deterministic
// order in increasing number of Matchers, then by sorted key/value pair strings.
// For example, Matcher {a:b, c:d} is ordered before {a:d, c:d} and {a:b, d:e}.
type byMatcher []Group
func (g byMatcher) Len() int {
return len(g)
}
func (g byMatcher) Swap(i, j int) {
g[i], g[j] = g[j], g[i]
}
func (g byMatcher) Less(i, j int) bool {
if len(g[i].Matcher) == len(g[j].Matcher) {
return g[i].Matcher.String() < g[j].Matcher.String()
}
return len(g[i].Matcher) < len(g[j].Matcher)
}
type groupsResource struct {
store Store
store storage.Store
}
func newGroupsResource(store Store) *groupsResource {
res := &groupsResource{
func newGroupsResource(store storage.Store) *groupsResource {
return &groupsResource{
store: store,
}
return res
}
// listGroups lists all Group resources.
func (gr *groupsResource) listGroups() ([]Group, error) {
return gr.store.ListGroups()
}
// matchSpecHandler returns a ContextHandler that matches machine requests
// to a Spec and adds the Spec to the ctx and calls the next handler. The
// next handler should handle the case that no matching Spec is found.
func (gr *groupsResource) matchSpecHandler(next ContextHandler) ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
attrs := labelsFromRequest(req)
// match machine request
group, err := gr.findMatch(attrs)
if err == nil {
// lookup Spec by id
spec, err := gr.store.Spec(group.Spec)
if err == nil {
// add the Spec to the ctx for next handler
ctx = withSpec(ctx, spec)
}
}
next.ServeHTTP(ctx, w, req)
}
return ContextHandlerFunc(fn)
}
// matchGroupHandler returns a ContextHandler that matches machine requests to
// a Group, adds the Group to the ctx, and calls the next handler. The next
// handler should handle the case that no matching Group is found.
func (gr *groupsResource) matchGroupHandler(next ContextHandler) ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
attrs := labelsFromRequest(req)
@@ -96,18 +41,39 @@ func (gr *groupsResource) matchGroupHandler(next ContextHandler) ContextHandler
return ContextHandlerFunc(fn)
}
// matchProfileHandler returns a ContextHandler that matches machine requests
// to a Profile, adds Profile to the ctx, and calls the next handler. The
// next handler should handle the case that no matching Profile is found.
func (gr *groupsResource) matchProfileHandler(next ContextHandler) ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
attrs := labelsFromRequest(req)
// match machine request
group, err := gr.findMatch(attrs)
if err == nil {
// lookup Profile by id
profile, err := gr.store.ProfileGet(group.Profile)
if err == nil {
// add the Profile to the ctx for next handler
ctx = withProfile(ctx, profile)
}
}
next.ServeHTTP(ctx, w, req)
}
return ContextHandlerFunc(fn)
}
// findMatch returns the first Group whose Matcher is satisfied by the given
// labels. Groups are attempted in sorted order, preferring those with
// more matcher conditions, alphabetically.
func (gr *groupsResource) findMatch(labels map[string]string) (*Group, error) {
groups, err := gr.store.ListGroups()
func (gr *groupsResource) findMatch(labels map[string]string) (*storagepb.Group, error) {
groups, err := gr.store.GroupList()
if err != nil {
return nil, err
}
sort.Sort(sort.Reverse(byMatcher(groups)))
sort.Sort(sort.Reverse(storagepb.ByReqs(groups)))
for _, group := range groups {
if group.Matcher.Matches(labels) {
return &group, nil
if group.Matches(labels) {
return group, nil
}
}
return nil, errNoMatchingGroup

View File

@@ -4,111 +4,37 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sort"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
"golang.org/x/net/context"
)
var (
validMACStr = "52:da:00:89:d8:10"
nonNormalizedMACStr = "52:dA:00:89:d8:10"
testGroup = Group{
Name: "test group",
Spec: "g1h2i3j4",
Metadata: map[string]interface{}{
"k8s_version": "v1.1.2",
"pod_network": "10.2.0.0/16",
"service_name": "etcd2",
},
Matcher: RequirementSet(map[string]string{"uuid": "a1b2c3d4"}),
}
testGroupWithMAC = Group{
Name: "machine with a MAC",
Spec: "g1h2i3j4",
Matcher: RequirementSet(map[string]string{"mac": validMACStr}),
}
testGroupNoSpec = &Group{
Name: "test group with missing spec",
Spec: "",
Matcher: RequirementSet(map[string]string{"uuid": "a1b2c3d4"}),
}
)
func TestByMatcherSort(t *testing.T) {
oneCondition := Group{
Name: "two matcher conditions",
Matcher: RequirementSet(map[string]string{
"region": "a",
}),
}
twoConditions := Group{
Name: "group with two matcher conditions",
Matcher: RequirementSet(map[string]string{
"region": "a",
"zone": "z",
}),
}
dualConditions := Group{
Name: "another group with two matcher conditions",
Matcher: RequirementSet(map[string]string{
"region": "b",
"zone": "z",
}),
}
cases := []struct {
input []Group
expected []Group
}{
{[]Group{oneCondition, dualConditions, twoConditions}, []Group{oneCondition, twoConditions, dualConditions}},
{[]Group{twoConditions, dualConditions, oneCondition}, []Group{oneCondition, twoConditions, dualConditions}},
{[]Group{testGroup, testGroupWithMAC, oneCondition, twoConditions, dualConditions}, []Group{testGroupWithMAC, oneCondition, testGroup, twoConditions, dualConditions}},
}
// assert that
// - groups are sorted in increasing Matcher length
// - when Matcher lengths are equal, groups are sorted by key=value strings.
// - group ordering is deterministic
for _, c := range cases {
sort.Sort(byMatcher(c.input))
assert.Equal(t, c.expected, c.input)
}
}
func TestNewGroupsResource(t *testing.T) {
store := &fixedStore{}
gr := newGroupsResource(store)
assert.Equal(t, store, gr.store)
}
func TestGroupsResource_ListGroups(t *testing.T) {
expectedGroups := []Group{Group{Name: "test group"}}
func TestGroupsResource_MatchProfileHandler(t *testing.T) {
store := &fixedStore{
Groups: expectedGroups,
}
res := newGroupsResource(store)
groups, err := res.listGroups()
assert.Nil(t, err)
assert.Equal(t, expectedGroups, groups)
}
func TestGroupsResource_MatchSpecHandler(t *testing.T) {
store := &fixedStore{
Groups: []Group{testGroup},
Specs: map[string]*Spec{testGroup.Spec: testSpec},
Groups: map[string]*storagepb.Group{testGroup.Id: testGroup},
Profiles: map[string]*storagepb.Profile{testGroup.Profile: testProfile},
}
gr := newGroupsResource(store)
next := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
spec, err := specFromContext(ctx)
profile, err := profileFromContext(ctx)
assert.Nil(t, err)
assert.Equal(t, testSpec, spec)
assert.Equal(t, testProfile, profile)
fmt.Fprintf(w, "next handler called")
}
// assert that:
// - request arguments are used to match uuid=a1b2c3d4 -> testGroup
// - the group's Spec is found by id and added to the context
// - the group's Profile is found by id and added to the context
// - next handler is called
h := gr.matchSpecHandler(ContextHandlerFunc(next))
h := gr.matchProfileHandler(ContextHandlerFunc(next))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "?uuid=a1b2c3d4", nil)
h.ServeHTTP(context.Background(), w, req)
@@ -117,13 +43,13 @@ func TestGroupsResource_MatchSpecHandler(t *testing.T) {
func TestGroupsResource_MatchGroupHandler(t *testing.T) {
store := &fixedStore{
Groups: []Group{testGroup},
Groups: map[string]*storagepb.Group{testGroup.Id: testGroup},
}
gr := newGroupsResource(store)
next := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
group, err := groupFromContext(ctx)
assert.Nil(t, err)
assert.Equal(t, &testGroup, group)
assert.Equal(t, testGroup, group)
fmt.Fprintf(w, "next handler called")
}
// assert that:
@@ -138,23 +64,18 @@ func TestGroupsResource_MatchGroupHandler(t *testing.T) {
func TestGroupsResource_FindMatch(t *testing.T) {
store := &fixedStore{
Groups: []Group{testGroup},
Specs: map[string]*Spec{testGroup.Spec: testSpec},
Groups: map[string]*storagepb.Group{testGroup.Id: testGroup},
}
uuidLabel := map[string]string{
"uuid": "a1b2c3d4",
}
cases := []struct {
store Store
store storage.Store
labels map[string]string
expectedGroup *Group
expectedGroup *storagepb.Group
expectedErr error
}{
{store, uuidLabel, &testGroup, nil},
{store, map[string]string{"uuid": "a1b2c3d4"}, testGroup, nil},
{store, nil, nil, errNoMatchingGroup},
// no groups in the store
{&emptyStore{}, uuidLabel, nil, errNoMatchingGroup},
{&emptyStore{}, map[string]string{"a": "b"}, nil, errNoMatchingGroup},
}
for _, c := range cases {

View File

@@ -22,13 +22,13 @@ initrdefi {{ range $element := .Initrd }}"{{$element}}" {{end}}
// requester.
func grubHandler() ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
spec, err := specFromContext(ctx)
profile, err := profileFromContext(ctx)
if err != nil {
http.NotFound(w, req)
return
}
var buf bytes.Buffer
err = grubTemplate.Execute(&buf, spec.BootConfig)
err = grubTemplate.Execute(&buf, profile.Boot)
if err != nil {
log.Errorf("error rendering template: %v", err)
http.NotFound(w, req)

View File

@@ -48,7 +48,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.handler.ServeHTTP(h.ctx, w, req)
}
// labelsFromRequest returns Labels from request query parameters.
// labelsFromRequest returns request query parameters.
func labelsFromRequest(req *http.Request) map[string]string {
values := req.URL.Query()
labels := map[string]string{}

View File

@@ -2,40 +2,45 @@ package api
import (
"bytes"
"encoding/json"
"gopkg.in/yaml.v2"
"net/http"
"strings"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
ignition "github.com/coreos/ignition/src/config"
"golang.org/x/net/context"
)
// ignitionHandler returns a handler that responds with the Ignition config
// for the requester. The Ignition file referenced in the Spec is rendered
// for the requester. The Ignition file referenced in the Profile is rendered
// with metadata and parsed and validated as either YAML or JSON based on the
// extension. The Ignition config is served as an HTTP JSON response.
func ignitionHandler(store Store) ContextHandler {
func ignitionHandler(store storage.Store) ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
group, err := groupFromContext(ctx)
if err != nil || group.Spec == "" {
if err != nil || group.Profile == "" {
http.NotFound(w, req)
return
}
spec, err := store.Spec(group.Spec)
if err != nil || spec.IgnitionConfig == "" {
profile, err := store.ProfileGet(group.Profile)
if err != nil || profile.IgnitionId == "" {
http.NotFound(w, req)
return
}
contents, err := store.IgnitionConfig(spec.IgnitionConfig)
contents, err := store.IgnitionGet(profile.IgnitionId)
if err != nil {
http.NotFound(w, req)
return
}
// collect data for rendering Ignition Config
data := make(map[string]interface{})
for k := range group.Metadata {
data[k] = group.Metadata[k]
var data map[string]interface{}
err = json.Unmarshal(group.Metadata, &data)
if err != nil {
log.Errorf("error unmarshalling metadata: %v", err)
http.NotFound(w, req)
return
}
data["query"] = req.URL.RawQuery
@@ -49,7 +54,7 @@ func ignitionHandler(store Store) ContextHandler {
// Unmarshal YAML or JSON Ignition config
var cfg ignition.Config
if isYAML(spec.IgnitionConfig) {
if isYAML(profile.IgnitionId) {
if err := yaml.Unmarshal(buf.Bytes(), &cfg); err != nil {
log.Errorf("error parsing YAML Ignition config: %v", err)
http.NotFound(w, req)

View File

@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
"golang.org/x/net/context"
)
@@ -14,11 +15,11 @@ var expectedIgnition = `{"ignitionVersion":1,"storage":{},"systemd":{"units":[{"
func TestIgnitionHandler(t *testing.T) {
content := `{"ignitionVersion": 1,"systemd":{"units":[{"name":"{{.service_name}}.service","enable":true}]}}`
store := &fixedStore{
Specs: map[string]*Spec{testGroup.Spec: testSpec},
IgnitionConfigs: map[string]string{testSpec.IgnitionConfig: content},
Profiles: map[string]*storagepb.Profile{testGroup.Profile: testProfile},
IgnitionConfigs: map[string]string{testProfile.IgnitionId: content},
}
h := ignitionHandler(store)
ctx := withGroup(context.Background(), &testGroup)
ctx := withGroup(context.Background(), testGroup)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)
@@ -40,11 +41,11 @@ systemd:
enable: true
`
store := &fixedStore{
Specs: map[string]*Spec{testGroup.Spec: testSpecWithIgnitionYAML},
IgnitionConfigs: map[string]string{testSpecWithIgnitionYAML.IgnitionConfig: content},
Profiles: map[string]*storagepb.Profile{testGroup.Profile: testProfileIgnitionYAML},
IgnitionConfigs: map[string]string{testProfileIgnitionYAML.IgnitionId: content},
}
h := ignitionHandler(store)
ctx := withGroup(context.Background(), &testGroup)
ctx := withGroup(context.Background(), testGroup)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)
@@ -57,7 +58,7 @@ systemd:
assert.Equal(t, expectedIgnition, w.Body.String())
}
func TestIgnitionHandler_MissingCtxSpec(t *testing.T) {
func TestIgnitionHandler_MissingCtxProfile(t *testing.T) {
h := ignitionHandler(&emptyStore{})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
@@ -67,7 +68,7 @@ func TestIgnitionHandler_MissingCtxSpec(t *testing.T) {
func TestIgnitionHandler_MissingIgnitionConfig(t *testing.T) {
h := ignitionHandler(&emptyStore{})
ctx := withSpec(context.Background(), testSpec)
ctx := withProfile(context.Background(), testProfile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)

View File

@@ -32,13 +32,13 @@ func ipxeInspect() http.Handler {
// requester.
func ipxeHandler() ContextHandler {
fn := func(ctx context.Context, w http.ResponseWriter, req *http.Request) {
spec, err := specFromContext(ctx)
profile, err := profileFromContext(ctx)
if err != nil {
http.NotFound(w, req)
return
}
var buf bytes.Buffer
err = ipxeTemplate.Execute(&buf, spec.BootConfig)
err = ipxeTemplate.Execute(&buf, profile.Boot)
if err != nil {
log.Errorf("error rendering template: %v", err)
http.NotFound(w, req)

View File

@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
"golang.org/x/net/context"
)
@@ -20,12 +21,12 @@ func TestIPXEInspect(t *testing.T) {
func TestIPXEHandler(t *testing.T) {
h := ipxeHandler()
ctx := withSpec(context.Background(), testSpec)
ctx := withProfile(context.Background(), testProfile)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)
// assert that:
// - the Spec's boot config is rendered as an iPXE script
// - the Profile's NetBoot config is rendered as an iPXE script
expectedScript := `#!ipxe
kernel /image/kernel a=b c
initrd /image/initrd_a /image/initrd_b
@@ -35,7 +36,7 @@ boot
assert.Equal(t, expectedScript, w.Body.String())
}
func TestIPXEHandler_MissingCtxSpec(t *testing.T) {
func TestIPXEHandler_MissingCtxProfile(t *testing.T) {
h := ipxeHandler()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
@@ -45,8 +46,8 @@ func TestIPXEHandler_MissingCtxSpec(t *testing.T) {
func TestIPXEHandler_RenderTemplateError(t *testing.T) {
h := ipxeHandler()
// a Spec with nil BootConfig forces a template.Execute error
ctx := withSpec(context.Background(), &Spec{BootConfig: nil})
// a Profile with nil NetBoot forces a template.Execute error
ctx := withProfile(context.Background(), &storagepb.Profile{Boot: nil})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)
@@ -55,7 +56,7 @@ func TestIPXEHandler_RenderTemplateError(t *testing.T) {
func TestIPXEHandler_WriteError(t *testing.T) {
h := ipxeHandler()
ctx := withSpec(context.Background(), testSpec)
ctx := withProfile(context.Background(), testProfile)
w := NewUnwriteableResponseWriter()
req, _ := http.NewRequest("GET", "/", nil)
h.ServeHTTP(ctx, w, req)

View File

@@ -1,31 +0,0 @@
package api
import (
"sort"
"strings"
)
// RequirementSet is a map of key:value equality requirements which
// match against any Labels which are supersets.
type RequirementSet map[string]string
// Matches returns true if the given labels satisfy all the requirements,
// false otherwise.
func (r RequirementSet) Matches(labels map[string]string) bool {
for key, val := range r {
if labels == nil || labels[key] != val {
return false
}
}
return true
}
func (r RequirementSet) String() string {
requirements := make([]string, 0, len(r))
for key, value := range r {
requirements = append(requirements, key+"="+value)
}
// sort by "key=value" pairs for a deterministic ordering
sort.StringSlice(requirements).Sort()
return strings.Join(requirements, ",")
}

View File

@@ -1,47 +0,0 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequirementMatches(t *testing.T) {
// requirements
reqs := map[string]string{
"region": "Central US",
"zone": "us-central1-a",
"lot": "42",
}
attrs := map[string]string{
"uuid": "16e7d8a7-bfa9-428b-9117-363341bb330b",
}
// labels
labels := map[string]string{
"region": "Central US",
"zone": "us-central1-a",
"lot": "42",
}
query := map[string]string{
"uuid": "16e7d8a7-bfa9-428b-9117-363341bb330b",
}
lacking := map[string]string{
"region": "Central US",
}
cases := []struct {
reqs map[string]string
labels map[string]string
expected bool
}{
{reqs, labels, true},
{attrs, query, true},
{reqs, lacking, false},
// zero requirements match any label set
{map[string]string{}, labels, true},
}
for _, c := range cases {
r := RequirementSet(c.reqs)
assert.Equal(t, c.expected, r.Matches(c.labels))
}
}

View File

@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -18,7 +19,15 @@ func metadataHandler() ContextHandler {
return
}
w.Header().Set(contentType, plainContentType)
for key, value := range group.Metadata {
var data map[string]interface{}
err = json.Unmarshal(group.Metadata, &data)
if err != nil {
log.Error("error unmarshalling metadata")
http.NotFound(w, req)
return
}
for key, value := range data {
fmt.Fprintf(w, "%s=%s\n", strings.ToUpper(key), value)
}
attrs := labelsFromRequest(req)

View File

@@ -13,9 +13,9 @@ import (
func TestMetadataHandler(t *testing.T) {
h := metadataHandler()
ctx := withGroup(context.Background(), &testGroup)
ctx := withGroup(context.Background(), testGroup)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/?uuid=a1b2c3d4&mac="+nonNormalizedMACStr, nil)
req, _ := http.NewRequest("GET", "/?uuid=a1b2c3d4&mac="+validMACStr, nil)
h.ServeHTTP(ctx, w, req)
// assert that:
// - the Group's custom metadata is

View File

@@ -3,12 +3,14 @@ package api
import (
"net/http"
"path/filepath"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
)
// pixiecoreHandler returns a handler that renders the boot config JSON for
// the requester, to implement the Pixiecore API specification.
// https://github.com/danderson/pixiecore/blob/master/README.api.md
func pixiecoreHandler(gr *groupsResource, store Store) http.Handler {
func pixiecoreHandler(gr *groupsResource, store storage.Store) http.Handler {
fn := func(w http.ResponseWriter, req *http.Request) {
macAddr, err := parseMAC(filepath.Base(req.URL.Path))
if err != nil {
@@ -22,12 +24,12 @@ func pixiecoreHandler(gr *groupsResource, store Store) http.Handler {
http.NotFound(w, req)
return
}
spec, err := store.Spec(group.Spec)
profile, err := store.ProfileGet(group.Profile)
if err != nil {
http.NotFound(w, req)
return
}
renderJSON(w, spec.BootConfig)
renderJSON(w, profile.Boot)
}
return http.HandlerFunc(fn)
}

View File

@@ -5,21 +5,22 @@ import (
"net/http/httptest"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
)
func TestPixiecoreHandler(t *testing.T) {
store := &fixedStore{
Groups: []Group{testGroupWithMAC},
Specs: map[string]*Spec{testGroupWithMAC.Spec: testSpec},
Groups: map[string]*storagepb.Group{testGroupWithMAC.Id: testGroupWithMAC},
Profiles: map[string]*storagepb.Profile{testGroupWithMAC.Profile: testProfile},
}
h := pixiecoreHandler(newGroupsResource(store), store)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/"+validMACStr, nil)
h.ServeHTTP(w, req)
// assert that:
// - MAC address argument is used for Spec matching
// - the Spec's boot config is rendered as Pixiecore JSON
// - MAC address argument is used for Group matching
// - the Profile's NetBoot config is rendered as Pixiecore JSON
expectedJSON := `{"kernel":"/image/kernel","initrd":["/image/initrd_a","/image/initrd_b"],"cmdline":{"a":"b","c":""}}`
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, jsonContentType, w.HeaderMap.Get(contentType))
@@ -43,9 +44,9 @@ func TestPixiecoreHandler_NoMatchingGroup(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestPixiecoreHandler_NoMatchingSpec(t *testing.T) {
func TestPixiecoreHandler_NoMatchingProfile(t *testing.T) {
store := &fixedStore{
Groups: []Group{testGroupWithMAC},
Groups: map[string]*storagepb.Group{testGroup.Id: testGroup},
}
h := pixiecoreHandler(newGroupsResource(store), &emptyStore{})
w := httptest.NewRecorder()

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/coreos/coreos-baremetal/bootcfg/sign"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
"github.com/coreos/pkg/capnslog"
)
@@ -17,7 +18,7 @@ var log = capnslog.NewPackageLogger("github.com/coreos/coreos-baremetal/bootcfg"
// Config configures the api Server.
type Config struct {
// Store for configs
Store Store
Store storage.Store
// Path to static assets
AssetsPath string
// config signers (.sig and .asc)
@@ -27,7 +28,7 @@ type Config struct {
// Server serves boot and provisioning configs to machines.
type Server struct {
store Store
store storage.Store
assetsPath string
signer sign.Signer
armoredSigner sign.Signer
@@ -46,22 +47,20 @@ func NewServer(config *Config) *Server {
// HTTPHandler returns a HTTP handler for the server.
func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux()
// API Resources
newSpecResource(mux, "/spec/", s.store)
gr := newGroupsResource(s.store)
// Endpoints
mux.Handle("/grub", logRequests(NewHandler(gr.matchSpecHandler(grubHandler()))))
// Boot via GRUB
mux.Handle("/grub", logRequests(NewHandler(gr.matchProfileHandler(grubHandler()))))
// Boot via iPXE
mux.Handle("/boot.ipxe", logRequests(ipxeInspect()))
mux.Handle("/boot.ipxe.0", logRequests(ipxeInspect()))
mux.Handle("/ipxe", logRequests(NewHandler(gr.matchSpecHandler(ipxeHandler()))))
mux.Handle("/ipxe", logRequests(NewHandler(gr.matchProfileHandler(ipxeHandler()))))
// Boot via Pixiecore
mux.Handle("/pixiecore/v1/boot/", logRequests(pixiecoreHandler(gr, s.store)))
// cloud configs
mux.Handle("/cloud", logRequests(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
// ignition configs
// Ignition Config
mux.Handle("/ignition", logRequests(NewHandler(gr.matchGroupHandler(ignitionHandler(s.store)))))
// Cloud-Config
mux.Handle("/cloud", logRequests(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
// metadata
mux.Handle("/metadata", logRequests(NewHandler(gr.matchGroupHandler(metadataHandler()))))
@@ -70,26 +69,26 @@ func (s *Server) HTTPHandler() http.Handler {
signerChain := func(next http.Handler) http.Handler {
return logRequests(sign.SignatureHandler(s.signer, next))
}
mux.Handle("/grub.sig", signerChain(NewHandler(gr.matchSpecHandler(grubHandler()))))
mux.Handle("/grub.sig", signerChain(NewHandler(gr.matchProfileHandler(grubHandler()))))
mux.Handle("/boot.ipxe.sig", signerChain(ipxeInspect()))
mux.Handle("/boot.ipxe.0.sig", signerChain(ipxeInspect()))
mux.Handle("/ipxe.sig", signerChain(NewHandler(gr.matchSpecHandler(ipxeHandler()))))
mux.Handle("/ipxe.sig", signerChain(NewHandler(gr.matchProfileHandler(ipxeHandler()))))
mux.Handle("/pixiecore/v1/boot.sig/", signerChain(pixiecoreHandler(gr, s.store)))
mux.Handle("/cloud.sig", signerChain(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
mux.Handle("/ignition.sig", signerChain(NewHandler(gr.matchGroupHandler(ignitionHandler(s.store)))))
mux.Handle("/cloud.sig", signerChain(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
mux.Handle("/metadata.sig", signerChain(NewHandler(gr.matchGroupHandler(metadataHandler()))))
}
if s.armoredSigner != nil {
signerChain := func(next http.Handler) http.Handler {
return logRequests(sign.SignatureHandler(s.armoredSigner, next))
}
mux.Handle("/grub.asc", signerChain(NewHandler(gr.matchSpecHandler(grubHandler()))))
mux.Handle("/grub.asc", signerChain(NewHandler(gr.matchProfileHandler(grubHandler()))))
mux.Handle("/boot.ipxe.asc", signerChain(ipxeInspect()))
mux.Handle("/boot.ipxe.0.asc", signerChain(ipxeInspect()))
mux.Handle("/ipxe.asc", signerChain(NewHandler(gr.matchSpecHandler(ipxeHandler()))))
mux.Handle("/ipxe.asc", signerChain(NewHandler(gr.matchProfileHandler(ipxeHandler()))))
mux.Handle("/pixiecore/v1/boot.asc/", signerChain(pixiecoreHandler(gr, s.store)))
mux.Handle("/cloud.asc", signerChain(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
mux.Handle("/ignition.asc", signerChain(NewHandler(gr.matchGroupHandler(ignitionHandler(s.store)))))
mux.Handle("/cloud.asc", signerChain(NewHandler(gr.matchGroupHandler(cloudHandler(s.store)))))
mux.Handle("/metadata.asc", signerChain(NewHandler(gr.matchGroupHandler(metadataHandler()))))
}

View File

@@ -1,33 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestServerSpecRoute(t *testing.T) {
store := &fixedStore{
Specs: map[string]*Spec{"g1h2i3j4": testSpec},
}
h := NewServer(&Config{Store: store}).HTTPHandler()
req, _ := http.NewRequest("GET", "/spec/g1h2i3j4", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// assert that:
// - spec is rendered as JSON
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, jsonContentType, w.HeaderMap.Get(contentType))
assert.Equal(t, expectedSpecJSON, w.Body.String())
}
func TestServerSpecRoute_WrongMethod(t *testing.T) {
h := NewServer(&Config{}).HTTPHandler()
req, _ := http.NewRequest("POST", "/spec/g1h2i3j4", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
assert.Equal(t, "only HTTP GET is supported\n", w.Body.String())
}

View File

@@ -1,40 +0,0 @@
package api
import (
"net/http"
"path/filepath"
)
// Spec is a named group of configs.
type Spec struct {
// spec identifier
ID string `json:"id"`
// boot kernel, initrd, and kernel options
BootConfig *BootConfig `json:"boot"`
// cloud config id
CloudConfig string `json:"cloud_id"`
// ignition config id
IgnitionConfig string `json:"ignition_id"`
}
// specResource serves Spec resources by id.
type specResource struct {
store Store
}
func newSpecResource(mux *http.ServeMux, pattern string, store Store) {
gr := &specResource{
store: store,
}
mux.Handle(pattern, logRequests(requireGET(gr)))
}
func (r *specResource) ServeHTTP(w http.ResponseWriter, req *http.Request) {
id := filepath.Base(req.URL.Path)
spec, err := r.store.Spec(id)
if err != nil {
http.NotFound(w, req)
return
}
renderJSON(w, spec)
}

View File

@@ -1,58 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
var (
// testSpec specifies a named group of configs for testing purposes.
testSpec = &Spec{
ID: "g1h2i3j4",
BootConfig: &BootConfig{
Kernel: "/image/kernel",
Initrd: []string{"/image/initrd_a", "/image/initrd_b"},
Cmdline: map[string]interface{}{
"a": "b",
"c": "",
},
},
CloudConfig: "cloud-config.yml",
IgnitionConfig: "ignition.json",
}
testSpecWithIgnitionYAML = &Spec{
ID: "g1h2i3j4",
IgnitionConfig: "ignition.yaml",
}
emptySpec = &Spec{
ID: "empty",
}
expectedSpecJSON = `{"id":"g1h2i3j4","boot":{"kernel":"/image/kernel","initrd":["/image/initrd_a","/image/initrd_b"],"cmdline":{"a":"b","c":""}},"cloud_id":"cloud-config.yml","ignition_id":"ignition.json"}`
)
func TestSpecHandler(t *testing.T) {
store := &fixedStore{
Specs: map[string]*Spec{"g1h2i3j4": testSpec},
}
h := specResource{store: store}
req, _ := http.NewRequest("GET", "/g1h2i3j4", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// assert that:
// - spec is rendered as JSON
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, jsonContentType, w.HeaderMap.Get(contentType))
assert.Equal(t, expectedSpecJSON, w.Body.String())
}
func TestSpecHandler_MissingSpec(t *testing.T) {
store := &emptyStore{}
h := specResource{store}
req, _ := http.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -1,114 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"path/filepath"
)
// Store provides Group, Spec, Ignition, and Cloud config resources.
type Store interface {
BootstrapGroups([]Group) error
ListGroups() ([]Group, error)
Spec(id string) (*Spec, error)
CloudConfig(id string) (string, error)
IgnitionConfig(id string) (string, error)
}
// fileStore provides configs from an http.Filesystem.
type fileStore struct {
root http.FileSystem
groups []Group
}
// NewFileStore returns a Store backed by a filesystem directory.
func NewFileStore(root http.FileSystem) Store {
return &fileStore{
root: root,
}
}
// BootstrapGroups loads an initial collection of groups.
func (s *fileStore) BootstrapGroups(groups []Group) error {
s.groups = groups
return nil
}
// ListGroups returns the list of groups with matchers.
func (s *fileStore) ListGroups() ([]Group, error) {
return s.groups, nil
}
// Spec returns the Spec with the given id.
func (s *fileStore) Spec(id string) (*Spec, error) {
file, err := openFile(s.root, filepath.Join("specs", id, "spec.json"))
if err != nil {
log.Debugf("no spec %s", id)
return nil, err
}
defer file.Close()
spec := new(Spec)
err = json.NewDecoder(file).Decode(spec)
if err != nil {
log.Errorf("error decoding spec: %s", err)
return nil, err
}
return spec, err
}
// CloudConfig returns the cloud config with the given id.
func (s *fileStore) CloudConfig(id string) (string, error) {
file, err := openFile(s.root, filepath.Join("cloud", id))
if err != nil {
log.Debugf("no cloud config %s", id)
return "", err
}
defer file.Close()
b, err := ioutil.ReadAll(file)
if err != nil {
log.Errorf("error reading cloud config: %s", err)
return "", err
}
return string(b), err
}
// IgnitionConfig returns the ignition template with the given id.
func (s *fileStore) IgnitionConfig(id string) (string, error) {
file, err := openFile(s.root, filepath.Join("ignition", id))
if err != nil {
log.Debugf("no ignition config %s", id)
return "", err
}
defer file.Close()
b, err := ioutil.ReadAll(file)
if err != nil {
log.Errorf("error reading ignition config: %s", err)
return "", err
}
return string(b), err
}
// openFile attempts to open the file within the specified Filesystem. If
// successful, the http.File is returned and must be closed by the caller.
// Otherwise, the path was not a regular file that could be opened and an
// error is returned.
func openFile(fs http.FileSystem, path string) (http.File, error) {
file, err := fs.Open(path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
if info.Mode().IsRegular() {
return file, nil
}
file.Close()
return nil, fmt.Errorf("%s is not a file on the given filesystem", path)
}

View File

@@ -1,66 +0,0 @@
package api
import (
"fmt"
)
// fixedStore provides fixed Group, Spec, Ignition, and Cloud config resources
// for testing.
type fixedStore struct {
Groups []Group
Specs map[string]*Spec
CloudConfigs map[string]string
IgnitionConfigs map[string]string
}
func (s *fixedStore) Spec(id string) (*Spec, error) {
if spec, present := s.Specs[id]; present {
return spec, nil
}
return nil, fmt.Errorf("no spec %s", id)
}
func (s *fixedStore) BootstrapGroups(groups []Group) error {
s.Groups = groups
return nil
}
func (s *fixedStore) ListGroups() ([]Group, error) {
return s.Groups, nil
}
func (s *fixedStore) CloudConfig(id string) (string, error) {
if config, present := s.CloudConfigs[id]; present {
return config, nil
}
return "", fmt.Errorf("no cloud config %s", id)
}
func (s *fixedStore) IgnitionConfig(id string) (string, error) {
if config, present := s.IgnitionConfigs[id]; present {
return config, nil
}
return "", fmt.Errorf("no ignition config %s", id)
}
type emptyStore struct{}
func (s *emptyStore) Spec(id string) (*Spec, error) {
return nil, fmt.Errorf("no group config %s", id)
}
func (s *emptyStore) BootstrapGroups(groups []Group) error {
return nil
}
func (s *emptyStore) ListGroups() (groups []Group, err error) {
return groups, nil
}
func (s emptyStore) CloudConfig(id string) (string, error) {
return "", fmt.Errorf("no cloud config %s", id)
}
func (s emptyStore) IgnitionConfig(id string) (string, error) {
return "", fmt.Errorf("no ignition config %s", id)
}

View File

@@ -0,0 +1,127 @@
package api
import (
"fmt"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
)
var (
validMACStr = "52:da:00:89:d8:10"
testProfile = &storagepb.Profile{
Id: "g1h2i3j4",
Boot: &storagepb.NetBoot{
Kernel: "/image/kernel",
Initrd: []string{"/image/initrd_a", "/image/initrd_b"},
Cmdline: map[string]string{
"a": "b",
"c": "",
},
},
CloudId: "cloud-config.yml",
IgnitionId: "ignition.json",
}
testProfileIgnitionYAML = &storagepb.Profile{
Id: "g1h2i3j4",
IgnitionId: "ignition.yaml",
}
testGroup = &storagepb.Group{
Id: "test-group",
Name: "test group",
Profile: "g1h2i3j4",
Requirements: map[string]string{"uuid": "a1b2c3d4"},
Metadata: []byte(`{"k8s_version":"v1.1.2","service_name":"etcd2","pod_network": "10.2.0.0/16"}`),
}
testGroupWithMAC = &storagepb.Group{
Id: "test-group",
Name: "test group",
Profile: "g1h2i3j4",
Requirements: map[string]string{"mac": validMACStr},
}
)
type fixedStore struct {
Groups map[string]*storagepb.Group
Profiles map[string]*storagepb.Profile
IgnitionConfigs map[string]string
CloudConfigs map[string]string
}
func (s *fixedStore) GroupGet(id string) (*storagepb.Group, error) {
if group, present := s.Groups[id]; present {
return group, nil
}
return nil, storage.ErrGroupNotFound
}
func (s *fixedStore) GroupList() ([]*storagepb.Group, error) {
groups := make([]*storagepb.Group, len(s.Groups))
i := 0
for _, g := range s.Groups {
groups[i] = g
i++
}
return groups, nil
}
func (s *fixedStore) ProfileGet(id string) (*storagepb.Profile, error) {
if profile, present := s.Profiles[id]; present {
return profile, nil
}
return nil, storage.ErrProfileNotFound
}
func (s *fixedStore) ProfileList() ([]*storagepb.Profile, error) {
profiles := make([]*storagepb.Profile, len(s.Profiles))
i := 0
for _, p := range s.Profiles {
profiles[i] = p
i++
}
return profiles, nil
}
func (s *fixedStore) IgnitionGet(id string) (string, error) {
if config, present := s.IgnitionConfigs[id]; present {
return config, nil
}
return "", fmt.Errorf("no Ignition Config %s", id)
}
func (s *fixedStore) CloudGet(id string) (string, error) {
if config, present := s.CloudConfigs[id]; present {
return config, nil
}
return "", fmt.Errorf("no Cloud Config %s", id)
}
type emptyStore struct{}
func (s *emptyStore) GroupGet(id string) (*storagepb.Group, error) {
return nil, storage.ErrGroupNotFound
}
func (s *emptyStore) GroupList() (groups []*storagepb.Group, err error) {
return groups, nil
}
func (s *emptyStore) ProfileGet(id string) (*storagepb.Profile, error) {
return nil, storage.ErrProfileNotFound
}
func (s *emptyStore) ProfileList() (profiles []*storagepb.Profile, err error) {
return profiles, nil
}
func (s *emptyStore) IgnitionGet(id string) (string, error) {
return "", fmt.Errorf("no Ignition Config %s", id)
}
func (s *emptyStore) CloudGet(id string) (string, error) {
return "", fmt.Errorf("no Cloud Config %s", id)
}

View File

@@ -9,7 +9,6 @@ import (
"os"
"strings"
"github.com/coreos/coreos-baremetal/bootcfg/api"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/coreos/pkg/capnslog"
"github.com/satori/go.uuid"
@@ -28,15 +27,28 @@ var (
var log = capnslog.NewPackageLogger("github.com/coreos/coreos-baremetal/bootcfg", "config")
// Config is a user defined matching of machines to specifications.
// Config is a user defined matching of machine groups to profiles.
type Config struct {
APIVersion string `yaml:"api_version"`
// allow YAML source for Groups
YAMLGroups []api.Group `yaml:"groups"`
YAMLGroups []Group `yaml:"groups"`
// populate protobuf Groups at parse
Groups []*storagepb.Group `yaml:"-"`
}
// A Group associates machines with required tags with a Profile and
// metadata. Zero or more machines may match to a machine Group.
type Group struct {
// Human readable name
Name string `yaml:"name"`
// Profile id
Profile string `yaml:"spec"`
// tags required to match the group
Requirements map[string]string `yaml:"require"`
// Metadata (with restrictions)
Metadata map[string]interface{} `yaml:"metadata"`
}
// LoadConfig opens a file and parses YAML data to returns a Config.
func LoadConfig(path string) (*Config, error) {
data, err := ioutil.ReadFile(os.ExpandEnv(path))
@@ -58,8 +70,8 @@ func ParseConfig(data []byte) (*Config, error) {
for _, ygroup := range config.YAMLGroups {
group := &storagepb.Group{
Name: ygroup.Name,
Profile: ygroup.Spec,
Requirements: normalizeMatchers(ygroup.Matcher),
Profile: ygroup.Profile,
Requirements: normalizeMatchers(ygroup.Requirements),
}
// Id: Generate a random UUID or use the name
if ygroup.Name == "" {
@@ -159,7 +171,7 @@ func (c *Config) validate() error {
return ErrIncorrectVersion
}
for _, group := range c.YAMLGroups {
for key, val := range group.Matcher {
for key, val := range group.Requirements {
switch strings.ToLower(key) {
case "mac":
macAddr, err := net.ParseMAC(val)

View File

@@ -6,7 +6,6 @@ import (
"os"
"testing"
"github.com/coreos/coreos-baremetal/bootcfg/api"
"github.com/coreos/coreos-baremetal/bootcfg/storage/storagepb"
"github.com/stretchr/testify/assert"
)
@@ -88,35 +87,35 @@ func TestValidate(t *testing.T) {
}
invalidMAC := &Config{
APIVersion: "v1alpha1",
YAMLGroups: []api.Group{
api.Group{
Matcher: api.RequirementSet(map[string]string{
YAMLGroups: []Group{
Group{
Requirements: map[string]string{
"mac": "?:?:?:?",
}),
},
},
},
}
nonNormalizedMAC := &Config{
APIVersion: "v1alpha1",
YAMLGroups: []api.Group{
api.Group{
Matcher: api.RequirementSet(map[string]string{
YAMLGroups: []Group{
Group{
Requirements: map[string]string{
"mac": "aB:Ab:3d:45:cD:10",
}),
},
},
},
}
validConfig := &Config{
APIVersion: "v1alpha1",
YAMLGroups: []api.Group{
api.Group{
Name: "node1",
Spec: "worker",
Matcher: api.RequirementSet(map[string]string{
YAMLGroups: []Group{
Group{
Name: "node1",
Profile: "worker",
Requirements: map[string]string{
"role": "worker",
"region": "us-central1-a",
"mac": "ab:ab:3d:45:cd:10",
}),
},
},
},
}

View File

@@ -27,6 +27,10 @@ type Store interface {
ProfileGet(id string) (*storagepb.Profile, error)
// ProfileList lists all profiles.
ProfileList() ([]*storagepb.Profile, error)
// IgnitionGet gets an Ignition Config template by name.
IgnitionGet(name string) (string, error)
// CloudGet gets a Cloud-Config template by name.
CloudGet(name string) (string, error)
}
// Config initializes a fileStore.
@@ -106,6 +110,36 @@ func (s *fileStore) ProfileList() ([]*storagepb.Profile, error) {
return profiles, nil
}
// IgnitionGet gets an Ignition Config template by name.
func (s *fileStore) IgnitionGet(id string) (string, error) {
file, err := openFile(http.Dir(s.dir), filepath.Join("ignition", id))
if err != nil {
return "", err
}
defer file.Close()
b, err := ioutil.ReadAll(file)
if err != nil {
return "", err
}
return string(b), err
}
// CloudGet gets a Cloud-Config template by name.
func (s *fileStore) CloudGet(id string) (string, error) {
file, err := openFile(http.Dir(s.dir), filepath.Join("cloud", id))
if err != nil {
return "", err
}
defer file.Close()
b, err := ioutil.ReadAll(file)
if err != nil {
return "", err
}
return string(b), err
}
// openFile attempts to open the file within the specified Filesystem. If
// successful, the http.File is returned and must be closed by the caller.
// Otherwise, the path was not a regular file that could be opened and an

View File

@@ -32,7 +32,7 @@ func (g *Group) requirementString() string {
// sorted order by increasing number of Requirements, then by sorted key/value
// strings. For example, a Group with Requirements {a:b, c:d} should be ordered
// after one with {a:b} and before one with {a:d, c:d}.
type ByReqs []Group
type ByReqs []*Group
func (groups ByReqs) Len() int {
return len(groups)

View File

@@ -8,16 +8,15 @@ import (
)
var (
validMACStr = "52:da:00:89:d8:10"
testGroup = Group{
testGroup = &Group{
Name: "test group",
Profile: "g1h2i3j4",
Requirements: map[string]string{
"uuid": "a1b2c3d4",
"mac": validMACStr,
"mac": "52:da:00:89:d8:10",
},
}
testGroupWithoutProfile = Group{
testGroupWithoutProfile = &Group{
Name: "test group without profile",
Profile: "",
Requirements: map[string]string{"uuid": "a1b2c3d4"},
@@ -56,20 +55,20 @@ func TestRequirementString(t *testing.T) {
}
func TestGroupSort(t *testing.T) {
oneCondition := Group{
oneCondition := &Group{
Name: "group with one requirement",
Requirements: map[string]string{
"region": "a",
},
}
twoConditions := Group{
twoConditions := &Group{
Name: "group with two requirements",
Requirements: map[string]string{
"region": "a",
"zone": "z",
},
}
dualConditions := Group{
dualConditions := &Group{
Name: "group with two requirements",
Requirements: map[string]string{
"region": "b",
@@ -77,12 +76,12 @@ func TestGroupSort(t *testing.T) {
},
}
cases := []struct {
input []Group
expected []Group
input []*Group
expected []*Group
}{
{[]Group{oneCondition, dualConditions, twoConditions}, []Group{oneCondition, twoConditions, dualConditions}},
{[]Group{twoConditions, dualConditions, oneCondition}, []Group{oneCondition, twoConditions, dualConditions}},
{[]Group{testGroup, testGroupWithoutProfile, oneCondition, twoConditions, dualConditions}, []Group{oneCondition, testGroupWithoutProfile, testGroup, twoConditions, dualConditions}},
{[]*Group{oneCondition, dualConditions, twoConditions}, []*Group{oneCondition, twoConditions, dualConditions}},
{[]*Group{twoConditions, dualConditions, oneCondition}, []*Group{oneCondition, twoConditions, dualConditions}},
{[]*Group{testGroup, testGroupWithoutProfile, oneCondition, twoConditions, dualConditions}, []*Group{oneCondition, testGroupWithoutProfile, testGroup, twoConditions, dualConditions}},
}
// assert that
// - Group ordering is deterministic

View File

@@ -6,7 +6,7 @@ message Group {
string id = 1;
// human readable name
string name = 2;
// profile id
// Profile id
string profile = 3;
// tags required to match the group
map<string, string> requirements = 4;

View File

@@ -14,6 +14,7 @@ import (
"github.com/coreos/coreos-baremetal/bootcfg/api"
"github.com/coreos/coreos-baremetal/bootcfg/config"
"github.com/coreos/coreos-baremetal/bootcfg/sign"
"github.com/coreos/coreos-baremetal/bootcfg/storage"
)
var (
@@ -84,9 +85,6 @@ func main() {
capnslog.SetGlobalLogLevel(lvl)
capnslog.SetFormatter(capnslog.NewPrettyFormatter(os.Stdout, false))
// storage
store := api.NewFileStore(http.Dir(flags.dataPath))
// (optional) signing
var signer, armoredSigner sign.Signer
if flags.keyRingPath != "" {
@@ -103,7 +101,12 @@ func main() {
if err != nil {
log.Fatal(err)
}
store.BootstrapGroups(cfg.YAMLGroups)
// storage
store := storage.NewFileStore(&storage.Config{
Dir: flags.dataPath,
Groups: cfg.Groups,
})
// HTTP server
config := &api.Config{