Skip to content

Commit

Permalink
Machine ID: Support path-based Kubernetes routing (#50898) (#51535)
Browse files Browse the repository at this point in the history
* Machine ID: Support path-based Kubernetes routing

This adds a new `kubernetes/v2` service to support path-based
routing, which allows clients to access an arbitrary number of
Kubernetes clusters using a single issued identity.

It can be used with `tbot start kubernetes/v2` and specifying one or
more explicit clusters with `--kubernetes-cluster-name` or a label
selector with `--kubernetes-cluster-labels`.

* Added tests for new Kubernetes selector behavior

* Fix failing tests

* Fix test failure after rebase

* Rename CLI parameters to match workload ID selector syntax

* Fix broken test after flag rename

* Remove unused function

* Remove user-configurable roles from kubernetes/v2
  • Loading branch information
timothyb89 authored Jan 29, 2025
1 parent 843d512 commit 8c3b25a
Show file tree
Hide file tree
Showing 17 changed files with 1,419 additions and 2 deletions.
104 changes: 104 additions & 0 deletions lib/tbot/cli/start_kubernetes_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package cli

import (
"fmt"
"log/slog"

"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"

"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/tbot/config"
)

// KubernetesV2Command implements `tbot start kubernetes` and
// `tbot configure kubernetes`.
type KubernetesV2Command struct {
*sharedStartArgs
*sharedDestinationArgs
*genericMutatorHandler

DisableExecPlugin bool
KubernetesClusterNames []string

// KubernetesClusterLabels contains a list of strings representing label
// selectors. Each entry generates one selector, but may contain several
// comma-separated strings to match multiple labels at once.
KubernetesClusterLabels []string
}

// NewKubernetesCommand initializes the command and flags for kubernetes outputs
// and returns a struct to contain the parse result.
func NewKubernetesV2Command(parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode) *KubernetesV2Command {
cmd := parentCmd.Command("kubernetes/v2", fmt.Sprintf("%s tbot with a Kubernetes V2 output.", mode)).Alias("k8s/v2")

c := &KubernetesV2Command{}
c.sharedStartArgs = newSharedStartArgs(cmd)
c.sharedDestinationArgs = newSharedDestinationArgs(cmd)
c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)

cmd.Flag("disable-exec-plugin", "If set, disables the exec plugin. This allows credentials to be used without the `tbot` binary.").BoolVar(&c.DisableExecPlugin)
cmd.Flag("name-selector", "An explicit Kubernetes cluster name to include. Repeatable.").StringsVar(&c.KubernetesClusterNames)
cmd.Flag("label-selector", "A set of Kubernetes labels to match in k1=v1,k2=v2 form. Repeatable.").StringsVar(&c.KubernetesClusterLabels)

return c
}

func (c *KubernetesV2Command) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
return trace.Wrap(err)
}

dest, err := c.BuildDestination()
if err != nil {
return trace.Wrap(err)
}

selectors := []*config.KubernetesSelector{}
for _, name := range c.KubernetesClusterNames {
selectors = append(selectors, &config.KubernetesSelector{
Name: name,
})
}

for _, s := range c.KubernetesClusterLabels {
labels, err := client.ParseLabelSpec(s)
if err != nil {
return trace.Wrap(err)
}

selectors = append(selectors, &config.KubernetesSelector{
Labels: labels,
})
}

if len(selectors) == 0 {
return trace.BadParameter("at least one name-selector or label-selector must be provided")
}

cfg.Services = append(cfg.Services, &config.KubernetesV2Output{
Destination: dest,
DisableExecPlugin: c.DisableExecPlugin,
Selectors: selectors,
})

return nil
}
85 changes: 85 additions & 0 deletions lib/tbot/cli/start_kubernetes_v2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package cli

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/lib/tbot/config"
)

// TestKubernetesV2Command tests that the KubernetesCommand properly parses its
// arguments and applies as expected onto a BotConfig.
func TestKubernetesV2Command(t *testing.T) {
testStartConfigureCommand(t, NewKubernetesV2Command, []startConfigureTestCase{
{
name: "success",
args: []string{
"start",
"kubernetes/v2",
"--destination=/bar",
"--token=foo",
"--join-method=github",
"--proxy-server=example.com:443",
"--disable-exec-plugin",
"--name-selector=a",
"--name-selector=b",
"--label-selector=c=\"foo bar\",d=\"baz qux\"",
},
assertConfig: func(t *testing.T, cfg *config.BotConfig) {
require.Len(t, cfg.Services, 1)

// It must configure a kubernetes output with a directory destination.
svc := cfg.Services[0]
k8s, ok := svc.(*config.KubernetesV2Output)
require.True(t, ok)

require.True(t, k8s.DisableExecPlugin)

dir, ok := k8s.Destination.(*config.DestinationDirectory)
require.True(t, ok)
require.Equal(t, "/bar", dir.Path)

var foundA, foundB, foundLabelSelector bool
for _, selector := range k8s.Selectors {
switch selector.Name {
case "a":
foundA = true
case "b":
foundB = true
case "":
require.Equal(t, map[string]string{
"c": "foo bar",
"d": "baz qux",
}, selector.Labels)
foundLabelSelector = true
default:
require.Fail(t, "unexpected selector name %q", selector.Name)
}
}

require.True(t, foundA, "name selector 'a' must exist")
require.True(t, foundB, "name selector 'b' must exist")
require.True(t, foundLabelSelector, "label selector must exist")
},
},
})
}
6 changes: 6 additions & 0 deletions lib/tbot/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error {
return trace.Wrap(err)
}
out = append(out, v)
case KubernetesV2OutputType:
v := &KubernetesV2Output{}
if err := node.Decode(v); err != nil {
return trace.Wrap(err)
}
out = append(out, v)
case SPIFFESVIDOutputType:
v := &SPIFFESVIDOutput{}
if err := node.Decode(v); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion lib/tbot/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func testYAML[T any](t *testing.T, tests []testYAMLCase[T]) {
decoder := yaml.NewDecoder(b)
var unmarshalled T
require.NoError(t, decoder.Decode(&unmarshalled))
require.Equal(t, unmarshalled, tt.in, "unmarshalling did not result in same object as input")
require.Equal(t, tt.in, unmarshalled, "unmarshalling did not result in same object as input")
})
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/tbot/config/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func memoryDestForTest() bot.Destination {
}

