mirror of
https://github.com/siderolabs/talos.git
synced 2025-10-10 07:01:12 +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>
379 lines
8.8 KiB
Go
379 lines
8.8 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/.
|
|
|
|
package inject
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer/json"
|
|
|
|
"github.com/talos-systems/talos/pkg/machinery/constants"
|
|
)
|
|
|
|
const (
|
|
injectToEnv = false
|
|
volumeName = "talos-secrets"
|
|
|
|
nameSuffix = "-talos-secrets"
|
|
|
|
apiVersionField = "apiVersion"
|
|
kindField = "kind"
|
|
metadataField = "metadata"
|
|
namespaceField = "namespace"
|
|
nameField = "name"
|
|
|
|
yamlSeparator = "---\n"
|
|
)
|
|
|
|
// ServiceAccount takes a YAML with Kubernetes manifests and requested Talos roles as input
|
|
// and injects Talos service accounts into them.
|
|
//
|
|
//nolint:gocyclo
|
|
func ServiceAccount(reader io.Reader, roles []string) ([]byte, error) {
|
|
var err error
|
|
|
|
objectSerializer := json.NewSerializerWithOptions(
|
|
json.DefaultMetaFactory,
|
|
nil,
|
|
nil,
|
|
json.SerializerOptions{
|
|
Yaml: true,
|
|
Pretty: true,
|
|
Strict: true,
|
|
},
|
|
)
|
|
|
|
seenResourceIDs := make(map[string]struct{})
|
|
|
|
var buf bytes.Buffer
|
|
|
|
decoder := yaml.NewDecoder(reader)
|
|
|
|
// loop over all documents in a possibly YAML with multiple documents separated by ---
|
|
for {
|
|
var raw map[string]any
|
|
|
|
err = decoder.Decode(&raw)
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if raw == nil {
|
|
continue
|
|
}
|
|
|
|
var injected metav1.Object
|
|
|
|
injected, err = injectToObject(raw)
|
|
if err != nil { // not a known resource with a PodSpec
|
|
// if this is already a Talos ServiceAccount resource we have seen,
|
|
// we keep it only if we have not seen it yet (means it belongs to the user, not injected by us)
|
|
id := readResourceIDFromServiceAccount(raw)
|
|
if id != "" {
|
|
if _, ok := seenResourceIDs[id]; ok {
|
|
continue
|
|
}
|
|
|
|
seenResourceIDs[id] = struct{}{}
|
|
}
|
|
|
|
err = yaml.NewEncoder(&buf).Encode(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
buf.WriteString(yamlSeparator)
|
|
|
|
continue
|
|
}
|
|
|
|
// injectable resource type which contains a PodSpec
|
|
|
|
runtimeObject, ok := injected.(runtime.Object)
|
|
if !ok {
|
|
return nil, errors.New("injected object is not a runtime.Object")
|
|
}
|
|
|
|
err = objectSerializer.Encode(runtimeObject, &buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
buf.WriteString(yamlSeparator)
|
|
|
|
id := readResourceIDFromObject(injected)
|
|
|
|
// inject service account for the resource
|
|
if _, ok = seenResourceIDs[id]; !ok {
|
|
sa := buildServiceAccount(injected.GetNamespace(), fmt.Sprintf("%s%s", injected.GetName(), nameSuffix), roles)
|
|
|
|
err = yaml.NewEncoder(&buf).Encode(sa)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
buf.WriteString(yamlSeparator)
|
|
|
|
// mark resource as seen
|
|
seenResourceIDs[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func buildServiceAccount(namespace string, name string, roles []string) map[string]any {
|
|
metadata := map[string]any{
|
|
nameField: name,
|
|
}
|
|
|
|
if namespace != "" {
|
|
metadata[namespaceField] = namespace
|
|
}
|
|
|
|
return map[string]any{
|
|
apiVersionField: fmt.Sprintf(
|
|
"%s/%s",
|
|
constants.ServiceAccountResourceGroup,
|
|
constants.ServiceAccountResourceVersion,
|
|
),
|
|
kindField: constants.ServiceAccountResourceKind,
|
|
metadataField: metadata,
|
|
"spec": map[string]any{
|
|
"roles": roles,
|
|
},
|
|
}
|
|
}
|
|
|
|
func isServiceAccount(raw map[string]any) bool {
|
|
apiVersionKind, err := readResourceAPIVersionKind(raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return apiVersionKind == fmt.Sprintf(
|
|
"%s/%s/%s",
|
|
constants.ServiceAccountResourceGroup,
|
|
constants.ServiceAccountResourceVersion,
|
|
constants.ServiceAccountResourceKind,
|
|
)
|
|
}
|
|
|
|
// injectToDocument takes a single YAML document and attempts to inject a ServiceAccount
|
|
// into it if it is a known Kubernetes resource type which contains a corev1.PodSpec.
|
|
//
|
|
//nolint:gocyclo
|
|
func injectToObject(raw map[string]any) (metav1.Object, error) {
|
|
var err error
|
|
|
|
apiVersionKind, err := readResourceAPIVersionKind(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch apiVersionKind {
|
|
case "v1/Pod":
|
|
return injectToPodSpecObject[corev1.Pod](raw, func(obj *corev1.Pod) *corev1.PodSpec {
|
|
return &obj.Spec
|
|
})
|
|
|
|
case "apps/v1/Deployment":
|
|
return injectToPodSpecObject[appsv1.Deployment](raw, func(obj *appsv1.Deployment) *corev1.PodSpec {
|
|
return &obj.Spec.Template.Spec
|
|
})
|
|
|
|
case "apps/v1/StatefulSet":
|
|
return injectToPodSpecObject[appsv1.StatefulSet](raw, func(obj *appsv1.StatefulSet) *corev1.PodSpec {
|
|
return &obj.Spec.Template.Spec
|
|
})
|
|
|
|
case "apps/v1/DaemonSet":
|
|
return injectToPodSpecObject[appsv1.DaemonSet](raw, func(obj *appsv1.DaemonSet) *corev1.PodSpec {
|
|
return &obj.Spec.Template.Spec
|
|
})
|
|
|
|
case "batch/v1/Job":
|
|
return injectToPodSpecObject[batchv1.Job](raw, func(obj *batchv1.Job) *corev1.PodSpec {
|
|
return &obj.Spec.Template.Spec
|
|
})
|
|
|
|
case "batch/v1/CronJob":
|
|
return injectToPodSpecObject[batchv1.CronJob](raw, func(obj *batchv1.CronJob) *corev1.PodSpec {
|
|
return &obj.Spec.JobTemplate.Spec.Template.Spec
|
|
})
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported object type: %s", apiVersionKind)
|
|
}
|
|
|
|
func injectToPodSpecObject[T any](raw map[string]any, podSpecFunc func(*T) *corev1.PodSpec) (*T, error) {
|
|
objectName, nameFound, err := unstructured.NestedString(raw, metadataField, nameField)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !nameFound {
|
|
return nil, errors.New("object has no name")
|
|
}
|
|
|
|
var obj T
|
|
|
|
err = runtime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(raw, &obj, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
injectToPodSpec(fmt.Sprintf("%s%s", objectName, nameSuffix), podSpecFunc(&obj))
|
|
|
|
return &obj, nil
|
|
}
|
|
|
|
func readResourceAPIVersionKind(raw map[string]any) (string, error) {
|
|
apiVersion, found, err := unstructured.NestedString(raw, apiVersionField)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !found {
|
|
return "", fmt.Errorf("%s not found", apiVersionField)
|
|
}
|
|
|
|
kind, found, err := unstructured.NestedString(raw, kindField)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !found {
|
|
return "", fmt.Errorf("%s not found", kindField)
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%s", apiVersion, kind), nil
|
|
}
|
|
|
|
func readResourceIDFromObject(obj metav1.Object) string {
|
|
if obj.GetNamespace() == "" {
|
|
return obj.GetName()
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())
|
|
}
|
|
|
|
func readResourceIDFromServiceAccount(raw map[string]any) string {
|
|
if !isServiceAccount(raw) {
|
|
return ""
|
|
}
|
|
|
|
name, nameFound, err := unstructured.NestedString(raw, metadataField, nameField)
|
|
if err != nil || !nameFound {
|
|
return ""
|
|
}
|
|
|
|
nameTrimmed := strings.TrimSuffix(name, nameSuffix)
|
|
|
|
ns, nsFound, err := unstructured.NestedString(raw, metadataField, namespaceField)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if nsFound {
|
|
return fmt.Sprintf("%s/%s", ns, nameTrimmed)
|
|
}
|
|
|
|
return nameTrimmed
|
|
}
|
|
|
|
func injectToPodSpec(secretName string, podSpec *corev1.PodSpec) {
|
|
podSpec.Volumes = injectToVolumes(secretName, podSpec.Volumes)
|
|
podSpec.InitContainers = injectToContainers(podSpec.InitContainers)
|
|
podSpec.Containers = injectToContainers(podSpec.Containers)
|
|
}
|
|
|
|
func injectToVolumes(name string, volumes []corev1.Volume) []corev1.Volume {
|
|
result := make([]corev1.Volume, 0, len(volumes))
|
|
|
|
for _, volume := range volumes {
|
|
if volume.Name != volumeName {
|
|
result = append(result, volume)
|
|
}
|
|
}
|
|
|
|
result = append(result, corev1.Volume{
|
|
Name: volumeName,
|
|
VolumeSource: corev1.VolumeSource{
|
|
Secret: &corev1.SecretVolumeSource{
|
|
SecretName: name,
|
|
},
|
|
},
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
func injectToContainers(containers []corev1.Container) []corev1.Container {
|
|
result := make([]corev1.Container, 0, len(containers))
|
|
|
|
for _, container := range containers {
|
|
injectToContainer(&container)
|
|
|
|
result = append(result, container)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func injectToContainer(container *corev1.Container) {
|
|
volumeMounts := make([]corev1.VolumeMount, 0, len(container.VolumeMounts))
|
|
|
|
for _, mount := range container.VolumeMounts {
|
|
if mount.Name != volumeName {
|
|
volumeMounts = append(volumeMounts, mount)
|
|
}
|
|
}
|
|
|
|
volumeMounts = append(volumeMounts, corev1.VolumeMount{
|
|
Name: volumeName,
|
|
MountPath: constants.ServiceAccountMountPath,
|
|
})
|
|
|
|
container.VolumeMounts = volumeMounts
|
|
|
|
if injectToEnv {
|
|
container.Env = injectToContainerEnv(container.Env)
|
|
}
|
|
}
|
|
|
|
func injectToContainerEnv(env []corev1.EnvVar) []corev1.EnvVar {
|
|
result := make([]corev1.EnvVar, 0, len(env))
|
|
|
|
for _, envVar := range env {
|
|
if envVar.Name != constants.TalosConfigEnvVar {
|
|
result = append(result, envVar)
|
|
}
|
|
}
|
|
|
|
result = append(result, corev1.EnvVar{
|
|
Name: constants.TalosConfigEnvVar,
|
|
Value: filepath.Join(constants.ServiceAccountMountPath, constants.TalosconfigFilename),
|
|
})
|
|
|
|
return result
|
|
}
|