Skip to content

Commit

Permalink
node-installer: initialize
Browse files Browse the repository at this point in the history
  • Loading branch information
malt3 committed Mar 16, 2024
1 parent 7cf0533 commit 121181c
Show file tree
Hide file tree
Showing 18 changed files with 1,140 additions and 1 deletion.
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ go 1.21
use (
.
./service-mesh
./node-installer
)
44 changes: 44 additions & 0 deletions node-installer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Contrast node installer

This program runs as a daemonset on every node of a Kubernetes cluster.
It expects the host filesystem of the node to be mounted under `/host`.
On start, it will read a configuration file under `$CONFIG_DIR/contrast-node-install.json` and install binary artifacts on the host filesystem according to the configuration.
After installing binary artifacts, it installs and patches configuration files for the Contrast runtime class `contrast-cc-isolation` and restarts containerd.

## Configuration

By default, the installer ships with a config file under `/config/contrast-node-install.json`, which takes binary artifacts from the container image.
If desired, you can replace the configuration using a Kubernetes configmap by mounting it into the container.

- `files`: List of files to be installed.
- `files[*].url`: Source of the file's content. Use `http://` or `https://` to download it or `file://` to copy a file from the container image.
- `files[*].path`: Target location of the file on the host filesystem.
- `files[*].integrity`: Expected Subresource Integrity (SRI) digest of the file. Only required if url starts with `http://` or `https://`.

Consider the following example:

```json
{
"files": [
{
"url": "https://cdn.confidential.cloud/contrast/node-components/2024-03-13/kata-containers.img",
"path": "/opt/edgeless/share/kata-containers.img",
"integrity": "sha256-EdFywKAU+xD0BXmmfbjV4cB6Gqbq9R9AnMWoZFCM3A0="
},
{
"url": "https://cdn.confidential.cloud/contrast/node-components/2024-03-13/kata-containers-igvm.img",
"path": "/opt/edgeless/share/kata-containers-igvm.img",
"integrity": "sha256-E9Ttx6f9QYwKlQonO/fl1bF2MNBoU4XG3/HHvt9Zv30="
},
{
"url": "https://cdn.confidential.cloud/contrast/node-components/2024-03-13/cloud-hypervisor-cvm",
"path": "/opt/edgeless/bin/cloud-hypervisor-snp",
"integrity": "sha256-coTHzd5/QLjlPQfrp9d2TJTIXKNuANTN7aNmpa8PRXo="
},
{
"url": "file:///opt/edgeless/bin/containerd-shim-contrast-cc-v2",
"path": "/opt/edgeless/bin/containerd-shim-contrast-cc-v2",
}
]
}
```
213 changes: 213 additions & 0 deletions node-installer/cmd/node-installer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"

"github.com/edgelesssys/contrast/node-installer/internal/asset"
"github.com/edgelesssys/contrast/node-installer/internal/config"
"github.com/edgelesssys/contrast/node-installer/internal/constants"
"github.com/pelletier/go-toml"
)

func main() {
fetcher := asset.NewDefaultFetcher()
if err := run(context.Background(), fetcher); err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Installation completed successfully.")
}

func run(ctx context.Context, fetcher assetFetcher) error {
configDir := envWithDefault("CONFIG_DIR", "/config")
hostMount := envWithDefault("HOST_MOUNT", "/host")
binDir := filepath.Join(hostMount, "opt", "edgeless", "bin")

// Create directory structure, ignore if it already exists
_ = os.MkdirAll(binDir, os.ModePerm)
_ = os.MkdirAll(filepath.Join(hostMount, "opt", "edgeless", "share"), os.ModePerm)
_ = os.MkdirAll(filepath.Join(hostMount, "opt", "edgeless", "etc"), os.ModePerm)
_ = os.MkdirAll(filepath.Join(hostMount, "etc", "containerd"), os.ModePerm)

configPath := filepath.Join(configDir, "contrast-node-install.json")
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config %q: %w", configPath, err)
}
var config config.Config
if err := json.Unmarshal(configData, &config); err != nil {
return fmt.Errorf("parsing config %q: %w", configPath, err)
}
if err := config.Validate(); err != nil {
return fmt.Errorf("validating config: %w", err)
}

