Skip to content

Commit

Permalink
Support for ServiceAccount token projection
Browse files Browse the repository at this point in the history
This commit adds a new field to the DWOC `workspace.serviceAccountTokens`, which is a an array
of ServiceAccount tokens that will be mounted to workspace pods as projected volumes.

Part of eclipse-che/che#22012

Signed-off-by: Andrew Obuchowicz <[email protected]>
  • Loading branch information
AObuchow committed Mar 15, 2023
1 parent 4f8fe70 commit 5c1ddab
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 0 deletions.
41 changes: 41 additions & 0 deletions apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package v1alpha1

import (
"fmt"

dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
Expand Down Expand Up @@ -94,6 +96,38 @@ type ServiceAccountConfig struct {
DisableCreation *bool `json:"disableCreation,omitempty"`
}

type ServiceAccountToken struct {
// Identifiable name of the ServiceAccount token.
// If multiple ServiceAccount tokens use the same mount path, a generic name will be used
// for the projected volume instead.
// +kubebuilder:validation:Required
Name string `json:"name"`
// Path within the workspace container at which the token should be mounted. Must
// not contain ':'.
// +kubebuilder:validation:Required
MountPath string `json:"mountPath"`
// Path is the path relative to the mount point of the file to project the
// token into.
// +kubebuilder:validation:Required
Path string `json:"path"`
// Audience is the intended audience of the token. A recipient of a token
// must identify itself with an identifier specified in the audience of the
// token, and otherwise should reject the token. The audience defaults to the
// identifier of the apiserver.
// +kubebuilder:validation:Optional
Audience string `json:"audience,omitempty"`
// ExpirationSeconds is the requested duration of validity of the service
// account token. As the token approaches expiration, the kubelet volume
// plugin will proactively rotate the service account token. The kubelet will
// start trying to rotate the token if the token is older than 80 percent of
// its time to live or if the token is older than 24 hours. Defaults to 1 hour
// and must be at least 10 minutes.
// +kubebuilder:validation:Minimum=600
// +kubebuilder:default:=3600
// +kubebuilder:validation:Optional
ExpirationSeconds int64 `json:"expirationSeconds,omitempty"`
}

type WorkspaceConfig struct {
// ImagePullPolicy defines the imagePullPolicy used for containers in a DevWorkspace
// For additional information, see Kubernetes documentation for imagePullPolicy. If
Expand All @@ -113,6 +147,9 @@ type WorkspaceConfig struct {
// ServiceAccount defines configuration options for the ServiceAccount used for
// DevWorkspaces.
ServiceAccount *ServiceAccountConfig `json:"serviceAccount,omitempty"`
// List of ServiceAccount tokens that will be mounted into workspace pods as projected volumes.
// +kubebuilder:validation:Optional
ServiceAccountTokens []ServiceAccountToken `json:"serviceAccountTokens,omitempty"`
// StorageClassName defines an optional storageClass to use for persistent
// volume claims created to support DevWorkspaces
StorageClassName *string `json:"storageClassName,omitempty"`
Expand Down Expand Up @@ -179,3 +216,7 @@ type DevWorkspaceOperatorConfigList struct {
func init() {
SchemeBuilder.Register(&DevWorkspaceOperatorConfig{}, &DevWorkspaceOperatorConfigList{})
}

func (saToken ServiceAccountToken) String() string {
return fmt.Sprintf("{name: %s, path: %s, mountPath: %s, audience: %s, expirationSeconds %d}", saToken.Name, saToken.Path, saToken.MountPath, saToken.Audience, saToken.ExpirationSeconds)
}
5 changes: 5 additions & 0 deletions controllers/workspace/devworkspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
devfilePodAdditions.InitContainers = append(devfilePodAdditions.InitContainers, *projectClone)
}

// Add ServiceAccount tokens into devfile containers
if err := automount.ProvisionServiceAccountTokensInto(devfilePodAdditions, workspace); err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount ServiceAccount tokens to workspace: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus)
}

// Add automount resources into devfile containers
if err := automount.ProvisionAutoMountResourcesInto(devfilePodAdditions, clusterAPI, workspace.Namespace); err != nil {
var autoMountErr *automount.AutoMountError
Expand Down
5 changes: 5 additions & 0 deletions pkg/common/naming.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ func AutoMountProjectedVolumeName(mountPath string) string {
return fmt.Sprintf("projected-%x", hash[:10])
}

func ServiceAccountTokenProjectionName(mountPath string) string {
hash := sha256.Sum256([]byte(mountPath))
return fmt.Sprintf("sa-token-projected-%x", hash[:10])
}

func WorkspaceRoleName() string {
return "devworkspace-default-role"
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ func mergeConfig(from, to *controller.OperatorConfiguration) {
to.Workspace.ServiceAccount.DisableCreation = pointer.BoolPtr(*from.Workspace.ServiceAccount.DisableCreation)
}
}
if from.Workspace.ServiceAccountTokens != nil {
to.Workspace.ServiceAccountTokens = from.Workspace.ServiceAccountTokens
}
if from.Workspace.ImagePullPolicy != "" {
to.Workspace.ImagePullPolicy = from.Workspace.ImagePullPolicy
}
Expand Down Expand Up @@ -410,6 +413,13 @@ func GetCurrentConfigString(currConfig *controller.OperatorConfiguration) string
config = append(config, fmt.Sprintf("workspace.serviceAccount.disableCreation=%t", *workspace.ServiceAccount.DisableCreation))
}
}
if workspace.ServiceAccountTokens != nil {
serviceAccountTokens := make([]string, 0)
for _, saToken := range workspace.ServiceAccountTokens {
serviceAccountTokens = append(serviceAccountTokens, saToken.String())
}
config = append(config, fmt.Sprintf("workspace.serviceAccountTokens=[%s]", strings.Join(serviceAccountTokens, ", ")))
}
if workspace.StorageClassName != nil && workspace.StorageClassName != defaultConfig.Workspace.StorageClassName {
config = append(config, fmt.Sprintf("workspace.storageClassName=%s", *workspace.StorageClassName))
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/provision/automount/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"path"
"strings"

"github.com/devfile/devworkspace-operator/pkg/common"
"github.com/devfile/devworkspace-operator/pkg/constants"
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -78,6 +79,16 @@ func ProvisionAutoMountResourcesInto(podAdditions *v1alpha1.PodAdditions, api sy
return nil
}

func ProvisionServiceAccountTokensInto(podAdditions *v1alpha1.PodAdditions, workspace *common.DevWorkspaceWithConfig) error {
saTokenVolumeMounts, saTokenVolumes, err := getSATokensVolumesAndVolumeMounts(workspace.Config.Workspace.ServiceAccountTokens)
if err != nil {
return err
}
podAdditions.VolumeMounts = append(podAdditions.VolumeMounts, saTokenVolumeMounts...)
podAdditions.Volumes = append(podAdditions.Volumes, saTokenVolumes...)
return nil
}

func getAutomountResources(api sync.ClusterAPI, namespace string) (*Resources, error) {
gitCMAutoMountResources, err := ProvisionGitConfiguration(api, namespace)
if err != nil {
Expand Down
109 changes: 109 additions & 0 deletions pkg/provision/automount/projected.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
package automount

import (
"errors"
"fmt"
"sort"
"strings"

"github.com/devfile/devworkspace-operator/apis/controller/v1alpha1"
"github.com/devfile/devworkspace-operator/pkg/common"
corev1 "k8s.io/api/core/v1"
"k8s.io/utils/pointer"
Expand Down Expand Up @@ -150,3 +152,110 @@ func checkCanUseProjectedVolumes(volumeMounts []corev1.VolumeMount, volumeNameTo
}
return nil
}

// Returns VolumeMounts and Volumes corresponding to the provided ServiceAccount Tokens.
// An error is returned if at least two ServiceAccount tokens share the same mounth path and path.
func getSATokensVolumesAndVolumeMounts(serviceAccountTokens []v1alpha1.ServiceAccountToken) ([]corev1.VolumeMount, []corev1.Volume, error) {
volumeMounts := []corev1.VolumeMount{}
volumes := []corev1.Volume{}
// Map of mountPath -> ServiceAccountTokens, to detect colliding volumeMounts
mountPathToSATokens := map[string][]v1alpha1.ServiceAccountToken{}

// Ordered list of mountPaths to process, to avoid random iteration order on maps
var mountPathOrder []string
for _, saToken := range serviceAccountTokens {
if len(mountPathToSATokens[saToken.MountPath]) == 0 {
mountPathOrder = append(mountPathOrder, saToken.MountPath)
}
mountPathToSATokens[saToken.MountPath] = append(mountPathToSATokens[saToken.MountPath], saToken)
}
sort.Strings(mountPathOrder)

for _, mountPath := range mountPathOrder {
saTokens := mountPathToSATokens[mountPath]
vm, vol, err := generateSATokenProjectedVolume(mountPath, saTokens)
if err != nil {
return nil, nil, err
}
volumeMounts = append(volumeMounts, *vm)
volumes = append(volumes, *vol)
}
return volumeMounts, volumes, nil
}

// Returns a VolumeMount and projected Volume for the provided ServiceAccount tokens that share the same mount path.
// The VolumeMount's mount path is set to the provided mount path and its name is set to the ServiceAccount token's name
// if only a single ServiceAccount token is provided, otherwise a common VolumeMount name is used
// if multiple ServiceAccount token's are provided.
// An error is returned if at least two ServiceAccount tokens share the same mounth path and path.
func generateSATokenProjectedVolume(mountPath string, saTokens []v1alpha1.ServiceAccountToken) (*corev1.VolumeMount, *corev1.Volume, error) {
// Check if two tokens share the same path and mountPath, which is invalid
err := checkSATokenPathCollisions(saTokens, mountPath)
if err != nil {
return nil, nil, err
}

// If multiple tokens are being projected into the same mount path, use a common name
volumeName := common.ServiceAccountTokenProjectionName(mountPath)
if len(saTokens) == 1 {
volumeName = saTokens[0].Name
}

volume := &corev1.Volume{
Name: volumeName,
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
DefaultMode: pointer.Int32(0640),
},
},
}

for _, saToken := range saTokens {
volumeProjection := &corev1.VolumeProjection{
ServiceAccountToken: &corev1.ServiceAccountTokenProjection{
Audience: saToken.Audience,
ExpirationSeconds: pointer.Int64(saToken.ExpirationSeconds),
Path: saToken.Path,
},
}
volume.Projected.Sources = append(volume.Projected.Sources, *volumeProjection)
}

volumeMount := &corev1.VolumeMount{
Name: volumeName,
MountPath: mountPath,
ReadOnly: true,
}

return volumeMount, volume, nil
}

// Checks if any of the given ServiceAccount Tokens (which are assumed to share the same mount path)
// have the same volume path, which would result in a mounting collision.
// Returns an error if at least two ServiceAccount tokens share the same volume path and mount path or nil otherwise
func checkSATokenPathCollisions(saTokens []v1alpha1.ServiceAccountToken, mountPath string) error {
pathsToSATokens := map[string][]v1alpha1.ServiceAccountToken{}
isError := false
problemPaths := make(map[string]bool)
for _, saToken := range saTokens {
if len(pathsToSATokens[saToken.Path]) > 0 {
isError = true
problemPaths[saToken.Path] = true
}
pathsToSATokens[saToken.Path] = append(pathsToSATokens[saToken.Path], saToken)
}

if isError {
var errorMsgs []string
for path := range problemPaths {
var problemNames []string
collidingSATokens := pathsToSATokens[path]
for _, saToken := range collidingSATokens {
problemNames = append(problemNames, saToken.Name)
}
errorMsgs = append(errorMsgs, fmt.Sprintf("the following ServiceAccount tokens have the same path (%s) and mount path (%s): %s.", path, mountPath, strings.Join(problemNames, ", ")))
}
return errors.New(strings.Join(errorMsgs, "\n "))
}
return nil
}

0 comments on commit 5c1ddab

Please sign in to comment.