diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index a27d79098..be44e63d6 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -22,6 +22,8 @@ import ( "strings" "time" + "github.com/devfile/devworkspace-operator/pkg/library/ssh" + devfilevalidation "github.com/devfile/api/v2/pkg/validation" controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/controllers/workspace/metrics" @@ -278,6 +280,12 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request reconcileStatus.addWarning(flatten.FormatVariablesWarning(warnings)) } workspace.Spec.Template = *flattenedWorkspace + + err = ssh.AddSshAgentPostStartEvent(&workspace.Spec.Template) + if err != nil { + return r.failWorkspace(workspace, "Failed to add ssh-agent post start event", metrics.ReasonWorkspaceEngineFailure, reqLogger, &reconcileStatus), nil + } + reconcileStatus.setConditionTrue(conditions.DevWorkspaceResolved, "Resolved plugins and parents from DevWorkspace") // Verify that the devworkspace components are valid after flattening @@ -352,6 +360,11 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount ServiceAccount tokens to workspace: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil } + // Add SSH ask-pass script into devfile containers + if err := wsprovision.ProvisionSshAskPass(clusterAPI, workspace.Namespace, devfilePodAdditions); err != nil { + return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount SSH askpass script to workspace: %s", err), metrics.ReasonWorkspaceEngineFailure, reqLogger, &reconcileStatus), nil + } + // Add automount resources into devfile containers err = automount.ProvisionAutoMountResourcesInto(devfilePodAdditions, clusterAPI, workspace.Namespace, home.PersistUserHomeEnabled(workspace)) if shouldReturn, reconcileResult, reconcileErr := r.checkDWError(workspace, err, "Failed to process automount resources", metrics.ReasonBadRequest, reqLogger, &reconcileStatus); shouldReturn { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index a1b162580..d50079c2c 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -45,6 +45,8 @@ const ( HomeInitEventId = "init-persistent-home" + SshAgentStartEventId = "init-ssh-agent" + ServiceAccount = "devworkspace" PVCStorageSize = "10Gi" diff --git a/pkg/constants/env.go b/pkg/constants/env.go index 0f80ad9d7..bad2a63d6 100644 --- a/pkg/constants/env.go +++ b/pkg/constants/env.go @@ -37,4 +37,6 @@ const ( // DevWorkspaceComponentName contains env var name which indicates from which devfile container component // the container is created from. Note the flattened devfile is used to evaluate it. DevWorkspaceComponentName = "DEVWORKSPACE_COMPONENT_NAME" + DISPLAY = "DISPLAY" + SSHAskPass = "SSH_ASKPASS" ) diff --git a/pkg/constants/metadata.go b/pkg/constants/metadata.go index 5fe9a0ba1..03f31b681 100644 --- a/pkg/constants/metadata.go +++ b/pkg/constants/metadata.go @@ -87,6 +87,8 @@ const ( // in a given namespace. It is used when e.g. adding Git credentials via secret GitCredentialsConfigMapName = "devworkspace-gitconfig" + SshAskPassConfigMapName = "devworkspace-ssh-askpass" + // GitCredentialsMergedSecretName is the name for the merged Git credentials secret that is mounted to workspaces // when Git credentials are defined. This secret combines the values of any secrets labelled // "controller.devfile.io/git-credential" diff --git a/pkg/library/env/workspaceenv.go b/pkg/library/env/workspaceenv.go index a0f763d37..753ba99e1 100644 --- a/pkg/library/env/workspaceenv.go +++ b/pkg/library/env/workspaceenv.go @@ -19,6 +19,8 @@ import ( "fmt" "os" + "github.com/devfile/devworkspace-operator/pkg/provision/workspace" + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" devfileConstants "github.com/devfile/devworkspace-operator/pkg/library/constants" @@ -82,12 +84,26 @@ func commonEnvironmentVariables(workspaceWithConfig *common.DevWorkspaceWithConf }, } - envvars = append(envvars, GetProxyEnvVars(workspaceWithConfig.Config.Routing.ProxyConfig)...) + envvars = append(envvars, getProxyEnvVars(workspaceWithConfig.Config.Routing.ProxyConfig)...) + envvars = append(envvars, getSshAskPassEnvVars()...) return envvars } -func GetProxyEnvVars(proxyConfig *v1alpha1.Proxy) []corev1.EnvVar { +func getSshAskPassEnvVars() []corev1.EnvVar { + return []corev1.EnvVar{ + { + Name: constants.SSHAskPass, + Value: fmt.Sprintf("%s%s", workspace.SshAskPassMountPath, workspace.SshAskPassScriptFileName), + }, + { + Name: constants.DISPLAY, + Value: ":0", + }, + } +} + +func getProxyEnvVars(proxyConfig *v1alpha1.Proxy) []corev1.EnvVar { if proxyConfig == nil { return nil } diff --git a/pkg/library/ssh/event.go b/pkg/library/ssh/event.go new file mode 100644 index 000000000..309506e97 --- /dev/null +++ b/pkg/library/ssh/event.go @@ -0,0 +1,57 @@ +// Copyright (c) 2019-2024 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh + +import ( + "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/library/lifecycle" +) + +const commandLine = `SSH_ENV_PATH=$HOME/ssh-environment \ +&& if [ -f /etc/ssh/passphrase ] && command -v ssh-add >/dev/null; \ +then ssh-agent | sed 's/^echo/#echo/' > $SSH_ENV_PATH \ +&& chmod 600 $SSH_ENV_PATH && source $SSH_ENV_PATH \ +&& ssh-add /etc/ssh/dwo_ssh_key < /etc/ssh/passphrase \ +&& if [ -f $HOME/.bashrc ] && [ -w $HOME/.bashrc ]; then echo "source ${SSH_ENV_PATH}" >> $HOME/.bashrc; fi; fi` + +// AddSshAgentPostStartEvent Start ssh-agent and add the default ssh key to it, if the ssh key has a passphrase. +// Initialise the ssh-agent session env variables in the user .bashrc file. +func AddSshAgentPostStartEvent(spec *v1alpha2.DevWorkspaceTemplateSpec) error { + if spec.Commands == nil { + spec.Commands = []v1alpha2.Command{} + } + + if spec.Events == nil { + spec.Events = &v1alpha2.Events{} + } + + _, mainComponents, err := lifecycle.GetInitContainers(spec.DevWorkspaceTemplateSpecContent) + for _, component := range mainComponents { + if component.Container == nil { + continue + } + spec.Commands = append(spec.Commands, v1alpha2.Command{ + Id: constants.SshAgentStartEventId, + CommandUnion: v1alpha2.CommandUnion{ + Exec: &v1alpha2.ExecCommand{ + CommandLine: commandLine, + Component: component.Name, + }, + }, + }) + } + spec.Events.PostStart = append(spec.Events.PostStart, constants.SshAgentStartEventId) + return err +} diff --git a/project-clone/ssh-askpass.sh b/pkg/provision/workspace/ssh-askpass.sh similarity index 100% rename from project-clone/ssh-askpass.sh rename to pkg/provision/workspace/ssh-askpass.sh diff --git a/pkg/provision/workspace/ssh.go b/pkg/provision/workspace/ssh.go new file mode 100644 index 000000000..9fcf6475c --- /dev/null +++ b/pkg/provision/workspace/ssh.go @@ -0,0 +1,94 @@ +// +// Copyright (c) 2019-2024 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package workspace + +import ( + _ "embed" + "path" + + "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/devfile/devworkspace-operator/pkg/dwerrors" + "github.com/devfile/devworkspace-operator/pkg/provision/sync" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/pointer" +) + +const SshAskPassMountPath = "/.ssh-askpass/" +const SshAskPassScriptFileName = "ssh-askpass.sh" + +//go:embed ssh-askpass.sh +var data string + +func ProvisionSshAskPass(api sync.ClusterAPI, namespace string, podAdditions *v1alpha1.PodAdditions) error { + sshAskPassConfigMap := constructSshAskPassCM(namespace) + if _, err := sync.SyncObjectWithCluster(sshAskPassConfigMap, api); err != nil { + switch err.(type) { + case *sync.NotInSyncError: // Ignore the object created error + default: + return dwerrors.WrapSyncError(err) + } + } + + sshAskPassVolumeMounts, sshAskPassVolumes, err := getSshAskPassVolumesAndVolumeMounts() + if err != nil { + return err + } + podAdditions.VolumeMounts = append(podAdditions.VolumeMounts, sshAskPassVolumeMounts...) + podAdditions.Volumes = append(podAdditions.Volumes, sshAskPassVolumes...) + return nil +} + +func constructSshAskPassCM(namespace string) *corev1.ConfigMap { + askPassConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.SshAskPassConfigMapName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/defaultName": "ssh-askpass-secret", + "app.kubernetes.io/part-of": "devworkspace-operator", + "controller.devfile.io/watch-configmap": "true", + }, + }, + Data: map[string]string{ + SshAskPassScriptFileName: data, + }, + } + return askPassConfigMap +} + +func getSshAskPassVolumesAndVolumeMounts() ([]corev1.VolumeMount, []corev1.Volume, error) { + name := "ssh-askpass" + volume := corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: constants.SshAskPassConfigMapName, + }, + DefaultMode: pointer.Int32(0755), + }, + }, + } + volumeMount := corev1.VolumeMount{ + Name: name, + ReadOnly: true, + MountPath: path.Join(SshAskPassMountPath, SshAskPassScriptFileName), + SubPath: SshAskPassScriptFileName, + } + return []corev1.VolumeMount{volumeMount}, []corev1.Volume{volume}, nil +} diff --git a/project-clone/Dockerfile b/project-clone/Dockerfile index e61b2bc41..a6acaf74e 100644 --- a/project-clone/Dockerfile +++ b/project-clone/Dockerfile @@ -44,14 +44,10 @@ COPY --from=builder /project-clone/_output/bin/project-clone /usr/local/bin/proj ENV USER_UID=1001 \ USER_NAME=project-clone \ - HOME=/home/user \ - DISPLAY=":0" \ - SSH_ASKPASS=/usr/local/bin/ssh-askpass.sh + HOME=/home/user COPY build/bin /usr/local/bin -COPY project-clone/ssh-askpass.sh /usr/local/bin RUN /usr/local/bin/user_setup -RUN chmod +x /usr/local/bin/ssh-askpass.sh USER ${USER_UID}