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) | |