for _, file := range config.Files {
fmt.Printf("Fetching %q to %q\n", file.URL, file.Path)
var fetchErr error
if file.Integrity == "" {
fetchErr = fetcher.FetchUnchecked(ctx, file.URL, filepath.Join(hostMount, file.Path))
} else {
_, fetchErr = fetcher.Fetch(ctx, file.URL, filepath.Join(hostMount, file.Path), file.Integrity)
}
if fetchErr != nil {
return fmt.Errorf("fetching file from %q to %q: %w", file.URL, file.Path, fetchErr)
}
}
items, err := os.ReadDir(binDir)
if err != nil {
return fmt.Errorf("reading bin directory %q: %w", binDir, err)
}

for _, item := range items {
if !item.Type().IsRegular() {
continue
}
if err := os.Chmod(filepath.Join(binDir, item.Name()), 0o755); err != nil {
return fmt.Errorf("chmod %q: %w", item.Name(), err)
}
}
err = containerdRuntimeConfig(filepath.Join(hostMount, "opt", "edgeless", "etc", "configuration-clh-snp.toml"))
if err != nil {
return fmt.Errorf("generating clh_config.toml: %w", err)
}
containerdConfigPath := filepath.Join(hostMount, "etc", "containerd", "config.toml")
err = patchContainerdConfig(containerdConfigPath)
if err != nil {
return fmt.Errorf("patching containerd config: %w", err)
}

return restartHostContainerd(containerdConfigPath)
}

func envWithDefault(key, dflt string) string {
value, ok := os.LookupEnv(key)
if !ok {
return dflt
}
return value
}

func containerdRuntimeConfig(path string) error {
return os.WriteFile(path, []byte(constants.ContainerdRuntimeConfig), os.ModePerm)
}

func patchContainerdConfig(path string) error {
existing, err := parseExistingContainerdConfig(path)
if err != nil {
existing = constants.ContainerdBaseConfig()
}
existingRaw, err := toml.Marshal(existing)
if err != nil {
return err
}

// Add tardev snapshotter
if existing.ProxyPlugins == nil {
existing.ProxyPlugins = make(map[string]config.ProxyPlugin)
}
if _, ok := existing.ProxyPlugins["tardev"]; !ok {
existing.ProxyPlugins["tardev"] = constants.TardevSnapshotterConfigFragment()
}

// Add contrast-cc runtime
ensureMapPath(&existing.Plugins, constants.CRIFQDN, "containerd", "runtimes")
runtimes := existing.Plugins[constants.CRIFQDN].(map[string]any)["containerd"].(map[string]any)["runtimes"].(map[string]any)
runtimes[constants.RuntimeName] = constants.ContainerdRuntimeConfigFragment()

rawConfig, err := toml.Marshal(existing)
if err != nil {
return err
}

if slices.Equal(existingRaw, rawConfig) {
return nil
}

fmt.Println("Patching containerd config")
return os.WriteFile(path, rawConfig, os.ModePerm)
}

func parseExistingContainerdConfig(path string) (config.ContainerdConfig, error) {
configData, err := os.ReadFile(path)
if err != nil {
return config.ContainerdConfig{}, err
}

var cfg config.ContainerdConfig
if err := toml.Unmarshal(configData, &cfg); err != nil {
return config.ContainerdConfig{}, err
}

return cfg, nil
}

func restartHostContainerd(containerdConfigPath string) error {
// get mtime of the config file
info, err := os.Stat(containerdConfigPath)
if err != nil {
return fmt.Errorf("stat %q: %w", containerdConfigPath, err)
}
configMtime := info.ModTime()

// get containerd start time
// Note that "--timestamp=unix" is not supported in the installed version of systemd (v250) at the time of writing.
containerdStartTime, err := exec.Command("nsenter", "--target", "1", "--mount", "--", "systemctl", "show", "--timestamp=utc", "--property=ActiveEnterTimestamp", "containerd").CombinedOutput()
if err != nil {
return fmt.Errorf("getting containerd start time: %w %q", err, containerdStartTime)
}

// format: ActiveEnterTimestamp=Day YYYY-MM-DD HH:MM:SS UTC
dayUTC := strings.TrimPrefix(strings.TrimSpace(string(containerdStartTime)), "ActiveEnterTimestamp=")
startTime, err := time.Parse("Mon 2006-01-02 15:04:05 MST", dayUTC)
if err != nil {
return fmt.Errorf("parsing containerd start time: %w", err)
}

fmt.Printf("containerd start time: %s\n", startTime.Format(time.RFC3339))
if startTime.After(configMtime) {
fmt.Println("containerd already running with the new config")
return nil
}

// This command will restart containerd on the host and will take down the installer with it.
out, err := exec.Command("nsenter", "--target", "1", "--mount", "--", "systemctl", "restart", "containerd").CombinedOutput()
if err != nil {
return fmt.Errorf("restarting containerd: %w: %s", err, out)
}
fmt.Printf("containerd restarted: %s\n", out)
return nil
}

