AB#2327 move debugd code into internal folder (#403)

* move debugd code into internal folder
* Fix paths in CMakeLists.txt
Signed-off-by: Fabian Kammel <fk@edgeless.systems>
This commit is contained in:
Fabian Kammel 2022-08-26 11:58:18 +02:00 committed by GitHub
parent 708c6e057e
commit 5b40e0cc77
25 changed files with 31 additions and 31 deletions

View file

@ -0,0 +1,117 @@
package deploy
import (
"context"
"fmt"
"net"
"strconv"
"time"
"github.com/edgelesssys/constellation/debugd/internal/bootstrapper"
"github.com/edgelesssys/constellation/debugd/internal/debugd"
pb "github.com/edgelesssys/constellation/debugd/service"
"github.com/edgelesssys/constellation/internal/constants"
"github.com/edgelesssys/constellation/internal/deploy/ssh"
"github.com/edgelesssys/constellation/internal/logger"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Download downloads a bootstrapper from a given debugd instance.
type Download struct {
log *logger.Logger
dialer NetDialer
writer streamToFileWriter
serviceManager serviceManager
attemptedDownloads map[string]time.Time
}
// New creates a new Download.
func New(log *logger.Logger, dialer NetDialer, serviceManager serviceManager, writer streamToFileWriter) *Download {
return &Download{
log: log,
dialer: dialer,
writer: writer,
serviceManager: serviceManager,
attemptedDownloads: map[string]time.Time{},
}
}
// DownloadDeployment will open a new grpc connection to another instance, attempting to download a bootstrapper from that instance.
func (d *Download) DownloadDeployment(ctx context.Context, ip string) ([]ssh.UserKey, error) {
log := d.log.With(zap.String("ip", ip))
serverAddr := net.JoinHostPort(ip, strconv.Itoa(constants.DebugdPort))
// only retry download from same endpoint after backoff
if lastAttempt, ok := d.attemptedDownloads[serverAddr]; ok && time.Since(lastAttempt) < debugd.BootstrapperDownloadRetryBackoff {
return nil, fmt.Errorf("download failed too recently: %v / %v", time.Since(lastAttempt), debugd.BootstrapperDownloadRetryBackoff)
}
log.Infof("Connecting to server")
d.attemptedDownloads[serverAddr] = time.Now()
conn, err := d.dial(ctx, serverAddr)
if err != nil {
return nil, fmt.Errorf("connecting to other instance via gRPC: %w", err)
}
defer conn.Close()
client := pb.NewDebugdClient(conn)
log.Infof("Trying to download bootstrapper")
stream, err := client.DownloadBootstrapper(ctx, &pb.DownloadBootstrapperRequest{})
if err != nil {
return nil, fmt.Errorf("starting bootstrapper download from other instance: %w", err)
}
if err := d.writer.WriteStream(debugd.BootstrapperDeployFilename, stream, true); err != nil {
return nil, fmt.Errorf("streaming bootstrapper from other instance: %w", err)
}
log.Infof("Successfully downloaded bootstrapper")
log.Infof("Trying to download ssh keys")
resp, err := client.DownloadAuthorizedKeys(ctx, &pb.DownloadAuthorizedKeysRequest{})
if err != nil {
return nil, fmt.Errorf("downloading authorized keys: %w", err)
}
var keys []ssh.UserKey
for _, key := range resp.Keys {
keys = append(keys, ssh.UserKey{Username: key.Username, PublicKey: key.KeyValue})
}
// after the upload succeeds, try to restart the bootstrapper
restartAction := ServiceManagerRequest{
Unit: debugd.BootstrapperSystemdUnitName,
Action: Restart,
}
if err := d.serviceManager.SystemdAction(ctx, restartAction); err != nil {
return nil, fmt.Errorf("restarting bootstrapper: %w", err)
}
return keys, nil
}
func (d *Download) dial(ctx context.Context, target string) (*grpc.ClientConn, error) {
return grpc.DialContext(ctx, target,
d.grpcWithDialer(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
}
func (d *Download) grpcWithDialer() grpc.DialOption {
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return d.dialer.DialContext(ctx, "tcp", addr)
})
}
type serviceManager interface {
SystemdAction(ctx context.Context, request ServiceManagerRequest) error
}
type streamToFileWriter interface {
WriteStream(filename string, stream bootstrapper.ReadChunkStream, showProgress bool) error
}
// NetDialer can open a net.Conn.
type NetDialer interface {
DialContext(ctx context.Context, network, address string) (net.Conn, error)
}

View file

@ -0,0 +1,187 @@
package deploy
import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"testing"
"time"
"github.com/edgelesssys/constellation/debugd/internal/bootstrapper"
"github.com/edgelesssys/constellation/debugd/internal/debugd"
pb "github.com/edgelesssys/constellation/debugd/service"
"github.com/edgelesssys/constellation/internal/constants"
"github.com/edgelesssys/constellation/internal/deploy/ssh"
"github.com/edgelesssys/constellation/internal/grpc/testdialer"
"github.com/edgelesssys/constellation/internal/logger"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
"google.golang.org/grpc"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
// https://github.com/census-instrumentation/opencensus-go/issues/1262
goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
)
}
func TestDownloadBootstrapper(t *testing.T) {
filename := "/opt/bootstrapper"
someErr := errors.New("failed")
testCases := map[string]struct {
server fakeDownloadServer
serviceManager stubServiceManager
attemptedDownloads map[string]time.Time
wantChunks [][]byte
wantDownloadErr bool
wantFile bool
wantSystemdAction bool
wantDeployed bool
wantKeys []ssh.UserKey
}{
"download works": {
server: fakeDownloadServer{
chunks: [][]byte{[]byte("test")},
keys: []*pb.AuthorizedKey{{Username: "name", KeyValue: "key"}},
},
attemptedDownloads: map[string]time.Time{},
wantChunks: [][]byte{[]byte("test")},
wantDownloadErr: false,
wantFile: true,
wantSystemdAction: true,
wantDeployed: true,
wantKeys: []ssh.UserKey{{Username: "name", PublicKey: "key"}},
},
"second download is not attempted twice": {
server: fakeDownloadServer{chunks: [][]byte{[]byte("test")}},
attemptedDownloads: map[string]time.Time{"192.0.2.0:" + strconv.Itoa(constants.DebugdPort): time.Now()},
wantDownloadErr: true,
},
"download rpc call error is detected": {
server: fakeDownloadServer{downladErr: someErr},
attemptedDownloads: map[string]time.Time{},
wantDownloadErr: true,
},
"download key error": {
server: fakeDownloadServer{
chunks: [][]byte{[]byte("test")},
downloadAuthorizedKeysErr: someErr,
},
attemptedDownloads: map[string]time.Time{},
wantDownloadErr: true,
},
"service restart error is detected": {
server: fakeDownloadServer{chunks: [][]byte{[]byte("test")}},
serviceManager: stubServiceManager{systemdActionErr: someErr},
attemptedDownloads: map[string]time.Time{},
wantChunks: [][]byte{[]byte("test")},
wantDownloadErr: true,
wantFile: true,
wantDeployed: true,
wantSystemdAction: false,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
ip := "192.0.2.0"
writer := &fakeStreamToFileWriter{}
dialer := testdialer.NewBufconnDialer()
grpcServ := grpc.NewServer()
pb.RegisterDebugdServer(grpcServ, &tc.server)
lis := dialer.GetListener(net.JoinHostPort(ip, strconv.Itoa(constants.DebugdPort)))
go grpcServ.Serve(lis)
defer grpcServ.GracefulStop()
download := &Download{
log: logger.NewTest(t),
dialer: dialer,
writer: writer,
serviceManager: &tc.serviceManager,
attemptedDownloads: tc.attemptedDownloads,
}
keys, err := download.DownloadDeployment(context.Background(), ip)
if tc.wantDownloadErr {
assert.Error(err)
} else {
assert.NoError(err)
}
if tc.wantFile {
assert.Equal(tc.wantChunks, writer.chunks)
assert.Equal(filename, writer.filename)
}
if tc.wantSystemdAction {
assert.ElementsMatch(
[]ServiceManagerRequest{
{Unit: debugd.BootstrapperSystemdUnitName, Action: Restart},
},
tc.serviceManager.requests,
)
}
assert.Equal(tc.wantKeys, keys)
})
}
}
type stubServiceManager struct {
requests []ServiceManagerRequest
systemdActionErr error
}
func (s *stubServiceManager) SystemdAction(ctx context.Context, request ServiceManagerRequest) error {
s.requests = append(s.requests, request)
return s.systemdActionErr
}
type fakeStreamToFileWriter struct {
chunks [][]byte
filename string
}
func (f *fakeStreamToFileWriter) WriteStream(filename string, stream bootstrapper.ReadChunkStream, showProgress bool) error {
f.filename = filename
for {
chunk, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("reading stream: %w", err)
}
f.chunks = append(f.chunks, chunk.Content)
}
}
// fakeDownloadServer implements DebugdServer; only fakes DownloadBootstrapper, panics on every other rpc.
type fakeDownloadServer struct {
chunks [][]byte
downladErr error
keys []*pb.AuthorizedKey
downloadAuthorizedKeysErr error
pb.UnimplementedDebugdServer
}
func (f *fakeDownloadServer) DownloadBootstrapper(request *pb.DownloadBootstrapperRequest, stream pb.Debugd_DownloadBootstrapperServer) error {
for _, chunk := range f.chunks {
if err := stream.Send(&pb.Chunk{Content: chunk}); err != nil {
return fmt.Errorf("sending chunk: %w", err)
}
}
return f.downladErr
}
func (s *fakeDownloadServer) DownloadAuthorizedKeys(context.Context, *pb.DownloadAuthorizedKeysRequest) (*pb.DownloadAuthorizedKeysResponse, error) {
return &pb.DownloadAuthorizedKeysResponse{Keys: s.keys}, s.downloadAuthorizedKeysErr
}

