Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

node-installer: initialize #253

Merged
merged 1 commit into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/styles/config/vocabularies/edgeless/accept.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ rollout
SBOM
sigstore
SSD
Subresource
malt3 marked this conversation as resolved.
Show resolved Hide resolved
substituters
superset
Syft
Expand Down
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
)
46 changes: 46 additions & 0 deletions node-installer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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://`.
- `runtimeHandlerName`: Name of the container runtime.

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",
}
],
"runtimeHandlerName": "contrast-cc"
}
```
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) (changed bool, retErr error) {
uri, err := url.Parse(sourceURI)
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.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) (bool, 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
}
138 changes: 138 additions & 0 deletions node-installer/internal/asset/fetcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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 {
modified, fetchErr = fetcher.FetchUnchecked(context.Background(), sourceURI, dst)
}
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: &stubRoundTripper{store: responses},
}

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

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

func (f *stubRoundTripper) 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
}
64 changes: 64 additions & 0 deletions node-installer/internal/asset/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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
malt3 marked this conversation as resolved.
Show resolved Hide resolved
}

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

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