Skip to content
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
59 changes: 59 additions & 0 deletions pkg/adaptation/adaptation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package adaptation

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)

const (
// PluginLaunchedByEngineVar is used to inform engine-launched plugins about their name.
PluginLaunchedByEngineVar = "DOCKER_SECRETS_ENGINE_PLUGIN_LAUNCH_CFG"
// DefaultPluginRegistrationTimeout is the default timeout for plugin registration.
DefaultPluginRegistrationTimeout = 5 * time.Second
)

func DefaultSocketPath() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return filepath.Join(dir, "secrets-engine", "engine.sock")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache", "secrets-engine", "engine.sock")
}
return filepath.Join(os.TempDir(), "secrets-engine", "engine.sock")
}

type PluginConfigFromEngine struct {
Name string `json:"name"`
RegistrationTimeout time.Duration `json:"timeout"`
Fd int `json:"fd"`
}

func (c *PluginConfigFromEngine) ToString() (string, error) {
result, err := json.Marshal(c)
if err != nil {
return "", err
}
return string(result), nil
}

func NewPluginConfigFromEngineEnv(in string) (*PluginConfigFromEngine, error) {
var result PluginConfigFromEngine
if err := json.Unmarshal([]byte(in), &result); err != nil {
return nil, fmt.Errorf("failed to decode plugin config from engine %q: %w", PluginLaunchedByEngineVar, err)
}
if result.Name == "" {
return nil, errors.New("plugin name is required")
}
if result.RegistrationTimeout == 0 {
return nil, errors.New("plugin registration timeout is required")
}
Comment on lines +51 to +53
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if it's necessary to error on a timeout value, perhaps just fallback to a sane default instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep for now. In NRI they didn't error on this which made them miss that in NRI configuring the registration timeout is actually bugged / doesn't work.

if result.Fd <= 2 {
// File descriptors 0, 1, and 2 are reserved for stdin, stdout, and stderr.
return nil, errors.New("invalid file descriptor for plugin connection")
}
return &result, nil
}
75 changes: 75 additions & 0 deletions pkg/adaptation/adaptation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package adaptation

import (
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPluginConfigFromEngine_ToString(t *testing.T) {
in := PluginConfigFromEngine{
Name: strings.Repeat("ab", 250), // 500 characters
RegistrationTimeout: 27 * time.Nanosecond,
Fd: 10,
}
out, err := in.ToString()
assert.NoError(t, err)
// This is coming from here: https://superuser.com/questions/1070272/why-does-windows-have-a-limit-on-environment-variables-at-all
// -> we verify that a plugin name of 500 characters is still within the limit
assert.LessOrEqual(t, len(out), 2048)
restored, err := NewPluginConfigFromEngineEnv(out)
assert.NoError(t, err)
assert.Equal(t, in, *restored)
}

func TestNewPluginConfigFromEngineFromString(t *testing.T) {
tests := []struct {
name string
in PluginConfigFromEngine
err string
}{
{
name: "name is empty",
err: "name is required",
},
{
name: "registration timeout is zero",
in: PluginConfigFromEngine{
Name: "test-plugin",
},
err: "registration timeout is required",
},
{
name: "fd is nonsense",
in: PluginConfigFromEngine{
Name: "test-plugin",
RegistrationTimeout: 10 * time.Second,
Fd: 2,
},
err: "invalid file descriptor",
},
{
name: "valid config",
in: PluginConfigFromEngine{
Name: "test-plugin",
RegistrationTimeout: 10 * time.Second,
Fd: 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := tt.in.ToString()
require.NoError(t, err)
_, err = NewPluginConfigFromEngineEnv(out)
if tt.err != "" {
assert.ErrorContains(t, err, tt.err)
} else {
assert.NoError(t, err)
}
})
}
}
48 changes: 0 additions & 48 deletions pkg/api/util.go

This file was deleted.

83 changes: 0 additions & 83 deletions pkg/api/util_test.go

This file was deleted.

128 changes: 128 additions & 0 deletions pkg/stub/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package stub

import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"

"github.com/docker/secrets-engine/pkg/adaptation"
)

// ManualLaunchOption to apply to a plugin during its creation
// when it's manually launched (not by the secrets engine).
type ManualLaunchOption func(c *cfg) error

// WithPluginName sets the name to use in plugin registration.
func WithPluginName(name string) ManualLaunchOption {
return func(s *cfg) error {
if name == "" {
return errors.New("plugin name cannot be empty")
}
s.name = name
return nil
}
}

// WithRegistrationTimeout sets custom registration timeout.
func WithRegistrationTimeout(timeout time.Duration) ManualLaunchOption {
return func(s *cfg) error {
s.registrationTimeout = timeout
return nil
}
}

// WithConnection sets an existing secrets engine connection to use.
func WithConnection(conn net.Conn) ManualLaunchOption {
return func(s *cfg) error {
if s.conn != nil {
return errors.New("connection already set")
}
s.conn = conn
return nil
}
}

type cfg struct {
plugin Plugin
name string
conn net.Conn
registrationTimeout time.Duration
}

func newCfg(p Plugin, opts ...ManualLaunchOption) (*cfg, error) {
engineCfg, err := restoreConfig(p)
if errors.Is(err, errPluginNotLaunchedByEngine) {
return newCfgForManualLaunch(p, opts...)
}
return engineCfg, err
}

func newCfgForManualLaunch(p Plugin, opts ...ManualLaunchOption) (*cfg, error) {
cfg := &cfg{
plugin: p,
registrationTimeout: adaptation.DefaultPluginRegistrationTimeout,
}
for _, o := range opts {
if err := o(cfg); err != nil {
return nil, err
}
}
if cfg.conn == nil {
defaultSocketPath := adaptation.DefaultSocketPath()
conn, err := net.Dial("unix", defaultSocketPath)
if err != nil {
return nil, fmt.Errorf("failed to connect to default socket %q: %w", defaultSocketPath, err)
}
cfg.conn = conn
}
if cfg.name == "" {
if len(os.Args) == 0 {
// This should never happen in practice but can happen in tests or when something else empties os.Args for whatever reason.
return nil, errors.New("plugin name must be specified (could not derive from os.Args)")
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit unfortunate. But I believe eg go test does it (that made the tests crash on CI so I noticed). Otherwise we could have moved that up to the line where the struct gets created initially.

}
cfg.name = filepath.Base(os.Args[0])
}
return cfg, nil
}

var (
errPluginNotLaunchedByEngine = errors.New("plugin not launched by secrets engine")
)

func restoreConfig(p Plugin) (*cfg, error) {
cfgString := os.Getenv(adaptation.PluginLaunchedByEngineVar)
if cfgString == "" {
return nil, errPluginNotLaunchedByEngine
}
c, err := adaptation.NewPluginConfigFromEngineEnv(cfgString)
if err != nil {
return nil, err
}
conn, err := connectionFromFileDescriptor(c.Fd)
if err != nil {
return nil, fmt.Errorf("invalid socket (%d) in environment: %w", c.Fd, err)
}
return &cfg{
plugin: p,
name: c.Name,
conn: conn,
registrationTimeout: c.RegistrationTimeout,
}, nil
}

func connectionFromFileDescriptor(fd int) (net.Conn, error) {
f := os.NewFile(uintptr(fd), "fd #"+strconv.Itoa(fd))
if f == nil {
return nil, fmt.Errorf("failed to open FD %d", fd)
}
defer f.Close()
conn, err := net.FileConn(f)
if err != nil {
return nil, fmt.Errorf("failed to create net.Conn for fd #%d: %w", fd, err)
}
return conn, nil
}
Loading