View file

@ -0,0 +1,163 @@
package deploy
import (
"context"
"fmt"
"sync"
"github.com/edgelesssys/constellation/debugd/internal/debugd"
"github.com/edgelesssys/constellation/internal/logger"
"github.com/spf13/afero"
"go.uber.org/zap"
)
const (
systemdUnitFolder = "/etc/systemd/system"
)
//go:generate stringer -type=SystemdAction
type SystemdAction uint32
const (
Unknown SystemdAction = iota
Start
Stop
Restart
Reload
)
// ServiceManagerRequest describes a requested ServiceManagerAction to be performed on a specified service unit.
type ServiceManagerRequest struct {
Unit string
Action SystemdAction
}
// SystemdUnit describes a systemd service file including the unit name and contents.
type SystemdUnit struct {
Name string `yaml:"name"`
Contents string `yaml:"contents"`
}
// ServiceManager receives ServiceManagerRequests and units via channels and performs the requests / creates the unit files.
type ServiceManager struct {
log *logger.Logger
dbus dbusClient
fs afero.Fs
systemdUnitFilewriteLock sync.Mutex
}
// NewServiceManager creates a new ServiceManager.
func NewServiceManager(log *logger.Logger) *ServiceManager {
fs := afero.NewOsFs()
return &ServiceManager{
log: log,
dbus: &dbusWrapper{},
fs: fs,
systemdUnitFilewriteLock: sync.Mutex{},
}
}
type dbusClient interface {
// NewSystemConnectionContext establishes a connection to the system bus and authenticates.
// Callers should call Close() when done with the connection.
NewSystemdConnectionContext(ctx context.Context) (dbusConn, error)
}
type dbusConn interface {
// StartUnitContext enqueues a start job and depending jobs, if any (unless otherwise
// specified by the mode string).
StartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error)
// StopUnitContext is similar to StartUnitContext, but stops the specified unit
// rather than starting it.
StopUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error)
// RestartUnitContext restarts a service. If a service is restarted that isn't
// running it will be started.
RestartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error)
// ReloadContext instructs systemd to scan for and reload unit files. This is
// an equivalent to systemctl daemon-reload.
ReloadContext(ctx context.Context) error
}
// SystemdAction will perform a systemd action on a service unit (start, stop, restart, reload).
func (s *ServiceManager) SystemdAction(ctx context.Context, request ServiceManagerRequest) error {
log := s.log.With(zap.String("unit", request.Unit), zap.String("action", request.Action.String()))
conn, err := s.dbus.NewSystemdConnectionContext(ctx)
if err != nil {
return fmt.Errorf("establishing systemd connection: %w", err)
}
resultChan := make(chan string, 1)
switch request.Action {
case Start:
_, err = conn.StartUnitContext(ctx, request.Unit, "replace", resultChan)
case Stop:
_, err = conn.StopUnitContext(ctx, request.Unit, "replace", resultChan)
case Restart:
_, err = conn.RestartUnitContext(ctx, request.Unit, "replace", resultChan)
case Reload:
err = conn.ReloadContext(ctx)
default:
return fmt.Errorf("unknown systemd action: %s", request.Action.String())
}
if err != nil {
return fmt.Errorf("performing systemd action %v on unit %v: %w", request.Action, request.Unit, err)
}
if request.Action == Reload {
log.Infof("daemon-reload succeeded")
return nil
}
// Wait for the action to finish and then check if it was
// successful or not.
result := <-resultChan
switch result {
case "done":
log.Infof("%s on systemd unit %s succeeded", request.Action, request.Unit)
return nil
default:
return fmt.Errorf("performing action %q on systemd unit %q failed: expected %q but received %q", request.Action.String(), request.Unit, "done", result)
}
}
// WriteSystemdUnitFile will write a systemd unit to disk.
func (s *ServiceManager) WriteSystemdUnitFile(ctx context.Context, unit SystemdUnit) error {
log := s.log.With(zap.String("unitFile", fmt.Sprintf("%s/%s", systemdUnitFolder, unit.Name)))
log.Infof("Writing systemd unit file")
s.systemdUnitFilewriteLock.Lock()
defer s.systemdUnitFilewriteLock.Unlock()
if err := afero.WriteFile(s.fs, fmt.Sprintf("%s/%s", systemdUnitFolder, unit.Name), []byte(unit.Contents), 0o644); err != nil {
return fmt.Errorf("writing systemd unit file \"%v\": %w", unit.Name, err)
}
if err := s.SystemdAction(ctx, ServiceManagerRequest{Unit: unit.Name, Action: Reload}); err != nil {
return fmt.Errorf("performing systemd daemon-reload: %w", err)
}
log.Infof("Wrote systemd unit file and performed daemon-reload")
return nil
}
// DeployDefaultServiceUnit will write the default "bootstrapper.service" unit file.
func DeployDefaultServiceUnit(ctx context.Context, serviceManager *ServiceManager) error {
if err := serviceManager.WriteSystemdUnitFile(ctx, SystemdUnit{
Name: debugd.BootstrapperSystemdUnitName,
Contents: debugd.BootstrapperSystemdUnitContents,
}); err != nil {
return fmt.Errorf("writing systemd unit file %q: %w", debugd.BootstrapperSystemdUnitName, err)
}
// try to start the default service if the binary exists but ignore failure.
// this is meant to start the bootstrapper after a reboot
// if a bootstrapper binary was uploaded before.
if ok, err := afero.Exists(serviceManager.fs, debugd.BootstrapperDeployFilename); ok && err == nil {
_ = serviceManager.SystemdAction(ctx, ServiceManagerRequest{
Unit: debugd.BootstrapperSystemdUnitName,
Action: Start,
})
}
return nil
}

