container wait: support health states

Support two new wait conditions, "healthy" and "unhealthy".  This
further paves the way for integrating sdnotify with health checks which
is currently being tracked in #6160.

Fixes: #13627
Signed-off-by: Valentin Rothberg <vrothberg@redhat.com>
This commit is contained in:
Valentin Rothberg 2023-06-22 10:47:24 +02:00
parent 811867249b
commit 1398cbce8a
10 changed files with 137 additions and 47 deletions

View File

@ -1474,7 +1474,9 @@ func AutocompleteImageSaveFormat(cmd *cobra.Command, args []string, toComplete s
// AutocompleteWaitCondition - Autocomplete wait condition options. // AutocompleteWaitCondition - Autocomplete wait condition options.
// -> "unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing" // -> "unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing"
func AutocompleteWaitCondition(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { func AutocompleteWaitCondition(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
states := []string{"unknown", "configured", "created", "running", "stopped", "paused", "exited", "removing"} states := []string{"unknown", "configured", "created", "exited",
"healthy", "initialized", "paused", "removing", "running",
"stopped", "stopping", "unhealthy"}
return states, cobra.ShellCompDirectiveNoFileComp return states, cobra.ShellCompDirectiveNoFileComp
} }

View File

@ -24,7 +24,7 @@ with different exit codes, but `podman wait` can only display and detect one.
## OPTIONS ## OPTIONS
#### **--condition**=*state* #### **--condition**=*state*
Container state or condition to wait for. Can be specified multiple times where at least one condition must match for the command to return. Supported values are "created", "exited", "initialized", "paused", "removing", "running", "stopped", "stopping". The default condition is "stopped". Container state or condition to wait for. Can be specified multiple times where at least one condition must match for the command to return. Supported values are "configured", "created", "exited", "healthy", "initialized", "paused", "removing", "running", "stopped", "stopping", "unhealthy". The default condition is "stopped".
#### **--help**, **-h** #### **--help**, **-h**

View File

@ -698,8 +698,16 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou
resultChan := make(chan waitResult) resultChan := make(chan waitResult)
waitForExit := false waitForExit := false
wantedStates := make(map[define.ContainerStatus]bool, len(conditions)) wantedStates := make(map[define.ContainerStatus]bool, len(conditions))
wantedHealthStates := make(map[string]bool)
for _, rawCondition := range conditions { for _, rawCondition := range conditions {
switch rawCondition {
case define.HealthCheckHealthy, define.HealthCheckUnhealthy:
if !c.HasHealthCheck() {
return -1, fmt.Errorf("cannot use condition %q: container %s has no healthcheck", rawCondition, c.ID())
}
wantedHealthStates[rawCondition] = true
default:
condition, err := define.StringToContainerStatus(rawCondition) condition, err := define.StringToContainerStatus(rawCondition)
if err != nil { if err != nil {
return -1, err return -1, err
@ -711,6 +719,7 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou
wantedStates[condition] = true wantedStates[condition] = true
} }
} }
}
trySend := func(code int32, err error) { trySend := func(code int32, err error) {
select { select {
@ -731,12 +740,13 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou
}() }()
} }
if len(wantedStates) > 0 { if len(wantedStates) > 0 || len(wantedHealthStates) > 0 {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for { for {
if len(wantedStates) > 0 {
state, err := c.State() state, err := c.State()
if err != nil { if err != nil {
trySend(-1, err) trySend(-1, err)
@ -746,6 +756,18 @@ func (c *Container) WaitForConditionWithInterval(ctx context.Context, waitTimeou
trySend(-1, nil) trySend(-1, nil)
return return
} }
}
if len(wantedHealthStates) > 0 {
status, err := c.HealthCheckStatus()
if err != nil {
trySend(-1, err)
return
}
if _, found := wantedHealthStates[status]; found {
trySend(-1, nil)
return
}
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return

View File

@ -29,7 +29,7 @@ type waitQueryDocker struct {
type waitQueryLibpod struct { type waitQueryLibpod struct {
Interval string `schema:"interval"` Interval string `schema:"interval"`
Condition []define.ContainerStatus `schema:"condition"` Conditions []string `schema:"condition"`
} }
func WaitContainerDocker(w http.ResponseWriter, r *http.Request) { func WaitContainerDocker(w http.ResponseWriter, r *http.Request) {
@ -118,19 +118,27 @@ func WaitContainerLibpod(w http.ResponseWriter, r *http.Request) {
} }
} }
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
containerEngine := &abi.ContainerEngine{Libpod: runtime}
opts := entities.WaitOptions{
Conditions: query.Conditions,
Interval: interval,
}
name := GetName(r) name := GetName(r)
reports, err := containerEngine.ContainerWait(r.Context(), []string{name}, opts)
waitFn := createContainerWaitFn(r.Context(), name, interval)
exitCode, err := waitFn(query.Condition...)
if err != nil { if err != nil {
if errors.Is(err, define.ErrNoSuchCtr) { if errors.Is(err, define.ErrNoSuchCtr) {
ContainerNotFound(w, name, err) ContainerNotFound(w, name, err)
return return
} }
InternalServerError(w, err) InternalServerError(w, err)
}
if len(reports) != 1 {
Error(w, http.StatusInternalServerError, fmt.Errorf("the ContainerWait() function returned unexpected count of reports: %d", len(reports)))
return return
} }
WriteResponse(w, http.StatusOK, strconv.Itoa(int(exitCode)))
WriteResponse(w, http.StatusOK, strconv.Itoa(int(reports[0].ExitCode)))
} }
type containerWaitFn func(conditions ...define.ContainerStatus) (int32, error) type containerWaitFn func(conditions ...define.ContainerStatus) (int32, error)

View File

@ -1229,12 +1229,15 @@ func (s *APIServer) registerContainersHandlers(r *mux.Router) error {
// enum: // enum:
// - configured // - configured
// - created // - created
// - exited
// - healthy
// - initialized
// - paused
// - removing
// - running // - running
// - stopped // - stopped
// - paused
// - exited
// - removing
// - stopping // - stopping
// - unhealthy
// description: "Conditions to wait for. If no condition provided the 'exited' condition is assumed." // description: "Conditions to wait for. If no condition provided the 'exited' condition is assumed."
// - in: query // - in: query
// name: interval // name: interval

View File

@ -341,6 +341,8 @@ func Unpause(ctx context.Context, nameOrID string, options *UnpauseOptions) erro
func Wait(ctx context.Context, nameOrID string, options *WaitOptions) (int32, error) { func Wait(ctx context.Context, nameOrID string, options *WaitOptions) (int32, error) {
if options == nil { if options == nil {
options = new(WaitOptions) options = new(WaitOptions)
} else if len(options.Condition) > 0 && len(options.Conditions) > 0 {
return -1, fmt.Errorf("%q field cannot be used with deprecated %q field", "Conditions", "Condition")
} }
var exitCode int32 var exitCode int32
conn, err := bindings.GetClient(ctx) conn, err := bindings.GetClient(ctx)
@ -351,6 +353,7 @@ func Wait(ctx context.Context, nameOrID string, options *WaitOptions) (int32, er
if err != nil { if err != nil {
return exitCode, err return exitCode, err
} }
delete(params, "conditions") // They're called "condition"
response, err := conn.DoRequest(ctx, nil, http.MethodPost, "/containers/%s/wait", params, nil, nameOrID) response, err := conn.DoRequest(ctx, nil, http.MethodPost, "/containers/%s/wait", params, nil, nameOrID)
if err != nil { if err != nil {
return exitCode, err return exitCode, err

View File

@ -229,8 +229,14 @@ type UnpauseOptions struct{}
// //
//go:generate go run ../generator/generator.go WaitOptions //go:generate go run ../generator/generator.go WaitOptions
type WaitOptions struct { type WaitOptions struct {
Condition []define.ContainerStatus // Conditions to wait on. Includes container statuses such as
// "running" or "stopped" and health-related values such "healthy".
Conditions []string `schema:"condition"`
// Time interval to wait before polling for completion.
Interval *string Interval *string
// Container status to wait on.
// Deprecated: use Conditions instead.
Condition []define.ContainerStatus
} }
// StopOptions are optional options for stopping containers // StopOptions are optional options for stopping containers

View File

@ -18,19 +18,19 @@ func (o *WaitOptions) ToParams() (url.Values, error) {
return util.ToParams(o) return util.ToParams(o)
} }
// WithCondition set field Condition to given value // WithConditions set field Conditions to given value
func (o *WaitOptions) WithCondition(value []define.ContainerStatus) *WaitOptions { func (o *WaitOptions) WithConditions(value []string) *WaitOptions {
o.Condition = value o.Conditions = value
return o return o
} }
// GetCondition returns value of field Condition // GetConditions returns value of field Conditions
func (o *WaitOptions) GetCondition() []define.ContainerStatus { func (o *WaitOptions) GetConditions() []string {
if o.Condition == nil { if o.Conditions == nil {
var z []define.ContainerStatus var z []string
return z return z
} }
return o.Condition return o.Conditions
} }
// WithInterval set field Interval to given value // WithInterval set field Interval to given value
@ -47,3 +47,18 @@ func (o *WaitOptions) GetInterval() string {
} }
return *o.Interval return *o.Interval
} }
// WithCondition set field Condition to given value
func (o *WaitOptions) WithCondition(value []define.ContainerStatus) *WaitOptions {
o.Condition = value
return o
}
// GetCondition returns value of field Condition
func (o *WaitOptions) GetCondition() []define.ContainerStatus {
if o.Condition == nil {
var z []define.ContainerStatus
return z
}
return o.Condition
}

View File

@ -38,17 +38,8 @@ func (ic *ContainerEngine) ContainerExists(ctx context.Context, nameOrID string,
} }
func (ic *ContainerEngine) ContainerWait(ctx context.Context, namesOrIds []string, opts entities.WaitOptions) ([]entities.WaitReport, error) { func (ic *ContainerEngine) ContainerWait(ctx context.Context, namesOrIds []string, opts entities.WaitOptions) ([]entities.WaitReport, error) {
conditions := make([]define.ContainerStatus, 0, len(opts.Conditions))
for _, condition := range opts.Conditions {
cond, err := define.StringToContainerStatus(condition)
if err != nil {
return nil, err
}
conditions = append(conditions, cond)
}
responses := make([]entities.WaitReport, 0, len(namesOrIds)) responses := make([]entities.WaitReport, 0, len(namesOrIds))
options := new(containers.WaitOptions).WithCondition(conditions).WithInterval(opts.Interval.String()) options := new(containers.WaitOptions).WithConditions(opts.Conditions).WithInterval(opts.Interval.String())
for _, n := range namesOrIds { for _, n := range namesOrIds {
response := entities.WaitReport{} response := entities.WaitReport{}
exitCode, err := containers.Wait(ic.ClientCtx, n, options) exitCode, err := containers.Wait(ic.ClientCtx, n, options)

View File

@ -83,7 +83,7 @@ Log[-1].Output | \"Uh-oh on stdout!\\\nUh-oh on stderr!\"
_build_health_check_image $img cleanfile _build_health_check_image $img cleanfile
run_podman run -d --name $ctr \ run_podman run -d --name $ctr \
--health-cmd /healthcheck \ --health-cmd /healthcheck \
--health-retries=2 \ --health-retries=3 \
--health-interval=disable \ --health-interval=disable \
$img $img
@ -105,6 +105,46 @@ Log[-1].Output | \"Uh-oh on stdout!\\\nUh-oh on stderr!\"
run_podman rmi $img run_podman rmi $img
} }
@test "podman wait --condition={healthy,unhealthy}" {
ctr="healthcheck_c"
img="healthcheck_i"
wait_file="$PODMAN_TMPDIR/$(random_string).wait_for_me"
_build_health_check_image $img
for condition in healthy unhealthy;do
rm -f $wait_file
run_podman run -d --name $ctr \
--health-cmd /healthcheck \
--health-retries=1 \
--health-interval=disable \
$img
if [[ $condition == "unhealthy" ]];then
# create the uh-oh file to let the health check fail
run_podman exec $ctr touch /uh-oh
fi
# Wait for the container in the background and create the $wait_file to
# signal the specified wait condition was met.
(timeout --foreground -v --kill=5 5 $PODMAN wait --condition=$condition $ctr && touch $wait_file) &
# Sleep 1 second to make sure above commands are running
sleep 1
if [[ -f $wait_file ]]; then
die "the wait file should only be created after the container turned healthy"
fi
if [[ $condition == "healthy" ]];then
run_podman healthcheck run $ctr
else
run_podman 1 healthcheck run $ctr
fi
wait_for_file $wait_file
run_podman rm -f -t0 $ctr
done
run_podman rmi $img
}
@test "podman healthcheck --health-on-failure" { @test "podman healthcheck --health-on-failure" {
run_podman 125 create --health-on-failure=kill $IMAGE run_podman 125 create --health-on-failure=kill $IMAGE
is "$output" "Error: cannot set on-failure action to kill without a health check" is "$output" "Error: cannot set on-failure action to kill without a health check"