talos/internal/integration/api/serviceaccount.go
Utku Ozdemir 84e712a9f1
feat: introduce Talos API access from Kubernetes
We add a new CRD, `serviceaccounts.talos.dev` (with `tsa` as short name), and its controller which allows users to get a `Secret` containing a short-lived Talosconfig in their namespaces with the roles they need. Additionally, we introduce the `talosctl inject serviceaccount` command to accept a YAML file with Kubernetes manifests and inject them with Talos service accounts so that they can be directly applied to Kubernetes afterwards. If Talos API access feature is enabled on Talos side, the injected workloads will be able to talk to Talos API.

Closes siderolabs/talos#4422.

Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
2022-08-08 18:27:26 +02:00

304 lines
9.1 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//go:build integration_api
// +build integration_api
package api
import (
"context"
"fmt"
"time"
"github.com/siderolabs/go-pointer"
"github.com/talos-systems/go-retry/retry"
corev1 "k8s.io/api/core/v1"
eventsv1 "k8s.io/api/events/v1"
kubeerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/talos-systems/talos/internal/integration/base"
machineapi "github.com/talos-systems/talos/pkg/machinery/api/machine"
"github.com/talos-systems/talos/pkg/machinery/client"
"github.com/talos-systems/talos/pkg/machinery/client/config"
"github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1"
"github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1/machine"
"github.com/talos-systems/talos/pkg/machinery/constants"
)
var (
serviceAccountGVR = schema.GroupVersionResource{
Group: constants.ServiceAccountResourceGroup,
Version: constants.ServiceAccountResourceVersion,
Resource: constants.ServiceAccountResourcePlural,
}
secretGVR = schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
}
)
// ServiceAccountSuite verifies Talos ServiceAccount.
type ServiceAccountSuite struct {
base.K8sSuite
ctx context.Context //nolint:containedctx
ctxCancel context.CancelFunc
}
// SuiteName ...
func (suite *ServiceAccountSuite) SuiteName() string {
return "api.ServiceAccountSuite"
}
// SetupTest ...
func (suite *ServiceAccountSuite) SetupTest() {
// make sure API calls have timeout
suite.ctx, suite.ctxCancel = context.WithTimeout(context.Background(), 5*time.Minute)
}
// TearDownTest ...
func (suite *ServiceAccountSuite) TearDownTest() {
if suite.ctxCancel != nil {
suite.ctxCancel()
}
}
// TestValid tests Kubernetes service accounts.
//
//nolint:dupl
func (suite *ServiceAccountSuite) TestValid() {
name := "test-valid"
err := suite.configureAPIAccess(true, []string{"os:reader"}, []string{"kube-system"})
suite.Assert().NoError(err)
_, err = suite.getCRD()
suite.Assert().NoError(err)
sa, err := suite.createServiceAccount("kube-system", name, []string{"os:reader"})
suite.Assert().NoError(err)
defer suite.DeleteResource(suite.ctx, serviceAccountGVR, "default", name) // nolint:errcheck
err = suite.WaitForEventExists(suite.ctx, "kube-system", func(event eventsv1.Event) bool {
return event.Regarding.UID == sa.GetUID() &&
event.Type == corev1.EventTypeNormal &&
event.Reason == "Synced"
})
suite.Assert().NoError(err)
secret, err := suite.waitForSecret("kube-system", name)
suite.Assert().NoError(err)
talosConfig := secret.Data["config"]
conf, err := config.FromBytes(talosConfig)
suite.Assert().NoError(err)
expectedServiceName := fmt.Sprintf(
"%s.%s",
constants.KubernetesTalosAPIServiceName,
constants.KubernetesTalosAPIServiceNamespace,
)
suite.Assert().Equal([]string{expectedServiceName}, conf.Contexts[conf.Context].Endpoints)
err = suite.DeleteResource(suite.ctx, serviceAccountGVR, "kube-system", name)
suite.Require().NoError(err)
err = suite.EnsureResourceIsDeleted(suite.ctx, 30*time.Second, secretGVR, "kube-system", name)
suite.Assert().NoError(err)
}
// TestNotAllowedNamespace tests Kubernetes service accounts in not allowed namespaces.
//
//nolint:dupl
func (suite *ServiceAccountSuite) TestNotAllowedNamespace() {
name := "test-allowed-ns"
err := suite.configureAPIAccess(true, []string{"os:reader"}, []string{"kube-system"})
suite.Assert().NoError(err)
sa, err := suite.createServiceAccount("default", name, []string{"os:reader"})
suite.Assert().NoError(err)
defer suite.DeleteResource(suite.ctx, serviceAccountGVR, "default", name) // nolint:errcheck
err = suite.WaitForEventExists(suite.ctx, "default", func(event eventsv1.Event) bool {
return event.Regarding.UID == sa.GetUID() &&
event.Type == corev1.EventTypeWarning &&
event.Reason == "ErrNamespaceNotAllowed"
})
suite.Assert().NoError(err)
}
// TestNotAllowedRoles tests Kubernetes service accounts with not allowed roles.
//
//nolint:dupl
func (suite *ServiceAccountSuite) TestNotAllowedRoles() {
name := "test-not-allowed-roles"
err := suite.configureAPIAccess(true, []string{"os:reader"}, []string{"kube-system"})
suite.Assert().NoError(err)
sa, err := suite.createServiceAccount("kube-system", name, []string{"os:admin"})
suite.Assert().NoError(err)
defer suite.DeleteResource(suite.ctx, serviceAccountGVR, "kube-system", name) // nolint:errcheck
err = suite.WaitForEventExists(suite.ctx, "kube-system", func(event eventsv1.Event) bool {
return event.Regarding.UID == sa.GetUID() &&
event.Type == corev1.EventTypeWarning &&
event.Reason == "ErrRolesNotAllowed"
})
suite.Assert().NoError(err)
}
// TestFeatureNotEnabled tests Kubernetes service accounts when API access feature is not enabled.
//
//nolint:dupl
func (suite *ServiceAccountSuite) TestFeatureNotEnabled() {
name := "test-feature-not-enabled"
err := suite.configureAPIAccess(false, []string{"os:reader"}, []string{"kube-system"})
suite.Assert().NoError(err)
sa, err := suite.createServiceAccount("kube-system", name, []string{"os:reader"})
if kubeerrors.IsNotFound(err) {
// CRD is not created because the feature was never enabled, all good
return
}
suite.Assert().NoError(err)
defer suite.DeleteResource(suite.ctx, serviceAccountGVR, "kube-system", name) // nolint:errcheck
err = suite.WaitForEventExists(suite.ctx, "kube-system", func(event eventsv1.Event) bool {
return event.Regarding.UID == sa.GetUID() &&
event.Type == corev1.EventTypeWarning &&
event.Reason == "ErrAccessNotEnabled"
})
suite.Assert().NoError(err)
}
func (suite *ServiceAccountSuite) waitForSecret(ns, name string) (*corev1.Secret, error) {
var (
secret *corev1.Secret
err error
)
err = retry.Constant(1*time.Minute).RetryWithContext(suite.ctx, func(ctx context.Context) error {
secret, err = suite.Clientset.CoreV1().Secrets(ns).Get(suite.ctx, name, metav1.GetOptions{})
if kubeerrors.IsNotFound(err) {
return retry.ExpectedError(err)
}
return err
})
if err != nil {
return nil, err
}
return secret, nil
}
func (suite *ServiceAccountSuite) getCRD() (*unstructured.Unstructured, error) {
crdName := fmt.Sprintf("%s.%s", constants.ServiceAccountResourcePlural, constants.ServiceAccountResourceGroup)
return suite.DynamicClient.Resource(schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
}).Get(suite.ctx, crdName, metav1.GetOptions{})
}
func (suite *ServiceAccountSuite) createServiceAccount(ns string, name string, roles []string) (*unstructured.Unstructured, error) {
return suite.DynamicClient.Resource(serviceAccountGVR).Namespace(ns).Create(suite.ctx, &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": fmt.Sprintf("%s/%s", constants.ServiceAccountResourceGroup, constants.ServiceAccountResourceVersion),
"kind": constants.ServiceAccountResourceKind,
"metadata": map[string]interface{}{
"name": name,
},
"spec": map[string]interface{}{
"roles": roles,
},
},
}, metav1.CreateOptions{})
}
// configureAPIAccess configures the API access feature on all control plane nodes.
func (suite *ServiceAccountSuite) configureAPIAccess(
enabled bool,
allowedRoles []string,
allowedNamespaces []string,
) error {
controlPlaneIPs := suite.DiscoverNodeInternalIPsByType(suite.ctx, machine.TypeControlPlane)
for _, ip := range controlPlaneIPs {
nodeCtx := client.WithNodes(suite.ctx, ip)
nodeConfig, err := suite.ReadConfigFromNode(nodeCtx)
if err != nil {
return err
}
nodeConfigRaw, ok := nodeConfig.Raw().(*v1alpha1.Config)
if !ok {
return fmt.Errorf("unexpected node config type %T", nodeConfig.Raw())
}
accessConfig := v1alpha1.KubernetesTalosAPIAccessConfig{
AccessEnabled: pointer.To(enabled),
AccessAllowedRoles: allowedRoles,
AccessAllowedKubernetesNamespaces: allowedNamespaces,
}
nodeConfigRaw.MachineConfig.MachineFeatures.KubernetesTalosAPIAccessConfig = &accessConfig
bytes, err := nodeConfigRaw.Bytes()
if err != nil {
return err
}
_, err = suite.Client.ApplyConfiguration(nodeCtx, &machineapi.ApplyConfigurationRequest{
Data: bytes,
Mode: machineapi.ApplyConfigurationRequest_NO_REBOOT,
})
if err != nil {
return err
}
}
if enabled { // wait for CRD and the Talos endpoint to be created
return retry.Constant(30*time.Second).RetryWithContext(suite.ctx, func(ctx context.Context) error {
_, err := suite.getCRD()
if err != nil {
return retry.ExpectedError(err)
}
_, err = suite.Clientset.CoreV1().
Services(constants.KubernetesTalosAPIServiceNamespace).
Get(suite.ctx, constants.KubernetesTalosAPIServiceName, metav1.GetOptions{})
if err != nil {
return retry.ExpectedError(err)
}
return nil
})
}
return nil
}
func init() {
allSuites = append(allSuites, new(ServiceAccountSuite))
}