Skip to content

postStart hook commands timeout #1440

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
613d0e6
feat: timeout the postStart hook commands
akurinnoy May 22, 2025
f6e8bc0
chore: run make update_devworkspace_api update_devworkspace_crds gene…
akurinnoy May 22, 2025
0c1c3e4
test: add/update tests related to postStart hook commands
akurinnoy May 28, 2025
4a999c8
fixup! chore: run make update_devworkspace_api update_devworkspace_cr…
akurinnoy May 29, 2025
095b2f2
fixup! test: add/update tests related to postStart hook commands
akurinnoy May 29, 2025
c0ee548
fixup! feat: timeout the postStart hook commands
akurinnoy May 30, 2025
7d064b9
fixup! fixup! test: add/update tests related to postStart hook commands
akurinnoy May 30, 2025
838b06b
fixup! fixup! feat: timeout the postStart hook commands
akurinnoy Jun 3, 2025
d50e932
fixup! fixup! fixup! test: add/update tests related to postStart hook…
akurinnoy Jun 3, 2025
95baeb8
fixup! fixup! fixup! feat: timeout the postStart hook commands
akurinnoy Jun 3, 2025
732bd4e
fixup! fixup! fixup! fixup! test: add/update tests related to postSta…
akurinnoy Jun 3, 2025
c090c8a
fix: fall back to the original behaviour if timeout is disabled
akurinnoy Jul 10, 2025
f356edf
fix: add postStartTimeout
akurinnoy Jul 14, 2025
3b0e379
fix: remove default postStartTimeout value
akurinnoy Jul 14, 2025
d2532e2
fixup! fix: remove default postStartTimeout value
akurinnoy Jul 14, 2025
2813408
fixup! fix: fall back to the original behaviour if timeout is disabled
akurinnoy Jul 16, 2025
443872c
fixup! fixup! fix: fall back to the original behaviour if timeout is …
akurinnoy Jul 16, 2025
0dddc52
fixup! fixup! fixup! fix: fall back to the original behaviour if time…
akurinnoy Jul 28, 2025
14629eb
fix: support env var for postStart commands
akurinnoy Jul 31, 2025
affe6c5
fix: change type `*int32` to `string`
akurinnoy Aug 4, 2025
2d2ad37
fixup! fix: change type `*int32` to `string`
akurinnoy Aug 4, 2025
7272e1d
fixup! fixup! fix: change type `*int32` to `string`
akurinnoy Aug 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ type WorkspaceConfig struct {
RuntimeClassName *string `json:"runtimeClassName,omitempty"`
// CleanupCronJobConfig defines configuration options for a cron job that automatically cleans up stale DevWorkspaces.
CleanupCronJob *CleanupCronJobConfig `json:"cleanupCronJob,omitempty"`
// PostStartTimeout defines the maximum duration the PostStart hook can run
// before it is automatically failed. This timeout is used for the postStart lifecycle hook
// that is used to run commands in the workspace container. The timeout is specified in seconds.
// Duration should be specified in a format parseable by Go's time package, e.g. "20s", "2m".
// If not specified or "0", the timeout is disabled.
// +kubebuilder:validation:Optional
PostStartTimeout string `json:"postStartTimeout,omitempty"`
}

type WebhookConfig struct {
Expand Down
4 changes: 3 additions & 1 deletion controllers/workspace/devworkspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
&workspace.Spec.Template,
workspace.Config.Workspace.ContainerSecurityContext,
workspace.Config.Workspace.ImagePullPolicy,
workspace.Config.Workspace.DefaultContainerResources)
workspace.Config.Workspace.DefaultContainerResources,
workspace.Config.Workspace.PostStartTimeout,
)
if err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Error processing devfile: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions deploy/deployment/kubernetes/combined.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions deploy/deployment/openshift/combined.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions pkg/config/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ func mergeConfig(from, to *controller.OperatorConfiguration) {
to.Workspace.CleanupCronJob.Schedule = from.Workspace.CleanupCronJob.Schedule
}
}

if from.Workspace.PostStartTimeout != "" {
to.Workspace.PostStartTimeout = from.Workspace.PostStartTimeout
}
}
}