func ensureMapPath(in *map[string]any, path ...string) bool {
var changed bool
if len(path) == 0 {
return false
}
if *in == nil {
changed = true
*in = make(map[string]any)
}
current := *in
for _, p := range path {
if current[p] == nil {
changed = true
current[p] = make(map[string]any)
}
current = current[p].(map[string]any)
}
return changed
}

type assetFetcher interface {
Fetch(ctx context.Context, sourceURI, destination, integrity string) (changed bool, retErr error)
FetchUnchecked(ctx context.Context, sourceURI, destination string) error
}
7 changes: 7 additions & 0 deletions node-installer/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/edgelesssys/contrast/node-installer

go 1.21

require github.com/pelletier/go-toml v1.9.5

require golang.org/x/sys v0.18.0
4 changes: 4 additions & 0 deletions node-installer/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
102 changes: 102 additions & 0 deletions node-installer/internal/asset/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package asset

import (
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"errors"
"fmt"
"hash"
"net/url"
"sync"
)

// Fetcher can retrieve assets from various sources.
// It works by delegating to a handler for the scheme of the source URI.
type Fetcher struct {
handlers map[string]handler
mux sync.RWMutex
}

// NewDefaultFetcher creates a new fetcher with default handlers.
func NewDefaultFetcher() *Fetcher {
fetcher := NewFetcher()
fetcher.RegisterHandler("file", NewFileFetcher())
fetcher.RegisterHandler("http", NewHTTPFetcher())
return fetcher
}

// NewFetcher creates a new fetcher.
func NewFetcher() *Fetcher {
return &Fetcher{
handlers: make(map[string]handler),
}
}

// Fetch retrieves a file from a source URI.
func (f *Fetcher) Fetch(ctx context.Context, sourceURI, destination, integrity string) (changed bool, retErr error) {
uri, err := url.Parse(sourceURI)
if err != nil {
return false, err
}
hasher, expectedSum, err := hashFromIntegrity(integrity)
if err != nil {
return false, err
}
schemeFetcher := f.getHandler(uri.Scheme)
if schemeFetcher == nil {
return false, errors.New("no handler for scheme " + uri.Scheme)
}
return schemeFetcher.Fetch(ctx, uri, destination, expectedSum, hasher)
}

// FetchUnchecked retrieves a file from a source URI without verifying its integrity.
func (f *Fetcher) FetchUnchecked(ctx context.Context, sourceURI, destination string) error {
uri, err := url.Parse(sourceURI)
if err != nil {
return err
}
schemeFetcher := f.getHandler(uri.Scheme)
if schemeFetcher == nil {
return errors.New("no handler for scheme " + uri.Scheme)
}
return schemeFetcher.FetchUnchecked(ctx, uri, destination)
}

// RegisterHandler registers a handler for a scheme.
func (f *Fetcher) RegisterHandler(scheme string, h handler) {
f.mux.Lock()
defer f.mux.Unlock()
f.handlers[scheme] = h
}

func (f *Fetcher) getHandler(scheme string) handler {
f.mux.RLock()
defer f.mux.RUnlock()
return f.handlers[scheme]
}

type handler interface {
Fetch(ctx context.Context, uri *url.URL, destination string, expectedSum []byte, hasher hash.Hash) (bool, error)
FetchUnchecked(ctx context.Context, uri *url.URL, destination string) error
}

func hashFromIntegrity(integrity string) (hash.Hash, []byte, error) {
var hash hash.Hash
switch integrity[:7] {
case "sha256-":
hash = sha256.New()
case "sha384-":
hash = sha512.New384()
case "sha512-":
hash = sha512.New()
default:
return nil, nil, fmt.Errorf("unsupported hash algorithm: %s", integrity[:7])
}
expectedSum, err := base64.StdEncoding.DecodeString(integrity[7:])
if err != nil {
return nil, nil, fmt.Errorf("decoding integrity value: %w", err)
}
return hash, expectedSum, nil
}
Loading

0 comments on commit 121181c

Please sign in to comment.