-
Notifications
You must be signed in to change notification settings - Fork 7
/
main.go
282 lines (260 loc) · 10.6 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
// Copyright 2023 The Connect 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 main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"connectrpc.com/conformance/internal"
"connectrpc.com/conformance/internal/app/connectconformance"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
modeFlagName = "mode"
configFlagName = "conf"
testFileFlagName = "test-file"
knownFailingFlagName = "known-failing"
verboseFlagName = "verbose"
verboseFlagShortName = "v"
versionFlagName = "version"
maxServersFlagName = "max-servers"
parallelFlagName = "parallel"
parallelFlagShortName = "p"
tlsCertFlagName = "cert"
tlsKeyFlagName = "key"
portFlagName = "port"
bindFlagName = "bind"
)
type flags struct {
mode string
configFile string
knownFailingFile string
testFile string
verbose bool
version bool
maxServers uint
parallel uint
tlsCertFile string
tlsKeyFile string
port uint
bind string
}
func main() {
flagset := &flags{}
rootCmd := &cobra.Command{
Use: `connectconformance --mode [client|server] -- command...
connectconformance --mode both -- client-command... ---- server-command...`,
Short: "Runs conformance tests against the given command.",
Long: `Runs conformance tests against a Connect implementation. Depending on the mode,
the given command must be either a conformance client or a conformance server.
When mode is both, two commands are given, separated by a quadruple-slash ("----"),
with the client command being first and the server command second.
A conformance client tests a client implementation: the command reads test cases
from stdin. Each test case describes an RPC to make. The command then records
the result of each operation to stdout. The input is a sequence of binary-encoded
Protobuf messages of type connectrpc.conformance.v1.ClientCompatRequest,
each prefixed with a fixed-32-bit length. The output is expected to be similar:
a sequence of fixed-32-bit-length-prefixed messages, but the results are of
type connectrpc.conformance.v1.ClientCompatResponse. The command should exit
when it has read all test cases (i.e reached EOF of stdin) and then issued RPCs
and recorded all results to stdout. The command should also exit and abort any
in-progress RPCs if it receives a SIGTERM signal.
A conformance server tests a server implementation: the command reads the required
server properties from stdin. This comes in the form of a binary-encoded Protobuf
message of type connectrpc.conformance.v1.ServerCompatRequest, prefixed with a
fixed-32-bit length. The command should then start a server process and write its
properties to stdout in the same form as the input, but using a
connectrpc.conformance.v1.ServerCompatResponse message. The server process should
provide an implementation of the test service defined by
connectrpc.conformance.v1.ConformanceService. The command should exit
upon receiving a SIGTERM signal. The command maybe invoked repeatedly, to start
and test servers with different properties.
A configuration file may be provided which specifies what features the client
or server under test supports. This is used to filter the set of test cases
that will be executed. If no config file is indicated, default configuration
will be used.
A file with a list of known-failing test cases may also be provided. For these
cases, the test runner reverses its assertions: it considers the test case
failing to be successful; if the test case succeeds, it is considered a failure.
(The latter aspect makes sure that the file is up-to-date and does not include
test cases which have actually been fixed.)
`,
Run: func(cmd *cobra.Command, args []string) {
run(flagset, cmd.Flags(), args)
},
}
bind(rootCmd, flagset)
_ = rootCmd.Execute()
}
func bind(cmd *cobra.Command, flags *flags) {
cmd.Flags().StringVar(&flags.mode, modeFlagName, "", "required: the mode of the test to run; must be 'client', 'server', or 'both'")
cmd.Flags().StringVar(&flags.configFile, configFlagName, "", "a config file in YAML format with supported features")
cmd.Flags().StringVar(&flags.testFile, testFileFlagName, "", "a file in YAML format containing tests to run, which will skip running the embedded tests")
cmd.Flags().StringVar(&flags.knownFailingFile, knownFailingFlagName, "", "a file with a list of known-failing test cases")
cmd.Flags().BoolVarP(&flags.verbose, verboseFlagName, verboseFlagShortName, false, "enables verbose output")
cmd.Flags().BoolVar(&flags.version, versionFlagName, false, "print version and exit")
cmd.Flags().UintVar(&flags.maxServers, maxServersFlagName, 4,
"the maximum number of server processes to be running in parallel")
cmd.Flags().UintVarP(&flags.parallel, parallelFlagName, parallelFlagShortName, uint(runtime.GOMAXPROCS(0)*4),
"in server mode, the level of parallelism used when issuing RPCs")
cmd.Flags().StringVar(&flags.tlsCertFile, tlsCertFlagName, "",
"in client mode, the path to a PEM-encoded TLS certificate file that the reference server should use")
cmd.Flags().StringVar(&flags.tlsKeyFile, tlsKeyFlagName, "",
"in client mode, the path to a PEM-encoded TLS key file that the reference server should use")
cmd.Flags().UintVar(&flags.port, portFlagName, internal.DefaultPort,
"in client mode, the port number on which the reference server should listen (implies --max-servers=1)")
cmd.Flags().StringVar(&flags.bind, bindFlagName, internal.DefaultHost,
"in client mode, the bind address on which the reference server should listen (0.0.0.0 means listen on all interfaces)")
}
func run(flags *flags, cobraFlags *pflag.FlagSet, command []string) { //nolint:gocyclo
if flags.version {
fmt.Printf("%s %s\n", filepath.Base(os.Args[0]), internal.Version)
return
}
fatal := func(format string, args ...any) {
_, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
if len(command) == 0 {
fatal(`Positional arguments are required to configure the command line of the client or server under test.`)
}
if flags.maxServers == 0 {
fatal(`Invalid max servers: must be greater than zero`)
}
if flags.port != 0 {
if flags.maxServers > 1 && cobraFlags.Changed(maxServersFlagName) {
fatal(`Invalid max servers: cannot be greater than one when non-zero --port is specified`)
}
// Can only run a single server process at a time with a specific port.
flags.maxServers = 1
}
if flags.parallel == 0 {
fatal(`Invalid parallelism: must be greater than zero`)
}
var clientCommand, serverCommand []string
switch flags.mode {
case "client":
clientCommand = command
case "server":
serverCommand = command
case "both":
pos := positionOf(command, "----")
if pos < 0 {
fatal(`Command is missing "----" separator. In mode "both", positional args should include client command, "----", then server command.`)
}
clientCommand = command[:pos]
serverCommand = command[pos+1:]
if len(clientCommand) == 0 {
fatal(`Client command (before the "----") is empty.`)
}
if len(serverCommand) == 0 {
fatal(`Server command (after the "----") is empty.`)
}
default:
// TODO: support mode "both", which would allow the caller to supply both the
// client and server commands, instead of using a reference impl?
fatal(`Invalid mode: expecting "client", "server", or "both"; got %q`, flags.mode)
}
if flags.mode != "client" {
if cobraFlags.Changed(tlsCertFlagName) {
fatal(fmt.Sprintf("Cannot specify --%s flag when mode is %s", tlsCertFlagName, flags.mode))
}
if cobraFlags.Changed(tlsKeyFlagName) {
fatal(fmt.Sprintf("Cannot specify --%s flag when mode is %s", tlsKeyFlagName, flags.mode))
}
if cobraFlags.Changed(portFlagName) {
fatal(fmt.Sprintf("Cannot specify --%s flag when mode is %s", portFlagName, flags.mode))
}
if cobraFlags.Changed(bindFlagName) {
fatal(fmt.Sprintf("Cannot specify --%s flag when mode is %s", bindFlagName, flags.mode))
}
}
if flags.mode != "server" {
if cobraFlags.Changed(parallelFlagName) {
fatal(fmt.Sprintf("Cannot specify --%s/-%s flag when mode is %s", parallelFlagName, parallelFlagShortName, flags.mode))
}
}
switch {
case flags.tlsCertFile != "" && flags.tlsKeyFile == "":
fatal(fmt.Sprintf("Missing TLS key: --%s flag must be specified when --%s is used", tlsKeyFlagName, tlsCertFlagName))
case flags.tlsCertFile == "" && flags.tlsKeyFile != "":
fatal(fmt.Sprintf("Missing TLS certificate: --%s flag must be specified when --%s is used", tlsCertFlagName, tlsKeyFlagName))
case flags.tlsCertFile != "":
// Make sure the given TLS files are present and readable
file, err := os.Open(flags.tlsCertFile)
if err != nil {
fatal(errWithFilename(err, flags.tlsCertFile).Error())
}
_ = file.Close()
file, err = os.Open(flags.tlsKeyFile)
if err != nil {
fatal(errWithFilename(err, flags.tlsKeyFile).Error())
}
_ = file.Close()
}
for _, cmd := range [][]string{clientCommand, serverCommand} {
if len(cmd) == 0 {
continue
}
// Resolve command name, using PATH if need be.
resolvedCommand, err := exec.LookPath(cmd[0])
if err != nil {
fatal("%s", errWithFilename(err, cmd[0]))
}
cmd[0] = resolvedCommand
}
ok, err := connectconformance.Run(
&connectconformance.Flags{
ConfigFile: flags.configFile,
KnownFailingFile: flags.knownFailingFile,
TestFile: flags.testFile,
Verbose: flags.verbose,
ClientCommand: clientCommand,
ServerCommand: serverCommand,
MaxServers: flags.maxServers,
Parallelism: flags.parallel,
TLSCertFile: flags.tlsCertFile,
TLSKeyFile: flags.tlsKeyFile,
ServerPort: flags.port,
ServerBind: flags.bind,
},
internal.NewPrinter(os.Stdout),
internal.NewPrinter(os.Stderr),
)
if err != nil {
fatal("%s", err)
}
if !ok {
os.Exit(1)
}
}
func positionOf(slice []string, item string) int {
for i, str := range slice {
if str == item {
return i
}
}
return -1
}
func errWithFilename(err error, filename string) error {
if strings.Contains(err.Error(), filename) {
return err
}
// make sure error message includes file name
return fmt.Errorf("%s: %w", filename, err)
}