Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Machine ID: Support path-based Kubernetes routing #50898

Open
wants to merge 8 commits into
base: timothyb89/kubernetes-path-routing
Choose a base branch
from
Open
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
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 @@ -327,7 +327,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"`
timothyb89 marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading