From 9d6e0309e3477b686fcf65f91b24c0af918160d2 Mon Sep 17 00:00:00 2001 From: Dalton Hubble Date: Mon, 14 Dec 2015 03:01:42 -0800 Subject: [PATCH] api: Add config store backed by filesystem --- .dockerignore | 4 + README.md | 4 +- api/adapter.go | 52 -------- api/boot.go | 8 +- api/cloud.go | 29 ++++ api/ipxe.go | 28 ++-- api/machine.go | 25 ++++ api/pixiecore.go | 22 ++-- api/server.go | 19 +-- api/store.go | 124 ++++++++++++++++++ cmd/bootcfg/main.go | 24 ++-- data/boot/default | 7 + data/cloud/default | 13 ++ .../uuid/1cff2cd8-f00a-42c8-9426-f55e6a1847f6 | 13 ++ docker-run | 2 +- dockerfiles/ipxe/dnsmasq.conf | 2 +- 16 files changed, 264 insertions(+), 112 deletions(-) create mode 100644 .dockerignore delete mode 100644 api/adapter.go create mode 100644 api/cloud.go create mode 100644 api/store.go create mode 100644 data/boot/default create mode 100644 data/cloud/default create mode 100644 data/cloud/uuid/1cff2cd8-f00a-42c8-9426-f55e6a1847f6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..63b8a2e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +data/ +Godeps/ +images/ +vagrant/ diff --git a/README.md b/README.md index 8522993f..76360e15 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Start the container services specific to the scenario you wish to test, followin Run the boot config service container. ./build - make build-docker - make run-docker + ./docker-build + ./docker-run Run the included `ipxe` Docker image which runs DHCP and sends options to point iPXE clients to the boot config service and to chainload PXE clients to iPXE. diff --git a/api/adapter.go b/api/adapter.go deleted file mode 100644 index 3a877ce9..00000000 --- a/api/adapter.go +++ /dev/null @@ -1,52 +0,0 @@ -package api - -import ( - "fmt" - "net" -) - -// MapBootAdapter maps MachineAttrs to BootConfigs using an in-memory map. -type MapBootAdapter struct { - uuids map[string]*BootConfig - macs map[string]*BootConfig - fallback *BootConfig -} - -// NewMapBootAdapter returns a new in-memory BootAdapter. -func NewMapBootAdapter() *MapBootAdapter { - return &MapBootAdapter{ - uuids: make(map[string]*BootConfig), - macs: make(map[string]*BootConfig), - } -} - -// Get returns the BootConfig for the machine with the given attributes. -// Matches are searched in priority order: UUID, MAC address, default. -func (a *MapBootAdapter) Get(attrs MachineAttrs) (*BootConfig, error) { - if config, ok := a.uuids[attrs.UUID]; ok { - return config, nil - } - if config, ok := a.macs[attrs.MAC.String()]; ok { - return config, nil - } - if a.fallback != nil { - return a.fallback, nil - } - log.Infof("No boot config found for %+v", attrs) - return nil, fmt.Errorf("no matching boot configuration") -} - -// SetUUID sets the BootConfig for the machine with the given UUID. -func (a *MapBootAdapter) SetUUID(uuid string, config *BootConfig) { - a.uuids[uuid] = config -} - -// SetMAC sets the BootConfig for the NIC with the given MAC address. -func (a *MapBootAdapter) SetMAC(mac net.HardwareAddr, config *BootConfig) { - a.macs[mac.String()] = config -} - -// SetDefault sets the default BootConfig if no machine attributes match. -func (a *MapBootAdapter) SetDefault(config *BootConfig) { - a.fallback = config -} diff --git a/api/boot.go b/api/boot.go index 46de984a..e6512505 100644 --- a/api/boot.go +++ b/api/boot.go @@ -1,13 +1,7 @@ package api -// A BootAdapter maps MachineAttrs to a BootConfig which should be used. -type BootAdapter interface { - // Get returns the BootConfig to boot the machine with the given attributes - Get(attrs MachineAttrs) (*BootConfig, error) -} - // BootConfig defines the kernel image, kernel options, and initrds to boot -// on a client machine. +// a client machine. type BootConfig struct { // the URL of the kernel boot image Kernel string `json:"kernel"` diff --git a/api/cloud.go b/api/cloud.go new file mode 100644 index 00000000..5057163b --- /dev/null +++ b/api/cloud.go @@ -0,0 +1,29 @@ +package api + +import ( + "net/http" + "strings" + "time" +) + +// CloudConfig defines the cloud-init config to initialize a client machine. +type CloudConfig struct { + Content string +} + +// cloudHandler returns a handler that responds with the cloud config the +// client machine should use. +func cloudHandler(store Store) http.Handler { + fn := func(w http.ResponseWriter, req *http.Request) { + attrs := attrsFromRequest(req) + log.Infof("cloud config request for %+v", attrs) + + config, err := store.CloudConfig(attrs) + if err != nil { + http.NotFound(w, req) + return + } + http.ServeContent(w, req, "", time.Time{}, strings.NewReader(config.Content)) + } + return http.HandlerFunc(fn) +} diff --git a/api/ipxe.go b/api/ipxe.go index d4bc7d43..bef64d35 100644 --- a/api/ipxe.go +++ b/api/ipxe.go @@ -8,23 +8,15 @@ import ( ) const ipxeBootstrap = `#!ipxe -chain config?uuid=${uuid} +chain ipxe?uuid=${uuid} ` var ipxeTemplate = template.Must(template.New("ipxe boot").Parse(`#!ipxe -kernel {{.Kernel}} cloud-config-url=cloud/config?uuid=${uuid} {{range $key, $value := .Cmdline}} {{if $value}}{{$key}}={{$value}}{{else}}{{$key}}{{end}}{{end}} -initrd {{ range $element := .Initrd }} {{$element}}{{end}} +kernel {{.Kernel}} cloud-config-url=http://172.17.0.2:8080/cloud?uuid=${uuid}{{range $key, $value := .Cmdline}} {{if $value}}{{$key}}={{$value}}{{else}}{{$key}}{{end}}{{end}} +initrd {{ range $element := .Initrd }}{{$element}}{{end}} boot `)) -// ipxeMux handles iPXE requests for boot (config) scripts. -func ipxeMux(bootConfigs BootAdapter) http.Handler { - mux := http.NewServeMux() - mux.Handle("/ipxe/boot.ipxe", ipxeInspect()) - mux.Handle("/ipxe/config", ipxeBoot(bootConfigs)) - return mux -} - // ipxeInspect returns a handler that responds with an iPXE script to gather // client machine data and chain load the real boot script. func ipxeInspect() http.Handler { @@ -37,25 +29,27 @@ func ipxeInspect() http.Handler { // ipxeBoot returns a handler which renders an iPXE boot config script based // on the machine attribtue query parameters. -func ipxeBoot(bootConfigs BootAdapter) http.Handler { +func ipxeHandler(store Store) http.Handler { fn := func(w http.ResponseWriter, req *http.Request) { - params := req.URL.Query() - attrs := MachineAttrs{UUID: params.Get("uuid")} + attrs := attrsFromRequest(req) log.Infof("iPXE boot config request for %+v", attrs) - bootConfig, err := bootConfigs.Get(attrs) + + config, err := store.BootConfig(attrs) if err != nil { http.NotFound(w, req) return } var buf bytes.Buffer - err = ipxeTemplate.Execute(&buf, bootConfig) + err = ipxeTemplate.Execute(&buf, config) if err != nil { log.Errorf("iPXE template render error: %s", err) http.NotFound(w, req) return } - buf.WriteTo(w) + if _, err := buf.WriteTo(w); err != nil { + log.Infof("error writing to response, %s", err) + } } return http.HandlerFunc(fn) } diff --git a/api/machine.go b/api/machine.go index 55edb29b..ac031e86 100644 --- a/api/machine.go +++ b/api/machine.go @@ -2,6 +2,7 @@ package api import ( "net" + "net/http" ) // MachineAttrs collects machine identifiers and attributes. @@ -9,3 +10,27 @@ type MachineAttrs struct { UUID string MAC net.HardwareAddr } + +// attrsFromRequest returns MachineAttrs from request query parameters. +func attrsFromRequest(req *http.Request) MachineAttrs { + params := req.URL.Query() + // if MAC address is unset or fails to parse, leave it nil + var macAddr net.HardwareAddr + if params.Get("mac") != "" { + macAddr, _ = parseMAC(params.Get("mac")) + } + return MachineAttrs{ + UUID: params.Get("uuid"), + MAC: macAddr, + } +} + +// parseMAC wraps net.ParseMAC with logging. +func parseMAC(s string) (net.HardwareAddr, error) { + macAddr, err := net.ParseMAC(s) + if err != nil { + log.Infof("error parsing MAC address: %s", err) + return nil, err + } + return macAddr, err +} diff --git a/api/pixiecore.go b/api/pixiecore.go index 3cd80298..fd26145a 100644 --- a/api/pixiecore.go +++ b/api/pixiecore.go @@ -2,27 +2,31 @@ package api import ( "encoding/json" - "net" "net/http" - "strings" + "path/filepath" ) -const pixiecorePath = "/v1/boot/" - // pixiecoreHandler returns a handler that renders Boot Configs as JSON to // implement the Pixiecore API specification. // https://github.com/danderson/pixiecore/blob/master/README.api.md -func pixiecoreHandler(bootConfigs BootAdapter) http.Handler { +func pixiecoreHandler(store Store) http.Handler { fn := func(w http.ResponseWriter, req *http.Request) { - mac := strings.TrimPrefix(req.URL.String(), pixiecorePath) - attrs := MachineAttrs{MAC: net.HardwareAddr(mac)} + macAddr, err := parseMAC(filepath.Base(req.URL.Path)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + // pixiecore only provides MAC addresses + attrs := MachineAttrs{MAC: macAddr} log.Infof("pixiecore boot config request for %+v", attrs) - bootConfig, err := bootConfigs.Get(attrs) + + config, err := store.BootConfig(attrs) if err != nil { http.NotFound(w, req) return } - json.NewEncoder(w).Encode(bootConfig) + if err := json.NewEncoder(w).Encode(config); err != nil { + log.Infof("error writing to response, %s", err) + } } return http.HandlerFunc(fn) } diff --git a/api/server.go b/api/server.go index c99ebef7..397e0b5c 100644 --- a/api/server.go +++ b/api/server.go @@ -10,23 +10,23 @@ var log = capnslog.NewPackageLogger("github.com/coreos/coreos-baremetal", "api") // Config configures the api Server. type Config struct { + // Store for configs (boot, cloud) + Store Store // Path to static image assets ImagePath string - // Adapter which provides BootConfigs - BootAdapter BootAdapter } -// Server serves iPXE/Pixiecore boot configs and hosts images. +// Server serves boot and cloud configs for PXE-based clients. type Server struct { + store Store imagePath string - bootConfigs BootAdapter } -// NewServer returns a new Server which uses the given BootAdapter. +// NewServer returns a new Server. func NewServer(config *Config) *Server { return &Server{ + store: config.Store, imagePath: config.ImagePath, - bootConfigs: config.BootAdapter, } } @@ -34,9 +34,12 @@ func NewServer(config *Config) *Server { func (s *Server) HTTPHandler() http.Handler { mux := http.NewServeMux() // iPXE - mux.Handle("/ipxe/", ipxeMux(s.bootConfigs)) + mux.Handle("/boot.ipxe", ipxeInspect()) + mux.Handle("/ipxe", ipxeHandler(s.store)) // Pixiecore - mux.Handle(pixiecorePath, pixiecoreHandler(s.bootConfigs)) + mux.Handle("/pixiecore/v1/boot", pixiecoreHandler(s.store)) + // cloud configs + mux.Handle("/cloud", cloudHandler(s.store)) // Kernel and Initrd Images mux.Handle("/images/", http.StripPrefix("/images/", http.FileServer(http.Dir(s.imagePath)))) return mux diff --git a/api/store.go b/api/store.go new file mode 100644 index 00000000..9b20b6aa --- /dev/null +++ b/api/store.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + + cloudinit "github.com/coreos/coreos-cloudinit/config" +) + +// Store maintains associations between machine attributes and configs. +type Store interface { + // BootConfig returns the boot config (kernel, options) for the machine. + BootConfig(attrs MachineAttrs) (*BootConfig, error) + // CloudConfig returns the cloud config user data for the machine. + CloudConfig(attrs MachineAttrs) (*CloudConfig, error) +} + +// fileStore maps machine attributes to configs based on an http.Filesystem. +type fileStore struct { + root http.FileSystem +} + +// NewFileStore returns a Store backed by a filesystem directory. +func NewFileStore(root http.FileSystem) Store { + return &fileStore{ + root: root, + } +} + +const ( + bootPrefix = "boot" + cloudPrefix = "cloud" +) + +// BootConfig returns the boot config (kernel, options) for the machine. +func (s *fileStore) BootConfig(attrs MachineAttrs) (*BootConfig, error) { + file, err := s.find(bootPrefix, attrs) + if err != nil { + log.Infof("no boot config for machine %+v", attrs) + return nil, err + } + defer file.Close() + + config := new(BootConfig) + err = json.NewDecoder(file).Decode(config) + if err != nil { + log.Infof("error decoding boot config: %s", err) + } + return config, err +} + +// CloudConfig returns the cloud config for the machine. +func (s *fileStore) CloudConfig(attrs MachineAttrs) (*CloudConfig, error) { + file, err := s.find(cloudPrefix, attrs) + if err != nil { + log.Infof("no cloud config for machine %+v", attrs) + return nil, err + } + defer file.Close() + // cloudinit requires reading the entire file + b, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + if !cloudinit.IsCloudConfig(string(b)) { + log.Infof("read an invalid cloud config for machine %+v", attrs) + return nil, fmt.Errorf("read an invalid cloud config") + } + return &CloudConfig{ + Content: string(b), + }, nil +} + +// find searches the prefix subdirectory of root for the first config file +// which matches the given machine attributes. If the error is non-nil, the +// caller must be sure to close the matched http.File. Matches are searched +// in priority order: uuid/, mac/, default. +func (s *fileStore) find(prefix string, attrs MachineAttrs) (http.File, error) { + search := []string{ + filepath.Join("uuid", attrs.UUID), + filepath.Join("mac", attrs.MAC.String()), + "/default", + } + for _, path := range filter(search) { + fullPath := filepath.Join(prefix, path) + if file, err := openFile(s.root, fullPath); err == nil { + return file, err + } + } + return nil, fmt.Errorf("no %s config for machine %+v", prefix, attrs) +} + +// filter returns only paths which have non-empty directory paths. For example, +// "uuid/123" has a directory path "uuid", while path "uuid" does not. +func filter(inputs []string) (paths []string) { + for _, path := range inputs { + if filepath.Dir(path) != "." { + paths = append(paths, path) + } + } + return paths +} + +// fileExists returns true if a file exists on the given path within the +// specified Filesystem. Returns false otherwise. +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) +} diff --git a/cmd/bootcfg/main.go b/cmd/bootcfg/main.go index d12af7d4..7e0e6484 100644 --- a/cmd/bootcfg/main.go +++ b/cmd/bootcfg/main.go @@ -1,28 +1,23 @@ package main import ( - "net/http" "flag" - "os" + "net/http" "net/url" + "os" "strings" "github.com/coreos/coreos-baremetal/api" - "github.com/coreos/pkg/flagutil" "github.com/coreos/pkg/capnslog" + "github.com/coreos/pkg/flagutil" ) var log = capnslog.NewPackageLogger("github.com/coreos/coreos-baremetal/cmd/bootcfg", "main") -var CoreOSLocal = &api.BootConfig{ - Kernel: "/images/stable/coreos_production_pxe.vmlinuz", - Initrd: []string{"/images/stable/coreos_production_pxe_image.cpio.gz"}, - Cmdline: map[string]interface{}{}, -} - func main() { flags := flag.NewFlagSet("bootcfg", flag.ExitOnError) address := flags.String("address", "127.0.0.1:8080", "HTTP listen address") + dataPath := flags.String("data-path", "./data", "Path to config data directory") imagesPath := flags.String("images-path", "./images", "Path to static image assets") // available log levels https://godoc.org/github.com/coreos/pkg/capnslog#LogLevel logLevel := flags.String("log-level", "info", "Set the logging level") @@ -39,6 +34,9 @@ func main() { if url, err := url.Parse(*address); err != nil || url.String() == "" { log.Fatal("A valid HTTP listen address is required") } + if finfo, err := os.Stat(*dataPath); err != nil || !finfo.IsDir() { + log.Fatal("A path to a config data directory is required") + } if finfo, err := os.Stat(*imagesPath); err != nil || !finfo.IsDir() { log.Fatal("A path to an image assets directory is required") } @@ -51,18 +49,14 @@ func main() { capnslog.SetGlobalLogLevel(lvl) capnslog.SetFormatter(capnslog.NewPrettyFormatter(os.Stdout, false)) - // load some boot configs - bootAdapter := api.NewMapBootAdapter() - bootAdapter.SetDefault(CoreOSLocal) - config := &api.Config{ ImagePath: *imagesPath, - BootAdapter: bootAdapter, + Store: api.NewFileStore(http.Dir(*dataPath)), } // API server server := api.NewServer(config) - log.Infof("Starting bootcfg API Server on %s", *address) + log.Infof("starting bootcfg API Server on %s", *address) err = http.ListenAndServe(*address, server.HTTPHandler()) if err != nil { log.Fatalf("failed to start listening: %s", err) diff --git a/data/boot/default b/data/boot/default new file mode 100644 index 00000000..f35b9334 --- /dev/null +++ b/data/boot/default @@ -0,0 +1,7 @@ +{ + "kernel": "/images/stable/coreos_production_pxe.vmlinuz", + "initrd": ["/images/stable/coreos_production_pxe_image.cpio.gz"], + "cmdline": { + "coreos.autologin": "" + } +} \ No newline at end of file diff --git a/data/cloud/default b/data/cloud/default new file mode 100644 index 00000000..d1928e44 --- /dev/null +++ b/data/cloud/default @@ -0,0 +1,13 @@ +#cloud-config +coreos: + units: + - name: etcd2.service + command: start + - name: fleet.service + command: start +write_files: + - path: "/home/core/welcome" + owner: "core" + permissions: "0644" + content: | + File added by default cloud-config. \ No newline at end of file diff --git a/data/cloud/uuid/1cff2cd8-f00a-42c8-9426-f55e6a1847f6 b/data/cloud/uuid/1cff2cd8-f00a-42c8-9426-f55e6a1847f6 new file mode 100644 index 00000000..990dd51a --- /dev/null +++ b/data/cloud/uuid/1cff2cd8-f00a-42c8-9426-f55e6a1847f6 @@ -0,0 +1,13 @@ +#cloud-config +coreos: + units: + - name: etcd2.service + command: start + - name: fleet.service + command: start +write_files: + - path: "/home/core/welcome" + owner: "core" + permissions: "0644" + content: | + File added by cloud-config. 1cff2cd8-f00a-42c8-9426-f55e6a1847f6 \ No newline at end of file diff --git a/docker-run b/docker-run index 59eee295..1b005653 100755 --- a/docker-run +++ b/docker-run @@ -1,3 +1,3 @@ #!/bin/bash -e -docker run -p 8080:8080 --name=bootcfg --rm -v $PWD/images:/images dghubble/bootcfg:latest -address=0.0.0.0:8080 \ No newline at end of file +docker run -p 8080:8080 --name=bootcfg --rm -v $PWD/data:/data -v $PWD/images:/images dghubble/bootcfg:latest -address=0.0.0.0:8080 -data-path=/data \ No newline at end of file diff --git a/dockerfiles/ipxe/dnsmasq.conf b/dockerfiles/ipxe/dnsmasq.conf index 5390581c..6a817c79 100644 --- a/dockerfiles/ipxe/dnsmasq.conf +++ b/dockerfiles/ipxe/dnsmasq.conf @@ -11,7 +11,7 @@ dhcp-userclass=set:ipxe,iPXE dhcp-boot=tag:!ipxe,undionly.kpxe # if PXE request came from iPXE, serve an iPXE boot script (via HTTP) -dhcp-boot=tag:ipxe,http://172.17.0.2:8080/ipxe/boot.ipxe +dhcp-boot=tag:ipxe,http://172.17.0.2:8080/boot.ipxe log-queries log-dhcp