mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
wantAbs was just wantSource under a different name, left over from an earlier revision. Use wantSource directly. (glours review on #14234) Signed-off-by: Nicolas De Loof <[email protected]>
718 lines
22 KiB
Go
718 lines
22 KiB
Go
/*
|
|
Copyright 2026 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
|
|
|
|
// This file is the scenario vocabulary: the complete set of observables a
|
|
// step can expect. It is meant to be read — and reviewed — as a whole; see
|
|
// SCENARIO.md for the rules governing its growth. In short: checks observe
|
|
// real state (containers, labels, image manifests), are generic (no
|
|
// test-specific logic), and are named after the observable they assert.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gotest.tools/v3/icmd"
|
|
"gotest.tools/v3/poll"
|
|
)
|
|
|
|
// CheckContext gives a check access to the step's result and to the project
|
|
// state captured before and after the step.
|
|
type CheckContext struct {
|
|
scenario *Scenario
|
|
result *icmd.Result
|
|
prev snapshot
|
|
curr snapshot
|
|
}
|
|
|
|
// refresh re-observes the project state, so subsequent checks of the same
|
|
// step see the latest state rather than the one captured right after the
|
|
// command returned.
|
|
func (ctx *CheckContext) refresh() {
|
|
s := ctx.scenario
|
|
ctx.curr = s.snapshot()
|
|
s.snaps[len(s.snaps)-1] = ctx.curr
|
|
}
|
|
|
|
// Check is a named observable expected to hold after a step.
|
|
type Check struct {
|
|
name string
|
|
fn func(*CheckContext) error
|
|
}
|
|
|
|
// OutputContains expects the command output to contain a string. Prefer
|
|
// state-based checks; use this when the CLI's reported decision is itself
|
|
// the observable.
|
|
func OutputContains(sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("output contains %q", sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
if !strings.Contains(ctx.result.Combined(), sub) {
|
|
return errors.New("not found in output")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// StdoutContains expects the command's stdout to contain a string. Use it
|
|
// instead of OutputContains when the expected text could collide with
|
|
// progress noise on stderr (e.g. asserting a container's printed output).
|
|
func StdoutContains(sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("stdout contains %q", sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
if !strings.Contains(ctx.result.Stdout(), sub) {
|
|
return errors.New("not found in stdout")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OutputMatches expects the regular expression to match the command's stdout,
|
|
// for expectations OutputContains cannot express (e.g. ordering).
|
|
func OutputMatches(pattern string) Check {
|
|
re := regexp.MustCompile(pattern) // panics at construction, not mid-check
|
|
return Check{
|
|
name: fmt.Sprintf("output matches %q", pattern),
|
|
fn: func(ctx *CheckContext) error {
|
|
if !re.MatchString(ctx.result.Stdout()) {
|
|
return errors.New("no match in stdout")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OutputMatchesCount expects the regular expression to match the command's
|
|
// stdout exactly n times, e.g. counting how many times a service was built.
|
|
func OutputMatchesCount(pattern string, n int) Check {
|
|
re := regexp.MustCompile(pattern) // panics at construction, not mid-check
|
|
return Check{
|
|
name: fmt.Sprintf("output matches %q %d time(s)", pattern, n),
|
|
fn: func(ctx *CheckContext) error {
|
|
matches := re.FindAllString(ctx.result.Stdout(), -1)
|
|
if len(matches) != n {
|
|
return fmt.Errorf("matched %d time(s)", len(matches))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// StderrContains expects the command's stderr to contain a string, e.g. a
|
|
// container's stderr stream relayed by up.
|
|
func StderrContains(sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("stderr contains %q", sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
if !strings.Contains(ctx.result.Stderr(), sub) {
|
|
return errors.New("not found in stderr")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OutputNotContains expects the command output not to contain a string.
|
|
func OutputNotContains(sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("output does not contain %q", sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
if strings.Contains(ctx.result.Combined(), sub) {
|
|
return errors.New("found in output")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ExitCode expects the step's command to have exited with the given code.
|
|
// Only meaningful on a MayFail action: without MayFail, any non-zero exit
|
|
// already fails the step before checks run.
|
|
func ExitCode(code int) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("command exits with code %d", code),
|
|
fn: func(ctx *CheckContext) error {
|
|
if ctx.result.ExitCode != code {
|
|
return fmt.Errorf("exit code is %d", ctx.result.ExitCode)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// Eventually retries a state-based check until it holds or the timeout
|
|
// expires, re-observing the project state between attempts. Output-based
|
|
// checks are not meaningful here: the step's output never changes.
|
|
// Polling is delegated to gotest.tools/v3/poll, the same engine the rest of
|
|
// the e2e framework uses.
|
|
func Eventually(check Check, timeout time.Duration) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("%s within %s", check.name, timeout),
|
|
fn: func(ctx *CheckContext) error {
|
|
first := true
|
|
capture := &pollCapture{}
|
|
done := make(chan struct{})
|
|
// poll.WaitOn reports timeout through TestingT.Fatalf and relies
|
|
// on it halting execution; run it in a goroutine so pollCapture
|
|
// can Goexit and hand the error back to the scenario report
|
|
// instead of failing the test on the spot.
|
|
go func() {
|
|
defer close(done)
|
|
poll.WaitOn(capture, func(poll.LogT) poll.Result {
|
|
if !first {
|
|
ctx.refresh()
|
|
}
|
|
first = false
|
|
if err := check.fn(ctx); err != nil {
|
|
return poll.Continue("%v", err)
|
|
}
|
|
return poll.Success()
|
|
}, poll.WithDelay(500*time.Millisecond), poll.WithTimeout(timeout))
|
|
}()
|
|
<-done
|
|
return capture.err
|
|
},
|
|
}
|
|
}
|
|
|
|
// pollCapture is a poll.TestingT that records the failure instead of failing
|
|
// the test, so Eventually can feed it to the scenario failure report.
|
|
type pollCapture struct {
|
|
err error
|
|
}
|
|
|
|
func (c *pollCapture) Log(args ...any) {}
|
|
func (c *pollCapture) Logf(format string, args ...any) {}
|
|
|
|
func (c *pollCapture) Fatalf(format string, args ...any) {
|
|
c.err = fmt.Errorf(format, args...)
|
|
runtime.Goexit()
|
|
}
|
|
|
|
// ServiceState expects every long-lived container of the service to be in
|
|
// the given state (running, exited, restarting, …). One-off (run) containers
|
|
// are not considered; see OneOffState.
|
|
func ServiceState(service, state string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q is %s", service, state),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
for _, c := range containers {
|
|
if c.State != state {
|
|
return fmt.Errorf("container %s is %s", c.Name, c.State)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ServiceScale expects the service to have exactly n long-lived containers.
|
|
func ServiceScale(service string, n int) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q has %d containers", service, n),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) != n {
|
|
var names []string
|
|
for _, c := range containers {
|
|
names = append(names, c.Name)
|
|
}
|
|
return fmt.Errorf("found %d: %s", len(names), strings.Join(names, ", "))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ReplicaNumbers expects the service's containers to carry exactly the given
|
|
// replica numbers (the com.docker.compose.container-number label), locking
|
|
// which replicas survive a scale up or down.
|
|
func ReplicaNumbers(service string, numbers ...int) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q has replicas %v", service, numbers),
|
|
fn: func(ctx *CheckContext) error {
|
|
var actual []string
|
|
for _, c := range ctx.curr.service(service) {
|
|
actual = append(actual, c.Labels["com.docker.compose.container-number"])
|
|
}
|
|
slices.Sort(actual)
|
|
var expected []string
|
|
for _, n := range numbers {
|
|
expected = append(expected, strconv.Itoa(n))
|
|
}
|
|
slices.Sort(expected)
|
|
if !slices.Equal(actual, expected) {
|
|
return fmt.Errorf("found replicas %v", actual)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ServiceNotCreated expects the service to have no container at all — one-off
|
|
// containers included — e.g. after an action that must leave unrelated
|
|
// services untouched.
|
|
func ServiceNotCreated(service string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q has no container", service),
|
|
fn: func(ctx *CheckContext) error {
|
|
var names []string
|
|
for _, c := range ctx.curr[service] {
|
|
names = append(names, c.Name)
|
|
}
|
|
if len(names) > 0 {
|
|
return fmt.Errorf("found %s", strings.Join(names, ", "))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// NotRecreated expects the services' containers to be exactly the ones that
|
|
// existed before the step (same container IDs).
|
|
func NotRecreated(services ...string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("services %s not recreated", strings.Join(services, ", ")),
|
|
fn: func(ctx *CheckContext) error {
|
|
for _, service := range services {
|
|
before, after := containerIDs(ctx.prev.service(service)), containerIDs(ctx.curr.service(service))
|
|
if len(before) == 0 {
|
|
return fmt.Errorf("service %q had no container before the step", service)
|
|
}
|
|
if !slices.Equal(before, after) {
|
|
return fmt.Errorf("service %q containers changed: %v -> %v", service, before, after)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// Recreated expects the services' containers to have been replaced by the
|
|
// step: none of the containers that existed before survived it.
|
|
func Recreated(services ...string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("services %s recreated", strings.Join(services, ", ")),
|
|
fn: func(ctx *CheckContext) error {
|
|
for _, service := range services {
|
|
before, after := containerIDs(ctx.prev.service(service)), containerIDs(ctx.curr.service(service))
|
|
if len(before) == 0 {
|
|
return fmt.Errorf("service %q had no container before the step", service)
|
|
}
|
|
for _, id := range after {
|
|
if slices.Contains(before, id) {
|
|
return fmt.Errorf("service %q kept container %s", service, id[:12])
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func containerIDs(containers []containerState) []string {
|
|
ids := make([]string, 0, len(containers))
|
|
for _, c := range containers {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
slices.Sort(ids)
|
|
return ids
|
|
}
|
|
|
|
// ServiceHealthy expects every container of the service to report a healthy
|
|
// state from its healthcheck.
|
|
func ServiceHealthy(service string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q is healthy", service),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
for _, c := range containers {
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t,
|
|
"inspect", "--format", "{{.State.Health.Status}}", c.ID))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("inspect failed: %s", res.Combined())
|
|
}
|
|
if status := strings.TrimSpace(res.Stdout()); status != "healthy" {
|
|
return fmt.Errorf("container %s is %s", c.Name, status)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OneOffState expects every one-off (run) container of the service to be in
|
|
// the given state.
|
|
func OneOffState(service, state string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("one-off of service %q is %s", service, state),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.oneOffs(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no one-off container")
|
|
}
|
|
for _, c := range containers {
|
|
if c.State != state {
|
|
return fmt.Errorf("container %s is %s", c.Name, c.State)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OneOffsUntouched expects the service's one-off containers to be exactly the
|
|
// ones from before the step, neither restarted nor removed: same container
|
|
// IDs, same state, same start time.
|
|
func OneOffsUntouched(service string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("one-offs of service %q untouched", service),
|
|
fn: func(ctx *CheckContext) error {
|
|
before, after := ctx.prev.oneOffs(service), ctx.curr.oneOffs(service)
|
|
if len(before) == 0 {
|
|
return errors.New("service had no one-off container before the step")
|
|
}
|
|
if !slices.Equal(containerIDs(before), containerIDs(after)) {
|
|
return fmt.Errorf("one-off containers changed: %v -> %v", containerIDs(before), containerIDs(after))
|
|
}
|
|
for i, b := range before {
|
|
a := after[i]
|
|
if a.State != b.State || a.StartedAt != b.StartedAt {
|
|
return fmt.Errorf("container %s was touched: %s (started %s) -> %s (started %s)",
|
|
b.Name, b.State, b.StartedAt, a.State, a.StartedAt)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// OneOffsRemoved expects the one-off containers the service had before the
|
|
// step to be gone; it errors if the service had none, rather than pass
|
|
// vacuously.
|
|
func OneOffsRemoved(service string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("one-offs of service %q removed", service),
|
|
fn: func(ctx *CheckContext) error {
|
|
if len(ctx.prev.oneOffs(service)) == 0 {
|
|
return errors.New("service had no one-off container before the step")
|
|
}
|
|
var names []string
|
|
for _, c := range ctx.curr.oneOffs(service) {
|
|
names = append(names, c.Name)
|
|
}
|
|
if len(names) > 0 {
|
|
return fmt.Errorf("still present: %s", strings.Join(names, ", "))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ImageExists expects an image with the given reference to be present in the
|
|
// local store.
|
|
func ImageExists(ref string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("image %q exists", ref),
|
|
fn: func(ctx *CheckContext) error {
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "image", "inspect", "--format", "{{.Id}}", ref))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("not found: %s", strings.TrimSpace(res.Combined()))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ImageAbsent expects no image with the given reference in the local store —
|
|
// the observable proof that a dry-run left the engine untouched.
|
|
func ImageAbsent(ref string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("image %q is absent", ref),
|
|
fn: func(ctx *CheckContext) error {
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "image", "inspect", "--format", "{{.Id}}", ref))
|
|
if res.ExitCode == 0 {
|
|
return fmt.Errorf("image %s exists", ref)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// FileExists expects a non-empty file at the given host path, e.g. the output
|
|
// of an export command.
|
|
func FileExists(path string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("file %q exists", path),
|
|
fn: func(ctx *CheckContext) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Size() == 0 {
|
|
return errors.New("file is empty")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// FileContains expects the host file at the given path to contain a string,
|
|
// e.g. a file copied out of a container.
|
|
func FileContains(path, sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("file %q contains %q", path, sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.Contains(string(data), sub) {
|
|
return fmt.Errorf("not found in file content: %q", truncate(string(data), 200))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// FileAbsent expects no file or directory to exist at the given host path,
|
|
// e.g. content a previous step wiped.
|
|
func FileAbsent(path string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("file %q is absent", path),
|
|
fn: func(ctx *CheckContext) error {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return errors.New("file still exists")
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ContainerEnv expects every container of the service to carry the given
|
|
// environment variable with the exact value, as recorded in the container
|
|
// config — the observable effect of `environment`, `env_file` or provider
|
|
// injection, whatever the source of the model.
|
|
func ContainerEnv(service, name, value string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q containers have env %s=%s", service, name, value),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
for _, c := range containers {
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format", "{{json .Config.Env}}", c.ID))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("inspect failed: %s", res.Combined())
|
|
}
|
|
var env []string
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(res.Stdout())), &env); err != nil {
|
|
return err
|
|
}
|
|
if !slices.Contains(env, name+"="+value) {
|
|
return fmt.Errorf("not in container %s environment: %v", c.Name, env)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// BindMountSource expects the service's bind mount at target to resolve to
|
|
// exactly wantSource (which must be absolute — this check pins down which
|
|
// working directory a relative volume path was resolved against, e.g. an
|
|
// explicit --project-directory, so a relative wantSource here would silently
|
|
// compare against the wrong base: the test process's own cwd, not any
|
|
// project directory).
|
|
func BindMountSource(service, target, wantSource string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q mount %q resolves to %s", service, target, wantSource),
|
|
fn: func(ctx *CheckContext) error {
|
|
if !filepath.IsAbs(wantSource) {
|
|
return fmt.Errorf("BindMountSource: wantSource must be absolute, got %q", wantSource)
|
|
}
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
for _, c := range containers {
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format", "{{json .Mounts}}", c.ID))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("inspect failed: %s", res.Combined())
|
|
}
|
|
var mounts []struct {
|
|
Type string
|
|
Destination string
|
|
Source string
|
|
}
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(res.Stdout())), &mounts); err != nil {
|
|
return err
|
|
}
|
|
found := false
|
|
for _, m := range mounts {
|
|
if m.Type != "bind" {
|
|
continue
|
|
}
|
|
if m.Destination != target {
|
|
continue
|
|
}
|
|
found = true
|
|
if m.Source != wantSource {
|
|
return fmt.Errorf("container %s mount %s resolves to %s, want %s", c.Name, target, m.Source, wantSource)
|
|
}
|
|
break
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("container %s has no mount at %s", c.Name, target)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// LabelSet expects every container of the service to carry a non-empty label.
|
|
func LabelSet(service, key string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q has label %q set", service, key),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
for _, c := range containers {
|
|
if c.Labels[key] == "" {
|
|
return fmt.Errorf("label empty on container %s", c.Name)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// LabelsDistinct expects the given services to carry pairwise-distinct values
|
|
// for a label.
|
|
func LabelsDistinct(key string, services ...string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("services %s have distinct %q labels", strings.Join(services, ", "), key),
|
|
fn: func(ctx *CheckContext) error {
|
|
seen := map[string]string{}
|
|
for _, service := range services {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return fmt.Errorf("service %q has no container", service)
|
|
}
|
|
value := containers[0].Labels[key]
|
|
if other, dup := seen[value]; dup {
|
|
return fmt.Errorf("services %q and %q share label value %q", other, service, value)
|
|
}
|
|
seen[value] = service
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// LabelUnchanged expects a service's label to have the same value as before
|
|
// the step.
|
|
func LabelUnchanged(service, key string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q label %q unchanged", service, key),
|
|
fn: func(ctx *CheckContext) error {
|
|
before, after := ctx.prev.service(service), ctx.curr.service(service)
|
|
if len(before) == 0 || len(after) == 0 {
|
|
return errors.New("service has no container to compare")
|
|
}
|
|
if before[0].Labels[key] != after[0].Labels[key] {
|
|
return fmt.Errorf("label changed: %q -> %q", before[0].Labels[key], after[0].Labels[key])
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// RunsOnPlatform expects the service's container to have been created for
|
|
// the given platform (from its image manifest descriptor).
|
|
func RunsOnPlatform(service, platform string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("service %q container created for platform %s", service, platform),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format",
|
|
"{{.ImageManifestDescriptor.Platform.OS}}/{{.ImageManifestDescriptor.Platform.Architecture}}",
|
|
containers[0].ID))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("inspect failed: %s", res.Combined())
|
|
}
|
|
if actual := strings.TrimSpace(res.Stdout()); actual != platform {
|
|
return fmt.Errorf("container platform is %s", actual)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// ExecOutputContains expects `docker exec` of the command in the service's
|
|
// first container to succeed and print a string — a state probe for effects
|
|
// only visible from inside the container (files written by hooks, mounted
|
|
// volumes), where no host-side observable exists.
|
|
func ExecOutputContains(service, command, sub string) Check {
|
|
return Check{
|
|
name: fmt.Sprintf("exec %q in service %q prints %q", command, service, sub),
|
|
fn: func(ctx *CheckContext) error {
|
|
containers := ctx.curr.service(service)
|
|
if len(containers) == 0 {
|
|
return errors.New("service has no container")
|
|
}
|
|
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t,
|
|
"exec", containers[0].ID, "sh", "-c", command))
|
|
if res.ExitCode != 0 {
|
|
return fmt.Errorf("exec failed: %s", res.Combined())
|
|
}
|
|
if !strings.Contains(res.Stdout(), sub) {
|
|
return fmt.Errorf("output %q does not contain %q", res.Stdout(), sub)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|