diff --git a/app/gitspace/orchestrator/container/container_orchestrator.go b/app/gitspace/orchestrator/container/container_orchestrator.go new file mode 100644 index 000000000..9217282a7 --- /dev/null +++ b/app/gitspace/orchestrator/container/container_orchestrator.go @@ -0,0 +1,40 @@ +// 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 container + +import ( + "context" + + "github.com/harness/gitness/infraprovider" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" +) + +type Orchestrator interface { + // StartGitspace starts the gitspace container using the specified image or default image, clones the code, + // runs SSH server and installs the IDE inside the container. It returns a map of the ports used by the Gitspace. + StartGitspace( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + devcontainerConfig *types.DevcontainerConfig, + infra *infraprovider.Infrastructure, + ) (map[enum.IDEType]string, error) + + // StopGitspace stops and removes the gitspace container. + StopGitspace(ctx context.Context, gitspaceConfig *types.GitspaceConfig, infra *infraprovider.Infrastructure) error + + // Status checks if the infra is reachable and ready to begin container creation. + Status(ctx context.Context, infra *infraprovider.Infrastructure) error +} diff --git a/app/gitspace/orchestrator/container/devcontainer.go b/app/gitspace/orchestrator/container/devcontainer.go new file mode 100644 index 000000000..0079cb8b7 --- /dev/null +++ b/app/gitspace/orchestrator/container/devcontainer.go @@ -0,0 +1,67 @@ +// 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 container + +import ( + "context" + "fmt" + "io" + + dockerTypes "github.com/docker/docker/api/types" + "github.com/docker/docker/client" +) + +type Devcontainer struct { + ContainerName string + WorkingDir string + DockerClient *client.Client +} + +func (d *Devcontainer) ExecuteCommand(ctx context.Context, command string, detach bool) (*[]byte, error) { + cmd := []string{"/bin/bash", "-c", command} + + execConfig := dockerTypes.ExecConfig{ + User: "root", + AttachStdout: true, + AttachStderr: true, + Cmd: cmd, + Detach: detach, + WorkingDir: d.WorkingDir, + } + + execID, err := d.DockerClient.ContainerExecCreate(ctx, d.ContainerName, execConfig) + if err != nil { + return nil, fmt.Errorf("failed to create docker exec for container %s: %w", d.ContainerName, err) + } + + execResponse, err := d.DockerClient.ContainerExecAttach(ctx, execID.ID, dockerTypes.ExecStartCheck{Detach: detach}) + if err != nil && err.Error() != "unable to upgrade to tcp, received 200" { + return nil, fmt.Errorf("failed to start docker exec for container %s: %w", d.ContainerName, err) + } + + if execResponse.Conn != nil { + defer execResponse.Close() + } + + var output []byte + if execResponse.Reader != nil { + output, err = io.ReadAll(execResponse.Reader) + if err != nil { + return nil, err + } + } + + return &output, nil +} diff --git a/app/gitspace/orchestrator/container/embedded_docker.go b/app/gitspace/orchestrator/container/embedded_docker.go new file mode 100644 index 000000000..7e86309bd --- /dev/null +++ b/app/gitspace/orchestrator/container/embedded_docker.go @@ -0,0 +1,508 @@ +// 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 container + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/harness/gitness/infraprovider" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + dockerTypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/strslice" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/rs/zerolog/log" +) + +var _ Orchestrator = (*EmbeddedDockerOrchestrator)(nil) + +const ( + loggingKey = "gitspace.container" + sshPort = "22/tcp" + catchAllIP = "0.0.0.0" + catchAllPort = "0" + containerStateRunning = "running" + containerStateRemoved = "removed" + templateCloneGit = "clone_git.sh" + templateSetupSSHServer = "setup_ssh_server.sh" +) + +type Config struct { + DefaultBaseImage string + DefaultBindMountTargetPath string + DefaultBindMountSourceBasePath string +} + +type EmbeddedDockerOrchestrator struct { + dockerClientFactory *infraprovider.DockerClientFactory + vsCodeService *VSCode + vsCodeWebService *VSCodeWeb + config *Config +} + +func NewEmbeddedDockerOrchestrator( + dockerClientFactory *infraprovider.DockerClientFactory, + vsCodeService *VSCode, + vsCodeWebService *VSCodeWeb, + config *Config, +) Orchestrator { + return &EmbeddedDockerOrchestrator{ + dockerClientFactory: dockerClientFactory, + vsCodeService: vsCodeService, + vsCodeWebService: vsCodeWebService, + config: config, + } +} + +// StartGitspace checks if the Gitspace is already running by checking its entry in a map. If is it running, +// it returns, else, it creates a new Gitspace container by using the provided image. If the provided image is +// nil, it uses a default image read from Gitness config. Post creation it runs the postCreate command and clones +// the code inside the container. It uses the IDE service to setup the relevant IDE and also installs SSH server +// inside the container. +func (e *EmbeddedDockerOrchestrator) StartGitspace( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + devcontainerConfig *types.DevcontainerConfig, + infra *infraprovider.Infrastructure, +) (map[enum.IDEType]string, error) { + containerName := getGitspaceContainerName(gitspaceConfig) + + log := log.Ctx(ctx).With().Str(loggingKey, containerName).Logger() + + dockerClient, err := e.dockerClientFactory.NewDockerClient(ctx, infra) + if err != nil { + return nil, fmt.Errorf("error getting docker client from docker client factory: %w", err) + } + + defer func() { + closingErr := dockerClient.Close() + if closingErr != nil { + log.Warn().Err(closingErr).Msg("failed to close docker client") + } + }() + + log.Debug().Msg("checking current state of gitspace") + state, err := e.containerState(ctx, containerName, dockerClient) + if err != nil { + return nil, err + } + + var usedPorts map[enum.IDEType]string + + switch state { + case containerStateRunning: + log.Debug().Msg("gitspace is already running") + + ideService, startErr := e.getIDEService(gitspaceConfig) + if startErr != nil { + return nil, startErr + } + + ports, startErr := e.getUsedPorts(ctx, containerName, dockerClient, ideService) + if startErr != nil { + return nil, startErr + } + usedPorts = ports + + case containerStateRemoved: + log.Debug().Msg("gitspace is not running, starting it...") + + ideService, startErr := e.getIDEService(gitspaceConfig) + if startErr != nil { + return nil, startErr + } + + startErr = e.startGitspace( + ctx, + gitspaceConfig, + devcontainerConfig, + containerName, + dockerClient, + ideService, + ) + if startErr != nil { + return nil, fmt.Errorf("failed to start gitspace %s: %w", containerName, startErr) + } + ports, startErr := e.getUsedPorts(ctx, containerName, dockerClient, ideService) + if startErr != nil { + return nil, startErr + } + usedPorts = ports + + // TODO: Add gitspace status reporting. + log.Debug().Msg("started gitspace") + + default: + return nil, fmt.Errorf("gitspace %s is in a bad state: %s", containerName, state) + } + + return usedPorts, nil +} + +func (e *EmbeddedDockerOrchestrator) startGitspace( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + devcontainerConfig *types.DevcontainerConfig, + containerName string, + dockerClient *client.Client, + ideService IDE, +) error { + var imageName = devcontainerConfig.Image + if imageName == "" { + imageName = e.config.DefaultBaseImage + } + + err := e.pullImage(ctx, imageName, dockerClient) + if err != nil { + return err + } + + err = e.createContainer(ctx, gitspaceConfig, dockerClient, imageName, containerName, ideService) + if err != nil { + return err + } + + var devcontainer = &Devcontainer{ + ContainerName: containerName, + DockerClient: dockerClient, + WorkingDir: e.config.DefaultBindMountTargetPath, + } + + err = e.executePostCreateCommand(ctx, devcontainerConfig, devcontainer) + if err != nil { + return err + } + + err = e.cloneCode(ctx, gitspaceConfig, devcontainerConfig, devcontainer) + if err != nil { + return err + } + + err = e.setupSSHServer(ctx, gitspaceConfig.GitspaceInstance, devcontainer) + if err != nil { + return err + } + + err = ideService.Setup(ctx, devcontainer, gitspaceConfig.GitspaceInstance) + if err != nil { + return fmt.Errorf("failed to setup IDE for gitspace %s: %w", containerName, err) + } + + return nil +} + +func (e *EmbeddedDockerOrchestrator) getUsedPorts( + ctx context.Context, + containerName string, + dockerClient *client.Client, + ideService IDE, +) (map[enum.IDEType]string, error) { + inspectResp, err := dockerClient.ContainerInspect(ctx, containerName) + if err != nil { + return nil, fmt.Errorf("could not inspect container %s: %w", containerName, err) + } + + usedPorts := map[enum.IDEType]string{} + for port, bindings := range inspectResp.NetworkSettings.Ports { + if port == sshPort { + usedPorts[enum.IDETypeVSCode] = bindings[0].HostPort + } + if port == nat.Port(ideService.PortAndProtocol()) { + usedPorts[ideService.Type()] = bindings[0].HostPort + } + } + + return usedPorts, nil +} + +func (e *EmbeddedDockerOrchestrator) getIDEService(gitspaceConfig *types.GitspaceConfig) (IDE, error) { + var ideService IDE + + switch gitspaceConfig.IDE { + case enum.IDETypeVSCode: + ideService = e.vsCodeService + case enum.IDETypeVSCodeWeb: + ideService = e.vsCodeWebService + default: + return nil, fmt.Errorf("unsupported IDE: %s", gitspaceConfig.IDE) + } + + return ideService, nil +} + +func (e *EmbeddedDockerOrchestrator) setupSSHServer( + ctx context.Context, + gitspaceInstance *types.GitspaceInstance, + devcontainer *Devcontainer, +) error { + sshServerScript, err := GenerateScriptFromTemplate( + templateSetupSSHServer, &SetupSSHServerPayload{ + Username: "harness", + Password: gitspaceInstance.AccessKey.String, + WorkingDirectory: devcontainer.WorkingDir, + }) + if err != nil { + return fmt.Errorf( + "failed to generate scipt to setup ssh server from template %s: %w", templateSetupSSHServer, err) + } + + _, err = devcontainer.ExecuteCommand(ctx, sshServerScript, false) + if err != nil { + return fmt.Errorf("failed to setup SSH server: %w", err) + } + + return nil +} + +func (e *EmbeddedDockerOrchestrator) cloneCode( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + devcontainerConfig *types.DevcontainerConfig, + devcontainer *Devcontainer, +) error { + var devcontainerPresent = "true" + if devcontainerConfig.Image == "" { + devcontainerPresent = "false" + } + + gitCloneScript, err := GenerateScriptFromTemplate( + templateCloneGit, &CloneGitPayload{ + RepoURL: gitspaceConfig.CodeRepoURL, + DevcontainerPresent: devcontainerPresent, + Image: e.config.DefaultBaseImage, + }) + if err != nil { + return fmt.Errorf("failed to generate scipt to clone git from template %s: %w", templateCloneGit, err) + } + + _, err = devcontainer.ExecuteCommand(ctx, gitCloneScript, false) + if err != nil { + return fmt.Errorf("failed to clone code: %w", err) + } + + return nil +} + +func (e *EmbeddedDockerOrchestrator) executePostCreateCommand( + ctx context.Context, + devcontainerConfig *types.DevcontainerConfig, + devcontainer *Devcontainer, +) error { + if devcontainerConfig.PostCreateCommand == "" { + return nil + } + + _, err := devcontainer.ExecuteCommand(ctx, devcontainerConfig.PostCreateCommand, false) + if err != nil { + return fmt.Errorf("post create command failed %q: %w", devcontainerConfig.PostCreateCommand, err) + } + + return nil +} + +func (e *EmbeddedDockerOrchestrator) createContainer( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + dockerClient *client.Client, + imageName string, + containerName string, + ideService IDE, +) error { + portUsedByIDE := ideService.PortAndProtocol() + + exposedPorts := nat.PortSet{ + sshPort: struct{}{}, + } + + hostPortBindings := []nat.PortBinding{ + { + HostIP: catchAllIP, + HostPort: catchAllPort, + }, + } + + portBindings := nat.PortMap{ + sshPort: hostPortBindings, + } + + if portUsedByIDE != "" { + natPort := nat.Port(portUsedByIDE) + exposedPorts[natPort] = struct{}{} + portBindings[natPort] = hostPortBindings + } + + entryPoint := make(strslice.StrSlice, 0) + entryPoint = append(entryPoint, "sleep") + + commands := make(strslice.StrSlice, 0) + commands = append(commands, "infinity") + + bindMountSourcePath := + filepath.Join( + e.config.DefaultBindMountSourceBasePath, + gitspaceConfig.SpacePath, + gitspaceConfig.Identifier, + ) + err := os.MkdirAll(bindMountSourcePath, 0600) + if err != nil { + return fmt.Errorf( + "could not create bind mount source path %s: %w", bindMountSourcePath, err) + } + + resp2, err := dockerClient.ContainerCreate(ctx, &container.Config{ + Image: imageName, + Entrypoint: entryPoint, + Cmd: commands, + ExposedPorts: exposedPorts, + }, &container.HostConfig{ + PortBindings: portBindings, + Mounts: []mount.Mount{ + { + Type: mount.TypeBind, + Source: bindMountSourcePath, + Target: e.config.DefaultBindMountTargetPath, + }, + }, + }, nil, containerName) + if err != nil { + return fmt.Errorf("could not create container %s: %w", containerName, err) + } + + err = dockerClient.ContainerStart(ctx, resp2.ID, dockerTypes.ContainerStartOptions{}) + if err != nil { + return fmt.Errorf("could not start container %s: %w", containerName, err) + } + + return nil +} + +func (e *EmbeddedDockerOrchestrator) pullImage( + ctx context.Context, + imageName string, + dockerClient *client.Client, +) error { + resp, err := dockerClient.ImagePull(ctx, imageName, dockerTypes.ImagePullOptions{}) + defer func() { + closingErr := resp.Close() + if closingErr != nil { + log.Warn().Err(closingErr).Msg("failed to close image pull response") + } + }() + if err != nil { + return fmt.Errorf("could not pull image %s: %w", imageName, err) + } + + return nil +} + +// StopGitspace checks if the Gitspace container is running. If yes, it stops and removes the container. +// Else it returns. +func (e EmbeddedDockerOrchestrator) StopGitspace( + ctx context.Context, + gitspaceConfig *types.GitspaceConfig, + infra *infraprovider.Infrastructure, +) error { + containerName := getGitspaceContainerName(gitspaceConfig) + + log := log.Ctx(ctx).With().Str(loggingKey, containerName).Logger() + + dockerClient, err := e.dockerClientFactory.NewDockerClient(ctx, infra) + if err != nil { + return fmt.Errorf("error getting docker client from docker client factory: %w", err) + } + + defer func() { + closingErr := dockerClient.Close() + if closingErr != nil { + log.Warn().Err(closingErr).Msg("failed to close docker client") + } + }() + + log.Debug().Msg("checking current state of gitspace") + state, err := e.containerState(ctx, containerName, dockerClient) + if err != nil { + return err + } + + if state == containerStateRemoved { + log.Debug().Msg("gitspace is already stopped") + return nil + } + + log.Debug().Msg("stopping gitspace") + err = e.stopGitspace(ctx, containerName, dockerClient) + if err != nil { + return fmt.Errorf("failed to stop gitspace %s: %w", containerName, err) + } + + log.Debug().Msg("stopped gitspace") + + return nil +} + +func (e EmbeddedDockerOrchestrator) stopGitspace( + ctx context.Context, + containerName string, + dockerClient *client.Client, +) error { + err := dockerClient.ContainerStop(ctx, containerName, nil) + if err != nil { + return fmt.Errorf("could not stop container %s: %w", containerName, err) + } + + err = dockerClient.ContainerRemove(ctx, containerName, dockerTypes.ContainerRemoveOptions{Force: true}) + if err != nil { + return fmt.Errorf("could not remove container %s: %w", containerName, err) + } + + return nil +} + +func getGitspaceContainerName(config *types.GitspaceConfig) string { + return "gitspace-" + config.UserID + "-" + config.Identifier +} + +// Status is NOOP for EmbeddedDockerOrchestrator as the docker host is verified by the infra provisioner. +func (e *EmbeddedDockerOrchestrator) Status(_ context.Context, _ *infraprovider.Infrastructure) error { + return nil +} + +func (e *EmbeddedDockerOrchestrator) containerState( + ctx context.Context, + containerName string, + dockerClient *client.Client, +) (string, error) { + var args = filters.NewArgs() + args.Add("name", containerName) + + containers, err := dockerClient.ContainerList(ctx, dockerTypes.ContainerListOptions{All: true, Filters: args}) + if err != nil { + return "", fmt.Errorf("could not list container %s: %w", containerName, err) + } + + if len(containers) == 0 { + return containerStateRemoved, nil + } + + return containers[0].State, nil +} diff --git a/infraprovider/docker_client.go b/app/gitspace/orchestrator/container/ide.go similarity index 50% rename from infraprovider/docker_client.go rename to app/gitspace/orchestrator/container/ide.go index e169ca3bc..e1d622ab9 100644 --- a/infraprovider/docker_client.go +++ b/app/gitspace/orchestrator/container/ide.go @@ -12,21 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -package infraprovider +package container import ( "context" - "github.com/docker/docker/client" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" ) -var _ Client = (*DockerClient)(nil) +type IDE interface { + // Setup is responsible for doing all the operations for setting up the IDE in the container e.g. installation, + // copying settings and configurations, ensuring SSH server is running etc. + Setup(ctx context.Context, containerParams *Devcontainer, gitspaceInstance *types.GitspaceInstance) error -type DockerClient struct { - dockerClient *client.Client - closeFunc func(ctx context.Context) -} + // PortAndProtocol provides the port with protocol which will be used by this IDE. + PortAndProtocol() string -func (d DockerClient) Close(ctx context.Context) { - d.closeFunc(ctx) + // Type provides the IDE type to which the service belongs. + Type() enum.IDEType } diff --git a/app/gitspace/orchestrator/container/template.go b/app/gitspace/orchestrator/container/template.go new file mode 100644 index 000000000..badde4e1c --- /dev/null +++ b/app/gitspace/orchestrator/container/template.go @@ -0,0 +1,96 @@ +// 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 container + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "path" + "text/template" +) + +const ( + templatesDir = "template" +) + +var ( + //go:embed template/* + files embed.FS + scriptTemplates map[string]*template.Template +) + +type CloneGitPayload struct { + RepoURL string + DevcontainerPresent string + Image string +} + +type InstallVSCodeWebPayload struct { + Password string + Port string +} + +type SetupSSHServerPayload struct { + Username string + Password string + WorkingDirectory string +} + +func init() { + err := LoadTemplates() + if err != nil { + panic(fmt.Sprintf("error loading script templates: %v", err)) + } +} + +func LoadTemplates() error { + scriptTemplates = make(map[string]*template.Template) + + tmplFiles, err := fs.ReadDir(files, templatesDir) + if err != nil { + return fmt.Errorf("error reading script templates: %w", err) + } + + for _, tmpl := range tmplFiles { + if tmpl.IsDir() { + continue + } + + textTemplate, parsingErr := template.ParseFS(files, path.Join(templatesDir, tmpl.Name())) + if parsingErr != nil { + return fmt.Errorf("error parsing template %s: %w", tmpl.Name(), parsingErr) + } + + scriptTemplates[tmpl.Name()] = textTemplate + } + + return nil +} + +func GenerateScriptFromTemplate(name string, data interface{}) (string, error) { + if scriptTemplates[name] == nil { + return "", fmt.Errorf("no script template found for %s", name) + } + + tmplOutput := bytes.Buffer{} + err := scriptTemplates[name].Execute(&tmplOutput, data) + if err != nil { + return "", fmt.Errorf("error executing template %s with data %+v: %w", name, data, err) + } + + return tmplOutput.String(), nil +} diff --git a/app/gitspace/orchestrator/container/template/clone_git.sh b/app/gitspace/orchestrator/container/template/clone_git.sh new file mode 100644 index 000000000..eba5d395d --- /dev/null +++ b/app/gitspace/orchestrator/container/template/clone_git.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +repo_url={{ .RepoURL }} +devcontainer_present={{ .DevcontainerPresent }} +image={{ .Image }} + +# Extract the repository name from the URL +repo_name=$(basename -s .git "$repo_url") + +# Check if Git is installed +if ! command -v git &>/dev/null; then + echo "Git is not installed. Installing Git..." + apt-get update + apt-get install -y git +fi + +if ! command -v git &>/dev/null; then + echo "Git is not installed. Exiting..." + exit 1 +fi + +# Clone the repository only if it doesn't exist +if [ ! -d "$repo_name" ]; then + echo "Cloning the repository..." + git clone "$repo_url" +else + echo "Repository already exists. Skipping clone." +fi + +# Check if devcontainer_present is set to false +if [ "$devcontainer_present" = "false" ]; then + # Ensure the repository is cloned + if [ -d "$repo_name" ]; then + echo "Creating .devcontainer directory and devcontainer.json..." + mkdir -p "$repo_name/.devcontainer" + cat < "$repo_name/.devcontainer/devcontainer.json" +{ + "image": "$image" +} +EOL + echo "devcontainer.json created." + else + echo "Repository directory not found. Cannot create .devcontainer." + fi +else + echo "devcontainer_present is set to true. Skipping .devcontainer creation." +fi + +echo "Script completed." diff --git a/app/gitspace/orchestrator/container/template/install_vscode_web.sh b/app/gitspace/orchestrator/container/template/install_vscode_web.sh new file mode 100644 index 000000000..961869f3f --- /dev/null +++ b/app/gitspace/orchestrator/container/template/install_vscode_web.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +echo "Installing code-server" + +password={{ .Password }} +port={{ .Port }} + +curl -fsSL https://code-server.dev/install.sh | sh + +# Ensure the configuration directory exists +mkdir -p /root/.config/code-server + +# Create or overwrite the config file with new settings +cat > /root/.config/code-server/config.yaml < /dev/null; then + echo "OpenSSH server is not installed. Installing..." + apt-get update + apt-get install -y openssh-server +else + echo "OpenSSH server is already installed." +fi + +username={{ .Username }} +password={{ .Password }} +workingDir={{ .WorkingDirectory }} + +# Check if the user already exists +if id "$username" &> /dev/null; then + echo "User $username already exists." +else + # Create a new user + adduser --disabled-password --home "$workingDir" --gecos "" "$username" + if [ $? -ne 0 ]; then + echo "Failed to create user $username." + exit 1 + fi +fi + +# Set or update the user's password using chpasswd +echo "$username:$password" | chpasswd + +# Configure SSH to allow this user +config_file='/etc/ssh/sshd_config' +grep -q "^AllowUsers" $config_file +if [ $? -eq 0 ]; then + # If AllowUsers exists, add the user to it + sed -i "/^AllowUsers/ s/$/ $username/" $config_file +else + # Otherwise, add a new AllowUsers line + echo "AllowUsers $username" >> $config_file +fi + +# Ensure password authentication is enabled +sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' $config_file +if ! grep -q "^PasswordAuthentication yes" $config_file; then + echo "PasswordAuthentication yes" >> $config_file +fi + +# Changing ownership of everything inside user home to the newly created user +chown -R $username . + +mkdir /var/run/sshd +/usr/sbin/sshd \ No newline at end of file diff --git a/app/gitspace/orchestrator/container/vscode.go b/app/gitspace/orchestrator/container/vscode.go new file mode 100644 index 000000000..cda4eec6e --- /dev/null +++ b/app/gitspace/orchestrator/container/vscode.go @@ -0,0 +1,45 @@ +// 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 container + +import ( + "context" + + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" +) + +var _ IDE = (*VSCode)(nil) + +type VSCode struct{} + +func NewVsCodeService() *VSCode { + return &VSCode{} +} + +// Setup is a NOOP since VS Code doesn't require any installation. +// TODO Check if the SSH server is accessible on the required port. +func (v *VSCode) Setup(_ context.Context, _ *Devcontainer, _ *types.GitspaceInstance) error { + return nil +} + +// PortAndProtocol return nil since VS Code doesn't require any additional port to be exposed. +func (v *VSCode) PortAndProtocol() string { + return "" +} + +func (v *VSCode) Type() enum.IDEType { + return enum.IDETypeVSCode +} diff --git a/app/gitspace/orchestrator/container/vscodeweb.go b/app/gitspace/orchestrator/container/vscodeweb.go new file mode 100644 index 000000000..f5fc5c749 --- /dev/null +++ b/app/gitspace/orchestrator/container/vscodeweb.go @@ -0,0 +1,76 @@ +// 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 container + +import ( + "context" + "fmt" + + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" +) + +var _ IDE = (*VSCodeWeb)(nil) + +const templateInstallVSCodeWeb = "install_vscode_web.sh" + +type VSCodeWebConfig struct { + Port string +} + +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 and runs it +// with the given password. +func (v *VSCodeWeb) Setup( + ctx context.Context, + devcontainer *Devcontainer, + gitspaceInstance *types.GitspaceInstance, +) error { + installScript, err := GenerateScriptFromTemplate( + templateInstallVSCodeWeb, &InstallVSCodeWebPayload{ + Password: gitspaceInstance.AccessKey.String, + Port: v.config.Port, + }) + if err != nil { + return fmt.Errorf( + "failed to generate scipt to install code server from template %s: %w", + templateInstallVSCodeWeb, + err, + ) + } + + _, err = devcontainer.ExecuteCommand(ctx, installScript, true) + if err != nil { + return fmt.Errorf("failed to install code-server: %w", err) + } + + return nil +} + +// PortAndProtocol returns the port on which the code-server is listening. +func (v *VSCodeWeb) PortAndProtocol() string { + return v.config.Port + "/tcp" +} + +func (v *VSCodeWeb) Type() enum.IDEType { + return enum.IDETypeVSCodeWeb +} diff --git a/app/gitspace/orchestrator/container/wire.go b/app/gitspace/orchestrator/container/wire.go new file mode 100644 index 000000000..0bc6ae4a3 --- /dev/null +++ b/app/gitspace/orchestrator/container/wire.go @@ -0,0 +1,49 @@ +// 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 container + +import ( + "github.com/harness/gitness/infraprovider" + + "github.com/google/wire" +) + +var WireSet = wire.NewSet( + ProvideEmbeddedDockerOrchestrator, + ProvideVSCodeWebService, + ProvideVSCodeService, +) + +func ProvideEmbeddedDockerOrchestrator( + dockerClientFactory *infraprovider.DockerClientFactory, + vsCodeService *VSCode, + vsCodeWebService *VSCodeWeb, + config *Config, +) Orchestrator { + return NewEmbeddedDockerOrchestrator( + dockerClientFactory, + vsCodeService, + vsCodeWebService, + config, + ) +} + +func ProvideVSCodeWebService(config *VSCodeWebConfig) *VSCodeWeb { + return NewVsCodeWebService(config) +} + +func ProvideVSCodeService() *VSCode { + return NewVsCodeService() +} diff --git a/cli/operations/server/config.go b/cli/operations/server/config.go index a4e1b6ac8..064175054 100644 --- a/cli/operations/server/config.go +++ b/cli/operations/server/config.go @@ -22,6 +22,7 @@ import ( "strings" "unicode" + "github.com/harness/gitness/app/gitspace/orchestrator/container" "github.com/harness/gitness/app/services/cleanup" "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/keywordsearch" @@ -31,6 +32,7 @@ import ( "github.com/harness/gitness/blob" "github.com/harness/gitness/events" gittypes "github.com/harness/gitness/git/types" + "github.com/harness/gitness/infraprovider" "github.com/harness/gitness/job" "github.com/harness/gitness/lock" "github.com/harness/gitness/pubsub" @@ -48,6 +50,7 @@ const ( schemeHTTPS = "https" gitnessHomeDir = ".gitness" blobDir = "blob" + gitspacesDir = "gitspaces" ) // LoadConfig returns the system configuration from the @@ -371,3 +374,44 @@ func ProvideJobsConfig(config *types.Config) job.Config { BackgroundJobsRetentionTime: config.BackgroundJobs.RetentionTime, } } + +// ProvideDockerConfig loads config for Docker. +func ProvideDockerConfig(config *types.Config) *infraprovider.DockerConfig { + return &infraprovider.DockerConfig{ + DockerHost: config.Docker.Host, + DockerAPIVersion: config.Docker.APIVersion, + DockerCertPath: config.Docker.CertPath, + DockerTLSVerify: config.Docker.TLSVerify, + } +} + +// ProvideIDEVSCodeWebConfig loads the VSCode Web IDE config from the main config. +func ProvideIDEVSCodeWebConfig(config *types.Config) *container.VSCodeWebConfig { + return &container.VSCodeWebConfig{ + Port: config.IDE.VSCodeWeb.Port, + } +} + +// ProvideGitspaceContainerOrchestratorConfig loads the Gitspace container orchestrator config from the main config. +func ProvideGitspaceContainerOrchestratorConfig(config *types.Config) (*container.Config, error) { + var bindMountSourceBasePath string + + if config.Gitspace.DefaultBindMountSourceBasePath == "" { + var homedir string + + homedir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("unable to determine home directory: %w", err) + } + + bindMountSourceBasePath = filepath.Join(homedir, gitnessHomeDir, gitspacesDir) + } else { + bindMountSourceBasePath = filepath.Join(config.Gitspace.DefaultBindMountSourceBasePath, gitspacesDir) + } + + return &container.Config{ + DefaultBaseImage: config.Gitspace.DefaultBaseImage, + DefaultBindMountTargetPath: config.Gitspace.DefaultBindMountTargetPath, + DefaultBindMountSourceBasePath: bindMountSourceBasePath, + }, nil +} diff --git a/infraprovider/client.go b/infraprovider/client.go deleted file mode 100644 index 355dc4f52..000000000 --- a/infraprovider/client.go +++ /dev/null @@ -1,22 +0,0 @@ -// 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 infraprovider - -import "context" - -type Client interface { - // Close closes the underlying client. - Close(ctx context.Context) -} diff --git a/infraprovider/docker_client_factory.go b/infraprovider/docker_client_factory.go new file mode 100644 index 000000000..b323464de --- /dev/null +++ b/infraprovider/docker_client_factory.go @@ -0,0 +1,97 @@ +// 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 infraprovider + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + + "github.com/harness/gitness/infraprovider/enum" + + "github.com/docker/docker/client" + "github.com/docker/go-connections/tlsconfig" +) + +type DockerConfig struct { + DockerHost string + DockerAPIVersion string + DockerCertPath string + DockerTLSVerify string +} + +type DockerClientFactory struct { + config *DockerConfig +} + +func NewDockerClientFactory(config *DockerConfig) *DockerClientFactory { + return &DockerClientFactory{config: config} +} + +// NewDockerClient returns a new docker client created using the docker config and infra. +func (d *DockerClientFactory) NewDockerClient( + _ context.Context, + infra *Infrastructure, +) (*client.Client, error) { + if infra.ProviderType != enum.InfraProviderTypeDocker { + return nil, fmt.Errorf("infra provider type %s not supported", infra.ProviderType) + } + dockerClient, err := d.getClient(infra.Parameters) + if err != nil { + return nil, fmt.Errorf("error creating docker client using infra %+v: %w", infra, err) + } + return dockerClient, nil +} + +func (d *DockerClientFactory) getClient(_ []Parameter) (*client.Client, error) { + var opts []client.Opt + + opts = append(opts, client.WithHost(d.config.DockerHost)) + + opts = append(opts, client.WithVersion(d.config.DockerAPIVersion)) + + if d.config.DockerCertPath != "" { + httpsClient, err := d.getHTTPSClient() + if err != nil { + return nil, fmt.Errorf("unable to create https client for docker client: %w", err) + } + opts = append(opts, client.WithHTTPClient(httpsClient)) + } + + dockerClient, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, fmt.Errorf("unable to create docker client: %w", err) + } + + return dockerClient, nil +} + +func (d *DockerClientFactory) getHTTPSClient() (*http.Client, error) { + options := tlsconfig.Options{ + CAFile: filepath.Join(d.config.DockerCertPath, "ca.pem"), + CertFile: filepath.Join(d.config.DockerCertPath, "cert.pem"), + KeyFile: filepath.Join(d.config.DockerCertPath, "key.pem"), + InsecureSkipVerify: d.config.DockerTLSVerify == "", + } + tlsc, err := tlsconfig.Client(options) + if err != nil { + return nil, err + } + return &http.Client{ + Transport: &http.Transport{TLSClientConfig: tlsc}, + CheckRedirect: client.CheckRedirect, + }, nil +} diff --git a/infraprovider/docker_provider.go b/infraprovider/docker_provider.go index a7bebbd24..655e437d6 100644 --- a/infraprovider/docker_provider.go +++ b/infraprovider/docker_provider.go @@ -18,43 +18,42 @@ import ( "context" "fmt" "io" - "net/http" - "path/filepath" "github.com/harness/gitness/infraprovider/enum" - "github.com/docker/docker/client" - "github.com/docker/go-connections/tlsconfig" "github.com/rs/zerolog/log" ) var _ InfraProvider = (*DockerProvider)(nil) -type Config struct { - DockerHost string - DockerAPIVersion string - DockerCertPath string - DockerTLSVerify string -} - type DockerProvider struct { - config *Config + dockerClientFactory *DockerClientFactory } -func NewDockerProvider(config *Config) *DockerProvider { +func NewDockerProvider(dockerClientFactory *DockerClientFactory) *DockerProvider { return &DockerProvider{ - config: config, + dockerClientFactory: dockerClientFactory, } } // Provision assumes a docker engine is already running on the Gitness host machine and re-uses that as infra. // It does not start docker engine. func (d DockerProvider) Provision(ctx context.Context, _ string, params []Parameter) (Infrastructure, error) { - dockerClient, closeFunc, err := d.getClient(params) + dockerClient, err := d.dockerClientFactory.NewDockerClient(ctx, &Infrastructure{ + ProviderType: enum.InfraProviderTypeDocker, + Parameters: params, + }) if err != nil { - return Infrastructure{}, err + return Infrastructure{}, fmt.Errorf("error getting docker client from docker client factory: %w", err) } - defer closeFunc(ctx) + + defer func() { + closingErr := dockerClient.Close() + if closingErr != nil { + log.Ctx(ctx).Warn().Err(closingErr).Msg("failed to close docker client") + } + }() + info, err := dockerClient.Info(ctx) if err != nil { return Infrastructure{}, fmt.Errorf("unable to connect to docker engine: %w", err) @@ -110,63 +109,3 @@ func (d DockerProvider) Exec(_ context.Context, _ Infrastructure, _ []string) (i // TODO implement me panic("implement me") } - -// Client returns a new docker client created using params. -func (d DockerProvider) Client(_ context.Context, infra Infrastructure) (Client, error) { - dockerClient, closeFunc, err := d.getClient(infra.Parameters) - if err != nil { - return nil, err - } - return &DockerClient{ - dockerClient: dockerClient, - closeFunc: closeFunc, - }, nil -} - -// getClient returns a new docker client created using values from gitness docker config. -func (d DockerProvider) getClient(_ []Parameter) (*client.Client, func(context.Context), error) { - var opts []client.Opt - - opts = append(opts, client.WithHost(d.config.DockerHost)) - - opts = append(opts, client.WithVersion(d.config.DockerAPIVersion)) - - if d.config.DockerCertPath != "" { - httpsClient, err := d.getHTTPSClient() - if err != nil { - return nil, nil, fmt.Errorf("unable to create https client for docker client: %w", err) - } - opts = append(opts, client.WithHTTPClient(httpsClient)) - } - - dockerClient, err := client.NewClientWithOpts(opts...) - if err != nil { - return nil, nil, fmt.Errorf("unable to create docker client: %w", err) - } - - closeFunc := func(ctx context.Context) { - closingErr := dockerClient.Close() - if closingErr != nil { - log.Ctx(ctx).Warn().Err(closingErr).Msg("failed to close docker client") - } - } - - return dockerClient, closeFunc, nil -} - -func (d DockerProvider) getHTTPSClient() (*http.Client, error) { - options := tlsconfig.Options{ - CAFile: filepath.Join(d.config.DockerCertPath, "ca.pem"), - CertFile: filepath.Join(d.config.DockerCertPath, "cert.pem"), - KeyFile: filepath.Join(d.config.DockerCertPath, "key.pem"), - InsecureSkipVerify: d.config.DockerTLSVerify == "", - } - tlsc, err := tlsconfig.Client(options) - if err != nil { - return nil, err - } - return &http.Client{ - Transport: &http.Transport{TLSClientConfig: tlsc}, - CheckRedirect: client.CheckRedirect, - }, nil -} diff --git a/infraprovider/infra_provider.go b/infraprovider/infra_provider.go index 69673e421..8b6f6ce1e 100644 --- a/infraprovider/infra_provider.go +++ b/infraprovider/infra_provider.go @@ -42,7 +42,4 @@ type InfraProvider interface { ProvisioningType() enum.InfraProvisioningType // Exec executes a shell command in the infrastructure. Exec(ctx context.Context, infra Infrastructure, cmd []string) (io.Reader, io.Reader, error) - // Client returns a client which can be used to connect the provided infra. - // The responsibility of calling the close func lies with the user. - Client(ctx context.Context, infra Infrastructure) (Client, error) } diff --git a/infraprovider/wire.go b/infraprovider/wire.go index 5e53f749b..efc185ce5 100644 --- a/infraprovider/wire.go +++ b/infraprovider/wire.go @@ -15,8 +15,6 @@ package infraprovider import ( - "github.com/harness/gitness/types" - "github.com/google/wire" ) @@ -24,18 +22,17 @@ import ( var WireSet = wire.NewSet( ProvideDockerProvider, ProvideFactory, + ProvideDockerClientFactory, ) -func ProvideDockerProvider(config *types.Config) *DockerProvider { - dockerConfig := Config{ - DockerHost: config.Docker.Host, - DockerAPIVersion: config.Docker.APIVersion, - DockerCertPath: config.Docker.CertPath, - DockerTLSVerify: config.Docker.TLSVerify, - } - return NewDockerProvider(&dockerConfig) +func ProvideDockerProvider(dockerClientFactory *DockerClientFactory) *DockerProvider { + return NewDockerProvider(dockerClientFactory) } func ProvideFactory(dockerProvider *DockerProvider) Factory { return NewFactory(dockerProvider) } + +func ProvideDockerClientFactory(config *DockerConfig) *DockerClientFactory { + return NewDockerClientFactory(config) +} diff --git a/types/config.go b/types/config.go index 1e88c9763..43ae6c754 100644 --- a/types/config.go +++ b/types/config.go @@ -386,4 +386,22 @@ type Config struct { // TLSVerify enables or disables TLS verification, off by default. TLSVerify string `envconfig:"GITNESS_DOCKER_TLS_VERIFY"` } + + IDE struct { + VSCodeWeb struct { + // PortAndProtocol is the port on which the VS Code Web will be accessible. + Port string `envconfig:"GITNESS_IDE_VSCODEWEB_PORT" default:"8089"` + } + } + + Gitspace struct { + // DefaultBaseImage is used to create the Gitspace when no devcontainer.json is absent or doesn't have image. + DefaultBaseImage string `envconfig:"GITNESS_GITSPACE_DEFAULT_BASE_IMAGE" default:"mcr.microsoft.com/devcontainers/base:dev-ubuntu-24.04"` //nolint:lll + // DefaultBindMountTargetPath is the target for bind mount in the Gitspace container. + DefaultBindMountTargetPath string `envconfig:"GITNESS_GITSPACE_DEFAULT_BIND_MOUNT_TARGET_PATH" default:"/gitspace"` //nolint:lll + // DefaultBindMountTargetPath is the source for bind mount in the Gitspace container. + // Sub-directories will be created from this eg /gitspace/space1/space2/config1 + // If left blank, it will be set to $HOME/.gitness + DefaultBindMountSourceBasePath string `envconfig:"GITNESS_GITSPACE_DEFAULT_BIND_MOUNT_SOURCE_BASE_PATH"` + } } diff --git a/types/enum/ide.go b/types/enum/ide.go index ad2759584..4ac0fedba 100644 --- a/types/enum/ide.go +++ b/types/enum/ide.go @@ -18,9 +18,7 @@ type IDEType string func (IDEType) Enum() []interface{} { return toInterfaceSlice(ideTypes) } -var ideTypes = ([]IDEType{ - IDETypeVSCode, IDETypeVSCodeWeb, -}) +var ideTypes = []IDEType{IDETypeVSCode, IDETypeVSCodeWeb} const ( IDETypeVSCode IDEType = "vs_code"