View file

@ -0,0 +1,242 @@
package deploy
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"github.com/edgelesssys/constellation/internal/logger"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSystemdAction(t *testing.T) {
unitName := "example.service"
testCases := map[string]struct {
dbus stubDbus
action SystemdAction
wantErr bool
}{
"start works": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "done",
},
},
action: Start,
wantErr: false,
},
"stop works": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "done",
},
},
action: Stop,
wantErr: false,
},
"restart works": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "done",
},
},
action: Restart,
wantErr: false,
},
"reload works": {
dbus: stubDbus{
conn: &fakeDbusConn{},
},
action: Reload,
wantErr: false,
},
"unknown action": {
dbus: stubDbus{
conn: &fakeDbusConn{},
},
action: Unknown,
wantErr: true,
},
"action fails": {
dbus: stubDbus{
conn: &fakeDbusConn{
actionErr: errors.New("action fails"),
},
},
action: Start,
wantErr: true,
},
"action result is failure": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "failure",
},
},
action: Start,
wantErr: true,
},
"newConn fails": {
dbus: stubDbus{
connErr: errors.New("newConn fails"),
},
action: Start,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
fs := afero.NewMemMapFs()
manager := ServiceManager{
log: logger.NewTest(t),
dbus: &tc.dbus,
fs: fs,
systemdUnitFilewriteLock: sync.Mutex{},
}
err := manager.SystemdAction(context.Background(), ServiceManagerRequest{
Unit: unitName,
Action: tc.action,
})
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
})
}
}
func TestWriteSystemdUnitFile(t *testing.T) {
testCases := map[string]struct {
dbus stubDbus
unit SystemdUnit
readonly bool
wantErr bool
wantFileContents string
}{
"start works": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "done",
},
},
unit: SystemdUnit{
Name: "test.service",
Contents: "testservicefilecontents",
},
wantErr: false,
wantFileContents: "testservicefilecontents",
},
"write fails": {
dbus: stubDbus{
conn: &fakeDbusConn{
result: "done",
},
},
unit: SystemdUnit{
Name: "test.service",
Contents: "testservicefilecontents",
},
readonly: true,
wantErr: true,
},
"systemd reload fails": {
dbus: stubDbus{
conn: &fakeDbusConn{
actionErr: errors.New("reload error"),
},
},
unit: SystemdUnit{
Name: "test.service",
Contents: "testservicefilecontents",
},
readonly: false,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
fs := afero.NewMemMapFs()
assert.NoError(fs.MkdirAll(systemdUnitFolder, 0o755))
if tc.readonly {
fs = afero.NewReadOnlyFs(fs)
}
manager := ServiceManager{
log: logger.NewTest(t),
dbus: &tc.dbus,
fs: fs,
systemdUnitFilewriteLock: sync.Mutex{},
}
err := manager.WriteSystemdUnitFile(context.Background(), tc.unit)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
fileContents, err := afero.ReadFile(fs, fmt.Sprintf("%s/%s", systemdUnitFolder, tc.unit.Name))
assert.NoError(err)
assert.Equal(tc.wantFileContents, string(fileContents))
})
}
}
type stubDbus struct {
conn dbusConn
connErr error
}
func (s *stubDbus) NewSystemdConnectionContext(ctx context.Context) (dbusConn, error) {
return s.conn, s.connErr
}
type dbusConnActionInput struct {
name string
mode string
}
type fakeDbusConn struct {
inputs []dbusConnActionInput
result string
jobID int
actionErr error
}
func (c *fakeDbusConn) StartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
c.inputs = append(c.inputs, dbusConnActionInput{name: name, mode: mode})
ch <- c.result
return c.jobID, c.actionErr
}
func (c *fakeDbusConn) StopUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
c.inputs = append(c.inputs, dbusConnActionInput{name: name, mode: mode})
ch <- c.result
return c.jobID, c.actionErr
}
func (c *fakeDbusConn) RestartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
c.inputs = append(c.inputs, dbusConnActionInput{name: name, mode: mode})
ch <- c.result
return c.jobID, c.actionErr
}
func (c *fakeDbusConn) ReloadContext(ctx context.Context) error {
return c.actionErr
}

