From 766e1d95d3a173519ac646f80c815e1dbc9d7af4 Mon Sep 17 00:00:00 2001 From: Rolf Neugebauer Date: Sun, 9 Apr 2017 19:54:19 +0100 Subject: [PATCH] pkg: Add a generic metadata package This package handles meta and user data for different cloud and other platforms. It should be easy to extend to new platforms. Currently, it handles GCP metadata and a simple CDROM userdata provider. Signed-off-by: Rolf Neugebauer --- docs/metadata.md | 70 +++++++++++++ pkg/metadata/.gitignore | 4 + pkg/metadata/Dockerfile | 3 + pkg/metadata/Makefile | 44 ++++++++ pkg/metadata/main.go | 181 +++++++++++++++++++++++++++++++++ pkg/metadata/provider_cdrom.go | 41 ++++++++ pkg/metadata/provider_gcp.go | 105 +++++++++++++++++++ 7 files changed, 448 insertions(+) create mode 100644 docs/metadata.md create mode 100644 pkg/metadata/.gitignore create mode 100644 pkg/metadata/Dockerfile create mode 100644 pkg/metadata/Makefile create mode 100644 pkg/metadata/main.go create mode 100644 pkg/metadata/provider_cdrom.go create mode 100644 pkg/metadata/provider_gcp.go diff --git a/docs/metadata.md b/docs/metadata.md new file mode 100644 index 000000000..414610952 --- /dev/null +++ b/docs/metadata.md @@ -0,0 +1,70 @@ +# Metadata and Userdata handling + +Most providers offer a mechanism to provide a OS with some additional +metadata as well as custom userdata. `Metadata` in this context is +fixed information provided by the provider (e.g. the host +name). `Userdata` is completely custom data which a user can supply to +the instance. + +The [metadata package](../pkg/metadata/) handles both metadata and +userdata for a number of providers (see below). It abstracts over the +provider differences by exposing both metadata and userdata in a +directory hierarchy under `/var/config`. For example, sshd config +files from the metadata are placed under `/var/config/ssh`. + +Userdata is assumed to be a single string and the contents will be +stored under `/var/config/userdata`. If userdata is a json file, the +contents will be further processed, where different keys cause +directories to be created and the directories are populated with files. Foer example, the following userdata file: +``` +{ + "ssh" : { + "sshd_config" : { + "perm" : "0600", + "content": "PermitRootLogin yes\nPasswordAuthentication no" + } + }, + "foo" : { + "bar" : { + "perm": "0644", + "content": "foobar" + }, + "baz" : { + "perm": "0600", + "content": "bar" + } + } +} +``` +will generate the following files: +``` +/var/config/ssh/sshd_config +/var/config/foo/bar +/var/config/foo/baz +``` + +This hierarchy can then be used by individual containers, who can bind +mount the config sub-directory into their namespace where it is +needed. + + +# Providers + +Below is a list of supported providers and notes on what is supported. We will add more over time. + + +## GCP + +GCP metadata is reached via a well known URL +(`http://metadata.google.internal/`) and currently +we extract the hostname and populate the +`/var/config/ssh/authorized_keys` from metadata. In the future we'll +add more complete SSH support. + +GCP userdata is extracted from `/computeMetadata/v1/instance/attributes/userdata`. + + +## HyperKit + +HyperKit does not support metadata and userdata is passed in as a single file via a ISO9660 image. + diff --git a/pkg/metadata/.gitignore b/pkg/metadata/.gitignore new file mode 100644 index 000000000..db2b4ca32 --- /dev/null +++ b/pkg/metadata/.gitignore @@ -0,0 +1,4 @@ +dev +proc +sys +usr diff --git a/pkg/metadata/Dockerfile b/pkg/metadata/Dockerfile new file mode 100644 index 000000000..de08c5b07 --- /dev/null +++ b/pkg/metadata/Dockerfile @@ -0,0 +1,3 @@ +FROM scratch +COPY . ./ +CMD ["/usr/bin/metadata"] diff --git a/pkg/metadata/Makefile b/pkg/metadata/Makefile new file mode 100644 index 000000000..2de6f1b83 --- /dev/null +++ b/pkg/metadata/Makefile @@ -0,0 +1,44 @@ +GO_COMPILE=mobylinux/go-compile:90607983001c2789911afabf420394d51f78ced8@sha256:188beb574d4702a92fa3396a57cabaade28003c82f9413c3121a370ff8becea4 + +SHA_IMAGE=alpine:3.5@sha256:dfbd4a3a8ebca874ebd2474f044a0b33600d4523d03b0df76e5c5986cb02d7e8 + +METADATA_BINARY=usr/bin/metadata + +IMAGE=metadata + +.PHONY: tag push clean container +default: push + +$(METADATA_BINARY): $(wildcard *.go) Makefile + mkdir -p $(dir $@) + tar cf - $^ | docker run --rm --net=none --log-driver=none -i $(GO_COMPILE) -o $@ | tar xf - + +DIRS=dev proc sys +$(DIRS): + mkdir -p $@ + +DEPS=$(DIRS) $(METADATA_BINARY) + +container: Dockerfile $(DEPS) + tar cf - $^ | docker build --no-cache -t $(IMAGE):build - + +hash: Dockerfile $(DEPS) + find $^ -type f | xargs cat | docker run --rm -i $(SHA_IMAGE) sha1sum - | sed 's/ .*//' > hash + +push: hash container + docker pull mobylinux/$(IMAGE):$(shell cat hash) || \ + (docker tag $(IMAGE):build mobylinux/$(IMAGE):$(shell cat hash) && \ + docker push mobylinux/$(IMAGE):$(shell cat hash)) + docker rmi $(IMAGE):build + rm -f hash + +tag: hash container + docker pull mobylinux/$(IMAGE):$(shell cat hash) || \ + docker tag $(IMAGE):build mobylinux/$(IMAGE):$(shell cat hash) + docker rmi $(IMAGE):build + rm -f hash + +clean: + rm -rf hash $(DIRS) usr + +.DELETE_ON_ERROR: diff --git a/pkg/metadata/main.go b/pkg/metadata/main.go new file mode 100644 index 000000000..52adcb952 --- /dev/null +++ b/pkg/metadata/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + "os" + "path" + "strconv" + "syscall" +) + +const ( + // ConfigPath is where the data is extracted to + ConfigPath = "/var/config" + + // MountPoint is where the CDROM is mounted + MountPoint = "/cdrom" + + // Hostname is the filename in configPath where the hostname is stored + Hostname = "hostname" + + // SSH is the path where sshd configuration from the provider is stored + SSH = "ssh" + + // TODO(rneugeba): Need to check this is the same everywhere + cdromDev = "/dev/sr0" +) + +// Provider is a generic interface for metadata/userdata providers. +type Provider interface { + // Probe returns true if the provider was detected. + Probe() bool + + // Extract user data. This may write some data, specific to a + // provider, to ConfigPath and should return the generic userdata. + Extract() ([]byte, error) +} + +// netProviders is a list of Providers offering metadata/userdata over the network +var netProviders []Provider + +// cdromProviders is a list of Providers offering metadata/userdata data via CDROM +var cdromProviders []Provider + +func init() { + netProviders = []Provider{NewGCP()} + cdromProviders = []Provider{NewCDROM()} +} + +func main() { + if err := os.MkdirAll(ConfigPath, 0755); err != nil { + log.Fatalf("Could not create %s: %s", ConfigPath, err) + } + + var userdata []byte + var err error + found := false + for _, p := range netProviders { + if p.Probe() { + userdata, err = p.Extract() + found = true + break + } + } + if !found { + log.Printf("Trying CDROM") + if err := os.Mkdir(MountPoint, 0755); err != nil { + log.Printf("CDROM: Failed to create %s: %s", MountPoint, err) + goto ErrorOut + } + if err := mountCDROM(cdromDev, MountPoint); err != nil { + log.Printf("Failed to mount cdrom: %s", err) + goto ErrorOut + } + defer syscall.Unmount(MountPoint, 0) + // Don't worry about removing MountPoint. We are in a container + + for _, p := range cdromProviders { + if p.Probe() { + userdata, err = p.Extract() + found = true + break + } + } + } + +ErrorOut: + if !found { + log.Printf("No metadata/userdata found. Bye") + return + } + + if err != nil { + log.Printf("Error during metadata probe: %s", err) + } + + if userdata != nil { + if err := processUserData(userdata); err != nil { + log.Printf("Could not extract user data: %s", err) + } + } + + // Handle setting the hostname as a special case. We want to + // do this early and don't really want another container for it. + hostname, err := ioutil.ReadFile(path.Join(ConfigPath, Hostname)) + if err == nil { + err := syscall.Sethostname(hostname) + if err != nil { + log.Printf("Failed to set hostname: %s", err) + } else { + log.Printf("Set hostname to: %s", string(hostname)) + } + } +} + +// If the userdata is a json file, create a directory/file hierarchy. +// Example: +// { +// "foobar" : { +// "foo" : { +// "perm": "0644", +// "content": "hello" +// } +// } +// Will create foobar/foo with mode 0644 and content "hello" +func processUserData(data []byte) error { + // Always write the raw data to a file + err := ioutil.WriteFile(path.Join(ConfigPath, "userdata"), data, 0644) + if err != nil { + log.Printf("Could not write userdata: %s", err) + return err + } + + var fd interface{} + if err := json.Unmarshal(data, &fd); err != nil { + // Userdata is no JSON, presumably... + log.Printf("Could not unmarshall userdata: %s", err) + // This is not an error + return nil + } + cm := fd.(map[string]interface{}) + for d, val := range cm { + dir := path.Join(ConfigPath, d) + if err := os.Mkdir(dir, 0755); err != nil { + log.Printf("Failed to create %s: %s", dir, err) + continue + } + files := val.(map[string]interface{}) + for f, i := range files { + fi := i.(map[string]interface{}) + if _, ok := fi["perm"]; !ok { + log.Printf("No permission provided %s:%s", f, fi) + continue + } + if _, ok := fi["content"]; !ok { + log.Printf("No content provided %s:%s", f, fi) + continue + } + c := fi["content"].(string) + p, err := strconv.ParseUint(fi["perm"].(string), 8, 32) + if err != nil { + log.Printf("Failed to parse permission %s: %s", fi, err) + continue + } + if err := ioutil.WriteFile(path.Join(dir, f), []byte(c), os.FileMode(p)); err != nil { + log.Printf("Failed to write %s/%s: %s", dir, f, err) + continue + + } + } + } + + return nil +} + +// mountCDROM mounts a CDROM/DVD device under mountPoint +func mountCDROM(device, mountPoint string) error { + // We may need to poll a little for device ready + return syscall.Mount(device, mountPoint, "iso9660", syscall.MS_RDONLY, "") +} diff --git a/pkg/metadata/provider_cdrom.go b/pkg/metadata/provider_cdrom.go new file mode 100644 index 000000000..90c45970e --- /dev/null +++ b/pkg/metadata/provider_cdrom.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path" +) + +const ( + configFile = "config" +) + +// ProviderCDROM is the type implementing the Provider interface for CDROMs +// It looks for a file called 'configFile' in the root +type ProviderCDROM struct { +} + +// NewCDROM returns a new ProviderCDROM +func NewCDROM() *ProviderCDROM { + return &ProviderCDROM{} +} + +// Probe checks if the CD has the right file +func (p *ProviderCDROM) Probe() bool { + _, err := os.Stat(path.Join(MountPoint, configFile)) + if err != nil { + log.Printf("CDROM: Probe -> %s", err) + } + return (!os.IsNotExist(err)) +} + +// Extract gets both the CDROM specific and generic userdata +func (p *ProviderCDROM) Extract() ([]byte, error) { + data, err := ioutil.ReadFile(path.Join(MountPoint, configFile)) + if err != nil { + return nil, fmt.Errorf("CDROM: Error reading file: %s", err) + } + return data, nil +} diff --git a/pkg/metadata/provider_gcp.go b/pkg/metadata/provider_gcp.go new file mode 100644 index 000000000..180c98986 --- /dev/null +++ b/pkg/metadata/provider_gcp.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "path" + "strings" + "time" +) + +const ( + project = "http://metadata.google.internal/computeMetadata/v1/project/" + instance = "http://metadata.google.internal/computeMetadata/v1/instance/" +) + +// ProviderGCP is the type implementing the Provider interface for GCP +type ProviderGCP struct { +} + +// NewGCP returns a new ProviderGCP +func NewGCP() *ProviderGCP { + return &ProviderGCP{} +} + +// Probe checks if we are running on GCP +func (p *ProviderGCP) Probe() bool { + // Getting the hostname should always work... + _, err := gcpGet(instance + "hostname") + log.Printf("GCP: Probe -> %s", err) + return (err == nil) +} + +// Extract gets both the GCP specific and generic userdata +func (p *ProviderGCP) Extract() ([]byte, error) { + // Get host name. This must not fail + hostname, err := gcpGet(instance + "hostname") + if err != nil { + return nil, err + } + err = ioutil.WriteFile(path.Join(ConfigPath, Hostname), hostname, 0644) + if err != nil { + return nil, fmt.Errorf("GCP: Failed to write hostname: %s", err) + } + + // SSH keys: + // TODO also retrieve the instance keys and respect block + // project keys see: + // https://cloud.google.com/compute/docs/instances/ssh-keys + // The keys have usernames attached, but as a simplification + // we are going to add them all to one root file + // TODO split them into individual user files and make the ssh + // container construct those users + sshKeys, err := gcpGet(project + "attributes/sshKeys") + if err == nil { + rootKeys := "" + for _, line := range strings.Split(string(sshKeys), "\n") { + parts := strings.SplitN(line, ":", 2) + // ignoring username for now + if len(parts) == 2 { + rootKeys = rootKeys + parts[1] + "\n" + } + } + err = ioutil.WriteFile(path.Join(ConfigPath, SSH, "authorized_keys"), []byte(rootKeys), 0600) + if err != nil { + log.Printf("GCP: Failed to write ssh keys: %s", err) + } + } + + // Generic userdata + userData, err := gcpGet(instance + "attributes/userdata") + if err != nil { + log.Printf("GCP: Failed to get user-data: %s", err) + // This is not an error + return nil, nil + } + return userData, nil +} + +// gcpGet requests and extracts the requested URL +func gcpGet(url string) ([]byte, error) { + var client = &http.Client{ + Timeout: time.Second * 2, + } + + req, err := http.NewRequest("", url, nil) + if err != nil { + return nil, fmt.Errorf("GCP: http.NewRequest failed: %s", err) + } + req.Header.Set("Metadata-Flavor", "Google") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GCP: Could not contact metadata service: %s", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("GCP: Status not ok: %d", resp.StatusCode) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("GCP: Failed to read http response: %s", err) + } + return body, nil +}