mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
Three tests failed 9 CI runs across 5 branches this week, all on asynchronous-observation races, none reproducible locally: - RequireServiceState asserted on a single `compose ps` snapshot; the daemon reports state transitions asynchronously from everything else a test observes (TestUpDependenciesNotStopped saw 'created' while the container's logs were already flowing). It now polls until the state converges (15s bound). - TestAttachRestart counted restart log lines in a snapshot taken as soon as the third exit notice appeared; exit notices come from the events monitor while log lines come from the re-attached logs stream — two channels with no ordering between them. The count is now awaited like the exit notices already were; a genuinely lost line still fails, by timeout. - TestUpExitCodeFromContainerKilled ran a full up+abort cycle under a 60s ceiling, once exceeded on a loaded oldstable runner; raised to 120s. Signed-off-by: Nicolas De Loof <[email protected]>
126 lines
5.3 KiB
Go
126 lines
5.3 KiB
Go
/*
|
|
Copyright 2022 Docker Compose CLI authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gotest.tools/v3/poll"
|
|
)
|
|
|
|
// RequireServiceState ensures that the container reaches the expected state
|
|
// (running or exited). The daemon reports state transitions asynchronously
|
|
// from everything else a test can observe (a container whose logs already
|
|
// flowed may still be listed under its previous state for a moment), so the
|
|
// check polls `compose ps` until the state converges instead of asserting on
|
|
// a single snapshot.
|
|
func RequireServiceState(t testing.TB, cli *CLI, service string, state string) {
|
|
t.Helper()
|
|
poll.WaitOn(t, func(poll.LogT) poll.Result {
|
|
// NoCheck: a non-zero `compose ps` is a transient state here (the
|
|
// project may not be registered yet) — and the asserting variant
|
|
// would t.FailNow() from the poll goroutine, which terminates it via
|
|
// runtime.Goexit without reporting: the poll would hang until its
|
|
// opaque timeout instead of surfacing the actual failure below.
|
|
psRes := cli.RunDockerComposeCmdNoCheck(t, "ps", "--all", "--format=json", service)
|
|
if psRes.ExitCode != 0 {
|
|
return poll.Continue("`compose ps %s` exited %d: %s", service, psRes.ExitCode, psRes.Combined())
|
|
}
|
|
out := strings.TrimSpace(psRes.Stdout())
|
|
if out == "" {
|
|
// The container is not registered yet (creation in progress):
|
|
// transient, keep polling.
|
|
return poll.Continue("service %q has no `compose ps` entry yet", service)
|
|
}
|
|
// --format=json emits one JSON object per line, and a service can
|
|
// briefly list two containers mid-transition (the old one being
|
|
// removed, its replacement being created). Succeed as soon as one
|
|
// entry of the target service reaches the expected state; everything
|
|
// short of malformed JSON is a transient condition to retry, not a
|
|
// hard failure — hard-failing on those is the exact race this helper
|
|
// exists to absorb.
|
|
var seen []string
|
|
for line := range strings.SplitSeq(out, "\n") {
|
|
var entry map[string]any
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
return poll.Error(fmt.Errorf("invalid `compose ps` JSON: %w: command output: %s", err, psRes.Combined()))
|
|
}
|
|
if name, _ := entry["Service"].(string); name != service {
|
|
// ps was invoked filtered on the service name; a foreign or
|
|
// incomplete entry is transient noise.
|
|
continue
|
|
}
|
|
current, _ := entry["State"].(string)
|
|
if strings.EqualFold(state, current) {
|
|
return poll.Success()
|
|
}
|
|
seen = append(seen, current)
|
|
}
|
|
return poll.Continue("service %q is %v, expected %q", service, seen, state)
|
|
}, poll.WithTimeout(15*time.Second), poll.WithDelay(200*time.Millisecond))
|
|
}
|
|
|
|
// RequireEventuallyServiceState polls `compose ps` until the service reaches
|
|
// the expected state, instead of checking once.
|
|
//
|
|
// `compose ps` makes a fresh ContainerList call to the daemon, an entirely
|
|
// different channel than a container's log stream: an application log line
|
|
// already relayed to a caller is no guarantee the daemon-reported state
|
|
// has caught up yet by the time that caller turns around and calls `ps` in a
|
|
// new process. Callers observing readiness through a log line (or any signal
|
|
// other than this same `ps` state) must poll here rather than check once.
|
|
func RequireEventuallyServiceState(t testing.TB, cli *CLI, service string, state string) {
|
|
t.Helper()
|
|
check := func(poll.LogT) poll.Result {
|
|
psRes := cli.RunDockerComposeCmdNoCheck(t, "ps", "--all", "--format=json", service)
|
|
if psRes.ExitCode != 0 {
|
|
return poll.Continue("compose ps exited %d: %s", psRes.ExitCode, psRes.Combined())
|
|
}
|
|
// --format=json prints one JSON object per line (NDJSON), not a
|
|
// single object or array: a scaled service, or a transient window
|
|
// during recreation where the old and new containers are both
|
|
// listed, means more than one line for the requested service — every
|
|
// one of them must reach the expected state, not just the first.
|
|
var found bool
|
|
for _, line := range strings.Split(strings.TrimSpace(psRes.Stdout()), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var entry map[string]any
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
return poll.Error(fmt.Errorf("invalid `compose ps` JSON line %q: %w", line, err))
|
|
}
|
|
if svc, _ := entry["Service"].(string); !strings.EqualFold(svc, service) {
|
|
continue
|
|
}
|
|
found = true
|
|
if current, _ := entry["State"].(string); !strings.EqualFold(current, state) {
|
|
return poll.Continue("service %q not in state %q yet (got %q): %s", service, state, current, psRes.Stdout())
|
|
}
|
|
}
|
|
if found {
|
|
return poll.Success()
|
|
}
|
|
return poll.Continue("service %q not in state %q yet: %s", service, state, psRes.Stdout())
|
|
}
|
|
poll.WaitOn(t, check, poll.WithDelay(250*time.Millisecond), poll.WithTimeout(15*time.Second))
|
|
}
|