diff --git a/pkg/bridge/convert.go b/pkg/bridge/convert.go index adf676eab4b..bdb48465aed 100644 --- a/pkg/bridge/convert.go +++ b/pkg/bridge/convert.go @@ -166,19 +166,34 @@ func LoadAdditionalResources(ctx context.Context, dockerCLI command.Cli, project for name, service := range project.Services { imageName := api.GetImageNameOrDefault(service, project.Name) - inspect, err := inspectWithPull(ctx, dockerCLI, imageName) - if err != nil { - return nil, err + var inspect image.InspectResponse + if service.Build != nil && service.Image == "" { + result, err := dockerCLI.Client().ImageInspect(ctx, imageName) + if err != nil { + if !errdefs.IsNotFound(err) { + return nil, err + } + logrus.Warnf("image %s for service %s not found locally; Dockerfile-exposed ports will not be included — run `docker compose build` first to include them", imageName, name) + } + inspect = result.InspectResponse + } else { + var err error + inspect, err = inspectWithPull(ctx, dockerCLI, imageName) + if err != nil { + return nil, err + } } service.Image = imageName exposed := utils.Set[string]{} exposed.AddAll(service.Expose...) - for port := range inspect.Config.ExposedPorts { - p, err := network.ParsePort(port) - if err != nil { - return nil, err + if inspect.Config != nil { + for port := range inspect.Config.ExposedPorts { + p, err := network.ParsePort(port) + if err != nil { + return nil, err + } + exposed.Add(strconv.Itoa(int(p.Num()))) } - exposed.Add(strconv.Itoa(int(p.Num()))) } for _, port := range service.Ports { exposed.Add(strconv.Itoa(int(port.Target))) diff --git a/pkg/bridge/convert_test.go b/pkg/bridge/convert_test.go new file mode 100644 index 00000000000..6f90bd0415e --- /dev/null +++ b/pkg/bridge/convert_test.go @@ -0,0 +1,54 @@ +/* + 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 bridge + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/mocks" +) + +func TestLoadAdditionalResources_BuildOnlySkipsPull(t *testing.T) { + mockCtrl := gomock.NewController(t) + dockerCLI := mocks.NewMockCli(mockCtrl) + apiClient := mocks.NewMockAPIClient(mockCtrl) + dockerCLI.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().ImageInspect(gomock.Any(), "test-api"). + Return(client.ImageInspectResult{}, errdefs.ErrNotFound) + + project := &types.Project{ + Name: "test", + Services: types.Services{ + "api": { + Name: "api", + Build: &types.BuildConfig{Context: "."}, + Expose: []string{"8080"}, + }, + }, + } + + actual, err := LoadAdditionalResources(t.Context(), dockerCLI, project) + assert.NilError(t, err) + assert.Equal(t, actual.Services["api"].Image, "test-api") + assert.DeepEqual(t, actual.Services["api"].Expose, types.StringOrNumberList{"8080"}) +} diff --git a/pkg/e2e/bridge_test.go b/pkg/e2e/bridge_test.go index c4c99b8d292..7212bcaf2c0 100644 --- a/pkg/e2e/bridge_test.go +++ b/pkg/e2e/bridge_test.go @@ -18,6 +18,7 @@ package e2e import ( "fmt" + "os" "path/filepath" "strings" "testing" @@ -25,11 +26,12 @@ import ( "gotest.tools/v3/assert" ) +const bridgeImageVersion = "v0.0.3" + func TestConvertAndTransformList(t *testing.T) { c := NewParallelCLI(t) const projectName = "bridge" - const bridgeImageVersion = "v0.0.3" tmpDir := t.TempDir() t.Run("kubernetes manifests", func(t *testing.T) { @@ -59,3 +61,17 @@ func TestConvertAndTransformList(t *testing.T) { assert.Assert(t, strings.Contains(res.Stdout(), "docker/compose-bridge-kubernetes"), res.Combined()) }) } + +func TestConvertBuildOnlyService(t *testing.T) { + c := NewParallelCLI(t) + outDir := t.TempDir() + + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/bridge-build-only/compose.yaml", "--project-name", "bridge-build-only", "bridge", "convert", + "--output", outDir, "--transformation", fmt.Sprintf("docker/compose-bridge-kubernetes:%s", bridgeImageVersion)) + assert.NilError(t, res.Error) + assert.Equal(t, res.ExitCode, 0) + + entries, err := os.ReadDir(outDir) + assert.NilError(t, err) + assert.Assert(t, len(entries) > 0, "expected bridge conversion to produce output") +} diff --git a/pkg/e2e/fixtures/bridge-build-only/Dockerfile b/pkg/e2e/fixtures/bridge-build-only/Dockerfile new file mode 100644 index 00000000000..be33c1c5a91 --- /dev/null +++ b/pkg/e2e/fixtures/bridge-build-only/Dockerfile @@ -0,0 +1,17 @@ +# 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. + +FROM alpine +EXPOSE 8080 +CMD ["echo", "Hello from Dockerfile"] diff --git a/pkg/e2e/fixtures/bridge-build-only/compose.yaml b/pkg/e2e/fixtures/bridge-build-only/compose.yaml new file mode 100644 index 00000000000..4d9019e602c --- /dev/null +++ b/pkg/e2e/fixtures/bridge-build-only/compose.yaml @@ -0,0 +1,5 @@ +services: + app: + build: . + expose: + - "8080"