Skip to content

Commit

Permalink
Machine ID: Support path-based Kubernetes routing
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
timothyb89 committed Jan 9, 2025
1 parent f7883aa commit ca0c4e8
Show file tree
Hide file tree
Showing 10 changed files with 917 additions and 0 deletions.
121 changes: 121 additions & 0 deletions lib/tbot/cli/start_kubernetes_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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"
"strings"

"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("kubernetes-cluster-name", "An explicit Kubernetes cluster name to include. Repeatable.").StringsVar(&c.KubernetesClusterNames)
cmd.Flag("kubernetes-cluster-labels", "A set of Kubernetes labels to match in k1=v1,k2=v2 form. Repeatable.").StringsVar(&c.KubernetesClusterLabels)

// Note: excluding roles; the bot will fetch all available in CLI mode.

return c
}

// parseLabelSelectorString parses a string containing key/value pairs in
// "k1=v1,k2=v2" form into a Kubernetes selector.
func parseLabelSelectorString(selectors string) (*config.KubernetesSelector, error) {
labels := map[string]string{}

for _, selector := range strings.Split(selectors, ",") {
k, v, found := strings.Cut(selector, "=")
if !found {
return nil, trace.BadParameter("invalid selector: %s", selector)
}

labels[k] = v
}

return &config.KubernetesSelector{
Labels: labels,
}, nil
}

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,
})
}

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

return nil
}
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: 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
144 changes: 144 additions & 0 deletions lib/tbot/config/service_kubernetes_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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/teleport/lib/tbot/bot"
"github.com/gravitational/trace"
"gopkg.in/yaml.v3"
)

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"`

// Roles is the list of roles to request for the generated credentials.
// If empty, it defaults to all the bot's roles.
Roles []string `yaml:"roles,omitempty"`

// 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
}
Loading

0 comments on commit ca0c4e8

Please sign in to comment.