From b94a78c7958bdbc028e007d6e8b83a9da4c7a9a7 Mon Sep 17 00:00:00 2001 From: Dhruv Dhruv Date: Wed, 15 Jan 2025 07:22:55 +0000 Subject: [PATCH] feat: [CDE-572]: Using features for devcontainers. (#3260) * feat: [CDE-572]: Using features for devcontainers. Adding changes to parse features from the devcontainer.json and build a new docker image from them. Also adding the support for new devcontainer.json properties- init, privileged, capAdd, securityOpt, mounts. Adding support for three runArgs- privileged, capAdd, mount. Also making the DownloadFeature method context aware, cancelling the goroutines when the ctx is cancelled. --- .../container/devcontainer_container_utils.go | 320 ++++++++++++++++-- .../embedded_docker_container_orchestrator.go | 167 ++++++--- .../orchestrator/container/runarg_utils.go | 21 ++ app/gitspace/orchestrator/container/util.go | 22 +- app/gitspace/orchestrator/runarg/runArgs.yaml | 7 +- .../orchestrator/utils/build_with_features.go | 7 +- .../orchestrator/utils/download_features.go | 28 +- types/devcontainer_config.go | 80 ++++- types/gitspace_run_arg.go | 3 + 9 files changed, 571 insertions(+), 84 deletions(-) diff --git a/app/gitspace/orchestrator/container/devcontainer_container_utils.go b/app/gitspace/orchestrator/container/devcontainer_container_utils.go index 2ea2871ab..c78032b24 100644 --- a/app/gitspace/orchestrator/container/devcontainer_container_utils.go +++ b/app/gitspace/orchestrator/container/devcontainer_container_utils.go @@ -40,6 +40,7 @@ import ( "github.com/docker/docker/api/types/strslice" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" + "github.com/gotidy/ptr" "github.com/rs/zerolog/log" ) @@ -142,35 +143,49 @@ func CreateContainer( runArgsMap map[types.RunArg]*types.RunArgValue, containerUser string, remoteUser string, -) error { + features []*types.ResolvedFeature, + devcontainerConfig types.DevcontainerConfig, + metadataFromImage map[string]any, +) (map[PostAction][]*LifecycleHookStep, error) { exposedPorts, portBindings := applyPortMappings(portMappings) - gitspaceLogger.Info("Creating container: " + containerName) + gitspaceLogger.Info(fmt.Sprintf("Creating container %s with image %s", containerName, imageName)) - hostConfig, err := prepareHostConfig(bindMountSource, bindMountTarget, mountType, portBindings, runArgsMap) + hostConfig, err := prepareHostConfig(bindMountSource, bindMountTarget, mountType, portBindings, runArgsMap, + features, devcontainerConfig, metadataFromImage) if err != nil { - return err + return nil, err } healthCheckConfig, err := getHealthCheckConfig(runArgsMap) if err != nil { - return err + return nil, err } stopTimeout, err := getStopTimeout(runArgsMap) if err != nil { - return err + return nil, err } - entrypoint := getEntrypoint(runArgsMap) + entrypoint := mergeEntrypoints(features, runArgsMap) var cmd strslice.StrSlice if len(entrypoint) == 0 { entrypoint = []string{"/bin/sh"} cmd = []string{"-c", "trap 'exit 0' 15; sleep infinity & wait $!"} } + lifecycleHookSteps := mergeLifeCycleHooks(devcontainerConfig, features) + lifecycleHookStepsStr, err := json.Marshal(lifecycleHookSteps) + if err != nil { + return nil, err + } + labels := getLabels(runArgsMap) + // Setting the following so that it can be read later to form gitspace URL. labels[gitspaceRemoteUserLabel] = remoteUser + // Setting the following so that it can be read later to run the postStartCommands during restarts. + labels[gitspaceLifeCycleHooksLabel] = string(lifecycleHookStepsStr) + // Create the container containerConfig := &container.Config{ Hostname: getHostname(runArgsMap), @@ -190,10 +205,74 @@ func CreateContainer( _, err = dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName) if err != nil { - return logStreamWrapError(gitspaceLogger, "Error while creating container", err) + return nil, logStreamWrapError(gitspaceLogger, "Error while creating container", err) } - return nil + return lifecycleHookSteps, nil +} + +func mergeLifeCycleHooks( + devcontainerConfig types.DevcontainerConfig, + features []*types.ResolvedFeature, +) map[PostAction][]*LifecycleHookStep { + var postCreateHooks []*LifecycleHookStep + var postStartHooks []*LifecycleHookStep + for _, feature := range features { + if len(feature.DownloadedFeature.DevcontainerFeatureConfig.PostCreateCommand.ToCommandArray()) > 0 { + postCreateHooks = append(postCreateHooks, &LifecycleHookStep{ + Source: feature.DownloadedFeature.Source, + Command: feature.DownloadedFeature.DevcontainerFeatureConfig.PostCreateCommand, + ActionType: PostCreateAction, + StopOnFailure: true, + }) + } + if len(feature.DownloadedFeature.DevcontainerFeatureConfig.PostStartCommand.ToCommandArray()) > 0 { + postStartHooks = append(postStartHooks, &LifecycleHookStep{ + Source: feature.DownloadedFeature.Source, + Command: feature.DownloadedFeature.DevcontainerFeatureConfig.PostStartCommand, + ActionType: PostStartAction, + StopOnFailure: true, + }) + } + } + + if len(devcontainerConfig.PostCreateCommand.ToCommandArray()) > 0 { + postCreateHooks = append(postCreateHooks, &LifecycleHookStep{ + Source: "devcontainer.json", + Command: devcontainerConfig.PostCreateCommand, + ActionType: PostCreateAction, + StopOnFailure: false, + }) + } + + if len(devcontainerConfig.PostStartCommand.ToCommandArray()) > 0 { + postStartHooks = append(postStartHooks, &LifecycleHookStep{ + Source: "devcontainer.json", + Command: devcontainerConfig.PostStartCommand, + ActionType: PostStartAction, + StopOnFailure: false, + }) + } + + return map[PostAction][]*LifecycleHookStep{ + PostCreateAction: postCreateHooks, + PostStartAction: postStartHooks, + } +} + +func mergeEntrypoints( + features []*types.ResolvedFeature, + runArgsMap map[types.RunArg]*types.RunArgValue, +) strslice.StrSlice { + entrypoints := strslice.StrSlice{} + for _, feature := range features { + entrypoint := feature.DownloadedFeature.DevcontainerFeatureConfig.Entrypoint + if entrypoint != "" { + entrypoints = append(entrypoints, entrypoint) + } + } + entrypoints = append(entrypoints, getEntrypoint(runArgsMap)...) + return entrypoints } // Prepare port mappings for container creation. @@ -221,6 +300,9 @@ func prepareHostConfig( mountType mount.Type, portBindings nat.PortMap, runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + devcontainerConfig types.DevcontainerConfig, + metadataFromImage map[string]any, ) (*container.HostConfig, error) { hostResources, err := getHostResources(runArgsMap) if err != nil { @@ -247,21 +329,27 @@ func prepareHostConfig( return nil, err } + defaultMount := mount.Mount{ + Type: mountType, + Source: bindMountSource, + Target: bindMountTarget, + } + + mergedMounts, err := mergeMounts(devcontainerConfig, runArgsMap, features, defaultMount, metadataFromImage) + if err != nil { + return nil, fmt.Errorf("failed to merge mounts: %w", err) + } + hostConfig := &container.HostConfig{ - PortBindings: portBindings, - Mounts: []mount.Mount{ - { - Type: mountType, - Source: bindMountSource, - Target: bindMountTarget, - }, - }, + PortBindings: portBindings, + Mounts: mergedMounts, Resources: hostResources, Annotations: getAnnotations(runArgsMap), ExtraHosts: extraHosts, NetworkMode: getNetworkMode(runArgsMap), RestartPolicy: restartPolicy, AutoRemove: getAutoRemove(runArgsMap), + CapAdd: mergeCapAdd(devcontainerConfig, runArgsMap, features, metadataFromImage), CapDrop: getCapDrop(runArgsMap), CgroupnsMode: getCgroupNSMode(runArgsMap), DNS: getDNS(runArgsMap), @@ -269,12 +357,13 @@ func prepareHostConfig( DNSSearch: getDNSSearch(runArgsMap), IpcMode: getIPCMode(runArgsMap), Isolation: getIsolation(runArgsMap), - Init: getInit(runArgsMap), + Init: mergeInit(devcontainerConfig, runArgsMap, features, metadataFromImage), Links: getLinks(runArgsMap), OomScoreAdj: oomScoreAdj, PidMode: getPIDMode(runArgsMap), + Privileged: mergePriviledged(devcontainerConfig, runArgsMap, features, metadataFromImage), Runtime: getRuntime(runArgsMap), - SecurityOpt: getSecurityOpt(runArgsMap), + SecurityOpt: mergeSecurityOpts(devcontainerConfig, runArgsMap, features, metadataFromImage), StorageOpt: getStorageOpt(runArgsMap), ShmSize: shmSize, Sysctls: getSysctls(runArgsMap), @@ -283,6 +372,186 @@ func prepareHostConfig( return hostConfig, nil } +func mergeMounts( + devcontainerConfig types.DevcontainerConfig, + runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + defaultMount mount.Mount, + metadataFromImage map[string]any, +) ([]mount.Mount, error) { + var allMountsRaw []*types.Mount + for _, feature := range features { + if len(feature.DownloadedFeature.DevcontainerFeatureConfig.Mounts) > 0 { + allMountsRaw = append(allMountsRaw, feature.DownloadedFeature.DevcontainerFeatureConfig.Mounts...) + } + } + + mountsFromRunArgs, err := getMounts(runArgsMap) + if err != nil { + return nil, err + } + + // First check if mounts have been overridden in the runArgs, then check if the devcontainer.json + // provides any security options, if not, only then check the image metadata. + switch { + case len(mountsFromRunArgs) > 0: + allMountsRaw = append(allMountsRaw, mountsFromRunArgs...) + case len(devcontainerConfig.Mounts) > 0: + allMountsRaw = append(allMountsRaw, devcontainerConfig.Mounts...) + default: + if values, ok := metadataFromImage["mounts"].([]any); ok { + parsedMounts, err := types.ParseMountsFromRawSlice(values) + if err != nil { + return nil, err + } + allMountsRaw = append(allMountsRaw, parsedMounts...) + } + } + + var allMounts []mount.Mount + for _, rawMount := range allMountsRaw { + if rawMount.Type == "" { + rawMount.Type = string(mount.TypeVolume) + } + parsedMount := mount.Mount{ + Type: mount.Type(rawMount.Type), + Source: rawMount.Source, + Target: rawMount.Target, + } + allMounts = append(allMounts, parsedMount) + } + + allMounts = append(allMounts, defaultMount) + + return allMounts, nil +} + +func mergeSecurityOpts( + devcontainerConfig types.DevcontainerConfig, + runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + metadataFromImage map[string]any, +) []string { + var allOpts []string + for _, feature := range features { + allOpts = append(allOpts, feature.DownloadedFeature.DevcontainerFeatureConfig.SecurityOpt...) + } + + // First check if security options have been overridden in the runArgs, then check if the devcontainer.json + // provides any security options, if not, only then check the image metadata. + securityOptsFromRunArgs := getSecurityOpt(runArgsMap) + switch { + case len(securityOptsFromRunArgs) > 0: + allOpts = append(allOpts, securityOptsFromRunArgs...) + case len(devcontainerConfig.SecurityOpt) > 0: + allOpts = append(allOpts, devcontainerConfig.SecurityOpt...) + default: + if value, ok := metadataFromImage["securityOpt"].([]string); ok { + allOpts = append(allOpts, value...) + } + } + + return allOpts +} + +func mergeCapAdd( + devcontainerConfig types.DevcontainerConfig, + runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + metadataFromImage map[string]any, +) strslice.StrSlice { + allCaps := strslice.StrSlice{} + for _, feature := range features { + allCaps = append(allCaps, feature.DownloadedFeature.DevcontainerFeatureConfig.CapAdd...) + } + + // First check if capAdd have been overridden in the runArgs, then check if the devcontainer.json + // provides any capAdd, if not, only then check the image metadata. + capAddFromRunArgs := getCapAdd(runArgsMap) + switch { + case len(capAddFromRunArgs) > 0: + allCaps = append(allCaps, capAddFromRunArgs...) + case len(devcontainerConfig.CapAdd) > 0: + allCaps = append(allCaps, devcontainerConfig.CapAdd...) + default: + if value, ok := metadataFromImage["capAdd"].([]string); ok { + allCaps = append(allCaps, value...) + } + } + + return allCaps +} + +func mergeInit( + devcontainerConfig types.DevcontainerConfig, + runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + metadataFromImage map[string]any, +) *bool { + // First check if init has been overridden in the runArgs, if not, then check in the devcontainer.json + // lastly check in the image metadata. + var initPtr = getInit(runArgsMap) + + if initPtr == nil { + if devcontainerConfig.Init != nil { + initPtr = devcontainerConfig.Init + } else { + if value, ok := metadataFromImage["init"].(bool); ok { + initPtr = ptr.Bool(value) + } + } + } + + var init = ptr.ToBool(initPtr) + + // Merge this valye with the value from the features. + if !init { + for _, feature := range features { + if feature.DownloadedFeature.DevcontainerFeatureConfig.Init { + init = true + break + } + } + } + + return ptr.Bool(init) +} + +func mergePriviledged( + devcontainerConfig types.DevcontainerConfig, + runArgsMap map[types.RunArg]*types.RunArgValue, + features []*types.ResolvedFeature, + metadataFromImage map[string]any, +) bool { + // First check if privileged has been overridden in the runArgs, if not, then check in the devcontainer.json + // lastly check in the image metadata. + var privilegedPtr = getPrivileged(runArgsMap) + + if privilegedPtr == nil { + if devcontainerConfig.Privileged != nil { + privilegedPtr = devcontainerConfig.Privileged + } else { + if value, ok := metadataFromImage["privileged"].(bool); ok { + privilegedPtr = ptr.Bool(value) + } + } + } + + var privileged = ptr.ToBool(privilegedPtr) + + // Merge this valye with the value from the features. + if !privileged { + for _, feature := range features { + if feature.DownloadedFeature.DevcontainerFeatureConfig.Privileged { + privileged = true + break + } + } + } + + return privileged +} + func GetContainerInfo( ctx context.Context, containerName string, @@ -542,17 +811,22 @@ func GetContainerResponse( }, nil } -func GetRemoteUserFromContainerLabel( +func GetGitspaceInfoFromContainerLabels( ctx context.Context, containerName string, dockerClient *client.Client, -) (string, error) { +) (string, map[PostAction][]*LifecycleHookStep, error) { inspectResp, err := dockerClient.ContainerInspect(ctx, containerName) if err != nil { - return "", fmt.Errorf("could not inspect container %s: %w", containerName, err) + return "", nil, fmt.Errorf("could not inspect container %s: %w", containerName, err) } - return ExtractRemoteUserFromLabels(inspectResp), nil + remoteUser := ExtractRemoteUserFromLabels(inspectResp) + lifecycleHooks, err := ExtractLifecycleHooksFromLabels(inspectResp) + if err != nil { + return "", nil, fmt.Errorf("could not extract lifecycle hooks: %w", err) + } + return remoteUser, lifecycleHooks, nil } // Helper function to encode the AuthConfig into a Base64 string. diff --git a/app/gitspace/orchestrator/container/embedded_docker_container_orchestrator.go b/app/gitspace/orchestrator/container/embedded_docker_container_orchestrator.go index d0abf7d63..e53c8e417 100644 --- a/app/gitspace/orchestrator/container/embedded_docker_container_orchestrator.go +++ b/app/gitspace/orchestrator/container/embedded_docker_container_orchestrator.go @@ -53,6 +53,13 @@ type step struct { StopOnFailure bool // Flag to control whether execution should stop on failure } +type LifecycleHookStep struct { + Source string `json:"source,omitempty"` + Command types.LifecycleCommand `json:"command,omitempty"` + ActionType PostAction `json:"action_type,omitempty"` + StopOnFailure bool `json:"stop_on_failure,omitempty"` +} + // ExecuteSteps executes all registered steps in sequence, respecting stopOnFailure flag. func (e *EmbeddedDockerOrchestrator) ExecuteSteps( ctx context.Context, @@ -181,7 +188,7 @@ func (e *EmbeddedDockerOrchestrator) startStoppedGitspace( } defer e.flushLogStream(logStreamInstance, gitspaceConfig.ID) - remoteUser, err := GetRemoteUserFromContainerLabel(ctx, containerName, dockerClient) + remoteUser, lifecycleHooks, err := GetGitspaceInfoFromContainerLabels(ctx, containerName, dockerClient) if err != nil { return fmt.Errorf("error getting remote user for gitspace instance %s: %w", gitspaceConfig.GitspaceInstance.Identifier, err) @@ -217,13 +224,24 @@ func (e *EmbeddedDockerOrchestrator) startStoppedGitspace( return err } - // Execute post-start command - devcontainerConfig := resolvedRepoDetails.DevcontainerConfig - command := ExtractLifecycleCommands(PostStartAction, devcontainerConfig) - startErr = ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, logStreamInstance, command, PostStartAction) - if startErr != nil { - log.Warn().Msgf("Error is post-start command, continuing : %s", startErr.Error()) + if len(lifecycleHooks) > 0 && len(lifecycleHooks[PostStartAction]) > 0 { + for _, lifecycleHook := range lifecycleHooks[PostStartAction] { + startErr = ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, logStreamInstance, + lifecycleHook.Command.ToCommandArray(), PostStartAction) + if startErr != nil { + log.Warn().Msgf("Error in post-start command, continuing : %s", startErr.Error()) + } + } + } else { + // Execute post-start command for the containers before this label was introduced + devcontainerConfig := resolvedRepoDetails.DevcontainerConfig + command := ExtractLifecycleCommands(PostStartAction, devcontainerConfig) + startErr = ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, logStreamInstance, command, PostStartAction) + if startErr != nil { + log.Warn().Msgf("Error in post-start command, continuing : %s", startErr.Error()) + } } + return nil } @@ -422,26 +440,43 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps( containerUser := GetContainerUser(runArgsMap, devcontainerConfig, metadataFromImage, imageUser) remoteUser := GetRemoteUser(devcontainerConfig, metadataFromImage, containerUser) - homeDir := GetUserHomeDir(remoteUser) + containerUserHomeDir := GetUserHomeDir(containerUser) + remoteUserHomeDir := GetUserHomeDir(remoteUser) gitspaceLogger.Info(fmt.Sprintf("Container user: %s", containerUser)) gitspaceLogger.Info(fmt.Sprintf("Remote user: %s", remoteUser)) + var features []*types.ResolvedFeature + if devcontainerConfig.Features != nil && len(*devcontainerConfig.Features) > 0 { + sortedFeatures, newImageName, err := InstallFeatures(ctx, gitspaceConfig.GitspaceInstance.Identifier, + dockerClient, *devcontainerConfig.Features, devcontainerConfig.OverrideFeatureInstallOrder, imageName, + containerUser, remoteUser, containerUserHomeDir, remoteUserHomeDir, gitspaceLogger) + if err != nil { + return err + } + features = sortedFeatures + imageName = newImageName + } else { + gitspaceLogger.Info("No features found") + } // Create the container - err = CreateContainer( + lifecycleHookSteps, err := CreateContainer( ctx, dockerClient, imageName, containerName, gitspaceLogger, storage, - homeDir, + remoteUserHomeDir, mount.TypeVolume, portMappings, environment, runArgsMap, containerUser, remoteUser, + features, + resolvedRepoDetails.DevcontainerConfig, + metadataFromImage, ) if err != nil { return err @@ -456,7 +491,7 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps( exec := &devcontainer.Exec{ ContainerName: containerName, DockerClient: dockerClient, - DefaultWorkingDir: homeDir, + DefaultWorkingDir: remoteUserHomeDir, RemoteUser: remoteUser, AccessKey: *gitspaceConfig.GitspaceInstance.AccessKey, AccessType: gitspaceConfig.GitspaceInstance.AccessType, @@ -471,6 +506,7 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps( resolvedRepoDetails, defaultBaseImage, environment, + lifecycleHookSteps, ); err != nil { return logStreamWrapError(gitspaceLogger, "Error while setting up gitspace", err) } @@ -478,18 +514,65 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps( return nil } +func InstallFeatures( + ctx context.Context, + gitspaceInstanceIdentifier string, + dockerClient *client.Client, + features types.Features, + overrideFeatureInstallOrder []string, + imageName string, + containerUser string, + remoteUser string, + containerUserHomeDir string, + remoteUserHomeDir string, + gitspaceLogger gitspaceTypes.GitspaceLogger, +) ([]*types.ResolvedFeature, string, error) { + gitspaceLogger.Info("Downloading features...") + downloadedFeatures, err := utils.DownloadFeatures(ctx, gitspaceInstanceIdentifier, features) + if err != nil { + return nil, "", logStreamWrapError(gitspaceLogger, "Error downloading features", err) + } + gitspaceLogger.Info(fmt.Sprintf("Downloaded %d features", len(*downloadedFeatures))) + + gitspaceLogger.Info("Resolving features...") + resolvedFeatures, err := utils.ResolveFeatures(features, *downloadedFeatures) + if err != nil { + return nil, "", logStreamWrapError(gitspaceLogger, "Error resolving features", err) + } + gitspaceLogger.Info(fmt.Sprintf("Resolved to %d features", len(resolvedFeatures))) + + gitspaceLogger.Info("Determining feature installation order...") + sortedFeatures, err := utils.SortFeatures(resolvedFeatures, overrideFeatureInstallOrder) + if err != nil { + return nil, "", logStreamWrapError(gitspaceLogger, "Error sorting features", err) + } + gitspaceLogger.Info("Feature installation order is:") + for index, feature := range sortedFeatures { + gitspaceLogger.Info(fmt.Sprintf("%d. %s", index, feature.Print())) + } + + gitspaceLogger.Info("Installing features...") + imageName, err = utils.BuildWithFeatures(ctx, dockerClient, imageName, sortedFeatures, gitspaceInstanceIdentifier, + containerUser, remoteUser, containerUserHomeDir, remoteUserHomeDir) + if err != nil { + return nil, "", logStreamWrapError(gitspaceLogger, "Error building with features", err) + } + gitspaceLogger.Info(fmt.Sprintf("Installed features, built new docker image %s", imageName)) + + return sortedFeatures, imageName, nil +} + // buildSetupSteps constructs the steps to be executed in the setup process. func (e *EmbeddedDockerOrchestrator) buildSetupSteps( - _ context.Context, ideService ide.IDE, gitspaceConfig types.GitspaceConfig, resolvedRepoDetails scm.ResolvedDetails, defaultBaseImage string, environment []string, - devcontainerConfig types.DevcontainerConfig, codeRepoDir string, + lifecycleHookSteps map[PostAction][]*LifecycleHookStep, ) []step { - return []step{ + steps := []step{ { Name: "Validate Supported OS", Execute: utils.ValidateSupportedOS, @@ -583,33 +666,41 @@ func (e *EmbeddedDockerOrchestrator) buildSetupSteps( return ideService.Run(ctx, exec, args, gitspaceLogger) }, StopOnFailure: true, - }, - // Post-create and Post-start steps - { - Name: "Execute PostCreate Command", + }} + + // Add the postCreateCommand lifecycle hooks to the steps + for _, lifecycleHook := range lifecycleHookSteps[PostCreateAction] { + steps = append(steps, step{ + Name: fmt.Sprintf("Execute postCreateCommand from %s", lifecycleHook.Source), Execute: func( ctx context.Context, exec *devcontainer.Exec, gitspaceLogger gitspaceTypes.GitspaceLogger, ) error { - command := ExtractLifecycleCommands(PostCreateAction, devcontainerConfig) - return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger, command, PostCreateAction) + return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger, + lifecycleHook.Command.ToCommandArray(), PostCreateAction) }, - StopOnFailure: false, - }, - { - Name: "Execute PostStart Command", - Execute: func( - ctx context.Context, - exec *devcontainer.Exec, - gitspaceLogger gitspaceTypes.GitspaceLogger, - ) error { - command := ExtractLifecycleCommands(PostStartAction, devcontainerConfig) - return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger, command, PostStartAction) - }, - StopOnFailure: false, - }, + StopOnFailure: lifecycleHook.StopOnFailure, + }) } + + // Add the postStartCommand lifecycle hooks to the steps + for _, lifecycleHook := range lifecycleHookSteps[PostStartAction] { + steps = append(steps, step{ + Name: fmt.Sprintf("Execute postStartCommand from %s", lifecycleHook.Source), + Execute: func( + ctx context.Context, + exec *devcontainer.Exec, + gitspaceLogger gitspaceTypes.GitspaceLogger, + ) error { + return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger, + lifecycleHook.Command.ToCommandArray(), PostStartAction) + }, + StopOnFailure: lifecycleHook.StopOnFailure, + }) + } + + return steps } // setupGitspaceAndIDE initializes Gitspace and IdeType by registering and executing the setup steps. @@ -622,20 +713,20 @@ func (e *EmbeddedDockerOrchestrator) setupGitspaceAndIDE( resolvedRepoDetails scm.ResolvedDetails, defaultBaseImage string, environment []string, + lifecycleHookSteps map[PostAction][]*LifecycleHookStep, ) error { homeDir := GetUserHomeDir(exec.RemoteUser) - devcontainerConfig := resolvedRepoDetails.DevcontainerConfig codeRepoDir := filepath.Join(homeDir, resolvedRepoDetails.RepoName) steps := e.buildSetupSteps( - ctx, ideService, gitspaceConfig, resolvedRepoDetails, defaultBaseImage, environment, - devcontainerConfig, - codeRepoDir) + codeRepoDir, + lifecycleHookSteps, + ) // Execute the registered steps if err := e.ExecuteSteps(ctx, exec, gitspaceLogger, steps); err != nil { diff --git a/app/gitspace/orchestrator/container/runarg_utils.go b/app/gitspace/orchestrator/container/runarg_utils.go index 900012ce5..2de9e28c7 100644 --- a/app/gitspace/orchestrator/container/runarg_utils.go +++ b/app/gitspace/orchestrator/container/runarg_utils.go @@ -159,6 +159,10 @@ func getNetworkMode(runArgsMap map[types.RunArg]*types.RunArgValue) container.Ne return container.NetworkMode(getArgValueString(runArgsMap, types.RunArgNetwork)) } +func getCapAdd(runArgsMap map[types.RunArg]*types.RunArgValue) strslice.StrSlice { + return getArgValueStringSlice(runArgsMap, types.RunArgCapAdd) +} + func getCapDrop(runArgsMap map[types.RunArg]*types.RunArgValue) strslice.StrSlice { return getArgValueStringSlice(runArgsMap, types.RunArgCapDrop) } @@ -253,6 +257,10 @@ func getAutoRemove(runArgsMap map[types.RunArg]*types.RunArgValue) bool { return getArgValueBool(runArgsMap, types.RunArgRm) } +func getPrivileged(runArgsMap map[types.RunArg]*types.RunArgValue) *bool { + return getArgValueBoolPtr(runArgsMap, types.RunArgPrivileged) +} + func getInit(runArgsMap map[types.RunArg]*types.RunArgValue) *bool { return getArgValueBoolPtr(runArgsMap, types.RunArgInit) } @@ -329,6 +337,19 @@ func getEntrypoint(runArgsMap map[types.RunArg]*types.RunArgValue) []string { return getArgValueStringSlice(runArgsMap, types.RunArgEntrypoint) } +func getMounts(runArgsMap map[types.RunArg]*types.RunArgValue) ([]*types.Mount, error) { + rawMounts, ok := runArgsMap[types.RunArgMount] + var mounts []*types.Mount + if ok { + parsedMounts, err := types.ParseMountsFromStringSlice(rawMounts.Values) + if err != nil { + return nil, err + } + return parsedMounts, nil + } + return mounts, nil +} + func getHealthCheckConfig(runArgsMap map[types.RunArg]*types.RunArgValue) (*container.HealthConfig, error) { var healthConfig = &container.HealthConfig{} diff --git a/app/gitspace/orchestrator/container/util.go b/app/gitspace/orchestrator/container/util.go index a12e0a7a6..763bff558 100644 --- a/app/gitspace/orchestrator/container/util.go +++ b/app/gitspace/orchestrator/container/util.go @@ -16,6 +16,7 @@ package container import ( "context" + "encoding/json" "fmt" "path/filepath" "sync" @@ -28,9 +29,10 @@ import ( ) const ( - linuxHome = "/home" - deprecatedRemoteUser = "harness" - gitspaceRemoteUserLabel = "gitspace.remote.user" + linuxHome = "/home" + deprecatedRemoteUser = "harness" + gitspaceRemoteUserLabel = "gitspace.remote.user" + gitspaceLifeCycleHooksLabel = "gitspace.lifecycle.hooks" ) func GetGitspaceContainerName(config types.GitspaceConfig) string { @@ -85,6 +87,20 @@ func ExtractRemoteUserFromLabels(inspectResp dockerTypes.ContainerJSON) string { return remoteUser } +func ExtractLifecycleHooksFromLabels( + inspectResp dockerTypes.ContainerJSON, +) (map[PostAction][]*LifecycleHookStep, error) { + var lifecycleHooks = make(map[PostAction][]*LifecycleHookStep) + + if lifecycleHooksStr, ok := inspectResp.Config.Labels[gitspaceLifeCycleHooksLabel]; ok { + err := json.Unmarshal([]byte(lifecycleHooksStr), &lifecycleHooks) + if err != nil { + return nil, err + } + } + return lifecycleHooks, nil +} + // ExecuteLifecycleCommands executes commands in parallel, logs with numbers, and prefixes all logs. func ExecuteLifecycleCommands( ctx context.Context, diff --git a/app/gitspace/orchestrator/runarg/runArgs.yaml b/app/gitspace/orchestrator/runarg/runArgs.yaml index f03e35f5c..f62b44234 100644 --- a/app/gitspace/orchestrator/runarg/runArgs.yaml +++ b/app/gitspace/orchestrator/runarg/runArgs.yaml @@ -36,7 +36,7 @@ - name: --cap-add short_hand: - supported: false + supported: true blocked_values: { } allowed_values: { } allow_multiple_occurrences: true @@ -396,6 +396,7 @@ supported: true blocked_values: ^gitspace\.remote\.user=: true + ^gitspace\.lifecycle\.hooks=: true allowed_values: { } allow_multiple_occurrences: true @@ -471,7 +472,7 @@ - name: --mount short_hand: - supported: false + supported: true blocked_values: { } allowed_values: { } allow_multiple_occurrences: true @@ -543,7 +544,7 @@ - name: --privileged short_hand: - supported: false + supported: true blocked_values: { } allowed_values: { } allow_multiple_occurrences: true diff --git a/app/gitspace/orchestrator/utils/build_with_features.go b/app/gitspace/orchestrator/utils/build_with_features.go index c87931e6b..3c8277ba6 100644 --- a/app/gitspace/orchestrator/utils/build_with_features.go +++ b/app/gitspace/orchestrator/utils/build_with_features.go @@ -172,7 +172,12 @@ func generateDockerFileWithFeatures( containerUserHomeDir string, remoteUserHomeDir string, ) error { - dockerFile := fmt.Sprintf("FROM %s\nARG %s=%s\nARG %s=%s\nARG %s=%s\nARG %s=%s\nCOPY ./devcontainer-features %s", + dockerFile := fmt.Sprintf(`FROM %s +ARG %s=%s +ARG %s=%s +ARG %s=%s +ARG %s=%s +COPY ./devcontainer-features %s`, imageName, convertOptionsToEnvVariables("_CONTAINER_USER"), containerUser, convertOptionsToEnvVariables("_REMOTE_USER"), remoteUser, convertOptionsToEnvVariables("_CONTAINER_USER_HOME"), containerUserHomeDir, diff --git a/app/gitspace/orchestrator/utils/download_features.go b/app/gitspace/orchestrator/utils/download_features.go index 54fa091f0..29ff92bdf 100644 --- a/app/gitspace/orchestrator/utils/download_features.go +++ b/app/gitspace/orchestrator/utils/download_features.go @@ -68,21 +68,24 @@ func DownloadFeatures( downloadQueue <- featureSource{sourceURL: key, sourceType: value.SourceType} } - // TODO: Add ctx based cancellations to the below goroutines. - // NOTE: The following logic might see performance issues with spikes in memory and CPU usage. // If there are such issues, we can introduce throttling on the basis of memory, CPU, etc. - go func() { + go func(ctx context.Context) { for source := range downloadQueue { - startCh <- 1 - go func(source featureSource) { - defer func(endCh chan int) { endCh <- 1 }(endCh) - err := downloadFeature(ctx, gitspaceInstanceIdentifier, &source, &featuresToBeDownloaded, - downloadQueue, &downloadedFeatures) - errorCh <- err - }(source) + select { + case <-ctx.Done(): + return + default: + startCh <- 1 + go func(source featureSource) { + defer func(endCh chan int) { endCh <- 1 }(endCh) + err := downloadFeature(ctx, gitspaceInstanceIdentifier, &source, &featuresToBeDownloaded, + downloadQueue, &downloadedFeatures) + errorCh <- err + }(source) + } } - }() + }(ctx) var totalStart int var totalEnd int @@ -90,6 +93,8 @@ func DownloadFeatures( waitLoop: for { select { + case <-ctx.Done(): + return nil, ctx.Err() case start := <-startCh: totalStart += start case end := <-endCh: @@ -116,6 +121,7 @@ waitLoop: close(startCh) close(endCh) close(downloadQueue) + close(errorCh) if downloadError != nil { return nil, downloadError diff --git a/types/devcontainer_config.go b/types/devcontainer_config.go index 2665a7598..ad4f32d47 100644 --- a/types/devcontainer_config.go +++ b/types/devcontainer_config.go @@ -15,9 +15,11 @@ package types import ( + "encoding/csv" "encoding/json" "fmt" "net/url" + "path/filepath" "strings" "github.com/harness/gitness/types/enum" @@ -40,8 +42,8 @@ type DevcontainerConfig struct { RemoteUser string `json:"remoteUser,omitempty"` Features *Features `json:"features,omitempty"` OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` - Privileged bool `json:"privileged,omitempty"` - Init bool `json:"init,omitempty"` + Privileged *bool `json:"privileged,omitempty"` + Init *bool `json:"init,omitempty"` CapAdd []string `json:"capAdd,omitempty"` SecurityOpt []string `json:"securityOpt,omitempty"` Mounts []*Mount `json:"mounts,omitempty"` @@ -246,11 +248,79 @@ type Mount struct { } func (m *Mount) UnmarshalJSON(data []byte) error { - // TODO: Add support for unmarshalling mount data from a string input - var mount Mount - err := json.Unmarshal(data, &mount) + if err := json.Unmarshal(data, m); err == nil { + return nil + } + dst, err := stringToObject(string(data)) if err != nil { return err } + *m = *dst return nil } + +func ParseMountsFromRawSlice(values []any) ([]*Mount, error) { + var mounts []*Mount + for _, value := range values { + if mountValue, isObject := value.(*Mount); isObject { + mounts = append(mounts, mountValue) + } else if strVal, isString := value.(string); isString { + dst, err := stringToObject(strVal) + if err != nil { + return nil, err + } + mounts = append(mounts, dst) + } else { + return nil, fmt.Errorf("invalid mount value: %+v", value) + } + } + return mounts, nil +} + +func ParseMountsFromStringSlice(values []string) ([]*Mount, error) { + var mounts []*Mount + for _, value := range values { + dst, err := stringToObject(value) + if err != nil { + return nil, err + } + mounts = append(mounts, dst) + } + return mounts, nil +} + +func stringToObject(mountStr string) (*Mount, error) { + csvReader := csv.NewReader(strings.NewReader(mountStr)) + fields, err := csvReader.Read() + if err != nil { + return nil, err + } + + newMount := Mount{Type: "volume"} + for _, field := range fields { + key, val, ok := strings.Cut(field, "=") + + key = strings.ToLower(key) + + if !ok { + return nil, fmt.Errorf("invalid format for mount field: %s", field) + } + + switch key { + case "type": + newMount.Type = strings.ToLower(val) + case "source", "src": + newMount.Source = val + if strings.HasPrefix(val, "."+string(filepath.Separator)) || val == "." { + if abs, err := filepath.Abs(val); err == nil { + newMount.Source = abs + } + } + case "target", "dst", "destination": + newMount.Target = val + default: + return nil, fmt.Errorf("unexpected key '%s' in '%s'", key, field) + } + } + return &newMount, nil +} diff --git a/types/gitspace_run_arg.go b/types/gitspace_run_arg.go index 5b059205e..2f2a01bda 100644 --- a/types/gitspace_run_arg.go +++ b/types/gitspace_run_arg.go @@ -82,6 +82,9 @@ const ( RunArgSysctl = RunArg("--sysctl") RunArgUlimit = RunArg("--ulimit") RunArgUser = RunArg("--user") + RunArgPrivileged = RunArg("--privileged") + RunArgCapAdd = RunArg("--cap-add") + RunArgMount = RunArg("--mount") ) type RunArgDefinition struct {