diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index d6c100876..d0189775d 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -326,7 +326,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request // Add init container to clone projects projectCloneOptions := projects.Options{ Image: workspace.Config.Workspace.ProjectCloneConfig.Image, - Env: workspace.Config.Workspace.ProjectCloneConfig.Env, + Env: env.GetEnvironmentVariablesForProjectClone(workspace), Resources: workspace.Config.Workspace.ProjectCloneConfig.Resources, } if workspace.Config.Workspace.ProjectCloneConfig.ImagePullPolicy != "" { diff --git a/pkg/constants/attributes.go b/pkg/constants/attributes.go index 59b1a364b..0a2d6760d 100644 --- a/pkg/constants/attributes.go +++ b/pkg/constants/attributes.go @@ -145,4 +145,10 @@ const ( // StarterProjectAttribute is an attribute applied to the top-level attributes in a DevWorkspace to specify which // starterProject in the workspace should be cloned. StarterProjectAttribute = "controller.devfile.io/use-starter-project" + + // BootstrapDevWorkspaceAttribute is an attribute applied to the top-level attributes in a DevWorkspace to configure + // the project-clone container to "bootstrap" the DevWorkspace from a devfile.yaml or .devfile.yaml file at the root + // of a cloned project. If the bootstrap process is successful, project-clone will automatically remove this attribute + // from the DevWorkspace + BootstrapDevWorkspaceAttribute = "controller.devfile.io/bootstrap-devworkspace" ) diff --git a/pkg/library/env/workspaceenv.go b/pkg/library/env/workspaceenv.go index 4f4982b24..e8edc765d 100644 --- a/pkg/library/env/workspaceenv.go +++ b/pkg/library/env/workspaceenv.go @@ -21,8 +21,8 @@ import ( 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" corev1 "k8s.io/api/core/v1" - v1 "k8s.io/api/core/v1" "github.com/devfile/devworkspace-operator/pkg/common" "github.com/devfile/devworkspace-operator/pkg/constants" @@ -47,6 +47,17 @@ func AddCommonEnvironmentVariables(podAdditions *v1alpha1.PodAdditions, clusterD return nil } +func GetEnvironmentVariablesForProjectClone(workspace *common.DevWorkspaceWithConfig) []corev1.EnvVar { + var cloneEnv []corev1.EnvVar + cloneEnv = append(cloneEnv, workspace.Config.Workspace.ProjectCloneConfig.Env...) + cloneEnv = append(cloneEnv, commonEnvironmentVariables(workspace)...) + cloneEnv = append(cloneEnv, corev1.EnvVar{ + Name: devfileConstants.ProjectsRootEnvVar, + Value: constants.DefaultProjectsSourcesRoot, + }) + return cloneEnv +} + func commonEnvironmentVariables(workspaceWithConfig *common.DevWorkspaceWithConfig) []corev1.EnvVar { envvars := []corev1.EnvVar{ { @@ -88,20 +99,20 @@ func GetProxyEnvVars(proxyConfig *v1alpha1.Proxy) []corev1.EnvVar { // Proxy env vars are defined by consensus rather than standard; most tools use the lower-snake-case version // but some may only look at the upper-snake-case version, so we add both. - var env []v1.EnvVar + var env []corev1.EnvVar if proxyConfig.HttpProxy != nil { - env = append(env, v1.EnvVar{Name: "http_proxy", Value: *proxyConfig.HttpProxy}) - env = append(env, v1.EnvVar{Name: "HTTP_PROXY", Value: *proxyConfig.HttpProxy}) + env = append(env, corev1.EnvVar{Name: "http_proxy", Value: *proxyConfig.HttpProxy}) + env = append(env, corev1.EnvVar{Name: "HTTP_PROXY", Value: *proxyConfig.HttpProxy}) } if proxyConfig.HttpsProxy != nil { - env = append(env, v1.EnvVar{Name: "https_proxy", Value: *proxyConfig.HttpsProxy}) - env = append(env, v1.EnvVar{Name: "HTTPS_PROXY", Value: *proxyConfig.HttpsProxy}) + env = append(env, corev1.EnvVar{Name: "https_proxy", Value: *proxyConfig.HttpsProxy}) + env = append(env, corev1.EnvVar{Name: "HTTPS_PROXY", Value: *proxyConfig.HttpsProxy}) } if proxyConfig.NoProxy != nil { // Adding 'KUBERNETES_SERVICE_HOST' env var to the 'no_proxy / NO_PROXY' list. Hot Fix for https://issues.redhat.com/browse/CRW-2820 kubernetesServiceHost := os.Getenv("KUBERNETES_SERVICE_HOST") - env = append(env, v1.EnvVar{Name: "no_proxy", Value: *proxyConfig.NoProxy + "," + kubernetesServiceHost}) - env = append(env, v1.EnvVar{Name: "NO_PROXY", Value: *proxyConfig.NoProxy + "," + kubernetesServiceHost}) + env = append(env, corev1.EnvVar{Name: "no_proxy", Value: *proxyConfig.NoProxy + "," + kubernetesServiceHost}) + env = append(env, corev1.EnvVar{Name: "NO_PROXY", Value: *proxyConfig.NoProxy + "," + kubernetesServiceHost}) } return env diff --git a/pkg/library/projects/clone.go b/pkg/library/projects/clone.go index ab3b9c35b..535bd4359 100644 --- a/pkg/library/projects/clone.go +++ b/pkg/library/projects/clone.go @@ -22,7 +22,6 @@ import ( dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" devfileConstants "github.com/devfile/devworkspace-operator/pkg/library/constants" - "github.com/devfile/devworkspace-operator/pkg/library/env" dwResources "github.com/devfile/devworkspace-operator/pkg/library/resources" corev1 "k8s.io/api/core/v1" @@ -68,15 +67,6 @@ func GetProjectCloneInitContainer(workspace *dw.DevWorkspaceTemplateSpec, option return nil, nil } - cloneEnv := []corev1.EnvVar{ - { - Name: devfileConstants.ProjectsRootEnvVar, - Value: constants.DefaultProjectsSourcesRoot, - }, - } - cloneEnv = append(cloneEnv, env.GetProxyEnvVars(proxyConfig)...) - cloneEnv = append(cloneEnv, options.Env...) - resources := dwResources.FilterResources(options.Resources) if err := dwResources.ValidateResources(resources); err != nil { return nil, fmt.Errorf("invalid resources for project clone container: %w", err) @@ -85,7 +75,7 @@ func GetProjectCloneInitContainer(workspace *dw.DevWorkspaceTemplateSpec, option return &corev1.Container{ Name: projectClonerContainerName, Image: cloneImage, - Env: cloneEnv, + Env: options.Env, Resources: *resources, VolumeMounts: []corev1.VolumeMount{ { diff --git a/project-clone/internal/bootstrap/bootstrap.go b/project-clone/internal/bootstrap/bootstrap.go new file mode 100644 index 000000000..cee06a767 --- /dev/null +++ b/project-clone/internal/bootstrap/bootstrap.go @@ -0,0 +1,123 @@ +// Copyright (c) 2019-2023 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 bootstrap + +import ( + "context" + "fmt" + "log" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/api/v2/pkg/attributes" + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +var devfileNames = []string{"devfile.yaml", ".devfile.yaml"} + +func NeedsBootstrap(dw *dw.DevWorkspaceTemplateSpec) (bool, error) { + if dw.Attributes == nil { + return false, nil + } + if !dw.Attributes.Exists(constants.BootstrapDevWorkspaceAttribute) { + return false, nil + } + var attrErr error + needBootstrap := dw.Attributes.GetBoolean(constants.BootstrapDevWorkspaceAttribute, &attrErr) + if attrErr != nil { + return false, attrErr + } + return needBootstrap, nil +} + +func BootstrapWorkspace(flattenedWorkspace *dw.DevWorkspaceTemplateSpec) error { + devfile, projectName, err := getBootstrapDevfile(flattenedWorkspace.Projects) + if err != nil { + return err + } + log.Printf("Updating current DevWorkspace with content from devfile found in project %s", projectName) + + kubeclient, err := setupKubeClient() + if err != nil { + return err + } + + workspaceNN, err := getWorkspaceNamespacedName() + if err != nil { + return err + } + + clusterWorkspace := &dw.DevWorkspace{} + if err := kubeclient.Get(context.Background(), workspaceNN, clusterWorkspace); err != nil { + return fmt.Errorf("failed to read DevWorkspace from cluster: %w", err) + } + + updatedWorkspace := updateWorkspaceFromDevfile(clusterWorkspace, devfile) + + if err := kubeclient.Update(context.Background(), updatedWorkspace); err != nil { + return fmt.Errorf("failed to update DevWorkspace on cluster: %w", err) + } + + log.Printf("Successfully updated DevWorkspace. Workspace may restart") + + return nil +} + +func updateWorkspaceFromDevfile(workspace *dw.DevWorkspace, devfile *dw.DevWorkspaceTemplateSpec) *dw.DevWorkspace { + updated := workspace.DeepCopy() + + // Use devfile contents for this DevWorkspace instead of whatever is there + updated.Spec.Template = *devfile + + // Add attributes from original DevWorkspace, since it's assumed they're more relevant than the devfile's attributes + // This will override any attributes present in both the devfile and DevWorkspace with the latter's value + if updated.Spec.Template.Attributes == nil { + updated.Spec.Template.Attributes = attributes.Attributes{} + } + for key, value := range workspace.Spec.Template.Attributes { + updated.Spec.Template.Attributes[key] = value + } + + // Merge projects; we want the DevWorkspace's projects to not be dropped from the workspace, but also want to add any projects + // present in the devfile. We also want workspace projects first in this list, since this is the order they're bootstrapped from + updated.Spec.Template.Projects = mergeProjects(workspace.Spec.Template.Projects, devfile.Projects) + + // Remove bootstrap attribute to avoid unnecessarily doing this process in the future + delete(updated.Spec.Template.Attributes, constants.BootstrapDevWorkspaceAttribute) + + return updated +} + +func mergeProjects(workspaceProjects, devfileProjects []dw.Project) []dw.Project { + var allProjects []dw.Project + + // Bookkeeping structs to avoid adding identical projects. We want to avoid an issue where DevWorkspace and devfile + // contain the same project; adding both to the workspace will cause the workspace to be invalid. + // An additional improvement in the future would be to avoid adding two very similar projects (e.g. identical projects + // with different names) + projectNames := map[string]bool{} + + for _, project := range workspaceProjects { + projectNames[project.Name] = true + allProjects = append(allProjects, project) + } + + for _, project := range devfileProjects { + if !projectNames[project.Name] { + projectNames[project.Name] = true + allProjects = append(allProjects, project) + } + } + + return allProjects +} diff --git a/project-clone/internal/bootstrap/cluster.go b/project-clone/internal/bootstrap/cluster.go new file mode 100644 index 000000000..6884ef3c7 --- /dev/null +++ b/project-clone/internal/bootstrap/cluster.go @@ -0,0 +1,61 @@ +// Copyright (c) 2019-2023 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 bootstrap + +import ( + "fmt" + "os" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/constants" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func setupKubeClient() (client.Client, error) { + scheme := k8sruntime.NewScheme() + + if err := dw.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to set up Kubernetes client: %w", err) + } + + cfg, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to read in-cluster Kubernetes configuration: %w", err) + } + + kubeClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + return kubeClient, nil +} + +func getWorkspaceNamespacedName() (types.NamespacedName, error) { + name := os.Getenv(constants.DevWorkspaceName) + namespace := os.Getenv(constants.DevWorkspaceNamespace) + + namespacedName := types.NamespacedName{ + Name: name, + Namespace: namespace, + } + + if name == "" || namespace == "" { + return namespacedName, fmt.Errorf("could not get workspace name or namespace from environment variables") + } + return namespacedName, nil +} diff --git a/project-clone/internal/bootstrap/util.go b/project-clone/internal/bootstrap/util.go new file mode 100644 index 000000000..8e5859de7 --- /dev/null +++ b/project-clone/internal/bootstrap/util.go @@ -0,0 +1,57 @@ +// Copyright (c) 2019-2023 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 bootstrap + +import ( + "fmt" + "os" + "path" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/library/projects" + "github.com/devfile/devworkspace-operator/project-clone/internal" + "sigs.k8s.io/yaml" +) + +func getBootstrapDevfile(projects []dw.Project) (devfile *dw.DevWorkspaceTemplateSpec, projectName string, err error) { + var devfileBytes []byte + var devfileProject string + for _, project := range projects { + bytes, err := getDevfileFromProject(project) + if err == nil && len(bytes) > 0 { + devfileBytes = bytes + devfileProject = project.Name + break + } + } + if len(devfileBytes) == 0 { + return nil, "", fmt.Errorf("could not find devfile in any project") + } + + devfile = &dw.DevWorkspaceTemplateSpec{} + if err := yaml.Unmarshal(devfileBytes, devfile); err != nil { + return nil, "", fmt.Errorf("failed to read devfile in project %s: %s", devfileProject, err) + } + return devfile, devfileProject, nil +} + +func getDevfileFromProject(project dw.Project) ([]byte, error) { + clonePath := projects.GetClonePath(&project) + for _, devfileName := range devfileNames { + if bytes, err := os.ReadFile(path.Join(internal.ProjectsRoot, clonePath, devfileName)); err == nil { + return bytes, nil + } + } + return nil, fmt.Errorf("no devfile found") +} diff --git a/project-clone/main.go b/project-clone/main.go index f74f72045..e8aac39a7 100644 --- a/project-clone/main.go +++ b/project-clone/main.go @@ -27,6 +27,7 @@ import ( projectslib "github.com/devfile/devworkspace-operator/pkg/library/projects" "github.com/devfile/devworkspace-operator/project-clone/internal" + "github.com/devfile/devworkspace-operator/project-clone/internal/bootstrap" "github.com/devfile/devworkspace-operator/project-clone/internal/git" "github.com/devfile/devworkspace-operator/project-clone/internal/zip" gitclient "github.com/go-git/go-git/v5/plumbing/transport/client" @@ -114,6 +115,18 @@ func main() { } if encounteredError { copyLogFileToProjectsRoot() + os.Exit(0) + } + + needBootstrap, err := bootstrap.NeedsBootstrap(workspace) + if err != nil { + log.Printf("Encountered error reading DevWorkspace attributes: %s", err) + copyLogFileToProjectsRoot() + } else if needBootstrap { + if err := bootstrap.BootstrapWorkspace(workspace); err != nil { + log.Printf("Encountered error setting up DevWorkspace from devfile: %s", err) + copyLogFileToProjectsRoot() + } } }