View file

@ -0,0 +1,27 @@
// Code generated by "stringer -type=SystemdAction"; DO NOT EDIT.
package deploy
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[Unknown-0]
_ = x[Start-1]
_ = x[Stop-2]
_ = x[Restart-3]
_ = x[Reload-4]
}
const _SystemdAction_name = "UnknownStartStopRestartReload"
var _SystemdAction_index = [...]uint8{0, 7, 12, 16, 23, 29}
func (i SystemdAction) String() string {
if i >= SystemdAction(len(_SystemdAction_index)-1) {
return "SystemdAction(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _SystemdAction_name[_SystemdAction_index[i]:_SystemdAction_index[i+1]]
}

View file

@ -0,0 +1,40 @@
package deploy
import (
"context"
"github.com/coreos/go-systemd/v22/dbus"
)
// wraps go-systemd dbus.
type dbusWrapper struct{}
func (d *dbusWrapper) NewSystemdConnectionContext(ctx context.Context) (dbusConn, error) {
conn, err := dbus.NewSystemdConnectionContext(ctx)
if err != nil {
return nil, err
}
return &dbusConnWrapper{
conn: conn,
}, nil
}
type dbusConnWrapper struct {
conn *dbus.Conn
}
func (c *dbusConnWrapper) StartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
return c.conn.StartUnitContext(ctx, name, mode, ch)
}
func (c *dbusConnWrapper) StopUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
return c.conn.StopUnitContext(ctx, name, mode, ch)
}
func (c *dbusConnWrapper) RestartUnitContext(ctx context.Context, name string, mode string, ch chan<- string) (int, error) {
return c.conn.RestartUnitContext(ctx, name, mode, ch)
}
func (c *dbusConnWrapper) ReloadContext(ctx context.Context) error {
return c.conn.ReloadContext(ctx)
}