func testCheckAndSetDefaults[T checkAndSetDefaulter](t *testing.T, tests []testCheckAndSetDefaultsCase[T]) {
t.Helper()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.in()
Expand Down
157 changes: 157 additions & 0 deletions lib/tbot/config/service_kubernetes_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package config

import (
"context"

"github.com/gravitational/trace"
"gopkg.in/yaml.v3"

"github.com/gravitational/teleport/lib/tbot/bot"
)

var (
_ ServiceConfig = &KubernetesV2Output{}
_ Initable = &KubernetesV2Output{}
)

const KubernetesV2OutputType = "kubernetes/v2"

// KubernetesOutput produces credentials which can be used to connect to a
// Kubernetes Cluster through teleport.
type KubernetesV2Output struct {
// Destination is where the credentials should be written to.
Destination bot.Destination `yaml:"destination"`

// DisableExecPlugin disables the default behavior of using `tbot` as a
// `kubectl` credentials exec plugin. This is useful in environments where
// `tbot` may not exist on the system that will consume the outputted
// kubeconfig. It does mean that kubectl will not be able to automatically
// refresh the credentials within an individual invocation.
DisableExecPlugin bool `yaml:"disable_exec_plugin,omitempty"`

// Selectors is a list of selectors for path-based routing. Multiple
// selectors can be used to generate an output containing all matches.
Selectors []*KubernetesSelector `yaml:"selectors,omitempty"`
}

func (o *KubernetesV2Output) CheckAndSetDefaults() error {
if err := validateOutputDestination(o.Destination); err != nil {
return trace.Wrap(err)
}

if len(o.Selectors) == 0 {
return trace.BadParameter("at least one selector must be provided")
}

for _, s := range o.Selectors {
if err := s.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}
}

return trace.Wrap(o.Destination.CheckAndSetDefaults())
}

func (o *KubernetesV2Output) GetDestination() bot.Destination {
return o.Destination
}

func (o *KubernetesV2Output) Init(ctx context.Context) error {
return trace.Wrap(o.Destination.Init(ctx, []string{}))
}

func (o *KubernetesV2Output) Describe() []FileDescription {
// Based on tbot.KubernetesOutputService.Render
return []FileDescription{
{
Name: "kubeconfig.yaml",
},
{
Name: IdentityFilePath,
},
{
Name: HostCAPath,
},
}
}

func (o *KubernetesV2Output) MarshalYAML() (interface{}, error) {
type raw KubernetesV2Output
return withTypeHeader((*raw)(o), KubernetesV2OutputType)
}

func (o *KubernetesV2Output) UnmarshalYAML(node *yaml.Node) error {
dest, err := extractOutputDestination(node)
if err != nil {
return trace.Wrap(err)
}
// Alias type to remove UnmarshalYAML to avoid recursion
type raw KubernetesV2Output
if err := node.Decode((*raw)(o)); err != nil {
return trace.Wrap(err)
}
o.Destination = dest
return nil
}

func (o *KubernetesV2Output) Type() string {
return KubernetesV2OutputType
}

// KubernetesSelector allows querying for a Kubernetes cluster to include either
// by its name or labels.
type KubernetesSelector struct {
Name string `yaml:"name,omitempty"`

Labels map[string]string `yaml:"labels,omitempty"`
}

func (s *KubernetesSelector) CheckAndSetDefaults() error {
if s.Name == "" && len(s.Labels) == 0 {
return trace.BadParameter("selectors: one of 'name' and 'labels' must be specified")
}

if s.Name != "" && len(s.Labels) > 0 {
return trace.BadParameter("selectors: only one of 'name' and 'labels' may be specified")
}

if s.Labels == nil {
s.Labels = map[string]string{}
}

return nil
}

func (s *KubernetesSelector) UnmarshalYAML(value *yaml.Node) error {
// A custom unmarshaler so Labels is consistently initialized to not-nil.
// Primarily needed for tests.
type temp KubernetesSelector
out := temp{
Labels: make(map[string]string),
}

if err := value.Decode(&out); err != nil {
return err
}

*s = KubernetesSelector(out)
return nil
}
Loading

0 comments on commit 8c3b25a

Please sign in to comment.