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 19, 2024
1 parent 4cc7026 commit 3b9fb59
Show file tree
Hide file tree
Showing 21 changed files with 1,565 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 CC-enabled 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",
}
]
}
```
17 changes: 17 additions & 0 deletions node-installer/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module github.com/edgelesssys/contrast/node-installer

go 1.21

require (
github.com/pelletier/go-toml v1.9.5
github.com/stretchr/testify v1.9.0
go.uber.org/goleak v1.3.0
golang.org/x/sys v0.18.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
22 changes: 22 additions & 0 deletions node-installer/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
89 changes: 89 additions & 0 deletions node-installer/internal/asset/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package asset

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

// 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
}

// NewDefaultFetcher creates a new fetcher with default handlers.
func NewDefaultFetcher() *Fetcher {
fileFetcher := NewFileFetcher()
httpFetcher := NewHTTPFetcher()
return NewFetcher(map[string]handler{
"file": fileFetcher,
"http": httpFetcher,
"https": httpFetcher,
})
}

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

// 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.handlers[uri.Scheme]
if schemeFetcher == nil {
return false, fmt.Errorf("no handler for scheme %s", 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.handlers[uri.Scheme]
if schemeFetcher == nil {
return fmt.Errorf("no handler for scheme %s", uri.Scheme)
}
return schemeFetcher.FetchUnchecked(ctx, uri, destination)
}

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
}
139 changes: 139 additions & 0 deletions node-installer/internal/asset/fetcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package asset

import (
"bytes"
"context"
"io"
"net/http"
"os"
"path/filepath"
"testing"

"github.com/edgelesssys/contrast/node-installer/internal/fileop"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)

func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

func TestFetch(t *testing.T) {
sourceDir, err := os.MkdirTemp("", "fileop-test-empty")
require.NoError(t, err)
fooSource := filepath.Join(sourceDir, "foo")
require.NoError(t, os.WriteFile(fooSource, []byte("foo"), 0o644))
defer os.RemoveAll(sourceDir)

testCases := map[string]struct {
dstContent []byte
sourceURIs []string
sri string
wantModified bool
wantErr bool
}{
"identical": {
dstContent: []byte("foo"),
sourceURIs: []string{"file://" + fooSource, "http://example.com/foo"},
sri: "sha256-LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=",
},
"different": {
dstContent: []byte("bar"),
sourceURIs: []string{"file://" + fooSource, "http://example.com/foo"},
sri: "sha256-LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=",
wantModified: true,
},
"sri mismatch": {
dstContent: []byte("foo"),
sourceURIs: []string{"file://" + fooSource, "http://example.com/foo"},
sri: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
wantErr: true,
},
"unchecked": {
dstContent: []byte("bar"),
sourceURIs: []string{"file://" + fooSource, "http://example.com/foo"},
wantModified: true,
},
"src not found": {
dstContent: []byte("foo"),
sourceURIs: []string{"file://this/file/is/nonexistent", "http://example.com//this/file/is/nonexistent"},
wantErr: true,
},
"dst not found": {
sourceURIs: []string{"file://" + fooSource, "http://example.com/foo"},
wantModified: true,
},
}
for name, tc := range testCases {
for _, sourceURI := range tc.sourceURIs {
t.Run(name+"_"+sourceURI, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)

emptyDir, err := os.MkdirTemp("", "fileop-test-empty")
require.NoError(err)
defer os.RemoveAll(emptyDir)
dst := filepath.Join(emptyDir, "dst")
if tc.dstContent != nil {
require.NoError(os.WriteFile(dst, tc.dstContent, 0o644))
defer os.Remove(dst)
}
httpResponses := map[string][]byte{
"/foo": []byte("foo"),
}
fetcher := NewFetcher(map[string]handler{
"file": NewFileFetcher(),
"http": newFakeHTTPFetcher(httpResponses),
})
var modified bool
var fetchErr error
if tc.sri != "" {
modified, fetchErr = fetcher.Fetch(context.Background(), sourceURI, dst, tc.sri)
} else {
fetchErr = fetcher.FetchUnchecked(context.Background(), sourceURI, dst)
modified = true // FetchUnchecked always modifies the file
}
if tc.wantErr {
require.Error(fetchErr)
return
}
require.NoError(fetchErr)
assert.Equal(tc.wantModified, modified)
got, err := os.ReadFile(dst)
require.NoError(err)
assert.Equal([]byte("foo"), got)
})
}
}
}

func newFakeHTTPFetcher(responses map[string][]byte) *HTTPFetcher {
hClient := http.Client{
Transport: &fakeRoundTripper{store: responses},
}

return &HTTPFetcher{
client: &hClient,
mover: fileop.NewDefault(),
}
}

type fakeRoundTripper struct {
store map[string][]byte
}

func (f *fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
path := req.URL.Path
body, ok := f.store[path]
if !ok {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewReader([]byte("not found"))),
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}, nil
}
65 changes: 65 additions & 0 deletions node-installer/internal/asset/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package asset

import (
"context"
"fmt"
"hash"
"io"
"net/url"
"os"
"slices"

"github.com/edgelesssys/contrast/node-installer/internal/fileop"
)

// FileFetcher is a Fetcher that retrieves assets from a file.
// It handles the "file" scheme.
type FileFetcher struct {
copier copier
}

// NewFileFetcher creates a new file fetcher.
func NewFileFetcher() *FileFetcher {
return &FileFetcher{copier: fileop.NewDefault()}
}

// Fetch retrieves a file from the local filesystem.
func (f *FileFetcher) Fetch(_ context.Context, uri *url.URL, destination string, expectedSum []byte, hasher hash.Hash) (bool, error) {
if uri.Scheme != "file" {
return false, fmt.Errorf("file fetcher does not support scheme %s", uri.Scheme)
}
sourceFile, err := os.Open(uri.Path)
if err != nil {
return false, fmt.Errorf("opening file: %w", err)
}
defer sourceFile.Close()

if _, err := io.Copy(hasher, sourceFile); err != nil {
return false, fmt.Errorf("hashing file: %w", err)
}
if err := sourceFile.Close(); err != nil {
return false, fmt.Errorf("closing file: %w", err)
}
actualSum := hasher.Sum(nil)
if !slices.Equal(actualSum, expectedSum) {
return false, fmt.Errorf("file hash mismatch: expected %x, got %x", expectedSum, actualSum)
}
changed, err := f.copier.CopyOnDiff(uri.Path, destination)
if err != nil {
return false, fmt.Errorf("copying file: %w", err)
}
return changed, nil
}

// FetchUnchecked retrieves a file from the local filesystem without verifying its integrity.
func (f *FileFetcher) FetchUnchecked(_ context.Context, uri *url.URL, destination string) error {
if uri.Scheme != "file" {
return fmt.Errorf("file fetcher does not support scheme %s", uri.Scheme)
}
_, err := f.copier.CopyOnDiff(uri.Path, destination)
return err
}

type copier interface {
CopyOnDiff(src, dst string) (bool, error)
}
Loading

0 comments on commit 3b9fb59

Please sign in to comment.