diff --git a/api/plugin_runtime_types.go b/api/plugin_runtime_types.go new file mode 100644 index 000000000000..d3acd0d002c4 --- /dev/null +++ b/api/plugin_runtime_types.go @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package api + +// NOTE: this file was copied from +// https://github.com/hashicorp/vault/blob/main/sdk/helper/consts/plugin_runtime_types.go +// Any changes made should be made to both files at the same time. + +import "fmt" + +var PluginRuntimeTypes = []PluginRuntimeType{ + PluginRuntimeTypeUnsupported, + PluginRuntimeTypeContainer, +} + +type PluginRuntimeType uint32 + +// This is a list of PluginRuntimeTypes used by Vault. +const ( + PluginRuntimeTypeUnsupported PluginRuntimeType = iota + PluginRuntimeTypeContainer +) + +func (r PluginRuntimeType) String() string { + switch r { + case PluginRuntimeTypeContainer: + return "container" + default: + return "unsupported" + } +} + +func ParsePluginRuntimeType(PluginRuntimeType string) (PluginRuntimeType, error) { + switch PluginRuntimeType { + case "container": + return PluginRuntimeTypeContainer, nil + default: + return PluginRuntimeTypeUnsupported, fmt.Errorf("%q is not a supported plugin runtime type", PluginRuntimeType) + } +} diff --git a/api/sudo_paths.go b/api/sudo_paths.go index fb7113a0f3b3..24beb4bb1f2a 100644 --- a/api/sudo_paths.go +++ b/api/sudo_paths.go @@ -32,19 +32,21 @@ var sudoPaths = map[string]*regexp.Regexp{ // This entry is a bit wrong... sys/leases/lookup does NOT require sudo. But sys/leases/lookup/ with a trailing // slash DOES require sudo. But the part of the Vault CLI that uses this logic doesn't pass operation-appropriate // trailing slashes, it always strips them off, so we end up giving the wrong answer for one of these. - "/sys/leases/lookup/{prefix}": regexp.MustCompile(`^/sys/leases/lookup(?:/.+)?$`), - "/sys/leases/revoke-force/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-force/.+$`), - "/sys/leases/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-prefix/.+$`), - "/sys/plugins/catalog/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[^/]+$`), - "/sys/plugins/catalog/{type}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+$`), - "/sys/plugins/catalog/{type}/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+/[^/]+$`), - "/sys/raw/{path}": regexp.MustCompile(`^/sys/raw(?:/.+)?$`), - "/sys/remount": regexp.MustCompile(`^/sys/remount$`), - "/sys/revoke-force/{prefix}": regexp.MustCompile(`^/sys/revoke-force/.+$`), - "/sys/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/revoke-prefix/.+$`), - "/sys/rotate": regexp.MustCompile(`^/sys/rotate$`), - "/sys/seal": regexp.MustCompile(`^/sys/seal$`), - "/sys/step-down": regexp.MustCompile(`^/sys/step-down$`), + "/sys/leases/lookup/{prefix}": regexp.MustCompile(`^/sys/leases/lookup(?:/.+)?$`), + "/sys/leases/revoke-force/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-force/.+$`), + "/sys/leases/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-prefix/.+$`), + "/sys/plugins/catalog/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[^/]+$`), + "/sys/plugins/catalog/{type}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+$`), + "/sys/plugins/catalog/{type}/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+/[^/]+$`), + "/sys/plugins/runtimes/catalog": regexp.MustCompile(`^/sys/plugins/runtimes/catalog/?$`), + "/sys/plugins/runtimes/catalog/{type}/{name}": regexp.MustCompile(`^/sys/plugins/runtimes/catalog/[\w-]+/[^/]+$`), + "/sys/raw/{path}": regexp.MustCompile(`^/sys/raw(?:/.+)?$`), + "/sys/remount": regexp.MustCompile(`^/sys/remount$`), + "/sys/revoke-force/{prefix}": regexp.MustCompile(`^/sys/revoke-force/.+$`), + "/sys/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/revoke-prefix/.+$`), + "/sys/rotate": regexp.MustCompile(`^/sys/rotate$`), + "/sys/seal": regexp.MustCompile(`^/sys/seal$`), + "/sys/step-down": regexp.MustCompile(`^/sys/step-down$`), // enterprise-only paths "/sys/replication/dr/primary/secondary-token": regexp.MustCompile(`^/sys/replication/dr/primary/secondary-token$`), diff --git a/api/sudo_paths_test.go b/api/sudo_paths_test.go index 2e97d44cb5b9..b23af7067fc9 100644 --- a/api/sudo_paths_test.go +++ b/api/sudo_paths_test.go @@ -55,6 +55,11 @@ func TestIsSudoPath(t *testing.T) { "/sys/plugins/catalog/some-type/some/name/with/slashes", false, }, + // Testing: sys/plugins/runtimes/catalog/{type}/{name} + { + "/sys/plugins/runtimes/catalog/some-type/some-name", + true, + }, // Testing: auth/token/accessors (an example of a sudo path that only accepts list operations) // It is matched as sudo without the trailing slash... { diff --git a/api/sys_plugins_runtimes.go b/api/sys_plugins_runtimes.go new file mode 100644 index 000000000000..c3380a85d1bf --- /dev/null +++ b/api/sys_plugins_runtimes.go @@ -0,0 +1,189 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" +) + +// GetPluginRuntimeInput is used as input to the GetPluginRuntime function. +type GetPluginRuntimeInput struct { + Name string `json:"-"` + + // Type of the plugin runtime. Required. + Type PluginRuntimeType `json:"type"` +} + +// GetPluginRuntimeResponse is the response from the GetPluginRuntime call. +type GetPluginRuntimeResponse struct { + Type string `json:"type"` + Name string `json:"name"` + OCIRuntime string `json:"oci_runtime"` + CgroupParent string `json:"cgroup_parent"` + CPU int64 `json:"cpu_nanos"` + Memory int64 `json:"memory_bytes"` +} + +// GetPluginRuntime retrieves information about the plugin. +func (c *Sys) GetPluginRuntime(ctx context.Context, i *GetPluginRuntimeInput) (*GetPluginRuntimeResponse, error) { + ctx, cancelFunc := c.c.withConfiguredTimeout(ctx) + defer cancelFunc() + + path := pluginRuntimeCatalogPathByType(i.Type, i.Name) + req := c.c.NewRequest(http.MethodGet, path) + + resp, err := c.c.rawRequestWithContext(ctx, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data *GetPluginRuntimeResponse + } + err = resp.DecodeJSON(&result) + if err != nil { + return nil, err + } + return result.Data, err +} + +// RegisterPluginRuntimeInput is used as input to the RegisterPluginRuntime function. +type RegisterPluginRuntimeInput struct { + // Name is the name of the plugin. Required. + Name string `json:"-"` + + // Type of the plugin. Required. + Type PluginRuntimeType `json:"type"` + + OCIRuntime string `json:"oci_runtime,omitempty"` + CgroupParent string `json:"cgroup_parent,omitempty"` + CPU int64 `json:"cpu,omitempty"` + Memory int64 `json:"memory,omitempty"` +} + +// RegisterPluginRuntime registers the plugin with the given information. +func (c *Sys) RegisterPluginRuntime(ctx context.Context, i *RegisterPluginRuntimeInput) error { + ctx, cancelFunc := c.c.withConfiguredTimeout(ctx) + defer cancelFunc() + + path := pluginRuntimeCatalogPathByType(i.Type, i.Name) + req := c.c.NewRequest(http.MethodPut, path) + + if err := req.SetJSONBody(i); err != nil { + return err + } + + resp, err := c.c.rawRequestWithContext(ctx, req) + if err == nil { + defer resp.Body.Close() + } + return err +} + +// DeregisterPluginRuntimeInput is used as input to the DeregisterPluginRuntime function. +type DeregisterPluginRuntimeInput struct { + // Name is the name of the plugin runtime. Required. + Name string `json:"-"` + + // Type of the plugin. Required. + Type PluginRuntimeType `json:"type"` +} + +// DeregisterPluginRuntime removes the plugin with the given name from the plugin +// catalog. +func (c *Sys) DeregisterPluginRuntime(ctx context.Context, i *DeregisterPluginRuntimeInput) error { + ctx, cancelFunc := c.c.withConfiguredTimeout(ctx) + defer cancelFunc() + + path := pluginRuntimeCatalogPathByType(i.Type, i.Name) + req := c.c.NewRequest(http.MethodDelete, path) + resp, err := c.c.rawRequestWithContext(ctx, req) + if err == nil { + defer resp.Body.Close() + } + return err +} + +type PluginRuntimeDetails struct { + Type string `json:"type" mapstructure:"type"` + Name string `json:"name" mapstructure:"name"` + OCIRuntime string `json:"oci_runtime" mapstructure:"oci_runtime"` + CgroupParent string `json:"cgroup_parent" mapstructure:"cgroup_parent"` + CPU int64 `json:"cpu_nanos" mapstructure:"cpu_nanos"` + Memory int64 `json:"memory_bytes" mapstructure:"memory_bytes"` +} + +// ListPluginRuntimesInput is used as input to the ListPluginRuntimes function. +type ListPluginRuntimesInput struct { + // Type of the plugin. Required. + Type PluginRuntimeType `json:"type"` +} + +// ListPluginRuntimesResponse is the response from the ListPluginRuntimes call. +type ListPluginRuntimesResponse struct { + // RuntimesByType is the list of plugin runtimes by type. + Runtimes []PluginRuntimeDetails `json:"runtimes"` +} + +// ListPluginRuntimes lists all plugin runtimes in the catalog and returns their names as a +// list of strings. +func (c *Sys) ListPluginRuntimes(ctx context.Context, input *ListPluginRuntimesInput) (*ListPluginRuntimesResponse, error) { + ctx, cancelFunc := c.c.withConfiguredTimeout(ctx) + defer cancelFunc() + + if input != nil && input.Type == PluginRuntimeTypeUnsupported { + return nil, fmt.Errorf("%q is not a supported runtime type", input.Type.String()) + } + + resp, err := c.c.rawRequestWithContext(ctx, c.c.NewRequest(http.MethodGet, "/v1/sys/plugins/runtimes/catalog")) + if err != nil && resp == nil { + return nil, err + } + if resp == nil { + return nil, nil + } + defer resp.Body.Close() + + secret, err := ParseSecret(resp.Body) + if err != nil { + return nil, err + } + if secret == nil || secret.Data == nil { + return nil, errors.New("data from server response is empty") + } + if _, ok := secret.Data["runtimes"]; !ok { + return nil, fmt.Errorf("data from server response does not contain runtimes") + } + + var runtimes []PluginRuntimeDetails + if err = mapstructure.Decode(secret.Data["runtimes"], &runtimes); err != nil { + return nil, err + } + + // return all runtimes in the catalog + if input == nil { + return &ListPluginRuntimesResponse{Runtimes: runtimes}, nil + } + + result := &ListPluginRuntimesResponse{ + Runtimes: []PluginRuntimeDetails{}, + } + for _, runtime := range runtimes { + if runtime.Type == input.Type.String() { + result.Runtimes = append(result.Runtimes, runtime) + } + } + return result, nil +} + +// pluginRuntimeCatalogPathByType is a helper to construct the proper API path by plugin type +func pluginRuntimeCatalogPathByType(runtimeType PluginRuntimeType, name string) string { + return fmt.Sprintf("/v1/sys/plugins/runtimes/catalog/%s/%s", runtimeType, name) +} diff --git a/api/sys_plugins_runtimes_test.go b/api/sys_plugins_runtimes_test.go new file mode 100644 index 000000000000..6c3486a31a00 --- /dev/null +++ b/api/sys_plugins_runtimes_test.go @@ -0,0 +1,268 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestRegisterPluginRuntime(t *testing.T) { + mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultHandlerRegister)) + defer mockVaultServer.Close() + + cfg := DefaultConfig() + cfg.Address = mockVaultServer.URL + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + err = client.Sys().RegisterPluginRuntime(context.Background(), &RegisterPluginRuntimeInput{ + Name: "gvisor", + Type: PluginRuntimeTypeContainer, + OCIRuntime: "runsc", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestGetPluginRuntime(t *testing.T) { + for name, tc := range map[string]struct { + body string + expected GetPluginRuntimeResponse + }{ + "gvisor": { + body: getPluginRuntimeResponse, + expected: GetPluginRuntimeResponse{ + Name: "gvisor", + Type: PluginRuntimeTypeContainer.String(), + OCIRuntime: "runsc", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + }, + }, + } { + t.Run(name, func(t *testing.T) { + mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultHandlerInfo(tc.body))) + defer mockVaultServer.Close() + + cfg := DefaultConfig() + cfg.Address = mockVaultServer.URL + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + input := GetPluginRuntimeInput{ + Name: "gvisor", + Type: PluginRuntimeTypeContainer, + } + + info, err := client.Sys().GetPluginRuntime(context.Background(), &input) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(tc.expected, *info) { + t.Errorf("expected: %#v\ngot: %#v", tc.expected, info) + } + }) + } +} + +func TestListPluginRuntimeTyped(t *testing.T) { + for _, tc := range []struct { + runtimeType PluginRuntimeType + body string + expectedResponse *ListPluginRuntimesResponse + expectedErrNil bool + }{ + { + runtimeType: PluginRuntimeTypeContainer, + body: listPluginRuntimeTypedResponse, + expectedResponse: &ListPluginRuntimesResponse{ + Runtimes: []PluginRuntimeDetails{ + { + Type: "container", + Name: "gvisor", + OCIRuntime: "runsc", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + }, + }, + }, + expectedErrNil: true, + }, + { + runtimeType: PluginRuntimeTypeUnsupported, + body: listPluginRuntimeTypedResponse, + expectedResponse: nil, + expectedErrNil: false, + }, + } { + t.Run(tc.runtimeType.String(), func(t *testing.T) { + mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultHandlerInfo(tc.body))) + defer mockVaultServer.Close() + + cfg := DefaultConfig() + cfg.Address = mockVaultServer.URL + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + input := ListPluginRuntimesInput{ + Type: tc.runtimeType, + } + + list, err := client.Sys().ListPluginRuntimes(context.Background(), &input) + if tc.expectedErrNil && err != nil { + t.Fatal(err) + } + + if (tc.expectedErrNil && !reflect.DeepEqual(tc.expectedResponse, list)) || (!tc.expectedErrNil && list != nil) { + t.Errorf("expected: %#v\ngot: %#v", tc.expectedResponse, list) + } + }) + } +} + +func TestListPluginRuntimeUntyped(t *testing.T) { + for _, tc := range []struct { + body string + expectedResponse *ListPluginRuntimesResponse + expectedErrNil bool + }{ + { + body: listPluginRuntimeUntypedResponse, + expectedResponse: &ListPluginRuntimesResponse{ + Runtimes: []PluginRuntimeDetails{ + { + Type: "container", + Name: "gvisor", + OCIRuntime: "runsc", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + }, + { + Type: "container", + Name: "foo", + OCIRuntime: "otherociruntime", + CgroupParent: "/memorylimit/", + CPU: 2, + Memory: 20000, + }, + { + Type: "container", + Name: "bar", + OCIRuntime: "otherociruntime", + CgroupParent: "/cpulimit/", + CPU: 3, + Memory: 30000, + }, + }, + }, + expectedErrNil: true, + }, + } { + t.Run("", func(t *testing.T) { + mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultHandlerInfo(tc.body))) + defer mockVaultServer.Close() + + cfg := DefaultConfig() + cfg.Address = mockVaultServer.URL + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + + info, err := client.Sys().ListPluginRuntimes(context.Background(), nil) + if tc.expectedErrNil && err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(tc.expectedResponse, info) { + t.Errorf("expected: %#v\ngot: %#v", tc.expectedResponse, info) + } + }) + } +} + +const getPluginRuntimeResponse = `{ + "request_id": "e93d3f93-8e4f-8443-a803-f1c97c123456", + "data": { + "name": "gvisor", + "type": "container", + "oci_runtime": "runsc", + "cgroup_parent": "/cpulimit/", + "cpu_nanos": 1, + "memory_bytes": 10000 + }, + "warnings": null, + "auth": null +}` + +const listPluginRuntimeTypedResponse = `{ + "request_id": "e93d3f93-8e4f-8443-a803-f1c97c123456", + "data": { + "runtimes": [ + { + "name": "gvisor", + "type": "container", + "oci_runtime": "runsc", + "cgroup_parent": "/cpulimit/", + "cpu_nanos": 1, + "memory_bytes": 10000 + } + ] + }, + "warnings": null, + "auth": null +} +` + +const listPluginRuntimeUntypedResponse = `{ + "request_id": "e93d3f93-8e4f-8443-a803-f1c97c123456", + "data": { + "runtimes": [ + { + "name": "gvisor", + "type": "container", + "oci_runtime": "runsc", + "cgroup_parent": "/cpulimit/", + "cpu_nanos": 1, + "memory_bytes": 10000 + }, + { + "name": "foo", + "type": "container", + "oci_runtime": "otherociruntime", + "cgroup_parent": "/memorylimit/", + "cpu_nanos": 2, + "memory_bytes": 20000 + }, + { + "name": "bar", + "type": "container", + "oci_runtime": "otherociruntime", + "cgroup_parent": "/cpulimit/", + "cpu_nanos": 3, + "memory_bytes": 30000 + } + ] + }, + "warnings": null, + "auth": null +}` diff --git a/sdk/helper/consts/plugin_runtime_types.go b/sdk/helper/consts/plugin_runtime_types.go new file mode 100644 index 000000000000..63b8127ec2c9 --- /dev/null +++ b/sdk/helper/consts/plugin_runtime_types.go @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package consts + +// NOTE: this file has been copied to +// https://github.com/hashicorp/vault/blob/main/api/plugin_runtime_types.go +// Any changes made should be made to both files at the same time. + +import "fmt" + +var PluginRuntimeTypes = []PluginRuntimeType{ + PluginRuntimeTypeUnsupported, + PluginRuntimeTypeContainer, +} + +type PluginRuntimeType uint32 + +// This is a list of PluginRuntimeTypes used by Vault. +const ( + PluginRuntimeTypeUnsupported PluginRuntimeType = iota + PluginRuntimeTypeContainer +) + +func (r PluginRuntimeType) String() string { + switch r { + case PluginRuntimeTypeContainer: + return "container" + default: + return "unsupported" + } +} + +func ParsePluginRuntimeType(PluginRuntimeType string) (PluginRuntimeType, error) { + switch PluginRuntimeType { + case "container": + return PluginRuntimeTypeContainer, nil + default: + return PluginRuntimeTypeUnsupported, fmt.Errorf("%q is not a supported plugin runtime type", PluginRuntimeType) + } +} diff --git a/sdk/helper/pluginruntimeutil/config.go b/sdk/helper/pluginruntimeutil/config.go new file mode 100644 index 000000000000..4a361ddfc7b5 --- /dev/null +++ b/sdk/helper/pluginruntimeutil/config.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package pluginruntimeutil + +import "github.com/hashicorp/vault/sdk/helper/consts" + +// PluginRuntimeConfig defines the metadata needed to run a plugin runtime +type PluginRuntimeConfig struct { + Name string `json:"name" structs:"name"` + Type consts.PluginRuntimeType `json:"type" structs:"type"` + OCIRuntime string `json:"oci_runtime" structs:"oci_runtime"` + CgroupParent string `json:"cgroup_parent" structs:"cgroup_parent"` + CPU int64 `json:"cpu" structs:"cpu"` + Memory int64 `json:"memory" structs:"memory"` +} diff --git a/vault/core.go b/vault/core.go index 86b7a312bfe4..a381027baf01 100644 --- a/vault/core.go +++ b/vault/core.go @@ -548,6 +548,9 @@ type Core struct { // pluginCatalog is used to manage plugin configurations pluginCatalog *PluginCatalog + // pluginRuntimeCatalog is used to manage plugin runtime configurations + pluginRuntimeCatalog *PluginRuntimeCatalog + // The userFailedLoginInfo map has user failed login information. // It has user information (alias-name and mount accessor) as a key // and login counter, last failed login time as value @@ -2293,6 +2296,9 @@ func (s standardUnsealStrategy) unseal(ctx context.Context, logger log.Logger, c return err } } + if err := c.setupPluginRuntimeCatalog(ctx); err != nil { + return err + } if err := c.setupPluginCatalog(ctx); err != nil { return err } diff --git a/vault/logical_system.go b/vault/logical_system.go index c6380104d3c4..e7d56ea6f6a5 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -43,6 +43,7 @@ import ( "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/jsonutil" + "github.com/hashicorp/vault/sdk/helper/pluginruntimeutil" "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/helper/roottoken" "github.com/hashicorp/vault/sdk/helper/wrapping" @@ -113,6 +114,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend { "config/auditing/*", "config/ui/headers/*", "plugins/catalog/*", + "plugins/runtimes/catalog/*", "revoke-prefix/*", "revoke-force/*", "leases/revoke-prefix/*", @@ -186,6 +188,8 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend { b.Backend.Paths = append(b.Backend.Paths, b.pluginsCatalogListPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.pluginsCatalogCRUDPath()) b.Backend.Paths = append(b.Backend.Paths, b.pluginsReloadPath()) + b.Backend.Paths = append(b.Backend.Paths, b.pluginsRuntimesCatalogCRUDPath()) + b.Backend.Paths = append(b.Backend.Paths, b.pluginsRuntimesCatalogListPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.auditPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.mountPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.authPaths()...) @@ -725,6 +729,147 @@ func (b *SystemBackend) handlePluginReloadUpdate(ctx context.Context, req *logic return &r, nil } +func (b *SystemBackend) handlePluginRuntimeCatalogUpdate(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + runtimeName := d.Get("name").(string) + if runtimeName == "" { + return logical.ErrorResponse("missing plugin runtime name"), nil + } + + runtimeTypeStr := d.Get("type").(string) + if runtimeTypeStr == "" { + return logical.ErrorResponse("missing plugin runtime type"), nil + } + + runtimeType, err := consts.ParsePluginRuntimeType(runtimeTypeStr) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + switch runtimeType { + case consts.PluginRuntimeTypeContainer: + ociRuntime := d.Get("oci_runtime").(string) + cgroupParent := d.Get("cgroup_parent").(string) + cpu := d.Get("cpu_nanos").(int64) + if cpu < 0 { + return logical.ErrorResponse("runtime cpu in nanos cannot be negative"), nil + } + memory := d.Get("memory_bytes").(int64) + if memory < 0 { + return logical.ErrorResponse("runtime memory in bytes cannot be negative"), nil + } + if err = b.Core.pluginRuntimeCatalog.Set(ctx, + &pluginruntimeutil.PluginRuntimeConfig{ + Name: runtimeName, + Type: runtimeType, + OCIRuntime: ociRuntime, + CgroupParent: cgroupParent, + CPU: cpu, + Memory: memory, + }); err != nil { + return logical.ErrorResponse(err.Error()), nil + } + default: + logical.ErrorResponse(fmt.Sprintf("%s is not a supported plugin runtime type", runtimeTypeStr)) + } + return nil, nil +} + +func (b *SystemBackend) handlePluginRuntimeCatalogDelete(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + runtimeName := d.Get("name").(string) + if runtimeName == "" { + return logical.ErrorResponse("missing plugin runtime name"), nil + } + + runtimeTypeStr := d.Get("type").(string) + if runtimeTypeStr == "" { + return logical.ErrorResponse("missing plugin runtime type"), nil + } + + runtimeType, err := consts.ParsePluginRuntimeType(runtimeTypeStr) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + err = b.Core.pluginRuntimeCatalog.Delete(ctx, runtimeName, runtimeType) + if err != nil { + return nil, err + } + return nil, nil +} + +func (b *SystemBackend) handlePluginRuntimeCatalogRead(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + runtimeName := d.Get("name").(string) + if runtimeName == "" { + return logical.ErrorResponse("missing plugin runtime name"), nil + } + + runtimeTypeStr := d.Get("type").(string) + if runtimeTypeStr == "" { + return logical.ErrorResponse("missing plugin runtime type"), nil + } + + runtimeType, err := consts.ParsePluginRuntimeType(runtimeTypeStr) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + conf, err := b.Core.pluginRuntimeCatalog.Get(ctx, runtimeName, runtimeType) + if err != nil { + return nil, err + } + if conf == nil { + return nil, nil + } + + return &logical.Response{Data: map[string]interface{}{ + "name": conf.Name, + "type": conf.Type.String(), + "oci_runtime": conf.OCIRuntime, + "cgroup_parent": conf.CgroupParent, + "cpu_nanos": conf.CPU, + "memory_bytes": conf.Memory, + }}, nil +} + +func (b *SystemBackend) handlePluginRuntimeCatalogList(ctx context.Context, _ *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + var data []map[string]any + for _, runtimeType := range consts.PluginRuntimeTypes { + if runtimeType == consts.PluginRuntimeTypeUnsupported { + continue + } + configs, err := b.Core.pluginRuntimeCatalog.List(ctx, runtimeType) + if err != nil { + return nil, err + } + + if len(configs) > 0 { + sort.Slice(configs, func(i, j int) bool { + return strings.Compare(configs[i].Name, configs[j].Name) == -1 + }) + for _, conf := range configs { + data = append(data, map[string]any{ + "name": conf.Name, + "type": conf.Type.String(), + "oci_runtime": conf.OCIRuntime, + "cgroup_parent": conf.CgroupParent, + "cpu_nanos": conf.CPU, + "memory_bytes": conf.Memory, + }) + } + } + } + + resp := &logical.Response{ + Data: map[string]interface{}{}, + } + + if len(data) > 0 { + resp.Data["runtimes"] = data + } + + return resp, nil +} + // handleAuditedHeaderUpdate creates or overwrites a header entry func (b *SystemBackend) handleAuditedHeaderUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { header := d.Get("header").(string) @@ -5947,6 +6092,51 @@ Each entry is of the form "key=value".`, "The semantic version of the plugin to use.", "", }, + "plugin-runtime-catalog": { + "Configures plugin runtimes", + ` +This path responds to the following HTTP methods. + LIST / + Returns a list of names of configured plugin runtimes. + + GET // + Retrieve the metadata for the named plugin runtime. + + PUT // + Add or update plugin runtime. + + DELETE // + Delete the plugin runtime with the given name. + `, + }, + "plugin-runtime-catalog-list-all": { + "List all plugin runtimes in the catalog as a map of type to names.", + "", + }, + "plugin-runtime-catalog_name": { + "The name of the plugin runtime", + "", + }, + "plugin-runtime-catalog_type": { + "The type of the plugin runtime", + "", + }, + "plugin-runtime-catalog_oci-runtime": { + "The OCI-compatible runtime (default \"runsc\")", + "", + }, + "plugin-runtime-catalog_cgroup-parent": { + "Optional parent cgroup for the container", + "", + }, + "plugin-runtime-catalog_cpu-nanos": { + "The limit of runtime CPU in nanos", + "", + }, + "plugin-runtime-catalog_memory-bytes": { + "The limit of runtime memory in bytes", + "", + }, "leases": { `View or list lease metadata.`, ` diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index c71e5e85983f..1d143493b944 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -2093,6 +2093,155 @@ func (b *SystemBackend) pluginsReloadPath() *framework.Path { } } +func (b *SystemBackend) pluginsRuntimesCatalogCRUDPath() *framework.Path { + return &framework.Path{ + Pattern: "plugins/runtimes/catalog/(?Pcontainer)/" + framework.GenericNameRegex("name"), + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "plugins-runtimes-catalog", + }, + + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_name"][0]), + }, + "type": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_type"][0]), + }, + "oci_runtime": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_oci-runtime"][0]), + }, + "cgroup_parent": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_cgroup-parent"][0]), + }, + "cpu_nanos": { + Type: framework.TypeInt64, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_cpu-nanos"][0]), + }, + "memory_bytes": { + Type: framework.TypeInt64, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_memory-bytes"][0]), + }, + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.handlePluginRuntimeCatalogUpdate, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "register", + OperationSuffix: "plugin-runtime|plugin-runtime-with-type|plugin-runtime-with-type-and-name", // TODO + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + }}, + }, + Summary: "Register a new plugin runtime, or updates an existing one with the supplied name.", + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.handlePluginRuntimeCatalogDelete, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "remove", + OperationSuffix: "plugin-runtime|plugin-runtime-with-type|plugin-runtime-with-type-and-name", // TODO + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + }}, + }, + Summary: "Remove the plugin runtime with the given name.", + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: b.handlePluginRuntimeCatalogRead, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "read", + OperationSuffix: "plugin-runtime-configuration|plugin-runtime-configuration-with-type|plugin-runtime-configuration-with-type-and-name", + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_name"][0]), + Required: true, + }, + "type": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_type"][0]), + Required: true, + }, + "oci_runtime": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_oci-runtime"][0]), + Required: true, + }, + "cgroup_parent": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_cgroup-parent"][0]), + Required: true, + }, + "cpu_nanos": { + Type: framework.TypeInt64, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_cpu-nanos"][0]), + Required: true, + }, + "memory_bytes": { + Type: framework.TypeInt64, + Description: strings.TrimSpace(sysHelp["plugin-runtime-catalog_memory-bytes"][0]), + Required: true, + }, + }, + }}, + }, + Summary: "Return the configuration data for the plugin runtime with the given name.", + }, + }, + + HelpSynopsis: strings.TrimSpace(sysHelp["plugin-runtime-catalog"][0]), + HelpDescription: strings.TrimSpace(sysHelp["plugin-runtime-catalog"][1]), + } +} + +func (b *SystemBackend) pluginsRuntimesCatalogListPaths() []*framework.Path { + return []*framework.Path{ + { + Pattern: "plugins/runtimes/catalog/?$", + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "plugins-runtimes-catalog", + OperationVerb: "list", + OperationSuffix: "plugins-runtimes", + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.handlePluginRuntimeCatalogList, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + Fields: map[string]*framework.FieldSchema{ + "runtimes": { + Type: framework.TypeSlice, + Description: "List of all plugin runtimes in the catalog", + Required: true, + }, + }, + }}, + }, + }, + }, + + HelpSynopsis: strings.TrimSpace(sysHelp["plugin-runtime-catalog-list-all"][0]), + HelpDescription: strings.TrimSpace(sysHelp["plugin-runtime-catalog-list-all"][1]), + }, + } +} + func (b *SystemBackend) toolsPaths() []*framework.Path { return []*framework.Path{ { diff --git a/vault/logical_system_test.go b/vault/logical_system_test.go index 7d5ce33a4714..3cbabf642d13 100644 --- a/vault/logical_system_test.go +++ b/vault/logical_system_test.go @@ -33,6 +33,7 @@ import ( "github.com/hashicorp/vault/sdk/helper/compressutil" "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/jsonutil" + "github.com/hashicorp/vault/sdk/helper/pluginruntimeutil" "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/helper/testhelpers/schema" "github.com/hashicorp/vault/sdk/logical" @@ -5884,3 +5885,122 @@ func TestSystemBackend_ReadExperiments(t *testing.T) { }) } } + +func TestSystemBackend_pluginRuntimeCRUD(t *testing.T) { + b := testSystemBackend(t) + + conf := pluginruntimeutil.PluginRuntimeConfig{ + Name: "foo", + Type: consts.PluginRuntimeTypeContainer, + OCIRuntime: "some-oci-runtime", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + } + + // Register the plugin runtime + req := logical.TestRequest(t, logical.UpdateOperation, fmt.Sprintf("plugins/runtimes/catalog/%s/%s", conf.Type.String(), conf.Name)) + req.Data = map[string]interface{}{ + "oci_runtime": conf.OCIRuntime, + "cgroup_parent": conf.OCIRuntime, + "cpu_nanos": conf.CPU, + "memory_bytes": conf.Memory, + } + + resp, err := b.HandleRequest(namespace.RootContext(nil), req) + if err != nil { + t.Fatalf("err: %v %#v", err, resp) + } + if resp != nil && (resp.IsError() || len(resp.Data) > 0) { + t.Fatalf("bad: %#v", resp) + } + + // validate the response structure for plugin container runtime named foo + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.(*SystemBackend).Route(req.Path), req.Operation), + resp, + true, + ) + + // Read the plugin runtime + req = logical.TestRequest(t, logical.ReadOperation, "plugins/runtimes/catalog/container/foo") + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil { + t.Fatalf("err: %v", err) + } + + // validate the response structure for plugin container runtime named foo + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.(*SystemBackend).Route(req.Path), req.Operation), + resp, + true, + ) + + readExp := map[string]any{ + "type": conf.Type.String(), + "name": conf.Name, + "oci_runtime": conf.OCIRuntime, + "cgroup_parent": conf.OCIRuntime, + "cpu_nanos": conf.CPU, + "memory_bytes": conf.Memory, + } + if !reflect.DeepEqual(resp.Data, readExp) { + t.Fatalf("got: %#v expect: %#v", resp.Data, readExp) + } + + // List the plugin runtimes (untyped or all) + req = logical.TestRequest(t, logical.ListOperation, "plugins/runtimes/catalog") + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil { + t.Fatalf("err: %v", err) + } + + listExp := map[string]interface{}{ + "runtimes": []map[string]any{readExp}, + } + if !reflect.DeepEqual(resp.Data, listExp) { + t.Fatalf("got: %#v expect: %#v", resp.Data, listExp) + } + + // Delete the plugin runtime + req = logical.TestRequest(t, logical.DeleteOperation, "plugins/runtimes/catalog/container/foo") + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp != nil { + t.Fatalf("bad: %#v", resp) + } + + // validate the response structure for plugin container runtime named foo + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.(*SystemBackend).Route(req.Path), req.Operation), + resp, + true, + ) + + // Read the plugin runtime (deleted) + req = logical.TestRequest(t, logical.ReadOperation, "plugins/runtimes/catalog/container/foo") + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err == nil { + t.Fatal("expected a read error after the runtime was deleted") + } + if resp != nil { + t.Fatalf("bad: %#v", resp) + } + + // List the plugin runtimes (untyped or all) + req = logical.TestRequest(t, logical.ListOperation, "plugins/runtimes/catalog") + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil { + t.Fatalf("err: %v", err) + } + + listExp = map[string]interface{}{} + if !reflect.DeepEqual(resp.Data, listExp) { + t.Fatalf("got: %#v expect: %#v", resp.Data, listExp) + } +} diff --git a/vault/plugin_runtime_catalog.go b/vault/plugin_runtime_catalog.go new file mode 100644 index 000000000000..9cb0f58869fd --- /dev/null +++ b/vault/plugin_runtime_catalog.go @@ -0,0 +1,140 @@ +package vault + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "sync" + + log "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/sdk/helper/jsonutil" + "github.com/hashicorp/vault/sdk/helper/pluginruntimeutil" + "github.com/hashicorp/vault/sdk/logical" +) + +var ( + pluginRuntimeCatalogPath = "core/plugin-runtime-catalog/" + ErrPluginRuntimeNotFound = errors.New("plugin runtime not found") + ErrPluginRuntimeBadType = errors.New("unable to determine plugin runtime type") + ErrPluginRuntimeBadContainerConfig = errors.New("bad container config") +) + +// PluginRuntimeCatalog keeps a record of plugin runtimes. Plugin runtimes need +// to be registered to the catalog before they can be used in backends when registering plugins with runtimes +type PluginRuntimeCatalog struct { + catalogView *BarrierView + logger log.Logger + + lock sync.RWMutex +} + +func (c *Core) setupPluginRuntimeCatalog(ctx context.Context) error { + c.pluginRuntimeCatalog = &PluginRuntimeCatalog{ + catalogView: NewBarrierView(c.barrier, pluginRuntimeCatalogPath), + logger: c.logger, + } + + if c.logger.IsInfo() { + c.logger.Info("successfully setup plugin runtime catalog") + } + + return nil +} + +// Get retrieves a plugin runtime with the specified name from the catalog +// It returns a PluginRuntimeConfig or an error if no plugin runtime was found. +func (c *PluginRuntimeCatalog) Get(ctx context.Context, name string, prt consts.PluginRuntimeType) (*pluginruntimeutil.PluginRuntimeConfig, error) { + storageKey := path.Join(prt.String(), name) + c.lock.RLock() + defer c.lock.RUnlock() + entry, err := c.catalogView.Get(ctx, storageKey) + if err != nil { + return nil, fmt.Errorf("failed to retrieve plugin runtime %q %q: %w", prt.String(), name, err) + } + if entry == nil { + return nil, fmt.Errorf("failed to retrieve plugin %q %q: %w", prt.String(), name, err) + } + runner := new(pluginruntimeutil.PluginRuntimeConfig) + if err := jsonutil.DecodeJSON(entry.Value, runner); err != nil { + return nil, fmt.Errorf("failed to decode plugin runtime entry: %w", err) + } + if runner.Type != prt { + return nil, nil + } + return runner, nil +} + +// Set registers a new plugin with the catalog, or updates an existing plugin runtime +func (c *PluginRuntimeCatalog) Set(ctx context.Context, conf *pluginruntimeutil.PluginRuntimeConfig) error { + c.lock.Lock() + defer c.lock.Unlock() + + if conf == nil { + return fmt.Errorf("plugin runtime config reference is nil") + } + + buf, err := json.Marshal(conf) + if err != nil { + return fmt.Errorf("failed to encode plugin entry: %w", err) + } + + storageKey := path.Join(conf.Type.String(), conf.Name) + logicalEntry := logical.StorageEntry{ + Key: storageKey, + Value: buf, + } + + if err := c.catalogView.Put(ctx, &logicalEntry); err != nil { + return fmt.Errorf("failed to persist plugin runtime entry: %w", err) + } + return err +} + +// Delete is used to remove an external plugin from the catalog. Builtin plugins +// can not be deleted. +func (c *PluginRuntimeCatalog) Delete(ctx context.Context, name string, prt consts.PluginRuntimeType) error { + c.lock.Lock() + defer c.lock.Unlock() + + storageKey := path.Join(prt.String(), name) + out, err := c.catalogView.Get(ctx, storageKey) + if err != nil || out == nil { + return ErrPluginRuntimeNotFound + } + + return c.catalogView.Delete(ctx, storageKey) +} + +func (c *PluginRuntimeCatalog) List(ctx context.Context, prt consts.PluginRuntimeType) ([]*pluginruntimeutil.PluginRuntimeConfig, error) { + c.lock.RLock() + defer c.lock.RUnlock() + + var retList []*pluginruntimeutil.PluginRuntimeConfig + keys, err := logical.CollectKeys(ctx, c.catalogView) + if err != nil { + return nil, err + } + + for _, key := range keys { + entry, err := c.catalogView.Get(ctx, key) + if err != nil || entry == nil { + continue + } + + conf := new(pluginruntimeutil.PluginRuntimeConfig) + if err := jsonutil.DecodeJSON(entry.Value, conf); err != nil { + return nil, fmt.Errorf("failed to decode plugin runtime entry: %w", err) + } + + if conf.Type != prt { + continue + } + + retList = append(retList, conf) + } + return retList, nil +} diff --git a/vault/plugin_runtime_catalog_test.go b/vault/plugin_runtime_catalog_test.go new file mode 100644 index 000000000000..50bded62fca4 --- /dev/null +++ b/vault/plugin_runtime_catalog_test.go @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package vault + +import ( + "context" + "reflect" + "testing" + + "github.com/hashicorp/vault/sdk/helper/pluginruntimeutil" +) + +func TestPluginRuntimeCatalog_CRUD(t *testing.T) { + core, _, _ := TestCoreUnsealed(t) + ctx := context.Background() + + expected := &pluginruntimeutil.PluginRuntimeConfig{ + Name: "gvisor", + OCIRuntime: "runsc", + CgroupParent: "/cpulimit/", + CPU: 1, + Memory: 10000, + } + + // Set new plugin runtime + err := core.pluginRuntimeCatalog.Set(ctx, expected) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Get plugin runtime + runner, err := core.pluginRuntimeCatalog.Get(ctx, expected.Name, expected.Type) + if err != nil { + t.Fatalf("err: %v", err) + } + if !reflect.DeepEqual(expected, runner) { + t.Fatalf("expected did not match actual, got %#v\n expected %#v\n", runner, expected) + } + + // Set existing plugin runtime + expected.CgroupParent = "memorylimit-cgroup" + expected.CPU = 2 + expected.Memory = 5000 + err = core.pluginRuntimeCatalog.Set(ctx, expected) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Get plugin runtime again + runner, err = core.pluginRuntimeCatalog.Get(ctx, expected.Name, expected.Type) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !reflect.DeepEqual(expected, runner) { + t.Fatalf("expected did not match actual, got %#v\n expected %#v\n", runner, expected) + } + + configs, err := core.pluginRuntimeCatalog.List(ctx, expected.Type) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(configs) != 1 { + t.Fatalf("expected plugin runtime catalog to have 1 container runtime but got %d", len(configs)) + } + + // Delete plugin runtime + err = core.pluginRuntimeCatalog.Delete(ctx, expected.Name, expected.Type) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Assert the plugin runtime catalog is empty + configs, err = core.pluginRuntimeCatalog.List(ctx, expected.Type) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(configs) != 0 { + t.Fatalf("expected plugin runtime catalog to have 0 container runtimes but got %d", len(configs)) + } +}