diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go index 2528fccacfb..a2414584512 100644 --- a/cmd/compose/ps.go +++ b/cmd/compose/ps.go @@ -30,6 +30,7 @@ import ( "github.com/spf13/cobra" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/coordinator" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" ) @@ -156,9 +157,14 @@ func runPs(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOp opts.Format = dockerCli.ConfigFile().PsFormat } + // Show the ENGINE column when the current Docker context is coordinator- + // enabled, so the column's presence is stable across `ps` and `ps ` + // rather than gated on which containers happen to carry the engine label. + showEngine := coordinator.PushEnabled(dockerCli) + containerCtx := cliformatter.Context{ Output: dockerCli.Out(), - Format: formatter.NewContainerFormat(opts.Format, opts.Quiet, false), + Format: formatter.NewContainerFormat(opts.Format, opts.Quiet, false, showEngine), Trunc: !opts.noTrunc, } return formatter.ContainerWrite(containerCtx, containers) diff --git a/cmd/formatter/container.go b/cmd/formatter/container.go index 546def442bb..860e7a60718 100644 --- a/cmd/formatter/container.go +++ b/cmd/formatter/container.go @@ -42,16 +42,20 @@ const ( mountsHeader = "MOUNTS" localVolumes = "LOCAL VOLUMES" networksHeader = "NETWORKS" + engineHeader = "ENGINE" ) // NewContainerFormat returns a Format for rendering using a Context -func NewContainerFormat(source string, quiet bool, size bool) formatter.Format { +func NewContainerFormat(source string, quiet bool, size bool, engine bool) formatter.Format { switch source { case formatter.TableFormatKey, "": // table formatting is the default if none is set. if quiet { return formatter.DefaultQuietFormat } format := defaultContainerTableFormat + if engine { + format += `\t{{.Engine}}` + } if size { format += `\t{{.Size}}` } @@ -126,6 +130,7 @@ func NewContainerContext() *ContainerContext { "Status": formatter.StatusHeader, "Size": formatter.SizeHeader, "Labels": formatter.LabelsHeader, + "Engine": engineHeader, } return &containerCtx } @@ -245,6 +250,15 @@ func (c *ContainerContext) Labels() string { return strings.Join(joinLabels, ",") } +// Engine returns the name of the engine that runs the container, as stored in +// the com.docker.compose.engine label, or an empty string if unset. +func (c *ContainerContext) Engine() string { + if c.c.Labels == nil { + return "" + } + return c.c.Labels[api.ContainerEngineLabel] +} + // Label returns the value of the label with the given name or an empty string // if the given label does not exist. func (c *ContainerContext) Label(name string) string { diff --git a/cmd/formatter/container_test.go b/cmd/formatter/container_test.go new file mode 100644 index 00000000000..b55599a4b60 --- /dev/null +++ b/cmd/formatter/container_test.go @@ -0,0 +1,78 @@ +/* + Copyright 2020 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 formatter + +import ( + "bytes" + "strings" + "testing" + + "github.com/docker/cli/cli/command/formatter" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +func TestContainerContextEngine(t *testing.T) { + withLabel := ContainerContext{c: api.ContainerSummary{ + Labels: map[string]string{api.ContainerEngineLabel: "moby"}, + }} + assert.Equal(t, withLabel.Engine(), "moby") + + noLabels := ContainerContext{c: api.ContainerSummary{}} + assert.Equal(t, noLabels.Engine(), "") + + otherLabel := ContainerContext{c: api.ContainerSummary{ + Labels: map[string]string{api.ProjectLabel: "test"}, + }} + assert.Equal(t, otherLabel.Engine(), "") +} + +func TestContainerWriteEngineColumn(t *testing.T) { + containers := []api.ContainerSummary{ + { + Name: "with-engine", + Service: "svc1", + Labels: map[string]string{api.ContainerEngineLabel: "moby"}, + }, + { + Name: "without-engine", + Service: "svc2", + }, + } + + t.Run("engine column shown when label present", func(t *testing.T) { + var out bytes.Buffer + ctx := formatter.Context{ + Output: &out, + Format: NewContainerFormat("table", false, false, true), + } + assert.NilError(t, ContainerWrite(ctx, containers)) + assert.Assert(t, strings.Contains(out.String(), engineHeader), out.String()) + assert.Assert(t, strings.Contains(out.String(), "moby"), out.String()) + }) + + t.Run("engine column hidden when no label present", func(t *testing.T) { + var out bytes.Buffer + ctx := formatter.Context{ + Output: &out, + Format: NewContainerFormat("table", false, false, false), + } + assert.NilError(t, ContainerWrite(ctx, containers)) + assert.Assert(t, !strings.Contains(out.String(), engineHeader), out.String()) + }) +} diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go new file mode 100644 index 00000000000..b7c9d20b64f --- /dev/null +++ b/internal/coordinator/coordinator.go @@ -0,0 +1,161 @@ +/* + Copyright 2020 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 coordinator holds the coordinator-specific integration used by +// Compose: detecting a coordinator-enabled Docker context and pushing the +// project configuration to it over the engine socket. It mirrors the +// self-contained layout of internal/desktop (detection, HTTP client, versioned +// URL and outcome handling) so that pkg/compose keeps only a thin call-site. +package coordinator + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client/pkg/versions" +) + +// MetadataKey is the Docker context metadata key that opts a socket into the +// Compose project-config push. When the current context's metadata carries +// this key set to a truthy value, the socket is assumed to accept +// POST /v{version}/compose/project (see MinAPIVersion), and Compose sends the +// project configuration to the coordinator at the start of "compose up" before +// any other Docker API call. +const MetadataKey = "com.docker.compose.project.config" + +// MinAPIVersion is the Engine/coordinator API version that introduced +// POST /v{version}/compose/project for the Compose project-config push. It +// documents the minimum coordinator version and is the single place the push +// gates on. +const MinAPIVersion = "1.51" + +// defaultTimeout bounds a single project-config push. The push runs before any +// other Docker API call in "compose up", so without a deadline a coordinator +// that accepts the connection but never responds would hang "up" indefinitely, +// defeating the non-fatal "warn and continue" guarantee. +const defaultTimeout = 30 * time.Second + +// responseBodyLimit bounds how much of an error response body is read back +// into the returned error message. +const responseBodyLimit = 2048 + +// PushEnabled reports whether the current Docker context opts into the +// project-config push via the MetadataKey metadata key. A metadata read +// failure is treated as "not enabled" so that "compose up" is never blocked on +// context inspection. The value is accepted as either a JSON boolean true or +// the string "true" (case-insensitive), mirroring how custom context metadata +// may be decoded (see ConfigFromDockerContext in +// internal/tracing/docker_context.go). +func PushEnabled(dockerCli command.Cli) bool { + meta, err := dockerCli.ContextStore().GetMetadata(dockerCli.CurrentContext()) + if err != nil { + return false + } + + var value any + switch m := meta.Metadata.(type) { + case command.DockerContext: + value = m.AdditionalFields[MetadataKey] + case map[string]any: + value = m[MetadataKey] + } + + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + default: + return false + } +} + +// Client pushes the Compose project configuration to a coordinator over the +// Docker engine socket, using the engine dialer for transport (mirroring +// internal/desktop/client.go). +type Client struct { + dialer func(ctx context.Context) (net.Conn, error) + timeout time.Duration +} + +// NewClient builds a coordinator client that reaches the engine over the given +// dialer (typically apiClient.Dialer()). +func NewClient(dialer func(ctx context.Context) (net.Conn, error)) *Client { + return &Client{dialer: dialer, timeout: defaultTimeout} +} + +// PushProjectConfig sends the full project configuration to the coordinator's +// version-negotiated POST /v{version}/compose/project endpoint. apiVersion is +// the negotiated engine API version; the push is rejected before any network +// call when it predates MinAPIVersion. A non-2xx response is returned as an +// error; callers warn and continue. The request is bounded by a timeout so a +// coordinator that never responds cannot hang "compose up". +func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, project *types.Project) error { + if versions.LessThan(apiVersion, MinAPIVersion) { + return fmt.Errorf("coordinator API version %s does not support the project-config push (requires %s or later)", + apiVersion, MinAPIVersion) + } + + payload, err := project.MarshalJSON() + if err != nil { + return fmt.Errorf("marshaling project config: %w", err) + } + + httpClient := &http.Client{ + Timeout: c.timeout, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return c.dialer(ctx) + }, + }, + } + + // The host is cosmetic: the custom dialer handles routing to the engine + // socket. It exists only to form a valid URL (see desktop.backendURL). + url := fmt.Sprintf("http://docker/v%s/compose/project", apiVersion) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(io.LimitReader(resp.Body, responseBodyLimit)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("coordinator returned status %d", resp.StatusCode) + } + return fmt.Errorf("coordinator returned status %d: %s", resp.StatusCode, msg) + } + + return nil +} diff --git a/internal/coordinator/coordinator_test.go b/internal/coordinator/coordinator_test.go new file mode 100644 index 00000000000..96a3ab09545 --- /dev/null +++ b/internal/coordinator/coordinator_test.go @@ -0,0 +1,229 @@ +/* + Copyright 2020 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 coordinator + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/store" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/mocks" +) + +var testStoreCfg = store.NewConfig( + func() any { + return &map[string]any{} + }, +) + +func newContextStore(t *testing.T, meta any) store.Store { + t.Helper() + st := store.New(t.TempDir(), testStoreCfg) + err := st.CreateOrUpdate(store.Metadata{ + Name: "test", + Metadata: meta, + Endpoints: make(map[string]any), + }) + assert.NilError(t, err) + return st +} + +func TestPushEnabled(t *testing.T) { + if testing.Short() { + t.Skip("Requires filesystem access") + } + + dockerContext := func(fields map[string]any) command.DockerContext { + return command.DockerContext{Description: "test", AdditionalFields: fields} + } + + tests := []struct { + name string + meta any + want bool + }{ + { + name: "boolean true", + meta: dockerContext(map[string]any{MetadataKey: true}), + want: true, + }, + { + name: "string true", + meta: dockerContext(map[string]any{MetadataKey: "true"}), + want: true, + }, + { + name: "string TRUE case-insensitive", + meta: dockerContext(map[string]any{MetadataKey: "TRUE"}), + want: true, + }, + { + name: "boolean false", + meta: dockerContext(map[string]any{MetadataKey: false}), + want: false, + }, + { + name: "string other", + meta: dockerContext(map[string]any{MetadataKey: "yes"}), + want: false, + }, + { + name: "key absent", + meta: dockerContext(map[string]any{"other": true}), + want: false, + }, + { + name: "raw map form", + meta: map[string]any{MetadataKey: true}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().ContextStore().Return(newContextStore(t, tt.meta)).AnyTimes() + cli.EXPECT().CurrentContext().Return("test").AnyTimes() + + assert.Equal(t, PushEnabled(cli), tt.want) + }) + } +} + +func TestPushEnabledMetadataError(t *testing.T) { + mockCtrl := gomock.NewController(t) + cli := mocks.NewMockCli(mockCtrl) + // An empty store returns an error for an unknown context; that must be + // treated as "not enabled" rather than blocking up. + cli.EXPECT().ContextStore().Return(store.New(t.TempDir(), testStoreCfg)).AnyTimes() + cli.EXPECT().CurrentContext().Return("missing").AnyTimes() + + assert.Equal(t, PushEnabled(cli), false) +} + +func newTestProject() *types.Project { + return &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx"}, + }, + } +} + +func dialerFor(addr string) func(context.Context) (net.Conn, error) { + return func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + } +} + +func TestPushProjectConfig(t *testing.T) { + var ( + gotMethod string + gotPath string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.NilError(t, err) + assert.Equal(t, gotMethod, http.MethodPost) + assert.Equal(t, gotPath, "/v"+MinAPIVersion+"/compose/project") + assert.Assert(t, len(gotBody) > 0) +} + +func TestPushProjectConfigServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "placement failed", http.StatusInternalServerError) + })) + defer srv.Close() + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "500") + assert.ErrorContains(t, err, "placement failed") +} + +func TestPushProjectConfigDialerError(t *testing.T) { + // A dialer that never connects surfaces as a request error, which callers + // treat as non-fatal. + dialer := func(context.Context) (net.Conn, error) { + return nil, errors.New("boom: no engine socket") + } + c := NewClient(dialer) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "boom: no engine socket") +} + +func TestPushProjectConfigErrorEmptyBody(t *testing.T) { + // A non-2xx status with no body still yields a useful error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "coordinator returned status 503") +} + +func TestPushProjectConfigVersionTooLow(t *testing.T) { + // The dialer must never be reached: the version gate rejects first. + dialer := func(context.Context) (net.Conn, error) { + t.Fatal("dialer should not be called when the API version is too low") + return nil, nil + } + c := NewClient(dialer) + err := c.PushProjectConfig(t.Context(), "1.44", newTestProject()) + assert.ErrorContains(t, err, "does not support the project-config push") +} + +func TestPushProjectConfigTimeout(t *testing.T) { + // A coordinator that accepts the connection but never responds must not + // hang the push: the client timeout bounds the request. + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-block + })) + defer srv.Close() + defer close(block) + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + c.timeout = 50 * time.Millisecond + + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "context deadline exceeded") +} diff --git a/pkg/api/labels.go b/pkg/api/labels.go index 3a0f684b98b..863f5a0037b 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -55,6 +55,8 @@ const ( ImageBuilderLabel = "com.docker.compose.image.builder" // ContainerReplaceLabel is set when container is created to replace another container (recreated) ContainerReplaceLabel = "com.docker.compose.replace" + // ContainerEngineLabel stores the name of the engine that runs the container + ContainerEngineLabel = "com.docker.compose.engine" ) // ComposeVersion is the compose tool version as declared by label VersionLabel diff --git a/pkg/compose/up.go b/pkg/compose/up.go index b2bcb3f7dbb..6611e392d00 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -36,6 +36,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/coordinator" "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" @@ -43,6 +44,15 @@ import ( func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { //nolint:gocyclo err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { + // When the current Docker context opts into the project-config push, send + // the project configuration to the coordinator before any other Docker API + // call. Failures are non-fatal: warn and continue bringing the project up. + if !s.dryRun && coordinator.PushEnabled(s.dockerCli) { + if err := s.pushProjectConfig(ctx, project); err != nil { + logrus.Warnf("project config push to coordinator failed, continuing: %v", err) + } + } + err := s.create(ctx, project, options.Create) if err != nil { return err @@ -302,6 +312,17 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options return err } +// pushProjectConfig negotiates the engine API version and hands the project +// off to the coordinator client, which owns the coordinator-specific transport +// and outcome handling (see internal/coordinator). +func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project) error { + version, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return fmt.Errorf("negotiating API version: %w", err) + } + return coordinator.NewClient(s.apiClient().Dialer()).PushProjectConfig(ctx, version, project) +} + func shouldFollowStartEvent(event api.ContainerEvent, attached []string, attachTo []string) bool { if event.Type != api.ContainerEventStarted { return false diff --git a/pkg/compose/up_test.go b/pkg/compose/up_test.go index f38a9e1aeeb..c5c2ce8765b 100644 --- a/pkg/compose/up_test.go +++ b/pkg/compose/up_test.go @@ -17,13 +17,96 @@ package compose import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" "testing" + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" "gotest.tools/v3/assert" + "github.com/docker/compose/v5/internal/coordinator" "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" ) +func newPushTestService(t *testing.T, apiClient *mocks.MockAPIClient, version string) *composeService { + t.Helper() + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{APIVersion: version}, nil).AnyTimes() + apiClient.EXPECT().ClientVersion().Return(version).AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + return tested.(*composeService) +} + +func dialerFor(addr string) func(context.Context) (net.Conn, error) { + return func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + } +} + +func newUpTestProject() *types.Project { + return &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx"}, + }, + } +} + +// TestPushProjectConfigGlue covers the pkg/compose glue: it negotiates the +// engine API version and delegates to the coordinator client, which pushes to +// the version-negotiated endpoint reached over the engine dialer. +func TestPushProjectConfigGlue(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + mockCtrl := gomock.NewController(t) + apiClient := mocks.NewMockAPIClient(mockCtrl) + apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() + s := newPushTestService(t, apiClient, coordinator.MinAPIVersion) + + err := s.pushProjectConfig(t.Context(), newUpTestProject()) + assert.NilError(t, err) + assert.Equal(t, gotPath, "/v"+coordinator.MinAPIVersion+"/compose/project") +} + +// TestPushProjectConfigGlueVersionError covers the glue's error branch when +// API-version negotiation fails; the coordinator dialer is never reached. +func TestPushProjectConfigGlueVersionError(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{}, errors.New("engine unreachable")).AnyTimes() + // The dialer must never be reached when negotiation fails. + apiClient.EXPECT().Dialer().Times(0) + + tested, err := NewComposeService(cli) + assert.NilError(t, err) + s := tested.(*composeService) + + err = s.pushProjectConfig(t.Context(), newUpTestProject()) + assert.ErrorContains(t, err, "negotiating API version") + assert.ErrorContains(t, err, "engine unreachable") +} + func TestShouldFollowStartEvent(t *testing.T) { tests := []struct { name string