Expand Down Expand Up @@ -601,6 +605,9 @@ func GetCurrentConfigString(currConfig *controller.OperatorConfiguration) string
if workspace.IdleTimeout != defaultConfig.Workspace.IdleTimeout {
config = append(config, fmt.Sprintf("workspace.idleTimeout=%s", workspace.IdleTimeout))
}
if workspace.PostStartTimeout != defaultConfig.Workspace.PostStartTimeout {
config = append(config, fmt.Sprintf("workspace.postStartTimeout=%s", workspace.PostStartTimeout))
}
if workspace.ProgressTimeout != "" && workspace.ProgressTimeout != defaultConfig.Workspace.ProgressTimeout {
config = append(config, fmt.Sprintf("workspace.progressTimeout=%s", workspace.ProgressTimeout))
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/library/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import (
// rewritten as Volumes are added to PodAdditions, in order to support e.g. using one PVC to hold all volumes
//
// Note: Requires DevWorkspace to be flattened (i.e. the DevWorkspace contains no Parent or Components of type Plugin)
func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, securityContext *corev1.SecurityContext, pullPolicy string, defaultResources *corev1.ResourceRequirements) (*v1alpha1.PodAdditions, error) {
func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, securityContext *corev1.SecurityContext, pullPolicy string, defaultResources *corev1.ResourceRequirements, postStartTimeout string) (*v1alpha1.PodAdditions, error) {
if !flatten.DevWorkspaceIsFlattened(workspace, nil) {
return nil, fmt.Errorf("devfile is not flattened")
}
Expand Down Expand Up @@ -77,7 +77,7 @@ func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, securi
podAdditions.Containers = append(podAdditions.Containers, *k8sContainer)
}

if err := lifecycle.AddPostStartLifecycleHooks(workspace, podAdditions.Containers); err != nil {
if err := lifecycle.AddPostStartLifecycleHooks(workspace, podAdditions.Containers, postStartTimeout); err != nil {
return nil, err
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/library/container/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestGetKubeContainersFromDevfile(t *testing.T) {
t.Run(tt.Name, func(t *testing.T) {
// sanity check that file is read correctly.
assert.True(t, len(tt.Input.Components) > 0, "Input defines no components")
gotPodAdditions, err := GetKubeContainersFromDevfile(tt.Input, nil, testImagePullPolicy, defaultResources)
gotPodAdditions, err := GetKubeContainersFromDevfile(tt.Input, nil, testImagePullPolicy, defaultResources, "")
if tt.Output.ErrRegexp != nil && assert.Error(t, err) {
assert.Regexp(t, *tt.Output.ErrRegexp, err.Error(), "Error message should match")
} else {
Expand Down
148 changes: 142 additions & 6 deletions pkg/library/lifecycle/poststart.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,32 @@ package lifecycle
import (
"fmt"
"strings"
"time"

dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/log"
)

const redirectOutputFmt = `{
const (
// `tee` both stdout and stderr to files and to the main output streams.
redirectOutputFmt = `{
# This script block ensures its exit code is preserved
# while its stdout and stderr are tee'd.
_script_to_run() {
%s # This will be replaced by scriptWithTimeout
}
_script_to_run
} 1> >(tee -a "/tmp/poststart-stdout.txt") 2> >(tee -a "/tmp/poststart-stderr.txt" >&2)
`

noTimeoutRedirectOutputFmt = `{
%s
} 1>/tmp/poststart-stdout.txt 2>/tmp/poststart-stderr.txt
`
)

func AddPostStartLifecycleHooks(wksp *dw.DevWorkspaceTemplateSpec, containers []corev1.Container) error {
func AddPostStartLifecycleHooks(wksp *dw.DevWorkspaceTemplateSpec, containers []corev1.Container, postStartTimeout string) error {
if wksp.Events == nil || len(wksp.Events.PostStart) == 0 {
return nil
}
Expand Down Expand Up @@ -54,7 +69,7 @@ func AddPostStartLifecycleHooks(wksp *dw.DevWorkspaceTemplateSpec, containers []
return fmt.Errorf("failed to process postStart event %s: %w", commands[0].Id, err)
}

postStartHandler, err := processCommandsForPostStart(commands)
postStartHandler, err := processCommandsForPostStart(commands, postStartTimeout)
if err != nil {
return fmt.Errorf("failed to process postStart event %s: %w", commands[0].Id, err)
}
Expand All @@ -68,7 +83,41 @@ func AddPostStartLifecycleHooks(wksp *dw.DevWorkspaceTemplateSpec, containers []
return nil
}

// processCommandsForPostStart builds a lifecycle handler that runs the provided command(s)
// processCommandsForPostStart processes a list of DevWorkspace commands
// and generates a corev1.LifecycleHandler for the PostStart lifecycle hook.
func processCommandsForPostStart(commands []dw.Command, postStartTimeout string) (*corev1.LifecycleHandler, error) {
if postStartTimeout == "" {
// use the fallback if no timeout propagated
return processCommandsWithoutTimeoutFallback(commands)
}

originalUserScript, err := buildUserScript(commands)
if err != nil {
return nil, fmt.Errorf("failed to build aggregated user script: %w", err)
}

// The user script needs 'set -e' to ensure it exits on error.
// This script is then passed to `sh -c '...'`, so single quotes within it must be escaped.
scriptToExecute := "set -e\n" + originalUserScript
escapedUserScriptForTimeoutWrapper := strings.ReplaceAll(scriptToExecute, "'", `'\''`)

fullScriptWithTimeout := generateScriptWithTimeout(escapedUserScriptForTimeoutWrapper, postStartTimeout)

finalScriptForHook := fmt.Sprintf(redirectOutputFmt, fullScriptWithTimeout)

handler := &corev1.LifecycleHandler{
Exec: &corev1.ExecAction{
Command: []string{
"/bin/sh",
"-c",
finalScriptForHook,
},
},
}
return handler, nil
}

// processCommandsWithoutTimeoutFallback builds a lifecycle handler that runs the provided command(s)
// The command has the format
//
// exec:
Expand All @@ -79,7 +128,7 @@ func AddPostStartLifecycleHooks(wksp *dw.DevWorkspaceTemplateSpec, containers []
// - |
// cd <workingDir>
// <commandline>
func processCommandsForPostStart(commands []dw.Command) (*corev1.LifecycleHandler, error) {
func processCommandsWithoutTimeoutFallback(commands []dw.Command) (*corev1.LifecycleHandler, error) {
var dwCommands []string
for _, command := range commands {
execCmd := command.Exec
Expand All @@ -99,9 +148,96 @@ func processCommandsForPostStart(commands []dw.Command) (*corev1.LifecycleHandle
Command: []string{
"/bin/sh",
"-c",
fmt.Sprintf(redirectOutputFmt, joinedCommands),
fmt.Sprintf(noTimeoutRedirectOutputFmt, joinedCommands),
},
},
}
return handler, nil
}

// buildUserScript takes a list of DevWorkspace commands and constructs a single
// shell script string that executes them sequentially.
func buildUserScript(commands []dw.Command) (string, error) {
var commandScriptLines []string
for _, command := range commands {
execCmd := command.Exec
if execCmd == nil {
// Should be caught by earlier validation, but good to be safe
return "", fmt.Errorf("exec command is nil for command ID %s", command.Id)
}
var singleCommandParts []string
for _, envVar := range execCmd.Env {
singleCommandParts = append(singleCommandParts, fmt.Sprintf("export %s=%q", envVar.Name, envVar.Value))
}

if execCmd.WorkingDir != "" {
singleCommandParts = append(singleCommandParts, fmt.Sprintf("cd %q", execCmd.WorkingDir))
}
if execCmd.CommandLine != "" {
singleCommandParts = append(singleCommandParts, execCmd.CommandLine)
}
if len(singleCommandParts) > 0 {
commandScriptLines = append(commandScriptLines, strings.Join(singleCommandParts, " && "))
}
}
return strings.Join(commandScriptLines, "\n"), nil
}

// generateScriptWithTimeout wraps a given user script with timeout logic,
// environment variable exports, and specific exit code handling.
// The killAfterDurationSeconds is hardcoded to 5s within this generated script.
// It conditionally prefixes the user script with the timeout command if available.
func generateScriptWithTimeout(escapedUserScript string, postStartTimeout string) string {
// Convert `postStartTimeout` into the `timeout` format
var timeoutSeconds int64
if postStartTimeout != "" && postStartTimeout != "0" {
duration, err := time.ParseDuration(postStartTimeout)
if err != nil {
log.Log.Error(err, "Could not parse post-start timeout, disabling timeout", "value", postStartTimeout)
timeoutSeconds = 0
} else {
timeoutSeconds = int64(duration.Seconds())
}
}

return fmt.Sprintf(`
export POSTSTART_TIMEOUT_DURATION="%d"
export POSTSTART_KILL_AFTER_DURATION="5"

_TIMEOUT_COMMAND_PART=""
_WAS_TIMEOUT_USED="false" # Use strings "true" or "false" for shell boolean

if command -v timeout >/dev/null 2>&1; then
echo "[postStart hook] Executing commands with timeout: ${POSTSTART_TIMEOUT_DURATION} seconds, kill after: ${POSTSTART_KILL_AFTER_DURATION} seconds" >&2
_TIMEOUT_COMMAND_PART="timeout --preserve-status --kill-after=${POSTSTART_KILL_AFTER_DURATION} ${POSTSTART_TIMEOUT_DURATION}"
_WAS_TIMEOUT_USED="true"
else
echo "[postStart hook] WARNING: 'timeout' utility not found. Executing commands without timeout." >&2
fi

# Execute the user's script
${_TIMEOUT_COMMAND_PART} /bin/sh -c '%s'
exit_code=$?

# Check the exit code based on whether timeout was attempted
if [ "$_WAS_TIMEOUT_USED" = "true" ]; then
if [ $exit_code -eq 143 ]; then # 128 + 15 (SIGTERM)
echo "[postStart hook] Commands terminated by SIGTERM (likely timed out after ${POSTSTART_TIMEOUT_DURATION}s). Exit code 143." >&2
elif [ $exit_code -eq 137 ]; then # 128 + 9 (SIGKILL)
echo "[postStart hook] Commands forcefully killed by SIGKILL (likely after --kill-after ${POSTSTART_KILL_AFTER_DURATION}s expired). Exit code 137." >&2
elif [ $exit_code -ne 0 ]; then # Catches any other non-zero exit code
echo "[postStart hook] Commands failed with exit code $exit_code." >&2
else
echo "[postStart hook] Commands completed successfully within the time limit." >&2
fi
else
if [ $exit_code -ne 0 ]; then
echo "[postStart hook] Commands failed with exit code $exit_code (no timeout)." >&2
else
echo "[postStart hook] Commands completed successfully (no timeout)." >&2
fi
fi

exit $exit_code
`, timeoutSeconds, escapedUserScript)
}
Loading
Loading