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.
pull/3616/head
Dhruv Dhruv 2025-01-15 07:22:55 +00:00 committed by Harness
parent f8957318f6
commit b94a78c795
9 changed files with 571 additions and 84 deletions

View File

@ -40,6 +40,7 @@ import (
"github.com/docker/docker/api/types/strslice" "github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/gotidy/ptr"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -142,35 +143,49 @@ func CreateContainer(
runArgsMap map[types.RunArg]*types.RunArgValue, runArgsMap map[types.RunArg]*types.RunArgValue,
containerUser string, containerUser string,
remoteUser string, remoteUser string,
) error { features []*types.ResolvedFeature,
devcontainerConfig types.DevcontainerConfig,
metadataFromImage map[string]any,
) (map[PostAction][]*LifecycleHookStep, error) {
exposedPorts, portBindings := applyPortMappings(portMappings) 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 { if err != nil {
return err return nil, err
} }
healthCheckConfig, err := getHealthCheckConfig(runArgsMap) healthCheckConfig, err := getHealthCheckConfig(runArgsMap)
if err != nil { if err != nil {
return err return nil, err
} }
stopTimeout, err := getStopTimeout(runArgsMap) stopTimeout, err := getStopTimeout(runArgsMap)
if err != nil { if err != nil {
return err return nil, err
} }
entrypoint := getEntrypoint(runArgsMap) entrypoint := mergeEntrypoints(features, runArgsMap)
var cmd strslice.StrSlice var cmd strslice.StrSlice
if len(entrypoint) == 0 { if len(entrypoint) == 0 {
entrypoint = []string{"/bin/sh"} entrypoint = []string{"/bin/sh"}
cmd = []string{"-c", "trap 'exit 0' 15; sleep infinity & wait $!"} 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) labels := getLabels(runArgsMap)
// Setting the following so that it can be read later to form gitspace URL. // Setting the following so that it can be read later to form gitspace URL.
labels[gitspaceRemoteUserLabel] = remoteUser 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 // Create the container
containerConfig := &container.Config{ containerConfig := &container.Config{
Hostname: getHostname(runArgsMap), Hostname: getHostname(runArgsMap),
@ -190,10 +205,74 @@ func CreateContainer(
_, err = dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName) _, err = dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName)
if err != nil { 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. // Prepare port mappings for container creation.
@ -221,6 +300,9 @@ func prepareHostConfig(
mountType mount.Type, mountType mount.Type,
portBindings nat.PortMap, portBindings nat.PortMap,
runArgsMap map[types.RunArg]*types.RunArgValue, runArgsMap map[types.RunArg]*types.RunArgValue,
features []*types.ResolvedFeature,
devcontainerConfig types.DevcontainerConfig,
metadataFromImage map[string]any,
) (*container.HostConfig, error) { ) (*container.HostConfig, error) {
hostResources, err := getHostResources(runArgsMap) hostResources, err := getHostResources(runArgsMap)
if err != nil { if err != nil {
@ -247,21 +329,27 @@ func prepareHostConfig(
return nil, err 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{ hostConfig := &container.HostConfig{
PortBindings: portBindings, PortBindings: portBindings,
Mounts: []mount.Mount{ Mounts: mergedMounts,
{
Type: mountType,
Source: bindMountSource,
Target: bindMountTarget,
},
},
Resources: hostResources, Resources: hostResources,
Annotations: getAnnotations(runArgsMap), Annotations: getAnnotations(runArgsMap),
ExtraHosts: extraHosts, ExtraHosts: extraHosts,
NetworkMode: getNetworkMode(runArgsMap), NetworkMode: getNetworkMode(runArgsMap),
RestartPolicy: restartPolicy, RestartPolicy: restartPolicy,
AutoRemove: getAutoRemove(runArgsMap), AutoRemove: getAutoRemove(runArgsMap),
CapAdd: mergeCapAdd(devcontainerConfig, runArgsMap, features, metadataFromImage),
CapDrop: getCapDrop(runArgsMap), CapDrop: getCapDrop(runArgsMap),
CgroupnsMode: getCgroupNSMode(runArgsMap), CgroupnsMode: getCgroupNSMode(runArgsMap),
DNS: getDNS(runArgsMap), DNS: getDNS(runArgsMap),
@ -269,12 +357,13 @@ func prepareHostConfig(
DNSSearch: getDNSSearch(runArgsMap), DNSSearch: getDNSSearch(runArgsMap),
IpcMode: getIPCMode(runArgsMap), IpcMode: getIPCMode(runArgsMap),
Isolation: getIsolation(runArgsMap), Isolation: getIsolation(runArgsMap),
Init: getInit(runArgsMap), Init: mergeInit(devcontainerConfig, runArgsMap, features, metadataFromImage),
Links: getLinks(runArgsMap), Links: getLinks(runArgsMap),
OomScoreAdj: oomScoreAdj, OomScoreAdj: oomScoreAdj,
PidMode: getPIDMode(runArgsMap), PidMode: getPIDMode(runArgsMap),
Privileged: mergePriviledged(devcontainerConfig, runArgsMap, features, metadataFromImage),
Runtime: getRuntime(runArgsMap), Runtime: getRuntime(runArgsMap),
SecurityOpt: getSecurityOpt(runArgsMap), SecurityOpt: mergeSecurityOpts(devcontainerConfig, runArgsMap, features, metadataFromImage),
StorageOpt: getStorageOpt(runArgsMap), StorageOpt: getStorageOpt(runArgsMap),
ShmSize: shmSize, ShmSize: shmSize,
Sysctls: getSysctls(runArgsMap), Sysctls: getSysctls(runArgsMap),
@ -283,6 +372,186 @@ func prepareHostConfig(
return hostConfig, nil 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( func GetContainerInfo(
ctx context.Context, ctx context.Context,
containerName string, containerName string,
@ -542,17 +811,22 @@ func GetContainerResponse(
}, nil }, nil
} }
func GetRemoteUserFromContainerLabel( func GetGitspaceInfoFromContainerLabels(
ctx context.Context, ctx context.Context,
containerName string, containerName string,
dockerClient *client.Client, dockerClient *client.Client,
) (string, error) { ) (string, map[PostAction][]*LifecycleHookStep, error) {
inspectResp, err := dockerClient.ContainerInspect(ctx, containerName) inspectResp, err := dockerClient.ContainerInspect(ctx, containerName)
if err != nil { 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. // Helper function to encode the AuthConfig into a Base64 string.

View File

@ -53,6 +53,13 @@ type step struct {
StopOnFailure bool // Flag to control whether execution should stop on failure 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. // ExecuteSteps executes all registered steps in sequence, respecting stopOnFailure flag.
func (e *EmbeddedDockerOrchestrator) ExecuteSteps( func (e *EmbeddedDockerOrchestrator) ExecuteSteps(
ctx context.Context, ctx context.Context,
@ -181,7 +188,7 @@ func (e *EmbeddedDockerOrchestrator) startStoppedGitspace(
} }
defer e.flushLogStream(logStreamInstance, gitspaceConfig.ID) defer e.flushLogStream(logStreamInstance, gitspaceConfig.ID)
remoteUser, err := GetRemoteUserFromContainerLabel(ctx, containerName, dockerClient) remoteUser, lifecycleHooks, err := GetGitspaceInfoFromContainerLabels(ctx, containerName, dockerClient)
if err != nil { if err != nil {
return fmt.Errorf("error getting remote user for gitspace instance %s: %w", return fmt.Errorf("error getting remote user for gitspace instance %s: %w",
gitspaceConfig.GitspaceInstance.Identifier, err) gitspaceConfig.GitspaceInstance.Identifier, err)
@ -217,13 +224,24 @@ func (e *EmbeddedDockerOrchestrator) startStoppedGitspace(
return err return err
} }
// Execute post-start command if len(lifecycleHooks) > 0 && len(lifecycleHooks[PostStartAction]) > 0 {
devcontainerConfig := resolvedRepoDetails.DevcontainerConfig for _, lifecycleHook := range lifecycleHooks[PostStartAction] {
command := ExtractLifecycleCommands(PostStartAction, devcontainerConfig) startErr = ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, logStreamInstance,
startErr = ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, logStreamInstance, command, PostStartAction) lifecycleHook.Command.ToCommandArray(), PostStartAction)
if startErr != nil { if startErr != nil {
log.Warn().Msgf("Error is post-start command, continuing : %s", startErr.Error()) 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 return nil
} }
@ -422,26 +440,43 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps(
containerUser := GetContainerUser(runArgsMap, devcontainerConfig, metadataFromImage, imageUser) containerUser := GetContainerUser(runArgsMap, devcontainerConfig, metadataFromImage, imageUser)
remoteUser := GetRemoteUser(devcontainerConfig, metadataFromImage, containerUser) 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("Container user: %s", containerUser))
gitspaceLogger.Info(fmt.Sprintf("Remote user: %s", remoteUser)) 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 // Create the container
err = CreateContainer( lifecycleHookSteps, err := CreateContainer(
ctx, ctx,
dockerClient, dockerClient,
imageName, imageName,
containerName, containerName,
gitspaceLogger, gitspaceLogger,
storage, storage,
homeDir, remoteUserHomeDir,
mount.TypeVolume, mount.TypeVolume,
portMappings, portMappings,
environment, environment,
runArgsMap, runArgsMap,
containerUser, containerUser,
remoteUser, remoteUser,
features,
resolvedRepoDetails.DevcontainerConfig,
metadataFromImage,
) )
if err != nil { if err != nil {
return err return err
@ -456,7 +491,7 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps(
exec := &devcontainer.Exec{ exec := &devcontainer.Exec{
ContainerName: containerName, ContainerName: containerName,
DockerClient: dockerClient, DockerClient: dockerClient,
DefaultWorkingDir: homeDir, DefaultWorkingDir: remoteUserHomeDir,
RemoteUser: remoteUser, RemoteUser: remoteUser,
AccessKey: *gitspaceConfig.GitspaceInstance.AccessKey, AccessKey: *gitspaceConfig.GitspaceInstance.AccessKey,
AccessType: gitspaceConfig.GitspaceInstance.AccessType, AccessType: gitspaceConfig.GitspaceInstance.AccessType,
@ -471,6 +506,7 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps(
resolvedRepoDetails, resolvedRepoDetails,
defaultBaseImage, defaultBaseImage,
environment, environment,
lifecycleHookSteps,
); err != nil { ); err != nil {
return logStreamWrapError(gitspaceLogger, "Error while setting up gitspace", err) return logStreamWrapError(gitspaceLogger, "Error while setting up gitspace", err)
} }
@ -478,18 +514,65 @@ func (e *EmbeddedDockerOrchestrator) runGitspaceSetupSteps(
return nil 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. // buildSetupSteps constructs the steps to be executed in the setup process.
func (e *EmbeddedDockerOrchestrator) buildSetupSteps( func (e *EmbeddedDockerOrchestrator) buildSetupSteps(
_ context.Context,
ideService ide.IDE, ideService ide.IDE,
gitspaceConfig types.GitspaceConfig, gitspaceConfig types.GitspaceConfig,
resolvedRepoDetails scm.ResolvedDetails, resolvedRepoDetails scm.ResolvedDetails,
defaultBaseImage string, defaultBaseImage string,
environment []string, environment []string,
devcontainerConfig types.DevcontainerConfig,
codeRepoDir string, codeRepoDir string,
lifecycleHookSteps map[PostAction][]*LifecycleHookStep,
) []step { ) []step {
return []step{ steps := []step{
{ {
Name: "Validate Supported OS", Name: "Validate Supported OS",
Execute: utils.ValidateSupportedOS, Execute: utils.ValidateSupportedOS,
@ -583,33 +666,41 @@ func (e *EmbeddedDockerOrchestrator) buildSetupSteps(
return ideService.Run(ctx, exec, args, gitspaceLogger) return ideService.Run(ctx, exec, args, gitspaceLogger)
}, },
StopOnFailure: true, StopOnFailure: true,
}, }}
// Post-create and Post-start steps
{ // Add the postCreateCommand lifecycle hooks to the steps
Name: "Execute PostCreate Command", for _, lifecycleHook := range lifecycleHookSteps[PostCreateAction] {
steps = append(steps, step{
Name: fmt.Sprintf("Execute postCreateCommand from %s", lifecycleHook.Source),
Execute: func( Execute: func(
ctx context.Context, ctx context.Context,
exec *devcontainer.Exec, exec *devcontainer.Exec,
gitspaceLogger gitspaceTypes.GitspaceLogger, gitspaceLogger gitspaceTypes.GitspaceLogger,
) error { ) error {
command := ExtractLifecycleCommands(PostCreateAction, devcontainerConfig) return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger,
return ExecuteLifecycleCommands(ctx, *exec, codeRepoDir, gitspaceLogger, command, PostCreateAction) lifecycleHook.Command.ToCommandArray(), PostCreateAction)
}, },
StopOnFailure: false, StopOnFailure: lifecycleHook.StopOnFailure,
}, })
{
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,
},
} }
// 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. // setupGitspaceAndIDE initializes Gitspace and IdeType by registering and executing the setup steps.
@ -622,20 +713,20 @@ func (e *EmbeddedDockerOrchestrator) setupGitspaceAndIDE(
resolvedRepoDetails scm.ResolvedDetails, resolvedRepoDetails scm.ResolvedDetails,
defaultBaseImage string, defaultBaseImage string,
environment []string, environment []string,
lifecycleHookSteps map[PostAction][]*LifecycleHookStep,
) error { ) error {
homeDir := GetUserHomeDir(exec.RemoteUser) homeDir := GetUserHomeDir(exec.RemoteUser)
devcontainerConfig := resolvedRepoDetails.DevcontainerConfig
codeRepoDir := filepath.Join(homeDir, resolvedRepoDetails.RepoName) codeRepoDir := filepath.Join(homeDir, resolvedRepoDetails.RepoName)
steps := e.buildSetupSteps( steps := e.buildSetupSteps(
ctx,
ideService, ideService,
gitspaceConfig, gitspaceConfig,
resolvedRepoDetails, resolvedRepoDetails,
defaultBaseImage, defaultBaseImage,
environment, environment,
devcontainerConfig, codeRepoDir,
codeRepoDir) lifecycleHookSteps,
)
// Execute the registered steps // Execute the registered steps
if err := e.ExecuteSteps(ctx, exec, gitspaceLogger, steps); err != nil { if err := e.ExecuteSteps(ctx, exec, gitspaceLogger, steps); err != nil {

View File

@ -159,6 +159,10 @@ func getNetworkMode(runArgsMap map[types.RunArg]*types.RunArgValue) container.Ne
return container.NetworkMode(getArgValueString(runArgsMap, types.RunArgNetwork)) 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 { func getCapDrop(runArgsMap map[types.RunArg]*types.RunArgValue) strslice.StrSlice {
return getArgValueStringSlice(runArgsMap, types.RunArgCapDrop) return getArgValueStringSlice(runArgsMap, types.RunArgCapDrop)
} }
@ -253,6 +257,10 @@ func getAutoRemove(runArgsMap map[types.RunArg]*types.RunArgValue) bool {
return getArgValueBool(runArgsMap, types.RunArgRm) 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 { func getInit(runArgsMap map[types.RunArg]*types.RunArgValue) *bool {
return getArgValueBoolPtr(runArgsMap, types.RunArgInit) return getArgValueBoolPtr(runArgsMap, types.RunArgInit)
} }
@ -329,6 +337,19 @@ func getEntrypoint(runArgsMap map[types.RunArg]*types.RunArgValue) []string {
return getArgValueStringSlice(runArgsMap, types.RunArgEntrypoint) 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) { func getHealthCheckConfig(runArgsMap map[types.RunArg]*types.RunArgValue) (*container.HealthConfig, error) {
var healthConfig = &container.HealthConfig{} var healthConfig = &container.HealthConfig{}

View File

@ -16,6 +16,7 @@ package container
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sync" "sync"
@ -28,9 +29,10 @@ import (
) )
const ( const (
linuxHome = "/home" linuxHome = "/home"
deprecatedRemoteUser = "harness" deprecatedRemoteUser = "harness"
gitspaceRemoteUserLabel = "gitspace.remote.user" gitspaceRemoteUserLabel = "gitspace.remote.user"
gitspaceLifeCycleHooksLabel = "gitspace.lifecycle.hooks"
) )
func GetGitspaceContainerName(config types.GitspaceConfig) string { func GetGitspaceContainerName(config types.GitspaceConfig) string {
@ -85,6 +87,20 @@ func ExtractRemoteUserFromLabels(inspectResp dockerTypes.ContainerJSON) string {
return remoteUser 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. // ExecuteLifecycleCommands executes commands in parallel, logs with numbers, and prefixes all logs.
func ExecuteLifecycleCommands( func ExecuteLifecycleCommands(
ctx context.Context, ctx context.Context,

View File

@ -36,7 +36,7 @@
- name: --cap-add - name: --cap-add
short_hand: short_hand:
supported: false supported: true
blocked_values: { } blocked_values: { }
allowed_values: { } allowed_values: { }
allow_multiple_occurrences: true allow_multiple_occurrences: true
@ -396,6 +396,7 @@
supported: true supported: true
blocked_values: blocked_values:
^gitspace\.remote\.user=: true ^gitspace\.remote\.user=: true
^gitspace\.lifecycle\.hooks=: true
allowed_values: { } allowed_values: { }
allow_multiple_occurrences: true allow_multiple_occurrences: true
@ -471,7 +472,7 @@
- name: --mount - name: --mount
short_hand: short_hand:
supported: false supported: true
blocked_values: { } blocked_values: { }
allowed_values: { } allowed_values: { }
allow_multiple_occurrences: true allow_multiple_occurrences: true
@ -543,7 +544,7 @@
- name: --privileged - name: --privileged
short_hand: short_hand:
supported: false supported: true
blocked_values: { } blocked_values: { }
allowed_values: { } allowed_values: { }
allow_multiple_occurrences: true allow_multiple_occurrences: true

View File

@ -172,7 +172,12 @@ func generateDockerFileWithFeatures(
containerUserHomeDir string, containerUserHomeDir string,
remoteUserHomeDir string, remoteUserHomeDir string,
) error { ) 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, imageName, convertOptionsToEnvVariables("_CONTAINER_USER"), containerUser,
convertOptionsToEnvVariables("_REMOTE_USER"), remoteUser, convertOptionsToEnvVariables("_REMOTE_USER"), remoteUser,
convertOptionsToEnvVariables("_CONTAINER_USER_HOME"), containerUserHomeDir, convertOptionsToEnvVariables("_CONTAINER_USER_HOME"), containerUserHomeDir,

View File

@ -68,21 +68,24 @@ func DownloadFeatures(
downloadQueue <- featureSource{sourceURL: key, sourceType: value.SourceType} 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. // 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. // 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 { for source := range downloadQueue {
startCh <- 1 select {
go func(source featureSource) { case <-ctx.Done():
defer func(endCh chan int) { endCh <- 1 }(endCh) return
err := downloadFeature(ctx, gitspaceInstanceIdentifier, &source, &featuresToBeDownloaded, default:
downloadQueue, &downloadedFeatures) startCh <- 1
errorCh <- err go func(source featureSource) {
}(source) defer func(endCh chan int) { endCh <- 1 }(endCh)
err := downloadFeature(ctx, gitspaceInstanceIdentifier, &source, &featuresToBeDownloaded,
downloadQueue, &downloadedFeatures)
errorCh <- err
}(source)
}
} }
}() }(ctx)
var totalStart int var totalStart int
var totalEnd int var totalEnd int
@ -90,6 +93,8 @@ func DownloadFeatures(
waitLoop: waitLoop:
for { for {
select { select {
case <-ctx.Done():
return nil, ctx.Err()
case start := <-startCh: case start := <-startCh:
totalStart += start totalStart += start
case end := <-endCh: case end := <-endCh:
@ -116,6 +121,7 @@ waitLoop:
close(startCh) close(startCh)
close(endCh) close(endCh)
close(downloadQueue) close(downloadQueue)
close(errorCh)
if downloadError != nil { if downloadError != nil {
return nil, downloadError return nil, downloadError

View File

@ -15,9 +15,11 @@
package types package types
import ( import (
"encoding/csv"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"path/filepath"
"strings" "strings"
"github.com/harness/gitness/types/enum" "github.com/harness/gitness/types/enum"
@ -40,8 +42,8 @@ type DevcontainerConfig struct {
RemoteUser string `json:"remoteUser,omitempty"` RemoteUser string `json:"remoteUser,omitempty"`
Features *Features `json:"features,omitempty"` Features *Features `json:"features,omitempty"`
OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"`
Privileged bool `json:"privileged,omitempty"` Privileged *bool `json:"privileged,omitempty"`
Init bool `json:"init,omitempty"` Init *bool `json:"init,omitempty"`
CapAdd []string `json:"capAdd,omitempty"` CapAdd []string `json:"capAdd,omitempty"`
SecurityOpt []string `json:"securityOpt,omitempty"` SecurityOpt []string `json:"securityOpt,omitempty"`
Mounts []*Mount `json:"mounts,omitempty"` Mounts []*Mount `json:"mounts,omitempty"`
@ -246,11 +248,79 @@ type Mount struct {
} }
func (m *Mount) UnmarshalJSON(data []byte) error { func (m *Mount) UnmarshalJSON(data []byte) error {
// TODO: Add support for unmarshalling mount data from a string input if err := json.Unmarshal(data, m); err == nil {
var mount Mount return nil
err := json.Unmarshal(data, &mount) }
dst, err := stringToObject(string(data))
if err != nil { if err != nil {
return err return err
} }
*m = *dst
return nil 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
}

View File

@ -82,6 +82,9 @@ const (
RunArgSysctl = RunArg("--sysctl") RunArgSysctl = RunArg("--sysctl")
RunArgUlimit = RunArg("--ulimit") RunArgUlimit = RunArg("--ulimit")
RunArgUser = RunArg("--user") RunArgUser = RunArg("--user")
RunArgPrivileged = RunArg("--privileged")
RunArgCapAdd = RunArg("--cap-add")
RunArgMount = RunArg("--mount")
) )
type RunArgDefinition struct { type RunArgDefinition struct {