Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Let a job start service containers beside it

c1929d2fceb5e9e65204d5eef907d0781b9a5391 · Keenan Nemetz · 5d ago · parent 1d06b008

ci/workflow.go +120−13

@@ -34,6 +34,11 @@ const (
34 34 MaxJobs = 50
35 35 MaxSteps = 100
36 36 MaxArtifacts = 32
37 // MaxServices bounds the containers a job asks for beside its own. A service is
38 // a process on the runner, not a declaration, and the runner's resource flags
39 // apply to each one; a job that needs more than a handful is describing an
40 // environment that belongs in its image.
41 MaxServices = 3
37 42 // MaxLabels bounds runs-on. A job naming more than a handful of labels is
38 43 // describing a specific machine, which is what --repos and the allowlist are for.
39 44 MaxLabels = 8
@@ -91,6 +96,10 @@ var (
91 96 // runner says it is with --label. Lowercase because a label is an identity, and
92 97 // `Linux` and `linux` being different machines is a support question nobody wants.
93 98 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
99 // serviceNameRE is a service's name and therefore the hostname a step connects
100 // to. It follows the DNS label rule, because the container runtime publishes it
101 // as a name on the job's network.
102 serviceNameRE = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
94 103 // accountRE is the roster's account-name rule. A filter names a person, so a value
95 104 // that could never be an account name is a typo, not a new kind of filter.
96 105 accountRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
@@ -158,6 +167,17 @@ func ValidNetwork(s string) error {
158 167 return fmt.Errorf("%q is not a network mode; use %q or %q", s, NetworkNone, NetworkBridge)
159 168 }
160 169
170 // ValidServiceName reports whether s can name a service. It is exported for the
171 // runner, which checks the service names it was handed before turning one into a
172 // container name and a network alias: the workflow validator and the runner must
173 // agree on what a name is, because the name becomes a hostname.
174 func ValidServiceName(s string) error {
175 if !serviceNameRE.MatchString(s) {
176 return fmt.Errorf("%q is not a service name (1-32 characters: lowercase letters, digits and '-', starting with a letter)", s)
177 }
178 return nil
179 }
180
161 181 type Step struct {
162 182 Name string
163 183 Run string
@@ -213,6 +233,20 @@ type Artifact struct {
213 233 Release bool
214 234 }
215 235
236 // Service is one container started beside a job: a database, a queue, or another
237 // server the steps talk to by name. It is not a job: it has no steps, no checkout,
238 // no secrets, and its output is a log the runner attaches when the job fails. The
239 // image is a registry reference because a service is an upstream container, not a
240 // build.
241 type Service struct {
242 // ID is the service's name and the hostname a step connects to.
243 ID string
244 Image string
245 // Env is the service's own environment, literal and bounded like a job's. It is
246 // not the job's environment and it is not where secrets arrive.
247 Env map[string]string
248 }
249
216 250 type Job struct {
217 251 ID string
218 252 // Name is the display name from `name:`, or the job ID when the job does not
@@ -235,6 +269,11 @@ type Job struct {
235 269 // that could provide bridge. The job decides, so the same workflow behaves the same
236 270 // way on every runner.
237 271 Network string
272 // Services are the containers started beside the job, in the order they were
273 // declared. A job with services must ask for NetworkBridge: the services are
274 // reached over the job's network, and a job that asked for none must never get one
275 // indirectly.
276 Services []Service
238 277 // Timeout is the wall clock the job's steps share, from `timeout:`, or zero when
239 278 // the job does not budget one. It is spent by the steps in order: a step that
240 279 // declares its own timeout is still cut short when the budget ends first, and a
@@ -813,20 +852,26 @@ func decodeDispatchInput(node *yaml.Node) (rawDispatchInput, error) {
813 852 return in, nil
814 853 }
815 854
855 type rawService struct {
856 Image string `yaml:"image"`
857 Env map[string]string `yaml:"env"`
858 }
859
816 860 type rawJob struct {
817 Name string `yaml:"name"`
818 Description string `yaml:"description"`
819 Steps []rawStep `yaml:"steps"`
820 RunsOn rawLabels `yaml:"runs-on"`
821 Network string `yaml:"network"`
822 Timeout string `yaml:"timeout"`
823 ActsAs string `yaml:"acts-as"`
824 Image string `yaml:"image"`
825 ImageFile string `yaml:"image-file"`
826 Artifacts []rawArtifact `yaml:"artifacts"`
827 Pages *rawPages `yaml:"pages"`
828 Secrets rawSecretNames `yaml:"secrets"`
829 Env map[string]string `yaml:"env"`
861 Name string `yaml:"name"`
862 Description string `yaml:"description"`
863 Steps []rawStep `yaml:"steps"`
864 RunsOn rawLabels `yaml:"runs-on"`
865 Network string `yaml:"network"`
866 Services map[string]rawService `yaml:"services"`
867 Timeout string `yaml:"timeout"`
868 ActsAs string `yaml:"acts-as"`
869 Image string `yaml:"image"`
870 ImageFile string `yaml:"image-file"`
871 Artifacts []rawArtifact `yaml:"artifacts"`
872 Pages *rawPages `yaml:"pages"`
873 Secrets rawSecretNames `yaml:"secrets"`
874 Env map[string]string `yaml:"env"`
830 875 }
831 876
832 877 // rawSecretNames accepts one name or a list, the same way runs-on takes one label
@@ -1311,6 +1356,68 @@ func Parse(file string, content string) (*Workflow, []string) {
1311 1356 }
1312 1357 job.Env[name] = value
1313 1358 }
1359 // Services are containers beside the job. They are reached over the job's
1360 // network, so the job has to ask for one: a service that quietly added a network
1361 // would break the promise `network: none` makes, and a job on a runner whose
1362 // default is none would look stuck rather than misconfigured.
1363 if len(jr.Services) > MaxServices {
1364 bad("jobs.%s: %d services, limit %d", id, len(jr.Services), MaxServices)
1365 }
1366 serviceNames := make([]string, 0, len(jr.Services))
1367 for name := range jr.Services {
1368 serviceNames = append(serviceNames, name)
1369 }
1370 sort.Strings(serviceNames)
1371 for _, name := range serviceNames {
1372 where := fmt.Sprintf("jobs.%s.services.%s", id, name)
1373 if !serviceNameRE.MatchString(name) {
1374 bad("%s: a service name is 1-32 characters, lowercase letters, digits and -, starting with a letter", where)
1375 continue
1376 }
1377 rs := jr.Services[name]
1378 service := Service{ID: name}
1379 image := strings.TrimSpace(rs.Image)
1380 switch {
1381 case image == "":
1382 bad("%s.image: an image is required", where)
1383 default:
1384 if err := ValidImageRef(image); err != nil {
1385 bad("%s.image: %v", where, err)
1386 } else {
1387 service.Image = image
1388 }
1389 }
1390 if len(rs.Env) > MaxEnv {
1391 bad("%s: %d environment variables, limit %d", where, len(rs.Env), MaxEnv)
1392 }
1393 envNames := make([]string, 0, len(rs.Env))
1394 for envName := range rs.Env {
1395 envNames = append(envNames, envName)
1396 }
1397 sort.Strings(envNames)
1398 for _, envName := range envNames {
1399 value := rs.Env[envName]
1400 switch {
1401 case !envNameRE.MatchString(envName):
1402 bad("%s.env: %q is not a variable name (letters, digits and _, not starting with a digit)", where, envName)
1403 continue
1404 case strings.ContainsAny(value, "\r\n"):
1405 bad("%s.env.%s: the value must be one line", where, envName)
1406 continue
1407 case len(value) > MaxEnvValue:
1408 bad("%s.env.%s: the value is %d bytes, limit %d", where, envName, len(value), MaxEnvValue)
1409 continue
1410 }
1411 if service.Env == nil {
1412 service.Env = map[string]string{}
1413 }
1414 service.Env[envName] = value
1415 }
1416 job.Services = append(job.Services, service)
1417 }
1418 if len(jr.Services) > 0 && job.Network != NetworkBridge {
1419 bad("jobs.%s.services: services need network: bridge, because a step reaches them over the job's network", id)
1420 }
1314 1421 if jr.Pages != nil {
1315 1422 where := fmt.Sprintf("jobs.%s.pages.path", id)
1316 1423 p := strings.TrimSpace(jr.Pages.Path)