Skip to content

Commit 57e62be

Browse files
authored
Merge pull request #9 from ahakanbaba/master
Add a whitelistpath env var option to the kube-applier
2 parents f2bf390 + d27fdba commit 57e62be

10 files changed

Lines changed: 208 additions & 53 deletions

File tree

README.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Project Status](http://opensource.box.com/badges/active.svg)](http://opensource.box.com/badges) [![Build Status](https://travis-ci.org/box/kube-applier.svg)](https://travis-ci.org/box/kube-applier)
44

5-
kube-applier is a service that enables continuous deployment of Kubernetes objects by applying declarative configuration files from a Git repository to a Kubernetes cluster.
5+
kube-applier is a service that enables continuous deployment of Kubernetes objects by applying declarative configuration files from a Git repository to a Kubernetes cluster.
66

77
kube-applier runs as a Pod in your cluster and watches the [Git repo](#mounting-the-git-repository) to ensure that the cluster objects are up-to-date with their associated spec files (JSON or YAML) in the repo.
88

@@ -39,12 +39,38 @@ We suggest running kube-applier as a Deployment (see [demo/](https://github.com/
3939
### Environment Variables
4040

4141
**Required:**
42-
* `REPO_PATH` - (string) Absolute path to the directory containing configuration files to be applied. It must be a Git repository or a path within one. All .json and .yaml files within this directory (and its subdirectories) will be applied, unless listed on the blacklist.
42+
* `REPO_PATH` - (string) Absolute path to the directory containing
43+
* configuration files to be applied. It must be a Git repository or a path
44+
* within one. All .json and .yaml files within this directory (and its
45+
* subdirectories) will be applied, unless listed on the blacklist or excluded
46+
* from the whitelist.
4347
* `LISTEN_PORT` - (int) Port for the container. This should be the same port specified in the container spec.
4448

4549
**Optional:**
4650
* `SERVER` - (string) Address of the Kubernetes API server. By default, discovery of the API server is handled by kube-proxy. If kube-proxy is not set up, the API server address must be specified with this environment variable (which is then written into a [kubeconfig file](http://kubernetes.io/docs/user-guide/kubeconfig-file/) on the backend). Authentication to the API server is handled by service account tokens. See [Accessing the Cluster](http://kubernetes.io/docs/user-guide/accessing-the-cluster/#accessing-the-api-from-a-pod) for more info.
47-
* `BLACKLIST_PATH` - (string) Path to a "blacklist" file which specifies files that should not be applied. This path should be absolute (e.g. `/k8s/conf/kube_applier_blacklist`), not relative to `REPO_PATH` (although you may want to check the blacklist file into the repo). The blacklist file itself should be a plaintext file, with a file path on each line. Each of these paths should be relative to `REPO_PATH` (for example, if `REPO_PATH` is set to `/git/repo`, and the file to be blacklisted is `/git/repo/apps/app1.json`, the line in the blacklist file should be `apps/app1.json`).
51+
* `BLACKLIST_PATH` - (string) Path to a "blacklist" file which specifies files
52+
that should not be applied. This path should be absolute (e.g.
53+
`/k8s/conf/kube_applier_blacklist`), not relative to `REPO_PATH` (although
54+
you may want to check the blacklist file into the repo). The blacklist file
55+
itself should be a plaintext file, with a file path on each line. Each of
56+
these paths should be relative to `REPO_PATH` (for example, if `REPO_PATH` is
57+
set to `/git/repo`, and the file to be blacklisted is
58+
`/git/repo/apps/app1.json`, the line in the blacklist file should be
59+
`apps/app1.json`).
60+
* `WHITELIST_PATH` - (string) Path to a "whiltelist" file which is used to
61+
make the applier consider a specific subset of files from the repo.
62+
Only the files listed in the whitelist file will be considered for apply.
63+
Empty whitelist (or unset env var) means all files in repo are eligible to be applied.
64+
In case of a file is listed in both the whitelist and the blacklist, the file is
65+
not applied.
66+
This path should be absolute (e.g.
67+
`/k8s/conf/kube_applier_whitelist`), not relative to `REPO_PATH` (although
68+
you may want to check the whitelist file into the repo). The whitelist file
69+
itself should be a plaintext file, with a file path on each line. Each of
70+
these paths should be relative to `REPO_PATH` (for example, if `REPO_PATH` is
71+
set to `/git/repo`, and the file to be whitelisted is
72+
`/git/repo/apps/app1.json`, the line in the whiltelist file should be
73+
`apps/app1.json`).
4874
* `POLL_INTERVAL_SECONDS` - (int) Number of seconds to wait between each check for new commits to the repo (default is 5). Set to 0 to disable the wait period.
4975
* <a name="run-interval"></a>`FULL_RUN_INTERVAL_SECONDS` - (int) Number of seconds between automatic full runs (default is 300, or 5 minutes). Set to 0 to disable the wait period.
5076
* `DIFF_URL_FORMAT` - (string) If specified, allows the status page to display a link to the source code referencing the diff for a specific commit. `DIFF_URL_FORMAT` should be a URL for a hosted remote repo that supports linking to a commit hash. Replace the commit hash portion with "%s" so it can be filled in by kube-applier (e.g. `https://github.com/kubernetes/kubernetes/commit/%s`).
@@ -76,7 +102,7 @@ Mount a Git repository from a host directory. This can be useful when you want k
76102

77103
**What happens if the contents of the local Git repo change in the middle of a kube-applier run?**
78104

79-
If there are changes to files in the `$REPO_PATH` directory during a kube-applier run, those changes may or may not be reflected in that run, depending on the timing of the changes.
105+
If there are changes to files in the `$REPO_PATH` directory during a kube-applier run, those changes may or may not be reflected in that run, depending on the timing of the changes.
80106

81107
Given that the `$REPO_PATH` directory is a Git repo or located within one, it is likely that the majority of changes will be associated with a Git commit. Thus, a change in the middle of a run will likely update the HEAD commit hash, which will immediately trigger another run upon completion of the current run (regardless of whether or not any of the changes were effective in the current run). However, changes that are not associated with a new Git commit will not trigger a run.
82108

@@ -95,6 +121,7 @@ kube-applier hosts a status page on a webserver, served at the service endpoint
95121
* Start and end times
96122
* Latency
97123
* Most recent commit
124+
* Whitelisted files
98125
* Blacklisted files
99126
* Errors
100127
* Files applied successfully

applylist/factory.go

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,69 @@ import (
88

99
// FactoryInterface allows for mocking out the functionality of Factory when testing the full process of an apply run.
1010
type FactoryInterface interface {
11-
Create() ([]string, []string, error)
11+
Create() ([]string, []string, []string, error)
1212
}
1313

1414
// Factory handles constructing the list of files to apply and the blacklist.
1515
type Factory struct {
1616
RepoPath string
1717
BlacklistPath string
18+
WhitelistPath string
1819
FileSystem sysutil.FileSystemInterface
1920
}
2021

2122
// Create returns two alphabetically sorted lists: the list of files to apply, and the blacklist of files to skip.
22-
func (f *Factory) Create() ([]string, []string, error) {
23+
func (f *Factory) Create() ([]string, []string, []string, error) {
2324
blacklist, err := f.createBlacklist()
2425
if err != nil {
25-
return nil, nil, err
26+
return nil, nil, nil, err
2627
}
27-
applyList, err := f.createApplyList(blacklist)
28+
whitelist, err := f.createWhitelist()
2829
if err != nil {
29-
return nil, nil, err
30+
return nil, nil, nil, err
3031
}
31-
return applyList, blacklist, nil
32+
applyList, err := f.createApplyList(blacklist, whitelist)
33+
if err != nil {
34+
return nil, nil, nil, err
35+
}
36+
return applyList, blacklist, whitelist, nil
3237
}
3338

34-
// createBlacklist reads lines from the blacklist file, converts the relative paths to full paths, and returns a sorted list of full paths.
35-
func (f *Factory) createBlacklist() ([]string, error) {
36-
if f.BlacklistPath == "" {
39+
// createFilelist reads lines from the given file, converts the relative
40+
// paths to full paths, and returns a sorted list of full paths.
41+
func (f *Factory) createFileList(listFilePath string) ([]string, error) {
42+
if listFilePath == "" {
3743
return []string{}, nil
3844
}
39-
rawBlacklist, err := f.FileSystem.ReadLines(f.BlacklistPath)
45+
rawList, err := f.FileSystem.ReadLines(listFilePath)
4046
if err != nil {
4147
return nil, err
4248
}
43-
blacklist := prependToEachPath(f.RepoPath, rawBlacklist)
44-
sort.Strings(blacklist)
45-
return blacklist, nil
49+
list := prependToEachPath(f.RepoPath, rawList)
50+
sort.Strings(list)
51+
return list, nil
52+
}
53+
54+
// createBlacklist reads lines from the blacklist file, converts the relative
55+
// paths to full paths, and returns a sorted list of full paths.
56+
func (f *Factory) createBlacklist() ([]string, error) {
57+
return f.createFileList(f.BlacklistPath)
58+
}
59+
60+
// createWhitelist reads lines from the whitelist file, converts the relative
61+
// paths to full paths, and returns a sorted list of full paths.
62+
func (f *Factory) createWhitelist() ([]string, error) {
63+
return f.createFileList(f.WhitelistPath)
4664
}
4765

48-
// createApplyList gets all files within the repo directory and returns a filtered and sorted list of full paths.
49-
func (f *Factory) createApplyList(blacklist []string) ([]string, error) {
66+
// createApplyList gets all files within the repo directory and returns a
67+
// filtered and sorted list of full paths.
68+
func (f *Factory) createApplyList(blacklist, whitelist []string) ([]string, error) {
5069
rawApplyList, err := f.FileSystem.ListAllFiles(f.RepoPath)
5170
if err != nil {
5271
return nil, err
5372
}
54-
applyList := filter(rawApplyList, blacklist)
73+
applyList := filter(rawApplyList, blacklist, whitelist)
5574
sort.Strings(applyList)
5675
return applyList, nil
5776
}
@@ -60,19 +79,27 @@ func (f *Factory) createApplyList(blacklist []string) ([]string, error) {
6079
// Conditions for skipping the file path are:
6180
// 1. File path is not a .json or .yaml file
6281
// 2. File path is listed in the blacklist
63-
func shouldApplyPath(path string, blacklistMap map[string]struct{}) bool {
82+
func shouldApplyPath(path string, blacklistMap, whitelistMap map[string]struct{}) bool {
6483
_, inBlacklist := blacklistMap[path]
84+
85+
// If whitelist is empty, essentially there is no whitelist.
86+
inWhiteList := len(whitelistMap) == 0
87+
if !inWhiteList {
88+
_, inWhiteList = whitelistMap[path]
89+
}
6590
ext := filepath.Ext(path)
66-
return !inBlacklist && (ext == ".json" || ext == ".yaml")
91+
return inWhiteList && !inBlacklist && (ext == ".json" || ext == ".yaml")
6792
}
6893

69-
// filter iterates through the list of all files in the repo and filters it down to a list of those that should be applied.
70-
func filter(rawApplyList, blacklist []string) []string {
94+
// filter iterates through the list of all files in the repo and filters it
95+
// down to a list of those that should be applied.
96+
func filter(rawApplyList, blacklist, whitelist []string) []string {
7197
blacklistMap := stringSliceToMap(blacklist)
98+
whitelistMap := stringSliceToMap(whitelist)
7299

73100
applyList := []string{}
74101
for _, filePath := range rawApplyList {
75-
if shouldApplyPath(filePath, blacklistMap) {
102+
if shouldApplyPath(filePath, blacklistMap, whitelistMap) {
76103
applyList = append(applyList, filePath)
77104
}
78105
}

applylist/factory_test.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
type testCase struct {
1212
repoPath string
1313
blacklistPath string
14+
whitelistPath string
1415
fs sysutil.FileSystemInterface
1516
expectedApplyList []string
1617
expectedBlacklist []string
@@ -26,78 +27,113 @@ func TestFactoryCreate(t *testing.T) {
2627
gomock.InOrder(
2728
fs.EXPECT().ReadLines("/blacklist").Times(1).Return(nil, fmt.Errorf("error")),
2829
)
29-
tc := testCase{"/repo", "/blacklist", fs, nil, nil, fmt.Errorf("error")}
30+
tc := testCase{"/repo", "/blacklist", "/whitelist", fs, nil, nil, fmt.Errorf("error")}
3031
createAndAssert(t, tc)
3132

3233
// ListAllFiles error -> return nil lists and error, ReadLines is called
3334
gomock.InOrder(
3435
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
36+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
3537
fs.EXPECT().ListAllFiles("/repo").Times(1).Return(nil, fmt.Errorf("error")),
3638
)
37-
tc = testCase{"/repo", "/blacklist", fs, nil, nil, fmt.Errorf("error")}
39+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, nil, nil, fmt.Errorf("error")}
3840
createAndAssert(t, tc)
3941

4042
// All lists and paths empty -> both lists empty, ReadLines not called
4143
gomock.InOrder(
4244
fs.EXPECT().ListAllFiles("").Times(1).Return([]string{}, nil),
4345
)
44-
tc = testCase{"", "", fs, []string{}, []string{}, nil}
46+
tc = testCase{"", "", "", fs, []string{}, []string{}, nil}
4547
createAndAssert(t, tc)
4648

4749
// Single .json file, empty blacklist -> file in applyList
4850
gomock.InOrder(
4951
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
52+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
5053
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a.json"}, nil),
5154
)
52-
tc = testCase{"/repo", "/blacklist", fs, []string{"/repo/a.json"}, []string{}, nil}
55+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a.json"}, []string{}, nil}
5356
createAndAssert(t, tc)
5457

55-
// Single .yaml file, empty blacklist -> file in applyList
58+
// Single .yaml file, empty blacklist empty whitelist -> file in applyList
5659
gomock.InOrder(
5760
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
61+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
5862
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a.yaml"}, nil),
5963
)
60-
tc = testCase{"/repo", "/blacklist", fs, []string{"/repo/a.yaml"}, []string{}, nil}
64+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a.yaml"}, []string{}, nil}
6165
createAndAssert(t, tc)
6266

63-
// Single non-.json & non-.yaml file, empty blacklist -> file not in applyList
67+
// Single non-.json & non-.yaml file, empty blacklist empty whitelist
68+
// -> file not in applyList
6469
gomock.InOrder(
6570
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
71+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
6672
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a"}, nil),
6773
)
68-
tc = testCase{"/repo", "/blacklist", fs, []string{}, []string{}, nil}
74+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{}, []string{}, nil}
6975
createAndAssert(t, tc)
7076

71-
// Multiple files (mixed extensions), empty blacklist
77+
// Multiple files (mixed extensions), empty blacklist, emptry whitelist
7278
gomock.InOrder(
7379
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
80+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
7481
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a.json", "/repo/b.jpg", "/repo/a/b.yaml", "/repo/a/b"}, nil),
7582
)
76-
tc = testCase{"/repo", "/blacklist", fs, []string{"/repo/a.json", "/repo/a/b.yaml"}, []string{}, nil}
83+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a.json", "/repo/a/b.yaml"}, []string{}, nil}
7784
createAndAssert(t, tc)
7885

79-
// Multiple files (mixed extensions), blacklist
86+
// Multiple files (mixed extensions), blacklist, empty whitelist
8087
gomock.InOrder(
8188
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{"b.json", "b/c.json"}, nil),
89+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
8290
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a.json", "/repo/b.json", "/repo/a/b/c.yaml", "/repo/a/b", "/repo/b/c.json"}, nil),
8391
)
84-
tc = testCase{"/repo", "/blacklist", fs, []string{"/repo/a.json", "/repo/a/b/c.yaml"}, []string{"/repo/b.json", "/repo/b/c.json"}, nil}
92+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a.json", "/repo/a/b/c.yaml"}, []string{"/repo/b.json", "/repo/b/c.json"}, nil}
8593
createAndAssert(t, tc)
8694

8795
// File in blacklist but not in repo
8896
// (Ends up on returned blacklist anyway)
8997
gomock.InOrder(
9098
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{"a/b/c.yaml", "f.json"}, nil),
99+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{}, nil),
91100
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a/b.json", "/repo/b/c", "/repo/a/b/c.yaml", "/repo/a/b/c", "/repo/c.json"}, nil),
92101
)
93-
tc = testCase{"/repo", "/blacklist", fs, []string{"/repo/a/b.json", "/repo/c.json"}, []string{"/repo/a/b/c.yaml", "/repo/f.json"}, nil}
102+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a/b.json", "/repo/c.json"}, []string{"/repo/a/b/c.yaml", "/repo/f.json"}, nil}
103+
createAndAssert(t, tc)
104+
105+
// Empty blacklist, valid whitelist all whitelist is in the repo
106+
gomock.InOrder(
107+
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
108+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{"a/b/c.yaml", "c.json"}, nil),
109+
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a/b.json", "/repo/b/c", "/repo/a/b/c.yaml", "/repo/a/b/c", "/repo/c.json"}, nil),
110+
)
111+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a/b/c.yaml", "/repo/c.json"}, []string{}, nil}
112+
createAndAssert(t, tc)
113+
114+
// Empty blacklist, valid whitelist some whitelist is not included in repo
115+
gomock.InOrder(
116+
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{}, nil),
117+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{"a/b/c.yaml", "c.json", "someRandomFile.yaml"}, nil),
118+
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a/b.json", "/repo/b/c", "/repo/a/b/c.yaml", "/repo/a/b/c", "/repo/c.json"}, nil),
119+
)
120+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/a/b/c.yaml", "/repo/c.json"}, []string{}, nil}
121+
createAndAssert(t, tc)
122+
123+
// Both whitelist and blacklist contain the same file
124+
gomock.InOrder(
125+
fs.EXPECT().ReadLines("/blacklist").Times(1).Return([]string{"a/b/c.yaml"}, nil),
126+
fs.EXPECT().ReadLines("/whitelist").Times(1).Return([]string{"a/b/c.yaml", "c.json"}, nil),
127+
fs.EXPECT().ListAllFiles("/repo").Times(1).Return([]string{"/repo/a/b.json", "/repo/b/c", "/repo/a/b/c.yaml", "/repo/a/b/c", "/repo/c.json"}, nil),
128+
)
129+
tc = testCase{"/repo", "/blacklist", "/whitelist", fs, []string{"/repo/c.json"}, []string{"/repo/a/b/c.yaml"}, nil}
94130
createAndAssert(t, tc)
95131
}
96132

97133
func createAndAssert(t *testing.T, tc testCase) {
98134
assert := assert.New(t)
99-
f := &Factory{tc.repoPath, tc.blacklistPath, tc.fs}
100-
applyList, blacklist, err := f.Create()
135+
f := &Factory{tc.repoPath, tc.blacklistPath, tc.whitelistPath, tc.fs}
136+
applyList, blacklist, _, err := f.Create()
101137
assert.Equal(tc.expectedApplyList, applyList)
102138
assert.Equal(tc.expectedBlacklist, blacklist)
103139
assert.Equal(tc.expectedErr, err)

applylist/mock_factory.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ func (_m *MockFactoryInterface) EXPECT() *_MockFactoryInterfaceRecorder {
2828
return _m.recorder
2929
}
3030

31-
func (_m *MockFactoryInterface) Create() ([]string, []string, error) {
31+
func (_m *MockFactoryInterface) Create() ([]string, []string, []string, error) {
3232
ret := _m.ctrl.Call(_m, "Create")
3333
ret0, _ := ret[0].([]string)
3434
ret1, _ := ret[1].([]string)
35-
ret2, _ := ret[2].(error)
36-
return ret0, ret1, ret2
35+
ret2, _ := ret[2].([]string)
36+
ret3, _ := ret[3].(error)
37+
return ret0, ret1, ret2, ret3
3738
}
3839

3940
func (_mr *_MockFactoryInterfaceRecorder) Create() *gomock.Call {

main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ func main() {
3030
listenPort := sysutil.GetRequiredEnvInt("LISTEN_PORT")
3131
server := sysutil.GetEnvStringOrDefault("SERVER", "")
3232
blacklistPath := sysutil.GetEnvStringOrDefault("BLACKLIST_PATH", "")
33+
34+
// A file that contains a list of files to consider for application.
35+
// If the env var is not defined or if the file is empty act like a no-op and
36+
// all files will be considered.
37+
whitelistPath := sysutil.GetEnvStringOrDefault("WHITELIST_PATH", "")
3338
diffURLFormat := sysutil.GetEnvStringOrDefault("DIFF_URL_FORMAT", "")
3439
pollInterval := time.Duration(sysutil.GetEnvIntOrDefault("POLL_INTERVAL_SECONDS", defaultPollIntervalSeconds)) * time.Second
3540
fullRunInterval := time.Duration(sysutil.GetEnvIntOrDefault("FULL_RUN_INTERVAL_SECONDS", defaultFullRunIntervalSeconds)) * time.Second
@@ -53,7 +58,7 @@ func main() {
5358
batchApplier := &run.BatchApplier{kubeClient, metrics}
5459
gitUtil := &git.GitUtil{repoPath}
5560
fileSystem := &sysutil.FileSystem{}
56-
listFactory := &applylist.Factory{repoPath, blacklistPath, fileSystem}
61+
listFactory := &applylist.Factory{repoPath, blacklistPath, whitelistPath, fileSystem}
5762

5863
// Webserver and scheduler send run requests to runQueue channel, runner receives the requests and initiates runs.
5964
// Only 1 pending request may sit in the queue at a time.

0 commit comments

Comments
 (0)