// Copyright 2023 Harness, 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 ide import ( "archive/tar" "bytes" "context" "embed" "fmt" "io" "path/filepath" "strconv" "strings" "github.com/harness/gitness/app/gitspace/orchestrator/devcontainer" "github.com/harness/gitness/app/gitspace/orchestrator/utils" gitspaceTypes "github.com/harness/gitness/app/gitspace/types" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" "github.com/docker/docker/api/types/container" ) var _ IDE = (*VSCodeWeb)(nil) //go:embed script/find_vscode_web_path.sh var findPathScript string //go:embed media/vscodeweb/* var mediaFiles embed.FS const templateRunVSCodeWeb = "run_vscode_web.sh" const templateSetupVSCodeWeb = "install_vscode_web.sh" const startMarker = "START_MARKER" const endMarker = "END_MARKER" type VSCodeWebConfig struct { Port int } type VSCodeWeb struct { config *VSCodeWebConfig } func NewVsCodeWebService(config *VSCodeWebConfig) *VSCodeWeb { return &VSCodeWeb{config: config} } // Setup runs the installScript which downloads the required version of the code-server binary. func (v *VSCodeWeb) Setup( ctx context.Context, exec *devcontainer.Exec, args map[gitspaceTypes.IDEArg]interface{}, gitspaceLogger gitspaceTypes.GitspaceLogger, ) error { gitspaceLogger.Info("Installing VSCode Web inside container...") payload := &gitspaceTypes.SetupVSCodeWebPayload{} if args != nil { if err := updateSetupPayloadFromArgs(args, payload, gitspaceLogger); err != nil { return err } } setupScript, err := utils.GenerateScriptFromTemplate(templateSetupVSCodeWeb, payload) if err != nil { return fmt.Errorf( "failed to generate script to setup VSCode Web from template %s: %w", templateRunVSCodeWeb, err, ) } err = exec.ExecuteCommandInHomeDirAndLog(ctx, setupScript, false, gitspaceLogger, false) if err != nil { return fmt.Errorf("failed to install VSCode Web: %w", err) } if err = v.updateMediaContent(ctx, exec); err != nil { return err } gitspaceLogger.Info("Successfully set up IDE inside container") return nil } func (v *VSCodeWeb) updateMediaContent(ctx context.Context, exec *devcontainer.Exec) error { findOutput, err := exec.ExecuteCommand(ctx, findPathScript, true, exec.DefaultWorkingDir) if err != nil { return fmt.Errorf("failed to find VSCode Web install path: %w", err) } path := findOutput startIndex := strings.Index(path, startMarker) endIndex := strings.Index(path, endMarker) if startIndex == -1 || endIndex == -1 || startIndex >= endIndex { return fmt.Errorf("could not find media folder path from find output: %s", path) } mediaFolderPath := path[startIndex+len(startMarker) : endIndex] if len(mediaFolderPath) == 0 { return fmt.Errorf("media folder path should not be empty, VSCode web is not installed") } err = v.copyMediaToContainer(ctx, exec, mediaFolderPath) if err != nil { return fmt.Errorf("failed to copy media folder to container at path %s: %w", mediaFolderPath, err) } return nil } // Run runs the code-server binary. func (v *VSCodeWeb) Run( ctx context.Context, exec *devcontainer.Exec, args map[gitspaceTypes.IDEArg]interface{}, gitspaceLogger gitspaceTypes.GitspaceLogger, ) error { payload := gitspaceTypes.RunVSCodeWebPayload{ Port: strconv.Itoa(v.config.Port), } if args != nil { if proxyURI, exists := args[gitspaceTypes.VSCodeProxyURIArg]; exists { // Perform a type assertion to ensure proxyURI is a string proxyURIStr, ok := proxyURI.(string) if !ok { return fmt.Errorf("%s is not a string", gitspaceTypes.VSCodeProxyURIArg) } payload.ProxyURI = proxyURIStr } } runScript, err := utils.GenerateScriptFromTemplate(templateRunVSCodeWeb, payload) if err != nil { return fmt.Errorf( "failed to generate script to run VSCode Web from template %s: %w", templateRunVSCodeWeb, err, ) } gitspaceLogger.Info("Starting IDE ...") // Execute the script in the home directory err = exec.ExecuteCommandInHomeDirAndLog(ctx, runScript, false, gitspaceLogger, true) if err != nil { return fmt.Errorf("failed to run VSCode Web: %w", err) } return nil } func updateSetupPayloadFromArgs( args map[gitspaceTypes.IDEArg]interface{}, payload *gitspaceTypes.SetupVSCodeWebPayload, gitspaceLogger gitspaceTypes.GitspaceLogger, ) error { if customization, exists := args[gitspaceTypes.VSCodeCustomizationArg]; exists { // Perform a type assertion to ensure customization is a VSCodeCustomizationSpecs vsCodeCustomizationSpecs, ok := customization.(types.VSCodeCustomizationSpecs) if !ok { return fmt.Errorf("customization is not of type VSCodeCustomizationSpecs") } payload.Extensions = vsCodeCustomizationSpecs.Extensions gitspaceLogger.Info(fmt.Sprintf( "VSCode Customizations: Extensions %v", vsCodeCustomizationSpecs.Extensions)) } return nil } // PortAndProtocol returns the port on which the code-server is listening. func (v *VSCodeWeb) Port() *types.GitspacePort { return &types.GitspacePort{ Port: v.config.Port, Protocol: enum.CommunicationProtocolHTTP, } } func (v *VSCodeWeb) Type() enum.IDEType { return enum.IDETypeVSCodeWeb } func (v *VSCodeWeb) copyMediaToContainer( ctx context.Context, exec *devcontainer.Exec, path string, ) error { // Create a buffer to hold the tar data var tarBuffer bytes.Buffer tarWriter := tar.NewWriter(&tarBuffer) // Walk through the embedded files and add them to the tar archive err := embedToTar(tarWriter, "media/vscodeweb", "") if err != nil { return fmt.Errorf("error creating tar archive: %w", err) } // Close the tar writer closeErr := tarWriter.Close() if closeErr != nil { return fmt.Errorf("error closing tar writer: %w", closeErr) } // Copy the tar archive to the container err = exec.DockerClient.CopyToContainer( ctx, exec.ContainerName, path, &tarBuffer, container.CopyToContainerOptions{}, ) if err != nil { return fmt.Errorf("error copying files to container: %w", err) } return nil } func embedToTar(tarWriter *tar.Writer, baseDir, prefix string) error { entries, err := mediaFiles.ReadDir(baseDir) if err != nil { return fmt.Errorf("error reading media files from base dir %s: %w", baseDir, err) } for _, entry := range entries { fullPath := filepath.Join(baseDir, entry.Name()) info, err2 := entry.Info() if err2 != nil { return fmt.Errorf("error getting file info for %s: %w", fullPath, err2) } // Remove the baseDir from the header name to ensure the files are copied directly into the destination headerName := filepath.Join(prefix, entry.Name()) header, err2 := tar.FileInfoHeader(info, "") if err2 != nil { return fmt.Errorf("error getting file info header for %s: %w", fullPath, err2) } header.Name = strings.TrimPrefix(headerName, "/") if err2 = tarWriter.WriteHeader(header); err2 != nil { return fmt.Errorf("error writing file header %+v: %w", header, err2) } if !entry.IsDir() { file, err3 := mediaFiles.Open(fullPath) if err3 != nil { return fmt.Errorf("error opening file %s: %w", fullPath, err3) } defer file.Close() _, err3 = io.Copy(tarWriter, file) if err3 != nil { return fmt.Errorf("error copying file %s: %w", fullPath, err3) } } else { if err3 := embedToTar(tarWriter, fullPath, headerName); err3 != nil { return fmt.Errorf("error embeding file %s: %w", fullPath, err3) } } } return nil }