diff --git a/docs/docs/20-usage/60-services.md b/docs/docs/20-usage/60-services.md index 3697fef4861..f68936f393d 100644 --- a/docs/docs/20-usage/60-services.md +++ b/docs/docs/20-usage/60-services.md @@ -15,10 +15,10 @@ steps: - go test services: - - name: database + database: image: mysql - - name: cache + cache: image: redis ``` @@ -26,12 +26,12 @@ You can define a port and a protocol explicitly: ```yaml services: - - name: database + database: image: mysql ports: - 3306 - - name: wireguard + wireguard: image: wg ports: - 51820/udp @@ -43,7 +43,7 @@ Service containers generally expose environment variables to customize service s ```diff services: - - name: database + database: image: mysql + environment: + MYSQL_DATABASE: test @@ -91,7 +91,7 @@ Service containers require time to initialize and begin to accept connections. I - go test services: - - name: database + database: image: mysql ``` diff --git a/pipeline/frontend/yaml/compiler/compiler.go b/pipeline/frontend/yaml/compiler/compiler.go index 612ac540985..329404992e3 100644 --- a/pipeline/frontend/yaml/compiler/compiler.go +++ b/pipeline/frontend/yaml/compiler/compiler.go @@ -198,10 +198,10 @@ func (c *Compiler) Compile(conf *yaml_types.Workflow) (*backend_types.Config, er } // add services steps - if len(conf.Services.ContainerList) != 0 { + if len(conf.Services.ContainerMap) != 0 { stage := new(backend_types.Stage) - for _, container := range conf.Services.ContainerList { + for _, container := range conf.Services.ContainerMap { if match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil { continue } else if err != nil { diff --git a/pipeline/frontend/yaml/linter/linter.go b/pipeline/frontend/yaml/linter/linter.go index 4b991dc41b4..865c6391e43 100644 --- a/pipeline/frontend/yaml/linter/linter.go +++ b/pipeline/frontend/yaml/linter/linter.go @@ -146,7 +146,21 @@ func (l *Linter) lintContainers(config *WorkflowConfig, area string) error { case "steps": containers = config.Workflow.Steps.ContainerList case "services": - containers = config.Workflow.Services.ContainerList + if len(config.Workflow.Services.Duplicated) != 0 { + linterErr = multierr.Append(linterErr, &errorTypes.PipelineError{ + Type: errorTypes.PipelineErrorTypeBadHabit, + Message: "Services have duplicated names declaired, second ones are ignored and should be removed", + Data: errors.BadHabitErrorData{ + File: config.File, + Field: "services", + Docs: "https://woodpecker-ci.org/docs/usage/services", + }, + IsWarning: true, + }) + } + for _, v := range config.Workflow.Services.ContainerMap { + containers = append(containers, v) + } } for _, container := range containers { diff --git a/pipeline/frontend/yaml/parse_test.go b/pipeline/frontend/yaml/parse_test.go index 2c2b2325b24..b8be17abdcd 100644 --- a/pipeline/frontend/yaml/parse_test.go +++ b/pipeline/frontend/yaml/parse_test.go @@ -34,8 +34,11 @@ func TestParse(t *testing.T) { assert.Equal(t, "/go", out.Workspace.Base) assert.Equal(t, "src/github.com/octocat/hello-world", out.Workspace.Path) - assert.Equal(t, "database", out.Services.ContainerList[0].Name) - assert.Equal(t, "mysql", out.Services.ContainerList[0].Image) + for k := range out.Services.ContainerMap { + assert.Equal(t, k, out.Services.ContainerMap[k].Name) + } + assert.Len(t, out.Services.ContainerMap, 1) + assert.Equal(t, "mysql", out.Services.ContainerMap["database"].Image) assert.Equal(t, "test", out.Steps.ContainerList[0].Name) assert.Equal(t, "golang", out.Steps.ContainerList[0].Image) assert.Equal(t, yaml_base_types.StringOrSlice{"go install", "go test"}, out.Steps.ContainerList[0].Commands) @@ -309,8 +312,8 @@ steps: when: event: failure services: - - name: database - image: mysql + database: + image: mysql labels: com.example.team: frontend com.example.type: build diff --git a/pipeline/frontend/yaml/types/container_map.go b/pipeline/frontend/yaml/types/container_map.go new file mode 100644 index 00000000000..99f18a71455 --- /dev/null +++ b/pipeline/frontend/yaml/types/container_map.go @@ -0,0 +1,85 @@ +// Copyright 2026 Woodpecker 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 types + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// ContainerMap contains collection of containers. +type ContainerMap struct { + ContainerMap map[string]*Container + Duplicated []string +} + +// UnmarshalYAML implements the Unmarshaler interface. +func (c *ContainerMap) UnmarshalYAML(value *yaml.Node) error { + c.ContainerMap = make(map[string]*Container, len(value.Content)/2+1) + switch value.Kind { + // We support maps ... + case yaml.MappingNode: + for i, n := range value.Content { + if i%2 == 1 { + container := &Container{} + if err := n.Decode(container); err != nil { + return err + } + + // service name is host name so we set it as name + container.Name = fmt.Sprintf("%v", value.Content[i-1].Value) + if container.Name == "" { + return fmt.Errorf("container map does not allow empty key item") + } + + c.ContainerMap[container.Name] = container + } + } + + // ... and lists + case yaml.SequenceNode: + for i, n := range value.Content { + container := &Container{} + if err := n.Decode(container); err != nil { + return err + } + + if container.Name == "" { + container.Name = fmt.Sprintf("step-%d", i) + } + + if _, exist := c.ContainerMap[container.Name]; exist { + c.Duplicated = append(c.Duplicated, container.Name) + } else { + c.ContainerMap[container.Name] = container + } + } + + default: + return fmt.Errorf("yaml node type[%d]: '%s' not supported", value.Kind, value.Tag) + } + + return nil +} + +// MarshalYAML implements custom Yaml marshaling. +func (c ContainerMap) MarshalYAML() (any, error) { + // we just relay on the map key for names + for _, v := range c.ContainerMap { + v.Name = "" + } + return c.ContainerMap, nil +} diff --git a/pipeline/frontend/yaml/types/workflow.go b/pipeline/frontend/yaml/types/workflow.go index 134aa47c6eb..85a58ac39fb 100644 --- a/pipeline/frontend/yaml/types/workflow.go +++ b/pipeline/frontend/yaml/types/workflow.go @@ -25,7 +25,7 @@ type ( Workspace Workspace `yaml:"workspace,omitempty"` Clone ContainerList `yaml:"clone,omitempty"` Steps ContainerList `yaml:"steps,omitempty"` - Services ContainerList `yaml:"services,omitempty"` + Services ContainerMap `yaml:"services,omitempty"` Labels map[string]string `yaml:"labels,omitempty"` DependsOn []string `yaml:"depends_on,omitempty"` RunsOn []string `yaml:"runs_on,omitempty"`