mirror of
https://github.com/siderolabs/talos.git
synced 2025-08-11 00:57:02 +02:00
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>
304 lines
9.1 KiB
Go
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))
|
|
}
|