Skip to content

Commit 7629c6f

Browse files
authoredMay 22, 2023
resolve channel collisions by a preferred channel type, where they are otherwise identical (operator-framework#1095)
* minorly-kludgy approach, but functional Signed-off-by: Jordan Keister <[email protected]> * review comments Signed-off-by: Jordan Keister <[email protected]> * retool flag checks and defaulting Signed-off-by: Jordan Keister <[email protected]> * more review updates Signed-off-by: Jordan Keister <[email protected]> --------- Signed-off-by: Jordan Keister <[email protected]>
1 parent e1eebae commit 7629c6f

File tree

4 files changed

+293
-112
lines changed

4 files changed

+293
-112
lines changed
 

‎alpha/template/composite/builder_test.go

+8-7
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ func TestSemverBuilder(t *testing.T) {
435435
defer file.Close()
436436
fileData, err := io.ReadAll(file)
437437
require.NoError(t, err)
438-
require.Equal(t, string(fileData), semverBuiltFbcYaml)
438+
require.Equal(t, semverBuiltFbcYaml, string(fileData))
439439
},
440440
validateAssertions: func(t *testing.T, validateErr error) {
441441
require.NoError(t, validateErr)
@@ -466,7 +466,7 @@ func TestSemverBuilder(t *testing.T) {
466466
defer file.Close()
467467
fileData, err := io.ReadAll(file)
468468
require.NoError(t, err)
469-
require.Equal(t, string(fileData), semverBuiltFbcJson)
469+
require.Equal(t, semverBuiltFbcJson, string(fileData))
470470
},
471471
validateAssertions: func(t *testing.T, validateErr error) {
472472
require.NoError(t, validateErr)
@@ -565,8 +565,8 @@ func TestSemverBuilder(t *testing.T) {
565565
buildAssertions: func(t *testing.T, dir string, buildErr error) {
566566
require.Error(t, buildErr)
567567
require.Equal(t,
568-
buildErr.Error(),
569-
"semver template configuration is invalid: semver template config must have a non-empty output (templateDefinition.config.output)")
568+
"semver template configuration is invalid: semver template config must have a non-empty output (templateDefinition.config.output)",
569+
buildErr.Error())
570570
},
571571
},
572572
{
@@ -584,8 +584,9 @@ func TestSemverBuilder(t *testing.T) {
584584
buildAssertions: func(t *testing.T, dir string, buildErr error) {
585585
require.Error(t, buildErr)
586586
require.Equal(t,
587+
"semver template configuration is invalid: semver template config must have a non-empty input (templateDefinition.config.input),semver template config must have a non-empty output (templateDefinition.config.output)",
587588
buildErr.Error(),
588-
"semver template configuration is invalid: semver template config must have a non-empty input (templateDefinition.config.input),semver template config must have a non-empty output (templateDefinition.config.output)")
589+
)
589590
},
590591
},
591592
}
@@ -638,7 +639,7 @@ Stable:
638639
`
639640

640641
const semverBuiltFbcYaml = `---
641-
defaultChannel: stable-v0
642+
defaultChannel: stable-v0.0
642643
name: webhook-operator
643644
schema: olm.package
644645
---
@@ -694,7 +695,7 @@ schema: olm.bundle
694695
const semverBuiltFbcJson = `{
695696
"schema": "olm.package",
696697
"name": "webhook-operator",
697-
"defaultChannel": "stable-v0"
698+
"defaultChannel": "stable-v0.0"
698699
}
699700
{
700701
"schema": "olm.channel",

‎alpha/template/semver/semver.go

+60-7
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,42 @@ func readFile(reader io.Reader) (*semverTemplate, error) {
7575
return nil, err
7676
}
7777

78-
// default behavior is to generate only minor channels
79-
sv := semverTemplate{
80-
GenerateMajorChannels: false,
81-
GenerateMinorChannels: true,
82-
}
78+
sv := semverTemplate{}
8379
if err := yaml.UnmarshalStrict(data, &sv); err != nil {
8480
return nil, err
8581
}
82+
8683
if sv.Schema != schema {
8784
return nil, fmt.Errorf("readFile: input file has unknown schema, should be %q", schema)
8885
}
86+
87+
// if no generate option is selected, default to GenerateMinorChannels
88+
if !sv.GenerateMajorChannels && !sv.GenerateMinorChannels {
89+
sv.GenerateMinorChannels = true
90+
}
91+
92+
// for default channel preference,
93+
// if un-set, default to align to the selected generate option
94+
// if set, error out if we mismatch the two
95+
switch sv.DefaultChannelTypePreference {
96+
case defaultStreamType:
97+
if sv.GenerateMinorChannels {
98+
sv.DefaultChannelTypePreference = minorStreamType
99+
} else if sv.GenerateMajorChannels {
100+
sv.DefaultChannelTypePreference = majorStreamType
101+
}
102+
case minorStreamType:
103+
if !sv.GenerateMinorChannels {
104+
return nil, fmt.Errorf("schema attribute mismatch: DefaultChannelTypePreference set to 'minor' doesn't make sense if not generating minor-version channels")
105+
}
106+
case majorStreamType:
107+
if !sv.GenerateMajorChannels {
108+
return nil, fmt.Errorf("schema attribute mismatch: DefaultChannelTypePreference set to 'major' doesn't make sense if not generating major-version channels")
109+
}
110+
default:
111+
return nil, fmt.Errorf("unknown DefaultChannelTypePreference: %q\nValid values are 'major' or 'minor'", sv.DefaultChannelTypePreference)
112+
}
113+
89114
return &sv, nil
90115
}
91116

@@ -241,8 +266,8 @@ func (sv *semverTemplate) generateChannels(semverChannels *bundleVersions) []dec
241266

242267
unlinkedChannels[cName] = ch
243268

244-
hwcCandidate := highwaterChannel{archetype: archetype, version: bundles[bundleName], name: cName}
245-
if hwcCandidate.gt(&hwc) {
269+
hwcCandidate := highwaterChannel{archetype: archetype, kind: cKey, version: bundles[bundleName], name: cName}
270+
if hwcCandidate.gt(&hwc, sv.DefaultChannelTypePreference) {
246271
hwc = hwcCandidate
247272
}
248273
}
@@ -420,3 +445,31 @@ func stripBuildMetadata(v semver.Version) string {
420445
v.Build = nil
421446
return v.String()
422447
}
448+
449+
// prefer (in descending order of preference):
450+
// - higher-rank archetype,
451+
// - semver version,
452+
// - a channel type matching the set preference, or
453+
// - a 'better' (higher value) channel type
454+
func (h *highwaterChannel) gt(ih *highwaterChannel, pref streamType) bool {
455+
if channelPriorities[h.archetype] != channelPriorities[ih.archetype] {
456+
return channelPriorities[h.archetype] > channelPriorities[ih.archetype]
457+
}
458+
if h.version.NE(ih.version) {
459+
return h.version.GT(ih.version)
460+
}
461+
if h.kind != ih.kind {
462+
if h.kind == pref {
463+
return true
464+
}
465+
if ih.kind == pref {
466+
return false
467+
}
468+
return h.kind.gt((*ih).kind)
469+
}
470+
return false
471+
}
472+
473+
func (t streamType) gt(in streamType) bool {
474+
return streamTypePriorities[t] > streamTypePriorities[in]
475+
}

‎alpha/template/semver/semver_test.go

+214-87
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package semver
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67

@@ -144,97 +145,195 @@ func TestGenerateChannels(t *testing.T) {
144145
},
145146
}
146147

148+
majorLinkedChannels := []declcfg.Channel{
149+
{
150+
Schema: "olm.channel",
151+
Name: "stable-v0",
152+
Package: "a",
153+
Entries: []declcfg.ChannelEntry{
154+
{Name: "a-v0.1.0", Replaces: ""},
155+
{Name: "a-v0.1.1", Replaces: "", Skips: []string{"a-v0.1.0"}},
156+
},
157+
},
158+
{
159+
Schema: "olm.channel",
160+
Name: "stable-v1",
161+
Package: "a",
162+
Entries: []declcfg.ChannelEntry{
163+
{Name: "a-v1.1.0", Replaces: "", Skips: []string{}},
164+
{Name: "a-v1.2.1", Replaces: "a-v1.1.0", Skips: []string{}},
165+
{Name: "a-v1.3.1-alpha", Replaces: ""},
166+
{Name: "a-v1.3.1-beta", Replaces: ""},
167+
{Name: "a-v1.3.1", Replaces: "a-v1.2.1", Skips: []string{"a-v1.1.0", "a-v1.3.1-alpha", "a-v1.3.1-beta"}},
168+
{Name: "a-v1.4.1-beta1", Replaces: ""},
169+
{Name: "a-v1.4.1-beta2", Replaces: ""},
170+
{Name: "a-v1.4.1", Replaces: "a-v1.3.1", Skips: []string{"a-v1.1.0", "a-v1.2.1", "a-v1.3.1-alpha", "a-v1.3.1-beta", "a-v1.4.1-beta1", "a-v1.4.1-beta2"}},
171+
},
172+
},
173+
{
174+
Schema: "olm.channel",
175+
Name: "stable-v2",
176+
Package: "a",
177+
Entries: []declcfg.ChannelEntry{
178+
{Name: "a-v2.1.0", Replaces: ""},
179+
{Name: "a-v2.1.1", Replaces: "", Skips: []string{"a-v2.1.0"}},
180+
{Name: "a-v2.3.1", Replaces: ""},
181+
{Name: "a-v2.3.2", Replaces: "a-v2.1.1", Skips: []string{"a-v2.1.0", "a-v2.3.1"}},
182+
},
183+
},
184+
{
185+
Schema: "olm.channel",
186+
Name: "stable-v3",
187+
Package: "a",
188+
Entries: []declcfg.ChannelEntry{
189+
{Name: "a-v3.1.0", Replaces: ""},
190+
{Name: "a-v3.1.1", Replaces: "", Skips: []string{"a-v3.1.0"}},
191+
},
192+
},
193+
}
194+
195+
minorLinkedChannels := []declcfg.Channel{
196+
{
197+
Schema: "olm.channel",
198+
Name: "stable-v0.1",
199+
Package: "a",
200+
Entries: []declcfg.ChannelEntry{
201+
{Name: "a-v0.1.0", Replaces: ""},
202+
{Name: "a-v0.1.1", Replaces: "", Skips: []string{"a-v0.1.0"}},
203+
},
204+
},
205+
{
206+
Schema: "olm.channel",
207+
Name: "stable-v1.1",
208+
Package: "a",
209+
Entries: []declcfg.ChannelEntry{
210+
{Name: "a-v1.1.0", Replaces: "", Skips: []string{}},
211+
},
212+
},
213+
{
214+
Schema: "olm.channel",
215+
Name: "stable-v1.2",
216+
Package: "a",
217+
Entries: []declcfg.ChannelEntry{
218+
{Name: "a-v1.2.1", Replaces: "a-v1.1.0", Skips: []string{}},
219+
},
220+
},
221+
{
222+
Schema: "olm.channel",
223+
Name: "stable-v1.3",
224+
Package: "a",
225+
Entries: []declcfg.ChannelEntry{
226+
{Name: "a-v1.3.1-alpha", Replaces: ""},
227+
{Name: "a-v1.3.1-beta", Replaces: ""},
228+
{Name: "a-v1.3.1", Replaces: "a-v1.2.1", Skips: []string{"a-v1.1.0", "a-v1.3.1-alpha", "a-v1.3.1-beta"}},
229+
},
230+
},
231+
{
232+
Schema: "olm.channel",
233+
Name: "stable-v1.4",
234+
Package: "a",
235+
Entries: []declcfg.ChannelEntry{
236+
{Name: "a-v1.4.1-beta1", Replaces: ""},
237+
{Name: "a-v1.4.1-beta2", Replaces: ""},
238+
{Name: "a-v1.4.1", Replaces: "a-v1.3.1", Skips: []string{"a-v1.1.0", "a-v1.2.1", "a-v1.3.1-alpha", "a-v1.3.1-beta", "a-v1.4.1-beta1", "a-v1.4.1-beta2"}},
239+
},
240+
},
241+
{
242+
Schema: "olm.channel",
243+
Name: "stable-v2.1",
244+
Package: "a",
245+
Entries: []declcfg.ChannelEntry{
246+
{Name: "a-v2.1.0", Replaces: ""},
247+
{Name: "a-v2.1.1", Replaces: "", Skips: []string{"a-v2.1.0"}},
248+
},
249+
},
250+
{
251+
Schema: "olm.channel",
252+
Name: "stable-v2.3",
253+
Package: "a",
254+
Entries: []declcfg.ChannelEntry{
255+
{Name: "a-v2.3.1", Replaces: ""},
256+
{Name: "a-v2.3.2", Replaces: "a-v2.1.1", Skips: []string{"a-v2.1.0", "a-v2.3.1"}},
257+
},
258+
},
259+
{
260+
Schema: "olm.channel",
261+
Name: "stable-v3.1",
262+
Package: "a",
263+
Entries: []declcfg.ChannelEntry{
264+
{Name: "a-v3.1.0", Replaces: ""},
265+
{Name: "a-v3.1.1", Replaces: "", Skips: []string{"a-v3.1.0"}},
266+
},
267+
},
268+
}
269+
270+
var combinedLinkedChannels []declcfg.Channel
271+
combinedLinkedChannels = append(combinedLinkedChannels, minorLinkedChannels...)
272+
combinedLinkedChannels = append(combinedLinkedChannels, majorLinkedChannels...)
273+
147274
tests := []struct {
148275
name string
149276
generateMinorChannels bool
150277
generateMajorChannels bool
278+
defaultChannel string
279+
channelTypePreference streamType
151280
out []declcfg.Channel
152281
}{
153282
{
154283
name: "Edges between minor channels",
155284
generateMinorChannels: true,
156285
generateMajorChannels: false,
157-
out: []declcfg.Channel{
158-
{
159-
Schema: "olm.channel",
160-
Name: "stable-v0.1",
161-
Package: "a",
162-
Entries: []declcfg.ChannelEntry{
163-
{Name: "a-v0.1.0", Replaces: ""},
164-
{Name: "a-v0.1.1", Replaces: "", Skips: []string{"a-v0.1.0"}},
165-
},
166-
},
167-
{
168-
Schema: "olm.channel",
169-
Name: "stable-v1.1",
170-
Package: "a",
171-
Entries: []declcfg.ChannelEntry{
172-
{Name: "a-v1.1.0", Replaces: "", Skips: []string{}},
173-
},
174-
},
175-
{
176-
Schema: "olm.channel",
177-
Name: "stable-v1.2",
178-
Package: "a",
179-
Entries: []declcfg.ChannelEntry{
180-
{Name: "a-v1.2.1", Replaces: "a-v1.1.0", Skips: []string{}},
181-
},
182-
},
183-
{
184-
Schema: "olm.channel",
185-
Name: "stable-v1.3",
186-
Package: "a",
187-
Entries: []declcfg.ChannelEntry{
188-
{Name: "a-v1.3.1-alpha", Replaces: ""},
189-
{Name: "a-v1.3.1-beta", Replaces: ""},
190-
{Name: "a-v1.3.1", Replaces: "a-v1.2.1", Skips: []string{"a-v1.1.0", "a-v1.3.1-alpha", "a-v1.3.1-beta"}},
191-
},
192-
},
193-
{
194-
Schema: "olm.channel",
195-
Name: "stable-v1.4",
196-
Package: "a",
197-
Entries: []declcfg.ChannelEntry{
198-
{Name: "a-v1.4.1-beta1", Replaces: ""},
199-
{Name: "a-v1.4.1-beta2", Replaces: ""},
200-
{Name: "a-v1.4.1", Replaces: "a-v1.3.1", Skips: []string{"a-v1.1.0", "a-v1.2.1", "a-v1.3.1-alpha", "a-v1.3.1-beta", "a-v1.4.1-beta1", "a-v1.4.1-beta2"}},
201-
},
202-
},
203-
{
204-
Schema: "olm.channel",
205-
Name: "stable-v2.1",
206-
Package: "a",
207-
Entries: []declcfg.ChannelEntry{
208-
{Name: "a-v2.1.0", Replaces: ""},
209-
{Name: "a-v2.1.1", Replaces: "", Skips: []string{"a-v2.1.0"}},
210-
},
211-
},
212-
{
213-
Schema: "olm.channel",
214-
Name: "stable-v2.3",
215-
Package: "a",
216-
Entries: []declcfg.ChannelEntry{
217-
{Name: "a-v2.3.1", Replaces: ""},
218-
{Name: "a-v2.3.2", Replaces: "a-v2.1.1", Skips: []string{"a-v2.1.0", "a-v2.3.1"}},
219-
},
220-
},
221-
{
222-
Schema: "olm.channel",
223-
Name: "stable-v3.1",
224-
Package: "a",
225-
Entries: []declcfg.ChannelEntry{
226-
{Name: "a-v3.1.0", Replaces: ""},
227-
{Name: "a-v3.1.1", Replaces: "", Skips: []string{"a-v3.1.0"}},
228-
},
229-
},
230-
},
286+
defaultChannel: "stable-v3.1",
287+
channelTypePreference: minorStreamType,
288+
out: minorLinkedChannels,
289+
},
290+
{
291+
name: "No edges between major channels",
292+
generateMinorChannels: false,
293+
generateMajorChannels: true,
294+
defaultChannel: "stable-v3",
295+
channelTypePreference: majorStreamType,
296+
out: majorLinkedChannels,
297+
},
298+
{
299+
name: "Preference for minor default channel",
300+
generateMinorChannels: true,
301+
generateMajorChannels: true,
302+
defaultChannel: "stable-v3.1",
303+
channelTypePreference: minorStreamType,
304+
out: combinedLinkedChannels,
305+
},
306+
{
307+
name: "Preference for major default channel",
308+
generateMinorChannels: true,
309+
generateMajorChannels: true,
310+
defaultChannel: "stable-v3",
311+
channelTypePreference: majorStreamType,
312+
out: combinedLinkedChannels,
313+
},
314+
{
315+
name: "Mismatch generate/preference minor/major default channel",
316+
generateMinorChannels: true,
317+
generateMajorChannels: false,
318+
defaultChannel: "stable-v3.1",
319+
channelTypePreference: majorStreamType,
320+
out: minorLinkedChannels,
321+
},
322+
{
323+
name: "Mismatch generate/preference major/minor default channel",
324+
generateMinorChannels: false,
325+
generateMajorChannels: true,
326+
defaultChannel: "stable-v3",
327+
channelTypePreference: minorStreamType,
328+
out: majorLinkedChannels,
231329
},
232330
}
233331

234332
for _, tt := range tests {
235333
t.Run(tt.name, func(t *testing.T) {
236-
sv := &semverTemplate{GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels, pkg: "a"}
334+
sv := &semverTemplate{GenerateMajorChannels: tt.generateMajorChannels, GenerateMinorChannels: tt.generateMinorChannels, pkg: "a", DefaultChannelTypePreference: tt.channelTypePreference}
237335
require.ElementsMatch(t, tt.out, sv.generateChannels(&channelOperatorVersions))
336+
require.Equal(t, tt.defaultChannel, sv.defaultChannel)
238337
})
239338
}
240339
}
@@ -364,18 +463,12 @@ func TestBailOnVersionBuildMetadata(t *testing.T) {
364463
}
365464

366465
func TestReadFile(t *testing.T) {
367-
type testCase struct {
368-
name string
369-
input string
370-
assertions func(*testing.T, *semverTemplate, error)
371-
}
372-
testCases := []testCase{
373-
{
374-
name: "valid",
375-
input: `---
466+
467+
templateFstr := `---
376468
schema: olm.semver
377-
generateMajorChannels: true
378-
generateMinorChannels: true
469+
generateMajorChannels: %s
470+
generateMinorChannels: %s
471+
defaultChannelTypePreference: %s
379472
candidate:
380473
bundles:
381474
- image: quay.io/foo/olm:testoperator.v0.1.0
@@ -399,7 +492,17 @@ fast:
399492
stable:
400493
bundles:
401494
- image: quay.io/foo/olm:testoperator.v1.0.1
402-
`,
495+
`
496+
497+
type testCase struct {
498+
name string
499+
input string
500+
assertions func(*testing.T, *semverTemplate, error)
501+
}
502+
testCases := []testCase{
503+
{
504+
name: "valid",
505+
input: fmt.Sprintf(templateFstr, "true", "true", "minor"),
403506
assertions: func(t *testing.T, template *semverTemplate, err error) {
404507
require.NotNil(t, template)
405508
require.NoError(t, err)
@@ -443,6 +546,30 @@ invalid:
443546
require.EqualError(t, err, `error unmarshaling JSON: while decoding JSON: json: unknown field "invalid"`)
444547
},
445548
},
549+
{
550+
name: "generate/default mismatch, minor/major",
551+
input: fmt.Sprintf(templateFstr, "true", "false", "minor"),
552+
assertions: func(t *testing.T, template *semverTemplate, err error) {
553+
require.Nil(t, template)
554+
require.ErrorContains(t, err, "schema attribute mismatch")
555+
},
556+
},
557+
{
558+
name: "generate/default mismatch, major/minor",
559+
input: fmt.Sprintf(templateFstr, "false", "true", "major"),
560+
assertions: func(t *testing.T, template *semverTemplate, err error) {
561+
require.Nil(t, template)
562+
require.ErrorContains(t, err, "schema attribute mismatch")
563+
},
564+
},
565+
{
566+
name: "unknown defaultchanneltypepreference",
567+
input: fmt.Sprintf(templateFstr, "false", "true", "foo"),
568+
assertions: func(t *testing.T, template *semverTemplate, err error) {
569+
require.Nil(t, template)
570+
require.ErrorContains(t, err, "unknown DefaultChannelTypePreference")
571+
},
572+
},
446573
}
447574

448575
for _, tc := range testCases {

‎alpha/template/semver/types.go

+11-11
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ type semverTemplateChannelBundles struct {
2424
}
2525

2626
type semverTemplate struct {
27-
Schema string `json:"schema"`
28-
GenerateMajorChannels bool `json:"generateMajorChannels,omitempty"`
29-
GenerateMinorChannels bool `json:"generateMinorChannels,omitempty"`
30-
Candidate semverTemplateChannelBundles `json:"candidate,omitempty"`
31-
Fast semverTemplateChannelBundles `json:"fast,omitempty"`
32-
Stable semverTemplateChannelBundles `json:"stable,omitempty"`
27+
Schema string `json:"schema"`
28+
GenerateMajorChannels bool `json:"generateMajorChannels,omitempty"`
29+
GenerateMinorChannels bool `json:"generateMinorChannels,omitempty"`
30+
DefaultChannelTypePreference streamType `json:"defaultChannelTypePreference,omitempty"`
31+
Candidate semverTemplateChannelBundles `json:"candidate,omitempty"`
32+
Fast semverTemplateChannelBundles `json:"fast,omitempty"`
33+
Stable semverTemplateChannelBundles `json:"stable,omitempty"`
3334

3435
pkg string `json:"-"` // the derived package name
3536
defaultChannel string `json:"-"` // detected "most stable" channel head
@@ -62,10 +63,12 @@ func (b byChannelPriority) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
6263

6364
type streamType string
6465

66+
const defaultStreamType streamType = ""
6567
const minorStreamType streamType = "minor"
6668
const majorStreamType streamType = "major"
6769

68-
var streamTypePriorities = map[streamType]int{minorStreamType: 0, majorStreamType: 1}
70+
// general preference for minor channels
71+
var streamTypePriorities = map[streamType]int{minorStreamType: 2, majorStreamType: 1, defaultStreamType: 0}
6972

7073
// map of archetypes --> bundles --> bundle-version from the input file
7174
type bundleVersions map[channelArchetype]map[string]semver.Version // e.g. srcv["stable"]["example-operator.v1.0.0"] = 1.0.0
@@ -74,14 +77,11 @@ type bundleVersions map[channelArchetype]map[string]semver.Version // e.g. srcv[
7477
// later as the package's defaultChannel attribute
7578
type highwaterChannel struct {
7679
archetype channelArchetype
80+
kind streamType
7781
version semver.Version
7882
name string
7983
}
8084

81-
func (h *highwaterChannel) gt(ih *highwaterChannel) bool {
82-
return (channelPriorities[h.archetype] > channelPriorities[ih.archetype]) || (h.version.GT(ih.version))
83-
}
84-
8585
type entryTuple struct {
8686
arch channelArchetype
8787
kind streamType

0 commit comments

Comments
 (0)
Please sign in to comment.