From 1cce9c02cb2c91fe61fd532cc2bb8815b54882de Mon Sep 17 00:00:00 2001 From: Malte Poll <1780588+malt3@users.noreply.github.com> Date: Fri, 15 Mar 2024 16:01:16 +0100 Subject: [PATCH] node-installer: initialize --- go.work | 1 + node-installer/README.md | 44 ++++ node-installer/go.mod | 17 ++ node-installer/go.sum | 22 ++ node-installer/internal/asset/fetcher.go | 89 +++++++ node-installer/internal/asset/fetcher_test.go | 139 +++++++++++ node-installer/internal/asset/file.go | 65 +++++ node-installer/internal/asset/http.go | 122 ++++++++++ node-installer/internal/config/config.go | 75 ++++++ node-installer/internal/config/config_test.go | 111 +++++++++ node-installer/internal/config/containerd.go | 95 ++++++++ .../constants/configuration-clh-snp.toml | 43 ++++ .../internal/constants/constants.go | 57 +++++ .../internal/constants/containerd-config.toml | 47 ++++ .../internal/fileop/copy_general.go | 12 + node-installer/internal/fileop/copy_linux.go | 46 ++++ node-installer/internal/fileop/fileop.go | 191 +++++++++++++++ node-installer/internal/fileop/fileop_test.go | 140 +++++++++++ node-installer/node-installer.go | 224 ++++++++++++++++++ .../contrast-node-installer/package.nix | 49 ++++ packages/by-name/contrast/package.nix | 5 +- 21 files changed, 1593 insertions(+), 1 deletion(-) create mode 100644 node-installer/README.md create mode 100644 node-installer/go.mod create mode 100644 node-installer/go.sum create mode 100644 node-installer/internal/asset/fetcher.go create mode 100644 node-installer/internal/asset/fetcher_test.go create mode 100644 node-installer/internal/asset/file.go create mode 100644 node-installer/internal/asset/http.go create mode 100644 node-installer/internal/config/config.go create mode 100644 node-installer/internal/config/config_test.go create mode 100644 node-installer/internal/config/containerd.go create mode 100644 node-installer/internal/constants/configuration-clh-snp.toml create mode 100644 node-installer/internal/constants/constants.go create mode 100644 node-installer/internal/constants/containerd-config.toml create mode 100644 node-installer/internal/fileop/copy_general.go create mode 100644 node-installer/internal/fileop/copy_linux.go create mode 100644 node-installer/internal/fileop/fileop.go create mode 100644 node-installer/internal/fileop/fileop_test.go create mode 100644 node-installer/node-installer.go create mode 100644 packages/by-name/contrast-node-installer/package.nix diff --git a/go.work b/go.work index 6dd3847131..e3971c83b7 100644 --- a/go.work +++ b/go.work @@ -3,4 +3,5 @@ go 1.21 use ( . ./service-mesh + ./node-installer ) diff --git a/node-installer/README.md b/node-installer/README.md new file mode 100644 index 0000000000..d3a4cf6479 --- /dev/null +++ b/node-installer/README.md @@ -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", + } + ] +} +``` diff --git a/node-installer/go.mod b/node-installer/go.mod new file mode 100644 index 0000000000..b6063dffbb --- /dev/null +++ b/node-installer/go.mod @@ -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 +) diff --git a/node-installer/go.sum b/node-installer/go.sum new file mode 100644 index 0000000000..69504264fd --- /dev/null +++ b/node-installer/go.sum @@ -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= diff --git a/node-installer/internal/asset/fetcher.go b/node-installer/internal/asset/fetcher.go new file mode 100644 index 0000000000..012c2b3b18 --- /dev/null +++ b/node-installer/internal/asset/fetcher.go @@ -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 +} diff --git a/node-installer/internal/asset/fetcher_test.go b/node-installer/internal/asset/fetcher_test.go new file mode 100644 index 0000000000..9c5389047c --- /dev/null +++ b/node-installer/internal/asset/fetcher_test.go @@ -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 +} diff --git a/node-installer/internal/asset/file.go b/node-installer/internal/asset/file.go new file mode 100644 index 0000000000..87494d9c02 --- /dev/null +++ b/node-installer/internal/asset/file.go @@ -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) +} diff --git a/node-installer/internal/asset/http.go b/node-installer/internal/asset/http.go new file mode 100644 index 0000000000..106d647ea6 --- /dev/null +++ b/node-installer/internal/asset/http.go @@ -0,0 +1,122 @@ +package asset + +import ( + "bytes" + "context" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "os" + + "github.com/edgelesssys/contrast/node-installer/internal/fileop" +) + +// HTTPFetcher is a Fetcher that retrieves assets from http(s). +// It handles the "http" and "https" schemes. +type HTTPFetcher struct { + mover mover + client *http.Client +} + +// NewHTTPFetcher creates a new HTTP fetcher. +func NewHTTPFetcher() *HTTPFetcher { + return &HTTPFetcher{mover: fileop.NewDefault(), client: http.DefaultClient} +} + +// Fetch retrieves a file from an HTTP server. +func (f *HTTPFetcher) Fetch(ctx context.Context, uri *url.URL, destination string, expectedSum []byte, hasher hash.Hash) (bool, error) { + if uri.Scheme != "http" && uri.Scheme != "https" { + return false, fmt.Errorf("http fetcher does not support scheme %s", uri.Scheme) + } + + if existing, err := os.Open(destination); err == nil { + defer existing.Close() + if _, err := io.Copy(hasher, existing); err != nil { + return false, fmt.Errorf("hashing existing file %s: %w", destination, err) + } + if sum := hasher.Sum(nil); bytes.Equal(sum, expectedSum) { + // File already exists and has the correct hash + return false, nil + } + hasher.Reset() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return false, fmt.Errorf("creating request: %w", err) + } + response, err := f.client.Do(req) + if err != nil { + return false, fmt.Errorf("fetching file: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return false, fmt.Errorf("fetching file: %s", response.Status) + } + + defer response.Body.Close() + + tmpfile, err := os.CreateTemp("", "download") + if err != nil { + return false, err + } + defer tmpfile.Close() + defer os.Remove(tmpfile.Name()) + + reader := io.TeeReader(response.Body, hasher) + + if _, err := io.Copy(tmpfile, reader); err != nil { + return false, fmt.Errorf("downloading file contents from %s: %w", uri.String(), err) + } + + sum := hasher.Sum(nil) + if !bytes.Equal(sum, expectedSum) { + return false, fmt.Errorf("hash mismatch for %s: expected %x, got %x", uri.String(), expectedSum, sum) + } + if err := tmpfile.Sync(); err != nil { + return false, fmt.Errorf("syncing file %s: %w", tmpfile.Name(), err) + } + if err := f.mover.Move(tmpfile.Name(), destination); err != nil { + return false, fmt.Errorf("moving file: %w", err) + } + + return true, nil +} + +// FetchUnchecked retrieves a file from an HTTP server without verifying its integrity. +func (f *HTTPFetcher) FetchUnchecked(ctx context.Context, uri *url.URL, destination string) error { + if uri.Scheme != "http" && uri.Scheme != "https" { + return fmt.Errorf("http fetcher does not support scheme %s", uri.Scheme) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + response, err := f.client.Do(req) + if err != nil { + return fmt.Errorf("fetching file: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("fetching file: %s", response.Status) + } + + dstFile, err := os.Create(destination) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, response.Body) + if err != nil { + return fmt.Errorf("downloading file contents from %s: %w", uri.String(), err) + } + return nil +} + +type mover interface { + Move(src, dst string) error +} diff --git a/node-installer/internal/config/config.go b/node-installer/internal/config/config.go new file mode 100644 index 0000000000..3580196581 --- /dev/null +++ b/node-installer/internal/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/base64" + "errors" + "net/url" + "path/filepath" +) + +// Config is the configuration for the node-installer. +type Config struct { + // Files is a list of files to download. + Files []File `json:"files"` +} + +// Validate validates the configuration. +func (c Config) Validate() error { + for _, file := range c.Files { + if err := file.Validate(); err != nil { + return err + } + } + return nil +} + +// File is a file to download. +type File struct { + // URL is the URL to fetch the file from. + URL string `json:"url"` + // Path is the absolute path (on the host) to save the file to. + Path string `json:"path"` + // Integrity is the content subresource integrity (expected hash) of the file. Required if the file is downloaded. + // The format of a subresource integrity string is defined here: + // https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity + Integrity string `json:"integrity"` +} + +// Validate validates the file. +func (f File) Validate() error { + if f.URL == "" { + return errors.New("url is required") + } + uri, err := url.Parse(f.URL) + if err != nil { + return errors.New("url is not valid") + } + var needsSRI bool + switch uri.Scheme { + case "http", "https": + needsSRI = true + case "file": + needsSRI = false + default: + return errors.New("url scheme must be http, https, or file") + } + if f.Path == "" { + return errors.New("path is required") + } + if !filepath.IsAbs(f.Path) { + return errors.New("path must be absolute") + } + if f.Integrity == "" { + if needsSRI { + return errors.New("integrity is required for http/https URLs") + } + return nil + } + if f.Integrity[:7] != "sha256-" && f.Integrity[:7] != "sha384-" && f.Integrity[:7] != "sha512-" { + return errors.New("integrity must use a valid content sri algorithm (sha256, sha384, sha512)") + } + if _, err := base64.StdEncoding.DecodeString(f.Integrity[7:]); err != nil { + return errors.New("integrity value is not valid base64") + } + return nil +} diff --git a/node-installer/internal/config/config_test.go b/node-installer/internal/config/config_test.go new file mode 100644 index 0000000000..98cda41926 --- /dev/null +++ b/node-installer/internal/config/config_test.go @@ -0,0 +1,111 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestValidate(t *testing.T) { + testCases := []struct { + name string + file File + valid bool + }{ + { + name: "valid http File", + file: File{ + URL: "https://example.com/file1", + Path: "/path/to/file1", + Integrity: "sha256-abcdef123456", + }, + valid: true, + }, + { + name: "valid file File", + file: File{ + URL: "file:////example.com/file1", + Path: "/path/to/file1", + Integrity: "sha256-abcdef123456", + }, + valid: true, + }, + { + name: "missing URL", + file: File{ + Path: "/path/to/file1", + Integrity: "sha256-abcdef123456", + }, + }, + { + name: "missing Path", + file: File{ + URL: "https://example.com/file1", + Integrity: "sha256-abcdef123456", + }, + }, + { + name: "missing relative path", + file: File{ + URL: "https://example.com/file1", + Path: "path/to/file1", + Integrity: "sha256-abcdef123456", + }, + }, + { + name: "missing Integrity", + file: File{ + URL: "https://example.com/file1", + Path: "/path/to/file1", + }, + }, + { + name: "invalid URL", + file: File{ + URL: "invalid\x00url", + Path: "/path/to/file1", + Integrity: "sha256-abcdef123456", + }, + }, + { + name: "invalid scheme", + file: File{ + URL: "ftp://example.com/file1", + Path: "/path/to/file1", + Integrity: "sha256-abcdef123456", + }, + }, + { + name: "invalid Integrity algorithm", + file: File{ + URL: "https://example.com/file1", + Path: "/path/to/file1", + Integrity: "md5-abcdef123456", + }, + }, + { + name: "invalid Integrity value", + file: File{ + URL: "https://example.com/file1", + Path: "/path/to/file1", + Integrity: "sha256-xyz", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.file.Validate() + if tc.valid { + assert.NoError(t, err, "Expected no error, but got one") + } else { + assert.Error(t, err, "Expected error, but got none") + } + }) + } +} diff --git a/node-installer/internal/config/containerd.go b/node-installer/internal/config/containerd.go new file mode 100644 index 0000000000..d6c31c4c2e --- /dev/null +++ b/node-installer/internal/config/containerd.go @@ -0,0 +1,95 @@ +package config + +// ContainerdConfig provides containerd configuration data. +// This is a simplified version of the actual struct. +// Source: https://github.com/containerd/containerd/blob/dcf2847247e18caba8dce86522029642f60fe96b/services/server/config/config.go#L35 +type ContainerdConfig struct { + // Version of the config file + Version int `toml:"version"` + // Root is the path to a directory where containerd will store persistent data + Root string `toml:"root"` + // State is the path to a directory where containerd will store transient data + State string `toml:"state"` + // TempDir is the path to a directory where to place containerd temporary files + TempDir string `toml:"temp"` + // PluginDir is the directory for dynamic plugins to be stored + PluginDir string `toml:"plugin_dir"` + // GRPC configuration settings + GRPC any `toml:"grpc"` + // TTRPC configuration settings + TTRPC any `toml:"ttrpc"` + // Debug and profiling settings + Debug any `toml:"debug"` + // Metrics and monitoring settings + Metrics any `toml:"metrics"` + // DisabledPlugins are IDs of plugins to disable. Disabled plugins won't be + // initialized and started. + DisabledPlugins []string `toml:"disabled_plugins"` + // RequiredPlugins are IDs of required plugins. Containerd exits if any + // required plugin doesn't exist or fails to be initialized or started. + RequiredPlugins []string `toml:"required_plugins"` + // Plugins provides plugin specific configuration for the initialization of a plugin + Plugins map[string]any `toml:"plugins"` + // OOMScore adjust the containerd's oom score + OOMScore int `toml:"oom_score"` + // Cgroup specifies cgroup information for the containerd daemon process + Cgroup any `toml:"cgroup"` + // ProxyPlugins configures plugins which are communicated to over GRPC + ProxyPlugins map[string]ProxyPlugin `toml:"proxy_plugins"` + // Timeouts specified as a duration + Timeouts map[string]string `toml:"timeouts"` + // Imports are additional file path list to config files that can overwrite main config file fields + Imports []string `toml:"imports"` + // StreamProcessors configuration + StreamProcessors map[string]any `toml:"stream_processors"` +} + +// ProxyPlugin provides a proxy plugin configuration. +type ProxyPlugin struct { + Type string `toml:"type"` + Address string `toml:"address"` +} + +// Runtime defines a containerd runtime. +type Runtime struct { + // Type is the runtime type to use in containerd e.g. io.containerd.runtime.v1.linux + Type string `toml:"runtime_type" json:"runtimeType"` + // Path is an optional field that can be used to overwrite path to a shim runtime binary. + // When specified, containerd will ignore runtime name field when resolving shim location. + // Path must be abs. + Path string `toml:"runtime_path,omitempty" json:"runtimePath,omitempty"` + // PodAnnotations is a list of pod annotations passed to both pod sandbox as well as + // container OCI annotations. + PodAnnotations []string `toml:"pod_annotations" json:"PodAnnotations"` + // ContainerAnnotations is a list of container annotations passed through to the OCI config of the containers. + // Container annotations in CRI are usually generated by other Kubernetes node components (i.e., not users). + // Currently, only device plugins populate the annotations. + ContainerAnnotations []string `toml:"container_annotations,omitempty" json:"ContainerAnnotations,omitempty"` + // Options are config options for the runtime. + Options map[string]interface{} `toml:"options,omitempty" json:"options,omitempty"` + // PrivilegedWithoutHostDevices overloads the default behaviour for adding host devices to the + // runtime spec when the container is privileged. Defaults to false. + PrivilegedWithoutHostDevices bool `toml:"privileged_without_host_devices,omitempty" json:"privileged_without_host_devices,omitempty"` + // PrivilegedWithoutHostDevicesAllDevicesAllowed overloads the default behaviour device allowlisting when + // to the runtime spec when the container when PrivilegedWithoutHostDevices is already enabled. Requires + // PrivilegedWithoutHostDevices to be enabled. Defaults to false. + PrivilegedWithoutHostDevicesAllDevicesAllowed bool `toml:"privileged_without_host_devices_all_devices_allowed,omitempty" json:"privileged_without_host_devices_all_devices_allowed,omitempty"` + // BaseRuntimeSpec is a json file with OCI spec to use as base spec that all container's will be created from. + BaseRuntimeSpec string `toml:"base_runtime_spec,omitempty" json:"baseRuntimeSpec,omitempty"` + // NetworkPluginConfDir is a directory containing the CNI network information for the runtime class. + NetworkPluginConfDir string `toml:"cni_conf_dir,omitempty" json:"cniConfDir,omitempty"` + // NetworkPluginMaxConfNum is the max number of plugin config files that will + // be loaded from the cni config directory by go-cni. Set the value to 0 to + // load all config files (no arbitrary limit). The legacy default value is 1. + NetworkPluginMaxConfNum int `toml:"cni_max_conf_num,omitempty" json:"cniMaxConfNum,omitempty"` + // Snapshotter setting snapshotter at runtime level instead of making it as a global configuration. + // An example use case is to use devmapper or other snapshotters in Kata containers for performance and security + // while using default snapshotters for operational simplicity. + // See https://github.com/containerd/containerd/issues/6657 for details. + Snapshotter string `toml:"snapshotter,omitempty" json:"snapshotter,omitempty"` + // Sandboxer defines which sandbox runtime to use when scheduling pods + // This features requires the new CRI server implementation (enabled by default in 2.0) + // shim - means use whatever Controller implementation provided by shim (e.g. use RemoteController). + // podsandbox - means use Controller implementation from sbserver podsandbox package. + Sandboxer string `toml:"sandboxer,omitempty" json:"sandboxer,omitempty"` +} diff --git a/node-installer/internal/constants/configuration-clh-snp.toml b/node-installer/internal/constants/configuration-clh-snp.toml new file mode 100644 index 0000000000..d419dc73ad --- /dev/null +++ b/node-installer/internal/constants/configuration-clh-snp.toml @@ -0,0 +1,43 @@ +# upstream source: https://github.com/kata-containers/kata-containers/blob/9f512c016e75599a4a921bd84ea47559fe610057/src/runtime/config/configuration-clh.toml.in +[hypervisor.clh] +path = "/opt/edgeless/bin/cloud-hypervisor-snp" +igvm = "/opt/edgeless/share/kata-containers-igvm.img" +image = "/opt/edgeless/share/kata-containers.img" +rootfs_type="ext4" +confidential_guest = true +sev_snp_guest = true +snp_guest_policy=0x30000 +disable_selinux=false +disable_guest_selinux=true +enable_annotations = ["enable_iommu", "virtio_fs_extra_args", "kernel_params"] +valid_hypervisor_paths = ["/opt/edgeless/bin/cloud-hypervisor-snp"] +kernel_params = "" +default_vcpus = 1 +default_maxvcpus = 0 +default_memory = 256 +default_maxmemory = 0 +shared_fs = "none" +virtio_fs_daemon = "/opt/confidential-containers/libexec/virtiofsd" +valid_virtio_fs_daemon_paths = ["/opt/confidential-containers/libexec/virtiofsd"] +virtio_fs_cache_size = 0 +virtio_fs_queue_size = 1024 +virtio_fs_extra_args = ["--thread-pool-size=1", "--announce-submounts"] +virtio_fs_cache = "auto" +block_device_driver = "virtio-blk" + +[agent.kata] +dial_timeout = 90 + +[runtime] +internetworking_model="tcfilter" +disable_guest_seccomp=true +sandbox_cgroup_only=false +static_sandbox_resource_mgmt=true +static_sandbox_default_workload_mem=1792 +sandbox_bind_mounts=[] +vfio_mode="guest-kernel" +disable_guest_empty_dir=false +experimental=[] + +[image] +service_offload = false diff --git a/node-installer/internal/constants/constants.go b/node-installer/internal/constants/constants.go new file mode 100644 index 0000000000..ae53026415 --- /dev/null +++ b/node-installer/internal/constants/constants.go @@ -0,0 +1,57 @@ +package constants + +import ( + _ "embed" + + "github.com/edgelesssys/contrast/node-installer/internal/config" + "github.com/pelletier/go-toml" +) + +var ( + // ContainerdRuntimeConfig is the configuration file for the containerd runtime + // + //go:embed configuration-clh-snp.toml + ContainerdRuntimeConfig string + + // containerdBaseConfig is the base configuration file for containerd + // + //go:embed containerd-config.toml + containerdBaseConfig string +) + +// RuntimeName is the name of the runtime. +const ( + RuntimeName = "contrast-cc" + CRIFQDN = "io.containerd.grpc.v1.cri" +) + +// ContainerdBaseConfig returns the base containerd configuration. +func ContainerdBaseConfig() config.ContainerdConfig { + var config config.ContainerdConfig + if err := toml.Unmarshal([]byte(containerdBaseConfig), &config); err != nil { + panic(err) // should never happen + } + return config +} + +// ContainerdRuntimeConfigFragment returns the containerd runtime configuration fragment. +func ContainerdRuntimeConfigFragment() config.Runtime { + return config.Runtime{ + Type: "io.containerd.contrast-cc.v2", + Path: "/opt/edgeless/bin/containerd-shim-contrast-cc-v2", + PodAnnotations: []string{"io.katacontainers.*"}, + Options: map[string]any{ + "ConfigPath": "/opt/edgeless/etc/configuration-clh-snp.toml", + }, + PrivilegedWithoutHostDevices: true, + Snapshotter: "tardev", + } +} + +// TardevSnapshotterConfigFragment returns the tardev snapshotter configuration fragment. +func TardevSnapshotterConfigFragment() config.ProxyPlugin { + return config.ProxyPlugin{ + Type: "snapshot", + Address: "/run/containerd/tardev-snapshotter.sock", + } +} diff --git a/node-installer/internal/constants/containerd-config.toml b/node-installer/internal/constants/containerd-config.toml new file mode 100644 index 0000000000..2dba5c1f7c --- /dev/null +++ b/node-installer/internal/constants/containerd-config.toml @@ -0,0 +1,47 @@ +# upstream source https://github.com/containerd/containerd/blob/c03290cb0052bd463d6dbcdb3827c7f5633ef48d/docs/man/containerd-config.toml.5.md#examples +version = 2 +oom_score = 0 +[plugins."io.containerd.grpc.v1.cri"] + sandbox_image = "mcr.microsoft.com/oss/kubernetes/pause:3.6" + [plugins."io.containerd.grpc.v1.cri".containerd] + disable_snapshot_annotations = false + default_runtime_name = "runc" + [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] + runtime_type = "io.containerd.runc.v2" + [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] + BinaryName = "/usr/bin/runc" + [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.untrusted] + runtime_type = "io.containerd.runc.v2" + [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.untrusted.options] + BinaryName = "/usr/bin/runc" + [plugins."io.containerd.grpc.v1.cri".cni] + bin_dir = "/opt/cni/bin" + conf_dir = "/etc/cni/net.d" + conf_template = "/etc/containerd/kubenet_template.conf" + [plugins."io.containerd.grpc.v1.cri".registry] + config_path = "/etc/containerd/certs.d" + [plugins."io.containerd.grpc.v1.cri".registry.headers] + X-Meta-Source-Client = ["azure/aks"] +[metrics] + address = "0.0.0.0:10257" +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata] + runtime_type = "io.containerd.kata.v2" +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.katacli] + runtime_type = "io.containerd.runc.v1" +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.katacli.options] + NoPivotRoot = false + NoNewKeyring = false + ShimCgroup = "" + IoUid = 0 + IoGid = 0 + BinaryName = "/usr/bin/kata-runtime" + Root = "" + CriuPath = "" + SystemdCgroup = false +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata-cc] + snapshotter = "tardev" + runtime_type = "io.containerd.kata-cc.v2" + privileged_without_host_devices = true + pod_annotations = ["io.katacontainers.*"] + [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata-cc.options] + ConfigPath = "/opt/confidential-containers/share/defaults/kata-containers/configuration-clh-snp.toml" diff --git a/node-installer/internal/fileop/copy_general.go b/node-installer/internal/fileop/copy_general.go new file mode 100644 index 0000000000..5c35995f81 --- /dev/null +++ b/node-installer/internal/fileop/copy_general.go @@ -0,0 +1,12 @@ +//go:build !linux + +package fileop + +import ( + "os" +) + +// copyAt copies the contents of src to dst. +func (o *OS) copyAt(src, dst *os.File) error { + return o.copyTraditional(src, dst) +} diff --git a/node-installer/internal/fileop/copy_linux.go b/node-installer/internal/fileop/copy_linux.go new file mode 100644 index 0000000000..11fe3bb114 --- /dev/null +++ b/node-installer/internal/fileop/copy_linux.go @@ -0,0 +1,46 @@ +package fileop + +import ( + "io" + "os" + + "golang.org/x/sys/unix" +) + +// copyAt copies the contents of src to dst. +func (o *OS) copyAt(src, dst *os.File) error { + // try to copy the file using the copy_file_range syscall + if _, err := copyReflink(src, dst); err == nil { + return nil + } + + // fallback to regular copy if the reflink is not supported + return o.copyTraditional(src, dst) +} + +// copyReflink copies the contents of src to dst using the copy_file_range syscall. +// This is more efficient than a regular copy, as it can share the backing storage (copy-on-write). +func copyReflink(src, dst *os.File) (int, error) { + size, err := src.Seek(0, io.SeekEnd) + if err != nil { + return 0, err + } + if err := dst.Truncate(size); err != nil { + return 0, err + } + offIn := int64(0) + offOut := int64(0) + written, err := unix.CopyFileRange( + int(src.Fd()), &offIn, // copy from the start of src + int(dst.Fd()), &offOut, // to the start of dst + int(size), // copy the entire file + 0, // no flags + ) + if err != nil { + return written, err + } + if written != int(size) { + return written, io.ErrShortWrite + } + return written, nil +} diff --git a/node-installer/internal/fileop/fileop.go b/node-installer/internal/fileop/fileop.go new file mode 100644 index 0000000000..fb400bc3a2 --- /dev/null +++ b/node-installer/internal/fileop/fileop.go @@ -0,0 +1,191 @@ +package fileop + +import ( + "crypto/sha512" + "errors" + "fmt" + "hash" + "io" + "os" + "slices" + "syscall" +) + +// OS provides file operations on the real filesystem. +type OS struct { + newHash func() hash.Hash +} + +// NewDefault creates a new file operation helper with a default hash function. +func NewDefault() *OS { + return New(sha512.New) +} + +// New creates a new file operation helper. +func New(newHash func() hash.Hash) *OS { + return &OS{newHash: newHash} +} + +// CopyOnDiff copies a file from src to dst. +// It will only modify the destination file if the contents are different. +// It returns true if the file was modified, or an error if any occurred. +func (o *OS) CopyOnDiff(src, dst string) (bool, error) { + srcFile, err := os.Open(src) + if err != nil { + return false, fmt.Errorf("opening source file %s: %w", src, err) + } + defer srcFile.Close() + + // open readonly first to prevent ETXTBSY + dstFile, err := os.OpenFile(dst, os.O_RDONLY|os.O_CREATE, 0o644) + if err != nil { + return false, fmt.Errorf("opening destination file %s: %w", dst, err) + } + defer dstFile.Close() + + // check if the file already exists and has the correct hash + identical, err := o.identical(srcFile, dstFile) + if err != nil { + return false, err + } + if identical { + return false, nil + } + + // reopen the file for writing + if err := dstFile.Close(); err != nil { + return false, fmt.Errorf("closing destination file: %w", err) + } + dstFile, err = o.openWritableDestructive(dst) + if err != nil { + return false, fmt.Errorf("opening destination file %s: %w", dst, err) + } + + if err := o.copyAt(srcFile, dstFile); err != nil { + return false, fmt.Errorf("copying file: %w", err) + } + return true, nil +} + +// Copy copies a file from src to dst. +// It will overwrite the destination file in any case. +func (o *OS) Copy(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening source file %s: %w", src, err) + } + defer srcFile.Close() + + dstFile, err := o.openWritableDestructive(dst) + if err != nil { + return fmt.Errorf("opening destination file %s: %w", dst, err) + } + defer dstFile.Close() + + if err := o.copyAt(srcFile, dstFile); err != nil { + return fmt.Errorf("copying file: %w", err) + } + return nil +} + +// Move moves a file from src to dst. +func (o *OS) Move(src, dst string) error { + switch err := os.Rename(src, dst); { + case err == nil: + return nil + default: + return fmt.Errorf("moving file: %w", err) + case errors.Is(err, syscall.EXDEV): + // rename across devices is not supported, so we need to copy and remove + } + + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening source file %s: %w", src, err) + } + defer srcFile.Close() + dstFile, err := o.openWritableDestructive(dst) + if err != nil { + return fmt.Errorf("opening destination file %s: %w", dst, err) + } + defer dstFile.Close() + if err := o.copyAt(srcFile, dstFile); err != nil { + return fmt.Errorf("copying file: %w", err) + } + if err := os.Remove(src); err != nil { + return fmt.Errorf("removing source file: %w", err) + } + return nil +} + +// identical returns true if the contents of the two files are identical. +// The files are rewound to their original positions after the comparison. +func (o *OS) identical(src, dst *os.File) (bool, error) { + srcHash, err := o.checksumAt(src) + if err != nil { + return false, fmt.Errorf("getting source file checksum: %w", err) + } + dstHash, err := o.checksumAt(dst) + if err != nil { + return false, fmt.Errorf("getting destination file checksum: %w", err) + } + return slices.Equal(srcHash, dstHash), nil +} + +func (o *OS) openWritableDestructive(path string) (*os.File, error) { + // try to open the file for writing + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) + if err == nil { + return file, nil + } + switch { + case errors.Is(err, syscall.ETXTBSY): + // file is currently being executed. need to unlink it first + if err := os.Remove(path); err != nil { + return nil, fmt.Errorf("removing file %s: %w", path, err) + } + default: + return nil, err + } + return os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) +} + +// checksumAt returns the checksum of the file. +// It will temporarily move the file offset but restore it to the previous position. +func (o *OS) checksumAt(file *os.File) ([]byte, error) { + pos, err := file.Seek(0, io.SeekCurrent) + if err != nil { + return nil, fmt.Errorf("getting file position: %w", err) + } + _, err = file.Seek(0, io.SeekStart) + if err != nil { + return nil, fmt.Errorf("rewinding file: %w", err) + } + hasher := o.newHash() + if _, err := io.Copy(hasher, file); err != nil { + return nil, fmt.Errorf("hashing file: %w", err) + } + _, err = file.Seek(pos, io.SeekStart) + if err != nil { + return nil, fmt.Errorf("restoring file position: %w", err) + } + return hasher.Sum(nil), nil +} + +// copyTraditional copies the contents of src to dst. +// This is the slow path, used when reflink is not supported. +func (o *OS) copyTraditional(src, dst *os.File) error { + _, err := src.Seek(0, io.SeekStart) + if err != nil { + return err + } + _, err = dst.Seek(0, io.SeekStart) + if err != nil { + return err + } + if err = dst.Truncate(0); err != nil { + return err + } + _, err = io.Copy(dst, src) + return err +} diff --git a/node-installer/internal/fileop/fileop_test.go b/node-installer/internal/fileop/fileop_test.go new file mode 100644 index 0000000000..08e81c31ad --- /dev/null +++ b/node-installer/internal/fileop/fileop_test.go @@ -0,0 +1,140 @@ +package fileop + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestOp(t *testing.T) { + testCases := map[string]struct { + srcContent []byte + dstContent []byte + wantModified bool + wantErr bool + }{ + "identical": { + srcContent: []byte("foo"), + dstContent: []byte("foo"), + }, + "different": { + srcContent: []byte("foo"), + dstContent: []byte("bar"), + wantModified: true, + }, + "src not found": { + dstContent: []byte("foo"), + wantErr: true, + }, + "dst not found": { + srcContent: []byte("foo"), + wantModified: true, + }, + } + + // Test CopyOnDiff + for name, tc := range testCases { + t.Run(name, 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) + src := filepath.Join(emptyDir, "src") + dst := filepath.Join(emptyDir, "dst") + if tc.srcContent != nil { + require.NoError(os.WriteFile(src, tc.srcContent, 0o644)) + defer os.Remove(src) + } + if tc.dstContent != nil { + require.NoError(os.WriteFile(dst, tc.dstContent, 0o644)) + defer os.Remove(dst) + } + osOP := NewDefault() + modified, err := osOP.CopyOnDiff(src, dst) + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(tc.wantModified, modified) + got, err := os.ReadFile(dst) + require.NoError(err) + assert.Equal(tc.srcContent, got) + }) + } + + // Test Copy + for name, tc := range testCases { + t.Run(name, 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) + src := filepath.Join(emptyDir, "src") + dst := filepath.Join(emptyDir, "dst") + if tc.srcContent != nil { + require.NoError(os.WriteFile(src, tc.srcContent, 0o644)) + defer os.Remove(src) + } + if tc.dstContent != nil { + require.NoError(os.WriteFile(dst, tc.dstContent, 0o644)) + defer os.Remove(dst) + } + osOP := NewDefault() + err = osOP.Copy(src, dst) + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + got, err := os.ReadFile(dst) + require.NoError(err) + assert.Equal(tc.srcContent, got) + }) + } + + // Test Move + for name, tc := range testCases { + t.Run(name, 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) + src := filepath.Join(emptyDir, "src") + dst := filepath.Join(emptyDir, "dst") + if tc.srcContent != nil { + require.NoError(os.WriteFile(src, tc.srcContent, 0o644)) + defer os.Remove(src) + } + if tc.dstContent != nil { + require.NoError(os.WriteFile(dst, tc.dstContent, 0o644)) + defer os.Remove(dst) + } + osOP := NewDefault() + err = osOP.Move(src, dst) + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + got, err := os.ReadFile(dst) + require.NoError(err) + assert.Equal(tc.srcContent, got) + assert.NoFileExists(src) + }) + } +} diff --git a/node-installer/node-installer.go b/node-installer/node-installer.go new file mode 100644 index 0000000000..24fdc74ed3 --- /dev/null +++ b/node-installer/node-installer.go @@ -0,0 +1,224 @@ +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 + if err := os.MkdirAll(binDir, os.ModePerm); err != nil { + return fmt.Errorf("creating bin directory: %w", err) + } + if err := os.MkdirAll(filepath.Join(hostMount, "opt", "edgeless", "share"), os.ModePerm); err != nil { + return fmt.Errorf("creating /opt/edgeless/share directory: %w", err) + } + if err := os.MkdirAll(filepath.Join(hostMount, "opt", "edgeless", "etc"), os.ModePerm); err != nil { + return fmt.Errorf("creating /opt/edgeless/etc directory: %w", err) + } + if err := os.MkdirAll(filepath.Join(hostMount, "etc", "containerd"), os.ModePerm); err != nil { + return fmt.Errorf("creating /etc/containerd directory: %w", err) + } + + 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) + } + } + clhConfigPath := filepath.Join(hostMount, "opt", "edgeless", "etc", "configuration-clh-snp.toml") + if err := containerdRuntimeConfig(clhConfigPath); err != nil { + return fmt.Errorf("generating clh_config.toml: %w", err) + } + containerdConfigPath := filepath.Join(hostMount, "etc", "containerd", "config.toml") + if err := patchContainerdConfig(containerdConfigPath); 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 + runtimes := ensureMapPath(&existing.Plugins, constants.CRIFQDN, "containerd", "runtimes") + 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 +} + +// ensureMapPath ensures that the given path exists in the map and +// returns the last map in the chain. +func ensureMapPath(in *map[string]any, path ...string) map[string]any { + if len(path) == 0 { + return *in + } + if *in == nil { + *in = make(map[string]any) + } + current := *in + for _, p := range path { + if current[p] == nil { + current[p] = make(map[string]any) + } + current = current[p].(map[string]any) + } + return current +} + +type assetFetcher interface { + Fetch(ctx context.Context, sourceURI, destination, integrity string) (changed bool, retErr error) + FetchUnchecked(ctx context.Context, sourceURI, destination string) error +} diff --git a/packages/by-name/contrast-node-installer/package.nix b/packages/by-name/contrast-node-installer/package.nix new file mode 100644 index 0000000000..30bb310763 --- /dev/null +++ b/packages/by-name/contrast-node-installer/package.nix @@ -0,0 +1,49 @@ +{ lib +, buildGoModule +, contrast +}: + +buildGoModule rec { + pname = "contrast-node-installer"; + inherit (contrast) version; + + # The source of the main module of this repo. We filter for Go files so that + # changes in the other parts of this repo don't trigger a rebuild. + src = + let + inherit (lib) fileset path hasSuffix; + root = ../../../node-installer; + in + fileset.toSource { + inherit root; + fileset = fileset.unions [ + (path.append root "go.mod") + (path.append root "go.sum") + (lib.fileset.fileFilter (file: lib.hasSuffix ".toml" file.name) root) + (lib.fileset.fileFilter (file: lib.hasSuffix ".go" file.name) root) + ]; + }; + + proxyVendor = true; + vendorHash = "sha256-uCmDgm68Q9ClAqKYIHVYIDAVWkUm2tyQ4eoP3L8+wLg="; + + subPackages = [ "." ]; + + CGO_ENABLED = 0; + ldflags = [ + "-s" + "-w" + ]; + + preCheck = '' + export CGO_ENABLED=1 + ''; + + checkPhase = '' + runHook preCheck + go test -race ./... + runHook postCheck + ''; + + meta.mainProgram = "node-installer"; +} diff --git a/packages/by-name/contrast/package.nix b/packages/by-name/contrast/package.nix index 017fd6e147..2e4fdc39e5 100644 --- a/packages/by-name/contrast/package.nix +++ b/packages/by-name/contrast/package.nix @@ -40,7 +40,10 @@ buildGoModule rec { (path.append root "go.sum") (lib.fileset.difference (lib.fileset.fileFilter (file: lib.hasSuffix ".go" file.name) root) - (path.append root "service-mesh")) + (fileset.unions [ + (path.append root "service-mesh") + (path.append root "node-installer") + ])) ]; };